claude-code · Agent 架构 · 2026-06-06

Claude Code 源码解析(03)Context Management:动态注入、工具结果预算与多层压缩

这篇源码解析基于本地 Claude Code 源码,拆解它如何把上下文分成静态 system/user context、按 turn 动态注入的 attachments、工具结果预算替换、cache-friendly microcompact、session memory compact 和 autocompact 多层机制,而不是只靠一次大摘要解决长上下文问题。

聚焦
上下文管理设计:system/user context、CLAUDE.md 与 nested memory 动态注入、queued command/skill/tool delta attachment、tool result budget、microcompact、session memory compact、autocompact 与边界
源码
assets/projects/claude-code
版本
4b9d30f
查看仓库

先给结论

Claude Code 的上下文管理不是“快满了就总结一下”。它更像一套分层预算系统:固定 context 只放稳定信息,动态信息用 attachment 增量注入,工具结果先替换和持久化,仍然太大时再进入 microcompact、session memory compact 或 legacy compact。

上下文管理全景:固定 context、消息历史、动态 attachment 和多层压缩在 query loop 中组合。
上下文管理全景:固定 context、消息历史、动态 attachment 和多层压缩在 query loop 中组合。

默认路径

每轮 query 先组装 system/user context 和消息历史,再做工具结果预算、snip、microcompact、context collapse、autocompact 等前置处理。工具执行完成后,再把 queued command、文件变更、tool/MCP/agent delta 等动态事实追加进历史。

可选增强

HISTORY_SNIP、CACHED_MICROCOMPACT、CONTEXT_COLLAPSE、session memory compact、reactive compact 都受 feature gate 或配置影响。它们不是互斥开关,而是按顺序尝试降低上下文压力。

这篇讲解只看一条主线:下一次模型调用会看到什么上下文,以及超预算时系统按什么顺序处理它。长期记忆抽取、checkpoint/file-history 回滚、subagent 编排会影响会话状态,但不是这条 context budget 管线的主轴。

源码证据地图

读这条线时,先把源码分成五个问题:固定内容从哪来,动态状态何时注入,工具结果何时变小,历史何时被局部删除,最后何时进入全局 compact。

text
1# 1 assets/projects/claude-code/src/context.ts2# 2 assets/projects/claude-code/src/query.ts3# 3 assets/projects/claude-code/src/utils/attachments.ts4# 4 assets/projects/claude-code/src/utils/toolResultStorage.ts5# 5 assets/projects/claude-code/src/services/compact/microCompact.ts6# 6 assets/projects/claude-code/src/services/compact/autoCompact.ts7# 7 assets/projects/claude-code/src/services/compact/compact.ts8# 8 assets/projects/claude-code/src/services/compact/sessionMemoryCompact.ts9# 9 assets/projects/claude-code/src/services/SessionMemory/sessionMemory.ts
1固定 context

system/user context 先进入 API 参数,尽量保持稳定。

2历史消息预算

消息历史先经过工具结果预算、snip、microcompact 等处理。

3局部压缩

能局部释放 token 的路径先执行,避免过早进入全局摘要。

4模型与工具循环

模型生成 tool_use,工具执行后把 tool_result 写回历史。

5动态 attachment

queued command、文件变更、tool/MCP delta 等运行态补丁追加进消息流。

6下一轮继续

下一轮模型看到的是固定前缀、处理后的历史和刚补进来的动态状态。

这个顺序很关键。如果把所有状态都写进 system prompt,缓存前缀会频繁变化;如果先 autocompact 再处理大工具结果,就会把很多本可局部替换的内容交给摘要器;如果 compact 后不补 attachment,模型会丢掉当前运行态。

固定上下文只放稳定信息

固定 context 解决的问题是:哪些信息应该每轮都可见,但又足够稳定,可以放在 API 请求的前缀里。Claude Code 把这部分拆成 getSystemContext()getUserContext()

getSystemContext() 放的是系统运行环境,例如 git status 和 cache breaker。getUserContext() 放的是用户/项目记忆,例如 CLAUDE.md、memory files 和日期。

ts
1// assets/projects/claude-code/src/context.ts2export const getSystemContext = memoize(async () => {3  const gitStatus =4    isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||5    !shouldIncludeGitInstructions()6      ? null7      : await getGitStatus()8  return {9    ...(gitStatus && { gitStatus }),10    ...(injection ? { cacheBreaker: `[CACHE_BREAKER: ${injection}]` } : {}),11  }12})

这段代码的机制含义不是“读取 git status”这么简单。它把 git 状态放在 memoized 的 system context 里,说明这类内容被当成相对稳定的请求前缀;而 cacheBreaker 是显式让前缀变化的例外,用来打破不希望复用的缓存。

ts
1// assets/projects/claude-code/src/context.ts2const claudeMd = shouldDisableClaudeMd3  ? null4  : getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))5 6return {7  ...(claudeMd && { claudeMd }),8  currentDate: `Today's date is ${getLocalISODate()}.`,9}

这里的状态流是:getMemoryFiles() 找到可注入的 memory 文件,filterInjectedMemoryFiles() 过滤掉不该重复注入的内容,getClaudeMds() 组装成 user context。下一轮模型调用会把它当成基础背景,而不是一条普通 user message。

上下文注入分两层:固定 context 进入 API 前缀,动态 attachment 在工具循环后按需补充。
上下文注入分两层:固定 context 进入 API 前缀,动态 attachment 在工具循环后按需补充。

这个边界解释了为什么后面需要 attachment。日期变化、MCP server 变更、文件被外部改动、plan mode 状态都太容易变;如果它们每次都改写固定前缀,prompt cache 会被频繁破坏。

动态注入不是拼大 prompt

动态 attachment 解决的问题是:运行过程中出现的新状态,需要让模型知道,但不应该塞进稳定 system/user context。query.ts 的处理时机很明确:工具结果先进入历史,随后才扫描动态 attachment。

ts
1// assets/projects/claude-code/src/query.ts2for await (const attachment of getAttachmentMessages(3  null,4  updatedToolUseContext,5  null,6  queuedCommandsSnapshot,7  [...messagesForQuery, ...assistantMessages, ...toolResults],8  querySource,9)) {10  yield attachment11  toolResults.push(attachment)12}

这段代码里的关键状态有三个:queuedCommandsSnapshot 是本轮工具执行期间排队的用户/系统输入;updatedToolUseContext 保存当前工具、文件、权限、plan 等运行态;最后一个数组是“截至此刻模型和工具已经产生的历史”。getAttachmentMessages() 基于这三者决定本轮要补哪些状态。

attachments.ts 里可以看到一组动态 attachment:queued commands、date change、deferred tools delta、agent listing delta、MCP instructions delta、changed files、nested memory、dynamic skill、plan mode、task reminder 等。

ts
1// assets/projects/claude-code/src/utils/attachments.ts2const allThreadAttachments = [3  maybe('queued_commands', () => getQueuedCommandAttachments(queuedCommands)),4  maybe('deferred_tools_delta', () =>5    Promise.resolve(getDeferredToolsDeltaAttachment(...))),6  maybe('agent_listing_delta', () =>7    Promise.resolve(getAgentListingDeltaAttachment(toolUseContext, messages))),8  maybe('mcp_instructions_delta', () =>9    Promise.resolve(getMcpInstructionsDeltaAttachment(...))),10  maybe('changed_files', () => getChangedFiles(context)),11  maybe('nested_memory', () => getNestedMemoryAttachments(context)),12  maybe('plan_mode', () => getPlanModeAttachments(messages, toolUseContext)),13]

可以把 attachment 理解成“状态增量日志”。例如用户在模型执行工具时又输入一句话,这不是固定 prompt 的一部分,而是 queued_commands;如果某个 MCP server instructions 变化,也不是重写工具 schema,而是 mcp_instructions_delta;如果进入 plan mode,则通过 plan_mode attachment 持续提醒模型当前只能规划。

1运行态发生变化

用户追加输入、文件改变、MCP 状态变化或 plan mode 变化先发生在运行态。

2getAttachmentMessages 扫描状态

query loop 在工具结果之后调用 getAttachmentMessages,并传入最新 toolUseContext 和历史。

3生成 attachment message

attachments.ts 把变化包装成 queued_commands、changed_files、delta 等 attachment。

4写回消息历史

这些 attachment 被 yield 出去,同时进入 toolResults,成为下一轮历史的一部分。

5下一轮模型消费

模型把它们当作当前状态补丁读取;compact/resume 后也能按需要重建。

这种设计最容易被误读成“只是换个地方拼 prompt”。真正的差别在于:稳定规则留在固定 context,运行态变化进入消息尾部。这样模型仍能看到新状态,但缓存前缀不必因为一个文件改动、一个 server 断开或一个 plan reminder 重新写入。

Delta 注入解决缓存破坏

Delta attachment 解决的问题是:某些运行态很大,而且会变化,但模型只需要知道“相对上次变了什么”。Agent listing、deferred tools、MCP instructions 都属于这一类。

Agent listing 的源码先从历史 attachment 里恢复“已经公告过哪些 agent 类型”,再和当前可用 agent 集合做差分。状态不是凭空算一次,而是从消息历史里的 delta 累积出来。

ts
1// assets/projects/claude-code/src/utils/attachments.ts2export function getAgentListingDeltaAttachment(toolUseContext, messages) {3  if (!shouldInjectAgentListInMessages()) return []4  const announced = new Set<string>()5  for (const msg of messages ?? []) {6    if (msg.type !== 'attachment') continue7    if (msg.attachment.type !== 'agent_listing_delta') continue8    for (const t of msg.attachment.addedTypes) announced.add(t)9    for (const t of msg.attachment.removedTypes) announced.delete(t)10  }11  return [{ type: 'agent_listing_delta', addedTypes, removedTypes }]12}

这段代码证明了一个机制点:delta attachment 是可重放的。历史里出现过 addedTypes 就加入当前已公告集合,出现 removedTypes 就删掉。下一轮只发送新差异,不必把所有 agent 描述塞进工具 schema。

MCP instructions 也是同一思路。系统先从当前 MCP clients/tools 得到最新 instructions,再和历史消息里已经公告过的 instructions 比较,只在变化时注入。

ts
1// assets/projects/claude-code/src/utils/attachments.ts2export function getMcpInstructionsDeltaAttachment(3  mcpClients,4  tools,5  model,6  messages,7) {8  const delta = getMcpInstructionsDelta(mcpClients, messages ?? [], clientSide)9  if (!delta) return []10  return [{ type: 'mcp_instructions_delta', ...delta }]11}

不用 delta 的后果

如果 agent listing 或 MCP instructions 直接写进工具描述,MCP 连接、插件重载、权限变化都会改变工具 schema。长会话里这会造成大段 cache creation。

delta 的行为

消息历史记录每次新增/移除。下一轮只注入差异;compact 后如果旧 delta 被吃掉,再用当前状态重新公告必要集合。

一个具体例子:某个 MCP server 断开后,模型不需要重新阅读所有 MCP server 的完整说明,只需要知道“这个 server 的 instructions 移除了”。如果稍后 compact 删除了旧 delta,post-compact 阶段会从当前 MCP 状态重新公告,避免模型拿着过期工具环境继续推理。

Nested memory 是运行态召回

Nested memory 解决的问题是:当前任务涉及某个路径时,模型需要看到局部 CLAUDE.md 或规则文件,但这些文件不应该每轮全量注入。它和长期 memory 写入不是一回事,这里只负责“把当前路径相关规则放进上下文”。

memoryFilesToAttachments() 同时检查 loadedNestedMemoryPathsreadFileState。前者是不淘汰的本轮已注入集合,后者是文件读取状态。两者合用,是为了防止忙碌会话里 read cache 淘汰后又反复注入同一 memory。

ts
1// assets/projects/claude-code/src/utils/attachments.ts2if (toolUseContext.loadedNestedMemoryPaths?.has(memoryFile.path)) {3  continue4}5if (!toolUseContext.readFileState.has(memoryFile.path)) {6  attachments.push({7    type: 'nested_memory',8    path: memoryFile.path,9    content: memoryFile,10  })11  toolUseContext.loadedNestedMemoryPaths?.add(memoryFile.path)12}

状态流是:用户提到或打开某个路径后,attachment 逻辑寻找该路径上适用的 nested memory;如果之前没注入过,也没有作为普通文件读取过,就生成 nested_memory attachment;随后路径写入 loadedNestedMemoryPaths,下一轮不再重复发。

这个边界很重要。读者如果把 nested memory 当成“学习用户偏好”,就会误解它的生命周期。它更像当前工作目录的局部规则加载器:触发来自路径,输出是 attachment,生命周期服务当前会话上下文。

第一层压缩:工具结果预算

工具结果预算解决的问题是:上下文爆掉的常常不是聊天文本,而是工具输出。尤其是 grep、bash、web fetch、文件读取结果,它们可能一次返回几万到几十万字符。

长上下文最容易爆的不是用户文本,而是工具结果。toolResultStorage.ts 先对大工具输出做 persistence:把完整内容写入 tool-results 目录,只把文件路径和预览留给模型。

ts
1// assets/projects/claude-code/src/utils/toolResultStorage.ts2export const TOOL_RESULTS_SUBDIR = 'tool-results'3 4export function buildLargeToolResultMessage(result) {5  return `<persisted-output>\n` +6    `Output too large. Full output saved to: ${result.filepath}\n\n` +7    `Preview:\n${result.preview}\n` +8    `</persisted-output>`9}

这段代码证明 persistence 的输出形态:模型不再看到完整工具结果,而是看到一个 <persisted-output> 包装,里面有路径和 preview。完整内容写到 session 目录下的 tool-results,之后如果真的需要,可以通过文件路径重新读取。

触发条件不是“看起来很大”,而是按工具声明和默认上限计算。普通工具会被 DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000 限制;同一 user message 里的多个工具结果,还会受聚合预算约束。

ts
1// assets/projects/claude-code/src/utils/toolResultStorage.ts2export type ContentReplacementState = {3  seenIds: Set<string>4  replacements: Map<string, string>5}

第二层是 aggregate tool result budget。它不是简单截断,而是记录每个 tool_use_id 的替换决策,resume 后也能复原同样的 replacement,避免 prompt cache 因模板变化或路径变化被破坏。

工具结果预算:大输出写入 tool-results,消息里保留稳定 replacement,必要时写 content-replacement record 供 resume 复原。
工具结果预算:大输出写入 tool-results,消息里保留稳定 replacement,必要时写 content-replacement record 供 resume 复原。

用一个具体场景看它为什么要冻结决策:

text
1同一轮工具结果:2A = 120k 字符3B = 90k 字符4C = 20k 字符5聚合预算 = 200k 字符

A+B+C 合计 230k,系统会选最大的 fresh 结果替换,例如把 A 写入 tool-results,模型只看 A 的 preview。关键是下一轮不能重新决定“这次替换 B”。seenIds 表示某个结果已经被预算器看过,replacements 保存模型当时看到的 exact replacement。resume 时也用这些记录复原同一串字节,而不是按新模板重算。

这个机制的负面事实也要说清:persistence 不是摘要。模型只看到 preview,如果后续真的需要完整输出,要重新按路径读取。它换来的是上下文稳定和可恢复,而不是把全部证据都压进模型注意力。

第二层压缩:microcompact

microcompact 解决的问题是:在真正改写整段历史之前,能不能先清掉一部分旧工具结果或缓存层内容。它比 legacy compact 轻,因为它主要处理 tool_result,而不是让模型生成整段摘要。

query.ts 在 autocompact 前调用 microcompact。源码注释强调顺序:tool result budget 先跑,snip 再跑,microcompact 再跑,最后才是 autocompact。

ts
1// assets/projects/claude-code/src/query.ts2messagesForQuery = await applyToolResultBudget(...)3const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)4const microcompactResult = await deps.microcompact(5  messagesForQuery,6  toolUseContext,7  querySource,8)9messagesForQuery = microcompactResult.messages

这个顺序是一个重要机制。工具结果预算先冻结大输出 replacement;snip 如果开启,会先移除中间历史并计算 snipTokensFreed;microcompact 再处理旧 tool_result;最后 autocompact 根据已经释放的 token 判断是否还需要全局摘要。

1工具结果预算

大工具输出先被 persistence 或 aggregate replacement 稳定下来。

2snip 移除中间历史

HISTORY_SNIP 若开启,会先移除中间历史并记录释放的 token。

3microcompact 清工具结果

microcompact 再处理旧 tool_result,走 time-based 清理或 cached cache_edits。

4context collapse 可选

context collapse 若开启,会在 autocompact 前尝试把上下文压到阈值以下。

5autocompact 阈值判断

autocompact 用已经释放后的 token 判断是否还需要重型 compact,避免误触发。

snipHISTORY_SNIP feature 下的一种局部历史移除机制。本地源码里没有包含完整 snipCompact.ts / SnipTool 实现,但可见调用点说明它发生在 microcompact 前,并把释放 token 反馈给后续阈值判断。

microCompact.ts 里有两条路径。time-based microcompact 在缓存已经冷掉时直接清理旧工具结果;cached microcompact 则使用 cache editing,尽量不改本地 message 内容。

ts
1// assets/projects/claude-code/src/services/compact/microCompact.ts2const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)3if (timeBasedResult) return timeBasedResult4 5if (mod.isCachedMicrocompactEnabled() && isMainThreadSource(querySource)) {6  return await cachedMicrocompactPath(messages, querySource)7}8return { messages }

time-based path 的触发条件是“服务端 prompt cache 很可能已经过期”。源码默认用距离上一条 main-loop assistant message 的时间差判断;缓存冷掉后,保持本地消息字节不变已经不能换来 cache hit,于是可以直接把旧工具结果替换成固定清理文本。

cached microcompact 的关键点是“不改本地消息,只生成 cache_edits”。这解释了为什么它能降低 token 压力,又尽量保持缓存前缀稳定。

ts
1// assets/projects/claude-code/src/services/compact/microCompact.ts2const toolsToDelete = mod.getToolResultsToDelete(state)3if (toolsToDelete.length > 0) {4  const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)5  if (cacheEdits) pendingCacheEdits = cacheEdits6  return {7    messages,8    compactionInfo: { pendingCacheEdits: { trigger: 'auto', deletedToolIds } },9  }10}

这段代码的行为很精细:返回的 messages 仍是原数组,说明本地 transcript 不变;真正变化的是 pendingCacheEdits,后续 API 层会把指定 tool_use_id 对应的缓存内容删除。也就是说,本地历史仍完整,但服务端缓存前缀可以被“修剪”。

time-based microcompact

缓存已经冷掉时,本地旧 tool_result 会被替换成固定 cleared 文本。这是有损清理,依赖最近结果保留和可重新执行工具来降低风险。

cached microcompact

缓存仍值得保护时,本地消息不改,只生成 cache_edits,让服务端删除缓存层里的旧工具结果。它更像 cache surgery,而不是 transcript rewrite。

一个例子:用户吃饭回来,一小时后继续任务,旧 prompt cache 很可能已经没了。time-based microcompact 直接清理早期 grep/bash 输出,下一次请求少发很多历史。反过来,如果会话还热,cached microcompact 尽量不动本地消息,通过 cache edit 删除旧工具结果,避免破坏缓存前缀。

第三层压缩:auto compact

autocompact 解决的问题是:前面所有局部手段都不够时,系统要主动重写历史,而不是等 API 报 prompt-too-long。它的触发条件来自模型上下文窗口、保留给摘要输出的预算,以及额外 buffer。

ts
1// assets/projects/claude-code/src/services/compact/autoCompact.ts2const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_0003export const AUTOCOMPACT_BUFFER_TOKENS = 13_0004 5export function getAutoCompactThreshold(model: string): number {6  const effectiveContextWindow = getEffectiveContextWindowSize(model)7  return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS8}

这段代码说明 autocompact 不是“满了再说”。它提前从有效上下文窗口里留出 buffer,给摘要生成、工具 schema、system/user context 这些额外内容留空间。否则 compact 请求本身也可能因为太长而失败。

它还带 circuit breaker。连续 autocompact 失败后,系统会停止继续尝试,避免每一轮都重新烧一次摘要请求。

ts
1// assets/projects/claude-code/src/services/compact/autoCompact.ts2const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 33if (tracking?.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {4  return { wasCompacted: false }5}
压缩管线:先做便宜、局部、可缓存的处理,再进入会改变历史形态的 compact。
压缩管线:先做便宜、局部、可缓存的处理,再进入会改变历史形态的 compact。

这里的状态流是:query.tsmessagesForQuerysnipTokensFreed 和 tracking 交给 autocompact;autocompact 计算阈值并决定是否 compact;成功后返回 CompactionResultquery.ts 再用 buildPostCompactMessages() 替换当前请求历史。

ts
1// assets/projects/claude-code/src/query.ts2const postCompactMessages = buildPostCompactMessages(compactionResult)3for (const message of postCompactMessages) {4  yield message5}6messagesForQuery = postCompactMessages

这段代码解释了 compact 的后果:它不是在原历史旁边追加一个 summary,而是把后续请求要看的消息数组换成 compact 后的新数组。这个新数组通常包含 boundary、summary、保留尾部、post-compact attachments 和 hook results。

Session memory compact 是优先尝试

Session memory compact 解决的问题是:如果系统平时已经维护了一份会话笔记,真正 autocompact 时就不必再让 summarizer 重读全部旧历史。它把“总结”这件事提前摊到普通会话过程中。

autocompact 真正进入重压缩前,会先尝试 session memory compact。它不是再 fork 一个 summarizer,而是读取已经抽取好的 session memory 文件,生成 compact summary,再保留最近未总结的消息尾部。

ts
1// assets/projects/claude-code/src/services/compact/autoCompact.ts2const sessionMemoryResult = await trySessionMemoryCompaction(3  messages,4  toolUseContext.agentId,5  recompactionInfo.autoCompactThreshold,6)7if (sessionMemoryResult) {8  return { wasCompacted: true, compactionResult: sessionMemoryResult }9}

真正的后台抽取发生在普通会话中。sessionMemory.ts 会在满足阈值、feature gate 打开、主 REPL 线程等条件时,用 querySource: 'session_memory' 启动 forked agent,更新 session memory 文件。成功后记录 lastSummarizedMessageId

ts
1// assets/projects/claude-code/src/services/SessionMemory/sessionMemory.ts2const result = await runForkedAgent({3  promptMessages,4  cacheSafeParams,5  querySource: 'session_memory',6  forkLabel: 'session_memory',7  maxTurns: 1,8})9updateLastSummarizedMessageIdIfSafe(messages)

这个状态流很像“后台笔记”:旧消息被 session memory 文件覆盖到某个 lastSummarizedMessageId;autocompact 到来时,trySessionMemoryCompaction() 先等待正在进行的抽取结束,再读取 session memory 文件和这个 message id。

ts
1// assets/projects/claude-code/src/services/compact/sessionMemoryCompact.ts2await waitForSessionMemoryExtraction()3const lastSummarizedMessageId = getLastSummarizedMessageId()4const sessionMemory = await getSessionMemoryContent()

如果有 lastSummarizedMessageId,系统按这个 id 找到已经总结到哪里;如果是 resume 后没有 id,但 session memory 文件存在,则走保守路径。随后 calculateMessagesToKeepIndex() 选择原文尾部。

ts
1// assets/projects/claude-code/src/services/compact/sessionMemoryCompact.ts2if (lastSummarizedMessageId) {3  lastSummarizedIndex = messages.findIndex(4    msg => msg.uuid === lastSummarizedMessageId,5  )6} else {7  lastSummarizedIndex = messages.length - 18}9const startIndex = calculateMessagesToKeepIndex(messages, lastSummarizedIndex)10const messagesToKeep = messages.slice(startIndex)

session memory compact

平时先由 session_memory agent 维护文件;压缩时读取这个文件,生成 compact summary,再保留 lastSummarizedMessageId 之后的原始尾部。

legacy compact

如果没有 session memory、文件为空、边界找不到,或压缩后仍超过 autocompact 阈值,就回退到 compactConversation 重新总结历史。

具体例子:前 200 轮会话已经被 session memory 覆盖,最近 8 轮还没总结。autocompact 到来时,系统不重读 208 轮消息,而是用 session memory 生成 summary,并保留最近 8 轮原文。calculateMessagesToKeepIndex() 还会避免切断 tool_use / tool_result 配对,必要时往前多留一点。

这个机制的边界也很重要:session memory compact 不是跨 session 的长期偏好记忆,也不是无条件开启。它服务当前长会话的压缩;如果结果仍然太大,源码会返回 null,让 autocompact 走 legacy compact。

legacy compact 会补回必要上下文

legacy compact 解决的问题是:当前面所有路径都不能把上下文降下来时,只能让模型生成 compact summary。但 Claude Code 没有把 summary 当成万能替代品;它会额外重建当前运行态。

compactConversation() 的状态流可以分成两半。前半段是摘要:跑 PreCompact hook,向 compact agent/模型发送 summary prompt,得到 summary。后半段是重建:保存并清理旧 read state,然后把继续执行所需的 attachment 补回来。

ts
1// assets/projects/claude-code/src/services/compact/compact.ts2const preCompactReadFileState = cacheToObject(context.readFileState)3 4context.readFileState.clear()5context.loadedNestedMemoryPaths?.clear()6 7const [fileAttachments, asyncAgentAttachments] = await Promise.all([8  createPostCompactFileAttachments(9    preCompactReadFileState,10    context,11    POST_COMPACT_MAX_FILES_TO_RESTORE,12  ),13  createAsyncAgentAttachmentsIfNeeded(context),14])

这段代码说明了为什么要先保存 preCompactReadFileState。compact 会删掉旧历史,也会清掉 readFileState;如果不提前快照最近读过的文件,模型压缩后会只剩 summary,不再拥有刚才完整读过的关键文件内容。createPostCompactFileAttachments() 会按最近访问顺序和 token budget 补回一部分文件。

接着系统补 plan、plan mode、invoked skills、异步 agent 状态,以及 tools/agent/MCP delta。

ts
1// assets/projects/claude-code/src/services/compact/compact.ts2const planAttachment = createPlanAttachmentIfNeeded(context.agentId)3const planModeAttachment = await createPlanModeAttachmentIfNeeded(context)4const skillAttachment = createSkillAttachmentIfNeeded(context.agentId)

这就是它精心的地方:compact 会删除大量历史,但不会假装 summary 能替代所有运行态上下文。被压掉的文件阅读、plan mode、skill 指令和工具变化,需要用 post-compact attachment 重建。

ts
1// assets/projects/claude-code/src/services/compact/compact.ts2for (const att of getDeferredToolsDeltaAttachment(context.options.tools, ...)) {3  postCompactFileAttachments.push(createAttachmentMessage(att))4}5for (const att of getAgentListingDeltaAttachment(context, [])) {6  postCompactFileAttachments.push(createAttachmentMessage(att))7}8for (const att of getMcpInstructionsDeltaAttachment(...)) {9  postCompactFileAttachments.push(createAttachmentMessage(att))10}
1生成 summary

compact summarizer 只负责把旧对话压成可读摘要。

2保存并清理 read state

系统先快照 preCompactReadFileState,再清掉会被旧历史污染的读取状态。

3恢复最近文件

createPostCompactFileAttachments 按最近访问和 token 预算补回关键文件。

4恢复 plan/plan mode

plan 文件和 plan mode reminder 被重新注入,避免模型压缩后忘记当前权限模式。

5恢复 skill/agent 状态

已调用 skill、异步 agent 状态会变成 attachment,继续约束后续行为。

6重新公告 tool/MCP delta

deferred tools、agent listing、MCP instructions 从当前状态重新公告,补回被 summary 吃掉的运行态。

一个具体例子:compact 前模型刚读过 query.ts,处于 plan mode,并且调用过一个 skill。summary 可能写“我们正在分析 query.ts 的 compact 流程”,但它不能替代 query.ts 的完整片段、plan mode 规则和 skill 指令。post-compact attachment 会把这些继续执行所需的状态重新放回上下文。

buildPostCompactMessages() 定义了 compact 后请求历史的顺序。

ts
1// assets/projects/claude-code/src/services/compact/compact.ts2export function buildPostCompactMessages(result: CompactionResult): Message[] {3  return [4    result.boundaryMarker,5    ...result.summaryMessages,6    ...(result.messagesToKeep ?? []),7    ...result.attachments,8    ...result.hookResults,9  ]10}

这个顺序也解释了“summary 不是全部”。先有 boundary 标记压缩发生,再有 summary 表示旧历史,再保留尾部原文,最后补运行态 attachment 和 hook results。后续模型看到的是一份重新组织过的上下文,而不是一段孤零零的摘要。

可复用设计

这套机制最值得复用的不是某个常量,而是顺序:先固定上下文,再动态增量;先局部替换,再局部压缩;最后才做全局摘要。这样能减少不必要的 token、避免 cache 失效,并让恢复后的模型仍然知道当前工具、文件、plan 和 skill 状态。

可以把 Claude Code 的做法抽成四条工程原则:

  1. 稳定前缀只放稳定事实。会变的信息放到尾部 message 或 attachment。
  2. 大工具结果先替换成稳定 representation,再考虑摘要。
  3. 局部可删的历史和工具结果先局部处理,减少进入全局 compact 的概率。
  4. compact 后必须重建运行态,不要让 summary 假装自己保存了所有状态。

边界

这篇没有把所有 context 相关 feature 都展开。HISTORY_SNIP 的完整选择逻辑在本地源码中不可见,只能依据 query.ts 的调用顺序、message id 注入和 boundary/resume 行为解释可确认部分。CONTEXT_COLLAPSE、reactive compact、prompt-too-long fallback 也只作为管线边界出现,没有展开成独立专题。

本文能确认

固定 context、动态 attachment、delta 注入、nested memory 去重、工具结果 persistence、aggregate replacement、microcompact 路径、session memory compact 优先尝试、legacy compact 后重建运行态。

本文不伪装确认

snip 内部打分、context collapse 的完整数据结构、不同 provider 的 cache edit 细节,以及 reactive compact 的全部 API error recovery 分支。

读这段源码最重要的是不要只记住“有 compact”。真正的设计在 compact 之前:它先把上下文压力拆成一系列更便宜、更可恢复、更 cache-friendly 的局部动作,只有这些动作不够时,才让 summary 接管旧历史。