Claude Code 源码解析(五):Reactive Compact 与 413 恢复
接续第四篇的 proactive 上下文预处理(budget → snip → microcompact → collapse → autocompact),本文专讲其对称另一半:reactive 恢复——当 proactive 没压住、或刻意 reactive-only 时,真实 API 返回 413 / 媒体过大后,queryLoop 如何 withhold 错误、尝试抢救、再决定 surface 或 continue。
源码说明:
reactiveCompact.js、contextCollapse/index.js等为REACTIVE_COMPACT/CONTEXT_COLLAPSEfeature-gated,外部 build 可能不存在;本文以 调用方 + 类型定义 + 共享工具 为准还原行为。
Proactive vs Reactive:同一问题的两面
| Proactive(第四篇) | Reactive(本篇) | |
|---|---|---|
| 触发时机 | 调 API 之前,本地 token 估算 | 调 API 之后,收到真实错误 |
| 典型手段 | snip / microcompact / collapse 投影 / autocompact | collapse drain → tryReactiveCompact |
| 目标 | 尽量不让请求失败 | 请求已失败时 抢救一轮 |
1 | flowchart LR |
Reactive-only 模式(GrowthBook tengu_cobalt_raccoon):关闭 proactive autocompact,专门等 API 413 再 compact(autoCompact.ts ~195–198)。手动 /compact 在该模式下走 compactViaReactive(commands/compact/compact.ts ~87)。
413 错误如何变成一条 message
API 抛错后,getAssistantMessageFromError(errors.ts)将其规范为带 isApiErrorMessage 的 assistant message。
Prompt too long(413 / 400)
Vertex 常返回 413,直连 API 常返回 400,文案都含 prompt is too long:
1 | // errors.ts ~560–573 |
判定:isPromptTooLongMessage(msg) — isApiErrorMessage 且 content 里 text 以 "Prompt is too long" 开头。
Reactive 恢复会用 getPromptTooLongTokenGap(msg) 从 errorDetails 解析 超出多少 token,以便一次丢掉足够多的 API round 组,而不是一组一组剥(errors.ts ~104–117;proactive 侧同类逻辑见 compact.ts truncateHeadForPTLRetry)。
媒体过大(image / PDF / many-image)
平行路径:isMediaSizeError / isMediaSizeErrorMessage(errors.ts ~133–152)。content 是用户可读文案;errorDetails 存原始 API 串,供 reactive 判断是否可 strip 后重试。
| 错误类型 | 恢复手段 |
|---|---|
| 413 PTL | collapse drain → reactive compact(摘要) |
| 媒体过大 | 仅 reactive compact(stripImagesFromMessages 后重试) |
Media error 不走 collapse drain:collapse 投影只替换 text span,不 strip image/document。
流式 Withhold:先藏起来,再决定要不要给用户看
query.ts ~788–825:API 流中收到 assistant message 时,若为 可恢复错误,则 不 yield 给 UI/SDK,但仍 push 到 assistantMessages,供后续恢复逻辑读取。
1 | let withheld = false |
为什么要 withhold
- SDK / desktop:看到
error字段可能 直接终止 session;恢复还在跑时泄露错误,调用方已退出,抢救无人接收(query.ts~166–171 注释,与max_output_tokenswithhold 对称)。 - 用户体验:能恢复则不先展示失败;确认救不回来再
yield lastMessage。
mediaRecoveryEnabled 必须 hoist
mediaRecoveryEnabled 在流式 前 算一次(query.ts ~626–627),流中与流后恢复必须用 同一值。否则可能出现「流里 withhold 了、流后认为 recovery 关闭」→ message 丢失。
恢复链:只在 !needsFollowUp 时执行
模型本轮 没有 tool_use(自然结束或纯错误)才进入恢复。有 tool 会先走 tool 执行路径。
1 | flowchart TD |
代码:query.ts ~1062–1183。
Collapse drain 专节(非 Git 概念)
第四篇提到 context collapse 的 读时投影;本节说清楚 drain 涉及的 staged / commit 语义。
重要:下文 staged、commit、queue、drain 均不是 Git 概念,与
utils/git.ts、/commit命令无关,只是 Context Collapse 子系统借用了相近词汇。
双状态:Staged vs Committed
| 状态 | 含义 | 持久化 |
|---|---|---|
| Staged | 「这段 span 要压成摘要 S」已写好,尚未对模型视图生效 | 内存队列 + 快照 marble-origami-snapshot |
| Committed | 已登记,投影时 span → <collapsed>摘要</collapsed> | 追加 marble-origami-commit |
REPL messages[] 一般不删;commit 只影响 模型看到的投影视图(projectView)。
Staged queue 里有什么
types/logs.ts ContextCollapseSnapshotEntry:
1 | staged: Array<{ |
谁写入 staged queue
主要是 ctx-agent(querySource === 'marble_origami')在后台产出:
- 上下文涨到约 90% → 开始 commit 流程;约 95% → 可能 blocking spawn(
autoCompact.ts~201–205 注释) - spawn ctx-agent,分析对话、选定 span、写好 summary
- 结果入 staged queue(内存)
- 每次 ctx-agent spawn 结束 → 快照写入 transcript(
types/logs.ts~274–275)
/context 可显示 N staged(commands/context/context-noninteractive.ts ~128)。
「积压的 collapse」
已在 staged queue 里排队、但 还没 commit 的项。ctx-agent 一次可能产出多段;正常路径未必立刻全部 commit,对话继续变长时队列会堆着多段「摘要已写好、投影未生效」的 collapse——模型视图里 原文仍在,token 未降。
Commit 做什么
- 追加
marble-origami-commit(span 边界 uuid +summaryContent等) - 之后
applyCollapsesIfNeeded→projectView重放 commit log,模型视图里该 span 变为<collapsed> - 原始 user/assistant 仍在 transcript;commit 条目 不 重复存 archived 正文(
types/logs.ts~240–244)
Drain = 413 时一次性 commit 全部 staged
recoverFromOverflow(messagesForQuery, querySource):
- 把 staged queue 里 所有 待处理项 一次性 commit(排空队列)
drained.committed > 0→ 更新state.messages,transition: 'collapse_drain_retry',continue重试 API- 只 drain 一次:若上轮已是
collapse_drain_retry仍 413 → fall through 到 reactive compact - 队列空 →
committed === 0,跳过 drain
相对 reactive compact,drain 便宜(摘要已写好,只 commit);reactive compact 贵(fork 摘要 agent)。
tryReactiveCompact:最后手段
query.ts ~1119–1165:
1 | const compacted = await reactiveCompact.tryReactiveCompact({ |
- 产出与 proactive 相同:
buildPostCompactMessages→ boundary + summary + attachments - 成功后
autoCompactTracking置undefined(当作全新 compact 后上下文)
hasAttemptedReactiveCompact:每 turn 最多一次
| 场景 | 行为 |
|---|---|
| 首次 413,compact 成功 | true + continue |
| compact 后仍 413 / media error | 不再 compact,surface 错误 |
stop-hook blocking 后 continue | 不能 reset 该 flag(query.ts ~1292–1297) |
否则死循环:compact → 仍太长 → error → stop hook 注入 token → 再 compact → 无限 API 调用。
恢复失败:不走 handleStopHooks
1 | // query.ts ~1168–1175 |
- 不走
handleStopHooks(会注入blockingErrors再continue,与 413 叠加 → error → hook → retry → error) - 只跑
StopFailurehooks(executeStopFailureHooks),不往 history 塞继续对话的 blocking message
与 Blocking Preempt 的配合
发 API 前 的 synthetic 413(blocking_limit,query.ts ~637–647)在以下情况 skip:
| 条件 | 原因 |
|---|---|
reactive compact enabled 且 isAutoCompactEnabled() | 需要 真实 API 413 触发 reactive |
context collapse enabled 且 isAutoCompactEnabled() | 同上,drain 依赖真实 413 |
| 本轮刚 autocompact 成功 | stale usage 会误判 |
| snip 已 freeing token | 计数需减 snipTokensFreed |
querySource === 'compact' | 'session_memory' | fork agent 不能在此被拦死 |
用户设 DISABLE_AUTO_COMPACT 时 仍 可能走 blocking preempt(「不要自动 anything」优先于 reactive 路径)。
三种机制的互斥关系
| 机制 | 何时 | 与其它 |
|---|---|---|
| Proactive autocompact | API 前 Phase 1 | CONTEXT_COLLAPSE on → 关;tengu_cobalt_raccoon reactive-only → 关 |
| Blocking preempt | API 前硬拦 | reactive/collapse + autocompact on → 关 |
| Reactive compact | API 后 413/media | 与 proactive 互补;collapse drain 为其前置便宜步骤 |
reactiveCompact.js 推断接口
| 导出 | 调用处 | 职责 |
|---|---|---|
isReactiveCompactEnabled() | query.ts | feature + experiment gate |
isReactiveOnlyMode() | /compact | tengu_cobalt_raccoon |
isWithheldPromptTooLong(msg) | 流式 withhold | PTL + gate |
isWithheldMediaSizeError(msg) | 流式 withhold | media + gate |
tryReactiveCompact(opts) | query 自动恢复 | → CompactionResult |
reactiveCompactOnPromptTooLong(...) | 手动 /compact | 同上,支持 customInstructions |
共享可见逻辑
| 模块 | 作用 |
|---|---|
grouping.ts groupMessagesByApiRound | 按新 assistant message.id 分组;reactive 从 tail 剥多轮 |
compact.ts stripImagesFromMessages | media 恢复:image/document → [image] / [document] |
compact.ts getPromptTooLongTokenGap | 按 413 超出量一次 drop 多组 |
compact.ts buildPostCompactMessages | 统一 post-compact 消息结构 |
postCompactCleanup.ts | compact 后清 module state |
compact.ts ~238 注释:gated 的 compactMessages.ts 有 从 tail 剥的 retry loop;proactive 的 truncateHeadForPTLRetry 是从 head 丢 的 fallback。
手动 /compact 的 reactive 路径
compactViaReactive(commands/compact/compact.ts ~139–227):
- 并发
executePreCompactHooks+getCacheSharingParams - 调
reactiveCompactOnPromptTooLong(内部跑 PostCompact hooks) - 失败 reason:
too_few_groups/aborted/exhausted/media_unstrippable等
邻接:max_output_tokens 恢复(同一 withhold 框架)
同一段 !needsFollowUp 内(query.ts ~1185–1256),与 413 共用 withhold,但 不走 compact:
- 默认 8k cap 命中 → 升到
ESCALATED_MAX_TOKENS(64k)同请求重试 - 仍不够 → 注入 meta user message「Output token limit hit…」,最多 3 次
- 耗尽才
yieldwithheld error
五个自检问题(含答案)
1. 为什么 413 在流式里不立刻显示?
可恢复错误先 withhold。SDK/desktop 见 error 可能终止 session;恢复成功则用户无需看到中间失败。确认救不回来再 yield。
2. 为什么先 collapse drain,再 reactive compact?media 为何跳过 collapse?
先便宜后昂贵:drain 用已 staged 的摘要 commit;reactive 要 fork 整段摘要。collapse 不处理 image/PDF,media 必须走 strip + reactive。
3. hasAttemptedReactiveCompact 防什么?
每 user turn 最多自动 reactive compact 一次;stop-hook 路径不得 reset,防 compact ↔ error ↔ hook 死循环。
4. 为什么 413 失败后不能走 handleStopHooks?
模型无有效回复;stop hook 会注入更多 token 再 continue,形成 error → hook → retry → error 螺旋。只跑 StopFailure hooks 并 return。
5. proactive autocompact、blocking preempt、reactive 何时互斥?
collapse on 或 reactive-only → proactive off;reactive/collapse + autocompact on → blocking preempt off(要真 413);DISABLE_AUTO_COMPACT → blocking 仍可能拦。
一次 413 恢复的数据流示例
1 | Proactive 预处理完成 → callModel |
若 tryReactiveCompact 返回 null → yield withheld 413 → StopFailure hooks → return { reason: 'prompt_too_long' }。
关键文件索引
| 主题 | 文件 |
|---|---|
| 恢复编排 | src/query.ts ~621–647, ~788–825, ~1062–1183 |
| PTL / media 错误 | src/services/api/errors.ts |
| API round 分组 | src/services/compact/grouping.ts |
| stripImages / PTL retry | src/services/compact/compact.ts |
| proactive 互斥 | src/services/compact/autoCompact.ts |
| 手动 reactive /compact | src/commands/compact/compact.ts |
| collapse 持久化类型 | src/types/logs.ts marble-origami-* |
| collapse 恢复 transcript | src/utils/sessionRestore.ts |
| StopFailure hooks | src/utils/hooks.ts executeStopFailureHooks |
| reactive 实现(gated) | src/services/compact/reactiveCompact.js |
| collapse 实现(gated) | src/services/contextCollapse/index.js |
小结
- Reactive 是 proactive 的对称补位:前者 API 前压 context,后者 API 失败后抢救
- Withhold:可恢复错误先不 yield,救不了再 surface
- 恢复顺序:413 → collapse drain(排空 staged queue,非 Git)→ reactive compact;media 跳过 drain
- Staged / commit:ctx-agent 写 staged queue;commit 让投影生效;drain = 413 时批量 commit
hasAttemptedReactiveCompact+ 不走 stop hooks:防死循环与 token 螺旋- Blocking preempt 与 reactive/collapse 配合时需 skip synthetic 413,保留真实 API 错误作触发信号
下一篇可继续 compact 摘要 agent 内部流程(compactConversation / session memory fork),或 Stop hooks + Attachments 管道(turn 末尾第二输入源)。