你有没有过这种时刻:一个 4 步的 Agent 流程跑到第 3 步报错,你想改一行重试一次,结果每一次复现都要重新花一遍 token、重新等一遍网络、重新触发一遍写操作。改 10 次就烧 10 次。
这篇文章讲的是工具调用磁带(record–replay):把一次真实会话的每一次工具调用按顺序录进一个 append-only 的 JSONL 文件,之后离线回放——工具一次都不跑,但流程逐步走完。文中所有数字都来自本页附带的tape.py+demo.py在本机实跑的输出(2026-09-30)。
TapeDrift,并把两个指纹和两份参数摆在一起。这是回归测试里最难抓的一类 bug——模型没报错、流程没报错、结果看起来也对,只是参数悄悄变了。| 根因 | 具体表现 | 磁带怎么解 |
|---|---|---|
| 非确定性 | 同一个 prompt 两次运行的工具参数不完全一样(措辞、顺序、多了个字段) | 按"工具名 + 规范化参数哈希"索引,参数变了就报漂移,而不是静默返回旧结果 |
| 外部副作用 | 写文件、发消息、提交表单——重跑一次就多一份真实垃圾 | 回放模式短路:命中磁带直接返回录制的返回值,fn(**args) 根本不执行 |
| 时间与金钱 | 4 步工具链里只要有一处调 LLM,重跑就是真花钱 + 真等待 | 命中磁带即返回,实测从 252 ms 掉到 0.06 ms |
第三条容易被低估。它不只是"省",而是改变了调试的节奏:当你改一行代码验证一次的成本从 20 秒变成 20 毫秒,你才会真的去做小步验证,而不是"攒一堆改动一次跑完看运气"。
一行一条调用,append-only,纯 JSON。这是本机实跑后磁带里的真实首行(截断展示):
{"args": {"url": "https://example.com/agent-long-task"}, "clock": null,
"key": "fetch_doc:d3ad8a5ee74b", "ms": 60.34,
"result": {"status": 200, "text": "agent 长任务需要账本,agent 长任务需要账本,…"},
"seq": 1, "side_effect": false, "tool": "fetch_doc"}
字段就七个,每一个都有明确用途:
| 字段 | 作用 | 少了它会怎样 |
|---|---|---|
seq |
调用在会话里的序号 | 无法区分"参数变了"(漂移)和"多了一步"(落空) |
tool |
工具名 | 指纹碰撞(不同工具同名参数) |
key |
工具名:参数规范化哈希前12位 |
查找只能靠顺序,插一步就错位 |
args |
脱敏后的参数原文 | 漂移时报出来只有哈希,人看不懂 |
result |
返回值 | 没法回放 |
ms |
真实耗时 | 没法回答"这次慢在哪一步" |
side_effect |
这一步是否改真实世界 | 回放时无法单独审计副作用 |
注意 args 存的是脱敏后的原文:录制内容是要进 git、要被 review 的。
序号做键的磁带,插一步就全线错位,而且无法回答"这次和上次哪个参数变了"。用哈希做键,再保留 seq 做漂移判断,两个问题一起解决:
def canon(obj):
"""稳定规范化:dict 按键排序、字符串去首尾空白、丢 None、浮点统一。"""
if isinstance(obj, dict):
return {k: canon(v) for k, v in sorted(obj.items()) if v is not None}
if isinstance(obj, list):
return [canon(v) for v in obj]
if isinstance(obj, str):
return obj.strip()
if isinstance(obj, float):
return round(obj, 6)
return obj
def call_key(tool, args):
payload = json.dumps(canon(args), ensure_ascii=False,
sort_keys=True, separators=(",", ":"))
return tool + ":" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
不规范化会怎样?我写了个对照脚本,同一个调用、不同写法,分别用"朴素哈希"和"规范化哈希"算指纹:
用例 朴素指纹一致? canon指纹一致?
dict 键序不同(语义相同) False True
字符串首尾空白 False True
数字 vs 字符串 False False
布尔 vs 整数 False False
全角空格 U+3000 False True
朴素指纹下,同一份参数只是 Python 字典插入顺序不同,就会算出两个完全不同的键:
朴素: t:9d57a9e5901d / t:a8076e39a4c2 ← 同一次调用,两个键
规范: t:2867182ebeac / t:2867182ebeac ← 同一个键
但有两类差异必须保留,绝不能"规整"掉:3 和 "3"、True 和 1,规范化后依然是两个不同的键。这是故意的——它们对被调工具来说真的是两种输入,静默合并等于把一类真实漂移藏起来。
顺带一个实测推翻猜想的例子:我原本以为全角空格 U+3000 需要自己处理,测下来 Python 的 str.strip() 已经按 Unicode 空白处理了它('agent '.strip() 长度 5)。这就是为什么要测不要猜。
回放时的三种情况必须分开处理,混在一起就退化成"一个会骗人的缓存":
| 情况 | 判断依据 | 严格模式行为 | 补录模式行为 |
|---|---|---|---|
| 命中 | 参数哈希在磁带里存在且未被消费 | 返回录制结果 | 返回录制结果 |
| 漂移 | seq 位置有记录,但参数哈希不同 |
抛 TapeDrift(含两份指纹与参数) |
抛 TapeDrift |
| 落空 | 该哈希磁带里没有 | 抛 TapeMiss |
真跑一次并追加进磁带 |
漂移永远不自动补录——它是"你的 Agent 行为变了"这个信号本身,自动放行等于把回归信号静默掉。
用一个 4 步工具链:fetch_doc(网络)→ search_index(检索)→ summarize(LLM,唯一花钱的一步)→ write_report(写盘,真实副作用)。
结果: {"bytes": 70, "file": "report.md"}
统计: {"mode": "record", "steps": 4, "tape_hits": 0, "tape_misses": 0,
"live_tool_calls": 4, "real_side_effects": 1, "drift_events": 0}
墙钟 252.68 ms 超参数花费 $0.012 磁带行数 4
真实副作用: report.md 72 bytes
结果与录制逐字节一致: True
统计: {"mode": "replay", "steps": 4, "tape_hits": 4, "tape_misses": 0,
"live_tool_calls": 0, "real_side_effects": 0, "drift_events": 0}
墙钟 0.06 ms 超参数花费 $0 report.md 被重新写了吗: False
四个数字合起来才是完整证明:live_tool_calls: 0(工具没跑)、real_side_effects: 0(没写盘)、True(结果一致)、False(产物文件确实没被重建)。少了任何一个,你都无法排除"它其实偷偷跑了一遍"。
(性能差 252.68 ms → 0.06 ms 约 4200 倍,其中绝大部分是我在模拟工具里故意 sleep 的时间;真实场景里省下的是网络往返和 LLM 延迟,量级更大。)
把 max_words 从 120 改成 80(模拟"我改了提示词模板里的一个参数"),同一个序号位置上参数变了:
TapeDrift 捕获:
step 3: summarize 参数漂移
recorded summarize:b70a2846e594 {"max_words": 120, "text": "agent 长任务需要账本,…(+528字)"}
replayed summarize:0ebe14ad758f {"max_words": 80, "text": "agent 长任务需要账本,…(+528字)"}
统计: {"steps": 3, "tape_hits": 2, "live_tool_calls": 0, "drift_events": 1}
报错里同时给了位置(step 3)、工具、两个指纹、两份参数。这就是回归测试想要的东西——不是"失败了",而是"第 3 步的 max_words 从 120 变成 80"。
v3 在末尾多加了一步检索:
严格模式 -> step 5: 磁带里没有 search_index 的这条调用(search_index:361f878a5b5b)
统计: {"mode": "replay", "steps": 5, "tape_hits": 4, "tape_misses": 1, "live_tool_calls": 0}
补录模式结果: {"bytes": 70, "file": "report.md"}
统计: {"mode": "replay+record", "steps": 5, "tape_hits": 4, "tape_misses": 1, "live_tool_calls": 1}
磁带行数: 5
注意补录模式下 live_tool_calls: 1 ——只有新加的那一步真跑了,已录制的 4 步依然走磁带。这让"边开发边跑测试"变成增量成本:改一步只付一步的钱。
把 fetch_doc 的返回改成上游 500,看降级分支是否符合预期:
结果: {"mode": "degraded", "reason": "上游 500"}
统计: {"mode": "replay", "steps": 1, "tape_hits": 1, "live_tool_calls": 0, "real_side_effects": 0}
降级分支没有产生副作用: True
错误分支是最难在真实环境里复现的东西(上游不故障你就测不到),而磁带里改一个返回值就能随时随地复现,且副作用为零。
PASS 1 里工具自报 bytes: 70,磁盘上是 72 字节。原因不是 bug:Windows 文本模式写文件会把 \n 翻译成 \r\n,多出 2 个字节;磁盘尾部实测是 b'…\xe6\x9d\xa1\r\n'。
这条对磁带的启示是:判断"产物对不对"要用回读磁盘的真实字节数,不要用工具自报的数字。 工具自报的数字是"它以为自己写了什么",磁盘上的才是"实际存在什么"——这是所有假成功检查的同一个道理。
这一节比前面所有节都重要,因为它决定了你会不会用错:
mode=record)并和磁带比对漂移数与结果差异。| 方案 | 覆盖范围 | 优点 | 短板 |
|---|---|---|---|
| 本页磁带 | 任意工具函数(HTTP、DB、文件、LLM) | 零依赖、可读可 review、漂移检测、副作用短路 | 需要你显式包一层 tape.call() |
| monkeypatch / 手写 mock | 单个函数 | 简单 | 无录制,参数一变假测试照样"通过" |
| HTTP 层录制(vcr 类库) | 只覆盖 HTTP | 对网络层透明 | 覆盖不到文件/DB/本地命令;无跨步漂移检测 |
| 重跑真实调用 | 全部 | 最真实 | 每次都花钱、每次都产生副作用 |
| 静态 fixture JSON | 数据 | 稳定 | 与真实会话的顺序和参数脱钩,抓不到漂移 |
结论:磁带的价值在"顺序 + 参数指纹 + 副作用分层"这三件事上,而不是"缓存返回值"。
args 里带着 token/api_key 被提交进 git,是磁带最常见的真实事故。canon() 覆盖 dict 键序、字符串首尾空白、None、浮点精度3 vs "3"、True vs 1)保持可区分args 落盘前过脱敏白名单(token/password/api_key/authorization/cookie/secret)side_effect=Truelive_tool_calls == 0 且 real_side_effects == 0replay+recorddrift_events == 0),而不是只打日志.gitignore 或单独的 tapes/ 目录,并配 .gitattributes 标注为二进制/不参与 diff 统计def test_agent_flow_offline():
tape = Tape("tapes/order_flow.jsonl", mode="replay") # 严格模式
result = run_order_agent(tape)
assert result == json.loads(open("tapes/order_flow.expected.json", encoding="utf-8").read())
assert tape.stats()["live_tool_calls"] == 0, "回放时不该有真实调用"
assert tape.stats()["real_side_effects"] == 0, "回放时不该有副作用"
assert tape.stats()["drift_events"] == 0, "参数漂移必须失败"
关键在最后三条断言:它们断言的是"没有发生的事"。绝大多数 flaky 测试之所以没用,就是因为只断言了返回值,而没有断言"这次运行没有偷偷干了别的"。
完整可跑(纯标准库,无第三方依赖)在仓库 tools/tape/ 下:
tape.py(142 行)——canon / call_key / redact / Tape.call / override / statsdemo.py(108 行)——本文五轮演示canon_check.py——第四节五个规范化用例python tools/tape/demo.py # 五轮演示全流程
python tools/tape/canon_check.py # 规范化用例对照
相关阅读:AI Agent长任务实战指南:断点续跑、幂等与进度对账(账本与恢复入口)·AI Agent自动化可靠性实战指南(假成功的四形态)·AI Agent评测与可观测性实战指南(怎么知道 Agent 真的在干活)。
📚 更多创作资源: https://afdian.com/a/felix007