Skip to content

第16章 上下文管理与自动压缩

"The art of programming is the art of organizing complexity, of mastering multitude and avoiding its bastard chaos." -- Edsger W. Dijkstra

本章要点

  • 大语言模型上下文窗口的物理限制及其对长对话的深层影响
  • Token 追踪体系:从 API 精确计量到客户端粗略估算的双轨机制
  • 自动压缩(Auto-compact)的触发条件、压缩算法与渐进式策略
  • Session Memory 压缩:一种无需 API 调用的轻量级替代方案
  • Microcompact:针对工具结果的细粒度内容清理机制
  • 持久化记忆系统(memdir):从文件组织到智能检索的全链路设计
  • 会话历史管理与 /resume 会话恢复的实现细节
  • CompactBoundaryMessage 如何在压缩前后建立语义连续性

对于一个交互式 AI 编程助手而言,上下文管理是一个贯穿始终的核心挑战。用户与 Claude Code 的对话可能持续数小时,涉及数十个文件的阅读与修改、数百次工具调用的结果,以及不断演进的任务目标。然而,大语言模型的上下文窗口终究是有限的——即使是拥有 200K 甚至 1M token 容量的模型,在一个复杂的编码会话中也会迅速逼近极限。

Claude Code 为此构建了一套精密的多层上下文管理体系。这个体系不仅仅是"在上下文快满时做一次摘要"这么简单——它包含 Token 的实时追踪与估算、多级压缩策略的协同调度、跨会话的持久化记忆、以及在压缩过程中对关键信息的精确保留。更深层来看,它反映了一个核心设计哲学:在有限的资源约束下,如何最大化地保留信息的价值密度。本章将深入剖析这套体系的每一个层次,从底层的度量机制到顶层的策略编排,完整呈现这个子系统的设计全貌。

16.1 为什么上下文管理如此重要

16.1.1 模型上下文窗口的物理限制

大语言模型的上下文窗口是一个硬性约束。在 Claude Code 中,不同模型的上下文窗口大小定义在 src/utils/context.ts 中:

typescript
// src/utils/context.ts
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000

export function getContextWindowForModel(
  model: string,
  betas?: string[],
): number {
  // 环境变量覆盖(仅 ant 内部使用)
  // 1M 上下文检测
  // 默认 200K
}

默认的上下文窗口为 200K token。对于支持 1M 上下文的模型(如 Claude Sonnet 4 和 Opus 4.6),系统会通过 has1mContext 函数检测模型名称中的 [1m] 标记来启用更大的窗口:

typescript
// src/utils/context.ts
export function has1mContext(model: string): boolean {
  if (is1mContextDisabled()) {
    return false
  }
  return /\[1m\]/i.test(model)
}

export function modelSupports1M(model: string): boolean {
  if (is1mContextDisabled()) {
    return false
  }
  const canonical = getCanonicalName(model)
  return canonical.includes('claude-sonnet-4') || canonical.includes('opus-4-6')
}

这里有一个重要的设计决策:即使模型本身支持 1M 上下文,管理员也可以通过 CLAUDE_CODE_DISABLE_1M_CONTEXT 环境变量强制禁用,这是为 HIPAA 等合规场景设计的。此外,getContextWindowForModel 函数还支持通过 CLAUDE_CODE_CONTEXT_WINDOW_OVERRIDE 环境变量手动设定有效上下文窗口大小,允许内部测试人员在不更换模型的前提下模拟较小的上下文环境,从而验证压缩策略在不同窗口尺寸下的行为。

16.1.2 长对话的记忆丢失问题

上下文窗口的限制带来的不仅仅是"放不下"的问题,更深层的挑战是信息的优先级排序。一个典型的 Claude Code 会话中,上下文空间被以下内容占据:

  1. 系统提示词:包含角色定义、工具说明、记忆内容、项目规则等,通常占用 20K-40K token
  2. 用户消息:用户的每一次输入和反馈
  3. 工具调用结果:文件读取内容、命令输出、搜索结果等,这些往往是上下文中最大的消耗者
  4. 助手响应:包含思考过程(thinking blocks)和文本输出

当上下文接近满载时,如果不进行管理,模型将无法接收新的输入,API 会返回 prompt_too_long 错误,整个对话被迫中止。更糟糕的是,简单的截断策略(丢弃最早的消息)会导致关键的早期决策信息丢失——比如用户在对话开始时提出的架构约束、中间发现并修复的关键 bug、或者用户明确表达的偏好和反馈。这些信息虽然在时间维度上较为久远,但在语义维度上可能具有贯穿整个会话的重要性。因此,上下文管理的本质不是简单的空间释放,而是一个信息价值的优先级排序问题。

Claude Code 的解决方案是一个分层递进的体系:

                    +-----------------------+
                    |   持久化记忆 (memdir)   |  跨会话
                    +-----------------------+
                              |
                    +-----------------------+
                    | Session Memory 压缩    |  会话内,无API调用
                    +-----------------------+
                              |
                    +-----------------------+
                    |   自动压缩 (compact)    |  会话内,需API调用
                    +-----------------------+
                              |
                    +-----------------------+
                    |  微压缩 (microcompact)  |  细粒度工具结果清理
                    +-----------------------+
                              |
                    +-----------------------+
                    |   Token 实时追踪       |  贯穿始终的度量基础
                    +-----------------------+

我们将自底向上地逐层剖析这个体系。

16.2 Token 追踪

Token 追踪是整个上下文管理体系的度量基础。没有准确的 token 计量,就无法判断何时触发压缩、压缩效果如何、预算是否超支。Claude Code 采用了"API 精确计量 + 客户端粗略估算"的双轨策略。

16.2.1 API 返回的精确用量

每次 API 调用返回的响应中都包含精确的 token 用量数据。src/utils/tokens.ts 中的 getTokenUsage 函数负责从助手消息中提取这些数据:

typescript
// src/utils/tokens.ts
export function getTokenUsage(message: Message): Usage | undefined {
  if (
    message?.type === 'assistant' &&
    'usage' in message.message &&
    !(
      message.message.content[0]?.type === 'text' &&
      SYNTHETIC_MESSAGES.has(message.message.content[0].text)
    ) &&
    message.message.model !== SYNTHETIC_MODEL
  ) {
    return message.message.usage
  }
  return undefined
}

注意这里有两个过滤条件:合成消息(SYNTHETIC_MESSAGES)和合成模型(SYNTHETIC_MODEL)的用量会被排除。这是因为 Claude Code 内部会创建一些不经过 API 的虚拟消息,它们的 usage 字段没有实际意义。

从 API 用量中计算完整的上下文窗口占用:

typescript
// src/utils/tokens.ts
export function getTokenCountFromUsage(usage: Usage): number {
  return (
    usage.input_tokens +
    (usage.cache_creation_input_tokens ?? 0) +
    (usage.cache_read_input_tokens ?? 0) +
    usage.output_tokens
  )
}

这个公式将输入 token、缓存创建 token、缓存读取 token 和输出 token 全部累加,得到该次 API 调用时的完整上下文大小。

16.2.2 客户端粗略估算

然而,API 用量只能告诉我们上一次调用时的上下文大小。在两次 API 调用之间,如果用户又输入了新消息、产生了工具结果,我们需要估算当前的上下文大小。这就是 tokenCountWithEstimation 的职责——它是 Claude Code 中判断是否需要压缩的核心函数:

typescript
// src/utils/tokens.ts
export function tokenCountWithEstimation(messages: readonly Message[]): number {
  let i = messages.length - 1
  while (i >= 0) {
    const message = messages[i]
    const usage = message ? getTokenUsage(message) : undefined
    if (message && usage) {
      // 处理并行工具调用产生的消息分裂
      const responseId = getAssistantMessageId(message)
      if (responseId) {
        let j = i - 1
        while (j >= 0) {
          const prior = messages[j]
          const priorId = prior ? getAssistantMessageId(prior) : undefined
          if (priorId === responseId) {
            i = j  // 回退到同一 API 响应的第一条拆分消息
          } else if (priorId !== undefined) {
            break
          }
          j--
        }
      }
      return (
        getTokenCountFromUsage(usage) +
        roughTokenCountEstimationForMessages(messages.slice(i + 1))
      )
    }
    i--
  }
  return roughTokenCountEstimationForMessages(messages)
}

这个函数的实现揭示了一个精妙的设计。它从消息列表末尾向前搜索,找到最近一条带有 API 用量数据的助手消息,以此作为基准,然后对基准之后新增的消息进行粗略估算。关键的复杂性在于并行工具调用的处理:当模型在一次响应中发起多个工具调用时,流式处理代码会为每个内容块创建独立的助手消息记录,它们共享同一个 message.id。函数必须回退到同一 API 响应的第一条拆分消息,以确保夹在中间的所有 tool_result 都被包含在估算中。

源码注释中明确指出了这一点:

Implementation note on parallel tool calls: when the model makes multiple tool calls in one response, the streaming code emits a SEPARATE assistant record per content block (all sharing the same message.id and usage)...

16.2.3 粗略估算算法

粗略估算的核心是 roughTokenCountEstimation,定义在 src/services/tokenEstimation.ts 中:

typescript
// src/services/tokenEstimation.ts
export function roughTokenCountEstimation(
  content: string,
  bytesPerToken: number = 4,
): number {
  return Math.round(content.length / bytesPerToken)
}

默认使用 4 字节/token 的比率,这是一个对英文文本合理的近似值。但对于不同的内容类型,系统会使用不同的比率:

typescript
// src/services/tokenEstimation.ts
export function bytesPerTokenForFileType(fileExtension: string): number {
  switch (fileExtension) {
    case 'json':
    case 'jsonl':
    case 'jsonc':
      return 2  // JSON 中有大量单字符 token({, }, :, ,, ")
    default:
      return 4
  }
}

JSON 文件使用 2 字节/token 的比率,因为 JSON 的语法字符(大括号、冒号、逗号、引号)在 tokenizer 中通常各占一个 token,使得实际的字节/token 比率远低于普通文本。

对于消息级别的估算,roughTokenCountEstimationForBlock 函数会针对不同的内容块类型采用不同的策略:

typescript
// src/services/tokenEstimation.ts
function roughTokenCountEstimationForBlock(
  block: string | Anthropic.ContentBlock | Anthropic.ContentBlockParam,
): number {
  if (block.type === 'text') {
    return roughTokenCountEstimation(block.text)
  }
  if (block.type === 'image' || block.type === 'document') {
    return 2000  // 图片和文档使用固定估算值
  }
  if (block.type === 'tool_use') {
    return roughTokenCountEstimation(
      block.name + jsonStringify(block.input ?? {})
    )
  }
  if (block.type === 'thinking') {
    return roughTokenCountEstimation(block.thinking)
  }
  // ...
}

图片和文档使用固定的 2000 token 估算值,这与 Claude API 的图片 token 计算公式(像素宽 x 像素高 / 750)在典型场景下大致吻合。需要注意的是,图片的实际 token 消耗取决于分辨率,最高可达 5333 token(2000x2000 像素)。使用保守的 2000 估算值是一个有意的权衡——它与微压缩中 calculateToolResultTokens 使用的常量保持一致,避免了在不同代码路径中使用不同估算导致的不一致。

16.2.4 费用追踪

Token 追踪的另一个维度是费用。src/cost-tracker.ts 负责累计整个会话的 API 费用:

typescript
// src/cost-tracker.ts
export function addToTotalSessionCost(
  cost: number,
  usage: Usage,
  model: string,
): number {
  const modelUsage = addToTotalModelUsage(cost, usage, model)
  addToTotalCostState(cost, modelUsage, model)
  // 记录到 OpenTelemetry 计量器
  getCostCounter()?.add(cost, attrs)
  getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
  // ...
}

费用追踪按模型分别累计,这是因为不同模型的定价各异——输入 token、输出 token、缓存读取 token 和缓存创建 token 的价格均不相同。费用数据最终会通过 formatTotalCost 函数展示在会话结束时的统计摘要中,按模型分别列出输入、输出、缓存读取和缓存写入的 token 数以及对应费用,帮助用户精确了解 API 使用成本的构成。此外,费用还可以持久化到项目配置中,当会话通过 /resume 恢复时,之前累计的费用也能被正确还原:

typescript
// src/cost-tracker.ts
export function restoreCostStateForSession(sessionId: string): boolean {
  const data = getStoredSessionCosts(sessionId)
  if (!data) {
    return false
  }
  setCostStateForRestore(data)
  return true
}

16.3 自动压缩

自动压缩是 Claude Code 上下文管理体系中最核心的机制。当上下文窗口使用量超过阈值时,系统会自动触发压缩,将冗长的对话历史浓缩为一份结构化摘要,从而释放空间继续工作。

Claude Code 的上下文管理采用多级防线策略,下图展示了从正常运行到上下文窗口满的各级压缩机制的协同关系:

16.3.1 触发条件与阈值计算

自动压缩的触发逻辑定义在 src/services/compact/autoCompact.ts 中。首先是有效上下文窗口的计算:

typescript
// src/services/compact/autoCompact.ts

// 为压缩输出预留的 token 数,基于 p99.99 的压缩摘要输出为 17,387 token
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())
  return contextWindow - reservedTokensForSummary
}

有效上下文窗口 = 模型上下文窗口 - 压缩输出预留空间。这个预留空间是 20K token,基于实际生产数据中 p99.99 的压缩摘要输出为 17,387 token 计算得来。

在有效窗口的基础上,自动压缩的触发阈值还要减去一个缓冲区:

typescript
// src/services/compact/autoCompact.ts
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000

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

以 200K 上下文窗口为例:有效窗口 = 200K - 20K = 180K,自动压缩阈值 = 180K - 13K = 167K。也就是说,当上下文使用量达到约 167K token(占总窗口的约 83.5%)时,自动压缩就会触发。

系统还定义了一系列递进的警告阈值:

typescript
// src/services/compact/autoCompact.ts
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000

这些阈值驱动 UI 层面的警告显示,让用户知道上下文使用情况。最终的阻塞限制(blocking limit)在有效窗口减去仅 3K token 的缓冲区处触发——此时如果自动压缩未启用,用户将被强制要求手动压缩。

16.3.2 shouldAutoCompact:层层守卫

判断是否应当触发自动压缩的 shouldAutoCompact 函数有着严密的守卫条件:

typescript
// src/services/compact/autoCompact.ts
export async function shouldAutoCompact(
  messages: Message[],
  model: string,
  querySource?: QuerySource,
  snipTokensFreed = 0,
): Promise<boolean> {
  // 1. 递归守卫:session_memory 和 compact 子代理不触发
  if (querySource === 'session_memory' || querySource === 'compact') {
    return false
  }

  // 2. 全局开关检查
  if (!isAutoCompactEnabled()) {
    return false
  }

  // 3. Context Collapse 模式互斥
  // 当 Context Collapse 启用时,它接管上下文管理

  // 4. Token 计数与阈值比较
  const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
  const threshold = getAutoCompactThreshold(model)
  const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
    tokenCount, model,
  )
  return isAboveAutoCompactThreshold
}

几个关键的守卫条件值得特别关注:

递归守卫:当 querySourcesession_memorycompact 时,直接返回 false。这是为了防止用于压缩的子代理自身触发新的压缩,形成死循环。

基于 VitePress 构建