用录制轨迹做回放测试。
你的评测量的是模型,你的单元测试量的是代码,而它们谁都不会注意到:一次重构调整了工具 schema 的顺序,于是智能体现在到第四步才去认证,而不是第一步。回放是抓住这一类问题最便宜的闸门——拿昨天真实跑过的轨迹去跑今天的构建——而它带着一条在你把套件建起来之前没人会告诉你的硬限制:录制把世界钉死了,所以一旦智能体做出了与录制不同的第一个动作,回放此后什么都证明不了。围着这条限制去设计,回放就成了智能体技术栈一直缺的那个回归测试;无视它,你得到的是一座会腐烂的夹具库,失败的理由与你的改动毫无关系。
回放是什么,以及它不是什么。
一次回放测试取一段录制好的运行——目标、初始状态、每一次工具调用及其参数、每一个工具响应、模型的每一次输出——然后让智能体对着那些录制下来的响应重新执行,而不是对着真实系统。没有网络、不占第三方配额、除模型调用外没有额外成本,而且每次输入都一样。
值得把它买到的东西说准,因为有三种相邻的做法常被混为一谈:
- 评测是在一组任务上给结果打分,它回答「这个智能体好不好?」。回放回答的是「智能体的行为变了没有?」——一个不同得多、也便宜得多的问题,而且你可以在每个 pull request 上都问一遍。
- 影子运行用的是真实生产流量、对着真实状态。回放是封闭且可重复的,影子两者都不是——而这恰恰是影子能看见分布、回放看不见的原因。
- 预发布环境给智能体一个真实但虚构的世界,带着真实的状态迁移。那是重量级选项;回放是当预发布慢到坐不进 CI 时你跑的那个。
回放独一份擅长的,是那套「外壳」:提示词组装、工具注册、schema 序列化、截断与压实逻辑、重试与超时处理、工具结果解析、顺序约束。这些代码每周都在变,对你的评测套件不可见,而它们弄坏智能体的方式,看起来像是模型退化。
分岔问题,而它就是全部设计。
回放的工作方式,是用一份录制来应答工具调用,通常以「工具名加规范化后的参数」为键。当智能体第一次调用录制里没有的东西时,你就撞上一次未命中;而你如何处理它,定义了你这个测试究竟证明了什么。选项只有三个,而每一个都是一件不同的仪器:
- 未命中即失败(严格回放)。任何偏离录制轨迹的行为都判定为测试失败。这是一台灵敏度拉满的变更探测器:它抓得住 schema 顺序被调整,也会在有人改好了一段提示词时照样报警。把它用在一小组精心挑出的场景上——在那些场景里,轨迹就是契约。
- 落空则打到线上。未录制的调用打到真实系统。此时测试既不封闭也不可重复,它可能改动真实状态,而且会因为别人的可用性而或过或败。在 CI 里这几乎总是错的答案;偶尔适合做一个每晚跑一次的哨兵。
- 由契约夹具应答。一个工具的假实现按该工具的契约来服务这次未命中,而不是按一份实录。此时智能体可以合理地偏离,并且仍然能被评判。这是能扩展的那个版本,而 STEP 3 讲的就是为它付出的代价。
别让某个框架悄悄替你做这个选择。从 HTTP 测试借来的录制回放库默认采用这三种行为之一,而默认往往是「打到线上」或者「合成一个看着合理的」。在回放框架里伪造出来的工具响应,会为一次根本不可能发生的运行亮起绿灯,那比没有测试更糟。
分两层,因为一个套件干不了这两份活。
想让同一个回放套件既当变更探测器又当行为测试,正是团队最后攒出三百个没人信得过的夹具的原因。把它拆开。
第一层——钉死的轨迹。二十到五十段录制,严格、快,在每个 pull request 上跑。断言针对的是这次运行的形状,而不是那些散文:调用了哪些工具、参数上的哪些不变量、顺序是否满足真正要紧的约束。一组好用的断言长这样:
assert trace.tools_called[0].name == "authenticate" assert "delete_record" not in trace.tool_names # never in this scenario assert trace.step_count <= 12 # compaction still working assert trace.tokens_in <= budget * 1.15 # prompt didn't silently grow assert order_before(trace, "fetch_policy", "issue_refund") assert trace.final.schema_valid
第二层——契约夹具。一个带真实状态的假工具服务:写入对后续读取可见、幂等键的行为真像个幂等键、错误可以按需注入。它建起来更慢,而且是唯一能回答「智能体是不是换了条路径照样把任务办成了」的版本——一旦有人开始改进提示词,这就是要紧的那个问题。让它每晚跑,按结果而不是按轨迹打分,并把它当作过程评估安家的地方。
第一层是一份 diff,第二层才是一个测试。跳过第一层的团队会漏掉管线级的退化;只有第一层的团队则会在一个季度内把它关掉,因为每一次真正的改进都会让它变红。
夹具腐烂是持续成本,也是那个失败模式。
一份录制,是别人家 API 在某个星期二的照片。API 会往前走,你的录制不会。半年之后,危险的状态是:一个整体全绿的套件,跑的却是任何真实系统都不会再返回的响应——一次对已经不复存在的世界的回放,而这正是第三方工具漂移无声无息抵达生产环境的路径。
- 给每份录制盖章。提供方、API 版本、采集日期,以及采集时所用的模型与外壳版本。一份没有来源信息的夹具无法被判定为过期,于是它永远不会被判定为过期。
- 按时钟过期。超过设定年龄的录制让构建以「陈旧」失败,而不是以「错误」失败。这把一种看不见的腐烂,变成了一件排好期的杂活——而只要做得勤,这件杂活就很小。
- 每个外部工具每晚跑一次线上一致性检查。不是整个套件,而是每个工具一次调用,断言响应仍然符合你的夹具所假定的形状。就是这一个任务,让另外四百个保持诚实。
- 从生产环境重新录制,别手写。手工编辑的夹具编码的是作者以为这个 API 会怎么做。请从真实轨迹里去收割替换件,而那也正是评测集维护取材的同一个语料库。
- 在采集时就做脱敏。录制是生产数据,工具响应里含着客户内容,而它们最终会躺进一个每位工程师都能读的 git 仓库。脱敏属于采集路径,不属于某个清理脚本——见在智能体追踪中脱敏 PII。
对效果做断言,不要对文本做断言。
回放并不能让模型变得确定。即便温度为零,输出也会随批处理、内核版本、量化方式与提供方侧的路由而变化,而可复现性解释了为什么追求逐比特相同的生成是一场必输的差事。所以任何针对模型散文的断言,都是一个会在一个月内被删掉的脆弱测试——而且删得对。
要断言的应该是系统真正控制得了的东西:调用了哪些工具、参数是什么、顺序如何;走了多少步;输入了多少 token;本该发生哪些副作用;最终输出是否通过它的 schema 校验。这些在寻常的生成波动下是稳定的,而对你建这个套件所要抓的那些外壳变更,恰恰是敏感的。
有两个后果值得提前安排:
- 一次模型升级会一次性让所有钉死的轨迹作废。请把它当成一项特性来对待——那是整个语料库上行为变化的一份 diff——但要为此排一个人去裁定,因为「这份录制过时了」与「智能体变差了」抵达时长得一模一样。这正是模型迁移里那个可供复核的时刻,而它比任何一个汇总分数都值钱。
- 回放会改变提示词缓存的表现。各次回放之间前缀完全相同,会让缓存看上去比生产环境更好;所以对成本的断言,要读成对结构的上限,而不是对花销的预测。
它在流水线里的位置。
回放靠「快到足以拦下一次合并」来挣得自己的位置。把它放在那儿,把所有慢的东西放到别处:
- 合并前:第一层的钉死轨迹,二十到五十段,两分钟以内,不用模型裁判、不走网络。失败意味着「你的改动改变了行为」——那是一次对话,未必是一个缺陷。
- 每晚:第二层契约夹具,按结果打分,外加每个外部工具的线上一致性检查。这是评测驱动的 CI 与回放交汇的地方。
- 出事故时:任何智能体事故的第一件产物,都该是那次失败运行的一份录制,并在同一周加进第一层。一次没有留下夹具的事故还会再发生一次,而回归检测的好坏,只取决于它跑在什么语料库上。
- 绝不:拿它当质量闸门。一个全绿的回放套件说的是,在你录下来的那些路径上,今天的构建表现得跟昨天一样。它对「昨天那个到底好不好」一个字都没说。
用一个下午做这件事:取你生产环境中量最大的十段轨迹,脱敏,严格钉死,并且只断言工具序列与步数。光是这一项就能抓住大多数外壳退化,而且它会在两周之内第一次报红——很可能是因为某个没人认为会影响行为的改动。等到有人改进了一段提示词、而钉死套件因为正当理由变红时,再加上契约夹具那一层;那是「时候到了」的信号,不是「这套办法失败了」的迹象。