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)...

基于 VitePress 构建