《AI Agent 开发入门》16.3 对比实验

本节把 minagent 推进到对比实验:在同一套评测集上改一个变量看分数怎么动——把 max_steps 从 8 收到 2、在提示里加一句引用约束,各跑一遍并输出成功率、平均步数、平均成本的对比表。核心是控制变量、固定评测集、多指标并看。

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_stepPASSPASS不受影响
calc_singlePASSPASS不受影响
search_plainFAIL(引用 0.00)PASS(引用 1.00)引用补上
search_then_readPASSPASS不受影响
wrong_toolFAIL(工具 0.00)FAIL(工具 0.00)不受影响
search_no_citeFAIL(引用 0.00)PASS(引用 1.00)引用补上

涨的分全部来自"引用可回溯"这一项——两条检索用例从 FAIL 翻成 PASS,其余用例纹丝不动;wrong_tool 那条仍然失败,因为引用约束治不了"调错工具"。归因清晰,是因为只动了一个变量。 如果这一版同时改了提示词又改了步数上限,上面这句"涨的分全部来自引用"就说不出口了——你无法证明是提示词的功劳还是步数的功劳。这就是 16.3.1 第一条规矩的威力。

16.3.4 怎么看这两张表

把两个实验放一起,能提炼出读对比表的一般方法:

看什么实验一(步数)实验二(引用约束)结论
成功率50 → 50 → 16.750 → 83.3涨是目的
平均步数2.3 → 2.3 → 1.32.3 → 2.7代价之一
平均成本不变 → 0.000440.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 库的对外接口 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距