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

第 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.ts Token 用量达到阈值时自动触发
微压缩(Micro Compact) microCompact.ts 单个大型工具结果的增量压缩
API 上下文管理 apiMicrocompact.ts 利用 API beta 功能
反应式压缩(Reactive Compact) reactiveCompact.ts max_output_tokens 错误后触发(功能门控)
会话记忆压缩 sessionMemoryCompact.ts 自动压缩前优先尝试;基于跨会话长期记忆

7.3 核心常量与恢复预算

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

typescript 复制代码
// 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() 中统一计算:

typescript 复制代码
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)

scss 复制代码
effectiveContextWindow
  = min(contextWindow, CLAUDE_CODE_AUTO_COMPACT_WINDOW)
  - min(getMaxOutputTokensForModel(model), 20_000)

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

4 个阈值

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

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

calculateTokenWarningState 的签名

typescript 复制代码
// 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 参数:

typescript 复制代码
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()

typescript 复制代码
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() 设置了多个递归防护:

typescript 复制代码
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)

typescript 复制代码
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 轮次间传递状态。

完整执行流程

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

执行顺序:

scss 复制代码
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。

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

CompactionResult 接口

typescript 复制代码
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

css 复制代码
步骤 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

typescript 复制代码
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

typescript 复制代码
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

typescript 复制代码
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 场景),不能直接报错让用户卡住,需要自动重试:

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

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

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


7.12 微压缩(Micro Compact)

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

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

typescript 复制代码
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 点:

typescript 复制代码
// 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 完整流程

scss 复制代码
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 loop calculateTokenWarningState(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-long truncateHeadForPTLRetry() ≤3 次 compact.ts:243
图像移除 压缩前 替换 image/document 为文本占位符 stripImagesFromMessages()
附件移除 压缩前 过滤 skill_discovery/listing stripReinjectedAttachments()
文件恢复 压缩后 最多 5 个文件,各 ≤5K token;使用压缩前快照 createPostCompactFileAttachments()
sentSkillNames 压缩后 故意不重置,避免 cache_creation 浪费 compact.ts:526-530
Delta 附件重注入 压缩后 deferred/agent/mcp 以 [] 为基准全量宣告 compact.ts:567-585
preCompactDiscoveredTools boundaryMarker 持久化已加载 Deferred 工具列表 compact.ts:606-611
微压缩 单个大结果过大 就地替换为占位文本 microCompact.ts
Pre/Post Hook 压缩前后 用户自定义扩展 hooks.ts
相关推荐
橘子星1 小时前
浅谈 TypeScript 与 Bun:现代 JavaScript 开发的利器
前端·javascript
嘟嘟07171 小时前
从 TypeScript 到 Bun:一份前端开发者的效率进阶笔记
后端
用户7508837061951 小时前
循环依赖加 @Lazy 后异常漂移?Spring 三级缓存为什么没兜住?
后端
铁皮饭盒1 小时前
Bun 的三种并发"暗器":reusePort、Worker、spawn,能硬刚 Java 吗?
前端·javascript·后端
Nturmoils1 小时前
从 MySQL 到 KingbaseES:Database、Schema、User 一次讲透
数据库·后端
CodeSheep1 小时前
宇树科技,即将上市!
前端·后端·程序员
MacroZheng1 小时前
这款DeepSeek V4终端编程神器,在GitHub上火了!
人工智能·后端·deepseek
yaoxin5211232 小时前
430. Java 日期时间 API - 时间计算 Temporal 包
java·前端·python