Claude Code 源码解剖

Claude Code 的上下文压缩

从 autoCompact、compactConversation、压缩提示词和微压缩入口拆解 Claude Code 如何在长任务里判断压缩时机、生成结构化摘要,并把必要工作现场接回下一轮。

第 9 章 18 分钟

Claude Code 的上下文压缩

这一章要解决的问题很具体:Claude Code 的对话越来越长时,系统什么时候决定压缩?压缩请求怎么发?失败了怎么兜底?压缩之后,模型又靠什么接着干活?

源码里的答案不是“把历史总结一下”这么简单。压缩链路至少包含三层动作:

  • autoCompactIfNeeded() 判断是否该压缩。
  • compactConversation() 把旧消息变成结构化摘要,并重建压缩后的消息数组。
  • createPostCompactFileAttachments()、技能附件、计划附件、delta 附件把必要现场重新挂回去。

核心直觉

Claude Code 的压缩不是记忆管理的装饰功能,而是长任务状态机的一次换轨:旧消息被摘要替换,文件、计划、技能和工具声明通过附件重新进入上下文。压缩成功的标准不是“少了多少 token”,而是下一轮还能不能继续执行。

先看源码入口

先把源码定位点放在桌面上。读这一章不需要先记住所有函数,但要知道每段逻辑大概在哪个文件里。

源码定位关键符号负责什么
services/compact/autoCompact.tsautoCompactIfNeeded()query loop 中的自动压缩入口,负责阈值、熔断、Session Memory 优先级和失败计数
services/compact/autoCompact.tsgetEffectiveContextWindowSize()getAutoCompactThreshold()shouldAutoCompact()计算有效窗口和触发条件
services/compact/compact.tscompactConversation()执行压缩请求、处理 prompt too long 重试、组装 CompactionResult
services/compact/prompt.tsBASE_COMPACT_PROMPTPARTIAL_COMPACT_PROMPTformatCompactSummary()定义压缩提示词模板,并剥离 @@INLINE_0@@ 草稿块
services/compact/compact.tsbuildPostCompactMessages()把边界、摘要、保留消息、附件、hook 结果合成压缩后的消息序列
services/compact/compact.tscreatePostCompactFileAttachments()createSkillAttachmentIfNeeded()恢复压缩后仍然要继续使用的文件和技能
services/compact/microCompact.tsmicrocompactMessages()maybeTimeBasedMicrocompact()cachedMicrocompactPath()在全量压缩前先修剪工具结果,降低上下文增长速度

这些入口共同说明一件事:压缩不是一个函数,而是一条从预算判断到现场重建的链路

总流程图

下面的流程图只画源码里真实出现的行为节点,不画抽象概念。

flowchart TD A["queryLoop 推进下一轮"] --> B["microcompactMessages()"] B --> C{"maybeTimeBasedMicrocompact() 触发?"} C -->|是| D["替换旧 tool_result 内容<br/>resetMicrocompactState()<br/>notifyCacheDeletion()"] C -->|否| E["cachedMicrocompactPath() 扫描可删 tool_result"] E --> F{"需要 cache_edits?"} F -->|是| G["pendingCacheEdits<br/>后续 API 请求发送 delete edits"] F -->|否| H["继续原消息"] D --> I["autoCompactIfNeeded()"] G --> I H --> I I --> J{"shouldAutoCompact()"} J -->|否| K["返回 wasCompacted: false"] J -->|是| L{"consecutiveFailures >= 3?"} L -->|是| K L -->|否| M["优先尝试 Session Memory 压缩"] M -->|不适用或不足| N["compactConversation()"] M -->|成功| O["返回压缩结果"] N --> P{"压缩请求 prompt_too_long?"} P -->|是| Q["truncateHeadForPTLRetry()<br/>最多 3 次"] Q --> N P -->|否| R["formatCompactSummary()<br/>生成 summaryMessages"] R --> S["快照并清空 readFileState"] S --> T["生成文件/计划/技能/Agent/工具/MCP 附件"] T --> U["buildPostCompactMessages()"] U --> O N -->|失败| V["consecutiveFailures++"] V --> K

这张图有两个关键点:

  • 微压缩发生在自动压缩之前,它不生成摘要,主要处理工具结果占用。
  • 自动压缩成功后还没有结束,必须把压缩后的第一轮上下文重新拼出来。

自动压缩入口:先算窗口,再决定是否压缩

自动压缩不是等到 API 报错才开始。autoCompact.ts 先根据模型上下文窗口计算一个提前触发点,再由 shouldAutoCompact() 做一串守卫判断。

有效窗口的计算

源码里第一步不是直接用模型窗口,而是先扣掉压缩摘要需要的输出空间。

const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000

export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,
  )
  let contextWindow = getContextWindowForModel(model, getSdkBetas())

  const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
  if (autoCompactWindow) {
    const parsed = parseInt(autoCompactWindow, 10)
    if (!isNaN(parsed) && parsed > 0) {
      contextWindow = Math.min(contextWindow, parsed)
    }
  }

  return contextWindow - reservedTokensForSummary
}

这段代码证明了两件事。

第一,压缩摘要本身也要占输出 token,所以有效窗口不是原始窗口。MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 来自线上摘要长度统计,原文记录 p99.99 摘要长度是 17,387 tokens,20K 是安全余量。

第二,CLAUDE_CODE_AUTO_COMPACT_WINDOW 只能通过 Math.min() 缩小窗口,不能扩大模型真实窗口。这是典型的 fail-closed 设计:环境变量可以让压缩更保守,不能让系统越过模型限制。

触发阈值的计算

有效窗口之后,还要再减一层 buffer。

export const AUTOCOMPACT_BUFFER_TOKENS = 13_000

export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)
  const autocompactThreshold =
    effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS

  const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
  if (envPercent) {
    const parsed = parseFloat(envPercent)
    if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
      const percentageThreshold = Math.floor(
        effectiveContextWindow * (parsed / 100),
      )
      return Math.min(percentageThreshold, autocompactThreshold)
    }
  }

  return autocompactThreshold
}

这段代码证明:自动压缩阈值是一个提前量,不是窗口边界。AUTOCOMPACT_BUFFER_TOKENS = 13_000 给当前轮可能新增的工具结果、系统附件和压缩过程留空间。

以 200K 窗口为例:

步骤公式结果
原始上下文窗口contextWindow200,000
摘要输出预留MAX_OUTPUT_TOKENS_FOR_SUMMARY20,000
有效窗口200,000 - 20,000180,000
自动压缩缓冲AUTOCOMPACT_BUFFER_TOKENS13,000
自动压缩阈值180,000 - 13,000167,000

CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 同样只能让压缩更早发生,因为返回值也是 Math.min(percentageThreshold, autocompactThreshold)。源码没有给用户一个“冒险晚压缩”的入口。

shouldAutoCompact() 的前置守卫

阈值只是最后一步。真正进入压缩前,shouldAutoCompact() 还会过滤掉几类不该压缩的调用。

if (querySource === 'session_memory' || querySource === 'compact') {
  return false
}

if (querySource === 'marble_origami') {
  return false
}

if (!isAutoCompactEnabled()) {
  return false
}

if (isReactiveCompactEnabled()) {
  return false
}

if (isContextCollapseEnabled()) {
  return false
}

这段代码证明,自动压缩的触发条件不是单纯的 tokenCount &gt;= threshold。它先排除递归来源,再排除 ctx-agent 共享状态污染,再让位给 reactive compact 和 Context Collapse。

容易误解

自动压缩不是“上下文越满越应该马上压缩”。当 Context Collapse 开启时,autocompact 会被禁用,因为 Collapse 在 90% 左右开始提交、95% 左右阻塞;autocompact 如果抢先执行,会破坏 Collapse 正准备保存的细粒度上下文。

熔断器:连续失败三次后停止自动尝试

压缩失败不是小概率噪音。原文引用的源码注释记录过一次线上事故:2026-03-10 的 BQ 数据显示,1,279 个 session 出现 50 次以上连续失败,单个 session 最高 3,272 次,全局每天浪费约 250K 次 API 调用。

源码里的修复非常硬:

const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

export type AutoCompactTrackingState = {
  compacted: boolean
  turnCounter: number
  turnId: string
  consecutiveFailures?: number
}

执行入口会先看失败次数。

if (
  tracking?.consecutiveFailures !== undefined &&
  tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
) {
  return { wasCompacted: false }
}

这段代码证明:连续三次自动压缩失败后,当前会话不再自动重试。成功路径会把 consecutiveFailures 重置为 0,失败路径递增计数。熔断只拦自动压缩,不等于用户不能手动 /compact

这里的设计判断很清楚:宁可让会话暴露出“需要人工处理”的状态,也不要让系统在后台用确定失败的请求烧预算

压缩提示词:摘要不是自由发挥,而是固定结构

触发自动压缩后,系统要向模型发送一个专门的压缩请求。prompt.ts 里不是一句“请总结对话”,而是三套变体和一个九段式输出结构。

三种提示词变体

变体常量摘要范围典型用途
完整压缩BASE_COMPACT_PROMPT到目前为止的整个对话首次自动压缩、手动 /compact
近期压缩PARTIAL_COMPACT_PROMPT已保留上下文之后的最近消息保留前缀,只压缩新增部分
前缀压缩PARTIAL_COMPACT_UP_TO_PROMPT新消息之前的历史前缀摘要放在开头,后面继续接保留消息

这三个变体的差异不是文案差异,而是压缩视野不同。PARTIAL 不能假装自己看过完整对话,PARTIAL_UP_TO 也不能写“下一步”,因为更新的消息会接在它后面。

九段式摘要模板

BASE_COMPACT_PROMPT 要求摘要覆盖这些段落:

段落保留的信息为什么重要
Primary Request and Intent用户显式请求和意图压缩后避免跑题
Key Technical Concepts技术、框架、模式、约束保留判断语境
Files and Code Sections文件名、代码片段、修改点让后续编辑有源码锚点
Errors and fixes错误、修复、用户反馈防止重复犯错
Problem Solving已解决问题和仍在排查的问题保留推理路径
All user messages非工具结果的用户消息最大化保留用户约束
Pending Tasks明确未完成任务下一轮继续推进
Current Work压缩前正在做什么接住当前现场
Optional Next Step直接符合最近请求的下一步给下一轮启动抓手

这说明 Claude Code 对压缩摘要的要求是“可继续执行”,不是“可读摘要”。尤其是 Files and Code SectionsErrors and fixesCurrent Work 三段,都是面向下一轮工具调用的。

@@INLINE_0@@ 先写草稿,再从结果中剥离

提示词要求模型先写 @@INLINE_0@@,再写 @@INLINE_1@@。但最终上下文里不会留下 @@INLINE_2@@

formattedSummary = formattedSummary.replace(
  /<analysis>[\s\S]*?<\/analysis>/,
  '',
)

这段代码证明:@@INLINE_0@@ 是一次性的整理空间,用来提高摘要覆盖率;它不是压缩后的长期记忆。Claude Code 用模型输出的一部分 token 换更稳定的摘要结构,但不把草稿继续带进后续上下文。

禁止工具调用

压缩请求还会在前后加入禁止工具调用的硬提示。

const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.

- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn; you will fail the task.
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
`

这段代码证明:压缩调用不是普通 agent loop。它使用 maxTurns: 1,工具调用会被拒绝。一旦模型在唯一一轮里调用工具,压缩就没有文本摘要可用,所以提示词必须把“不要调用工具”放到最高优先级。

compactConversation():真正执行压缩的编排器

compactConversation() 负责把“需要压缩”变成一个新的消息序列。它不只调用模型,还要处理 prompt too long、清理文件状态、生成附件、触发 hook、记录 telemetry。

flowchart TD A["compactConversation()"] --> B["执行 PreCompact hooks"] B --> C["选择 BASE / PARTIAL / PARTIAL_UP_TO prompt"] C --> D["发送压缩请求 maxTurns: 1"] D --> E{"PROMPT_TOO_LONG?"} E -->|是| F["truncateHeadForPTLRetry()"] F --> G{"重试次数 <= 3?"} G -->|是| D G -->|否| H["抛出 conversation too long"] E -->|否| I["formatCompactSummary()"] I --> J["快照 preCompactReadFileState"] J --> K["清空 readFileState / loadedNestedMemoryPaths"] K --> L["生成 postCompactFileAttachments"] L --> M["执行 SessionStart hooks"] M --> N["buildPostCompactMessages()"] N --> O["返回 CompactionResult"]

压缩后的消息结构

压缩结果不是一条 summary message 单独返回,而是按固定顺序拼成消息数组。

return [
  boundaryMarker,
  ...summaryMessages,
  ...messagesToKeep,
  ...attachments,
  ...hookResults,
]

这段代码证明:压缩后的上下文有明确层次。

部分含义后续影响
boundaryMarkerSystemCompactBoundaryMessage,标记压缩边界让系统知道这里发生过压缩
summaryMessages模型生成的结构化摘要取代旧历史主线
messagesToKeep部分压缩时保留的近端消息防止刚发生的交互被摘要化丢细节
attachments文件、计划、技能、工具声明等重建工作现场
hookResultsSessionStart hooks 结果让压缩后的会话像新 session 一样补齐环境信号

核心直觉

buildPostCompactMessages() 的顺序很重要。先有边界,再有摘要,再接保留消息,最后追加附件和 hook。模型看到的是“上一段会话的总结 + 近期原文 + 当前环境补丁”,不是孤零零的一份摘要。

压缩请求过长:Prompt too long 的重试路径

长会话需要压缩,但压缩请求也要把旧历史发给模型。如果旧历史已经超过输入限制,就会出现递归问题:想压缩,但压缩 prompt 自己太长

源码用 truncateHeadForPTLRetry() 处理这类失败。

const groups = groupMessagesByApiRound(input)
const tokenGap = getPromptTooLongTokenGap(ptlResponse)
let dropCount: number

if (tokenGap !== undefined) {
  let acc = 0
  dropCount = 0
  for (const g of groups) {
    acc += roughTokenCountEstimationForMessages(g)
    dropCount++
    if (acc >= tokenGap) break
  }
} else {
  dropCount = Math.max(1, Math.floor(groups.length * 0.2))
}

这段代码证明 PTL 重试不是随便裁剪字符串,而是按 API round 分组丢弃旧内容。groupMessagesByApiRound() 的作用是避免把一个 tool_use 和对应的 tool_result 拆开。

如果错误响应能解析出 tokenGap,就从最旧的组开始累加,直到丢掉足够 token。如果不能解析,就丢掉 20% 的轮次组。最多重试 MAX_PTL_RETRIES = 3 次。

还有一个小但关键的修复:如果上次重试已经插入过 PTL_RETRY_MARKER,下一次分组前会先去掉它。

const input =
  messages[0]?.type === 'user' &&
  messages[0].isMeta &&
  messages[0].message.content === PTL_RETRY_MARKER
    ? messages.slice(1)
    : messages

这段代码证明了一个边界处理:避免每次重试都只丢掉上一次插入的 marker,导致没有实际进展。

压缩后恢复:先清空缓存,再选择性接回现场

压缩摘要生成之后,模型丢失了旧消息里的文件内容、技能内容、计划状态和延迟工具声明。compactConversation() 不会假设摘要能保住所有东西,而是用附件重新注入关键现场。

文件状态:快照、清空、恢复

压缩前先快照当前读过的文件状态,然后清空缓存。

const preCompactReadFileState = cacheToObject(context.readFileState)

context.readFileState.clear()
context.loadedNestedMemoryPaths?.clear()

这段代码证明:压缩后系统不再认为模型“已经知道”那些文件内容。否则后续去重逻辑会误判模型仍持有旧文件状态。清空之后,系统再把真正值得恢复的文件作为附件发给模型。

恢复预算由几个常量控制:

export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5
export const POST_COMPACT_TOKEN_BUDGET = 50_000
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000

这些常量说明:压缩后的恢复是预算系统,不是全量回填。

预算数值影响
POST_COMPACT_MAX_FILES_TO_RESTORE5最多恢复最近 5 个文件
POST_COMPACT_TOKEN_BUDGET50K文件附件总预算
POST_COMPACT_MAX_TOKENS_PER_FILE5K单文件恢复上限
POST_COMPACT_MAX_TOKENS_PER_SKILL5K单个技能内容上限
POST_COMPACT_SKILLS_TOKEN_BUDGET25K技能附件总预算

文件恢复过滤链

createPostCompactFileAttachments() 会从压缩前的 readFileState 里挑文件。

const preservedReadPaths = collectReadToolFilePaths(preservedMessages)
const recentFiles = Object.entries(readFileState)
  .map(([filename, state]) => ({ filename, ...state }))
  .filter(
    file =>
      !shouldExcludeFromPostCompactRestore(
        file.filename,
        toolUseContext.agentId,
      ) && !preservedReadPaths.has(expandPath(file.filename)),
  )
  .sort((a, b) => b.timestamp - a.timestamp)
  .slice(0, maxFiles)

这段代码证明了文件恢复的优先级:

  • 排除 plan 文件和记忆文件,因为它们有独立注入路径。
  • 排除仍保留在近端消息里的 Read 文件,避免重复注入。
  • timestamp 降序,只取最近的 maxFiles

最后还有总预算过滤。

let usedTokens = 0
return results.filter((result): result is AttachmentMessage => {
  if (result === null) {
    return false
  }
  const attachmentTokens = roughTokenCountEstimation(jsonStringify(result))
  if (usedTokens + attachmentTokens <= POST_COMPACT_TOKEN_BUDGET) {
    usedTokens += attachmentTokens
    return true
  }
  return false
})

这段代码证明,即使最多只有 5 个文件,也不是一定全进上下文。恢复附件按顺序累加 token,超过 50K 的文件会被丢弃。

flowchart TD A["preCompactReadFileState"] --> B["collectReadToolFilePaths(preservedMessages)"] B --> C["过滤 plan 文件 / 记忆文件 / 已保留消息中的 Read 文件"] C --> D["sort(timestamp desc)"] D --> E["slice(0, POST_COMPACT_MAX_FILES_TO_RESTORE)"] E --> F["generateFileAttachment()<br/>重新读取当前磁盘内容"] F --> G{"累计 <= POST_COMPACT_TOKEN_BUDGET?"} G -->|是| H["作为附件恢复"] G -->|否| I["丢弃"]

容易误解

文件恢复读取的是压缩后的当前磁盘内容,不是 readFileState 快照里的旧文本。快照提供“哪些文件最近重要”的排序依据,真正注入时会重新生成文件附件。

技能、计划和工具声明的恢复

技能恢复走 createSkillAttachmentIfNeeded()。它按调用时间排序,每个技能截断到 5K token,总预算 25K。

const skills = Array.from(invokedSkills.values())
  .sort((a, b) => b.invokedAt - a.invokedAt)
  .map(skill => ({
    name: skill.skillName,
    path: skill.skillPath,
    content: truncateToTokens(
      skill.content,
      POST_COMPACT_MAX_TOKENS_PER_SKILL,
    ),
  }))

这段代码证明:技能恢复不是恢复技能列表,而是恢复已经调用过的技能内容。源码注释里还特别说明,技能文件开头通常最关键,所以“按技能截断”比“整个丢弃”更合理。

计划和计划模式分别恢复:

附件函数恢复什么
plan_file_referencecreatePlanAttachmentIfNeeded()当前 agent 的计划文件内容
plan_modecreatePlanModeAttachmentIfNeeded()当前是否仍处于 plan mode
invoked_skillscreateSkillAttachmentIfNeeded()已调用技能内容
task_statuscreateAsyncAgentAttachmentsIfNeeded()后台 async agent 状态

压缩还会吞掉之前的 delta 附件,所以 compactConversation() 会重新宣告工具和指令上下文。

for (const att of getDeferredToolsDeltaAttachment(
  context.options.tools,
  context.options.mainLoopModel,
  [],
  { callSite: 'compact_full' },
)) {
  postCompactFileAttachments.push(createAttachmentMessage(att))
}

for (const att of getAgentListingDeltaAttachment(context, [])) {
  postCompactFileAttachments.push(createAttachmentMessage(att))
}

for (const att of getMcpInstructionsDeltaAttachment(
  context.options.mcpClients,
  context.options.tools,
  context.options.mainLoopModel,
  [],
)) {
  postCompactFileAttachments.push(createAttachmentMessage(att))
}

这段代码里最关键的是空数组 []。正常 delta 附件会和历史消息做 diff,只发送新增部分;压缩后传空历史,等于“和空基线比较”,因此重新发送完整声明。callSite: &#39;compact_full&#39; 也说明这是压缩后的完整重播路径。

微压缩:不是摘要,而是提前修剪工具结果

第 9 章主线是自动压缩,但源码里还有一层容易混淆的机制:microcompact。它不是 compactConversation() 的小号版本。它不调用模型生成摘要,而是直接处理旧的 tool_result

时间型微压缩

maybeTimeBasedMicrocompact() 先根据时间间隔判断是否触发。

export function evaluateTimeBasedTrigger(
  messages: Message[],
  querySource: QuerySource | undefined,
): { gapMinutes: number; config: TimeBasedMCConfig } | null {
  const config = getTimeBasedMCConfig()
  if (!config.enabled || !querySource || !isMainThreadSource(querySource)) {
    return null
  }
  const lastAssistant = messages.findLast(m => m.type === 'assistant')
  if (!lastAssistant) {
    return null
  }
  const gapMinutes =
    (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
  if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
    return null
  }
  return { gapMinutes, config }
}

这段代码证明,时间型微压缩只在主线程、明确 query source、并且距离上次 assistant 足够久时触发。默认配置来自 TimeBasedMCConfigenabled: falsegapThresholdMinutes: 60keepRecent: 5

触发后,它会把旧 tool_result.content 替换成占位文本,同时保留最近若干个工具结果。

const result: Message[] = messages.map(message => {
  if (message.type !== 'user' || !Array.isArray(message.message.content)) {
    return message
  }
  const newContent = message.message.content.map(block => {
    if (
      block.type === 'tool_result' &&
      clearSet.has(block.tool_use_id) &&
      block.content !== TIME_BASED_MC_CLEARED_MESSAGE
    ) {
      return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
    }
    return block
  })
  return { ...message, message: { ...message.message, content: newContent } }
})

这段代码证明:时间型微压缩会修改本地消息内容。它适合冷缓存场景,因为距离上次 assistant 很久,服务端缓存大概率已经过期,下一次本来就要重写前缀。

缓存型微压缩

实时会话不能轻易改本地消息,因为会破坏 prompt cache。cachedMicrocompactPath() 的做法是扫描可压缩工具结果,然后生成 cache_edits

const compactableToolIds = new Set(collectCompactableToolIds(messages))

for (const message of messages) {
  if (message.type === 'user' && Array.isArray(message.message.content)) {
    const groupIds: string[] = []
    for (const block of message.message.content) {
      if (
        block.type === 'tool_result' &&
        compactableToolIds.has(block.tool_use_id) &&
        !state.registeredTools.has(block.tool_use_id)
      ) {
        mod.registerToolResult(state, block.tool_use_id)
        groupIds.push(block.tool_use_id)
      }
    }
    mod.registerToolMessage(state, groupIds)
  }
}

这段代码证明:缓存微压缩按 tool_use_id 追踪工具结果,而不是按文本内容盲删。后续 createCacheEditsBlock() 会把要删除的工具结果变成 API 可以理解的编辑块。

type CachedMCEditsBlock = {
  type: 'cache_edits'
  edits: { type: 'delete'; cache_reference: string }[]
}

缓存型微压缩的特点是:本地消息不变,删除动作交给服务端缓存层执行。时间型微压缩一旦触发,会直接短路缓存型路径,因为冷缓存场景不需要再维护 warm cache 的连续性。

状态和数据结构

状态/结构所在模块关键字段影响
AutoCompactTrackingStateautoCompact.tscompactedturnCounterturnIdconsecutiveFailures在 query loop 轮次之间追踪压缩状态和失败熔断
CompactionResultcompact.tsmessagessummarywasCompacted返回压缩后的消息序列和状态
readFileStateToolUseContext文件路径、内容、时间戳决定压缩后哪些文件有资格恢复
invokedSkillsskills 状态skillNameskillPathcontentinvokedAt决定压缩后恢复哪些技能内容
pendingCacheEditsmicroCompact.tscache_edits block缓存型微压缩等待 API 层发送的删除指令
pinnedEditsAPI 层user message index、edit block确保已经发送过的 cache edits 在后续请求中稳定复现

这些状态共同构成压缩系统的“记忆外骨骼”:摘要负责主线,状态表负责恢复工作集,cache edits 负责在压缩前减缓上下文增长。

设计取舍

取舍源码证据工程判断
提前压缩,而不是撞到窗口再压缩MAX_OUTPUT_TOKENS_FOR_SUMMARYAUTOCOMPACT_BUFFER_TOKENS压缩调用本身也需要输入输出空间,必须预留安全区
环境变量只能更保守Math.min(contextWindow, parsed)Math.min(percentageThreshold, autocompactThreshold)用户可以降低风险,不能绕过模型限制
Context Collapse 开启时禁用 autocompactshouldAutoCompact() 的 Collapse 守卫避免两个上下文管理器抢同一段历史
连续失败三次熔断MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3停止确定失败的自动重试,保护 API 预算
压缩后清空 readFileState 再恢复cacheToObject()clear()防止系统误以为模型仍知道旧文件内容
文件恢复有数量、单文件、总预算POST_COMPACT_* 常量恢复工作集,不恢复全集
技能按头部截断truncateToTokens(skill.content, 5K)技能文件开头通常是核心指令,截断比丢弃更好
delta 附件传空历史完整重播getDeferredToolsDeltaAttachment(..., [])复用增量机制,在压缩后恢复完整能力声明
微压缩分冷缓存和热缓存路径maybeTimeBasedMicrocompact() 短路 cachedMicrocompactPath()冷缓存直接改消息,热缓存用 cache_edits 保护缓存连续性

读源码抓手

读源码路线

不要从压缩提示词开始读。更好的路线是先看触发,再看压缩结果怎么回写。

  1. 先读 autoCompactIfNeeded(),看 query loop 每轮如何决定是否压缩。
  2. 再读 getEffectiveContextWindowSize()getAutoCompactThreshold(),把 20K、13K、环境变量覆盖算清楚。
  3. 接着读 shouldAutoCompact(),重点看 querySourceREACTIVE_COMPACT、Context Collapse 的守卫。
  4. 然后读 compactConversation(),追踪压缩请求、PTL retry、summary formatting、hook 和 telemetry。
  5. 再看 buildPostCompactMessages(),确认压缩后消息顺序。
  6. 最后追 createPostCompactFileAttachments()createSkillAttachmentIfNeeded()、delta attachment 重播,理解“压缩后为什么还能继续干活”。
  7. 如果还想理解压缩前的减压机制,再读 microcompactMessages(),区分时间型和缓存型微压缩。

小结

小结

Claude Code 的上下文压缩是一个预算驱动的状态重建系统。

  • 自动压缩阈值来自 有效窗口 - 13K buffer,有效窗口还会先扣掉最多 20K 的摘要输出空间。
  • shouldAutoCompact() 不只看 token,还会排除递归来源、ctx-agent、reactive compact 和 Context Collapse。
  • 压缩摘要由固定提示词生成,@@INLINE_0@@ 用来提升覆盖率,但会在 formatCompactSummary() 中剥离。
  • compactConversation() 的核心产物是新的消息序列:边界、摘要、保留消息、附件和 hook 结果。
  • 压缩后的文件、技能、计划、工具声明都走选择性恢复,源码通过预算和过滤规则保证“接回工作集”,而不是把压缩省下来的空间立刻填满。