Skip to main content

从零把 8B 模型训成投标 Agent:数据、训练、奖励函数、拒答、部署全链路复盘

原文:Part 1 数据工程 | Part 2 四阶段训练 | Part 3 奖励函数 | Part 4 拒答与部署

这篇文章将四篇原文合为一篇,以第一视角完整复盘从数据准备到训练到奖励函数到拒答到线上部署的全部过程。补充了大量原文未展开的技术细节和工程直觉。


这篇文章说了什么

先理解这个生意是怎么回事(零基础也能懂)

我接了一个项目,客户是一家做电力设备的大公司。他们的生意模式是投标——什么意思呢?

打个比方:某个国家要建一座变电站(把高压电降压后送进千家万户的那种设施),他们不会自己造设备,而是发一份招标文件说"我要买 500 台断路器、200 台变压器、300 套开关柜,符合条件的供应商来报价"。然后几十家电力设备厂就各自提交方案和报价,谁的技术指标最匹配、价格最合理,谁就中标(拿到这笔生意)。

这个投标准备工作里最痛苦的一环,就是响应规格书。规格书是什么?

客户发来的招标文件里,会有一份几百页的文档,叫"技术规格书"——说白了就是一张超长的"要求清单"。每一行都是一个技术要求,比如:

客户写的(规格书原文)客户的意思(翻译成人话)
Rated voltage Ur = 12 kV"这个断路器的额定电压必须是 12000 伏,不能低"
Rated normal current Ir = 630 A"这个断路器额定电流 630 安培"
SF6 gas rated pressure = 0.5 MPa at 20°C"断路器里面的绝缘气体压力在 20 度环境下必须是 0.5 兆帕"
Operating mechanism type = motor-charged spring"操动机构必须是电机储能弹簧式,不能是液压的"
Impulse withstand voltage = 75 kV"这个断路器要能扛住 75000 伏的瞬间雷击电压"

一份规格书通常有 1000-2000 行这样的参数。客户要供应商逐条回复:你的产品能满足这一条吗?能的话,你对应的产品型号是什么?报价多少?

以前是怎么干的(三个人手动干两天的噩梦)

在自动化之前,这件事是工程师纯手动干的:

  1. 客户发来一份 200 页的 PDF(比如西门子某变电站项目,全英文)
  2. 工程师 A 打开 PDF,一行一行看,把每行参数复制到 Excel 里
  3. 工程师 B 打开公司的产品数据库(一个巨大的 Excel 或者内部系统),逐条查"断路器额定电压 12kV → 我们有什么产品符合?→ 找到了,ZW32-12 型真空断路器,成本 $2100"
  4. 工程师 C 打开一个报价 Excel 模板,把查到的结果一行一行填进去:产品型号、价格、是否合规
  5. 三个人干两天才能填完 2000 行。而且特别容易出错——填错一行(比如把 12kV 填成 120kV)、填错单位(别人写的是 Mpa,你填成了 psi)、漏掉一行(第 873 行忘了回)

更坑的是,每家客户的规格书格式完全不一样

  • 西门子喜欢用 PDF 表格,参数密密麻麻挤在格子里
  • ABB 喜欢用 Excel 多级表头,同一行参数被标题叠了三层——"电气参数 → 主回路 → 额定值 → 电压"
  • 施耐德喜欢用 Word 自然段落,"额定电压为 12 kV,频率为 50 Hz,短路开断电流为 25 kA"——一句话里塞了三个参数
  • 还有发 JSON 格式的——"额定电压"可能叫 rated_voltageUrV_ratedUn——名字全不一样但指的是同一个东西

我做的系统是怎么自动化的

这个系统拿到一份规格书,要干五件事。拿一行真实数据来走一遍你就清楚了:

原始输入(规格书第 47 行的原文):
"Rated voltage Ur = 12 kV, frequency 50/60 Hz,
impulse withstand voltage 75 kV"

系统要完成的工作:
Step 1: 判断这行属于哪个章节("5.1 电气特性 - 主回路参数")
Step 2: 从这一行里拆出 3 个独立参数:
参数 1:额定电压(Rated voltage / Ur)→ 12 kV
参数 2:频率(frequency)→ 50/60 Hz
参数 3:冲击耐受电压(impulse withstand voltage)→ 75 kV
Step 3: 对每个参数做"翻译和匹配"——
a. 同一化名称:客户写 "Rated voltage",也叫 "Ur",也叫 "标称电压"→ 都翻译成公司内部标准名称"额定电压"
b. 同一化数值和单位:客户写了"12 kV",如果产品库里存的是"12000 V",就得自动换算(1 kV = 1000 V)
c. 从公司产品库里搜索"额定电压 12kV 的断路器"→ 找到 ZW32-12 型真空断路器
d. 判断合规:12 kV 在公司产品能力范围内 → 合规 ✓
e. 查价格:ZW32-12 成本 $2100,建议报价 $2800
Step 4: 校验"跨行一致性"——
第 47 行说额定电压 12 kV,第 89 行说冲击耐受电压 75 kV
国际标准里这两者有固定关系(冲击 ≈ 额定 × 4~6 倍)→ 75/12 = 6.25 → 符合 ✓
如果某个参数的比例严重偏离正常范围,系统报警——可能是客户写错了,也可能是我们理解错了
Step 5: 把结果填回报价模板第 47 行:
"ZW32-12 真空断路器 | 12kV | 满足要求 | $2800"

一行的处理就是这样。一份 2000 行的规格书,系统就把这个流程跑 2000 遍。

整个系统是 8 个 Agent 协作的架构,每个各司其职:

Planner Agent          → 拿到文档先做整体规划:识别格式、分配任务
├── Document Agent → 解析 PDF/Excel/Word,OCR 识别,修复乱码
├── Matcher Agent → 抽取字段、匹配产品、单位归一 ← 本文重点
├── Verifier Agent → 跨字段一致性校验 ← 本文重点
├── Compliance Agent → 合规规则检查(比如认证过期没)
├── Risk Agent → 估价风险评估(这个报价赚不赚钱)
├── Writer Agent → 生成最终响应文档
└── Curator Agent → 终审格式化,检查有没有漏字段

其中 Matcher Agent(字段匹配)和 Verifier Agent(校验)占了 95%+ 的大模型调用量。为什么这两个最费大模型?因为每一步都要做"理解+推理+工具调用":看懂缩写、查产品库、算单位换算、判断一致性——这些都是大模型才搞得定的活。抽 2000 行字段,每行平均 6 次工具调用、1.3 次重试,加上 Verifier 的交叉验证,一个文档差不多 1.5 万次大模型调用。而 Planner、Writer、Curator 要么是规则逻辑、要么是单轮调用,量很小。

我的任务就是把 Matcher 和 Verifier 的底座从闭源大模型(用第三方 API,每次调用都要花钱)换成 8B 开源模型(Qwen,部署在自己服务器上),目标是把每文档的推理成本从 60 美元压到 4 美元——差了 15 倍。这篇文章按时间线复盘了六个环节:系统架构、概念、数据工程、四阶段训练、奖励函数迭代、拒答系统、线上部署。


一、先搞清楚这些概念

这篇文章涉及的概念比普通的"怎么训练模型"要多得多——因为训的是一个 Agent,不是普通的文本生成模型。概念没搞清楚的话,后面大量实验细节会看得云里雾里。

1.1 这是什么任务

上一节已经用真实业务流程走了一遍。这里用一句话概括:给模型一行规格书原文,让它判断这行在描述什么技术参数,然后从报价模板里找到对应的字段填进去。 每一步都需要先思考、再调用工具、再基于工具返回的结果做下一步决策——这就是"Agent 训练"和普通"文本生成训练"的区别:

输入:"Ur, max .... 6.8V"(在标书「电气特性」段落下)

Step 1: thought: "Ur 在 IEC 60664 标准中是额定电压" ← 先想
Step 2: 调 lookup_taxonomy("Ur", "electrical") ← 调工具
→ 返回 "额定电压, confidence 0.92" ← 工具回结果
Step 3: 确认单位是 V(不是 kV 或 mV) ← 基于结果再做判断
Step 4: final_answer: {"field_name": "额定电压", "value": "6.8 V", "confidence": 0.92}

普通文本生成只要输出一段话就行了。但 Agent 需要先"想"再"调工具"再"看工具返回什么"再"决定下一步"——这和写作文完全是两回事。

1.2 任务为什么难

难度有三个层面:

第一,领域知识。 Ur 在 IEC 60664(国际电工委员会的标准编号)里是额定电压(rated voltage),不是参考电压(reference voltage)。Vth 是阈值电压(threshold voltage),Iddq 是静态电流(quiescent supply current),t_pd 是传播延迟(propagation delay)。这些缩写不是模型"参数不够大"的问题,是训练数据分布的问题——MMLU(一个测试通用知识的 AI 考试,满分 100)考 90 分对认识 Ur 没有任何帮助。我用几个主流模型做过测试:

模型对 "Ur = 6.8V 代表什么" 的回答
Llama-3.1-8B-Instruct"可能是某种电压参考值"(错)
Qwen3-8B-Instruct"Ur 通常表示电压调节范围"(错)
闭源大模型(低配)"Reference voltage"(错得很自信)
闭源大模型(高配)"Rated voltage(额定电压)"(对)

第二,格式泛化。 每个客户的规格书格式都不一样——有的是 PDF 里表格排版,有的是 Excel 多级表头,有的是 Word 自然段落。模型面对的东西永远是没见过的布局。

第三,多语言。 客户的文档覆盖英文(40%)、西班牙语(25%)、葡萄牙语(15%)、中文(10%)、其他(德语/越南语 10%)。同一个参数类型在不同语言里的表述、缩写、单位习惯完全不同,模型必须跨语言对齐。

1.3 token 是什么

token 是大语言模型处理文本的最小单位。它不是"一个字",而是模型切割文本的方式。比如 "Ur, max .... 6.8V" 这句话,模型会把它拆成 ["Ur", ",", " max", " ....", " 6", ".", "8", "V"],每个片段就是一个 token。英文大约 1 个词 = 1-2 个 token,中文大约 1 个字 = 1-2 个 token。

全文到处说的"一条 trajectory 约 2900 token",意思就是这条训练样本切成 token 后有约 2900 个单位。普通 SFT 样本可能只有 400-500 token。Agent 的数据天然更"长",意味着训练成本更高、每个样本能传递的信号也更多。

1.4 trajectory 是什么

trajectory(轨迹)就是模型处理一个任务的完整过程记录——每一步"思考了什么、调了什么工具、工具返回了什么、最后输出了什么"全部串起来。普通 SFT 数据只记录"最终答案",trajectory 记录了"怎么得到这个答案的"。

一条典型的 trajectory 长这样(简化版):

Step 1  [模型思考] "Ur 应该是额定电压,先查 taxonomy 确认"
Step 2 [调用工具] lookup_taxonomy("Ur")
Step 3 [工具返回] {"额定电压", confidence: 0.92}
Step 4 [模型确认] "confidence 0.92,可以认定"
Step 5 [最终输出] {"field_name": "额定电压", "value": "6.8V"}

这就是为什么标注一条 trajectory 的成本很高——标注员得审每一步是不是都合理,不只是看最终答案对不对。

1.5 SFT、DPO、RL、GRPO 分别是什么

这四种训练方法按难度递进,本文的四个阶段训练就对应这四种。

SFT(Supervised Fine-Tuning,监督微调):给模型"标准答案",让它照着学。比如给 4 万条完整的 trajectory,模型学着模仿每一步动作。SFT 的问题是:它能学会"怎么做",但学不会"什么样的输出才是好的"——只要标准答案有偏差,模型照单全收。

DPO(Direct Preference Optimization,直接偏好优化):不给标准答案,而是给一对比较——"这个答案比那个答案好"。比如同一条输入,模型生成了 A 和 B 两个输出,A 完全正确、B 选错了工具。训练时 push A(让它更可能出现)、pull B(让它更不可能出现)。DPO 让模型学会"偏好",但它只能学已有数据里的偏好,探索能力有限。

RL(Reinforcement Learning,强化学习):不给标准答案也不给偏好对,只给一个"奖励规则"(奖励函数)。模型自己试,试对了加分、试错了扣分,通过 trial and error 自己探索。RL 的核心是"探索"——能发现 SFT 和 DPO 数据里从没出现过的好策略。

GRPO(Group Relative Policy Optimization):RL 的一种具体实现。它不直接看一个答案是好是坏,而是把同一个问题的 8 个答案放一组,看每个答案在组里的相对排名。"8 个答案里最好那个"获得正梯度,"最差那个"获得负梯度。GRPO 为什么适合这个项目?因为它不需要人工标注——只需要一个能自动判断答案好坏的奖励函数。

为什么不能一锅炖? 我的第一个尝试就是把 SFT 和 DPO 的数据混在一起训练,结果困难案例反而比纯 SFT 差了 5 个点。因为 DPO 需要模型先学会"基本动作",如果基础动作还没学会就做偏好对比,DPO 会把概率推向一个错误的方向。正确顺序是:先 SFT 到"会做但做不好",再 DPO 到"做得精准",最后 RL 到"能自己探索新解法"。

1.6 hallucination 是什么

hallucination(幻觉)= 模型"编造"了不存在的信息。这是一种需要专门处理的错误模式,后面的训练和奖励函数会反复提到它。

最常见的几种幻觉:

  • 编造缩写含义:把 Ur 解释成 "Unknown reference"——言之凿凿但完全错误
  • 编造证据:输出说"根据 IEC 60664 标准,电压范围是……",但其实根本没查过
  • 编造字段名:报价模板里根本没有 "额定电流密度" 这个字段,模型自己编了一个
  • 工具参数幻觉:应该传 abbr="Vth",模型传了 abbr="voltage",工具不认识

本文的 hallucination 率就是指"模型输出里包含虚构信息的比例"。客户要求 hallucination 率低于 3%——也就是说每 100 次输出,最多 3 次可以出现编造信息。

1.7 F1 和 ECE 是什么(怎么衡量模型好坏)

F1 是这个项目最核心的指标。它是精确率(Precision)和召回率(Recall)的调和平均数——精确率衡量"模型输出对的东西里,有几个是真的对的",召回率衡量"所有应该对的东西里,模型找到了几个"。F1 把两个指标合并成一个 0 到 1 之间的数字,越高越好。

拿个具体例子:一份标书有 100 个字段需要抽取,模型抽取了 90 个,其中 80 个正确、10 个错误,漏了 20 个根本没抽。精确率 = 80/90 = 0.89,召回率 = 80/100 = 0.80,F1 = 2×(0.89×0.80)/(0.89+0.80) = 0.84。

ECE(Expected Calibration Error,预期校准误差):衡量模型的"自信程度"是否可信。模型输出 confidence: 0.9,表示它"有 90% 把握"。如果抽 100 条 confidence 0.9 的输出,实际只有 70 条是对的,那 ECE 就偏高。理想情况下,模型说 80% 把握时就真有 80% 的概率答对。ECE 越低越好——0.183 表示平均偏差约 18 个百分点(虚高严重),0.034 表示平均只差 3 个百分点(基本真实)。

1.8 entropy 是什么

entropy(熵)衡量模型在生成文本时的"犹豫程度"——entropy 高说明模型在选择下一个词时很纠结(有很多候选词概率都差不多),entropy 低说明模型很确定(只有一个词概率远高于其他)。

正常的生成过程里,entropy 应该在 0.5-1.5 之间自然波动。如果某一步 entropy 骤降(比如从 0.9 直接掉到 0.3),说明模型找到了一个"模板",接下来所有类似输入都套这个模板——这叫策略熵塌陷,模型停止探索、开始走捷径。

本文的 V1 奖励函数就在 step 600 出现了 entropy 从 0.9 掉到 0.3 的断崖,是一类常见训练失败的预警信号。

1.9 logp、log-prob、perplexity 是什么

这三个概念本质是同一个东西的不同叫法,都在衡量"模型觉得这个答案有多合理"。

logp(log-probability,对数概率):模型对一段文本给出的"合理程度"评分,取了对数。logp = -0.1 表示模型觉得这段文本很合理,logp = -5.0 表示模型觉得这段文本非常不合理、几乎不可能出现。取对数的原因是原始概率太小(可能 0.000000001),直接看一堆零不方便比较。

perplexity(困惑度):logp 的另一个换算形式。perplexity 越低,模型越"不困惑",说明它觉得这些文本很正常。如果你要往模型上做 DPO,先去算一下 base model 在 winner trajectory 上的 perplexity——如果很高(>50),说明模型觉得这条 trajectory 很陌生,DPO 很难有效拉上去。

本文在 Path A 有用性过滤里反复用 logp 来衡量"有没有某步工具调用帮忙"——完整 trajectory 下 logp 高,删掉某步后 logp 断崖下跌,说明那步是关键的。

1.10 LoRA 是什么

LoRA(Low-Rank Adaptation,低秩适配)是一种"省钱版"微调方法。全量微调要改模型所有参数(8B 模型 = 80 亿个参数),显卡吃不消。LoRA 只训练一小部分额外参数(r=16 意味着在关键矩阵上加 16 个低维通道),数量大约是原始参数的 0.1%-1%。

打个比方:全量微调是把一栋 80 亿块砖的大楼拆了重盖;LoRA 是在大楼外侧加装 16 根钢筋支架,只动支架不动大楼。效果接近全量微调的 90%,但显卡需求只有 1/5。

本文所有训练阶段都用 LoRA(除了 Stage 4 GRPO 做全量),r=16, alpha=32 是常用配置——r 控制"支架数量",alpha 控制"支架强度"。

1.11 temperature、lr、epochs 是什么(训练常用参数)

temperature(温度):控制模型输出的"随机程度"。temperature=0 时模型每次都输出概率最高的答案(完全确定,但缺乏多样性)。temperature=0.7-0.9 时输出有一定随机性,适合需要探索的训练阶段——比如采样 8 条轨迹时,8 条之间得有差异才能做出有意义的组内比较。temperature=1.0 以上会越来越随机,可能输出胡言乱语。

lr(learning rate,学习率):模型每次更新参数的"步长"。lr 太高(如 1e-4),模型会跳来跳去学不到精细的东西;lr 太低(如 1e-8),模型几乎不动。本文 SFT 阶段 lr=2e-5(即 0.00002),DPO 阶段 lr=5e-7(即 0.0000005)——偏好学习比模仿学习需要小得多的步长,因为 DPO 更容易训崩。

epochs:把全部训练数据完整过一遍叫 1 个 epoch。SFT 一般 2-3 epochs,DPO 一般 1 epoch——DPO 多过几遍容易过拟合,越来越偏向训练数据里的特定偏好而丧失泛化能力。

1.12 其他会反复出现的概念速查

概念一句话解释出现在
reward hacking模型学会了在奖励规则下"钻空子"拿高分,而不是真的变强。比如模型学会写看起来很专业的假推理来骗过评分系统第五章奖励函数从头到尾都在跟它斗
KL 散度 / KL 约束衡量模型和原始版本"偏离了多远"。KL 散度太大会导致模型行为失控、无法回退。β=0.01 就是给这个偏离加一个软上限Stage 3/4 DPO 和 GRPO 训练
NLI 模型Natural Language Inference,判断"一段文本能不能从另一段文本推理出来"。本文用它判断 thought 里的结论是否有工具返回的证据支持数据过滤、Faithfulness Head
SBERT把句子变成向量(一串数字)的模型。两个句子的向量越接近,语义越相似。本文用它做答案聚类拒答系统的采样一致性
on-policy / near-on-policy用模型自己生成的数据训练自己(on-policy),vs 用别人的数据训练(off-policy)。Stage 3 之后模型已经跟原始数据分布有差异,继续用别人的数据效果不好,要切到自己的数据Stage 3/4
ablation控制变量实验——"把某个组件去掉,看效果掉多少",用来量化每个组件贡献了多少Stage 4 验证 process reward vs masking
held-out训练时完全没见过的数据,用来测模型的泛化能力——不是记忆,是真的学会了Stage 4 的核心评测指标
8B / 1.3BB = Billion(十亿)。8B 模型 = 80 亿参数,1.3B 模型 = 13 亿参数全文
loss mask训练时"遮住"某些 token,不让它们参与梯度计算。本文 mask 掉工具返回的 observation token,因为这些不是模型产生的,不该让模型模仿Stage 1 关键技巧
gradient maskingloss mask 的 RL 版本。GRPO 训练时只让模型自己说的话参与更新参数,工具返回的内容挡住Stage 4 v2 修复
backbone模型的核心主体部分,去掉输入输出层的"骨架"。说"5 个 head 共享 backbone",意思是 5 个评分维度都建立在同一个基础理解上5-head RM
hidden state / MLPhidden state = 模型中间层的输出向量(一串数字,通常是 4096 维),代表了模型当前"对文本的理解"。MLP = 多层感知机,可以理解为一种简单的"迷你神经网络",本文中 Calibration Head 就是一个接在 hidden state 上的 2 层 MLPCalibration Head
KV cache模型生成下一个 token 时,之前所有 token 的计算结果可以缓存复用。8 次采样共享 prefix KV cache 意味着同一段文本只算一次,后面的采样免费推理成本优化
Eval set从全部数据中划出的一小部分不参与训练,专门用来"考试"。如果考试分数和训练分数差距很大,说明模型在死记硬背而不是真的学会了全文评测都用

二、8 个 Agent 各是干什么的

前面提到了整个系统是 8 个 Agent 在协作。这里把每个 Agent 具体干什么、输入什么、输出什么、怎么跟其他 Agent 配合,挨个讲清楚。不过本文只训 Matcher 和 Verifier,其余 6 个是规则逻辑或单轮调用,了解即可。

2.1 Planner Agent(规划器)—— 就像快递分拣中心的调度员

打个比方:你是一个快递分拣中心的调度员。一个集装箱到了,你不会自己搬包裹,而是先扫一眼——这批货从哪来的(德国西门子)、里面有哪些种类(电气设备区 / 机械零件区 / 说明书区)、每类大概多少件。然后你给各个工位分配任务:"电气参数去 3 号传送带,机械参数去 5 号"。

Planner 干的就是这个活。

职责:拿到一份规格书后做整体分析,决定"这份文档怎么处理"。

输入:一份原始文档(PDF/Excel/Word/JSON)。

干了什么

  • 判断文档语言(英文?西班牙文?中英混杂?)
  • 识别文档结构——哪些页是表格、哪些页是文字说明、哪些页是目录
  • 把文档拆成"行"——一条参数一行,发给 Matcher
  • 判断哪些行需要校验一致性,标出来发给 Verifier

举个例子:拿到一份 200 页的西门子变电站规格书,Planner 先识别出 1-5 页是封面和目录(跳过),6-150 页是电气参数表(发给 Matcher),151-180 页是机械参数表(发给 Matcher),181-200 页是附录(跳过)。识别出 3 个需要交叉校验的参数组:"额定电压 + 冲击耐受电压""额定电流 + 短路开断电流""SF6 压力 + 操作机构类型"(发给 Verifier)。

2.2 Document Agent(文档解析器)—— 就像翻译不同方言的"万能翻译机"

打个比方:你收到一封信,但不知道它是用什么语言写的——可能是英文(PDF 格式)、可能是日文手写(图片)、可能是一张 Excel 表格、甚至可能是一个 JSON 数据包。你需要一个翻译机,不管输入的是什么,都转成标准中文。

Document Agent 就是这个翻译机。

职责:把各种格式的文档转成结构化的文本。

输入:PDF/Excel/Word/图片/JSON 文件。

干了什么

  • PDF → 识别表格边界、提取单元格文本、OCR 修复扫描件里的乱码
  • Excel → 处理多级表头("电气参数 / 额定值 / 电压"这种三层嵌套)
  • Word → 处理自然段落里的参数描述("额定电压为 12 kV,频率为 50 Hz")
  • 全局:把不同格式统一成一个标准化的"行文本 + 上下文标注"格式

举个例子:客户发来一张手机拍的断路器铭牌照片,模糊不清。Document Agent 先做图像增强、OCR 识别出"Ur = 12kV Ir = 630A",然后把这两行分别打包好发给 Matcher。

2.3 Matcher Agent(字段匹配器)★ 本文训练目标 —— 就像考试时的"阅读理解+翻书找答案"

打个比方:你考试的时候,拿到一道题"请解释 Ur = 6.8V 的含义"。你不是直接写答案,而是先翻课本——"Ur 在 IEC 60664 标准第 3.2 节是额定电压";然后翻产品手册——"我们公司有 ZW32-12 型号,额定电压 12kV 起,能覆盖 6.8V";然后翻价格表——"这个型号成本 2100"。最后把"额定电压、ZW32122100"。最后把"额定电压、ZW32-12、2100"填到答题卡上。

Matcher 做的就是这件事——每一步都是"先思考 → 翻资料 → 基于查到的东西再决定下一步"

职责:读懂一行参数,在公司的产品数据库和报价模板里找到对应的东西。

输入:一行原始文本 + 这段文本所在的上下文(哪个表格、哪个章节)。

干了什么:这是整个系统里最复杂的 Agent,每一步都是一次大模型推理 + 工具调用:

输入:第 47 行,文本 "Rated voltage Ur = 12 kV"
上下文 "5.1 电气特性 - 主回路参数"

Step 1 [think]: "Ur 在 IEC 60664 中是 rated voltage,查 taxonomy 确认"
Step 2 [tool call]: lookup_taxonomy("Ur", category="electrical")
Step 3 [tool return]: {"额定电压", confidence: 0.92}
Step 4 [think]: "confidence 0.92,确认是额定电压。12 kV → 12000 V"
Step 5 [tool call]: search_product_db(field="额定电压", value="12000 V")
Step 6 [tool return]: {"ZW32-12 真空断路器", "额定电压: 12kV", "price: $2800"}
Step 7 [think]: "找到匹配产品 ZW32-12,价格 $2800,单位一致"
Step 8 [final_answer]: {
"field_name": "额定电压",
"original_value": "12 kV",
"normalized_value": "12000 V",
"matched_product": "ZW32-12",
"price": 2800,
"confidence": 0.92
}

这就是前面说的"一行参数 ≈ 6 次工具调用"。处理 2000 行就是 1.2 万次调用。

2.4 Verifier Agent(一致性校验器)★ 本文训练目标 —— 就像会计对账时的"三表勾稽"

打个比方:你是一个会计。销售部说"本月卖了 100 台断路器,收入 $280,000",但库存记录显示本月出库了 102 台、发票开了 98 张——三个数对不上。你的工作不是去卖断路器,而是检查各个部门报来的数字之间有没有矛盾——如果有矛盾,标记出来让人工复查。

Verifier 就是这个"会计"。

职责:检查不同行的参数之间有没有矛盾。

输入:Matcher 处理完的一组相关字段(比如"额定电压 12 kV"和"冲击耐受电压 75 kV")。

干了什么

  • 用标准公式验算跨字段关系。比如额定电压(Ue)和冲击耐受电压(Uimp)在 IEC 标准中有 Uimp ≈ (4~6) × Ue 的经验关系——如果 Matcher 抽出了 Ue=12kV 但 Uimp=30kV 而不是 75kV 左右,Verifier 会报警
  • 检查单位一致性——同文档里同一个参数不能又写 kV 又写 V
  • 检查逻辑一致性——"操作机构类型 = 弹簧操动"和"操作电压 = 220V DC"是自洽的(弹簧操动通常配 DC),但"操作机构类型 = 液压操动"配"操作电压 = 48V DC"就有问题

举个例子:Matcher 在第 47 行抽出了"额定电压 = 12 kV",在第 89 行抽出了"额定电压 = 12.5 kV"——同一个参数出现了两个值。Verifier 判断是同一个字段的重复定义还是一行写错了,如果无法判定就标为"需要人工确认"。

2.5 Compliance Agent(合规检查器)—— 就像海关的"安检员"

打个比方:你带了行李箱过海关。安检员看你的护照——"签证 2025 年到期,现在是 2026 → 有问题"。看你带的药品——"这个成分需要特殊申报"。看你的电子产品——"锂电池超量,不能托运"。

Compliance Agent 就是这个安检员——它不关心参数对不对,只关心有没有违反规则

职责:检查参数是否符合行业标准和法规要求。

输入:Matcher 的输出 + 行业标准库。

干了什么

  • 检查认证是否过期("ISO 9001:2015 证书有效期为 2022-2025",当前是 2026 → 不合规)
  • 检查参数是否在国家强制标准范围内(比如 GB 标准要求某些断路器额定电压不能低于某个值)
  • 检查环保合规(RoHS、REACH 等)

这个 Agent 大部分是规则判断 + 数据库查询,不依赖大模型。

2.6 Risk Agent(风险评估器)—— 就像开餐厅前的"成本核算员"

打个比方:你要开一家餐厅。供应商报价"牛肉一斤 15",你去翻历史记录——"上次同样的牛肉进价是15",你去翻历史记录——"上次同样的牛肉进价是 12,市场均价 13,他报13,他报 15 是不是高了?"再算——"这道牛排售价 30,食材成本30,食材成本 15,人工 8,租金均摊8,租金均摊 3 → 利润 $4,利润率只有 13%,风险中"。

"成本核算员":检查报价的利润空间和历史合理性。Risk Agent 就是这个核算员。

职责:评估报价的风险——这个价格能不能做?会不会亏?

输入:Matcher 输出的产品匹配 + 价格 + 历史项目的实际成本数据。

干了什么

  • 对比历史相似项目:同样规格的 ZW32-12 断路器上次投标报价 2800,实际制造成本2800,实际制造成本 2100,利润 700。这次报700。这次报 2500 就只剩 $400 利润——风险中等
  • 检测"异常低价":如果某个参数在产品库里匹配到的产品价格明显低于同类产品,可能是 Matcher 匹配错了,也可能是供应商报了个亏本价
  • 输出风险等级(低/中/高)和建议价格区间

2.7 Writer Agent(文档生成器)—— 就像婚礼请帖的"批量填写员"

打个比方:你要给 200 个亲朋好友发婚礼请帖。请帖模板是固定的("亲爱的 ____,诚邀您参加我们的婚礼,时间 ____,地点 ____"),但你不可能给每个人都发一样的——每张请帖的名字不同、时间不同。你的做法是拿一份客人名单,把每个名字和时间逐个填进模板,200 张请帖自动生成。

Writer 就是这个批量填写员——拿模板 + 拿 Matcher 的结果 + 拿 Verifier 的校验结果,逐行填成响应文档。不涉及复杂的推理,就是"填空"

职责:把前面所有 Agent 的结果组装成一份完整的投标响应文档。

输入:Matcher 的逐行结果 + Verifier 的校验报告 + Compliance 的合规报告 + Risk 的风险评估。

干了什么

  • 按客户原规格书的格式生成响应文档——客户在第 47 行问"额定电压",Writer 就在响应文档第 47 行填"ZW32-12, 12kV, $2800, 满足要求"
  • 填不出来的行标记为"需人工确认"
  • 生成一份汇总表:总共多少行、自动填了多少行、需要人工确认多少行、风险等级分布

这个 Agent 大部分是模板填充 + 格式排版,大模型调用量很少。

2.8 Curator Agent(终审员)—— 就像出书前的"校对编辑"

打个比方:一本书要出版了。编辑已经写好了所有章节(Writer),但你不能直接拿去印。你要最后一关——"第八章的 873 页是不是空白?""封面上作者名字是不是打错了?""图 12.1 的编号是不是跳号了?""目录页码跟实际内容对应吗?"

Curator 就是这个校对编辑。它不做内容创作,只做终审检查——有没有漏的、错的、格式不统一的。不需要大模型,纯规则检查就够。

职责:最后一道关卡——检查有没有漏填的、明显的错误、格式问题。全部是规则检查,不需要大模型。

总结:为什么只训 Matcher 和 Verifier

8 个 Agent 里,Planner/Writer/Curator 是规则逻辑,Compliance/Risk 是数据库查询,Document 虽然用 OCR 和大模型,但用的是现成的闭源模型(成本占比低)。只有 Matcher 和 Verifier 是"每行都要跑好几次大模型推理"的——单文档 1.5 万次调用,成本占比 95%+。把这两个换成自训练的 8B 模型,整个系统的成本就降了 15 倍。


三、所有有效实验先列出来

总共踩了无数坑,最后活下来的方法就这么几招。

数据工程

方法做了什么效果
Path A 有用性过滤对每条 trajectory 的每一步工具调用算 usefulness_gain(删掉这步后 logp 掉多少),gain≤0 的步数 >30% 整条剔除4 万条高质量数据,模型部署后工具调用次数比闭源大模型少 40%
Path B DFSDT 多路径搜索闭源大模型在 3000 个难例上跑深度优先搜索,每个 input 找 3-5 条独立正确路径1.5 万条多路径数据,模型不再死记一种策略
Path C 偏好对 + rejection对每个 input 采样 8 条 trajectory,最好/最差组 pair,独立保留 8k 失败案例标注失败模式1.2 万偏好对 + 8k rejection,为 DPO 和 RM 提供训练数据

四阶段训练

方法做了什么效果
Stage 1 纯 SFT4 万条 Path A + loss mask 掉 tool observation tokenF1 0.71,基础动作学会
Stage 2 focal SFT + easy DPO1.5 万 Path B + 5k 最干净 DPO pair,focal loss γ=2.0F1 0.83,hallucination 18.3%→7.2%
Stage 3 on-policy ODPO自采 + RM 过滤,ODPO margin=RM score diff,beta=0.05F1 0.89,hallucination 2.1%
Stage 4 GRPO + process reward在线 RL,GenRM 给每步工具调用打分,gradient 只回传 assistant token新格式 F1 0.76→0.84,是最大泛化收益
Stage 4 GRPO v1 失败 → v2 修复v1 outcome-only reward → tool-call collapse(response length 180→70);v2 加 process reward + gradient maskingv1 held-out F1 从 0.76 掉到 0.71,v2 拉到 0.84

奖励函数

方法做了什么效果
V1→V2 三档 reward + abstain从 binary 0/1 升级到 exact/0.5/0.3 + abstain 动作 0.8F1 0.78→0.84,近似错误率 12.4%→6.1%
V2 修复 over-abstentionabstain 从 0.4% 飙到 15.3%——63% 是能答却弃权。条件弃权:能答还弃 → -0.6拒答合理率 37%→89%
V3 5-head RM5 个独立 head(语义/单位/位置/置信度/证据)+ 2 层 MLP 动态聚合F1 0.89,ECE 0.183→0.034
V3 GenRM独立 8B 模型做 second opinion,说 FAIL 就截断 -1.0判别式 RM 被 hack 的防护网
V3 困难负例构造6 类最小修改对抗负例(改高/低 conf、单位偷换、字段名替换、漏证据、假 source)Calibration Head 对 confidence 微小差异极度敏感

拒答系统

方法做了什么效果
R-Tuning24 次采样探测模型"知道/不知道"边界,按复杂度分桶控制 knows:unknown=4:1真不会时拒答率 12%→73%,错误拒答 3.2%
采样一致性校准8 次采样聚类→最大簇占比,三步投票(grounding 过滤 + 结构化比对 + SBERT)ECE 0.183→0.041
补考流程拒答后强制输出假设 → 定向检索证据 → 重新判断拒答不合理率 22%→18%
三档路由confidence>0.85 直接采信(90%)/ 0.5-0.85 fallback(8%)/ ≤0.5 人工(2%)线上成本为闭源大模型的 1/15

试了但翻车的方法:SFT+DPO 混训(难 case 跌 5 点)、prompt 控拒答(从没用)、logit confidence 校准(ECE 0.183 太低)、Dr.GRPO/REINFORCE++(Too hot/cold 解决不了问题)。


四、数据工程:怎么从生产环境里"洗"出高质量 Agent 训练数据

3.1 Agent SFT 数据和普通 SFT 数据完全不一样

普通 SFT 数据是一对 (input, output)——标注员看一眼输入写个输出。成本很低。

Agent SFT 数据是一条完整的 trajectory(轨迹):

{
"trajectory_id": "xxx",
"task_type": "字段分类",
"system": "你是 DocSpec 字段分类助手。Tools: [lookup_taxonomy, search_examples, ask_layout_worker, abstain]。",
"user_input": {
"row_text": "Ur, max .... 6.8V",
"context_block": "5. 电气特性 (Tj = 25°C,除非另有说明)"
},
"trajectory": [
{
"step": 1,
"role": "assistant",
"thought": "缩写 Ur 在 IEC 60664 中通常是额定电压。但这里在电气特性段,先用工具确认分类。",
"action": {"tool": "lookup_taxonomy", "args": {"abbr": "Ur", "category": "electrical"}},
"label": {"action_correct": true, "thought_quality": 0.9}
},
{
"step": 2,
"role": "tool",
"name": "lookup_taxonomy",
"content": {"matches": [{"name": "额定电压", "confidence": 0.92, "source": "IEC 60664"}, {"name": "参考电压", "confidence": 0.31, "source": "general"}]}
},
{
"step": 3,
"role": "assistant",
"thought": "额定电压的置信度为 0.92,且与上下文一致,无需进一步查询。",
"action": {"tool": "final_answer", "args": {"field_name": "额定电压", "value": "6.8 V", "confidence": 0.92, "evidence_span": "Ur, max .... 6.8V"}},
"label": {"action_correct": true, "outcome_correct": true}
}
]
}

一条 trajectory 平均 8 步,每步 thought + action + observation 加起来约 300 token,整条约 2900 token——光 token 数就是普通 SFT 数据的 6 倍。

更挑战的是标注。标注员要判断的不只是"最终答案对不对",还包括:每一步该不该调工具?工具参数对不对?thought 和 action 一致吗?中间重试是合理的还是死循环?

3.2 一个隐蔽的坑:Agent 任务没有唯一正确答案

同一行输入"Vth(typ) = 0.7 V at 25°C",至少有三条合理路径:

  1. 直接 lookup_taxonomy("Vth") → threshold voltage → 给答案
  2. search_examples("Vth typ") → 看类似 spec 怎么标的 → 再确认
  3. ask_layout_worker(...) 确认这一行属于哪个表格 → 再决定查哪个 taxonomy

三条都对,区别只在效率。如果训练数据只标了路径 1,模型上学到的就是一个极度狭窄的策略:永远 lookup_taxonomy。一旦上线遇到 taxonomy 里查不到的缩写,模型直接死循环——它没见过其他解法。

这也是 ToolBench、AgentInstruct 这一批早期 Agent 数据集的共同短板:本质都是"单条 trajectory + outcome reward",模型学到的是脆弱的捷径。

3.3 三条数据路径并行

Path A:生产 trajectory + 有用性过滤(最终 4 万条)

这是数据量最大的一支。来源是闭源大模型跑了两个月积累的原始 trajectory。

原始数据里充满了乱调工具、重试循环、中途崩掉但没记录的噪音。过滤分四步:

Step 1 完整性过滤:trajectory 必须以 final_answer 收尾。

Step 2 业务正确性过滤:人工抽样 + 闭源大模型复核 + 客户下游签收三重确认。这一步淘汰最狠。

Step 3 有用性过滤——这是最核心的一步。借鉴了 Toolformer 的思路:一个工具调用如果对最终答案没有实质帮助,就该被剔除。

核心做法是把某步工具调用从 trajectory 中抠掉,看参考模型(Qwen3-8B-Base 快速 SFT 的 bootstrap checkpoint——意思是用一小批数据快速训出来的"草稿版"模型,F1 约 0.55,很弱但够用来判断工具调用的有用性)给出正确答案的概率掉了多少。掉得多说明这步有用,几乎没掉说明这步多余。

完整 trajectory 下 logp = -0.12(模型很确定能答对)
删掉 lookup_taxonomy 那步后 logp = -2.3(断崖下跌!)
→ gain = 2.18,说明 taxonomy 查询是关键步骤

删掉 unit_convert 那步后 logp = -0.14(几乎没变)
→ gain = 0.02,单位本来就没变化,白查了一次

每条 trajectory 的每步都算一遍 gain。gain≤0 的步骤超过 30% 的 trajectory 整条剔除——它代表"乱调工具"的低效策略,不该被模型学到。

这一步有个意想不到的副作用:训练完之后,模型部署的工具调用次数比闭源大模型 pipeline 还少 40%。这部分成本节省是直接在数据筛选阶段拿到的。

拿一条真实数据走一遍 usefuless_gain 计算过程。下面是一条来自生产环境的原始 trajectory——它看起来最终答案对了,但中间混了大量无用步骤。

// ===== 原始 trajectory(过滤前) =====
{
"trajectory_id": "traj_042718",
"input": {
"row_text": "Ir = 630 A at 40°C, frequency 50/60 Hz",
"context_block": "5.1 电气特性 - 主回路参数"
},
"ground_truth": {
"field_name": "额定电流",
"value": "630",
"unit": "A"
},
"steps": [
// Step 1: 查 taxonomy —— 这是关键步骤
{
"step": 1,
"role": "assistant",
"thought": "Ir 在电气标准中常见,先查 taxonomy 确认含义",
"action": {"tool": "lookup_taxonomy", "args": {"abbr": "Ir", "category": "electrical"}},
"usefulness_gain": 2.18
},
// Step 2: taxonomy 返回结果
{
"step": 2,
"role": "tool",
"name": "lookup_taxonomy",
"content": {"matches": [{"name": "额定电流", "confidence": 0.95}]}
},
// Step 3: 查 IEC 标准原文 —— 多余步骤!taxonomy 已经有 0.95 置信度了
{
"step": 3,
"role": "assistant",
"thought": "置信度 0.95 还不够,再查 IEC 标准原文确认",
"action": {"tool": "search_standard", "args": {"query": "Ir rated current IEC"}},
"usefulness_gain": -0.03
},
// Step 4: search_standard 返回
{
"step": 4,
"role": "tool",
"name": "search_standard",
"content": {"results": [{"text": "Ir = rated current...", "source": "IEC 62271-1"}]}
},
// Step 5: 做单位换算 —— gpt 上下文已经有 "630 A",不需要换算
{
"step": 5,
"role": "assistant",
"thought": "确认 630 A 是否需要换算为 mA 或 kA",
"action": {"tool": "unit_convert", "args": {"value": 630, "from": "A", "to": "A"}},
"usefulness_gain": -0.01
},
// Step 6: unit_convert 返回
{
"step": 6,
"role": "tool",
"name": "unit_convert",
"content": {"result": 630, "unit": "A"}
},
// Step 7: 查产品 —— 关键步骤
{
"step": 7,
"role": "assistant",
"thought": "额定电流 630A,查产品库匹配",
"action": {"tool": "search_product_db", "args": {"field": "额定电流", "value": "630", "unit": "A"}},
"usefulness_gain": 1.67
},
// Step 8: search_product_db 返回
{
"step": 8,
"role": "tool",
"name": "search_product_db",
"content": {"matches": [{"model": "ZW32-12/630", "price": 2800}]}
},
// Step 9: 查竞争对手历史报价 —— 多余步骤!业务不需要竞品分析
{
"step": 9,
"role": "assistant",
"thought": "看一下竞品历史报价作为参考",
"action": {"tool": "search_competitor_price", "args": {"product": "ZW32-12/630"}},
"usefulness_gain": -0.12
},
// Step 10: final_answer
{
"step": 10,
"role": "assistant",
"thought": "确认完毕,输出答案",
"action": {"tool": "final_answer", "args": {"field_name": "额定电流", "value": "630 A", "matched_product": "ZW32-12/630", "confidence": 0.94}},
"usefulness_gain": null
}
]
}

usefulness_gain 的计算方法:对每条 trajectory 的第 i 步(assistant 的 thought + action),构造一个「删除了第 i 步及对应 tool observation」的残缺 trajectory,用 bootstrap checkpoint 模型计算该残缺 trajectory 下 final_answer 正确的 log-probability。然后与完整 trajectory 的 logp 做差:

usefulness_gain(i) = logp(正确答案 | 完整 trajectory) 
- logp(正确答案 | 删掉第 i 步的残缺 trajectory)

上面这条 trajectory 共 10 步,其中 assistant 步骤 4 个(step 1/3/5/7/9),有用的只有 step 1(gain=2.18)和 step 7(gain=1.67)。多余步骤 3 个(step 3/5/9),gain 全部 ≤0。多余步占比 = 3/5 = 60% > 30% 阈值 → 整条剔除。

如果这条 trajectory 通过了过滤呢? 实际上生产数据中有大量类似 case 被剔除了。过滤后的干净版本长这样:

// ===== 过滤后的 trajectory(保留) =====
{
"trajectory_id": "traj_042718_clean",
"input": {
"row_text": "Ir = 630 A at 40°C, frequency 50/60 Hz",
"context_block": "5.1 电气特性 - 主回路参数"
},
"ground_truth": {
"field_name": "额定电流",
"value": "630",
"unit": "A"
},
"filtering_stats": {
"original_steps": 9,
"removed_steps": 3,
"removed_tools": ["search_standard", "unit_convert", "search_competitor_price"],
"useless_ratio": 0.60,
"verdict": "rejected"
},
"clean_steps": [
{"step": 1, "role": "assistant", "thought": "Ir 在电气标准中常见,先查 taxonomy 确认含义", "action": {"tool": "lookup_taxonomy", "args": {"abbr": "Ir", "category": "electrical"}}},
{"step": 2, "role": "tool", "name": "lookup_taxonomy", "content": {"matches": [{"name": "额定电流", "confidence": 0.95}]}},
{"step": 3, "role": "assistant", "thought": "额定电流 630A,查产品库匹配", "action": {"tool": "search_product_db", "args": {"field": "额定电流", "value": "630", "unit": "A"}}},
{"step": 4, "role": "tool", "name": "search_product_db", "content": {"matches": [{"model": "ZW32-12/630", "price": 2800}]}},
{"step": 5, "role": "assistant", "thought": "确认完毕,输出答案", "action": {"tool": "final_answer", "args": {"field_name": "额定电流", "value": "630 A", "matched_product": "ZW32-12/630", "confidence": 0.94}}}
]
}

过滤后的 trajectory 从 10 步压缩到 5 步,所有步骤都对最终答案有实质贡献。模型从这种数据里学到的策略是"只调有用的工具"——这就是为什么部署后工具调用次数比闭源大模型少 40% 的根本原因。

Step 4 多样性采样:按 task_type × difficulty × language × tool_combination 四维分桶,每桶上限 800 条。高频简单字段(如英文"Rated voltage")有 80 条就够了,不需要 2000 条。

Path B:DFSDT 多路径搜索(最终 1.5 万条)

Path A 的每条 input 只有一条 trajectory,会导致模型学狭窄策略。Path B 专门解决这个:对同一 input 找出多条独立正确路径。

做法是用闭源大模型在 3000 个难例上跑深度优先搜索(DFSDT——对同一个问题让模型尝试多条不同的工具调用路径,每条正确路径都保留),每个 input 产出 3-5 条不同但都正确的路径。判断"正确"有两个条件必须同时满足:

  1. final_answer 与 ground truth(人工标注的标准答案)一致
  2. thought 无幻觉——用 NLI 模型(自然语言推理模型,专门判断一句话能不能从另一句话推理出来)确认 thought 里提到的事实都能在 observation 里找到对应

第二个条件很重要。一开始只看 final_answer,筛进来不少"最终答案对但中间 thought 满嘴跑火车"的 trajectory——这种数据训出来的模型学到的是"瞎编也能编对"。

3000 input × 平均 5 条路径 = 约 1.5 万条,刻意覆盖所有语言(每种语言约 500 个难例)。

拿一个真实 case 看 DFSDT 怎么搜出三条路径。下面是一个西班牙语难例——输入行是 "Tensión nominal Ur = 24 kV, frecuencia 50 Hz"。闭源大模型为其搜索了三条完全不同的工具调用策略,全部正确。

// ===== DFSDT 搜索:一个 input 的三条正确路径 =====
{
"input": {
"row_text": "Tensión nominal Ur = 24 kV, frecuencia 50 Hz",
"language": "es",
"context_block": "3. Características eléctricas - Interruptor automático",
"difficulty": "hard"
},
"ground_truth": {
"field_name": "额定电压",
"value": "24",
"unit": "kV"
},
"search_config": {
"method": "DFSDT",
"max_branches": 5,
"temperature": 0.3,
"validation": "nli + exact_match"
},

// ===== 路径 1:标准三步法(taxonomy → product → answer) =====
"path_1": {
"strategy": "标准三步",
"nli_score": 0.97,
"steps": [
{"step": 1, "role": "assistant", "thought": "Ur 在电气标准中通常表示额定电压,但文本是西班牙语 'Tensión nominal',也需要确认。", "action": {"tool": "lookup_taxonomy", "args": {"abbr": "Ur", "lang": "es"}}},
{"step": 2, "role": "tool", "content": {"matches": [{"name": "额定电压", "name_es": "Tensión nominal", "confidence": 0.94}]}},
{"step": 3, "role": "assistant", "thought": "taxonomy 返回额定电压,与西班牙语 'Tensión nominal' 对应,confidence 0.94。现在查产品。", "action": {"tool": "search_product_db", "args": {"field": "额定电压", "value": "24", "unit": "kV"}}},
{"step": 4, "role": "tool", "content": {"matches": [{"model": "ZW32-24", "rated_voltage": "24kV", "price": 3200}]}},
{"step": 5, "role": "assistant", "thought": "确认找到匹配产品 ZW32-24,24kV,输出", "action": {"tool": "final_answer", "args": {"field_name": "额定电压", "value": "24 kV", "matched_product": "ZW32-24", "confidence": 0.92}}}
]
},

// ===== 路径 2:先看文档布局再查(layout → taxonomy → product → answer) =====
"path_2": {
"strategy": "先查布局再消歧",
"nli_score": 0.94,
"steps": [
{"step": 1, "role": "assistant", "thought": "这一行在 'Características eléctricas' 段落下,先确认文档布局定位。", "action": {"tool": "ask_layout_worker", "args": {"row_index": 47, "section": "3. Características eléctricas"}}},
{"step": 2, "role": "tool", "content": {"section_type": "电气参数表", "column_headers": ["Parámetro", "Valor", "Unidad"]}},
{"step": 3, "role": "assistant", "thought": "确认是电气参数表。'Tensión nominal Ur = 24 kV' 中 Ur 应该是额定电压。查 taxonomy 双确认。", "action": {"tool": "lookup_taxonomy", "args": {"abbr": "Ur"}}},
{"step": 4, "role": "tool", "content": {"matches": [{"name": "额定电压", "confidence": 0.94}]}},
{"step": 5, "role": "assistant", "thought": "layout + taxonomy 双重确认,现在查产品", "action": {"tool": "search_product_db", "args": {"field": "额定电压", "value": "24", "unit": "kV"}}},
{"step": 6, "role": "tool", "content": {"matches": [{"model": "ZW32-24", "rated_voltage": "24kV", "price": 3200}]}},
{"step": 7, "role": "assistant", "thought": "路径 2 完成。多了一步 layout 确认作为保险,适合歧义场景。", "action": {"tool": "final_answer", "args": {"field_name": "额定电压", "value": "24 kV", "matched_product": "ZW32-24", "confidence": 0.94}}}
]
},

// ===== 路径 3:先搜类似案例再查(检索增强式) =====
"path_3": {
"strategy": "检索增强",
"nli_score": 0.91,
"steps": [
{"step": 1, "role": "assistant", "thought": "西班牙语规格书,先搜类似案例看 'Tensión nominal Ur' 历史上怎么标的。", "action": {"tool": "search_examples", "args": {"query": "Tensión nominal Ur kV", "lang": "es", "limit": 3}}},
{"step": 2, "role": "tool", "content": {"examples": [{"row": "Tensión nominal Ur = 12 kV → 额定电压", "count": 142}, {"row": "Tensión nominal Ur = 24 kV → 额定电压", "count": 87}]}},
{"step": 3, "role": "assistant", "thought": "142+87 个类似案例都标了额定电压,这个信号很强。直接查产品。", "action": {"tool": "search_product_db", "args": {"field": "额定电压", "value": "24", "unit": "kV"}}},
{"step": 4, "role": "tool", "content": {"matches": [{"model": "ZW32-24", "rated_voltage": "24kV", "price": 3200}]}},
{"step": 5, "role": "assistant", "thought": "跳过 taxonomy 直接靠历史案例匹配成功。这种路径在 taxonomy 查不到时是唯一可用方案。", "action": {"tool": "final_answer", "args": {"field_name": "额定电压", "value": "24 kV", "matched_product": "ZW32-24", "confidence": 0.88}}}
]
}
}

三条路径全部正确,但策略完全不同:路径 1 最直接最快(5 步),路径 2 多了一步 layout 保险(7 步),路径 3 绕过了 taxonomy 靠历史案例对齐(5 步)。如果训练数据只有路径 1,模型一遇到 taxonomy 查不到的缩写就死循环——而实际上路径 3 的策略(search_examples → 对齐)能处理这种 case。

NLI 验证是怎么做的:每条路径的每个 thought 都要通过 NLI 模型检查——thought 里的每条"事实陈述"是否能被同一步的工具返回文本所蕴含(entail)。比如路径 3 的 step 1:

// NLI 验证示例:路径 3 的 step 1 thought
{
"thought": "西班牙语规格书,先搜类似案例看 'Tensión nominal Ur' 历史上怎么标的。",
"claims": [
{"claim": "Tensión nominal 在历史案例中常被标为额定电压", "evidence_span": "Tensión nominal Ur = 12 kV → 额定电压, count: 142", "nli_score": 0.98, "verdict": "entailment"},
{"claim": "Tensión nominal Ur = 24 kV 出现过 87 次", "evidence_span": "Tensión nominal Ur = 24 kV → 额定电压, count: 87", "nli_score": 0.95, "verdict": "entailment"}
],
"overall_nli": 0.91,
"hallucination_flag": false
}

NLI score < 0.5 的 claim 会触发整条 trajectory 剔除——因为它意味着模型在 thought 里编造了工具返回中不存在的信息。这套机制在 Path A 中也同样应用,但 Path A 的原始数据量太大(几十万条),只在最终采样阶段抽检;Path B 是每条都验。

Path C:从失败里学(最终 1.2 万偏好对 + 8k rejection)

光看好数据,模型不知道边界在哪。Path C 专门提供"反面教材"。

偏好对:对每个 input 让早期 SFT checkpoint 采样 8 条 trajectory,最好一条(winner)vs 最差一条(loser,必须 outcome 错)组 pair,最终 1.2 万条。

独立失败案例集:约 8k 条不组对,单独标注失败模式。我统计了 top 失败模式:

失败模式占比例子
过早 final_answer(没用工具就猜)30%+直接猜 Ur = unknown reference
工具调用循环(同一工具调 3+ 次)20%+lookup_taxonomy 调 5 遍
abstain 当偷懒20%-第一次拿不准就弃权
工具参数 hallucination15%-abbr="voltage" 但正确的是 "Vth"
Thought 与 Action 不一致10%-想的是 lookup,调的是 search

这些失败模式会在后面的奖励函数和拒答训练中被专门针对。

偏好对的数据结构长什么样。下面是一个真实的 DPO pair——同一个 input 采了 8 条轨迹,最好的一条(winner)和最差的一条(loser)组成一对。

// ===== 一个完整的 DPO 偏好对 =====
{
"pair_id": "dpo_003827",
"input": {
"row_text": "SF6 gas rated pressure = 0.5 MPa at 20 C",
"context_block": "4.2 绝缘介质参数",
"language": "en",
"difficulty": "medium"
},
"sampling_config": {
"model": "sft_checkpoint_step2000",
"temperature": 0.7,
"num_samples": 8,
"top_p": 0.95
},

// ===== Winner:正确轨迹 =====
"winner": {
"trajectory_id": "traj_sample_3",
"rm_score": 0.94,
"trajectory": [
{
"step": 1,
"role": "assistant",
"thought": "SF6 是六氟化硫气体,rated pressure 是额定压力。查 taxonomy 确认。",
"action": {"tool": "lookup_taxonomy", "args": {"abbr": "SF6", "category": "insulation"}}
},
{
"step": 2,
"role": "tool",
"name": "lookup_taxonomy",
"content": {"matches": [{"name": "SF6 气体额定压力", "confidence": 0.91}]}
},
{
"step": 3,
"role": "assistant",
"thought": "确认是 SF6 气体额定压力。值 0.5 MPa,温度 20°C 是标准条件。查产品。",
"action": {"tool": "search_product_db", "args": {"field": "SF6 气体额定压力", "value": "0.5", "unit": "MPa"}}
},
{
"step": 4,
"role": "tool",
"name": "search_product_db",
"content": {"matches": [{"model": "ZW32-12/630-0.5", "rated_sf6_pressure": "0.5 MPa", "price": 2800}]}
},
{
"step": 5,
"role": "assistant",
"thought": "找到 ZW32-12/630-0.5,SF6 额定压力 0.5 MPa,完全匹配。",
"action": {"tool": "final_answer", "args": {"field_name": "SF6 气体额定压力", "value": "0.5 MPa", "condition": "20°C", "matched_product": "ZW32-12/630-0.5", "confidence": 0.93}}
}
]
},

// ===== Loser:错误的轨迹 =====
"loser": {
"trajectory_id": "traj_sample_6",
"rm_score": 0.12,
"failure_mode": "工具参数 hallucination",
"failure_detail": "taxonomy 查询时把 'SF6' 写成了 'sf6_gas_pressure'(自然语言而非标准缩写),导致工具返回空结果后模型直接猜了答案",
"trajectory": [
{
"step": 1,
"role": "assistant",
"thought": "这一行说的是 SF6 气体的额定压力,我先查 taxonomy。",
"action": {"tool": "lookup_taxonomy", "args": {"abbr": "sf6_gas_pressure", "category": "insulation"}}
},
{
"step": 2,
"role": "tool",
"name": "lookup_taxonomy",
"content": {"matches": [], "error": "abbr 'sf6_gas_pressure' not found in taxonomy"}
},
{
"step": 3,
"role": "assistant",
"thought": "taxonomy 里没查到。但 'rated pressure = 0.5 MPa' 看起来就是 SF6 的额定压力,我直接认定。",
"action": {"tool": "final_answer", "args": {"field_name": "SF6 气体额定压力", "value": "0.5 MPa", "confidence": 0.85}}
}
],
"error_analysis": "step 1 的 abbr 应该是 'SF6' 而不是 'sf6_gas_pressure'——这是工具参数幻觉。工具返回空结果后,模型没有尝试重试(如用正确的缩写再查一次),而是直接猜了答案。虽然 final_answer 碰巧对了,但 confidence 0.85 缺乏证据支持。"
},

"pair_metadata": {
"rm_score_diff": 0.82,
"winner_tools": ["lookup_taxonomy", "search_product_db"],
"loser_tools": ["lookup_taxonomy"],
"loser_missed_tool": "search_product_db",
"dpo_margin": 0.82
}
}

偏好对的关键约束:loser 必须 outcome 错(或严重偏离),不能是 winner 的"弱化版"。如果两条轨迹最终答案都对、只是效率不同,这种 pair 对 DPO 几乎没有信息量——因为 DPO 的 contrastive signal 来自"不可接受的策略"vs"可接受的策略",而非"好"vs"更好"。

独立失败案例集的标注格式。8k 条 rejection 数据不组对,每条单独标注失败模式、根因和建议正确做法,供后续奖励函数和 Gabstain 训练参考。

// ===== 独立失败案例标注 =====
{
"rejection_id": "rej_001284",
"input": {
"row_text": "Vth(typ) = 0.7 V, Iddq(max) = 10 μA",
"context_block": "6. DC 特性参数",
"language": "en"
},
"model_output": {
"final_answer": {"field_name": "电压阈值", "value": "0.7 V", "confidence": 0.88},
"ground_truth": {"field_name": "阈值电压", "value": "0.7", "unit": "V"}
},
"annotation": {
"failure_mode": "字段名近似错误",
"severity": "medium",
"root_cause": "Vth 的标准中文是 '阈值电压'(threshold voltage),模型输出了 '电压阈值'——词序颠倒、语义相近但不是标准字段名。下游模板匹配会找不到这个字段。",
"correct_action": "lookup_taxonomy('Vth') → 阈值电压",
"related_to": "命名规范未对齐,taxonomy 中需增加常见误称映射",
"training_use": "困难负例构造:将正确答案 '阈值电压' 替换为 '电压阈值' 作为接近但不正确的负例,训练 RM 的 Semantic Fit head 对近义词敏感"
}
}
// ===== 另一个失败案例:过早 final_answer =====
{
"rejection_id": "rej_005612",
"input": {
"row_text": "Iddq(max) = 10 μA at Vdd = 3.3V",
"context_block": "6. DC 特性参数",
"language": "en"
},
"model_output": {
"final_answer": {"field_name": "静态电流", "value": "10 μA", "confidence": 0.42},
"trajectory_length": 1
},
"annotation": {
"failure_mode": "过早 final_answer",
"severity": "high",
"root_cause": "模型第一步就直接输出答案,没有调任何工具。confidence 只有 0.42 说明它自己也不确定,但因为没查 taxonomy,它不知道 Iddq 代表静态电流(quiescent supply current)——是碰巧猜对的。",
"correct_trajectory": "step1: lookup_taxonomy('Iddq') → 静态电流 → step2: search_product_db → step3: final_answer",
"training_use": "作为 DPO loser,训练模型在没有工具证据时不得输出 final_answer"
}
}
// ===== 另一个失败案例:工具调用循环 =====
{
"rejection_id": "rej_008923",
"input": {
"row_text": "t_pd(typ) = 2.5 ns at Vdd = 3.3V, CL = 15 pF",
"context_block": "7. 时序特性",
"language": "en"
},
"model_output": {
"final_answer": null,
"termination": "max_steps_exceeded",
"tool_calls": [
{"step": 1, "tool": "search_examples", "args": {"query": "t_pd"}},
{"step": 2, "tool": "search_examples", "args": {"query": "t_pd typ"}},
{"step": 3, "tool": "search_examples", "args": {"query": "t_pd propagation delay"}},
{"step": 4, "tool": "search_examples", "args": {"query": "t_pd timing"}},
{"step": 5, "tool": "search_examples", "args": {"query": "t_pd ns"}}
]
},
"annotation": {
"failure_mode": "工具调用循环",
"severity": "critical",
"root_cause": "模型反复调用同一个工具 search_examples,每次换一个 query 参数但本质上在做同一件事。正确的做法是:第一次 search_examples 无果后,应该切换到 lookup_taxonomy('t_pd')。模型缺乏 fallback 策略意识。",
"correct_trajectory": "step1: search_examples('t_pd') → step2: 无结果 → step3: lookup_taxonomy('t_pd') → 传播延迟 → step4: final_answer",
"training_use": "作为 DPO loser + 单独标记循环模式,训练模型在工具返回空时触发 fallback 而非换个参数重试"
}
}

这些失败案例的标注成本很高——每条需要工程师手动分析根因并写正确 trajectory。但它们在后续训练中的回报也很高:1)直接作为 DPO loser 训练模型避免这些模式,2)为奖励函数设计提供真实的反面教材(比如 V3 的 Faithfulness Head 和困难负例构造),3)为拒答系统提供"模型容易在这儿栽跟头"的信号。


五、训练:四阶段课程学习——为什么不能一锅炖

4.1 第一锅的惨痛教训

我的第一个尝试很自然:5.5 万 SFT + 2 万 DPO 混在一起训。

指标纯 SFTSFT + DPO 混训
简单案例 F10.790.85
困难案例 F1(跨语言、单位换算、缩写消歧)0.760.71(跌 5 点)
Tool-call valid rate0.810.78
Hallucinated abbr rate14.2%19.8%

简单案例涨了 6 点,但困难案例比纯 SFT 还差 5 点。hallucination 反而升高了。

根因:DPO 在弱基座上会放大初始策略偏差。 DPO 本质是 push winner 上去、pull loser 下来。如果 base model 连 winner 的动作都做不好(log-prob 很低),DPO 拉不上去,概率会被挤到第三个方向——一条更差的捷径。

具体例子:初始 8B 对"额定电压"的倾向是匹给 "Reference voltage"(字面接近),DPO winner 是 "Rated voltage"。混训后模型没学会正确的语义匹配,反而学会了跳过缩写消歧、直接调 ask_layout_worker 兜底——把"不擅长的事"全部 outsource,看起来 reward 高了,实际退化。

正确的顺序是:先 SFT 到"会做但做不好",再 DPO 到"做得精准"。

4.2 Stage 1:模仿(Imitate)— 纯 SFT

数据:4 万条 Path A trajectory。方法:Qwen3-8B-Base + LoRA SFT(r=16, alpha=32),学习率 lr=2e-5,训练 3 个 epoch(全量数据过 3 遍)。

一个关键操作:loss mask 必须把 tool observation 全部 mask 掉。 tool 调用后返回的文本(比如 OCR 识别出的表格内容——OCR = 光学字符识别,把图片或 PDF 里的文字转成可读文本)本质上是外部的随机输出,不是模型该学的。不 mask 的话,模型会试图预测这些 OCR 输出的内容——这没意义,但 F1 会低 4 个点。

指标Stage 1
Field-Classify F10.71
Tool-call valid rate0.83
Hallucinated abbr rate18.3%

Stage 1 目标只是把流程跑通。如果你在这一阶段看到 F1 很高、hallucination 很低这种"过好"的指标,反而要警惕——通常意味着 eval set 跟训练集太接近,没真正测泛化。我的 eval set 与训练数据完全去重且刻意按难度分层,所以 0.71 是真实水平。

4.3 Stage 2:探索(Explore)— focal SFT + 最干净的 DPO

数据:1.5 万条 Path B + 5k 条最简单 DPO pair(loser 必须明确错误:调错工具/参数幻觉/abstain 偷懒)。

两个关键技巧:

Focal loss(γ=2.0):用 (1-p)^γ 降权高置信 token,让梯度集中在模型还吃不准的 token 上。γ=2 时准确率 >0.9 的 token 权重压到 1%,p≈0.5 的高难度 token 保持完整权重。我测了 γ∈3:γ=1 提升不明显;γ=3 过于忽略"巩固",简单 case F1 掉了 2 点;γ=2 在难 case 提升最大且不伤简单 case。

只用最干净的 DPO pair:筛选条件是 loser ground-truth match < 0.3、winner/loser prefix 有明显分歧。最终筛出 5k 条,其余 7k 条复杂 pair 留到 Stage 3。

Stage 2 超参:focal SFT lr=1e-5,epochs=2;DPO beta=0.1,lr=5e-7(1e-6 训崩、2e-7 没动),epochs=1,ref_model=Stage 1 merged checkpoint。

指标Stage 1Stage 2
F10.710.83
Tool-call valid rate0.830.94
Hallucinated abbr rate18.3%7.2%
Avg tool calls8.16.9

F1 涨 12 点,hallucination 砍掉 60%。DFSDT 多路径数据起了关键作用——模型不再死记一种调用顺序。

4.4 Stage 3:自我进化(Self-Improve)— on-policy ODPO

前两个阶段的数据都是外部生成的(Path A/B 来自闭源大模型)。Stage 2 后模型已经有了自己的能力分布,它的盲点跟闭源大模型不重合——继续用闭源大模型数据训练是重复劳动。

切到 near-on-policy:

  • 准备 20k 个 Stage 2 没见过的 input(来自更晚期的生产流量,刻意多选新语言/格式)
  • 每个 input 用 Stage 2 模型采样 4 条 trajectory(temperature=0.7)
  • RM 打分:score > 0.85 进 SFT(约 1 万条),score < 0.3 且同 input 下有高分的配对做 DPO(约 7k 条)

关键改进:用 ODPO(带 margin 的 DPO),margin 来自 RM score 差值——大 margin pair 获得更强的梯度信号。beta 从 0.1 降到 0.05(on-policy 下 KL 约束要更松),lr 降到 2e-7。

指标Stage 2Stage 3
F1(训练覆盖格式)0.830.89
Tool-call valid rate0.940.98
Hallucination7.2%2.1%
Avg tool calls6.95.2

hallucination 终于压到了 2.1%——还是高于客户要求的 3% 以下,有继续推进的必要。

4.5 Stage 4:GRPO — 让模型自己探索新策略

Stage 3 的 DPO 泛化到顶了。因为每个客户、每份文档格式都不一样,泛化能力才是核心指标。

4.5.1 v1 失败:tool-call collapse

直接用 outcome reward 训 GRPO。reward 曲线看着正常上涨,但 eval 数据暴露了问题:训练覆盖格式上 F1 基本不变(~0.89),held-out 新格式 F1 从 0.76 掉到 0.71。

诊断:平均 response length 在 50 step 内从 180 token 降到 70 token。 模型不调工具了,直接猜答案。新格式上工具调用从 3.2 次降到 1.8 次。

这条指标——mean response length——我后来把它加进了训练必看面板。它是 Agent 任务里最快速的 collapse 预警信号,比 F1 早约 100 step 就能看出趋势。

为什么 reward 在涨但模型在退化?因为 GRPO 的 token-level loss 用 1/|o_i| 做长度归一化:

K=8 采样中:
traj_A: 调了 4 个工具,答对,reward=0.88
traj_B: 只调 1 个工具,猜对,reward=0.85
traj_C: 调了 5 个工具,走偏,答错,reward=0.31

group normalization 后 A 和 B 的 advantage 接近(reward 接近),
但 B 更短 → 1/|o_B| 更大 → 每个 token 被更强地强化。
结果:策略被推向"少调工具、多猜"。

outcome reward 对"是否充分使用了工具"完全没信号,短 trajectory 天然占便宜。

这可能是 Agent RL 领域最容易被忽略的坑:reward 在涨但 response length 在缩短 = 模型在走捷径,不是在学习。

4.5.2 v2 修复:Process Reward + Gradient Masking

修复 1:Gradient masking。 只在 assistant token 上回传梯度,observation token mask 掉——因为 observation 是工具返回的,不是 policy 产生的,对它算 policy gradient 在数学上不成立。

修复 2:Step-level process reward。 用 GenRM(8B 模型)给每步工具调用打分——是否返回了有用的 chunk?是否有实质推进?分母用 max(实际步数, 3),惩罚跳步行为。最终 reward = 0.7×outcome + 0.3×process_reward。

这是 outcome-conditioned 的 step labeling,不是严格的过程奖励模型(PRM),但对工具调用步骤明确可判定的场景够用。GenRM 已经存在,只需改 prompt,开发成本为零。

v2 训练配置:30k inputs(含 20% 从未出现在训练中的新格式),K=8,lr=1e-6,kl_penalty=0.03,epochs=2。

指标Stage 3GRPO v1 (失败)GRPO v2
F1(训练覆盖格式)0.89~0.890.90
F1(held-out 新格式)0.760.71 ↓0.84
Avg tool calls(新格式)3.21.8 ↓3.4

held-out 新格式 F1 从 0.76 拉到 0.84——这 8 个点来自"模型学会在完全陌生的格式下也坚持用工具取证"。Ablation:去掉 process reward 只留 masking → held-out F1 0.79;去掉 masking 只留 process reward → 0.83。Process reward 是主要 driver。

这一阶段的核心经验:在已经很强的 SFT+DPO 基础上做 Agent RL,最大的坑是 outcome-only reward 会悄无声息地压缩工具使用。reward hacking 和训练不稳定都排在它后面。解法在 trajectory 结构层面(process reward + gradient masking),不在 RL 超参层面。

4.6 四阶段总览

Stage 1Stage 2Stage 3Stage 4
数据来源闭源大模型 trajectoryDFSDT + easy DPO自采 + RM 过滤在线采样(含新格式)
数据量4 万 SFT1.5 万 SFT + 5k DPO1 万 SFT + 7k DPO3 万 inputs × K=8
损失函数token CEfocal SFT + DPOfocal SFT + ODPOGRPO + process reward
学习率2e-51e-5 / 5e-75e-6 / 2e-71e-6
核心机制模仿探索边界自我对抗环境探索 + 步骤级反馈
GPU-hours36241870
F1(训练覆盖格式)0.710.830.890.90
F1(held-out 新格式)0.760.84
Hallucination18.3%7.2%2.1%1.8%

六、奖励函数:三版迭代——每版都被模型"游戏化"

奖励函数我前后改了三版。每版都是上线后被生产环境暴露出新的 reward hacking 模式才改的。核心教训:奖励函数不是一个数学问题,是一个产品问题。

5.1 V1:二元奖励 baseline(正确 +1,错误 0)

用最简单的信号跑通整条训练 pipeline。离线 F1 从 0.71 涨到 0.78。

但发现三个问题:

问题 1:近似错误不退。 12.4% 的错误属于"找到了正确区域但抓错了相邻行"——比如文档表格里同时有"SF6 额定压力 0.5MPa"和"SF6 试验压力 0.6MPa"两行,模型定位到了正确表格但选了邻行。这种错误和"完全跨 section 答非所问"在 V1 里都是 0 分,但从学习角度看,前者显然更接近正确。binary reward 区分不了。

问题 2:过度自信。 recall 从 0.83 掉到 0.79,同时高置信度回答的错误率从 8.2% 涨到 15.6%。模型学会了"把一切都标高置信度",因为高置信答对 reward=1,答错也是 0——没有针对"自信地错"的额外惩罚。

问题 3:entropy 断崖。 训练 log 中 token-level entropy(模型在选择下一个词时的犹豫程度,见 1.8)在 step 600 从 0.9 骤降到 0.3 nats(nats 是熵的单位,类似摄氏度的"度")——模型回答塌缩到几个模板句式。sparse 0/1 信号下,一旦找到"能稳定拿 1 分"的窄路径,binary reward 就无力把它推开。

结论:二元信号无法编码"错的程度"。

5.2 V2:三档奖励 + 弃权选项

把"错的结构"编码进 reward:

def reward_v2(trajectory, ground_truth):
final = trajectory.final_answer
if final.action == "abstain":
return 0.8 # 弃权
if exact_match(final, ground_truth):
return 1.0 # 完全正确
if same_section(final, ground_truth):
return 0.5 # 对了 section 但抓错行
if same_sheet(final, ground_truth):
return 0.3 # 对了 sheet 但抓错 section
return 0.0

新增 abstain(弃权)动作,reward=0.8。业务上有一步人工兜底,漏一个字段比错填一个字段危害小。

指标V1V2
F10.780.84
近似错误率12.4%6.1%
高置信度错误率15.6%5.3%

但新的问题来了:over-abstention。 abstain 率从 0.4% 飙到 15.3%。抽检 200 条:63% 是模型明明能答对却选了弃权。

原因是一个博弈论问题:即使考虑近似命中(0.5),对一个 70% 把握的题,硬答的期望约在 0.70-0.80 之间(取决于错误的分布),而 abstain 是确定拿 0.80 且方差为零。PPO/DPO 的优化过程天然偏好 reward 方差小的轨迹——abstain 成了一个稳定的高 reward 吸引子。模型 game 了这个机制:对所有把握 90% 以下的题全部弃权。

结论:每加一个动作选项都要想——模型能不能 game 它。

5.3 V3:多维度奖励模型

V2 之后我意识到,用一个标量概括"一条 trajectory 有多好"本身就有问题。V3 把奖励从 if-else 重写成一个带 5 个 head 的 1.3B 评分模型。

修复 1:条件弃权。 能答却弃权 → -0.6;真不会才 +0.8。判断"能不能答":对同一 input 采样 8 条 trajectory,≥5 条答对即标记为"能答"。

修复 2:证据加权。 V2 只看 final_answer,V3 同时检查 trajectory 里是否有工具返回的证据支持答案——猜对但没证据的会被 Faithfulness Head 压低。用 NLI 模型(见 1.12)判断 final_answer 是否能被某个工具返回文本 entail(逻辑上"蕴含"——A 能不能自然推理出 B),entailment score 低于 0.5 视为无证据。

修复 3:Hard pair 筛选。 只保留 winner/loser 分数差在 0.15-0.7 之间的 pair。差值低于 0.15 太接近学不到信号,高于 0.7 太简单(loser 是格式崩溃或完全 run-off)。数据从 1.2 万缩到 7k,但每个 pair 都足够 informative。

5-head RM

Head评什么训练信号
Semantic Fit答案语义接近度标注数据监督
Unit Compatibility单位匹配规则 + 1k 人工校验
Section Relevance答案位置是否正确 section文档结构规则
Confidence Calibration置信度是否对齐正确率per-bin 校准标签
Evidence Citation引用是否真实存在NLI 模型 + 人工抽检

5 个 head 共享 backbone,多任务联合训练(约 3 万条带 5 维标签的样本)。最终 reward 由 2 层 MLP 根据任务类型 embedding 输出 5 维 softmax 权重,动态聚合各 head 分数。

每次 reward 版本升级后,policy 从 SFT checkpoint 重新跑后续训练阶段,不做 continual。原因:如果接着 V2 policy 继续训,模型已经内化了"遇到不确定就弃权"的策略,即使 V3 reward 惩罚了 false-abstain,旧习惯需要很多梯度步才能纠正——不如从干净起点重新学。

GenRM:生成式验证器

5-head RM 容易被 hack——模型学会写"听起来有道理"的 thought 骗过 Faithfulness Head。加了一个独立 8B 模型做 second opinion:

GenRM 输入完整 trajectory,输出 {"verdict": "FAIL", "reason": "Step 3 提到 IEC 60664 但工具调用中没有该 source。属于 hallucination。"}——说 FAIL 时 reward 直接截断为 -1.0,无论 5-head RM 给了多高的分。

GenRM 不是线上每条都跑——只在 RM 评分处于边界区间时触发,通过 cache + selective gating 控制成本。在 GRPO 阶段还承担 step-level 评分:看到完整 trajectory 后回头给每一步打分,关键步骤获得更高权重、无效绕路接近零。这是事后归因,不是严格 PRM,但工程上够用且成本可控。

困难负例构造

RM 训练数据里有 2 万条最小修改对抗负例(借鉴 CLAIR 的最小修订思路):拿一条正确 trajectory,做一处最小改动让它变错。6 类策略:

  • confidence 改高/改低(0.92→0.99)
  • 单位偷换(V→kV)
  • 字段名近邻替换
  • 漏掉证据查询
  • 引用不存在的 source(捏造 IEC 标准号)

这让 Calibration Head 对 confidence 的微小差异极度敏感。

V3 成绩

指标V1V2V3
F10.780.840.89
近似错误率12.4%6.1%2.8%
拒答率15.3%5.7%
拒答合理性37%89%
ECE0.1830.0970.034

V3 是第一版所有指标同时变好的版本。ECE 0.034 意味着模型说"90% 把握"时真的有约 90% 概率答对——下游的置信度路由才有意义。


七、拒答系统:让模型敢说"我不知道"

6.1 prompt 救不了拒答

我最先用 prompt engineering。试了各种说法:

Prompt 策略拒答率拒答合理性
Baseline(无指令)0.4%
"If unsure, say unknown"1.1%31%
"Be very cautious. Say unknown if any doubt."4.8%45%
"Penalty for wrong answer is 100×. Say unknown if conf < 0.9"0.6%

最后一行最让人意外——明确告诉模型"答错的代价是答对收益的 100 倍",按逻辑它应该疯狂拒答。但拒答率反而很低。

这是训练分布的问题,不是推理时能修的问题。 通用 SFT/RLHF 阶段几乎从来不奖励"我不知道"——人类标注员看到模型说"unknown"会本能打低分,这种偏见经过几轮 RLHF 后深度内化进模型的输出分布。Prompt 是 in-context 引导,改变不了模型的先验分布

6.2 第一层:R-Tuning 构造"知道/不知道"边界

借鉴 R-Tuning(Zhang et al., NAACL 2024)的思路:用 24 次采样投票来探测模型的知识边界

对每条 query 让训完 pipeline 的模型生成 24 次(8 prompt 模板 × 3 温度):
答对 ≥ 20 次 → "knows"
答对 ≤ 4 次 → "unknown"
其他 → "ambiguous"(丢弃)

丢弃中间区间的逻辑:ambiguous 样本是模型 50% 概率答对的噪声,强行给"知道"或"不知道"标签都会引入矛盾梯度。

构造 SFT 数据:knows 类 target 写正常答案,unknown 类 target 写拒答模板 "I don't have reliable information about this. Confidence: 0.2"。跟 Stage 1 SFT 数据按 1:4 混合重新训练。

修复 over-abstention

第一版 R-Tuning 后出现了新的 over-abstention:模型学会了"只要 query 复杂就拒答"。因为复杂 query 天然更难答对,unknown 类样本里复杂 query 占比远高于 knows 类——模型学到的是"看起来复杂就拒答",而不是"真的不知道就拒答"。

治本方案:按文档复杂度(页数、表格密度、术语频率)把样本分成 5 个桶,在每个桶内分别控制 knows:unknown=4:1。对高复杂度桶上采样 knows + 下采样 unknown。

修完后:

指标不加 R-Tuning加 R-Tuning
真不知道时的拒答率12%73%
实际能答时的错误拒答率0.4%3.2%

12% 到 73% 提升很大,但代价是 3.2% 的 over-abstention。还需要更深层的护栏。

6.3 第二层:用采样一致性替代单点置信度

模型输出里写的 conf: 0.92 不能当真——那是它生成的文本,不是经过校准的概率。

logit confidence 的问题:模型对同一个边界 query 采样 8 次,每次都给高置信度但答案不同——"Threshold voltage"(0.85)、"Reference voltage"(0.78)、"Threshold voltage"(0.82)……平均 0.83 但 8 次出了 3 种不同答案。logit confidence 测的是模型在某条解码路径上有多自信,不告诉你它对自己的整体观点有多稳定

采样一致性(Self-Consistency 的工程变体):每条 query 采样 8 次,答案做语义聚类,看最大簇占比。8 次里 7 次语义相同 → 真的知道;8 次出 4 个簇 → 不知道。

踩坑:SBERT 的分辨率不够

第一版直接把 8 个答案丢给 SBERT 聚类。查个案时发现一批明明该低 confidence 的 case 全都虚高。根因很好笑:模型在边界 case 上的典型摇摆是"抓错行"——表格里同时有"额定压力 0.5MPa"和"试验压力 0.6MPa"两行,8 次采样有几次抽了前者、几次抽了后者。但在通用语义空间里这两个答案太像了——都在描述同一设备压力参数,句式一样只差一个修饰词,余弦相似度高达 0.94。轻松超过 0.85 的聚类阈值,两个答案被并成一簇,confidence 直接拉满。

修复成三步投票:

  1. grounding 过滤:数值+单位归一化后回原文定位,定位不到 → 幻觉 → 直接丢弃
  2. 结构化字段按"参数名+数值"分别比对:参数名归一化一致 + 数值误差 1% 以内 → 同簇。"额定压力 0.5MPa"和"试验压力 0.6MPa"老老实实分成两簇
  3. 自由文本才走 SBERT:型号、材质描述继续用语义聚类
置信度来源ECE
单 sample logit confidence0.183
8-sample 采样一致性0.041

ECE 从 0.183 降到 0.041 是质变——平均偏差从 18 个百分点降到 4 个百分点。

推理成本妥协

8 次采样理论上成本是原来的 8 倍,但 vLLM(一个高性能推理引擎,专门优化了大模型批量生成的速度)的 n=8 参数让这 8 次采样的前半段共享 KV cache(见 1.12),实际额外开销大约是原来的 2 倍而不是 8 倍。我的折中方案:在 8B 模型最后一层 hidden state(模型内部的中间计算结果,见 1.12)上接一个 2 层 MLP(迷你神经网络),这就是前面说的 Calibration Head。它与 RM 联合训练,每次 forward pass(模型跑一次计算)几乎零成本给出粗筛置信度。只在粗筛落在 [0.4, 0.7] 这个"不确定区间"时,才触发完整的 8 次采样。 线上实际触发率约 11%,端到端处理延迟增加约 20%、系统吞吐量下降约 15%,可以接受。

6.4 第三层:给弃权一次补考机会

到这里拒答不合理率还有 22%(拒答中约 1/5 是模型其实能答但被表面特征压低了置信度——比如 query 措辞奇怪、context 噪声大)。

借鉴 ReCoVERR 的思路,拒答后触发补考:

if first_pass.action == "abstain":
# 强制模型给假设答案——绕过拒答冲动
hypothesis = model.generate(query + "Even if uncertain, what's your best guess?")

# 围绕假设做定向证据检索
evidence = search_evidence(hypothesis)
evidence += lookup_taxonomy(extract_keywords(hypothesis))

# 证据塞回去重新判断
final = model.generate(query + evidence)

if final.has_strong_support(threshold=0.85):
return final.answer, confidence=0.7 # 推翻拒答,但 confidence 保守
else:
return abstain # 维持拒答

补考成功推翻后 confidence 只给 0.7(不高于 0.85),必须走闭源大模型复核。复核时用原始 query + 原始文档,防止补考的定向检索引入确认偏误(confirmation bias——只找支持自己假设的证据,忽略反对证据)。

6.5 三层叠加

拒答不合理率(false abstention / total abstention):

阶段拒答不合理率
起点(V2 拒答策略)63%
+ Layer 1 (R-Tuning)32%
+ Layer 2 (采样一致性)22%
+ Layer 3 (补考)18%

从 63% 降到 18%,剩下的 18% 是真的难到闭源大模型也答不准的边缘 case。


八、部署:置信度路由

每条请求先过 8B 模型拿到答案,Calibration Head 输出 confidence,三档路由:

  • confidence > 0.85(约 90% 的请求):直接采信,不做复核
  • 0.5 < confidence ≤ 0.85(约 8%):fallback 闭源大模型重做
  • confidence ≤ 0.5(约 2%):人工审核

阈值 0.85 是扫了 0.70-0.95 后选的最优拐点:接受率 90%,被接受案例准确率 0.93,整体 F1 0.89。再放宽准确率掉,再收紧涌向 fallback 增加成本。

Calibration Head 粗筛 [0.4, 0.7] 决定"要不要触发 8 次采样";路由阈值 0.85/0.5 决定"最终答案走哪条路"。89% 的 query 直接用 Calibration Head 分数路由;11% 触发采样后用采样结果替换再路由。

最终成本:闭源大模型的 1/15。


九、Insight 提炼

9.1 Agent 训练数据是最大的隐性成本

普通 SFT 数据一对 (input, output),标注成本很低。Agent SFT 数据是完整 trajectory——每条约 2900 token,标注员要审每一步 thought/action/observation。更隐蔽的坑是"Agent 任务没有唯一正确路径":同一个 input 有 3-5 条合理策略,只标其中一条会让模型学到脆弱的窄策略。

我的解法:Path A 有用性过滤去噪音,Path B DFSDT 造多路径,Path C 保留失败案例标注失败模式。三路并行,不迷信单一来源。

9.2 SFT → DPO → RL 不能跳步

第一锅混训的惨痛教训:DPO 在弱基座上会放大初始策略偏差。正确顺序是 SFT 建基础 → DPO 学偏好 → RL 学探索。DPO 上去之前,先看 base 在 winner trajectory 上的 perplexity 够不够低——不够说明 base 还不会这种动作模式,DPO 上去只会乱拉。

9.3 Agent RL 最大的坑:outcome-only reward silently 压缩工具使用

Stage 4 v1 的 tool-call collapse 是我在这个项目里踩的最贵的坑。reward 在涨、F1 在掉、response length 在缩短——三个信号组合就是"模型在走捷径"的铁证。mean response length 应加进 Agent RL 必看面板。

解法:process reward + gradient masking。process reward 给每步工具调用独立打分,惩罚跳步——这才是 Agent RL 的核心,不是调 RL 超参。

9.4 奖励函数是博弈论问题

V1 二元 → V2 三档 → V3 多维 RM + GenRM,每版都被模型的"游戏行为"打脸。核心规律:每加一个动作选项(如 abstain),都要想清楚"模型能不能 game 它"——能答却弃权拿 0.8 vs 硬答期望 0.75,理性 agent 当然弃权。条件弃权(能答还弃 → -0.6)是必须的防护。

一个标量概括不了复杂 trajectory 的好坏。 多维独立评估 + GenRM 二次审 + 困难负例构造,才是工业级奖励函数的基线。

9.5 让模型说"不知道"要从训练层面解决

prompt 是 in-context 引导,改不了模型的先验分布——通用 SFT/RLHF 阶段几乎从不奖励拒答。解法分三层:R-Tuning 构造知识边界(训练层)、采样一致性校准置信度(推理层)、补考挽回错误拒答(后处理层)。三层叠加把拒答不合理率从 63% 降到 18%。

9.6 数据清洗比模型训练更能省钱

Path A 有用性过滤的一个意外效果:模型部署后工具调用次数比闭源大模型 pipeline 少 40%。这部分成本节省是在数据筛选阶段直接拿到的——删掉"调了但没用"的工具调用步骤,模型根本没学会这些冗余行为。

原文 Part 1: https://zhuanlan.zhihu.com/p/2047280719182016622 原文 Part 2: https://zhuanlan.zhihu.com/p/2047678807373509108 原文 Part 3: https://zhuanlan.zhihu.com/p/2048017742356395864 原文 Part 4: https://zhuanlan.zhihu.com/p/2048730835873018335