第 22 课:Context Compression — 上下文压缩

0 阅读11分钟

模块七:记忆与上下文 | 前置依赖:第 20 课 | 预计学习时间:70 分钟


学习目标

完成本课后,你将能够:

  1. 解释三层压缩策略(microCompact / autoCompact / 手动 compact)的触发条件与协作关系
  2. 描述 compactConversation 的核心流程:分组、剥离图片、生成摘要、恢复文件与技能
  3. 理解 token 预算体系(50K 上下文、5K/文件、5K/技能、25K 技能总额)
  4. 说明 calculateTokenWarningState 的多级阈值设计
  5. 区分 REACTIVE_COMPACT 和 CONTEXT_COLLAPSE 两个实验性功能的作用

22.1 系统概览

上下文压缩是 Claude Code 中最关键的基础设施之一。LLM 的上下文窗口有限(通常 128K-200K tokens),而一个复杂的编码会话可能轻松超过这个限制。压缩系统确保对话可以无限延续。

三层压缩架构

                    上下文使用量增长 →

  ┌─────────────────────────────────────────────────────┐
  │                                                     │
  │  microCompact                                       │
  │  (每次 API 调用前)                                   │
  │  清理旧工具结果                                      │
  │                                                     │
  │         ┌──────────────────────────────────────────┐│
  │         │                                          ││
  │         │  autoCompact                             ││
  │         │  (token 阈值触发)                         ││
  │         │  完整摘要生成                              ││
  │         │                                          ││
  │         │         ┌────────────────────────────────┐││
  │         │         │                                │││
  │         │         │  手动 /compact                  │││
  │         │         │  (用户主动触发)                  │││
  │         │         │  支持自定义指令                  │││
  │         │         │                                │││
  │         │         └────────────────────────────────┘││
  │         └──────────────────────────────────────────┘│
  └─────────────────────────────────────────────────────┘
  
  轻量 ←──────────────────────────────────────→ 重量
  高频 ←──────────────────────────────────────→ 低频

22.2 microCompact — 轻量级压缩

触发时机

microCompact 在每次 API 调用前运行,成本极低。它不生成摘要,而是清理已经被处理过的工具结果。

三种 microCompact 路径

export async function microcompactMessages(
  messages: Message[],
  toolUseContext?: ToolUseContext,
  querySource?: QuerySource,
): Promise<MicrocompactResult> {
  // 路径 1: 时间基础微压缩(优先级最高)
  const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)
  if (timeBasedResult) return timeBasedResult

  // 路径 2: 缓存编辑微压缩(ant-only feature)
  if (feature('CACHED_MICROCOMPACT')) {
    // 使用 cache_edits API 删除工具结果
    // 不修改本地消息内容,通过 API 层操作
  }

  // 路径 3: 无操作(外部构建回退到 autoCompact)
  return { messages }
}

时间基础微压缩

当用户离开一段时间后回来,服务端的 prompt cache 已经过期(通常 5 分钟 TTL)。此时清理旧工具结果不会破坏缓存(因为缓存已经不存在了),反而能减少重写量:

export function evaluateTimeBasedTrigger(
  messages: Message[],
  querySource: QuerySource | undefined,
): { gapMinutes: number; config: TimeBasedMCConfig } | null {
  // 计算距离上次 assistant 消息的时间间隔
  const gapMinutes =
    (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
  if (gapMinutes < config.gapThresholdMinutes) return null
  return { gapMinutes, config }
}

清理策略:保留最近 N 个工具结果,将其余替换为占位符:

// 保留最后 keepRecent 个,至少保留 1 个
const keepRecent = Math.max(1, config.keepRecent)
const keepSet = new Set(compactableIds.slice(-keepRecent))
const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id)))

// 将清除目标的内容替换为占位符
return { ...block, content: '[Old tool result content cleared]' }

可清理的工具类型

const COMPACTABLE_TOOLS = new Set<string>([
  FILE_READ_TOOL_NAME,    // 文件读取结果
  ...SHELL_TOOL_NAMES,    // Bash 执行结果
  GREP_TOOL_NAME,         // 搜索结果
  GLOB_TOOL_NAME,         // 文件匹配结果
  WEB_SEARCH_TOOL_NAME,   // 网页搜索结果
  WEB_FETCH_TOOL_NAME,    // 网页获取结果
  FILE_EDIT_TOOL_NAME,    // 编辑确认
  FILE_WRITE_TOOL_NAME,   // 写入确认
])

只有这些工具的结果会被清理。MCP 工具、Agent 工具等不在列表中。


22.3 autoCompact — 自动摘要压缩

token 阈值计算

export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)
  return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS  // 13K 缓冲
}

export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,  // 20K
  )
  return contextWindow - reservedTokensForSummary
}

假设上下文窗口为 200K tokens:

总上下文窗口:     200,000 tokens
- 摘要输出预留:   - 20,000 tokens
= 有效上下文:     180,000 tokens
- autoCompact 缓冲: - 13,000 tokens
= autoCompact 阈值: 167,000 tokens

多级警告状态

calculateTokenWarningState 计算四个阈值状态:

export function calculateTokenWarningState(tokenUsage: number, model: string) {
  const threshold = isAutoCompactEnabled()
    ? autoCompactThreshold
    : getEffectiveContextWindowSize(model)

  return {
    percentLeft: Math.max(0, Math.round(((threshold - tokenUsage) / threshold) * 100)),
    isAboveWarningThreshold: tokenUsage >= threshold - 20_000,
    isAboveErrorThreshold: tokenUsage >= threshold - 20_000,
    isAboveAutoCompactThreshold: isAutoCompactEnabled() && tokenUsage >= autoCompactThreshold,
    isAtBlockingLimit: tokenUsage >= actualContextWindow - 3_000,
  }
}

视觉化这些阈值:

0%                                                           100%
│═══════════════════════════════════════════════════════════════│
│                                                             │
│                    ▲ Warning     ▲ AutoCompact  ▲ Blocking  │
│                    │ (-20K)      │ (-13K)       │ (-3K)     │
│                    │             │              │           │
│              状态栏变黄      自动压缩触发    阻止用户输入      │

熔断器

const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

if (tracking?.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) {
  return { wasCompacted: false }  // 停止重试
}

如果 autoCompact 连续失败 3 次,停止尝试。这是一个重要的保护机制 — 没有它,上下文不可恢复地超限的会话每轮都会发出无效的压缩 API 调用,全局每天浪费约 250K 次调用。

Session Memory 优先

export async function autoCompactIfNeeded(...) {
  // 首先尝试 Session Memory 压缩(更便宜)
  const sessionMemoryResult = await trySessionMemoryCompaction(
    messages, toolUseContext.agentId, recompactionInfo.autoCompactThreshold,
  )
  if (sessionMemoryResult) {
    // 成功 → 不需要完整摘要
    return { wasCompacted: true, compactionResult: sessionMemoryResult }
  }

  // Session Memory 不可用/不足 → 回退到完整摘要
  const compactionResult = await compactConversation(...)
}

Session Memory 压缩(第 20 课的 MEMORY.md)作为更轻量的替代方案优先尝试。它直接使用已有的记忆文件作为摘要,不需要额外的 API 调用来生成摘要。


22.4 compactConversation — 核心压缩流程

完整流程

compactConversation(messages, context, ...)
  │
  ├── 1. 执行 PreCompact hooks
  │
  ├── 2. 按 API 轮次分组消息
  │      groupMessagesByApiRound()
  │
  ├── 3. 剥离图片和文档
  │      stripImagesFromMessages()
  │
  ├── 4. 生成摘要(Fork 或 Stream)
  │      streamCompactSummary()
  │       ├── 共享缓存路径(runForkedAgent)
  │       └── 独立流式路径(queryModelWithStreaming)
  │
  ├── 5. 处理 prompt_too_long 重试
  │      truncateHeadForPTLRetry()
  │
  ├── 6. 清空文件缓存
  │      context.readFileState.clear()
  │
  ├── 7. 恢复关键文件
  │      createPostCompactFileAttachments()
  │
  ├── 8. 恢复技能内容
  │      createSkillAttachmentIfNeeded()
  │
  ├── 9. 重新注入工具/Agent/MCP 信息
  │      getDeferredToolsDeltaAttachment()
  │      getAgentListingDeltaAttachment()
  │      getMcpInstructionsDeltaAttachment()
  │
  ├── 10. 执行 SessionStart hooks
  │
  └── 11. 构建最终消息数组
         boundaryMarker + summary + attachments + hookResults

消息分组

groupMessagesByApiRound() 按 assistant 消息的 message.id 分组 — 来自同一个 API 响应的消息(包括流式分块)共享同一个 id,被归入同一组。这确保了 tool_use 和 tool_result 配对不被拆散。

图片剥离

stripImagesFromMessages() 将 image/document 块替换为 [image]/[document] 文本标记。图片不参与摘要生成(压缩 API 本身可能因图片导致 prompt_too_long),但标记让摘要知道这里有图片。也处理 tool_result 中嵌套的图片。

摘要提示

摘要提示要求模型生成包含 9 个章节的详细摘要:

1. Primary Request and Intent     — 用户意图
2. Key Technical Concepts          — 技术概念
3. Files and Code Sections         — 文件与代码
4. Errors and fixes                — 错误与修复
5. Problem Solving                 — 问题解决
6. All user messages               — 所有用户消息
7. Pending Tasks                   — 待办任务
8. Current Work                    — 当前工作
9. Optional Next Step              — 可选下一步

特别的是,提示中包含一个 <analysis> 草稿区:

const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
...
Your entire response must be plain text: an <analysis> block followed by a <summary> block.`

<analysis> 块是模型的思考过程,在格式化时被移除:

export function formatCompactSummary(summary: string): string {
  // 移除 analysis 草稿区
  formattedSummary = formattedSummary.replace(/<analysis>[\s\S]*?<\/analysis>/, '')
  // 提取 summary 内容
  const summaryMatch = formattedSummary.match(/<summary>([\s\S]*?)<\/summary>/)
  // ...
}

22.5 Token 预算体系

压缩后需要恢复关键的上下文信息,但恢复量必须有上限,否则压缩效果会被稀释。

预算常量

export const POST_COMPACT_TOKEN_BUDGET = 50_000        // 总恢复预算 50K
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5     // 最多恢复 5 个文件
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000  // 每个文件 5K
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 // 每个技能 5K
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000  // 技能总预算 25K

预算分配图

POST_COMPACT_TOKEN_BUDGET (50K)
├── 文件恢复 (最多 5 个 x 5K = 25K)
│   ├── 最近读取的文件(按 readFileState)
│   ├── 去重(file_unchanged 的不重复读取)
│   └── 超长文件截断到 5K tokens
│
├── 技能恢复 (最多 25K)
│   ├── 当前会话中调用过的技能
│   ├── 每个技能截断到 5K tokens
│   └── "Instructions at the top are usually critical"
│
├── 计划恢复 (如有)
│   └── Plan mode 的当前计划文件
│
├── 工具信息重注入
│   ├── Deferred tools delta
│   ├── Agent listing delta
│   └── MCP instructions delta
│
└── SessionStart hooks 结果
    └── CLAUDE.md 重加载等

技能截断的设计考量

// Skills can be large (verify=18.7KB, claude-api=20.1KB). Previously re-injected
// unbounded on every compact → 5-10K tok/compact. Per-skill truncation beats
// dropping — instructions at the top of a skill file are usually the critical part.
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000

技能文件可以很大(最大超过 20KB)。之前不限制地重注入导致每次压缩消耗 5-10K tokens。截断策略优于直接丢弃 — 技能文件顶部的指令通常是最关键的部分。


22.6 prompt_too_long 重试

当压缩请求本身因为上下文太长而失败时,truncateHeadForPTLRetry() 自动截断最老的消息组并重试。策略:如果 API 返回了精确的 token 缺口,按需丢弃覆盖缺口的组数;否则回退到丢弃 20% 的组。至少保留 1 组,最多重试 3 次。这是"最后手段" — 丢弃最老的上下文有损,但解除了用户被卡住的困境。


22.7 Feature-Gated 实验

REACTIVE_COMPACT

if (feature('REACTIVE_COMPACT')) {
  if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) {
    return false  // 抑制主动 autoCompact
  }
}

Reactive Compact 是一种替代策略:不主动压缩,而是等到 API 返回 prompt_too_long 错误时再被动压缩。这减少了不必要的压缩操作(很多会话永远不会达到上限),但增加了到达上限时的延迟。

CONTEXT_COLLAPSE

if (feature('CONTEXT_COLLAPSE')) {
  const { isContextCollapseEnabled } =
    require('../contextCollapse/index.js')
  if (isContextCollapseEnabled()) {
    return false  // Context Collapse 取代 autoCompact
  }
}

Context Collapse 是一个更精细的上下文管理系统。当它启用时,autoCompact 被完全禁用,因为两者会竞争:

autoCompact 触发点:    ~93% 有效上下文
Context Collapse 提交: ~90% 有效上下文
Context Collapse 阻塞: ~95% 有效上下文

如果两者同时启用,autoCompact 会在 93% 时触发,销毁 Context Collapse 正在维护的细粒度上下文。所以必须互斥。

两个 feature flag 都用 feature() 包裹,确保在外部构建中被死代码消除,flag 字符串不会出现在发布的代码中。


22.8 Post-Compact 清理

压缩后需要重置大量缓存和追踪状态:

export function runPostCompactCleanup(querySource?: QuerySource): void {
  resetMicrocompactState()         // 重置微压缩状态
  // Context Collapse 重置(仅主线程)
  // getUserContext 缓存清除(仅主线程)
  // CLAUDE.md 文件缓存重置
  clearSystemPromptSections()      // 系统提示缓存
  clearClassifierApprovals()       // 分类器审批缓存
  clearSpeculativeChecks()         // 推测执行检查
  clearBetaTracingState()          // 追踪状态
  clearSessionMessagesCache()      // 会话消息缓存
}

注意主线程保护:sub-agent 也可能触发压缩,但它们与主线程共享模块级状态。如果 sub-agent 的压缩清理了主线程的缓存,会导致主线程状态损坏。通过 querySource 判断来保护。


22.9 Session Memory Compact — 免 API 调用的压缩

当 Session Memory(第 20 课)可用时,系统可以用它替代完整的摘要 API 调用:

export async function trySessionMemoryCompaction(
  messages: Message[],
  agentId?: AgentId,
  autoCompactThreshold?: number,
): Promise<CompactionResult | null> {
  // 前置条件检查
  if (!shouldUseSessionMemoryCompaction()) return null
  await waitForSessionMemoryExtraction()  // 等待进行中的提取
  const sessionMemory = await getSessionMemoryContent()
  if (!sessionMemory || await isSessionMemoryEmpty(sessionMemory)) return null

  // 计算保留多少消息
  const startIndex = calculateMessagesToKeepIndex(messages, lastSummarizedIndex)
  const messagesToKeep = messages.slice(startIndex)
    .filter(m => !isCompactBoundaryMessage(m))

  // 用 session memory 内容作为摘要
  return createCompactionResultFromSessionMemory(
    messages, sessionMemory, messagesToKeep, hookResults, transcriptPath,
  )
}

优势:零额外 API 调用。Session Memory 在对话过程中就已经被提取了(第 20 课),压缩时直接使用。

保留消息策略

export const DEFAULT_SM_COMPACT_CONFIG = {
  minTokens: 10_000,           // 至少保留 10K tokens 的近期消息
  minTextBlockMessages: 5,     // 至少保留 5 条有文本的消息
  maxTokens: 40_000,           // 最多保留 40K tokens
}

lastSummarizedMessageId 开始向前扩展,直到满足 minTokens 和 minTextBlockMessages 的最低要求,但不超过 maxTokens 的上限。还需要确保 tool_use/tool_result 配对完整性。


课后练习

练习 1:阈值计算

假设模型上下文窗口为 128K tokens,最大输出为 16K tokens。计算以下值:

  • 有效上下文窗口大小
  • autoCompact 触发阈值
  • Warning 阈值
  • Blocking 限制

练习 2:压缩路径分析

一个会话有 120 条消息,总共 150K tokens。Session Memory 已启用但内容为空(匹配模板)。autoCompact 触发。详细描述系统会走哪条压缩路径,以及为什么。

练习 3:post-compact 恢复

压缩前 readFileState 中有 10 个文件,其中 3 个超过 5K tokens。会话中调用了 2 个技能,一个 3K tokens,一个 18K tokens。计算 post-compact 恢复消息的总 token 预算消耗,并说明哪些内容会被截断。

练习 4:竞争条件

如果 CONTEXT_COLLAPSE 和 autoCompact 同时启用(由于配置错误),且上下文使用量达到 93%,会发生什么?追踪 shouldAutoCompact() 的代码路径,说明系统如何处理这种冲突。


本课小结

要点内容
三层架构microCompact(每次 API 前)→ autoCompact(阈值触发)→ 手动 /compact
microCompact时间基础清理 / cache_edits API / 只清理 8 种工具结果
autoCompact有效上下文 - 13K buffer = 触发阈值,3 次失败熔断
核心流程分组 → 剥离图片 → 摘要 → 清缓存 → 恢复文件/技能/工具
Token 预算50K 总额,5K/文件(最多 5 个),5K/技能(25K 总额)
SM Compact用 Session Memory 替代摘要 API,零额外调用
PTL 重试压缩请求本身太长时截断最老的组,最多重试 3 次
实验功能REACTIVE_COMPACT(被动压缩)、CONTEXT_COLLAPSE(精细管理)
清理保护sub-agent 压缩不清除主线程缓存,通过 querySource 判断

下一课预告

第 23 课:Prompt Cache 与 API 优化 — 深入 Claude Code 如何利用 Anthropic API 的 prompt cache 机制降低成本和延迟,包括 cache-safe parameters 的设计、cache break detection 系统、以及 forked agent 如何共享主对话的 cache prefix。