实验 5-1 / 5-2:跨厂商的轨迹接管,与输出中断后的接续¶
《深入理解 AI Agent》第 5 章「故障与错误恢复」配套实验。一条跑到一半的 Agent 轨迹,能不能换一家模型接着跑完?一次流式输出断在半路,是整轮重来还是接着往下写?
这两个实验在测什么¶
本文记录的全部厂商行为都是 2026 年 8 月 24 日的实测结果。 各家的接口约定演进很快,字段名、校验强度、报错文案都可能已经变了。正文只保留不随版本变化的原则,这里的具体细节请当作那一天的快照,重跑之前先自己确认一遍。
轨迹里有三样东西:思考、工具调用、工具结果。后两样各家结构不同但语义一致,重新渲染即可;麻烦的是思考——它可能带着一份只对签发它的厂商有效的凭证。
选这三家,是因为它们当天恰好代表三种不同的做法:
| 厂商 | 思考放在哪 | 凭证 | 凭证挂在哪 |
|---|---|---|---|
Moonshot(kimi-k3) |
reasoning_content,明文 |
无 | — |
Anthropic(claude-haiku-4-5) |
thinking 块,明文 |
signature |
思考本身 |
Google(gemini-3.5-flash) |
thought 部分,摘要 |
thoughtSignature |
工具调用上 |
最后一行是很多接管方案栽跟头的地方:以为把思考删干净就万事大吉,结果对方的凭证根本不在思考里。
目录结构¶
provider-failover/
├── neutral_trace.py # 中立轨迹格式:思考拆成「明文」和「凭证」两个槽位
├── providers.py # 三家的最小 HTTP 客户端 + 把响应读回中立格式
├── renderers.py # 中立轨迹 → 各家线上格式,三条臂的差异都在这里
├── streaming.py # 流式请求,以及在指定位置把它切断
├── tools.py # 任务与确定性工具(答案唯一,可程序化核对)
├── run_handoff.py # 实验 5-1:跨厂商接管
├── run_continuation.py # 实验 5-2:中断后接续
├── summarize.py # 汇总为 manifest + 表格
├── tests/ # 离线测试,不打外部 API
└── validation/runs/ # 正式运行的原始请求、原始响应与 manifest
不用 SDK 而直接发 JSON,是因为这个实验关心的就是线上格式的差异,SDK 会把它藏起来。
配置与运行¶
uv sync --locked --python 3.12 --extra ch5 # 或 python -m pip install -e ".[ch5]"
cd chapter5/provider-failover
cp env.example .env # 三个密钥都要,因为要测的就是三种格式之间的切换
python run_handoff.py # 实验 5-1:六种组合 × 三条臂
python run_continuation.py # 实验 5-2:三家 × 三类断点 × 三种策略
python summarize.py validation/runs/<run_id> # 汇总
离线测试(不打任何外部 API):
兼容路径:python -m pip install -r requirements.txt。
实验 5-1:跨厂商的轨迹接管¶
任务需要四次工具调用(机票、住宿、餐费、汇率),最后算出人民币总额。跑到第二次调用之后,把当前厂商打成不可用,换另一家接着跑。
故障注入是模拟的,格式报错是真实的。 厂商不会配合我们宕机,所以触发熔断的连续 429/503 是注入的,在每份记录里都标成 injected: true。切换之后目标厂商返回的 4xx 则是真实响应,原始报错体原样留在证据里。
三条臂:
- 直传:把上一家原样返回的 payload 按字段名对应搬进新一家的结构,思考和凭证一并带过去。
- 剥离:删掉全部思考与凭证。
- 中立:凭证丢弃,可移植的明文或摘要以普通文本的身份带走,工具调用 id 按目标厂商重铸;遇到强制要求凭证的接收端,把历史调用拍平成文本叙述。
三条臂只管别家产生的步骤。目标厂商自己产生的步骤一律原样回传,连同它自己签发的凭证——切换之后模型还要接着往下跑,把它自己刚签的名删掉同样会报错。
实验 5-2:输出中断后的接续¶
流式响应在三个位置被切断:思考中途、正文中途、工具调用参数 JSON 中途。切断发生在字符层面而不是增量边界上——真实的连接中断不会正好停在一个 delta 结束的地方。
三种恢复方式:整轮重发;把半截内容作为末尾的 assistant 消息要求续写;追加一条元指令说明从断点继续。
半截的工具调用没法以原生结构回传(没有哪家接受一个参数只写了一半的 tool_use),只能先文本化再让模型把 JSON 补完,拼接后重新解析校验。续写请求里工具定义要保留:schema 一旦从上下文里拿掉,模型补参数时就会开始编字段。
第一轮已经执行过的 get_flight_price 是重复副作用的探针——恢复时如果又调一次,就说明这条恢复路径会把已经发生的事情再做一遍。
实验 5-1 的正式运行结果(2026-08-24)¶
运行 validation/runs/exp5-1-handoff-20260824T150228Z,六种厂商组合 × 三条臂共 18 个单元,全部留有原始请求与原始响应。
切换之后第一个请求的状态码:
| 组合 | 直传 | 剥离 | 中立 |
|---|---|---|---|
| kimi → anthropic | 400 | 200 | 200 |
| kimi → gemini | 400 | 400 | 200 |
| anthropic → kimi | 200 | 200 | 200 |
| anthropic → gemini | 200 | 400 | 200 |
| gemini → kimi | 200 | 200 | 200 |
| gemini → anthropic | 400 | 200 | 200 |
| 通过 | 3/6 | 4/6 | 6/6 |
中立臂六种组合全部切换成功、数据齐备,六次的人民币总额也都算对了。另外两条臂的四次失败都是厂商的真实报错,原文保留在各单元的 handoff.error_body 里:
messages.1.content.0.thinking.signature: Field required——把 Kimi 的明文思考塞进 Claude 的 thinking 槽位,没有签名。messages.1.content.0: Invalid signature in thinking block——把 Gemini 的thoughtSignature当作 Claude 的签名。Function call is missing a thought_signature in functionCall parts——Gemini 收到没有凭证的工具调用。
最值得看的是 anthropic → gemini 这一行:直传过了,剥离反而挂了。 Gemini 只检查 thoughtSignature 这一格在不在,不检查是谁签的,所以 Claude 的签名被原样贴过去照样收;而剥离臂老老实实把凭证删干净,反倒触发了 400。直传能过不代表它对,只代表接收端没校验。
重复调用:三条臂都是 0。 这是个负结果,也说明了一件事:这个任务的状态全都落在工具结果里,工具结果在三条臂里都完整保留,所以思考带不带走并不影响模型接着往下做。真正卡住接管的是格式与凭证,不是思考内容。如果任务的关键状态只存在于思考里(比如模型在思考中排除了某个方案却没有写进任何工具调用),差异才会显现。
带思考是要付钱的。 在三条臂都跑通的两种组合上,切换后的输入 token 中立臂 4,183、剥离臂 2,760(多 52%),而两条臂用掉的轮数完全相同。把思考带过去买到的是接管的确定性,不是更少的步数。
汇总与门禁见 validation/runs/<run_id>/manifest.json。
实验 5-2 的正式运行结果(2026-08-24)¶
运行 validation/runs/exp5-2-continuation-20260824T152045Z,三家 × 三类断点 × 三次重复。每个成功单元都留有切断时手上那一截和恢复请求的原始响应。
先说哪些断点根本没能复现。 思考中途这个断点,9 次里只有 1 次真的切在了思考上——其余 8 次模型在这个位置压根没输出可见的思考,直接就去调工具了。工具参数中途这个断点在 Gemini 上 3/3 不可复现:它的流式接口把工具调用整块吐出来,流里从来不存在“半截参数”这种状态。这两条都不是实现没做到,而是这些断点在当天的接口行为下不成立。
三种恢复方式的输出 token 均值,以及续写相对整轮重发省下的比例:
| 厂商 | 断点 | 整轮重发 | 前缀续写 | 元指令 | 续写省下 |
|---|---|---|---|---|---|
| Moonshot | 正文中途 | 347.0 | 197.3 | 547.0 | 43.1% |
| Anthropic | 正文中途 | 257.3 | 218.5 | 529.0 | 15.1% |
| 正文中途 | 627.7 | 211.3 | 1465.0 | 66.3% | |
| Moonshot | 参数中途 | 117.0 | 27.7 | 300.0 | 76.3% |
| Anthropic | 参数中途 | 135.7 | 147.3 | 277.7 | −8.5% |
元指令在每一个单元上都比整轮重发更贵,最多贵到三倍以上。它要把断点前的内容重新组织一遍再往下写,模型往往顺手又把说过的话说了一遍。
参数被截断时,续写省得最多,也最容易悄悄写错。 Moonshot 的三次续写里两次拼出了合法 JSON,却没有一次语义正确:最典型的一次是在断点 {"city":"东 后面直接补了 "},得到 {"city":"东"}——JSON 合法,城市却从“东京”变成了“东”。同一断点上整轮重发与元指令都是 3/3 正确。Anthropic 的续写 3/3 合法且正确,但比整轮重发还贵 8.5%。参数只有十几个字符时,续写省下的那点 token 不值得冒改错参数的风险;续写真正划算的是被截断的正文足够长的时候。
一个很实在的坑:Anthropic 拒绝以空白结尾的 assistant 前缀(final assistant content cannot end with trailing whitespace),而连接中断恰恰可能停在一个空格上。这一格因此以 400 失败,原因如实记在 summary.json 里。要么在拼前缀之前去掉尾部空白,要么退回整轮重发。
重复副作用全部为 0。 三种恢复方式都没有把第一轮已经执行过的 get_flight_price 再调一次。
判定口径写在 judging.py 里,汇总时会拿留下的原始数据重判一遍——口径变了不必重跑整场活动。