跳转至

实验 5-1 / 5-2:跨厂商的轨迹接管,与输出中断后的接续

《深入理解 AI Agent》第 5 章「故障与错误恢复」配套实验。一条跑到一半的 Agent 轨迹,能不能换一家模型接着跑完?一次流式输出断在半路,是整轮重来还是接着往下写?

返回第 5 章目录

这两个实验在测什么

本文记录的全部厂商行为都是 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 pytest tests -q

兼容路径: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%
Google 正文中途 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 里,汇总时会拿留下的原始数据重判一遍——口径变了不必重跑整场活动。