Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

错误处理与重试

TL;DR:Pi 的错误处理是分层接力的:pi-ai 的 provider 适配器把异常吞掉,变成一条 stopReason: "error" 的标准 assistant 消息;agent-core 看到错误就干净地结束本次 loop,不做任何重试决策;真正的策略在 coding-agent 的 AgentSession:可重试的瞬时错误按 2s → 4s → 8s 退避,最多重试 3 次,失败的那次回复从模型上下文中省略后 continue();上下文溢出不走普通重试,而是先压缩再重试一次;额度、计费类错误直接失败。另外 pi-ai 里还有一层默认关闭的“请求级重试”,不要和 AgentSession 的“回合级重试”混为一谈。

先看一个具体错误

交互模式里偶尔会看到:

Retry failed after 3 attempts: fetch failed

它的意思是:模型请求遇到网络/传输错误 fetch failed,Pi 认为这可能是瞬时问题,自动重试了 3 次(加上第一次,共 4 次模型调用),仍然失败,最后由 UI 拼出这句提示。它不是 Pi 进程崩溃,也不是工具执行出错——工具抛异常走的是另一条路:agent-core 把它包装成 isError: true 的工具结果交还给模型,让模型自己调整,不会触发这里的重试。本轮请求结束后会话仍然可用,可以继续输入。

下面沿着这条错误从底层往上走一遍。

第一层 pi-ai:把异常变成消息

provider 适配器(例如 OpenAI Completions)把整个“建立请求 + 读取流”包在 try/catch 里。无论是请求失败、流中途断开,还是 provider 返回的错误体,都会被转换成:

AssistantMessage { stopReason: "error", errorMessage: "fetch failed", ...已收到的部分内容 }

然后通过流的 error 事件交出去,而不是继续向上抛异常。用户主动中止时同样走这条路,只是 stopReason 是 aborted。错误文本由统一的归一化逻辑生成,主要保留 HTTP 状态、响应体和 error.message。

这样设计的好处是上层永远只需要处理“一条消息”,不用区分各家 SDK 抛出的异常类型;坏处是信息可能被压扁——Node 的 DNS、TLS、代理、连接重置等具体原因常藏在 error.cause 里,只看 fetch failed 无法进一步判断是哪一种。

pi-ai 同时提供了两个纯分类函数,供上层做决策:

  • isRetryableAssistantError(message):先匹配“不可重试”名单(额度耗尽、billing、订阅用量上限等),命中直接返回 false;否则匹配“可重试”名单:过载、限流、429/5xx、fetch failed 等连接错误、超时、流提前结束等。负面规则优先,所以同时出现 429 和 quota exceeded 时会判为额度问题、立即失败。
  • isContextOverflow(message, contextWindow):识别上下文溢出,包括 provider 明确报错、成功返回但输入 token 已超过窗口的“静默溢出”,以及服务端截断输入导致输出为 0 的 length 停止。

注意它们只做分类,不实现重试策略——策略留给调用方。

第二层 agent-core:结束本次 run,不管重试

agent loop 拿到这条 assistant 消息后,只要 stopReason 是 error 或 aborted,就调用 finishTurn、发 turn_end 和 agent_end,然后返回:

  • 不执行错误回复里可能残留的工具调用;
  • 结束当前 agent run;
  • 把错误留给上层。

它不决定是否重试。这和 agent-core 的定位一致:它是通用 runtime,重试次数、退避、是否压缩都是产品策略,由宿主决定。

第三层 AgentSession:真正的回合级自动恢复

AgentSession 在 message_end 时把每条 assistant 消息先落盘到会话 JSONL,并记下“最后一条 assistant 消息”。发给订阅者的 agent_end 会附带 willRetry,让 UI 提前知道后面还有动作。

真正的恢复发生在 AgentSession 的 run 循环里:底层 loop 结束后做一次 run 后检查,需要继续就 agent.continue() 再跑一次,直到没有自动工作为止,最后发 agent_settled。对一条错误消息,它按顺序判断:

1. 是否可重试。 先排除上下文溢出(溢出交给压缩),再调用 pi-ai 的 isRetryableAssistantError()。可重试时:

  • 读取 settings.retry,默认 enabled: true、maxRetries: 3、baseDelayMs: 2000,单次退避上限 maxAgentDelayMs 60 秒;
  • 计数加一,超过上限就不再重试;
  • 延迟为 baseDelayMs × 2^(attempt-1),即 2s、4s、8s,并发出 auto_retry_start { attempt, maxAttempts, delayMs, errorMessage };
  • 把失败的回复(以及它残留的工具结果)从模型上下文中省略:做法是向会话追加 context_edit 条目(replacement: null),原始失败记录仍保留在 JSONL 里便于追溯,但后续请求的投影里看不到它。这样模型不会看到上一次残缺的输出,也不会出现“工具调用没有配对结果”导致下一次请求被 provider 拒绝;
  • 可中止地 sleep(abort、RPC 的 abort_retry 都能打断),然后返回 true,由循环调用 agent.continue() 从原来的用户消息继续。

2. 重试成功。 下一条非错误的 assistant 消息在 message_end 时就会触发 auto_retry_end { success: true } 并把计数清零——计数按“连续失败”算,不会在一个长任务的多次模型调用之间累积。

3. 重试耗尽或不可重试。 如果是错误且之前重试过,发 auto_retry_end { success: false, attempt, finalError },交互模式据此显示 Retry failed after 3 attempts: ...。不可重试的错误(额度耗尽、401、无效参数等)不进入重试,直接作为最终结果。如果在退避期间被中止,则以 finalError: "Retry cancelled" 结束。

所以 “after 3 attempts” 指的是 3 次重试,实际最多发生 1 + 3 = 4 次模型调用。

一句话:pi-ai 负责分类,agent-core 负责停下,AgentSession 负责决定退避多久、要不要省略失败回复、何时 continue。

上下文溢出:先压缩,再重试一次

上下文溢出如果按普通重试处理,只会原样失败 4 次。所以 run 后检查在重试判断之后再做一次压缩检查,区分几种情况:

  • 溢出类错误(provider 明确报超长、静默溢出、服务端截断)或可恢复的 length 截断(输出在模型原定上限之前就被截断,通常是上下文挤压所致):先把这次失败回复省略,再以 reason: "overflow" 运行自动压缩,成功后 compaction_end 带 willRetry: true,循环 continue() 重新请求。
  • 只尝试一次:同一条用户消息内,如果压缩后重试仍然溢出,就发 compaction_end(带错误)和失败事件,提示“Context overflow recovery failed after one compact-and-retry attempt”,建议缩减上下文或换更大窗口的模型。每次新的用户消息开始时这个标记才重置。
  • 回复其实成功了但已超窗口:压缩,但不重试——一条完整的回复无法用 continue() 接着生成。
  • 未出错但超过阈值:普通的阈值压缩(reason: "threshold"),也不重试。

还有两个防误判的细节:消息来自另一个模型(用户刚从小窗口模型切到大窗口模型)时不按溢出处理;早于最近一次压缩的旧消息不会再次触发压缩。

此外,阈值压缩不只在 run 结束后检查:每轮之间和每个 run 开始前也会估算投影后的 token,超过阈值就提前压缩,尽量让长任务根本不走到溢出这一步。压缩本身也要调模型生成摘要,这次调用使用同一套 settings.retry 预算和退避(通过 pi-ai 的 retryAssistantCall),并发出单独的 summarization_retry_* 事件。压缩算法本身见 会话树与上下文压缩。

两套重试不要混淆

Pi 实际上有两层重试,排查问题时必须分清看到的是哪一层:

请求级重试(pi-ai)回合级重试(AgentSession)
位置provider 适配器里的 retryProviderRequest()AgentSession 的 run 后处理
配置retry.provider.maxRetries,默认 0retry.maxRetries,默认 3
判断依据HTTP 状态 408/409/429/5xx、无状态的连接异常、x-should-retry 头错误文本分类(isRetryableAssistantError)
覆盖范围主要是建立请求阶段,不一定覆盖响应已开始后的流中断整个回复,包括流中断和 provider 在流里报的错
对外可见无事件,上层只看到最终结果auto_retry_start / auto_retry_end 事件,UI 有倒计时

请求级重试复刻了 OpenAI/Anthropic SDK 的策略(尊重 retry-after,否则指数退避),存在的原因是 SDK 自带的重试计时器不响应 AbortSignal,所以 Pi 让 SDK 以 maxRetries: 0 调用、自己包一层可中止的版本。它默认关闭,文档也建议保持 0:请求级重试会在底层悄悄等待,延迟 Pi 识别额度和用量上限错误。

两层之间还有一个衔接:如果服务端要求的 retry-after 超过 retry.provider.maxRetryDelayMs(默认 60 秒),请求级直接失败,错误文本是 “Server requested Ns retry delay (max: Ms)”,其中的 retry delay 正好命中回合级的可重试规则,于是交给外层带事件、可中止的重试来处理,用户能看到也能取消。

所以看到 Retry failed after 3 attempts 时,可以确定提示来自外层回合级重试;内层默认根本没有重试。

各模式看到的是什么

  • Interactive:auto_retry_start 显示倒计时,auto_retry_end 失败时显示最终错误;溢出恢复显示压缩提示。
  • Print:最终回复 stopReason 为 error / aborted 时把错误写 stderr、退出码非 0。
  • JSON / RPC:重试、压缩事件原样出现在事件流里。RPC 命令的成功响应只代表受理,provider 失败体现在消息和事件中,不会变成失败响应;set_auto_retry 可开关回合级重试,abort_retry 可取消等待中的重试。
  • SDK:await session.prompt() 在所有自动重试和恢复结束、发出 agent_settled 之后才返回;需要判断成败时检查最后一条 assistant 消息的 stopReason。

细节见 运行模式。

常见追问

为什么重试前要把失败回复从上下文里去掉,而不是直接删掉? 会话 JSONL 是追加式的事实记录,删掉会丢失排障信息;用 context_edit 省略只影响模型看到的投影,既保留了历史,又避免模型基于残缺输出继续,或因工具调用缺少配对结果被 provider 拒绝。

为什么额度错误要放在“负面优先”? 很多 provider 用 429 同时表示“稍后再试”和“额度用完”。后者要几小时甚至到下个计费周期才恢复,按瞬时错误退避重试只会浪费时间、推迟提示用户。

为什么用错误文本匹配而不是结构化错误码? 错误要穿过几十家 provider、代理网关和传输层,到 AgentSession 时只剩归一化后的消息;文本匹配是唯一跨 provider 一致的信号。代价是需要持续维护模式表,源码里每条规则基本都对应一个真实 issue。

溢出为什么只恢复一次? 压缩后仍然溢出,说明保留的近期内容本身就放不下(比如一个巨大的工具结果),继续循环只会反复压缩;交给用户换模型或缩减上下文更合理。

源码入口(v1.5):packages/ai/src/utils/retry.ts、packages/ai/src/utils/overflow.ts、packages/ai/src/utils/provider-retry.ts、packages/agent/src/agent-loop.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/settings-defaults.ts