Claude Code 源码解析(四):上下文预处理全链路

接续第三篇对 queryLoop 各 phase 的概述,本文专讲 Phase 1:上下文预处理——从 REPL 全量 history 到 messagesForQuery 的完整变换链,以及 tool result budget、preview 落盘与 cache editing 三条容易混淆的子系统。

设计原则:阶梯式压缩

预处理不是「选一个策略」,而是 从轻到重、依次尝试 的流水线:

1
2
3
4
5
6
getMessagesAfterCompactBoundary(切片 + snip 投影)
→ applyToolResultBudget(内容替换,非 compact)
→ snip(删 message)
→ microcompact(清 tool 结果 / cache edit)
→ context collapse(span 摘要,读时投影)
→ autocompact(整段摘要,替换 messages)

越往后越「破坏性」;前面步骤能把 token 压到阈值以下,后面就 no-op,尽量保留细粒度上下文。

代码入口:query.ts ~365–468 行,每轮 while (true) iteration 的 API 调用前执行。

1
2
3
4
5
6
7
8
flowchart LR
A[REPL 全量 messages] --> B[boundary 切片]
B --> C[budget 替换]
C --> D[snip]
D --> E[microcompact]
E --> F[collapse 投影]
F --> G[autocompact]
G --> H[messagesForQuery → API]

双视图分离

存储内容
REPL messages[]全量 history(compact/snip 前内容仍可 UI 回看)
messagesForQuery模型实际看到的子集(切片 + 投影 + 替换后)

/context 命令用同样变换(getMessagesAfterCompactBoundary + projectView),保证展示 token 与 API 一致。


Step 0:getMessagesAfterCompactBoundary

1
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]

做什么utils/messages.ts):

  1. findLastCompactBoundaryIndex — 从后往前找最后一个 compact_boundary system message
  2. messages.slice(boundaryIndex) — 只保留 boundary 及之后 的消息
  3. HISTORY_SNIP 开启 → projectSnippedView 过滤已 snip 的消息

boundary 本身是 system message,发 API 时由 normalizeMessagesForAPI 过滤掉;其语义是「此线之前的历史已被 autocompact 摘要,不再送入模型」。

Snip 双视图与 [id:xxx] tag

Snip 不是简单「删数组元素」:

行为
REPL messages[]保留被 snip 的 message(UI 可滚动回看)
getMessagesAfterCompactBoundary默认再跑 projectSnippedView,过滤已 snip 段
normalizeMessagesForAPI给非 meta 的 user message 末尾追加 [id:xxxxxx](由 uuid 派生的 6 位 base36)

[id:xxx] 只出现在 API-bound 副本,不改 REPL 存储;供 SnipTool 引用「删哪条 message」。因此 snip 是 双视图 + 模型侧 ID 标注,不是单纯 splice。


Step 0.5:applyToolResultBudget — 单条 user message 体积上限

1
2
3
4
5
6
messagesForQuery = await applyToolResultBudget(
messagesForQuery,
toolUseContext.contentReplacementState,
persistReplacements ? writeToTranscript : undefined,
skipToolNames, // Read 等 maxResultSizeChars: Infinity 的工具
)

性质:不是 compact,而是 内容替换——超大 tool_result 落盘 + 替换成短 preview。

触发条件

wire-level user message 聚合:同一 API user turn 里多个 tool_result 的字符数 合计 超过 budget(默认 200,000 chars,GrowthBook tengu_hawthorn_window 可覆盖)。

collectCandidatesByMessage 按与 normalizeMessagesForAPI 相同的 merge 规则分组——并行 tool 在 state 里是 N 条 user message,上 API 时合并成一条,budget 必须在合并粒度上 enforcement。

分组边界细节toolResultStorage.ts 注释):

  • 只有 assistant message 才 flush 分组;progress / attachment / system(local_command) 在 normalize 时会被 merge 或过滤,不能当边界
  • Streaming 会把同一 turn 拆成多条 assistant(同一 message.id);budget 用 seenAsstIds 跟踪——同一 id 重现时不 flush,否则 abort 中途 parallel tools 会漏检
  • 已是 <persisted-output> 前缀的内容(单-tool persist 或上轮 budget 产物)→ isContentAlreadyCompacted 跳过,不再参与候选
  • 含 image block 的 tool_result 跳过(不能替成纯文本 preview)

frozen 合计 alone 已超 budget,接受 overage 不再替换——microcompact / autocompact 最终会清掉。

选哪些 result 替换

selectFreshToReplace:只对 fresh(首次见到的 tool_use_id)操作,按 size 从大到小 选,直到 frozenSize + remainingFresh ≤ limit

状态行为
mustReapply之前 replace 过 → 从 replacements Map 原样重放同一字符串(prompt cache 字节稳定)
frozen之前 seen 但未 replace → 永不再动
fresh本 turn 新 message → 可参与 budget 决策

Feature gate:tengu_hawthorn_steeplestate === undefined 时整步 no-op。

Preview 怎么来的

不是 LLM 摘要,是 原文前 ~2000 字节 + 固定 XML 模板。

  1. persistToolResult 把全文写入
    ~/.claude/projects/<repo>/<sessionId>/tool-results/<toolUseId>.txt(或 .json
    不是 Auto Memory 的 memdir。

  2. generatePreview(contentStr, PREVIEW_SIZE_BYTES=2000)

    • 全文 ≤ 2000 字节 → preview = 全文
    • 否则取前 2000 字节,尽量在 最后一个 \n 处截断(且 \n 位置 > 1000 字节)
    • hasMore = true 时模板末尾加 \n...\n
  3. buildLargeToolResultMessage 包装:

1
2
3
4
5
6
7
<persisted-output>
Output too large (1.2 MB). Full output saved to: .../tool-results/<id>.txt

Preview (first 2.0 KB):
<原文前缀>
...
</persisted-output>
  1. replaceToolResultContents 把 tool_result 的 content 换成上述字符串。

Resume:完整 replacement 字符串写入 transcript 的 content-replacement record;reconstructContentReplacementState 直接读 record,不再读盘重算,避免模板变更破坏 cache。

与单-tool 阈值路径的关系

单-tool 阈值Message budget
时机tool 执行完(processToolResultBlockquery 预处理
条件单个 result > tool 的 maxResultSizeChars同条 user message 合计 > 200k
Preview 逻辑相同相同

单-tool 路径先 replace;budget 是对「合并后仍过大」的兜底。


Step 1:Snip(HISTORY_SNIP

1
2
3
const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
messagesForQuery = snipResult.messages
snipTokensFreed = snipResult.tokensFreed

源码:services/compact/snipCompact.js(feature-gated,外部 build 可能不存在)。

  • 删除整条 message 段(模型 /snip 或 token 超阈值自动 snip)
  • REPL 保留全量;projectSnippedView 在 Step 0 已过滤
  • 产出 snipTokensFreed → 传给 autocompact(见下)

Step 2:Microcompact

1
2
3
const microcompactResult = await deps.microcompact(messagesForQuery, toolUseContext, querySource)
messagesForQuery = microcompactResult.messages
const pendingCacheEdits = microcompactResult.compactionInfo?.pendingCacheEdits

入口:microcompactMessages()services/compact/microCompact.ts)。

分支优先级

1
2
3
4
5
1. Time-based microcompact(距上次 assistant 超过 gap 阈值)
↓ 未触发
2. Cached microcompact(cache editing,主线程 + 支持模型)
↓ 不可用
3. Legacy 路径已移除 → return { messages } 不变

Time-based MC

  • 触发:cache 已冷(gap > gapThresholdMinutes
  • 动作:除最近 N 个外,compactable tool 的 result content → '[Old tool result content cleared]'
  • 直接 mutate 本地 content(cache 反正失效)
  • 触发时 跳过 cached MC
  • 副作用:resetMicrocompactState()(清 module 级 cached MC 注册表,否则下轮会对已不存在的 cache entry 发 delete);notifyCacheDeletion 抑制 prompt cache break 误报

Cached MC(Cache Editing)

见下文专节。要点:本地 messages 不变,删的是服务端 prompt cache 里的 tool 内容。

Deferred boundary

cached MC 删除 tool 后,boundary message defer 到 API 响应后,用真实 cache_deleted_input_tokens delta yield(query.ts ~870)。


Step 3:Context Collapse(CONTEXT_COLLAPSE

1
2
const collapseResult = await contextCollapse.applyCollapsesIfNeeded(...)
messagesForQuery = collapseResult.messages

模型

概念说明
REPL history完整 messages,archived 段仍在
Collapse storecommit log(transcript 存 marble-origami-commit
projectView读时投影:span → <collapsed>summary</collapsed>
  • 不 yield 到 REPL — summary 在 store 里,不在 messages 数组
  • 在 autocompact 之前:collapse 压到阈值下 → autocompact no-op
  • shouldAutoCompact 在 collapse enabled 时 return false
  • 413 恢复:recoverFromOverflow drain staged collapses

Step 4:Autocompact

1
2
3
4
5
const { compactionResult } = await deps.autocompact(...)
if (compactionResult) {
yield ...buildPostCompactMessages(compactionResult)
messagesForQuery = postCompactMessages
}

阈值(autoCompact.ts

1
2
3
effectiveWindow = contextWindow(model) - reservedForSummary(~20k)
autoCompactThreshold = effectiveWindow - 13_000
blockingLimit = effectiveWindow - 3_000 (autocompact 关闭时的硬拦截)

token 计数:tokenCountWithEstimation(messages) - snipTokensFreed

两条 compact 路径

1
2
3
4
5
flowchart TD
T[shouldAutoCompact] --> SM[trySessionMemoryCompaction]
SM -->|成功| OUT1[boundary + summary + messagesToKeep]
SM -->|null| LEG[compactConversation]
LEG --> OUT2[fork agent 生成整段摘要]

Session Memory Compact(实验优先):保留 tail + session-memory 文件摘要。

Legacy compactConversation:PreCompact hooks → fork 子 agent 调 API 摘要 → 产出:

1
2
3
4
5
6
7
{
boundaryMarker, // system compact_boundary
summaryMessages,
messagesToKeep?,
attachments,
hookResults,
}

buildPostCompactMessages 顺序:boundary → summary → keep → attachments → hooks。

compact_boundary 不只标记切点,还携带 metadata:

  • preCompactDiscoveredTools — autocompact 摘要会丢 tool_reference,boundary 保存 compact 前已 discover 的 deferred tool 名,供 post-compact schema 继续发送

成功后 yield 并替换 messagesForQueryrunPostCompactCleanup 清理 module 级 state(microcompact、collapse store、getUserContext cache、classifier approvals 等)。subagent compact 不能 reset main thread 的 module state——querySource.startsWith('repl_main_thread') 才做 collapse/memory 重置。

不触发 / 跳过的情况

  • DISABLE_COMPACT / DISABLE_AUTO_COMPACT / 用户关闭 autoCompact
  • querySource === 'compact' | 'session_memory'(fork agent 需跑完 compact 降 token,此处 block 会 deadlock)
  • querySource === 'marble_origami'(ctx-agent compact 会 reset 主线程 collapse commit log)
  • reactive-only(REACTIVE_COMPACT + tengu_cobalt_raccoon)/ context collapse enabled
  • 连续失败 ≥ 3(circuit breaker)

各步骤对比表

步骤删 message改 tool_result 本地 content替换整段 history动 REPL 存储Prompt cache
boundary 切片逻辑丢弃 boundary 前部分boundary 后 prefix 稳定
tool result budget替换为 preview可选 persistseenIds 冻结决策
snip是(投影)部分变 prefix
time-based MC清空 content故意 invalidate
cached MC(API cache edit)preserve prefix
context collapse否(投影)读时投影视 commit
autocompact完全替换新 prefix

Cache Editing 专节

为什么需要

Prompt caching 缓存对话前缀。直接改 message 正文 → 整段 cache 失效。Cache editing 在 不改本地 transcript 的前提下,让 API 从 cache 里删掉指定 tool_result 块

与 Context Management 的区别

Cache editing(Cached MC)Context editing(API)
配置message 内 cache_edits / cache_reference请求体 context_management.edits
Betacache editing beta(ant 动态 import)context-management-2025-06-27
谁决定删客户端 cachedMicrocompact服务端 trigger/keep
Claude Code主线程 microcompactgetAPIContextManagement()(thinking 清理等)

API 语义

1. cache_reference — 挂在 cache 前缀内、最后一个 cache_control 之前 的 tool_result 上:

1
{ type: 'tool_result', tool_use_id: '...', cache_reference: '<tool_use_id>', ... }

2. cache_edits — 插在 user message content 里(通常在 tool_result 之后):

1
2
3
4
{
type: 'cache_edits',
edits: [{ type: 'delete', cache_reference: '<tool_use_id>' }]
}

响应 usage 含 cache_deleted_input_tokens(累积值,query 里减 baseline 得 delta)。

Claude Code 链路

1
2
3
4
5
6
7
8
9
10
11
12
flowchart TD
A[microcompactMessages] --> B[cachedMicrocompactPath]
B --> C[注册 tool_result 到 cachedMCState]
C --> D{数量 > triggerThreshold?}
D -->|是| E[createCacheEditsBlock → pendingCacheEdits]
D -->|否| F[不变]
E --> G[claude.ts consumePendingCacheEdits]
G --> H[addCacheBreakpoints 注入]
H --> I[插 pinned + new cache_edits]
I --> J[加 cache_reference 到 prefix tool_results]
J --> K[API 请求]
K --> L[query.ts yield microcompact boundary]

启用条件claude.ts):

  • CACHED_MICROCOMPACT feature + isCachedMicrocompactEnabled()
  • isModelSupportedForCacheEditing(model)
  • getAPIProvider() === 'firstParty'
  • querySource.startsWith('repl_main_thread')(含 outputStyle 变体)
  • cache editing beta header session latch

Pinned edits:历史 cache_edits 按原 user message index 每轮重发,保证多轮指令一致。

实现细节

  • consumePendingCacheEdits()claude.ts 请求构建前只 consume 一次——paramsFromContext 会被 logging/retry 多次调用,不能在里面 consume
  • API 流式成功后 markToolsSentToAPIState() 更新 registeredTools,与 delete 决策对齐
  • querySource 判定用 startsWith('repl_main_thread')(含 outputStyle 变体),不是裸字符串相等
  • main thread 跑 cached MC——subagent 若注册 tool 会污染全局 cachedMCState

API context_management(与 Cached MC 同请求可并存)

getAPIContextManagement()apiMicrocompact.ts)在 claude.ts 里作为请求体 context_management.edits 发出,与 message 内的 cache_edits 不同层

Strategy触发 / 条件
clear_thinking_20251015有 thinking 且非 redact-thinking;idle >1h(thinkingClearLatched)时 keep 1 turn,否则 keep all
clear_tool_uses_20250919ant-only + env USE_API_CLEAR_TOOL_RESULTS / USE_API_CLEAR_TOOL_USES;服务端按 input_tokens trigger 清 tool results / tool uses

这是 服务端 在超阈值时清 context;本地 budget/snip/microcompact 是 客户端 预处理。一次请求可能同时带 cache_edits(客户端指定删哪些 cache tool)和 context_management(服务端策略)。


与 Phase 2 Blocking Limit

预处理完成后、API 前还有硬拦截(query.ts ~637):

1
2
3
4
if (isAtBlockingLimit) {
yield createAssistantAPIErrorMessage({ content: PROMPT_TOO_LONG_ERROR_MESSAGE })
return { reason: 'blocking_limit' }
}

仅在 autocompact 关闭 时生效。以下情况 skip synthetic 413,让真实 API 413 走 reactive/collapse 恢复链:

  • 本轮刚 autocompact 成功(compactionResult 存在)——tokenCountWithEstimation 会用 kept messages 上 stale 的 usage,误判仍超限
  • snip 已 freeing token → 计数减去 snipTokensFreed(protected-tail assistant 的 usage 看不到 snip 收益)
  • reactive compact enabled + autocompact allowed
  • context collapse enabled + autocompact allowed
  • querySource === 'compact' | 'session_memory'

用户显式 DISABLE_AUTO_COMPACT 时仍走 blocking preempt(「不要自动 anything」的配置优先)。


预处理之后:normalizeMessagesForAPI 延伸

严格在 query Phase 1 之后、API 序列化时执行,但影响模型最终看到的 context:

  • orphan thinking-only assistant 过滤、trailing thinking strip(顺序有依赖,见 messages.ts 注释)
  • attachment → meta user message,可能 merge 进上一条 user
  • [id:xxx] tag 注入(snip 配套)
  • ensureToolResultPairing — 修 orphan / duplicate tool_use(API 前最后一道)

这些不算 Phase 1 步骤,但与预处理产物共同构成 wire payload。


一次 iteration 数据流示例

1
2
3
4
5
6
7
8
9
10
REPL: 500 条 messages
↓ getMessagesAfterCompactBoundary(compact 在 #200)
messagesForQuery: #200..#500
↓ applyToolResultBudget — 80MB Read → <persisted-output> preview
↓ snip — 删 #210-#280, tokensFreed=40k
↓ cached microcompact — pendingCacheEdits={deletedToolIds:[...]},本地不变
↓ context collapse — #300-#350 投影为 <collapsed>
↓ autocompact — 仍超阈值 → yield [boundary, summary, tail],替换为 20 条
↓ callModel(prependUserContext(messagesForQuery))
API: 压缩 history + cache_edits block + cache_reference 标注

关键文件索引

主题文件
预处理编排src/query.ts ~365–468
boundary / snip 投影src/utils/messages.ts
tool result budget / previewsrc/utils/toolResultStorage.ts
microcompactsrc/services/compact/microCompact.ts
autocompact 阈值src/services/compact/autoCompact.ts
compact 摘要src/services/compact/compact.ts
session memory compactsrc/services/compact/sessionMemoryCompact.ts
API context_managementsrc/services/compact/apiMicrocompact.ts
cache editing 注入src/services/api/claude.ts addCacheBreakpoints
content 插入位置src/utils/contentArray.ts
collapse transcript 类型src/types/logs.ts marble-origami-*

小结

Claude Code 上下文预处理可以概括为:

  1. 阶梯式:budget → snip → microcompact → collapse → autocompact,前面成功则后面常 no-op
  2. 双视图:REPL 全量 vs messagesForQuery 模型视图;snip/collapse 多靠投影;snip 另配 API 侧 [id:xxx]
  3. Budget 状态机:fresh / frozen / mustReapply + wire-level 分组对齐 normalize;resume 靠 contentReplacements 重建
  4. Preview:原文前 2000 字节 + <persisted-output> 模板,落盘在 session tool-results/
  5. Microcompact 三路径:time-based(mutate + reset cached state)→ cached MC(API cache edit)→ no-op
  6. Cache editing vs context_management:前者客户端指定删 cache tool;后者服务端策略(含 idle thinking 清理)
  7. snipTokensFreed + post-compact skip:修正 stale usage,避免 blocking preempt 误拦

下一篇可继续 compact 摘要 agent 内部流程compactConversation / session memory fork),或 reactive compact + 413 恢复 全链路。