《AI Agent 工程实战》12.2 离线回放与夹具:假模型 + 录制样本确定性回归

CI 里调真模型不确定、要钱、要网络、会限流。本节把一次模型交互的请求与响应存成夹具,回放时按请求指纹匹配取响应:本机按 Anthropic Messages API 结构手工构造三条夹具真跑一遍完整 run,连跑两次逐字一致;再改问题制造夹具失配,验证回放器报错而非静默给错响应,最后给出夹具的存放与更新流程。

12.2 离线回放与夹具:假模型 + 录制样本确定性回归

12.1 的评测集里,每条样本的 script 还是人手写死的:我告诉假模型「先搜 RAG、再取文档、然后带引用作答」。这对测引擎逻辑够用,但对测真实行为就不够了——手写脚本代表的是「我以为模型会怎么做」,不是「模型实际怎么做」。要让评测用上真实行为,同时又不联网、不花钱,就要把真实交互录下来、离线重放。这一节讲这套机制:夹具回放。

本节把 ResearchCopilot 推进到「确定性回放」:把一次模型交互的请求与响应存成夹具,回放时按请求指纹匹配取响应;本机手工构造三条夹具跑通完整 run、验证逐字确定,再制造夹具失配验证「报错而非静默给错响应」。

12.2.1 为什么 CI 里不能调真模型

把真模型塞进 CI 是最自然的想法,也是最容易翻车的做法。四个理由,每一个都足以否决它:

问题表现后果
不确定同输入两次输出不同断言随机红,CI 不可信
要花钱每次流水线都消耗额度高频提交下成本失控
要网络依赖外网可达与密钥断网 / 限流即挂,CI 不稳定
会限流并发高时被 429排队、超时、假失败

四个问题里,「不确定」最致命。CI 的全部价值在于「同样代码跑出同样结论」;一旦引入了随机输出,红绿就成了抛硬币,团队很快学会「重跑一次就好」——质量门形同虚设。所以工程上的铁律是:CI 只跑确定性的东西,不确定的调用全部替换成回放。

回放的对象就是夹具(fixture):一次真实交互的请求 + 响应。录制阶段真调一次模型,把「发出去的请求」和「收到的响应」成对存下来;回放阶段不调网络,只按请求去查对应的响应。这样既保留了真实行为,又拿回了确定性、零成本、零网络。

「确定」二字买来的不只是稳定性,还有可调试性。真模型下,一次失败你无法复现——重跑一次输出就变了,bug 抓不住。回放下,失败是逐字可复现的:把夹具留在原地,任何人任何机器跑出来的轨迹都一模一样,可以直接单步调试、可以 diff 两次运行的每一帧。工程上有个说法:「能复现的 bug 都是小 bug,不能复现的才是噩梦」——夹具回放把 Agent 的大多数 bug 从「噩梦」拉回了「小 bug」。

12.2.2 夹具长什么样

一条夹具 = 一个请求 + 一个响应。请求是发往 Anthropic Messages API 的形状,响应也是 Messages API 的响应形状:

{
  "key": "87be954b9b97",
  "request": {
    "model": "claude-sonnet-4-5", "max_tokens": 1024,
    "system": "你是严谨的研究助手。",
    "messages": [{"role": "user",
                  "content": [{"type": "text", "text": "什么是 RAG?"}]}],
    "tools": [{"name": "search_corpus", "description": "在本地语料里检索", "parameters": {}}]
  },
  "response": {
    "id": "msg_01", "type": "message", "role": "assistant",
    "content": [{"type": "tool_use", "id": "toolu_01",
                 "name": "search_corpus", "input": {"q": "RAG"}}],
    "stop_reason": "tool_use",
    "usage": {"input_tokens": 42, "output_tokens": 18}
  }
}

必须坦白一点:本节正文里的夹具是按 Anthropic Messages API 的响应结构手工构造的,不是从真实 API 录制来的——因为本机没有真实 API 额度(api.openai.com 不可达,Anthropic 调用需用户另行授权)。手工构造能完整演示「请求-响应配对 + 回放」的机制,但样本本身是结构真实、内容构造的。

真实项目里,这些夹具应当由录制真实响应得到,录制方式就是给模型客户端套一层「旁路」:

def record(client, request: dict) -> dict:
    """真实调用一次,把请求与响应成对落盘成夹具。"""
    resp = client.messages.create(**request)          # 真实网络调用
    fixture = {"key": fixture_key(request),
               "request": request,
               "response": resp.model_dump()}         # Anthropic SDK 对象转 dict
    append_jsonl("fixtures/live.jsonl", fixture)      # 追加写入,天然是录像
    return resp.model_dump()

录制只在低频发生:新增一条评测样本、或模型 / 提示词大改之后,跑一次真实调用把新行为录下来,之后所有 CI 都回放这份录像。这和卷一 16.3 的「快筛/验收」两段式是同一套经济学——贵的操作低频做一次,便宜的回放高频跑无数次。

12.2.3 按请求匹配:指纹而不是序号

回放的核心问题是:拿到一个请求,怎么找到对应的响应?最直觉的答案是「按序号」——第 1 次调用配第 1 条夹具。但这个做法有个致命缺陷:它不校验请求是否变了。一旦你的提示词改了、消息条数变了,回放器还是会傻乎乎地把第 1 条响应塞回去,于是「代码变了但测试还是绿的」——这正是夹具回放要防的假绿。

正确做法是按请求指纹匹配:把请求规范化成 JSON,算一个哈希当 key,回放时用同一个算法算当前请求的 key,去索引里查:

def fixture_key(request: dict) -> str:
    canon = json.dumps(request, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(canon.encode()).hexdigest()[:12]

sort_keys=True 保证字段顺序不同但内容相同的请求得到同一个 key;ensure_ascii=False 保证中文原样参与哈希。指纹匹配的语义是:「这个请求」曾经录过一份响应,才允许回放。请求只要有一个字节不同(问题措辞、系统提示、工具 schema、消息历史),key 就变了,查不到,回放器就必须报错——而不是退而求其次给个近似响应。

两种匹配方式的差别,值得摆成一张表:

维度按序号匹配按请求指纹匹配
命中依据第 N 次调用配第 N 条夹具请求内容哈希相同
请求变了会怎样照样回放旧响应(假绿)指纹变、查不到、报错
能否发现消息装配 bug不能能(多拼一条消息就失配)
实现成本极低多一次哈希
适用场景一次性临时脚本长期回归(本卷选它)

指纹匹配多花的只是一次哈希,换回的却是「请求与响应必须严格对应」这条不变量。这条不变量是整个回放机制可信的根基——没有它,回放就退化成「看起来在测,其实什么都没测」。

回放器本身很短,但那条 if resp is None: raise 是整节的重点:

class FixtureMismatchError(RuntimeError):
    """请求变了但夹具没更新——必须报错,绝不能静默返回错响应。"""

class ReplayModel:
    def __init__(self, fixtures: list[dict]) -> None:
        self._index = {f["key"]: f["response"] for f in fixtures}
        self.calls: list[tuple[list[Message], list[dict]]] = []

    def complete(self, messages, tools) -> ModelResponse:
        self.calls.append((list(messages), list(tools)))
        req = to_anthropic_request(messages, tools)     # 引擎消息 -> API 请求形状
        key = fixture_key(req)
        resp = self._index.get(key)
        if resp is None:
            raise FixtureMismatchError(
                f"夹具失配:请求指纹 {key} 未命中任何夹具(共 {len(self._index)} 条)")
        return from_anthropic_response(resp)            # API 响应 -> 引擎 ModelResponse

ReplayModel 实现了和 FakeModel 一样的 complete(messages, tools) 协议,所以它能无缝替换假模型接进 run()——引擎完全不知道背后是脚本还是录像。两侧各有一个转换函数:to_anthropic_request 把引擎的 Message 列表折成 API 请求,from_anthropic_response 把 API 响应解析回引擎的 ModelResponse。这两次转换是「引擎契约」与「API 契约」的桥,桥的稳定就是回放能对上的前提。

12.2.4 真跑:回放是确定性的

用三条夹具(搜 RAG、取文档、带引用作答)跑一遍完整的 run,再跑第二遍,比对两次的轨迹与终答:

def replay(question: str, system: str = "你是严谨的研究助手。"):
    model = ReplayModel(FIXTURES)
    result = run(model, build_registry(), question, system=system)
    trace = [s.detail for s in result.steps]
    return result.final, trace, model

本机真跑(Python 3.14.6,ReplayModel 读手工构造的夹具):

=== 回放(第 1 次)===
   search_corpus({'q': 'RAG'}) -> {"hits": [{"doc_id": "doc-rag", "snippet": "RAG 即检索增强生成:先检索外部语料,再把命中"}]}
   fetch_doc({'doc_id': 'doc-rag'}) -> {"doc_id": "doc-rag", "text": "RAG 即检索增强生成:先检索外部语料,再把命中片段作为上下文交给模型,让回答有据可依。检索质量决定上限,生成只负责组织语言。"}
   RAG 是检索增强生成 [doc-rag]。
final: RAG 是检索增强生成 [doc-rag]。
=== 确定性检查 ===
两次 final 逐字一致: True
两次轨迹逐字一致: True

两行 True 是这一节想要的结论:同一个请求指纹,永远拿到同一条响应,于是整条轨迹逐字可复现。回放没有网络、没有随机、没有时间依赖,秒级完成——这样的东西才能塞进每次提交的 CI。

这里能看到回放和手写脚本的一个本质差别:手写脚本里的工具输出是「假装」的(比如写死「命中 3 条」),而回放里的工具输出是真的——search_corpus 真的去查了本地语料,fetch_doc 真的取回了正文。只有模型那一层被夹具替换了,工具、循环、消息装配全部走真实代码。这让回放测到的东西比手写脚本多得多:工具接错了、消息拼错了,回放都会红。

换句话说,回放把「不确定的边界」收窄到了最小:整个系统里只有模型的输出是不确定的,其余一切都是确定的代码。把这唯一的变量录下来,系统就整体确定化了。这也解释了为什么回放的置信度取决于录制时机——录得越接近真实使用,回放越接近真实行为;录了一份过时的夹具,回放出来的就是过时的行为。

12.2.5 真跑:夹具失配必须报错

现在制造一个失配:把问题从「什么是 RAG?」改成「RAG 是什么?」——语义一样,但措辞变了,于是首轮请求的指纹变了,夹具里没有对应的响应。回放器应当报错:

try:
    replay("RAG 是什么?")            # 措辞变了 -> 首轮请求指纹变了
    print("!!! 没有报错(这是 bug)")
except FixtureMismatchError as e:
    print("已拦截:", e)

本机真跑输出:

=== 夹具失配(改了问题,夹具没更新)===
已拦截: 夹具失配:请求指纹 2b7bc3e3f2c6 未命中任何夹具(共 3 条)

回放器当场报错,而不是随便挑一条响应塞回去。这正是 12.2.3 那条 raise 的价值:它把「代码改了但夹具没跟上」从一个静默的假绿变成了一个响亮的红。如果回放器写成「查不到就返回第一条」,上面这个测试会「通过」,而实际上它验证的是三年前的旧行为——这种假绿比没有测试更危险。

有一点要提前说清:失配报错不总是代表代码有 bug。它可能只是你故意改了提示词——这时正确的动作是重新录制夹具,而不是把报错改成警告。把「失配」当成一个必须回应的信号:要么回滚代码,要么重录夹具,不允许两者都不做。

反过来说,如果回放器从没报过失配,也要警惕:那可能说明夹具是按序号匹配的,或者夹具的 key 根本没人维护。一个健康的回放器应该在提示词迭代时频繁失配——失配的频率,恰恰是「夹具在真实约束代码」的证据。没有失配的回放,多半是没在真的比对请求。

12.2.6 夹具的存放与更新流程

夹具是资产,资产就要有规矩。ResearchCopilot 的夹具管理四条:

事项做法
存哪tests/fixtures/<feature>.jsonl,一条夹具一行,随代码进版本控制
谁改只有改动相关行为的 PR 才允许改对应夹具,改动写进 PR 描述
怎么评审夹具 diff 和代码 diff 一起看:请求变了要能解释「为什么变」
何时失效提示词、工具 schema、模型 id 任一变化,旧夹具全部标记待重录

最关键的是**「夹具改动必须和代码改动一起评审」。如果允许「代码没动、夹具偷偷改了」,那就等于允许悄悄放宽标准——和 12.1 说的「改期望值要留痕」是同一个纪律。夹具 diff 里最该被盯的是请求的 diff**:请求变了说明喂给模型的输入变了,评审者要能说出这次变化是有意的(改了提示词)还是意外的(消息装配 bug 多拼了一条)。

反过来,响应 diff 通常不该被盯——响应变了只说明模型这次说了不同的话,那是模型的自由,不是 bug。真正该报警的是「请求没变、响应却被改了」:请求一模一样意味着输入没动,响应凭什么变?这种情况要么是夹具被人手动编辑过(可疑),要么是模型 id 悄悄换了(模型升级的信号)。把这条当成一条自检规则,能挡住大多数「夹具被篡改」的意外。

更新流程可以固化成一条命令:

# 重录:真调模型,覆盖对应夹具(需显式授权,会消耗额度)
python -m replay.record --fixtures tests/fixtures/rag.jsonl --approve-live
# 回放:CI 里只跑这个,零网络零成本
python -m replay.run --fixtures tests/fixtures/rag.jsonl

--approve-live 这个开关是刻意的:录制是要花钱的动作,必须显式点头,不能藏在某个 make test 里被顺手触发。这与 BRIEF 里「真实 API 调用须先获得用户明确同意」是同一条底线——默认路径永远是不花钱的回放。

12.2.7 真实模型下会怎样

真实模型下,回放机制的骨架不变,变的是录制的来源和夹具的稳定性。三点值得提前想好:

  • 响应不再稳定。真实模型同输入两次输出可能不同,所以夹具必须是录制所得——你不能「猜一个响应」写进去,因为真实响应的措辞、工具选择、字段细节都可能和你猜的不一样。本节手工构造只是为了在无额度环境下演示机制,真实项目里这一步必须真录。
  • 夹具要脱敏。真实响应里可能带 usage、id 等无关字段,甚至(如果录了带上下文的请求)夹带用户数据。录制落盘前要裁剪:只保留回放需要的字段,敏感内容一律不留。
  • 模型升级会批量失效。换了模型 id,所有请求里的 model 字段变了,指纹全变,夹具集体失配。这是预期行为——它逼你为每次模型升级重录一遍、重跑一遍评测,而不是「悄悄换模型、假装没变」。把「升级模型 = 一次显式的重录与重评」写进流程,能避免最隐蔽的一类退化:模型悄悄变了,评测还对着旧行为绿着。

本节验收清单:

  • 回放器按请求指纹匹配,不是按序号
  • 失配时抛异常,绝不静默返回近似响应
  • 同一请求连跑两次,轨迹与终答逐字一致
  • 回放路径零网络、零密钥、秒级完成
  • 录制需要显式开关(如 --approve-live),不藏在默认路径里
  • 夹具改动与代码改动一起评审,请求 diff 能解释

12.2.8 小结

  • CI 里不能调真模型:不确定、要钱、要网络、会限流——四者任一都足以否决。
  • 夹具 = 一次交互的请求 + 响应;回放时只替换模型层,工具与循环走真实代码。
  • 按请求指纹匹配(sort_keys 归一化后哈希),不是按序号——序号匹配会让「代码变了测试还绿」。
  • 失配必须报错:这是把静默假绿变成响亮红灯的关键一行。
  • 录制要显式授权、要脱敏、要低频;回放要高频、免费、确定。
  • 夹具与代码同版本、同评审,请求 diff 必须能解释。

夹具解决了「确定性」,但评测跑完只是打印一张表——它不会拦住任何东西。下一节 12.3 CI 质量门:分数下降即拦截 会把这张表和基线比对,让分数掉超过阈值时直接让流水线失败。想回看评测集与评分器怎么搭,翻 12.1 评测集与评分器 。

阅读导航:上一节:12.1 评测集与评分器:成功率/引用准确率/成本 · 下一节:12.3 CI 质量门:分数下降即拦截 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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