Book 第二十三章:溢出错误为什么还能自恢复
第五部分:会话续航与治理

第二十三章:溢出错误为什么还能自恢复

拆 prompt-too-long、媒体超限和 max_output_tokens 这些可恢复错误的回路。

1. 为什么这一层值得单独拆

前面几篇我们已经把上下文治理的“预防层”拆得差不多了:

  • Tool Result Budget
  • microcompact
  • session memory compact
  • full compact

但 Claude Code 还有一层同样关键的东西:

请求已经发出去了,模型或者 API 已经报错了,这时候怎么救。

这层不是预防,而是恢复。

而且它不是简单看到错误就返回给用户,而是:

  • 先判断这个错能不能救
  • 能救就先别把错误往外吐
  • 在 query 循环内部走恢复分支
  • 确认真救不回来以后,再把错误暴露出去

这套逻辑主要落在:

  • src/query.ts
  • src/services/api/errors.ts
  • src/utils/messages.ts

如果你后面做 C# 版,这一层非常值得单独抽出来。因为 Claude Code 在这里已经不是“调用模型”,而是在跑一个:

带 recoverable error policy 的 agent turn loop。

2. 先说结论

我对这套设计的判断是:

Claude Code 把一部分 API 错误当成中间态,而不是终态。

整个恢复回路可以先看成这样:

flowchart TD
    A["模型/API 返回错误 assistant message"] --> B{"是否可恢复"}
    B -- "否" --> C["直接 yield 错误并结束"]
    B -- "是" --> D["先 withheld,不立刻对外暴露"]

    D --> E{"Prompt too long"}
    D --> F{"Media size error"}
    D --> G{"max_output_tokens"}

    E --> H["先试 collapse drain"]
    H --> I{"排空成功?"}
    I -- "是" --> J["重试同一轮"]
    I -- "否" --> K["尝试 reactive compact"]

    F --> K
    K --> L{"compact 成功?"}
    L -- "是" --> M["yield post-compact messages<br/>继续 query"]
    L -- "否" --> N["再把 withheld error 放出来并结束"]

    G --> O{"还没升到 64k?"}
    O -- "是" --> P["max_output_tokens_escalate"]
    O -- "否" --> Q{"恢复次数 < 3?"}
    Q -- "是" --> R["插入 meta recovery message<br/>继续下一轮"]
    Q -- "否" --> N

这张图里最关键的点有三个:

  • recoverable error 会先被“暂扣”,不会立刻暴露给宿主。
  • prompt-too-long、媒体超限、max_output_tokens 三类错误各走不同恢复分支。
  • Claude Code 会故意跳过 stop hooks,避免错误恢复和 hook 互相打架形成死循环。

3. query.ts 里真正做的是“暂扣错误,再决定是否放行”

源码锚点:

  • src/query.ts:790
  • src/query.ts:807
  • src/query.ts:815
  • src/query.ts:820

在流式循环里,Claude Code 会先检查一条错误消息是不是属于“可恢复错误”。

当前能明确看到的几类是:

  • prompt too long
  • 媒体大小错误
  • max_output_tokens

命中后它不会立刻 yield 给外部,而是先把这条错误留在内部状态里。

这就是源码里的 withheld 语义。

这个设计非常重要,因为很多宿主一旦看到错误就会直接把本轮会话判死。

如果 Claude Code先把错误吐出去,再在内部偷偷恢复,那恢复根本没有意义,外面的调用方早就不等了。

所以它的策略是:

恢复能不能成功,必须先在 query loop 内部判定完。

4. prompt-too-long 的恢复,不是只有一种方案

源码锚点:

  • src/query.ts:1070
  • src/query.ts:1087
  • src/query.ts:1094
  • src/query.ts:1120

4.1 第一步不是立刻 compact,而是先 drain staged collapse

如果是 prompt too long,Claude Code 第一反应不是马上做 reactive compact。

它先问:

  • CONTEXT_COLLAPSE 有没有开
  • 当前是不是还没走过 collapse_drain_retry

如果满足,就先调用:

  • contextCollapse.recoverFromOverflow(...)

也就是说,这里的优先级是:

先把已经 staged 但还没真正提交的 collapse 全部排空,再看够不够。

这是很合理的,因为 staged collapse 本来就是“已经准备好、只是还没落到主上下文”的压缩收益。

先吃这个收益,代价最低。

4.2 只有 drain 还不够,才会继续走 reactive compact

如果 drain 没救回来,或者根本没有 staged collapse,才会继续调:

  • reactiveCompact.tryReactiveCompact(...)

这说明 reactive compact 在恢复链里的位置,不是第一选择,而是:

overflow 发生后的第二道修复手段。

5. 媒体大小错误直接跳过 collapse,走 reactive compact

源码锚点:

  • src/query.ts:1074
  • src/query.ts:1083
  • src/query.ts:1119

媒体错误这条分支和 prompt-too-long 不一样。

源码注释已经写得很清楚:

  • collapse 不会 strip 图片
  • 所以媒体过大时,先 drain collapse 没意义

因此媒体错误会直接走:

  • reactiveCompact.tryReactiveCompact(...)

而且这里还有一个很重要的保护:

  • 如果 oversized media 恰好在 preserved tail 里
  • post-compact turn 仍然可能再次 media error
  • hasAttemptedReactiveCompact 会防止系统不断 spiral

这说明作者对这条链的认识很清楚:

不是所有错误都能被“再 compact 一次”解决,所以恢复路径本身也要有限流。

6. reactive compact 失败时,Claude Code 会故意跳过 stop hooks

源码锚点:

  • src/query.ts:1166
  • src/query.ts:1173
  • src/query.ts:1180

这点很值得单独记下来。

如果恢复失败,Claude Code 会:

  1. 把之前暂扣的错误消息重新 yield 出去
  2. executeStopFailureHooks(...)
  3. 直接 return

并且源码里明确写了:

不要再落回 stop hooks 正常路径。

原因是:

  • 模型根本没给出一条有效响应
  • 这时候跑 stop hooks 没有实际意义
  • hook 还可能继续注入更多 token
  • 最后形成 error -> hook blocking -> retry -> error 的死循环

这说明在 Claude Code 里,恢复失败不是普通 assistant turn 的一种变体,而是:

一种必须尽快切断后续治理链的终止态。

7. max_output_tokens 走的是另一套恢复逻辑

源码锚点:

  • src/query.ts:164
  • src/query.ts:1195
  • src/query.ts:1223
  • src/utils/context.ts:25

7.1 第一步先尝试“同请求升档”

如果 hit 了 max_output_tokens,Claude Code 的第一步不是 compact,而是:

  • 如果当前还没手动 override
  • 如果环境变量没强行指定
  • 就先把上限从 capped default 提升到 ESCALATED_MAX_TOKENS = 64_000

这一轮 transition 叫:

  • max_output_tokens_escalate

这个设计特别像生产系统里的“先放宽局部资源限额,再考虑重构请求”。

也很合理,因为 max_output_tokensprompt-too-long 是两回事:

  • 一个是输出没写完
  • 一个是输入根本塞不下

7.2 升档还不够,才进入多轮恢复

如果升到 64k 还不够,Claude Code 会最多再走 3 次恢复循环:

  • MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3

恢复方式不是 compact,而是往消息里插一条 meta user message:

  • 不要道歉
  • 不要 recap
  • 直接接着上次断点继续
  • 把剩余工作拆小

也就是说,这里在做的是:

把一次超长输出,切成几次连续输出。

它更像 continuation,而不是 compaction。

8. hasAttemptedReactiveCompact 这个 flag 很关键

源码锚点:

  • src/query.ts:209
  • src/query.ts:275
  • src/query.ts:1157
  • src/query.ts:1292
  • src/query.ts:1720

这个布尔值看起来不起眼,但其实是恢复回路里非常关键的护栏。

它的语义大概是:

  • 本 turn 里已经做过一次 reactive compact 了吗

作用主要有两个:

8.1 防止媒体错误或 PTL 一直重复 compact

如果 compact 已经跑过了,后面还继续出同样的 recoverable error,就不能无限再 compact。

8.2 防止 stop hook blocking 路径把 guard 重置掉

源码里还专门写了注释:

如果 reactive compact 已经跑过且没救回来,而 stop hook 又刚好产生 blocking error,这时候不能把这个 guard 重置掉。

否则会出现:

  • compact
  • 还是 too long
  • stop hook blocking
  • retry
  • 再 compact

最后烧掉大量 API 调用。

这说明作者不是只写了恢复路径,还专门盯着:

不同恢复路径之间的组合爆炸。

9. cached microcompact 的 boundary 为什么要延后发

源码锚点:

  • src/query.ts:866
  • src/query.ts:877
  • src/utils/messages.ts:4557

这里有个挺细但很漂亮的设计。

cached microcompact 在请求发出前就已经决定了:

  • 哪些 tool result 要删

但它不会立刻生成 boundary message。

它会等 API 返回后,再根据 assistant usage 里的:

  • cache_deleted_input_tokens

算出这次真实删掉了多少 token,然后再创建:

  • createMicrocompactBoundaryMessage(...)

这说明 Claude Code 不满足于“客户端估算大概删了多少”,而是尽量用:

服务端实际记账结果来回填边界信息。

这对后面的 telemetry、可视化和恢复分析都更靠谱。

10. blocking preempt 也会给恢复系统让路

源码锚点:

  • src/query.ts:590
  • src/query.ts:624
  • src/query.ts:633

Claude Code 在真正发请求前还有一层 blocking limit 检查。

但这个检查并不是永远先于恢复系统。

源码里明确写了几种要让路的情况:

  • reactive compact 开着且允许自动 compact
  • context collapse owns overflow
  • querySource 本身就是 compact/session_memory 这类恢复路径

原因很简单:

如果这里直接用 synthetic preempt 把请求挡掉,那么后面的:

  • reactive compact
  • collapse overflow recovery

根本没有机会看到真实错误,更谈不上恢复。

这说明 Claude Code 的优先级是:

宁可让一次真实 413 发出去,也不要因为前置拦截把恢复链饿死。

11. 这份快照里,关于 reactiveCompact.ts 能确认和不能确认的边界

这里要非常明确。

11.1 能确认的

query.ts 和周边调用点,我们能确定:

  • 存在 isReactiveCompactEnabled()
  • 存在 isWithheldPromptTooLong()
  • 存在 isWithheldMediaSizeError()
  • 存在 tryReactiveCompact(...)
  • 它和 buildPostCompactMessages()、deferred tool delta、MCP instructions delta 是一条正式路径
  • 它在工具搜索统计里是正式 call-site:reactive_compact

11.2 当前不能当成事实写死的

这份快照里看不到:

  • src/services/compact/reactiveCompact.ts

所以现在不能直接下结论的包括:

  • reactive compact 具体裁剪了哪些消息
  • 它和 full compact 是否共享同一 prompt 模板
  • 媒体 strip 的具体实现细节
  • preserved tail 的精确算法

因此这篇文档只把它当成:

query 层明确接入的一条恢复路径接口。

12. 如果转成 C#,我建议你怎么拆这层

12.1 恢复错误判定器

public interface IRecoverableErrorClassifier
{
    RecoverableErrorKind? Classify(AssistantMessage message);
}

至少区分:

  • PromptTooLong
  • MediaTooLarge
  • MaxOutputTokens

12.2 turn 级恢复编排器

public interface ITurnRecoveryLoop
{
    Task<RecoveryResult> TryRecoverAsync(
        QueryIterationContext context,
        AssistantMessage withheldError,
        CancellationToken ct);
}

它负责:

  • 是否先 drain collapse
  • 是否尝试 reactive compact
  • 是否改走 output continuation
  • 是否应该立刻终止

12.3 一个很重要的状态对象

public sealed class RecoveryState
{
    public bool HasAttemptedReactiveCompact { get; set; }
    public int MaxOutputTokensRecoveryCount { get; set; }
    public string? PreviousTransitionReason { get; set; }
}

Claude Code 这套恢复逻辑很依赖 turn 内状态。

如果你把它写成纯函数、又不保留这些标志位,很容易在组合路径上出死循环。

13. 这一层最值得抄的设计习惯

最后收一下,我觉得最值钱的是这五个点:

  1. 可恢复错误先暂扣,不要急着暴露给宿主。
  2. 不同错误类型要走不同恢复策略,不要只有一个“大一统 compact”。
  3. 恢复失败后要果断跳过后续普通治理节点,尤其是 stop hooks。
  4. 恢复路径也需要自己的 guard 和次数限制。
  5. 前置 blocking 逻辑要给真实恢复链让路。

14. 一句话总结

Claude Code 的这层设计,本质上是在把“API 报错了”从一个立即失败事件,改造成一个:

先尝试自救、确认救不回来再失败的 turn-level recovery protocol。