手撕 Claude Code-7:自动压缩与记忆恢复

0 阅读11分钟

第 7 章:Context Compaction — 自动压缩与记忆恢复

源码位置:src/services/compact/compact.ts、src/services/compact/autoCompact.ts


7.1 为什么需要上下文压缩?

Claude API 有上下文窗口限制(通常是 200K tokens)。在长时间的编程会话中,对话历史会不断增长:

  • 代码文件读取(可能几千行)
  • 工具调用结果(Bash 输出、搜索结果)
  • 来回的对话消息

当上下文接近限制时,有两个选择:

  1. 直接截断历史(丢失重要信息)
  2. 智能压缩:让 Claude 总结之前的对话,保留关键信息

Claude Code 选择了方案 2,并实现了多层次自动触发机制。


7.2 压缩类型

类型文件触发方式
主压缩(Full Compact)compact.ts手动 /compact 或自动触发
自动压缩(Auto Compact)autoCompact.tsToken 用量达到阈值时自动触发
微压缩(Micro Compact)microCompact.ts单个大型工具结果的增量压缩
API 上下文管理apiMicrocompact.ts利用 API beta 功能
反应式压缩(Reactive Compact)reactiveCompact.tsmax_output_tokens 错误后触发(功能门控)
会话记忆压缩sessionMemoryCompact.ts自动压缩前优先尝试;基于跨会话长期记忆

7.3 核心常量与恢复预算

源码位置:src/services/compact/compact.ts:122-131

// compact 后恢复文件数量上限
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5

// compact 后文件恢复总 token 预算
export const POST_COMPACT_TOKEN_BUDGET = 50_000

// 每个文件最多恢复 5K token
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000

// 每个技能最多恢复 5K token
// 注:verify=18.7KB, claude-api=20.1KB;之前无限制导致每次 compact 5-10K token
// 截断优于丢弃——技能文件头部是最重要的指令部分
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000

// 技能恢复总 token 预算(约 5 个技能)
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000

// 压缩流式请求最大重试次数(PTL 重试)
const MAX_PTL_RETRIES = 3

7.4 阈值计算体系(4 个层次)

源码位置:src/services/compact/autoCompact.ts

Auto Compact 引入了 4 个不同层次的 token 警戒线,全部在 calculateTokenWarningState() 中统一计算:

export const AUTOCOMPACT_BUFFER_TOKENS   = 13_000   // 自动压缩缓冲
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000  // 警告阈值缓冲
export const ERROR_THRESHOLD_BUFFER_TOKENS   = 20_000  // 错误阈值缓冲
export const MANUAL_COMPACT_BUFFER_TOKENS    = 3_000   // 阻塞限制缓冲

有效上下文窗口(effectiveContextWindow)

effectiveContextWindow
  = min(contextWindow, CLAUDE_CODE_AUTO_COMPACT_WINDOW)
  - min(getMaxOutputTokensForModel(model), 20_000)

预留 min(maxOutput, 20_000) tokens 给摘要输出(基于 p99.99 实测最大摘要为 17,387 tokens)。

4 个阈值

阈值公式含义
autoCompactThresholdeffectiveWindow - 13_000触发自动压缩
warningThresholdthreshold - 20_000显示橙色百分比警告
errorThresholdthreshold - 20_000显示红色错误状态(与 warning 相同,行为不同)
blockingLimiteffectiveWindow - 3_000完全阻塞新输入

注:thresholdcalculateTokenWarningState 内部的值:启用 auto compact 时 = autoCompactThreshold;禁用时 = effectiveContextWindow

calculateTokenWarningState 的签名

// src/services/compact/autoCompact.ts
export function calculateTokenWarningState(
  tokenUsage: number,  // 当前 token 数量(不是 messages 数组)
  model: string,       // 模型名称(用于查询上下文窗口大小)
): {
  percentLeft: number                // 剩余百分比(0-100)
  isAboveWarningThreshold: boolean   // 是否超过警告线
  isAboveErrorThreshold: boolean     // 是否超过错误线
  isAboveAutoCompactThreshold: boolean  // 是否触发自动压缩
  isAtBlockingLimit: boolean         // 是否达到阻塞限制
}

注意:签名是 (tokenUsage: number, model: string) 而非 (messages, contextLimit),返回值是 5 个布尔字段的对象而非 'warning' | 'critical' | null

getAutoCompactThreshold 也接受 model 参数:

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

  // 支持环境变量 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 覆盖(用于测试)
  const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
  if (envPercent) {
    const percentageThreshold = Math.floor(effectiveContextWindow * (parsed / 100))
    return Math.min(percentageThreshold, autocompactThreshold)
  }
  return autocompactThreshold
}

7.5 isAutoCompactEnabled 与 shouldAutoCompact

isAutoCompactEnabled()

export function isAutoCompactEnabled(): boolean {
  if (isEnvTruthy(process.env.DISABLE_COMPACT)) return false
  // 只禁用自动压缩,保留手动 /compact
  if (isEnvTruthy(process.env.DISABLE_AUTO_COMPACT)) return false
  return getGlobalConfig().autoCompactEnabled
}

检查顺序:DISABLE_COMPACTDISABLE_AUTO_COMPACT → 用户配置。

shouldAutoCompact() 的递归防护

源码位置:src/services/compact/autoCompact.ts:160-238

压缩本身也会启动 forked agent(querySource 为 'compact'),如果允许在这些子调用中再次触发压缩,将导致死锁。因此 shouldAutoCompact() 设置了多个递归防护:

export async function shouldAutoCompact(
  messages: Message[],
  model: string,
  querySource?: QuerySource,
  snipTokensFreed = 0,
): Promise<boolean> {
  // 防护 1:自身递归(compact 的 forked agent 不能再触发 compact)
  if (querySource === 'session_memory' || querySource === 'compact') {
    return false
  }

  // 防护 2:feature gate - marble_origami (ctx-agent) 触发会破坏主线程
  if (feature('CONTEXT_COLLAPSE') && querySource === 'marble_origami') {
    return false
  }

  // 防护 3:feature gate - 反应式压缩模式下禁止主动压缩
  if (feature('REACTIVE_COMPACT') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) {
    return false
  }

  // 防护 4:Context Collapse 模式已经管理上下文,不应与 autocompact 竞争
  if (feature('CONTEXT_COLLAPSE') && isContextCollapseEnabled()) {
    return false
  }

  if (!isAutoCompactEnabled()) return false

  const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
  const { isAboveAutoCompactThreshold } = calculateTokenWarningState(tokenCount, model)
  return isAboveAutoCompactThreshold
}

7.6 autoCompactIfNeeded:熔断器 + Session Memory 优先

源码位置:src/services/compact/autoCompact.ts:241-351

熔断器(Circuit Breaker)

const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.

当上下文不可恢复地超过限制(如 prompt_too_long),每轮都重试只会浪费 API 调用。熔断器在连续失败 3 次后停止尝试,通过 AutoCompactTrackingState.consecutiveFailures 在 query loop 轮次间传递状态。

完整执行流程

export async function autoCompactIfNeeded(
  messages,
  toolUseContext,
  cacheSafeParams,
  querySource?,
  tracking?,
  snipTokensFreed?,
): Promise<{ wasCompacted: boolean; compactionResult?: CompactionResult; consecutiveFailures?: number }>

执行顺序:

1. 检查 DISABLE_COMPACT 环境变量
2. 检查熔断器(consecutiveFailures >= 3 → 跳过)
3. shouldAutoCompact() — 递归防护 + token 检查
4. 优先尝试 trySessionMemoryCompaction()   ← 先用会话记忆压缩
   ├─ 成功 → notifyCompaction() + markPostCompaction() + return
   └─ 失败 → 继续主压缩
5. compactConversation()                   ← 主压缩
   ├─ 成功 → consecutiveFailures: 0(重置)
   └─ 失败 → consecutiveFailures: prevFailures + 1

关键设计:Session Memory Compact 在 Full Compact 之前尝试,因为 Session Memory 是增量裁剪而不是全量重写,对 prompt cache 更友好。


7.7 主压缩函数:compactConversation

源码位置:src/services/compact/compact.ts:387-763

注意compactConversation 是普通的 async function,返回 Promise<CompactionResult>,不是 async generator。

export async function compactConversation(
  messages: Message[],
  context: ToolUseContext,
  cacheSafeParams: CacheSafeParams,
  suppressFollowUpQuestions: boolean,
  customInstructions?: string,
  isAutoCompact: boolean = false,
  recompactionInfo?: RecompactionInfo,
): Promise<CompactionResult>

CompactionResult 接口

export interface CompactionResult {
  boundaryMarker: SystemMessage       // compact 边界消息
  summaryMessages: UserMessage[]      // 摘要消息(1条)
  attachments: AttachmentMessage[]    // 恢复的附件
  hookResults: HookResultMessage[]    // SessionStart hook 结果
  messagesToKeep?: Message[]          // 保留的原始消息(reactive/SM compact 用)
  userDisplayMessage?: string         // 显示给用户的消息
  preCompactTokenCount?: number
  postCompactTokenCount?: number      // 实为 compact API 调用的总消耗(非结果大小)
  truePostCompactTokenCount?: number  // 压缩后消息的实际 token 估计
  compactionUsage?: ReturnType<typeof getTokenUsage>
}

执行步骤详解

源码位置:src/services/compact/compact.ts:395-762

步骤 1: 执行 PreCompact hooks
  ├─ hookResult.newCustomInstructions 合并到 customInstructions
  └─ hookResult.userDisplayMessage 保存备用

步骤 2: stripImagesFromMessages(messages)
  └─ 替换图像为 [image] 文本占位符,避免压缩请求自身触发 prompt-too-long

步骤 3: stripReinjectedAttachments(messages)
  └─ 过滤 skill_discovery/skill_listing 附件(压缩后会重新注入,无需送入摘要)

步骤 4: PTL 重试循环(最多 MAX_PTL_RETRIES = 3 次)
  ├─ streamCompactSummary() — 流式调用 Claude 生成摘要
  ├─ 若摘要以 PROMPT_TOO_LONG_ERROR_MESSAGE 开头:
  │   └─ truncateHeadForPTLRetry() 截断最旧的 API 轮次 → 重试
  └─ 超过重试次数 → throw ERROR_MESSAGE_PROMPT_TOO_LONG

步骤 5: 保存 preCompactReadFileState 快照
  └─ preCompactReadFileState = cacheToObject(context.readFileState)
     !! 必须在 readFileState.clear() 之前保存 !!

步骤 6: 清除状态缓存
  ├─ context.readFileState.clear()
  ├─ context.loadedNestedMemoryPaths?.clear()
  └─ sentSkillNames 故意不重置(重注入收益极低,且会产生 cache_creation 浪费)

步骤 7: 并行构建附件
  ├─ createPostCompactFileAttachments(preCompactReadFileState, context, 5)
  │   └─ 使用步骤 5 的快照,最多 5 个文件,各 ≤5K token
  └─ createAsyncAgentAttachmentsIfNeeded(context)

步骤 8: 构建额外附件
  ├─ createPlanAttachmentIfNeeded()  — 当前计划
  ├─ createPlanModeAttachmentIfNeeded()  — Plan 模式指令
  ├─ createSkillAttachmentIfNeeded()  — 已调用技能
  ├─ getDeferredToolsDeltaAttachment(tools, model, [], ...)  — Deferred 工具(diff against [])
  ├─ getAgentListingDeltaAttachment(context, [])  — Agent 列表(diff against [])
  └─ getMcpInstructionsDeltaAttachment(mcpClients, tools, model, [])  — MCP 指令(diff against [])

  注:传入空 [] 作为 history,意味着 delta = "全部重新宣告",确保压缩后模型知道所有工具

步骤 9: 执行 SessionStart hooks(trigger='compact')
  └─ processSessionStartHooks('compact', { model }) → hookMessages

步骤 10: 创建 boundaryMarker
  ├─ createCompactBoundaryMessage(isAutoCompact ? 'auto' : 'manual', preCompactTokenCount, lastMsgUuid)
  └─ 提取 preCompactDiscoveredTools(已加载的 Deferred 工具名列表)写入 compactMetadata
     确保压缩后 schema filter 仍然正确发送已加载的 Deferred 工具 schema

步骤 11: notifyCompaction() + markPostCompaction() + reAppendSessionMetadata()
  └─ 重置 prompt cache 基线,避免压缩后的缓存变化被误报为 cache break

步骤 12: 执行 PostCompact hooks
  └─ executePostCompactHooks({ trigger, compactSummary }, signal)

返回 CompactionResult

7.8 buildPostCompactMessages

源码位置:src/services/compact/compact.ts:330-337

export function buildPostCompactMessages(result: CompactionResult): Message[] {
  return [
    result.boundaryMarker,
    ...result.summaryMessages,
    ...(result.messagesToKeep ?? []),
    ...result.attachments,
    ...result.hookResults,
  ]
}

注意:这是一个非常简单的函数,只是将 CompactionResult 的各字段按固定顺序组装成消息数组。复杂的恢复逻辑(文件、技能、delta 附件)发生在 compactConversation() 内部,结果已包含在 attachments 字段里。

顺序:boundaryMarker → summaryMessages → messagesToKeep → attachments → hookResults


7.9 图像移除(stripImagesFromMessages)

源码位置:src/services/compact/compact.ts:145-200

export function stripImagesFromMessages(messages: Message[]): Message[] {
  return messages.map(message => {
    if (message.type !== 'user') return message  // 只处理 user 消息

    const content = message.message.content
    if (!Array.isArray(content)) return message

    let hasMediaBlock = false
    const newContent = content.flatMap(block => {
      if (block.type === 'image') {
        hasMediaBlock = true
        return [{ type: 'text', text: '[image]' }]
      }
      if (block.type === 'document') {
        hasMediaBlock = true
        return [{ type: 'text', text: '[document]' }]
      }
      // 处理 tool_result 内部嵌套的图像
      if (block.type === 'tool_result' && Array.isArray(block.content)) {
        // ... 同样替换为 '[image]' / '[document]'
      }
      return [block]
    })

    if (!hasMediaBlock) return message
    return { ...message, message: { ...message.message, content: newContent } }
  })
}

为什么要移除图像? CCD(Claude.ai 桌面)用户频繁附加图像。如果压缩 API 调用本身包含太多图像,会触发 prompt-too-long 错误。移除图像后只保留文本占位符,仍能让 Claude 知道"那里有过图像"。


7.10 stripReinjectedAttachments

源码位置:src/services/compact/compact.ts:211-223

export function stripReinjectedAttachments(messages: Message[]): Message[] {
  if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
    return messages.filter(
      m =>
        !(
          m.type === 'attachment' &&
          (m.attachment.type === 'skill_discovery' ||
            m.attachment.type === 'skill_listing')
        ),
    )
  }
  return messages
}

skill_discoveryskill_listing 附件在压缩后会通过下一轮的 delta 机制重新注入,无需送入摘要 prompt 浪费 token。


7.11 PTL 重试机制(truncateHeadForPTLRetry)

源码位置:src/services/compact/compact.ts:243-291

当压缩 API 请求本身触发 prompt-too-long 时(CC-1180 场景),不能直接报错让用户卡住,需要自动重试:

最多重试 MAX_PTL_RETRIES = 3 次
  │
  ▼
truncateHeadForPTLRetry(messagesToSummarize, summaryResponse)
  ├─ 解析 PTL 错误响应中的 tokenGap
  ├─ 按 tokenGap 累积丢弃最旧的 API-round groups
  │   └─ 若无法解析 gap → 按比例丢弃 20% groups
  ├─ 保留至少 1group(避免没有内容可摘要)
  └─ 若裁剪后首条是 assistant 消息 → 在头部插入 PTL_RETRY_MARKER user 消息
     (API 要求第一条消息角色为 user)

  连续重试时会先检测并移除上次插入的 PTL_RETRY_MARKER,避免虚假的 group 0 影响统计

这是"有损但能解锁用户"的最后逃生路线,丢失最旧上下文但总比完全卡死强。


7.12 微压缩(Micro Compact)

源码位置:src/services/compact/microCompact.ts:41-50

微压缩处理单个大型工具结果的就地压缩,不需要重写整个对话历史。只针对特定工具:

const COMPACTABLE_TOOLS = new Set<string>([
  FILE_READ_TOOL_NAME,    // Read
  ...SHELL_TOOL_NAMES,    // Bash / Shell
  GREP_TOOL_NAME,         // Grep
  GLOB_TOOL_NAME,         // Glob
  WEB_SEARCH_TOOL_NAME,   // WebSearch
  WEB_FETCH_TOOL_NAME,    // WebFetch
  FILE_EDIT_TOOL_NAME,    // Edit
  FILE_WRITE_TOOL_NAME,   // Write
])

对于每个符合条件的工具调用结果,若其 token 数量超过阈值,微压缩会将其内容替换为 [Old tool result content cleared],释放空间而不影响对话结构。


7.13 Session Memory Compact

源码位置:src/services/compact/sessionMemoryCompact.ts

Session Memory Compact 是 Full Compact 的前置候选。autoCompactIfNeeded() 在调用 compactConversation() 之前先尝试 trySessionMemoryCompaction()

  • 区别:Session Memory 是增量裁剪(删除可摘要的旧消息),而不是全量重写
  • 对 prompt cache 更友好:保留前缀结构,cache miss 更少
  • 失败时降级:Session Memory 压缩不足,再走完整 Full Compact
  • 后处理一致:两者都会调用 setLastSummarizedMessageId(undefined) + runPostCompactCleanup() + notifyCompaction()

7.14 Hook 集成

压缩过程有三个 hook 点:

// PreCompact Hook:压缩前
await executePreCompactHooks({ trigger: 'auto' | 'manual', customInstructions }, signal)
// 用户可以:
// - 注入额外的"不要压缩这些信息"指令(newCustomInstructions)
// - 记录压缩发生
// - 注入用户显示消息(userDisplayMessage)

// SessionStart Hook:摘要生成后,boundaryMarker 创建前
await processSessionStartHooks('compact', { model })
// 结果作为 hookMessages 写入 CompactionResult.hookResults

// PostCompact Hook:压缩完成后
await executePostCompactHooks({ trigger, compactSummary }, signal)
// 用户可以:
// - 注入恢复信息(userDisplayMessage)
// - 通知外部系统

7.15 流程图:Auto Compaction 完整流程

Agent Loop 轮次结束
        │
        ▼
autoCompactIfNeeded()
        │
        ├─ DISABLE_COMPACT=true → 跳过
        ├─ consecutiveFailures >= 3 → 熔断器跳过
        │
        ▼
shouldAutoCompact()
        │
        ├─ querySource in [session_memory, compact, marble_origami] → false
        ├─ !isAutoCompactEnabled() → false
        ├─ tokenCount < autoCompactThreshold → false
        └─ true ↓
        │
        ▼
trySessionMemoryCompaction()
        │
   ┌────┴─────┐
  成功        失败
   │           │
   ▼           ▼
notifyCompaction  compactConversation()
markPostCompaction    │
return wasCompacted   ▼
              [PreCompact hooks]stripImagesFromMessages()
              stripReinjectedAttachments()
                      │
              [PTL 重试循环 ≤3次]
              streamCompactSummary()
                      │
              preCompactReadFileState = snapshot()
              readFileState.clear()
                      │
              [并行] createPostCompactFileAttachments
                     createAsyncAgentAttachments
                      │
              [串行] plan/planMode/skill/deferred/agent/mcp 附件
                      │
              processSessionStartHooks('compact')
                      │
              createCompactBoundaryMessage()
              ├─ 写入 preCompactDiscoveredTools
                      │
              notifyCompaction()
              markPostCompaction()
              reAppendSessionMetadata()
                      │
              [PostCompact hooks]
                      │
              return CompactionResult
                      │
              ├─ 成功: consecutiveFailures = 0
              └─ 失败: consecutiveFailures++
                      │
                      ▼
               继续 Agent Loop

小结

机制触发条件核心操作源码位置
Auto Compact 判断每轮 agent loopcalculateTokenWarningState(tokenUsage, model)autoCompact.ts
熔断器连续失败 ≥ 3 次停止重试,避免 API 浪费autoCompact.ts:70
Session Memory Compact优先于 Full Compact增量裁剪,cache 友好sessionMemoryCompact.ts
主压缩手动或自动compactConversation() 普通 async 函数compact.ts:387
PTL 重试compact 自身触发 prompt-too-longtruncateHeadForPTLRetry() ≤3 次compact.ts:243
图像移除压缩前替换 image/document 为文本占位符stripImagesFromMessages()
附件移除压缩前过滤 skill_discovery/listingstripReinjectedAttachments()
文件恢复压缩后最多 5 个文件,各 ≤5K token;使用压缩前快照createPostCompactFileAttachments()
sentSkillNames压缩后故意不重置,避免 cache_creation 浪费compact.ts:526-530
Delta 附件重注入压缩后deferred/agent/mcp 以 [] 为基准全量宣告compact.ts:567-585
preCompactDiscoveredToolsboundaryMarker持久化已加载 Deferred 工具列表compact.ts:606-611
微压缩单个大结果过大就地替换为占位文本microCompact.ts
Pre/Post Hook压缩前后用户自定义扩展hooks.ts