Claude Code 源码解析(五):Reactive Compact 与 413 恢复

接续第四篇proactive 上下文预处理(budget → snip → microcompact → collapse → autocompact),本文专讲其对称另一半:reactive 恢复——当 proactive 没压住、或刻意 reactive-only 时,真实 API 返回 413 / 媒体过大后,queryLoop 如何 withhold 错误、尝试抢救、再决定 surface 或 continue。

源码说明reactiveCompact.jscontextCollapse/index.js 等为 REACTIVE_COMPACT / CONTEXT_COLLAPSE feature-gated,外部 build 可能不存在;本文以 调用方 + 类型定义 + 共享工具 为准还原行为。

Proactive vs Reactive:同一问题的两面

Proactive(第四篇)Reactive(本篇)
触发时机调 API 之前,本地 token 估算调 API 之后,收到真实错误
典型手段snip / microcompact / collapse 投影 / autocompactcollapse draintryReactiveCompact
目标尽量不让请求失败请求已失败时 抢救一轮
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
flowchart LR
subgraph proactive [Proactive Phase 1]
P1[budget / snip / MC / collapse / autocompact]
end
subgraph api [API]
A[callModel]
end
subgraph reactive [Reactive 恢复]
R1[collapse drain]
R2[tryReactiveCompact]
R3[surface error]
end
P1 --> A
A -->|413 / media| R1
R1 -->|仍失败| R2
R2 -->|仍失败| R3
R1 -->|成功| A
R2 -->|成功| A

Reactive-only 模式(GrowthBook tengu_cobalt_raccoon):关闭 proactive autocompact,专门等 API 413 再 compact(autoCompact.ts ~195–198)。手动 /compact 在该模式下走 compactViaReactivecommands/compact/compact.ts ~87)。


413 错误如何变成一条 message

API 抛错后,getAssistantMessageFromErrorerrors.ts)将其规范为带 isApiErrorMessage 的 assistant message。

Prompt too long(413 / 400)

Vertex 常返回 413,直连 API 常返回 400,文案都含 prompt is too long

1
2
3
4
5
6
// errors.ts ~560–573
return createAssistantAPIErrorMessage({
content: PROMPT_TOO_LONG_ERROR_MESSAGE, // 固定串 "Prompt is too long" — UI 匹配用
error: 'invalid_request',
errorDetails: error.message, // 原始串,如 "137500 tokens > 135000 maximum"
})

判定: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 / isMediaSizeErrorMessageerrors.ts ~133–152)。content 是用户可读文案;errorDetails 存原始 API 串,供 reactive 判断是否可 strip 后重试。

错误类型恢复手段
413 PTLcollapse 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,但仍 pushassistantMessages,供后续恢复逻辑读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
let withheld = false
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(...)) withheld = true
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) {
yield yieldMessage
}
if (message.type === 'assistant') {
assistantMessages.push(message)
}

为什么要 withhold

  1. SDK / desktop:看到 error 字段可能 直接终止 session;恢复还在跑时泄露错误,调用方已退出,抢救无人接收(query.ts ~166–171 注释,与 max_output_tokens withhold 对称)。
  2. 用户体验:能恢复则不先展示失败;确认救不回来再 yield lastMessage

mediaRecoveryEnabled 必须 hoist

mediaRecoveryEnabled 在流式 算一次(query.ts ~626–627),流中与流后恢复必须用 同一值。否则可能出现「流里 withhold 了、流后认为 recovery 关闭」→ message 丢失。


恢复链:只在 !needsFollowUp 时执行

模型本轮 没有 tool_use(自然结束或纯错误)才进入恢复。有 tool 会先走 tool 执行路径。

1
2
3
4
5
6
7
8
9
10
11
flowchart TD
A[流结束 needsFollowUp=false] --> B{withheld 413?}
B -->|是| C{上轮 transition 不是 collapse_drain_retry?}
C -->|是| D[recoverFromOverflow]
D -->|committed > 0| E[continue transition=collapse_drain_retry]
D -->|0| F[tryReactiveCompact]
C -->|否| F
B -->|否| G{withheld media?}
G -->|是| F
F -->|成功| H[yield postCompact continue hasAttemptedReactiveCompact=true]
F -->|失败| I[yield error StopFailure hooks return]

代码: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
2
3
4
5
6
7
staged: Array<{
startUuid: string // span 起点 message uuid
endUuid: string // span 终点
summary: string // ctx-agent 写好的摘要正文
risk: number
stagedAt: number
}>

谁写入 staged queue

主要是 ctx-agentquerySource === 'marble_origami')在后台产出:

  1. 上下文涨到约 90% → 开始 commit 流程;约 95% → 可能 blocking spawn(autoCompact.ts ~201–205 注释)
  2. spawn ctx-agent,分析对话、选定 span、写好 summary
  3. 结果入 staged queue(内存)
  4. 每次 ctx-agent spawn 结束 → 快照写入 transcript(types/logs.ts ~274–275)

/context 可显示 N stagedcommands/context/context-noninteractive.ts ~128)。

「积压的 collapse」

已在 staged queue 里排队、但 还没 commit 的项。ctx-agent 一次可能产出多段;正常路径未必立刻全部 commit,对话继续变长时队列会堆着多段「摘要已写好、投影未生效」的 collapse——模型视图里 原文仍在,token 未降。

Commit 做什么

  1. 追加 marble-origami-commit(span 边界 uuid + summaryContent 等)
  2. 之后 applyCollapsesIfNeededprojectView 重放 commit log,模型视图里该 span 变为 <collapsed>
  3. 原始 user/assistant 仍在 transcript;commit 条目 重复存 archived 正文(types/logs.ts ~240–244)

Drain = 413 时一次性 commit 全部 staged

recoverFromOverflow(messagesForQuery, querySource)

  • 把 staged queue 里 所有 待处理项 一次性 commit(排空队列)
  • drained.committed > 0 → 更新 state.messagestransition: '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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const compacted = await reactiveCompact.tryReactiveCompact({
hasAttempted: hasAttemptedReactiveCompact,
querySource,
aborted: toolUseContext.abortController.signal.aborted,
messages: messagesForQuery,
cacheSafeParams: {
systemPrompt, userContext, systemContext,
toolUseContext,
forkContextMessages: messagesForQuery,
},
})

if (compacted) {
// task_budget 扣减(同 proactive autocompact)
const postCompactMessages = buildPostCompactMessages(compacted)
yield ...postCompactMessages
state = {
messages: postCompactMessages,
hasAttemptedReactiveCompact: true,
autoCompactTracking: undefined,
transition: { reason: 'reactive_compact_retry' },
...
}
continue
}
  • 产出与 proactive 相同:buildPostCompactMessages → boundary + summary + attachments
  • 成功后 autoCompactTrackingundefined(当作全新 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
2
3
4
// query.ts ~1168–1175
yield lastMessage
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: isWithheldMedia ? 'image_error' : 'prompt_too_long' }
  • 不走 handleStopHooks(会注入 blockingErrorscontinue,与 413 叠加 → error → hook → retry → error)
  • 只跑 StopFailure hooksexecuteStopFailureHooks),不往 history 塞继续对话的 blocking message

与 Blocking Preempt 的配合

发 API 的 synthetic 413(blocking_limitquery.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 autocompactAPI 前 Phase 1CONTEXT_COLLAPSE on → tengu_cobalt_raccoon reactive-only →
Blocking preemptAPI 前硬拦reactive/collapse + autocompact on →
Reactive compactAPI 后 413/media与 proactive 互补;collapse drain 为其前置便宜步骤

reactiveCompact.js 推断接口

导出调用处职责
isReactiveCompactEnabled()query.tsfeature + experiment gate
isReactiveOnlyMode()/compacttengu_cobalt_raccoon
isWithheldPromptTooLong(msg)流式 withholdPTL + gate
isWithheldMediaSizeError(msg)流式 withholdmedia + gate
tryReactiveCompact(opts)query 自动恢复CompactionResult
reactiveCompactOnPromptTooLong(...)手动 /compact同上,支持 customInstructions

共享可见逻辑

模块作用
grouping.ts groupMessagesByApiRound按新 assistant message.id 分组;reactive 从 tail 剥多轮
compact.ts stripImagesFromMessagesmedia 恢复:image/document → [image] / [document]
compact.ts getPromptTooLongTokenGap按 413 超出量一次 drop 多组
compact.ts buildPostCompactMessages统一 post-compact 消息结构
postCompactCleanup.tscompact 后清 module state

compact.ts ~238 注释:gated 的 compactMessages.ts从 tail 剥的 retry loop;proactive 的 truncateHeadForPTLRetry 是从 head 丢 的 fallback。

手动 /compact 的 reactive 路径

compactViaReactivecommands/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

  1. 默认 8k cap 命中 → 升到 ESCALATED_MAX_TOKENS(64k)同请求重试
  2. 仍不够 → 注入 meta user message「Output token limit hit…」,最多 3 次
  3. 耗尽才 yield withheld 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
2
3
4
5
6
7
8
9
10
11
12
Proactive 预处理完成 → callModel
↓ API 返回 413
流式 withhold(用户未见错误)
↓ needsFollowUp=false
recoverFromOverflow → committed=2(两段 staged collapse 一次性 commit)
↓ continue, transition=collapse_drain_retry
callModel 仍 413

tryReactiveCompact → CompactionResult
↓ buildPostCompactMessages → yield boundary + summary
↓ continue, hasAttemptedReactiveCompact=true
callModel 成功 → 正常 turn 继续

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 retrysrc/services/compact/compact.ts
proactive 互斥src/services/compact/autoCompact.ts
手动 reactive /compactsrc/commands/compact/compact.ts
collapse 持久化类型src/types/logs.ts marble-origami-*
collapse 恢复 transcriptsrc/utils/sessionRestore.ts
StopFailure hookssrc/utils/hooks.ts executeStopFailureHooks
reactive 实现(gated)src/services/compact/reactiveCompact.js
collapse 实现(gated)src/services/contextCollapse/index.js

小结

  1. Reactive 是 proactive 的对称补位:前者 API 前压 context,后者 API 失败后抢救
  2. Withhold:可恢复错误先不 yield,救不了再 surface
  3. 恢复顺序:413 → collapse drain(排空 staged queue,非 Git)→ reactive compact;media 跳过 drain
  4. Staged / commit:ctx-agent 写 staged queue;commit 让投影生效;drain = 413 时批量 commit
  5. hasAttemptedReactiveCompact + 不走 stop hooks:防死循环与 token 螺旋
  6. Blocking preempt 与 reactive/collapse 配合时需 skip synthetic 413,保留真实 API 错误作触发信号

下一篇可继续 compact 摘要 agent 内部流程compactConversation / session memory fork),或 Stop hooks + Attachments 管道(turn 末尾第二输入源)。