Claude Code 源码解析(四):上下文预处理全链路
接续第三篇对 queryLoop 各 phase 的概述,本文专讲 Phase 1:上下文预处理——从 REPL 全量 history 到 messagesForQuery 的完整变换链,以及 tool result budget、preview 落盘与 cache editing 三条容易混淆的子系统。
设计原则:阶梯式压缩
预处理不是「选一个策略」,而是 从轻到重、依次尝试 的流水线:
1 | getMessagesAfterCompactBoundary(切片 + snip 投影) |
越往后越「破坏性」;前面步骤能把 token 压到阈值以下,后面就 no-op,尽量保留细粒度上下文。
代码入口:query.ts ~365–468 行,每轮 while (true) iteration 的 API 调用前执行。
1 | flowchart LR |
双视图分离:
| 存储 | 内容 |
|---|---|
REPL messages[] | 全量 history(compact/snip 前内容仍可 UI 回看) |
messagesForQuery | 模型实际看到的子集(切片 + 投影 + 替换后) |
/context 命令用同样变换(getMessagesAfterCompactBoundary + projectView),保证展示 token 与 API 一致。
Step 0:getMessagesAfterCompactBoundary
1 | let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)] |
做什么(utils/messages.ts):
findLastCompactBoundaryIndex— 从后往前找最后一个compact_boundarysystem messagemessages.slice(boundaryIndex)— 只保留 boundary 及之后 的消息- 若
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 | messagesForQuery = await applyToolResultBudget( |
性质:不是 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_steeple;state === undefined 时整步 no-op。
Preview 怎么来的
不是 LLM 摘要,是 原文前 ~2000 字节 + 固定 XML 模板。
persistToolResult把全文写入
~/.claude/projects/<repo>/<sessionId>/tool-results/<toolUseId>.txt(或.json)
不是 Auto Memory 的 memdir。generatePreview(contentStr, PREVIEW_SIZE_BYTES=2000):- 全文 ≤ 2000 字节 → preview = 全文
- 否则取前 2000 字节,尽量在 最后一个
\n处截断(且\n位置 > 1000 字节) hasMore = true时模板末尾加\n...\n
buildLargeToolResultMessage包装:
1 | <persisted-output> |
replaceToolResultContents把 tool_result 的content换成上述字符串。
Resume:完整 replacement 字符串写入 transcript 的 content-replacement record;reconstructContentReplacementState 直接读 record,不再读盘重算,避免模板变更破坏 cache。
与单-tool 阈值路径的关系
| 单-tool 阈值 | Message budget | |
|---|---|---|
| 时机 | tool 执行完(processToolResultBlock) | query 预处理 |
| 条件 | 单个 result > tool 的 maxResultSizeChars | 同条 user message 合计 > 200k |
| Preview 逻辑 | 相同 | 相同 |
单-tool 路径先 replace;budget 是对「合并后仍过大」的兜底。
Step 1:Snip(HISTORY_SNIP)
1 | const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery) |
源码:services/compact/snipCompact.js(feature-gated,外部 build 可能不存在)。
- 删除整条 message 段(模型
/snip或 token 超阈值自动 snip) - REPL 保留全量;
projectSnippedView在 Step 0 已过滤 - 产出
snipTokensFreed→ 传给 autocompact(见下)
Step 2:Microcompact
1 | const microcompactResult = await deps.microcompact(messagesForQuery, toolUseContext, querySource) |
入口:microcompactMessages()(services/compact/microCompact.ts)。
分支优先级
1 | 1. Time-based microcompact(距上次 assistant 超过 gap 阈值) |
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 | const collapseResult = await contextCollapse.applyCollapsesIfNeeded(...) |
模型:
| 概念 | 说明 |
|---|---|
| REPL history | 完整 messages,archived 段仍在 |
| Collapse store | commit 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 恢复:
recoverFromOverflowdrain staged collapses
Step 4:Autocompact
1 | const { compactionResult } = await deps.autocompact(...) |
阈值(autoCompact.ts)
1 | effectiveWindow = contextWindow(model) - reservedForSummary(~20k) |
token 计数:tokenCountWithEstimation(messages) - snipTokensFreed
两条 compact 路径
1 | flowchart TD |
Session Memory Compact(实验优先):保留 tail + session-memory 文件摘要。
Legacy compactConversation:PreCompact hooks → fork 子 agent 调 API 摘要 → 产出:
1 | { |
buildPostCompactMessages 顺序:boundary → summary → keep → attachments → hooks。
compact_boundary 不只标记切点,还携带 metadata:
preCompactDiscoveredTools— autocompact 摘要会丢tool_reference,boundary 保存 compact 前已 discover 的 deferred tool 名,供 post-compact schema 继续发送
成功后 yield 并替换 messagesForQuery;runPostCompactCleanup 清理 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/ 用户关闭 autoCompactquerySource === '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 | 否 | 可选 persist | seenIds 冻结决策 |
| 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 |
| Beta | cache editing beta(ant 动态 import) | context-management-2025-06-27 |
| 谁决定删 | 客户端 cachedMicrocompact | 服务端 trigger/keep |
| Claude Code | 主线程 microcompact | getAPIContextManagement()(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 | { |
响应 usage 含 cache_deleted_input_tokens(累积值,query 里减 baseline 得 delta)。
Claude Code 链路
1 | flowchart TD |
启用条件(claude.ts):
CACHED_MICROCOMPACTfeature +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_20250919 | ant-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 | if (isAtBlockingLimit) { |
仅在 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 | REPL: 500 条 messages |
关键文件索引
| 主题 | 文件 |
|---|---|
| 预处理编排 | src/query.ts ~365–468 |
| boundary / snip 投影 | src/utils/messages.ts |
| tool result budget / preview | src/utils/toolResultStorage.ts |
| microcompact | src/services/compact/microCompact.ts |
| autocompact 阈值 | src/services/compact/autoCompact.ts |
| compact 摘要 | src/services/compact/compact.ts |
| session memory compact | src/services/compact/sessionMemoryCompact.ts |
| API context_management | src/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 上下文预处理可以概括为:
- 阶梯式:budget → snip → microcompact → collapse → autocompact,前面成功则后面常 no-op
- 双视图:REPL 全量 vs
messagesForQuery模型视图;snip/collapse 多靠投影;snip 另配 API 侧[id:xxx] - Budget 状态机:fresh / frozen / mustReapply + wire-level 分组对齐 normalize;resume 靠
contentReplacements重建 - Preview:原文前 2000 字节 +
<persisted-output>模板,落盘在 sessiontool-results/ - Microcompact 三路径:time-based(mutate + reset cached state)→ cached MC(API cache edit)→ no-op
- Cache editing vs context_management:前者客户端指定删 cache tool;后者服务端策略(含 idle thinking 清理)
- snipTokensFreed + post-compact skip:修正 stale usage,避免 blocking preempt 误拦
下一篇可继续 compact 摘要 agent 内部流程(compactConversation / session memory fork),或 reactive compact + 413 恢复 全链路。