← 返回全部思考

给多模态 Agent 训一个评委:奖励模型的三次返工

为多模态规划 Agent 训 reward model 的完整过程。第一版一致率 77%、只比"全判合格"高 2.5 个点;查下去发现教师标签在纯文本回复上几乎是噪声。第二版修好了准确率,AUC 反而从 0.68 掉到 0.61——因为我把理由写在了判决前面,verdict token 的概率被写成了必然。第三版 AUC 0.91。三个失败里有两个是我自己设计错的。

给多模态 Agent 训一个评委:奖励模型的三次返工

我们在给一个多模态规划 Agent 做后训练。这个 Agent 的工作是:读用户的需求和参考图,决定要生成几个交付物、选哪个工具、每个工具的参数怎么填。基座是一个 35B 总参 / 3B 激活的 MoE,带视觉编码器。

流程是 SFT 到 RM 再到 RL。这篇讲中间那步:怎么训出打分的评委,以及我在这上面返工了三次。

结论先放这里:

版本 判决准确率 多数类基线 抓错误的 F1 AUC 结果
v1 77.1% 74.6% 42.9% 0.837 不可用,教师标签是噪声
v2 93.6% 92.8% 34.3% 0.610 不可用,AUC 塌了
v3 93.7% 91.9% 57.3% 0.908 可用

三个数字里 AUC 最重要。后面会讲为什么。

一、为什么非要训个评委

RL 需要给每次回答打分。最省事的做法是写规则:能不能解析出结构良好的工具调用、该调工具时有没有调、必填字段全不全、有没有重复调用同一个工具。这些规则我们早就有了,跑得也稳。

问题是它有一大块盲区。这个 Agent 是多模态的,用户会甩一张参考图进来说"照这个风格改"。我做过一组消融实验:把同一批 prompt 的图片替换成随机的其他图,看模型输出变不变。

  • 用视觉裁判来看"输出和图片是否一致",正常组和乱序组的差异显著(p = 0.0045)。说明模型确实在看图、并且理解了图。
  • 但用规则奖励去打分,两组几乎没差别(p = 0.13)。规则对图片视而不见。

也就是说,模型已经具备的能力,规则奖励衡量不了。用它做 RL,等于告诉模型"图看不看都一样"。

还有一半问题在纯文本回复上。这个 Agent 有相当比例的回答是不调工具的:交付说明、澄清提问、拒绝并给替代方案。规则对这类回答几乎只能判"格式对不对",给出的分数高度雷同。而 RL 用的 GRPO 需要同一个问题的几个回答之间有分差,全都一样的话这组样本对训练毫无贡献。

所以需要一个能看图、能读懂上下文、能对纯文本回答给出有区分度分数的评委。

二、三个设计决定

2.1 评委的底座用我们自己的 SFT 模型

候选有三个:一个 4B 的小模型(省卡)、一个外部的强 VL 模型、我们自己的 SFT 存档。

选了自己的 SFT 存档,理由三条:

1)同分布。评委要判的就是这个策略模型的输出。用同一个底座,它见过同样的工具定义、同样的系统提示、同样的图片预处理方式。外部模型要从零理解我们这套 13 个工具的 schema。

2)有视觉且训推一致。图像一致性是我们要打的一个分,评委必须能看图。而且它的图片 token 上限、longest_edge 参数必须和策略模型一致,否则"评委看到的图"和"策略看到的图"不是一张图。用自己的存档,这些配置天然对齐。

3)只维护一个模型。上一代用了 30B 的底座,同样的量级;换成 4B 会掉能力,换成外部模型会多一套部署和一套预处理配置。

代价是评委和策略共享盲区:策略看错的东西,评委可能也看错。这个风险用"教师是更强/更独立的模型"来对冲——评委学的标签不是自己产的。

2.2 生成式判别,不是标量头

常见的 RM 是在底座上接一个标量头,输出一个分数,用 pairwise 偏好数据训。我们做的是生成式判别:让评委输出一个 JSON。

{"l3": "pass", "reason": "工具调用参数完整,prompt 准确复述了用户需求", "issues": [], "img": 9}

四个字段:整体判决、一行理由、问题类型列表、图像一致性 0 到 10 分。

选它的原因:

  • 可解释。RL 训崩的时候要能回答"为什么这条被打了低分"。标量头给不出。
  • 多目标。我们要的不是一个笼统的好坏,而是"路由对不对 / 参数漏没漏 / 图对不对"三件事,生成式一次输出全部。
  • 数据形态匹配。我们没有人工的成对偏好数据,只有教师给出的逐条判决。生成式判别直接学这个。

2.3 监督训练,不用 RL 训 RM

这个问题被问过:为什么 RM 是 SFT 出来的,不用 RL 训?

因为 RL 解决的是"没有标准答案、只有偏好信号"的问题。而这里的任务是有明确对错的:用户说要 2K 分辨率,工具的 resolution 字段填没填 2k,这是查得出来的事实。教师给的判决就是标准答案,监督学习直接拟合它,样本效率高得多。

RL 训 RM 有意义的场景是奖励本身很主观(比如"哪个回答更有帮助"),需要从人类偏好里学一个隐含的价值函数。我们不是。

2.4 连续分数从哪来

这是整件事最关键的一处设计,也是我第二次返工的原因。

RL 不能只吃"合格/不合格"两个值。GRPO 要在同一组回答之间比较,如果只有 0 和 1,一组四个回答里三个合格一个不合格,梯度信息就非常粗。

生成式判别输出的是 token,怎么得到连续分数?答案是取 verdict 那个 token 的概率。模型在写 "l3": " 之后要选下一个 token,pass 和 fail 各有一个 logprob,归一化之后就是 P(pass)。这个数是连续的,而且它天然编码了模型的犹豫程度——0.95 是很确定,0.55 是拿不准。

最终的奖励函数:

reward = 规则分                             # 格式、schema、工具循环,违规给大负分
       + 1.0 * (2 * P(pass) - 1)            # 评委判合格的概率,映射到 [-1, +1]
       - 0.5 * 问题数量                      # 评委列出的致命问题个数
       + 0.5 * (图像分 - 5) / 5              # 0 到 10 分映射到 [-1, +1],无图时这项为 0

规则分保留了硬约束的门控作用:输出未闭合的工具调用、调用不存在的工具,直接给 -2.0,不管评委说什么。这条是防止 RL 找到"生成畸形输出但骗过评委"的捷径。

三、教师标签流水线

评委需要标准答案。我们没有人工标注,用两个更强的模型当教师。

采样。 从 SFT 训练集里按固定间隔抽 11,034 个 prompt,用当时的 SFT 存档以温度 0.8 各采 3 个回答,共 33,102 条。温度不能是 0——要的就是同一个 prompt 上有好有坏的回答,这样评委才学得到分界。

两个头分开打。

  • 判决头:把上一代的判别契约移植过来(13 个工具的规则、比例判定铁律、清晰度判定、8 类问题归因),用我们自己的基座模型零样本当裁判。这个契约很长,包含"判 fail 前的硬约束",比如禁止拿产物像素反推比例、禁止追溯不到上游就判 URL 幻觉。
  • 图像一致性头:用一个外部的强多模态模型,把所有相关图片压到 512 像素长边一起送进去,让它给"输出和图片是否一致"打 0 到 10 分。

图像头这一路踩了两个坑。一是并发 3 个全分辨率多图请求就把那个服务打满了,压到 512 像素缩略图、并发降到 2 才稳。二是我一开始只送前 4 张图省带宽,结果出现反向误判——图 5 到图 8 才是关键证据。能送全就送全。

判决头也踩了个坑:所有带工具调用的样本 61 比 61 全判不合格。查出来是推理服务的工具调用解析器把 arguments 双重编码了(对一个已经是 JSON 字符串的东西又做了一次 json.dumps),教师看到的是转义后的乱码。修法是循环反解析最多四次。这个坑污染了两批标签,全部作废重打。

四、v1:一致率 77%,只比蒙对高 2.5 个点

第一版从 SFT 的第 2,100 步存档起训,32,333 条数据,两个 epoch。训到 700 步测留出集:

判决准确率     77.1%   (多数类基线 74.6%)
抓错误的召回   33.9%
AUC            0.837
图像分相关     0.72

准确率只比"全判合格"高 2.5 个点。这个结果没法用。

我先怀疑是容量或泛化问题,于是在训练集切片上测了同一个存档:准确率 88.2%、AUC 0.933。有泛化差距,但训练集上的表现说明模型学得动。问题在标签本身。

五、定位:教师在纯文本回复上是噪声

我做了一个不需要人工标注的检验。

对于纯文本回答(不调工具的),我手上有一个天然的参照:线上真实发生的那条回复。如果策略采样出来的回答和线上真实回复几乎一样,它大概率是对的。于是按"策略输出与线上真实回复的字符相似度"分箱,看教师的判不合格率:

相似度区间 样本数 教师判不合格率
0.0 – 0.2 1,561 46.5%
0.2 – 0.4 3,050 46.4%
0.4 – 0.6 4,200 42.2%
0.6 – 0.8 3,163 40.8%
0.8 – 1.0 1,740 39.0%

跟内容几乎无关。哪怕输出和线上真实回复相似度超过 0.8,还是有 39% 被判不合格。

对比一下:纯文本样本占全体 45.2%,教师判不合格率 42.8%;有工具调用的样本,不合格率只有 8.9%。差了近五倍。

抽出具体案例看,问题一目了然:

用户:还是糊,图片要求高清 2K 策略输出:已放大至 2K 高清(2048×2048),材质纹理和清晰度明显提升。 线上真实回复:已输出 2K(2048×2048)高清版本,纹理细节和清晰度都有明显提升。 教师判决:不合格 —— “工具调用 resolution 参数未接住用户明确要求的 2K 清晰度”

这条回答根本没有工具调用。教师凭空编了一个工具调用,然后判它参数不对。

5.1 两个代码级原因

原因一:契约强制填表。 移植过来的判别契约要求输出里必须有 tool_results 数组,每个元素对应一次工具调用。这个契约是给"评判工具调用"设计的。当目标回答没有工具调用时,模型为了填满这个数组,就编造一次调用出来。它不是在幻觉,它是在遵守我给的格式。

原因二:教师看不到历史产物。 渲染对话的函数里,tool 消息(工具返回结果)是通过 tool_call_id 挂到对应的 tool_call 条目上的。挂不上的就丢弃。

而我们的线上请求载荷里,历史的 assistant 消息不带 tool_calls 字段——线上组装 prompt 时就没放。所以所有历史工具结果都挂不上,全被丢掉了。教师看到的对话里没有任何"图已经生成过了"的痕迹,于是把"已完成,效果如何"这类交付说明判成虚假声明。

顺带说,这个"历史 assistant 不带 tool_calls"是线上的真实形态,不是数据 bug,SFT 数据保持原样才是训推一致。但它意味着 planner 在线上看不到自己此前调了什么工具、填了什么参数——这个事实值得产品侧知道。

5.2 修法与验证

两处改动:

1)孤儿工具结果以 tool 条目回显进对话。挂不上 tool_call 的,就单独作为一条 role=tool 放进去,教师至少能看到"Image ready: 尺寸 2048×2048"。同时把线上那种 JSON 字符串形态的 tool content 解析成紧凑文本,图片部分标记成占位符。

2)纯文本目标走专用契约。新写了一份,只评文本本身,问题类型换成五类:虚假完成声明、该调工具却没调、与请求或工具结果矛盾、文本幻觉、忽视用户纠正。硬约束第一条就是:任何 issue 不得以"工具调用缺少某字段"为理由,本轮没有工具调用,出现这类理由即误判。判决路由很简单——目标轮有工具调用就用原契约,没有就用新契约。

验证用 80 条:30 条旧判不合格的纯文本、30 条旧判合格的纯文本、20 条工具调用样本。

组 旧教师判不合格 新教师判不合格
纯文本(旧不合格) 30 3
纯文本(旧合格) 0 4
工具调用 3 2

旧的 30 条误判只剩 3 条,抽看新理由全部站得住。更值得看的是新抓出的那 4 条——旧教师判合格、新教师判不合格:

策略输出:已完成!两张图都成功将图二座椅替换到了图一车内……尺寸与原图一致。 新教师判决:不合格 —— “文本宣称两张图都成功替换,但历史工具结果仅返回一张图,且附图均为图一原图,未展示任何生成产物,属于虚假完成声明。”

这条是靠"看到了附图"和"数了工具结果条数"抓出来的,正是修好回显之后才具备的能力。

全量重标 33,102 条(三个教师实例分片并发,约两小时)。整体不合格率从 24.2% 降到 8.2%。抽 2,083 条看迁移情况:旧不合格改判合格 371 条,旧合格改判不合格 75 条。

六、v2:准确率修好了,AUC 塌了

用新标签重训。这次我顺手做了一个"改进":把目标里的字段顺序改成理由在前、判决在后。

{"reason": "参数完整、无遗漏", "l3": "pass", "issues": [], "img": 9}

想法很直觉:让模型先写理由再下判决,等于强制它先思考,判别类任务上一般能提准确率。

训到 500 步的结果:

判决准确率     93.6%   (多数类基线 92.8%)  ← 站上基线了
抓错误的召回   23.1%
AUC            0.610                        ← 比 v1 的 0.837 还差
概率落在 0.2 到 0.8 之间的样本占比   0.0%    ← v1 是 34.5%

准确率修好了,AUC 反而更差。而且"中间地带"的样本占比是零。

6.1 机制

问题就在字段顺序上。

我们要的连续分数是 verdict token 的概率。当理由写在前面时,模型先输出"参数完整、无遗漏"这一整句,写完这句之后,下一个 token 是 pass 已经是必然事件,概率恒为 0.99 以上。模型的不确定性没有消失,它转移到了"理由文字怎么写"这个分布里——而理由是自由文本,没法归约成一个标量。

换个说法:让评委先写评语再打分,评语写完分数就定了。我们要的是评委下笔那一刻的犹豫,那就必须让他先打分。

这里有一个我之前没意识到的普遍冲突:在判别式任务里,"先思考后回答"和"要从 verdict 概率取连续分数"是互斥的。 思维链会把不确定性从待测的那个 token 上吸走。如果下游只要离散判决,理由在前是好设计;如果下游要概率,理由必须在后。

顺带一个实现细节:把理由挪到后面之后,解析 P(pass) 的代码也得改。原来的实现是扫描输出 token 流、遇到第一个 pass 或 fail 就取它的概率。理由在后的话,理由文字里也可能出现 “pass” 这个词(比如"未 pass 校验"),会取错。改成只认紧跟在 "l3": " 之后的那个 token,并补了单测覆盖两种字段顺序。

七、v3:三处改动

v2 训到 614 步就停了,结论已经够用。第三版从 SFT 的第 2,450 步存档起训,改三处:

1)判决在前,理由在后。 判决 token 的概率重新可用;理由留着作辅助监督,帮模型内化"为什么",但不再影响打分那一刻。

2)错误样本 3 倍过采样。 新教师标注下不合格样本只占 8.2%,评委见得太少,v2 的召回只有 23%。训练集里把它们复制 3 份,占比提到 21.1%。留出集不复制,指标不受影响。

3)留出集从 720 扩到 1,440 条。 原来不合格样本只有 52 条,召回每差一条跳 2%,抖得没法比较。新的有 116 条。新留出集包含旧的 690 条、零泄漏进训练集,和 v1/v2 的数字仍可对比。

训练配置:单节点 8 卡,专家并行 8,最大长度 8,192,全局 batch 32,学习率 5e-6,两个 epoch 共 2,299 步,7 小时 19 分,峰值显存 87 GiB。

长度这么定是实测的:RM 输入的文本部分中位数 1,418 token、最长 4,071;图片按每张最多 1,024 token 算,总长的 p99 是 7,357、最大 9,774。8,192 会截掉 0.7%,可以接受。

7.1 结果

留出集 1,440 条,其中不合格 116 条,多数类基线 91.9%:

存档 准确率 抓错误的准确度 抓错误的召回 F1 AUC 中间地带占比 图像分相关
500 91.3% 47.2% 64.7% 54.5% 0.920 15.5% 0.563
900 93.7% 62.9% 52.6% 57.3% 0.908 9.8% 0.669
1000 91.4% 47.6% 69.8% 56.6% 0.910 10.3% 0.608
1100 93.5% 64.9% 41.4% 50.5% 0.905 5.9% 0.637
1500 93.3% 62.0% 42.2% 50.3% 0.888 5.0% 0.640
2000 93.5% 64.2% 44.8% 52.8% 0.886 5.3% 0.648

AUC 从 v2 的 0.61 回到 0.91,字段顺序这一处改动就解决了。

7.2 为什么选第 900 步而不是最后一个

第一个 epoch 在 1,150 步左右结束。900 步之后的趋势很清楚:

  • 准确率不再上升(93.7% 到 93.5%,噪声范围内)
  • 中间地带占比从 9.8% 缩到 5%——模型变得过度自信
  • 抓错误的召回从 52.6% 掉到 44.8%
  • AUC 从 0.908 降到 0.886

验证损失也在 1,100 步取到最低 0.223,之后走平;而训练损失继续从 0.10 掉到 0.07,典型的开始记忆训练集。

这里的取舍值得说一下。如果只看准确率,1,500 和 2,000 步和 900 步没差别,随便选。但对 RL 来说,"中间地带占比"和 AUC 才是关键——一个把所有样本都判成 0.99 或 0.01 的评委,在 GRPO 里等于只给离散信号。第二个 epoch 把模型训得更自信,恰好损害了我们最需要的那个性质。

所以选存档的标准要跟下游用法对齐,不是选验证损失最低的那个。 900 步在准确率、F1、图像分相关三项上都是最优,AUC 只比 500 步低 0.012 而准确率高 2.4 个点。

唯一没达标的是图像分相关 0.669(目标 0.7),差得不多,先用着;它在奖励里只占权重 0.5 的一项。

八、奖励函数集成与验证

评委训好不等于奖励可用。RL 前最后一道闸:拿 120 条真实回答(教师判合格 60、不合格 60)跑完整的奖励函数,看它能不能把好坏分开。

教师合格 教师不合格
总奖励 +1.907 −0.248
其中规则部分 +0.883 +0.633
评委给的 P(pass) 0.952 0.388
评委列出的问题数 0.03 1.28

总奖励的分离度 AUC 0.821。120 条里有 102 个不同的奖励取值,足够连续。

最值得看的是第二行:规则部分自己几乎分不开(+0.883 对 +0.633)。分离几乎全部来自评委。这个数字事后印证了第一节的判断——规则奖励确实存在大片盲区,而这正是训 RM 的全部理由。

分组看也成立:有工具调用的样本,合格 +1.935 对不合格 +0.250;纯文本样本,合格 +1.869 对不合格 −1.246。纯文本这一路的分离度反而更大,说明第五节修的那件事直接兑现了。

一处防御性设计:无图样本强制把图像分置空。评委在没有图的时候仍会吐一个数字(比如 2),如果照算,那一项会变成 −0.3 的固定惩罚。训练目标里无图样本的图像分是 null,但模型没完全学会,所以在奖励函数里加了硬性守卫。

九、RL 试跑:真正要检查的是组内梯度

单节点 8 卡跑 20 步,20 步全过,无 OOM。指标:

指标 数值
奖励均值 +1.84(范围 −5.0 到 +3.34)
组内奖励跨度 均值 3.19,最小 0.40
优势值跨度 均值 2.66,最小 2.26
优势跨度为零的步数 0 / 20
KL 惩罚 0.001
回答长度 平均 288 token

最后那两行是重点。GRPO 的优势值是组内归一化算的:同一个 prompt 的几个回答,减去组均值再除以组标准差。如果一组回答拿到相同的分数,这组的优势全是 0,对梯度毫无贡献。 用纯规则奖励时这正是隐患:纯文本回答容易被打出同一个分。

20 步里没有一步出现这种退化,组内奖励跨度最小也有 0.40。这才是"奖励可用"的实证,比奖励均值好看更重要。

两个已知不足:试跑用的短样本子集里只有 4% 带图(短 prompt 天然图少),图像那一路奖励几乎没被走到,正式跑前要单独验;奖励均值 +1.84 偏高,说明当前策略在这些样本上大多被判合格,可提升空间被压缩,换成正式长样本集后要重看分布,必要时调三项权重。

十、五个工程卡点

RL 试跑从"配置写好"到"20 步跑通"中间拆了五个,记录下来给同样场景的人省时间。

1)差点跑到别人机器上。 容器里 /tmp/ray/ray_current_cluster 残留着一周前多机实验的地址,ray.init() 不带参数时会读这个文件静默连过去,日志里只有一行 Connecting to existing Ray cluster at ...,不报错不提示。这次那个集群已经死了所以没造成影响,但如果那个地址上正好有别人的活集群,任务就直接调度到别人机器上了。集群是共享的,这个后果很严重。修法是在启动脚本开头强制 RAY_ADDRESS=local 并清掉残留记录,且要求日志里必须出现 Started a local Ray instance。

2)32.6 GB 的单个数据文件加载不了。 嵌套列(对话是 list of struct)超过 pyarrow 单块 2 GB 上限,报 Nested data conversions not implemented for chunked array outputs。切成 61 个小片(每片不超过 528 MB)即可。顺带发现建数据时那 28 分钟的合并步骤纯属白等,直接产分片就行。

3)训练框架要重新逐条 tokenize 48 万条来过滤超长样本。 实测 2.3 条每秒,需要 58 小时。而建数据时已经按同一个长度上限过滤过(实测 0 条超长),关掉这一步即可。这类"框架好心帮你做一遍你已经做过的事"的开销,在大数据集上很容易变成致命的。

4)并行配置不能整除卡数。 Megatron 要求 专家并行 × 流水并行 能整除 world size。原配置为 64 卡设计(8 × 4 = 32,整除 64),单机 8 卡直接报错,改成 2 × 4 = 8。代价是每卡驻留的专家参数变成 4 倍,和推理引擎抢显存,所以试跑改用了短样本子集。

5)显存分配器和推理引擎不兼容。 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True(做显存压测时为抗碎片加的)和推理引擎的休眠内存池冲突,报 Expandable segments are not compatible with memory pool。训练和推理放在同一批卡上时必须关掉。

这里还有个元问题:第五条的真实报错一开始看不到,因为训练框架把推理引擎的日志级别写死成 WARN,真实异常被压成一句"某个后台进程初始化失败"。我写了个小脚本按需把它提到 INFO。调试时先确认自己能看到真话,否则会在错误的方向上猜很久。

十一、复盘

三次返工里有两次是我自己的设计错误,一次是继承来的假设没验证。

第一条:教师标签必须先验证,而且要找不依赖人工标注的验证手段。 我直接信了移植过来的判别契约,训完 RM 才发现问题。事后那个"按与线上真实回复的相似度分箱看不合格率"的检验,成本只有几分钟,本该在打标之前就做。任何"用模型给模型打标"的流程,都应该先找一个这样的代理指标。

第二条:判别契约要按目标形态分流。 一份为"评判工具调用"写的契约,遇到没有工具调用的目标,模型会为了满足格式而编造事实。它不是幻觉,是在遵守我给的格式。凡是输出契约里有"必须填满某个数组"这类要求,就要问一句:这个数组为空的情况,格式允许吗?

第三条:选存档的标准要跟下游用法对齐。 验证损失最低的存档(1,100 步)不是最适合 RL 的存档(900 步)。因为 RL 要的是"评委的犹豫程度"这个连续量,而继续训练会让模型变得过度自信——这个性质在验证损失里完全看不出来。同理,"先思考后回答"这个在别处很好的做法,在"要从 verdict 概率取分数"的场景下是有害的。下游怎么用,就用什么指标选模型。