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 质量门:分数下降即拦截 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。