青雲的博客
深入浅出 Pi 第二部:模型请求不是一次 fetch 第 12 章

重试、溢出与中止在哪一层收口

区分 provider transport retry、assistant turn retry 与 context overflow recovery,并追踪 AbortSignal 如何打断请求、退避和压缩相关操作。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

Pi 里至少有三种看起来都像“再试一次”的动作。第一种留在 provider 请求内部,第二种重新启动 assistant turn,第三种先改写上下文再恢复。它们的预算、可见事件和中止控制器不同,不能用一个 maxRetries 概括。

flowchart TD
  accTitle: 失败后的三条处理路径
  accDescr: transport 错误可在 provider 内重试,统一 assistant 错误由会话退避后续跑,上下文溢出则先压缩且最多恢复一次
  F["请求或终态失败"] --> T{"provider transport 可重试?"}
  T -->|是| PR["同一 stream 内 fresh SDK request"]
  T -->|否| O{"context overflow?"}
  O -->|错误或 length overflow| C["compact + 最多一次 continue"]
  O -->|成功但 usage 越界| CO["只 compact,不重放答案"]
  O -->|否,瞬时 assistant error| AR["会话可见退避 + continue"]
  O -->|确定性错误或预算耗尽| X["保留最终错误"]
  AB["AbortSignal"] -.-> PR
  AB -.-> C
  AB -.-> AR

同一轮请求里的 transport retry

provider 层的 retryProviderRequest() 模拟固定 SDK 的短重试策略:x-should-retry 可显式覆盖,缺失 status、408、409、429 和 5xx 可重试;retry-after 或指数抖动决定等待。适配器把 SDK 内置 retry 设为 0,每次由 helper 新建请求,使等待能被同一个 signal 中止。服务器要求的等待默认超过 60 秒就直接失败,避免一轮请求长期无声挂起。

这层没有 auto_retry_start 一类会话事件;从 Agent 看,它仍是同一个尚未结算的 stream。若 provider 层耗尽后返回统一 error,会话层才可能再启动整轮 assistant retry。两个 maxRetries 因此可能连续生效,排障时要分别统计 HTTP 尝试和 assistant turn,不能从界面显示次数倒推网络请求数。

第二层面对的是已经归一化的 AssistantMessage。通用 retryAssistantCall() 只重试被判定为瞬时的 error,以 baseDelayMs * 2^(attempt-1) 退避,aborted、配额或计费类错误立即返回;回调允许宿主显示 schedule、attempt start 和最终状态。静态 regex 只是分类器,是否启用、预算多少,以及用户何时取消,都是运行时策略。

溢出先改上下文,再决定是否继续

溢出分类还有三种来源:错误文本命中 provider pattern;成功 stop 但 input 与 cacheRead 超过窗口;length stop、零输出且输入达到窗口的 99%。已知限流文本先被排除,防止把 429 当成 context overflow。

coding-agent 在 AgentSession 收口恢复:只处理当前模型且晚于最近压缩边界的消息;成功答案越过窗口时只自动压缩,不再生成一次。错误或零输出的溢出会从 Agent 的活动 Context 移走失败消息,但历史仍持久化,然后 compact 并最多继续一次。普通 _prepareRetry() 明确排除 overflow,另用可中止的控制器等待,并发出 auto_retry_* 事件。

同模型与压缩边界检查避免了另一类重复动作:用户切到更大窗口模型后,旧模型的 overflow 不应触发新模型压缩;已经位于最近 compaction 之前的错误也不能在下次 prompt 再触发恢复。这里判断的是实际终态消息、当前模型和持久化边界的组合,而不是看到某段错误文本就无条件 compact。

多个操作各有 signal 时,combineAbortSignals() 会建立一个合成 signal,并提供 cleanup 移除监听器。这解释了“中止”不是全局布尔量:请求、retry sleep、compaction 和 branch summary 各自终结,宿主负责把相关控制器连到当前操作。

中止终结当前操作

只读实验可以把三层实现并排打印:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/ai/src/utils/provider-retry.ts |
  nl -ba | sed -n '22,67p;97,124p'
git -C "$repo" show v0.83.0:packages/ai/src/utils/overflow.ts |
  nl -ba | sed -n '132,160p'
git -C "$repo" show v0.83.0:packages/coding-agent/src/core/agent-session.ts |
  nl -ba | sed -n '1979,2010p;2631,2639p;2672,2733p'

依赖已安装时,可在 packages/ai 下分别运行 node ../../node_modules/vitest/dist/cli.js --run test/provider-retry.test.tsnode ../../node_modules/vitest/dist/cli.js --run test/overflow.test.tsnode ../../node_modules/vitest/dist/cli.js --run test/retry.test.ts。它们不需要真实模型。第二部到这里结束于统一失败终态;下一部不会立刻跳到工具,而是先看 Agent 怎样把事件还原成 state,再拆开 outer loop 与 inner loop 的继续条件。状态与两层循环的所有权清楚以后,才进入 tool preflight、并行批次和结果回填。