16.3 对比实验
上一节的评测集给了你一个分数。但"分数 0.80"本身没有意义——有意义的是"这一版比上一版好,还是差"。判断这件事的唯一可靠办法是对比实验:固定评测集,改一个变量,各跑一遍,看分数怎么动。这一节把 minagent 的评测能力再往前推一步,从"能打分"变成"能比较"。
本节把 minagent 推进到对比实验:在同一套评测集上改一个变量——把
max_steps从 8 收到 2、在提示里加一句引用约束——各跑一遍,输出成功率 / 平均步数 / 平均成本的对比表。
16.3.1 对比实验的三个前提
在动手之前,先立三条规矩。它们决定了对比实验是"科学"还是"玄学":
| 前提 | 含义 | 违反的后果 |
|---|---|---|
| 控制变量 | 一次只改一个东西 | 分不清是哪个改动带来的差异 |
| 固定评测集 | 两版跑的是同一批用例 | 换一套题,分数不可比 |
| 多指标并看 | 成功率、步数、成本一起看 | 用成本暴涨换来的微小提升被当成胜利 |
控制变量是最容易被忽略的一条。真实开发里,人常常"顺手"同时改好几处——换个提示词、顺手调了步数、又加了个工具——然后发现分数变了,却说不清是哪一处起了作用。对比实验强制你一次只动一个变量,这样差异才能归因。
多指标并看是另一条。只看成功率会诱导你用无限步数、无限成本去堆分数;只看成本会诱导你把 Agent 砍成一个只会说"我不知道"的机器人。成功率和成本是一对此消彼长的指标,必须一起看才有意义——这也是上一节为什么把过程指标单独报告。
还有一条隐含的前提:你得先有一个能打分的评测集。没有稳定分数,对比实验就退化成"凭感觉比较两个版本"。所以对比实验不是独立的能力,它是建在上一节评测集之上的第二层——评测集负责"量出好坏",对比实验负责"量出差异"。
举个反面例子。假设你同时做了三件事:把 system 从"你是助手"改成"你是严谨的助手,必须引用来源"、把 max_steps 从 8 调到 16、给注册表加了一个 read 工具。跑完评测,成功率从 50% 涨到 83%。你很开心,准备合并。可问题来了:这 33 个百分点到底是谁的功劳? 是引用约束逼模型多读文档?是步数上限放宽让长轨迹跑完?还是新工具打开了某条路径?你答不上来。而如果其中某一项其实在拖后腿(比如放宽步数让某些用例绕远路反而失败),你更看不出来——净效果是正的,就把坏改动一起合并了。
对比实验的全部意义,就是让上面这句话变成"我知道是引用约束贡献了这 33 个百分点"。代价是你得跑三次单变量实验,而不是一次全上。
值得对比的"变量"其实很多,本节只挑两个最典型的演示:
| 变量 | 取值示例 | 关心什么 |
|---|---|---|
| 步数预算 | max_steps 8 / 4 / 2 | 预算够不够、成本能不能压 |
| 提示约束 | 是否要求引用 | 加约束值不值 |
| 推理模式 | ReAct / Plan-and-Solve | 哪种循环更省更准 |
| 工具描述 | 详写 / 略写 | 描述质量对选工具的影响 |
| 模型档位 | 大模型 / 小模型 | 花更多钱是否换来更多分 |
所有这些变量,都能用同一套"改一个、跑一遍、看表"的流程验证。本节的 evaluate 就是那个通用的杠杆。
16.3.2 改一个变量:步数预算
第一个变量最机械也最干净:max_steps。它不改变任何脚本、任何工具、任何提示,只是改循环的上限。整个对比脚本只有十来行,全部复用上一节的 evaluate——对比实验的代码骨架,就是把 evaluate 循环调用几遍:
from evaluate import Scorer, build_cases, build_registry, evaluate
def row(name: str, rep) -> str:
return f"{name:<16}{rep.pass_rate:>8.1%}{rep.avg_steps:>10.1f}{rep.avg_cost:>12.5f}"
def main() -> None:
reg = build_registry()
scorer = Scorer()
header = f"{'配置':<16}{'成功率':>8}{'平均步数':>10}{'平均成本(元)':>12}"
print("实验一:改步数预算(同一套用例与脚本)")
print(header)
print("-" * len(header))
for ms in (8, 4, 2):
rep = evaluate(build_cases(), reg, scorer, max_steps=ms)
print(row(f"max_steps={ms}", rep))
真跑输出:
实验一:改步数预算(同一套用例与脚本)
配置 成功率 平均步数 平均成本(元)
----------------------------------------------
max_steps=8 50.0% 2.3 0.00082
max_steps=4 50.0% 2.3 0.00082
max_steps=2 16.7% 1.3 0.00044
这张表讲了一个很值钱的故事。8 收到 4,分数纹丝不动——因为评测集里最长的轨迹只有 3 步,4 的上限根本没碰到。这是一个重要的教训:改了变量却没看到变化,不代表变量没用,可能只是没改到位。 如果不做这个阶梯对比,你可能会误以为"步数预算无关紧要",从而错过它在别处的价值。
4 收到 2,成功率从 50% 掉到 16.7%——两条三步轨迹(calc_two_step、search_then_read)被硬生生截断,直接判失败。同时平均步数降到 1.3、平均成本降到 0.00044。这就是"多指标并看"的意义:成功率掉了一半多,成本也掉了近一半——如果只看成本,会以为收紧预算是好事;把成功率摆在一起,才知道这是在牺牲正确性换省钱。
注意这次对比为什么"公平":三行跑的是同一批 Case、同一个 Scorer、同一份工具注册表,唯一的差别就是传给 run() 的 max_steps。没有换题、没有换打分口径、没有偷改脚本——所以成功率的变化只能归因到步数上限这一个变量。这就是 16.3.1 那三条规矩在代码里的落地。
所以这张表给出的结论是:对这个评测集,max_steps 的下限大约在 3——再低就会开始砍掉正确轨迹。这个数字不是拍脑袋来的,是对比实验量出来的。
顺带一个自检技巧:两张表的 baseline 行数值应当完全一致(都是 50.0% / 2.3 / 0.00082)。如果不一致,说明两次实验用的评测集或打分口径变了,实验就作废了。把"基线行必须对齐"当成一条纪律,能挡住大多数"偷偷改了评测集还不自知"的失误。
16.3.3 再改一个变量:提示约束
第二个变量更贴近真实开发:在系统提示里加一句约束。这里要先坦白一个假模型的局限——假模型的行为由脚本写死,它不会自己因为提示词变了就改行为。所以在离线评测里,“换提示词"的效果要靠两版脚本离线重放来体现:同一道题,准备一份"baseline 行为"的脚本、一份"加了约束后行为"的脚本,分别跑。
这正是离线评测集的正确用法——它重放的是"录下来的行为”,不是"现场推理"。真实场景里,两份脚本对应两次真实调用录下来的轨迹:
print("实验二:提示里加一句引用约束(两版脚本离线重放)")
print(header)
print("-" * len(header))
print(row("baseline", evaluate(build_cases(cite=False), reg, scorer)))
print(row("+引用约束", evaluate(build_cases(cite=True), reg, scorer)))
build_cases(cite=True) 把两条检索用例的脚本,从"搜一次直接答"换成"搜一次、读正文、再带 [doc1] 作答"。实现上只是给工厂加一个开关:
def build_cases(cite: bool = False) -> list[Case]:
search_script = ([[ToolCall("c1", "search", {"q": "RAG"})],
[ToolCall("c2", "read", {"doc_id": "doc1"})],
"根据 [doc1],RAG 是检索增强生成。"] if cite
else [[ToolCall("c1", "search", {"q": "RAG"})],
"RAG 是检索增强生成。"])
# 其余用例不变,两条检索用例共用 search_script
...
注意"两版"的差别只在脚本:Case 的期望字段(expected_tools、expected_citations)完全一样,Scorer 也完全一样。这就保证了分数的变化只能来自脚本所代表的"行为差异",而不是打分口径的松动。真跑输出:
实验二:提示里加一句引用约束(两版脚本离线重放)
配置 成功率 平均步数 平均成本(元)
----------------------------------------------
baseline 50.0% 2.3 0.00082
+引用约束 83.3% 2.7 0.00104
成功率从 50% 涨到 83.3%,代价是平均步数从 2.3 涨到 2.7、平均成本从 0.00082 涨到 0.00104。这次是明确的正收益:多花约 27% 的成本,换回 33 个百分点的成功率。逐条对比更能看清是哪几条翻了颜色:
| 用例 | baseline | +引用约束 | 变化 |
|---|---|---|---|
calc_two_step | PASS | PASS | 不受影响 |
calc_single | PASS | PASS | 不受影响 |
search_plain | FAIL(引用 0.00) | PASS(引用 1.00) | 引用补上 |
search_then_read | PASS | PASS | 不受影响 |
wrong_tool | FAIL(工具 0.00) | FAIL(工具 0.00) | 不受影响 |
search_no_cite | FAIL(引用 0.00) | PASS(引用 1.00) | 引用补上 |
涨的分全部来自"引用可回溯"这一项——两条检索用例从 FAIL 翻成 PASS,其余用例纹丝不动;wrong_tool 那条仍然失败,因为引用约束治不了"调错工具"。归因清晰,是因为只动了一个变量。 如果这一版同时改了提示词又改了步数上限,上面这句"涨的分全部来自引用"就说不出口了——你无法证明是提示词的功劳还是步数的功劳。这就是 16.3.1 第一条规矩的威力。
16.3.4 怎么看这两张表
把两个实验放一起,能提炼出读对比表的一般方法:
| 看什么 | 实验一(步数) | 实验二(引用约束) | 结论 |
|---|---|---|---|
| 成功率 | 50 → 50 → 16.7 | 50 → 83.3 | 涨是目的 |
| 平均步数 | 2.3 → 2.3 → 1.3 | 2.3 → 2.7 | 代价之一 |
| 平均成本 | 不变 → 0.00044 | 0.00082 → 0.00104 | 代价之二 |
| 归因 | 单变量,干净 | 单变量,干净 | 能说清是哪一步 |
三个实用结论:
- 没变化也是结果。
8→4没动,说明当前评测集碰不到这个上限;要验证这个变量,得加一条更长的用例,而不是草率宣布"它没用"。 - 收益要用代价折算。
+引用约束多花 27% 成本换 33 个百分点成功率,通常划算;但如果换来的只是 2 个百分点,就未必了。每次对比都要问一句"这个提升值这些成本吗"。 - 一次一个变量。这是唯一能保证"归因成立"的做法。想同时验证多个改动,就跑多轮单变量实验,而不是一次全上。
还有一条容易被忽视的:对比实验要有稳定的评测集。如果你为了"让新版本好看点"而改了评测集,两版就不可比了,整个实验作废。评测集一旦定版,就应该像代码一样被版本管理起来——改动它需要单独的、明确的理由。
把两张表折算成"要不要合并这个改动",其实就是在回答一个问题:收益是否显著大于代价。三种典型情况:
| 成功率 | 成本 | 判断 |
|---|---|---|
| 明显上升 | 基本不变 | 直接合并 |
| 明显上升 | 明显上升 | 算一笔账:提升是否值这些成本 |
| 基本不变 | 明显上升 | 拒绝合并(纯亏) |
| 下降 | 任意 | 拒绝合并(除非有别的理由) |
本节的实验二属于第二行——成功率涨 33 个百分点,成本涨约 27%,通常划算;但换到线上动辄百万次调用,这 27% 的成本是真实预算,所以"值不值"最终要由业务决定,评测只负责把账算清楚。评测不替你做决策,它让你有依据做决策。
16.3.5 为什么这些实验能离线做
细看这两组实验,会发现一个共同点:它们全部零网络、零费用、秒级完成。这正是把评测建在假模型上的回报。真实开发里,“改个提示词跑一遍大评测集"可能要好几分钟、花几十块钱;而在假模型下,同样的对比几毫秒就跑完,可以塞进每次提交的 CI。
当然,代价是"假模型下的结论未必等于真实模型下的结论”——脚本是我们写的,它代表的是我们预期模型会怎么做,而不是模型实际会怎么做。所以正确的工作流是:假模型下做高频回归与快速筛除,真实模型下做低频验收。前者便宜到可以每次跑,后者昂贵但可信。两者的评测骨架完全一样,只是 model_factory 换了个后端。
这一点决定了对比实验在工程里的位置:它是一道廉价的门,拦住那些"改了还不如不改"的版本,让它们根本没机会花真实模型的钱。真模型只用来验收少数几个走到最后的候选。先把筛选做便宜,再把验收做可信——这是所有评测体系的基本经济学。
16.3.6 常见陷阱
对比实验看起来简单,实操里却反复踩同样的坑。列几条最常见的:
| 陷阱 | 表现 | 破解 |
|---|---|---|
| 一次改多个变量 | 分数变了,说不清是谁的功劳 | 拆成多轮单变量实验 |
| 中途换评测集 | 两版分数不可比 | 评测集定版后再开实验 |
| 只看成功率 | 用成本暴涨堆分数 | 成功率与成本并看 |
| 只跑一次 | 把噪声当信号 | 多次取平均或做大评测集 |
| 只挑能过的用例 | 分数恒为 100% | 主动纳入已知失败样例 |
| 改了变量没变化就下结论 | 误判变量无用 | 阶梯取值,确认是否碰到边界 |
最后一条在本节已经演示过:8→4 没变化,不是 max_steps 没用,而是评测集里没有超过 4 步的轨迹。正确做法是补一条更长的用例,而不是宣布这个变量无关紧要。
还有一个隐蔽的陷阱:用测试集调参。如果你反复用同一批用例去"试哪个提示词分数高",实际上是在对这批判例过拟合——换一批用例,分数可能就崩了。工程上的做法是把评测集分成调参集和留出集:前者用来反复试,后者只在最后验收时跑一次。本节的用例集很小,够不上这个层次,但思路要提前建立。
两个和"指标"本身有关的陷阱也值得记下。其一,别把平均值当成全部。 平均步数 2.3 听起来很温和,但它是"多数用例 2 步、少数 3 步"平出来的;如果某个版本是"多数 1 步、少数 8 步",平均值可能更低,体验却更差——长尾那些 8 步的用例才是用户真正会骂的。所以除了平均值,也值得看一眼最大值和分布。
其二,指标之间会互相打架。 提高引用可回溯率会让模型多读文档,步数和成本随之上升;收紧步数上限又会让长轨迹失败。没有哪个版本能同时把三个指标都压到最优——对比实验的目的不是找到"全指标最优"的版本,而是找到"在可接受的成本下成功率最高"的版本。这也是为什么最后要由人来拍板:评测把账算清楚,权衡由业务来做。
16.3.7 本节小结
- 对比实验回答"这一版比上一版好还是差",是判断改动值不值得合并的唯一可靠办法。
- 三条前提:控制变量、固定评测集、多指标并看。
max_steps实验说明"没看到变化"往往是"没改到位",要靠阶梯取值;同时提醒成功率和成本此消彼长。- 提示约束实验说明离线评测重放的是"录下来的行为",加约束能补上引用可回溯率,但要多付步数与成本。
- 归因成立的前提是一次只改一个变量;评测集一旦定版就要像代码一样版本化。
- 常见陷阱里最隐蔽的是"用测试集调参",要靠调参集/留出集分离来防。
16.3.8 真实模型下会怎样
真实模型下,“换提示词"的效果由模型自己产生,不再需要手写两版脚本——你只需改 system 字符串,真实模型的行为变化就会反映到评测分数上。但随之而来两个新问题:
其一,真实模型的输出有随机性,同一配置跑两次分数可能不同。所以要区分"真差异"和"噪声”:要么把温度调到 0(仍不完全确定),要么对每条用例跑多次取平均,要么把评测集做大到噪声被淹没。别拿一次运行的小数点后第二位做决策。
其二,真实实验的成本高得多。上一节那种"平均成本 0.001 元"是假模型下的估算;真实模型跑几十上百条用例,是要真金白银的。所以工程上常见的做法是两段式:先在假模型/小评测集上快速筛掉明显变差的改动,只在候选版本上跑真实模型的大评测集。
两段式的工作流大致是这样:
| 阶段 | 后端 | 评测集规模 | 跑的频率 | 作用 |
|---|---|---|---|---|
| 快筛 | 假模型 | 十几条 | 每次提交 | 拦住明显变差的改动 |
| 验收 | 真实模型 | 上百条 | 发布前一次 | 确认真实收益 |
第一段便宜到可以无脑每次跑,第二段昂贵但可信。关键在于两段用的是同一套 Case 与 Scorer——只是 model_factory 从"造 FakeModel“换成"造真实适配器”。如果两段各写各的评测逻辑,那第二段的结论就没法跟第一段对齐了。
一句话:对比实验的骨架(控制变量、固定评测集、多指标)与真假模型无关,变的只是"行为从哪来"和"代价有多大"。这也是把评测能力建在 minagent 内部的回报——同一套对比流程,假模型下当回归网天天跑,真模型下当决策依据偶尔跑。
最后提醒一句:别让对比实验变成形式主义。它的价值在于"让决策有依据",而不是"生成一堆漂亮的表格"。如果一次改动的结论是"成功率和成本都没明显变化",那就如实写"无显著差异",而不是硬凑一个"提升了 0.3%“的结论去邀功。诚实,是评测体系里最贵也最容易被牺牲的东西。
到这里,minagent 有了完整的评测能力。下一章 17.1 库的对外接口 会把这一路长大的引擎整理成稳定的公开接口。想回看评测集怎么搭,翻 16.2 评测集与打分 。
阅读导航:上一节:16.2 评测集与打分 · 下一节:17.1 库的对外接口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。