TL;DR: Agent 应用「demo 阶段永远是对的,上线就开始崩」,原因是没人定义过「对」。评测要分三层:最终答案、执行轨迹、工具调用参数。LangGraph 独有的优势是 checkpoint 回放可以当回归工具用:从同一张快照出发换模型、换 prompt 重跑,diff 出行为变化。工具选型上 LangSmith 最顺但有绑定和账单,DeepEval、Promptfoo 是可用的开源替代。
先看一次没有评测的上线
假设团队改了一版 system prompt,目的是让客服 agent 的语气更正式。改完在本地试了五个问题,回答都挺好,上线。三天后客服主管找过来:语气确实正式了,但退款类问题的处理开始出错,有的该查订单没查就答复,有的把退款金额算错。你把这周的回答翻出来对比,结论是「有的变好了,有的变坏了」,说不清整体是变好还是变坏,也说不清下一个改动会碰坏什么。
这就是没有评测的典型状态:demo 阶段「永远是对的」,因为「对」由在场的几双眼睛临时定义,样本小到看不出分布;上线之后没人盯着,而「对」从来没有被定义过。prompt、模型版本、图结构任何一个字动了,行为分布整体移动,变好的和变坏的一起动,靠人眼分不出来。评测体系要解决的就是这件事:把「对」变成机器能检查的断言,让每次改动都有回滚的依据。
传统软件的回归测试在这里只解决了一半问题。代码逻辑的回归,单测能抓住;agent 的行为回归发生在代码完全没变的地方:换了个模型版本,模型开始走另一条路径;改了一个措辞,模型对某类输入的理解整体偏移。输入相同、输出不同,且两边都不能算 bug,这才是 agent 评测和传统测试的分界线,也是它必须单独建一套的原因。
「我们每次都人工测过」为什么不够,值得说透。第一,覆盖:人测十个问题,线上进来的是一万种问法,行为回归往往藏在第 37 种问法里。第二,注意力:人工点检前五个认真,后五个敷衍,这个衰减没有记录也查不到。第三,组合爆炸:prompt、模型、工具描述、图结构四个变量,每个改动都要和存量行为交叉验证,人力测不完交叉项。人工的价值在抽检和校准(后面答案层会讲),不在全量把关,全量把关只能靠机器断言。
评测的时机有三个:提交前(门禁)、上线前(验证)、上线后(监控)。这篇文章覆盖前两个,第三个主要靠 trace 和指标告警,第 14 篇的观测体系加上 LangSmith 一类的线上监控承接,文末只做衔接。
先定义「对」:三层评测对象
「agent 回答错了」没法进 CI。把它拆成可断言的三层,三层的检查手段、成本和稳定性都不同。
- 轨迹层(最重要)。 执行路径对不对:该调工具的时候调了吗?走的哪个分支?循环了几次?轨迹是因,答案是果。轨迹错但答案碰巧对的用例,是上线后最不可控的那类:这次的「对」是运气,输入稍微一变就翻车,而且翻车的方式是静默的,答案看着像模像样,底下根本没有数据支撑。比如一个退款问题,正确路径是先查订单再查退款政策,模型却直接凭问题里的数字编了一个答案,答案恰好合理,评审的人挑不出毛病,直到某天订单里的数字和用户口述的不一致。
- 工具参数层。 调对了工具、传错了参数,等价于错,甚至更隐蔽:轨迹看起来完全正确,错在下游看不到的地方。比如退款工具调了,但金额传了商品原价,没扣已使用的优惠券;或者日期传成了字符串
next Friday而不是解析过的 ISO 格式,工具没报错,查的是错误的时间窗。参数是结构化的,能精确断言,是性价比最高的评测层。 - 答案层。 最终输出是否正确、格式是否合规。文本答案的判定需要 LLM 当裁判(LLM-as-judge)或人工抽检,而裁判本身不稳定、也贵,所以成本最高,放最外层。
排序就是投入顺序:先把轨迹和参数断言建起来(纯代码,确定性,跑得快),答案层只对核心场景配裁判模型。这个顺序背后是确定性递减:轨迹断言是普通代码,参数断言是普通代码,答案判定引入了另一个模型的不确定性。先把便宜的、稳定的地基打厚,再用贵且抖的手段补最后一层,反过来做会先花掉预算还得不到可信的信号。还要纠正一个常见误解:三层不是流程里的三个步骤,一个用例跑一遍就同时产出三份数据(轨迹、参数、答案),断言各自取用。分层是断言的组织方式,不是执行方式。
有一类指标不属于这三层,提前划出去免得混进评测集:延迟、token 成本、成功率这类运营数据。它们重要,但它们是监控指标,看分布和趋势,不是对一个具体行为判对错的断言。评测集回答「这个行为对不对」,监控回答「整体健不健康」,两套仪表盘,混在一起两边都看不清。
前两层是纯代码断言,写法上就是把结构抽出来比对:
def tool_sequence(result):
# 从最终 state 的 messages 里抽出工具调用序列
return [c["name"] for m in result["messages"]
for c in (m.get("tool_calls") or [])]
seq = tool_sequence(new_result)
assert seq == ["query_order", "get_policy", "refund"] # 轨迹断言
calls = collect_tool_calls(new_result)
assert calls["refund"]["amount"] == 8800 # 参数关键字段断言
答案层也不全是裁判的活。格式类的要求先走代码:输出是合法 JSON、必填字段齐全、枚举值在白名单里,这些用 schema 校验又快又稳,根本不用惊动裁判模型。裁判真正接手的是语义类的要求:事实对不对、口径是否一致、语气是否符合产品要求。一条答案先进代码校验再进裁判,能省下一大笔裁判调用的钱。
把三层放在同一个例子里看一遍,分工立刻清楚。输入是用户的一句「我的订单 O1234 退款到账了吗」。轨迹层的断言:先调 query_order,再调 get_refund_status,没有多余工具,没有绕圈。参数层的断言:query_order 收到的 order_id 是从消息里正确抽取的 O1234,不是用户上文的另一个单号;get_refund_status 的时间参数是解析过的日期而不是原始文本。答案层的断言:回复里引用的到账时间与工具返回一致,金额格式符合口径,没有承诺系统做不到的到账日。三层各自能抓住的错互不重叠:轨迹层抓「编造路径」,参数层抓「路径对但数据错」,答案层抓「都对但说得不对」。上线后的事故基本都能溯源到其中一层的断言缺口。
起步不用一步到位。第一周只做一件事:挑最痛的一条业务线,给它建轨迹断言加录制用例,让它进 CI。这一条线上的行为回归从此有了报警,团队对评测的信任从这条线攒起来,再横向铺开。一上来就想给全系统建评测的团队,通常在第三周维护成本爆发后放弃。
写裁判时给三个建议:逐项打分(事实、格式、口径各一列),不要一句「这个回答好吗」;评分尺定义清楚(什么算 1 分什么算 5 分,各给一个例子);核心用例配人工校准,定期抽人工复核裁判的打分,裁判漂移了要及时发现。人工抽检也讲抽样策略:随机抽样看整体,高风险类目(涉钱、涉删除)全检,两个桶分开。抽检发现的争议样本别扔,回流进裁判的评分尺示例里,人工判断慢慢就沉淀成了机器可执行的协议。裁判协议更深的坑(分数漂移、用例泄漏)在仓库的评测专题里有展开,这里不重复。
LangGraph 独有的武器:回放当回归
一般框架做回归,输入相同、执行路径靠运气:同样的输入今天走三个节点,明天模型兴许只走两个。LangGraph 的 checkpoint 让「从执行中间的某一点开始重跑」成为原生能力:
# 从历史快照出发,用新版本的图重跑,对比新旧轨迹
config = {"configurable": {"thread_id": "case-17",
"checkpoint_id": snapshot_id}}
new_result = new_app.invoke(None, config)
old_result = load_recorded("case-17.json") # 上次记录的轨迹与答案
diff_traj(old_result, new_result)
invoke(None, config) 的语义是:不提供新输入,从指定的 checkpoint 恢复,继续往下跑。第 03 篇讲过快照记录了「下一步要跑什么」,所以恢复点是精确的,不是近似重启。这是回放回归能成立的机制基础:对比的起点是同一个字节级的状态,跑出来的差异只能来自你这边的改动(prompt、模型、图结构),归因是干净的。
具体做法分录制和对比两步。录制阶段,每个评测用例跑一遍,把最终状态和轨迹存档。存档格式不用复杂,但有几个字段不能省:
record = {
"name": "refund-with-coupon", # 用例名,说清业务场景
"thread_id": "case-17",
"snapshot_id": snapshot_id, # 重跑的恢复点
"versions": {"commit": GIT_SHA, "model": MODEL,
"prompt": PROMPT_VERSION}, # 没有版本信息的用例会过期成废纸
"trajectory": tool_sequence(result), # 期望轨迹
"tool_mocks": {"query_order": ORDER_FIXTURE}, # 冻结外部世界的返回
}
版本信息是最容易被省略也最不该省的:录制用例是针对某个行为版本录的,两个月后没人说得清它是哪个版本,对比结论就废了。存档放哪也有讲究:几十条以内的用例,直接进代码仓库,跟着代码走版本控制,评审时 diff 看得见;规模大了或快照数据体积大了,数据挪对象存储、仓库里只留索引和期望轨迹。命名用业务语义(refund-with-coupon 而不是 case-17),用例名是人读的文档。回归阶段,每次改完 prompt、模型或图结构,从存档快照重跑,对比轨迹差异。模型行为不确定导致答案可能不同,但轨迹差异能告诉你「新版本多绕了一圈」「换了个工具」「新增了一次循环」,这正是回归要抓的。
从哪张快照出发有讲究。只改了某几个节点,就从受影响路径之前最近的快照出发,前面没变的部分不用重跑,省时省钱,diff 也更干净;图结构大改或换了 thread 级的配置,就从头录制。判断「最近的快照」不用精确到字节,实用做法是录制时按业务节点边界打标(「政策已查明」「订单已核实」),重跑时挑改动点上游最近的那个标记。diff 的对齐也有个小坑:新旧版本节点数不同时,按执行位置对齐会整体错位,按节点名对齐,新增的节点单独标注「新增」,删掉的节点标注「未出现」,diff 才读得懂。
回放的另一个用法是对比实验:同一个用例集,分别用旧模型和新模型从相同快照跑一遍,轨迹 diff 直接量化「升级模型会改变多少执行行为」。这比看评测榜单可靠得多,榜单测的是通用能力,回放测的是你的任务上的行为变化,两者经常不一致。
这套机制别的框架给不了,是 LangGraph 用户最该占的便宜。它有一个盲区要提前处理:从中间快照重跑时,上游真实世界的状态可能已经变了。录制那天库存还有 12 件,重跑那天卖光了,工具返回不同,轨迹自然不同,这种「假红」会和真正的回归混在一起。对策就是上面字段里的 tool_mocks:录制时存下工具的返回,重跑时直接喂存档值,只让被测的编排和 prompt 变化,外部世界冻结。代价是 mock 层要跟着真实接口维护,这笔账在权衡一节算。
还有一句要说的实话:录制回放是 LangGraph 的原生能力,和用哪家评测工具正交。用 LangSmith 的数据集和实验功能做评分的团队,录制回放照样成立,两者一个管「从哪重跑」,一个管「跑完怎么打分」。
接入日常开发流的姿势要轻:把「拉录制用例、重跑、diff」包成一条本地命令,开发者改完图自己在分支上先跑一遍,红了当场看 diff,不用等 CI 排队再回来说「是不是环境问题」。本地命令和 CI 用同一套用例文件,区别只在规模(本地跑改动相关的十几条,CI 跑全集)。归因干净带来的隐性收益也值得说:有了行为级 diff,重构图结构的心理成本大幅下降,敢把一个臃肿节点拆成三个,因为拆完跑一遍就知道行为没变。没有这层保障的团队,图结构会越来越不敢动,烂在原地。
工具选型:绑定与账单要摆在台面上
LangSmith:和数据集、trace、线上监控是一体的,做 LLM-as-judge 和轨迹评分的配套最全。两个代价:闭源 SaaS,trace 数据出你的环境;收费(按席位加用量,价格以官方页为准)。适合的团队画像:已经在用它收 trace,评测顺手就做了,是最短路径;对数据出境没有硬约束。
DeepEval:开源,agent 评测指标齐全(任务完成、参数正确性),可进 CI。适合要把评测留在自己环境里、又不想从零写指标的团队。
Promptfoo:开源 CLI,适合 prompt 回归、红队用例和 CI 集成,配置是声明式的。改动主要发生在 prompt 层的团队,它的投入产出比最高。
不绑定任何一家的组合也完全成立:录制回放自己写(两百行以内,核心就是「存快照、带版本、重跑、比对」四个动作),轨迹断言用 pytest,裁判模型只在答案层用。这个组合的维护成本在三件事上:工具返回格式变了要更新 mock,state 结构变了要更新录制,裁判 prompt 变了要重校准。选型时把维护成本和许可费放一起看,别只比价格。
评测跑在 CI 里还有一笔容易漏的账:评测集每跑一遍都是真实的模型调用。几十条用例、每条几步到十几步,每次提交都跑,一个月下来是一笔固定的 token 开销;裁判模型的调用更贵。控制手段有三个:门禁层只放确定性断言和录制回放(回放的大半步数被快照截断,比全量跑便宜),裁判挪到每日定时,回放用例的恢复点尽量选靠后的快照。把评测当成一笔要管理的云开销,而不是「测试嘛反正要跑」。
选型收拢成一张表:
| 你的情况 | 更顺手的起点 |
|---|---|
| 已在用 LangSmith 收 trace,无数据出境顾虑 | 直接用它的数据集与评分 |
| 数据要留在自己环境,想省指标开发 | DeepEval |
| 改动集中在 prompt 层,要红队与回归 | Promptfoo |
| 需求就是轨迹回归本身 | 自建录制回放加 pytest,别引依赖 |
和第 01 篇的选型表同一个读法:每一行的成立条件就是左边那句话,条件变了结论就变。本仓库的 评测专题 有模型、RAG、Agent 各维度的评测方法地图,评测协议的坑(裁判不稳定、用例泄漏)在那里有展开,不重复。
进 CI 的最小方案
每次提交跑得起的版本,四层各配预算:
- 节点单测 + 路由断言(毫秒级,必过,红了一票否决)。
- 假模型跑核心轨迹用例(秒级,覆盖拓扑和分支,同样做门禁)。
- 固定 20 到 50 条录制用例回放,只断言轨迹和参数(分钟级,做门禁但允许白名单豁免)。
- 答案层 LLM 裁判不进每次提交,每天定时跑,输出趋势而不是门禁。
第 4 条单独解释一下为什么不做门禁:裁判模型的分数有抖动,今天 4.2 明天 4.0,门禁会把这种噪声变成随机的合并失败,一周之后团队的习惯就是无脑重试再合并,门禁的信用就破产了。趋势的意义是看方向,分数连续三天下滑才值得人工介入。门禁留给确定性断言,趋势留给统计性断言,这条分工是整个 CI 方案里最重要的决定。
门禁失败了怎么办,也要有纪律。录制回放红了,先看 diff 是不是预期的行为变化:是(这次改动本来就要改行为),走白名单豁免,豁免必须带 issue 号和失效时间,下次评审时逐条过;不是,就是真回归,修代码。没有豁免纪律的白名单会膨胀成「永久红名单」,门禁形同虚设。第 2 层的假模型剧本也有一个同步问题:工具的真实返回格式变了,剧本还是旧格式,图集成测试照常通过,保护却在悄悄失效。对策是给工具的契约测试留一个「格式对齐」断言(真实接口的样例返回能通过剧本的 schema),让剧本和现实脱钩这件事自己会红。
先有第 1、2 条的团队很多,卡在第 3 条:录制用例从哪来、怎么够用。凭空设计用例又慢又假,实用的三个来源按优先级排:线上失败(每次事故复盘固化一条,它记录的是系统真实跌过的坑)、核心业务路径(每类主要业务动作至少一条)、分支与边界(图里每个条件分支至少一条走到、一类典型边界输入一条)。按这个矩阵填,20 条上下就能盖住大部分回归面,不需要凑大数。
各层信号该不该阻断合并,收拢成一张表:
| 层 | 断言类型 | CI 里的角色 | 失败的含义 |
|---|---|---|---|
| 节点单测 | 普通代码断言 | 门禁,一票否决 | 代码写错了 |
| 假模型轨迹 | 确定性断言 | 门禁,一票否决 | 编排逻辑错了 |
| 录制回放 | 轨迹与参数 diff | 门禁,可豁免 | 行为变了,要人工判定是否预期 |
| 答案层裁判 | 统计性评分 | 每日趋势 | 行为质量在漂移 |
表的读法:越往下越接近用户感受到的「好」,也越不确定。门禁的信用靠确定性维持,把不确定的断言放进门禁,等于教团队无视门禁。
一次改动的完整走查
把全文的工具串成一条时间线。假设这次需求是「让退款回答带上政策依据链接」。开发者改了答案节点的 prompt,提交。CI 第 1、2 层秒级通过:代码没写错,拓扑没变。第 3 层红了:三条退款相关录制用例的轨迹 diff 显示,新版本在答案节点前多了一次 get_policy 调用。看 diff 判定:这正是本次需求要的行为变化,走白名单豁免,issue 号写明「政策链接需求」,失效时间定在需求上线一周后。合并,上线。第二天每日裁判的趋势里,退款类用例的「口径一致」分上升,其余类目持平,需求的效果有了数字确认。第三周评审豁免清单,把三条用例的期望轨迹更新成新行为,豁免转正,评测集完成了一次跟着产品走的进化。整个闭环里没有一步靠「我觉得还行」,每个决定都有断言或 diff 支撑。走查里没出现的一种情况也提前交代:diff 显示了非预期变化,本地却复现不出来。这时候别在重跑上耗,直接回到第 14 篇的手册:把出事用例的快照拉出来逐边界比对,非预期变化总有产生它的第一个边界,找到了它就退化成一个普通 bug。评测集和观测体系在这个时刻是同一套基础设施的两面。
权衡
评测体系最大的成本不是工具,是用例的持续维护。轨迹断言写得太死(逐条消息比对),模型一升级全线红;写得太松(只看最终状态),bug 全漏。经验值是断言「关键决策点」:调了哪些工具、走了哪些分支、参数里的关键字段,消息措辞不比。判断一个断言该不该存在的标准:它失败的时候,你会不会立刻知道哪里坏了。会,就留着;不会,它只是未来某天的假警报。用例数量同理:不是越多越好,每条用例都是一笔持续维护的债,盖住业务动作矩阵之后新增的用例,边际收益递减很快,加之前先问它记录的是哪一类别的失败。
用例集本身也会腐烂:产品改版后,老用例断言的行为已经不是产品要的行为,评测集的红不是代码的红。所以用例要定期评审,和产品变更挂钩:需求文档里改了退款流程,退款相关的录制用例同一次提交里更新。评测集的维护责任要落到人,通常是谁改的图谁更新用例,放成「大家共同维护」等于没人维护。
回放回归的盲区再强调一次:外部世界会变(库存、汇率、政策文档),录制环境必须 mock 掉这类依赖,否则回归集永远假红,假红多了真红就被淹没。mock 的更新成本是真实存在的,把它算进评测体系的账里。
评测数据本身的敏感性别忽略。录制用例存的是真实失败 thread 的快照,里面可能有用户个人信息、订单数据,评测集放在代码仓库里要脱敏(换 id、抹手机号),存放位置按第 16 篇对敏感数据的标准处理,不然评测体系自己先成了合规问题。
上线前的最后一道验证可以加一层灰度对照:新版本先放百分之几的线上流量,定时采样两侧的轨迹做 diff,看真实分布下的行为差异有没有超出评测集预测的范围。评测集是实验室,灰度是现场,实验室过了现场再确认一次,成本不高,能接住评测集没覆盖的长尾问法。真出问题,快照和回放就是现成的取证工具,又绕回第 14 篇的排查手册。
职责分工也提前定好,评测体系才活得久:改图的人在同一次提交里负责更新受影响的用例,这是写代码的一部分,不是额外劳动;每日趋势指定一个人看,轮值也行,但要有名字;豁免清单的评审归技术负责人,每周一次走完豁免的转正或销毁。三件事都有名字,评测集就不会烂;任何一件没名字,三个月后你会发现评测集最后一次更新停在某个离职同事的最后一份提交。
如果团队想把这套东西用得更主动,还有一步可选的姿态:评测驱动。改图之前先把新行为的轨迹断言写好(预期多一次工具调用、预期某个分支不再走到),再动 prompt 和结构,断言从红变绿就是完成。它把评测集从「验收工具」变成「设计工具」,适合同一个团队长期迭代自己系统的场景;一次性项目不必强求。
最后对齐一下本篇和第 14 篇的分工:那一篇的测试分层解决「这次提交有没有写错」,这一篇的评测回归解决「这次改动有没有让行为变坏」。前者对代码,后者对行为,两套都有的团队才同时拥有发版的胆量和依据。
延伸阅读
- 本仓库评测专题:模型 / RAG / Agent 评测方法地图
- LangSmith Evaluation:trajectory evaluation 官方文档
- DeepEval Agents:开源 agent 评测指标
- Use Time Travel 指南:回放机制的操作细节