给多模态 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 概率取分数"的场景下是有害的。下游怎么用,就用什么指标选模型。