第二十三章:溢出错误为什么还能自恢复
拆 prompt-too-long、媒体超限和 max_output_tokens 这些可恢复错误的回路。
1. 为什么这一层值得单独拆
前面几篇我们已经把上下文治理的“预防层”拆得差不多了:
Tool Result Budgetmicrocompactsession memory compactfull compact
但 Claude Code 还有一层同样关键的东西:
请求已经发出去了,模型或者 API 已经报错了,这时候怎么救。
这层不是预防,而是恢复。
而且它不是简单看到错误就返回给用户,而是:
- 先判断这个错能不能救
- 能救就先别把错误往外吐
- 在 query 循环内部走恢复分支
- 确认真救不回来以后,再把错误暴露出去
这套逻辑主要落在:
src/query.tssrc/services/api/errors.tssrc/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:790src/query.ts:807src/query.ts:815src/query.ts:820
在流式循环里,Claude Code 会先检查一条错误消息是不是属于“可恢复错误”。
当前能明确看到的几类是:
prompt too long- 媒体大小错误
max_output_tokens
命中后它不会立刻 yield 给外部,而是先把这条错误留在内部状态里。
这就是源码里的 withheld 语义。
这个设计非常重要,因为很多宿主一旦看到错误就会直接把本轮会话判死。
如果 Claude Code先把错误吐出去,再在内部偷偷恢复,那恢复根本没有意义,外面的调用方早就不等了。
所以它的策略是:
恢复能不能成功,必须先在 query loop 内部判定完。
4. prompt-too-long 的恢复,不是只有一种方案
源码锚点:
src/query.ts:1070src/query.ts:1087src/query.ts:1094src/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:1074src/query.ts:1083src/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:1166src/query.ts:1173src/query.ts:1180
这点很值得单独记下来。
如果恢复失败,Claude Code 会:
- 把之前暂扣的错误消息重新
yield出去 - 调
executeStopFailureHooks(...) - 直接 return
并且源码里明确写了:
不要再落回 stop hooks 正常路径。
原因是:
- 模型根本没给出一条有效响应
- 这时候跑 stop hooks 没有实际意义
- hook 还可能继续注入更多 token
- 最后形成
error -> hook blocking -> retry -> error的死循环
这说明在 Claude Code 里,恢复失败不是普通 assistant turn 的一种变体,而是:
一种必须尽快切断后续治理链的终止态。
7. max_output_tokens 走的是另一套恢复逻辑
源码锚点:
src/query.ts:164src/query.ts:1195src/query.ts:1223src/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_tokens 和 prompt-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:209src/query.ts:275src/query.ts:1157src/query.ts:1292src/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:866src/query.ts:877src/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:590src/query.ts:624src/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. 这一层最值得抄的设计习惯
最后收一下,我觉得最值钱的是这五个点:
- 可恢复错误先暂扣,不要急着暴露给宿主。
- 不同错误类型要走不同恢复策略,不要只有一个“大一统 compact”。
- 恢复失败后要果断跳过后续普通治理节点,尤其是 stop hooks。
- 恢复路径也需要自己的 guard 和次数限制。
- 前置 blocking 逻辑要给真实恢复链让路。
14. 一句话总结
Claude Code 的这层设计,本质上是在把“API 报错了”从一个立即失败事件,改造成一个:
先尝试自救、确认救不回来再失败的 turn-level recovery protocol。