AsyncK
全部笔记

Agent 评测的三层地基:可复现、可归因、可回归

把评测当成一次性的跑分,是 Agent 项目里最常见的自欺。这篇文章拆开三层地基,以及每一层最少需要哪些工程约束。

一个 Agent 项目最容易死的地方,不是第一次演示,而是第三次运行。

第一次跑通靠的是运气和新鲜感:模型状态好、工具没超时、用户输入刚好落在模型擅长的那一类。第二次跑通靠的是记忆。第三次开始,输入稍微一变,行为就漂了,而团队里没有人能说清楚漂在哪里——因为从一开始,就没有任何东西被固定下来。

于是“评测”变成了一个尴尬的词。它听起来像是给模型打分,实际上绝大多数团队需要的是另一件事:把一次 Agent 行为变成可以被重复观察的对象。打分只是最后一步,甚至不是最重要的一步。

我把这件事拆成三层。三层都成立,评测才有意义;任何一层缺失,上面那层就是在给噪声做统计。

第一层:可复现

可复现不是“再跑一次结果差不多”,而是“再跑一次,能够确定差异来自哪里”。这两件事差别很大。

真正需要固定的是四样东西:环境、输入、工具、时间。

环境包括模型版本、系统提示词、工具 schema、甚至依赖库的版本。听起来琐碎,但“上周还好好的”这句话,九成来自某个没被记录的隐式变更。系统提示词尤其危险:它常常散落在三四个文件里,被字符串拼接出来,没人知道线上跑的是哪一版。

输入包括种子和顺序。LLM 的采样不是确定性的,temperature 设成 0 也不保证逐字一致——不同批次的 kernel、不同的并行度都会让结果抖动。所以不要追求“完全一样”,要追求“差异可解释”:把 seed、temperature、top_p、max_tokens 全部写进用例记录,让每次运行都携带自己的指纹。

工具是最大的不确定源。同一个搜索接口,今天返回十条,明天返回八条;同一张订单表,昨天的状态和今天不同。评测里的工具必须是幂等且可回放的:要么打桩(stub)成固定返回,要么录制真实响应后回放。录制回放还有一个额外好处——它把“工具挂了”和“模型不会用工具”这两类失败彻底分开,而这恰恰是最难分清的一对。

时间要冻结。任何 today()、now()、相对时间表达(“最近三天”)都会让用例在某个时刻悄悄失效。冻结时间不是洁癖,是让失败可复现的最低成本手段。

一份最小可用的用例记录大概长这样:

case_id: refund-flow-014
input: "我上个月的订单还没退款,帮我查一下"
frozen_time: 2026-09-30T10:00:00+08:00
model: <provider>/<model>@<version>
prompt_rev: 7f3c1a
tools:
  - name: order.lookup
    mode: replay            # replay | stub | live
    fixture: fixtures/order-8821.json
  - name: refund.create
    mode: stub
    response: { ok: true, refund_id: "rf_0001" }
assertions:
  - calls: [order.lookup, refund.create]
  - refund.create.args.amount == order.lookup.result.paid_amount
  - final_answer.contains("3-5 个工作日")

注意 assertions 里没有一条是“回答听起来不错”。这不是巧合——可复现的层里不该出现主观判断,主观判断属于最后一层。

第二层:可归因

归因的意思是:一次失败发生之后,能在十分钟内回答“是哪一环坏了”。

大多数团队的做法是打印整段对话。这在单轮任务里够用,在多步 Agent 里几乎无用:一次任务可能产生几十次工具调用,日志被压成一个字符串,失败点在中间某处,找起来像在大海捞针。

有效的做法是给每一次运行建立 trace:把一次用户请求当成根 span,把每次模型调用、每次工具调用、每次重试都当成子 span,每个 span 携带自己的输入、输出、耗时和状态。这不需要引入昂贵的可观测性平台,一个 JSONL 文件就够,只要结构固定。

然后定义失败分类。这是最被低估的一步。没有分类,“通过率 62%“只是一句废话;有了分类,你能看出 62% 里有多少是产品问题、多少是评测集问题。

类别 典型症状 归属
路由错 该查订单却去查物流 提示词 / 工具描述
参数错 订单号格式对,但把用户 ID 当订单号 工具 schema
工具错 接口 500、超时、返回结构变了 外部依赖
编排错 该重试没重试,该停止却继续 控制流
越权 调用了不该调用的高危工具 权限设计
表达错 事实都对,但措辞引发误解 生成层

分类的价值在于它把“模型不行”这个无法行动的结论,拆成了五六个可以分派的工单。一个团队如果每次失败都只能得出“模型不行”,那它其实还没有评测能力。

还有一条经验:先看工具,再看模型。我在实际项目里见过的失败,超过一半最终落在工具描述含糊、返回结构不稳定、错误信息缺失这三件事上,而不是模型能力。工具的报错信息写清楚“参数 order_id 需要 18 位纯数字”,比换一个更大的模型有效得多。

第三层:可回归

前两层是静态的,第三层是动态的:每次改动都跑,跑完能判断“这次改动是不是让某些东西变差了”。

回归的第一原则是看分布,不看均值。通过率从 74% 涨到 76% 可能毫无意义,也可能意味着某一类任务从 100% 掉到了 40% 而另一类补了回来。按类别、按难度、按工具组合切片,才能看见结构。

第二原则是把成本和延迟当成一等指标。一个通过率 90%、平均 14 步、每步都重试两次的 Agent,在实际产品里不如通过率 85%、平均 5 步的那个。评测报告里如果只有准确率,团队就会不自觉地用更长的推理链去换分数,最后换来一个又慢又贵的系统。

第三原则是认真对待 flaky。同一个用例在同样的输入下时好时坏,说明这个用例本身在测一个不稳定的东西。处理方式只有三种:修好它、标记它、删掉它。最糟的是第四种——留着它,然后每次看报告时手动跳过。

一段最小回归流程:

  1. 改动前跑一次基线,记录每个切片的通过率和成本。
  2. 改动后跑同一批用例,只对比差异。
  3. 差异为负的切片,逐个看 trace,判断是回归还是用例本身有问题。
  4. 用例本身有问题的,当天修;修不了的,标注 known_flaky 并写清原因。
  5. 每次线上事故,反向补一条用例。这条用例的存在,比任何复盘文档都长久。

第五步是整套流程里唯一会产生复利的地方。事故是用真实用户输入的、免费的、高价值的用例来源,但绝大多数团队在复盘会上写了几页 PPT,然后什么都没留下。

三层之外:评测集是资产

写到这里,我想说一个更根本的判断。

评测集不是脚本,是资产。脚本可以重写,资产需要养护。它会随着产品演化不断陈旧:工具的返回结构变了、业务规则变了、用户说话的方式变了。一个三个月没更新的评测集,通过率再高也只说明系统还停留在三个月前。

养护的成本主要在标注,而不是运行。所以设计用例时,要尽可能让“正确答案”是可判定的:金额能比对、调用序列能比对、最终状态能比对。凡是必须靠人读一遍才能判断的,都应该单独归类,并且明确接受它无法自动化——假装它能自动化的代价,是团队开始相信一个自己并不相信的数字。

最后一句话,留给那些正在被“我们要不要上评测”困扰的团队:如果你现在无法回答“上次改提示词之后,哪一类任务变差了”,那你需要的不是更贵的模型,而是一份能跑第二遍的东西。