Claude Code设计与实现-第16章 上下文管理与自动压缩

《Claude Code 设计与实现》完整目录

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

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

:::tip 本章要点

  • 大语言模型上下文窗口的物理限制及其对长对话的深层影响
  • 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 的解决方案是一个分层递进的体系:

lua 复制代码
                    +-----------------------+
                    |   持久化记忆 (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 的上下文管理采用多级防线策略,下图展示了从正常运行到上下文窗口满的各级压缩机制的协同关系:

flowchart LR subgraph Window["200K Token 上下文窗口"] direction TB Normal["0% ~ 83%\n正常运行区间"] AutoCompact["83% 167K\n自动压缩触发"] Warning["90%\n警告阈值"] Blocking["98%\n阻塞限制"] Full["100%\n窗口满"] end subgraph Strategies["压缩策略 (按优先级)"] direction TB Micro["Microcompact"] SessionMem["Session Memory"] AutoComp["Auto-compact"] ForceComp["强制压缩"] end Normal -->|"token 持续增长"| AutoCompact AutoCompact --> Micro Micro -->|"释放不足"| SessionMem SessionMem -->|"释放不足"| AutoComp AutoCompact -->|"继续增长"| Warning Warning -->|"继续增长"| Blocking Blocking --> ForceComp

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。这是为了防止用于压缩的子代理自身触发新的压缩,形成死循环。

Context Collapse 互斥:这是一个实验性的上下文管理方案。当它启用时,自动压缩让位,因为两者会在相近的阈值上竞争,导致 Context Collapse 精心保存的细粒度上下文被自动压缩"消灭"。

Snip 偏移snipTokensFreed 参数处理了 Snip 操作(移除部分消息)释放的 token 数。由于 Snip 后幸存的助手消息中仍然记录着 Snip 前的 usage 数据,tokenCountWithEstimation 无法感知 Snip 带来的节省,因此需要手动减去。

16.3.3 断路器机制

autoCompactIfNeeded 实现了一个精巧的断路器(circuit breaker)模式:

typescript 复制代码
// src/services/compact/autoCompact.ts
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

export async function autoCompactIfNeeded(
  messages: Message[],
  toolUseContext: ToolUseContext,
  // ...
  tracking?: AutoCompactTrackingState,
): Promise<{
  wasCompacted: boolean
  consecutiveFailures?: number
}> {
  // 断路器:连续失败 N 次后停止重试
  if (
    tracking?.consecutiveFailures !== undefined &&
    tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
  ) {
    return { wasCompacted: false }
  }
  // ...
}

源码注释中有一条来自生产数据的洞察:

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 错误)时,系统会在每个回合都尝试压缩,全局每天浪费约 25 万次 API 调用。断路器将连续失败上限设为 3 次,有效遏制了这种浪费。

16.3.4 压缩算法:compactConversation

完整的压缩流程由 src/services/compact/compact.ts 中的 compactConversation 函数编排。整个过程可以分为以下几个阶段。

阶段一:预处理

rust 复制代码
记录 preCompactTokenCount -> 执行 PreCompact Hooks -> 准备压缩提示词

压缩使用的提示词定义在 src/services/compact/prompt.ts 中。基础提示词要求模型生成一份涵盖九个维度的结构化摘要:

  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> 草稿区:

typescript 复制代码
// src/services/compact/prompt.ts
const DETAILED_ANALYSIS_INSTRUCTION_BASE = `Before providing your final summary,
wrap your analysis in <analysis> tags to organize your thoughts and ensure
you've covered all necessary points. In your analysis process:

1. Chronologically analyze each message and section of the conversation...
2. Double-check for technical accuracy and completeness...`

模型首先在 <analysis> 标签内进行详细的思考和整理,然后在 <summary> 标签内给出最终摘要。formatCompactSummary 函数随后会剥离 <analysis> 部分:

typescript 复制代码
// src/services/compact/prompt.ts
export function formatCompactSummary(summary: string): string {
  let formattedSummary = summary
  // 剥离 analysis 区域------它是提升摘要质量的草稿板,
  // 但一旦摘要写好就没有信息价值了
  formattedSummary = formattedSummary.replace(
    /<analysis>[\s\S]*?<\/analysis>/, ''
  )
  // 提取并格式化 summary 区域
  const summaryMatch = formattedSummary.match(/<summary>([\s\S]*?)<\/summary>/)
  if (summaryMatch) {
    formattedSummary = formattedSummary.replace(
      /<summary>[\s\S]*?<\/summary>/,
      `Summary:\n${summaryMatch[1].trim()}`
    )
  }
  return formattedSummary.trim()
}

这是一个典型的"思维链"(Chain-of-Thought)应用:让模型先充分思考再给出最终输出,显著提升了摘要的准确性和完整性,而思考过程本身不会占用压缩后的上下文空间。

阶段二:生成摘要

压缩通过 forked agent(分叉代理)执行,复用主对话的 prompt cache 前缀以节省缓存创建成本。如果压缩请求本身触发了 prompt-too-long 错误,系统会进入重试循环:

typescript 复制代码
// src/services/compact/compact.ts
for (;;) {
  summaryResponse = await streamCompactSummary({...})
  summary = getAssistantMessageText(summaryResponse)
  if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break

  ptlAttempts++
  const truncated = ptlAttempts <= MAX_PTL_RETRIES
    ? truncateHeadForPTLRetry(messagesToSummarize, summaryResponse)
    : null
  if (!truncated) {
    throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG)
  }
  messagesToSummarize = truncated
}

truncateHeadForPTLRetry 按照 API 轮次(API-round)分组,从最早的消息开始丢弃,直到释放足够的空间。如果无法解析 token gap 的具体大小,则回退到丢弃 20% 的分组。

阶段三:构建压缩后消息

压缩成功后,系统构建全新的消息序列,包含:

typescript 复制代码
// src/services/compact/compact.ts
export function buildPostCompactMessages(result: CompactionResult): Message[] {
  return [
    result.boundaryMarker,      // 压缩边界标记
    ...result.summaryMessages,   // 压缩摘要
    ...(result.messagesToKeep ?? []),  // 保留的近期消息
    ...result.attachments,       // 附件(文件内容、计划等)
    ...result.hookResults,       // Hook 结果(CLAUDE.md 等)
  ]
}

压缩后还会重新注入若干关键附件:最近读取的文件内容(至多 5 个文件,每个上限 5K token)、当前计划文件、已调用的 Skill 内容、以及延迟工具和 MCP 指令的重新通告。

16.3.5 消息分组:groupMessagesByApiRound

压缩过程中的一个关键辅助函数是 groupMessagesByApiRound,定义在 src/services/compact/grouping.ts 中:

typescript 复制代码
// src/services/compact/grouping.ts
export function groupMessagesByApiRound(messages: Message[]): Message[][] {
  const groups: Message[][] = []
  let current: Message[] = []
  let lastAssistantId: string | undefined

  for (const msg of messages) {
    if (
      msg.type === 'assistant' &&
      msg.message.id !== lastAssistantId &&
      current.length > 0
    ) {
      groups.push(current)
      current = [msg]
    } else {
      current.push(msg)
    }
    if (msg.type === 'assistant') {
      lastAssistantId = msg.message.id
    }
  }
  if (current.length > 0) {
    groups.push(current)
  }
  return groups
}

这个函数以 API 轮次为边界对消息进行分组。边界的判定依据是助手消息的 message.id 变化------来自同一次 API 响应的多条助手消息(因并行工具调用而拆分)共享相同的 id,因此会被归入同一组。这种分组方式确保了压缩时不会在一次 API 交互的中间截断,破坏 tool_use/tool_result 的配对关系。

16.3.6 CompactBoundaryMessage 的作用

每次压缩都会创建一个边界标记消息,这是 Claude Code 中连接压缩前后语义的关键枢纽:

typescript 复制代码
// src/utils/messages.ts
export function createCompactBoundaryMessage(
  trigger: 'manual' | 'auto',
  preTokens: number,
  lastPreCompactMessageUuid?: UUID,
): SystemCompactBoundaryMessage {
  return {
    type: 'system',
    subtype: 'compact_boundary',
    content: `Conversation compacted`,
    isMeta: false,
    timestamp: new Date().toISOString(),
    uuid: randomUUID(),
    level: 'info',
    compactMetadata: {
      trigger,
      preTokens,
    },
    ...(lastPreCompactMessageUuid && {
      logicalParentUuid: lastPreCompactMessageUuid,
    }),
  }
}

CompactBoundaryMessage 承担着多重角色:

  1. 语义边界标记 :后续代码通过 isCompactBoundaryMessage 检测到它,就知道此前的消息已被压缩
  2. 压缩元数据记录compactMetadata 记录了触发方式(手动/自动)、压缩前的 token 数、以及需要保留的工具信息
  3. 消息链链接logicalParentUuid 指向压缩前最后一条消息,使得会话恢复(resume)时能够正确重建消息链
  4. 保留段元数据 :通过 annotateBoundaryWithPreservedSegment 函数,可以在边界消息上标注哪些消息被原样保留,便于磁盘加载器正确处理
typescript 复制代码
// src/services/compact/compact.ts
export function annotateBoundaryWithPreservedSegment(
  boundary: SystemCompactBoundaryMessage,
  anchorUuid: UUID,
  messagesToKeep: readonly Message[] | undefined,
): SystemCompactBoundaryMessage {
  const keep = messagesToKeep ?? []
  if (keep.length === 0) return boundary
  return {
    ...boundary,
    compactMetadata: {
      ...boundary.compactMetadata,
      preservedSegment: {
        headUuid: keep[0]!.uuid,
        anchorUuid,
        tailUuid: keep.at(-1)!.uuid,
      },
    },
  }
}

16.3.7 部分压缩与压缩方向

除了对全部对话进行压缩的基础模式,Claude Code 还支持部分压缩(Partial Compact),有两个方向:

  • from 方向:保留早期消息不变,只压缩最近的部分。摘要会附加在保留消息之后。
  • up_to 方向:压缩前面的部分,保留最近的消息原样。模型生成的摘要会被放置在保留消息之前。
typescript 复制代码
// src/services/compact/prompt.ts
export function getPartialCompactPrompt(
  customInstructions?: string,
  direction: PartialCompactDirection = 'from',
): string {
  const template = direction === 'up_to'
    ? PARTIAL_COMPACT_UP_TO_PROMPT
    : PARTIAL_COMPACT_PROMPT
  // ...
}

up_to 方向的提示词特别指出:"This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after your summary." 这让模型知道它的摘要将作为后续消息的前序上下文,从而做出更有针对性的总结。

16.4 微压缩(Microcompact)

如果说自动压缩是"大手术"------用全新的摘要替换整个对话历史,那么微压缩就是"微创手术"------只清理那些不再需要的工具结果内容,保持对话结构不变。

下面的时序图展示了微压缩的两种触发模式及其在查询循环中的作用位置:

sequenceDiagram participant Loop as 查询循环 participant Micro as Microcompact participant Messages as 消息历史 participant Cache as Prompt Cache Loop->>Micro: 检查微压缩条件 alt 基于时间的微压缩 Note over Micro: 距上次查询超过阈值\n旧工具结果已过时 Micro->>Messages: 清理旧的 tool_result Note over Messages: 替换为简短摘要:\n旧工具结果已压缩 else 基于缓存的微压缩 Note over Micro: 工具结果在 cache 断点之前\n不影响 cache 命中率 Micro->>Cache: 检查 cache_creation 边界 Cache-->>Micro: 断点位置 Micro->>Messages: 清理断点前的 tool_result end Note over Micro: 可压缩: Bash/Read/Glob/Grep 等 Micro-->>Loop: 释放的 token 数

16.4.1 可压缩的工具类型

微压缩只针对特定类型的工具结果,定义在 src/services/compact/microCompact.ts 中:

typescript 复制代码
// src/services/compact/microCompact.ts
const COMPACTABLE_TOOLS = new Set<string>([
  FILE_READ_TOOL_NAME,     // Read
  ...SHELL_TOOL_NAMES,     // Bash
  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
])

这些工具的共同特点是:它们的输出通常很大(文件内容、命令输出、搜索结果),但随着对话推进,早期的输出价值递减------模型已经从中提取了需要的信息。

16.4.2 基于时间的微压缩

一种特殊的微压缩策略基于时间触发。当用户长时间未与 Claude Code 交互后重新提问时,服务器端的 prompt cache 很可能已经过期(通常为 5 分钟)。此时整个前缀都需要重写,正好可以借机清理旧的工具结果:

typescript 复制代码
// src/services/compact/microCompact.ts
export function evaluateTimeBasedTrigger(
  messages: Message[],
  querySource: QuerySource | undefined,
): { gapMinutes: number; config: TimeBasedMCConfig } | null {
  const config = getTimeBasedMCConfig()
  if (!config.enabled || !querySource || !isMainThreadSource(querySource)) {
    return null
  }
  const lastAssistant = messages.findLast(m => m.type === 'assistant')
  if (!lastAssistant) return null

  const gapMinutes =
    (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
  if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
    return null
  }
  return { gapMinutes, config }
}

当触发时,系统保留最近 N 个工具结果不变,清理更早的工具结果内容:

typescript 复制代码
// src/services/compact/microCompact.ts
const keepRecent = Math.max(1, config.keepRecent)
const keepSet = new Set(compactableIds.slice(-keepRecent))
const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id)))

// 将被清理的工具结果内容替换为占位文本
const result: Message[] = messages.map(message => {
  // ...
  const newContent = message.message.content.map(block => {
    if (
      block.type === 'tool_result' &&
      clearSet.has(block.tool_use_id) &&
      block.content !== TIME_BASED_MC_CLEARED_MESSAGE
    ) {
      tokensSaved += calculateToolResultTokens(block)
      return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
    }
    return block
  })
  // ...
})

被清理的内容替换为 [Old tool result content cleared] 占位文本。注意 keepRecent 有一个 Math.max(1, ...) 的下限保护------slice(-0) 会返回整个数组,slice(0) 也不会清理任何内容,两者都不是合理行为。

16.4.3 基于缓存的微压缩

另一种更精细的微压缩策略利用了 Claude API 的 cache_edits 能力。与时间基准的微压缩直接修改消息内容不同,基于缓存的微压缩不改变本地消息,而是在 API 层面通过 cache_edits 指令删除特定工具结果:

typescript 复制代码
// src/services/compact/microCompact.ts
async function cachedMicrocompactPath(
  messages: Message[],
  querySource: QuerySource | undefined,
): Promise<MicrocompactResult> {
  const mod = await getCachedMCModule()
  const state = ensureCachedMCState()
  // ... 注册工具结果,计算需要删除的工具
  const toolsToDelete = mod.getToolResultsToDelete(state)

  if (toolsToDelete.length > 0) {
    const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
    if (cacheEdits) {
      pendingCacheEdits = cacheEdits
    }
    // 返回未修改的消息------cache_edits 在 API 层处理
    return {
      messages,
      compactionInfo: {
        pendingCacheEdits: { trigger: 'auto', deletedToolIds: toolsToDelete },
      },
    }
  }
  return { messages }
}

这种方式的优势在于不破坏已有的 prompt cache 前缀。被删除的工具结果在服务器端通过 cache 编辑移除,避免了因修改消息内容而触发整个前缀重写的高昂成本。在高频交互场景下,保持 prompt cache 的命中率对于控制延迟和费用都至关重要。这两种微压缩策略是互斥的:时间基准的微压缩在逻辑上优先执行,如果它触发了(意味着间隔较长、缓存已冷),就跳过缓存编辑路径,因为缓存编辑的前提是存在可编辑的热缓存。

16.4.4 Token 估算辅助函数

微压缩中使用的 estimateMessageTokens 函数提供了比 roughTokenCountEstimation 更精确的消息级 token 估算:

typescript 复制代码
// src/services/compact/microCompact.ts
export function estimateMessageTokens(messages: Message[]): number {
  let totalTokens = 0
  for (const message of messages) {
    if (message.type !== 'user' && message.type !== 'assistant') continue
    for (const block of message.message.content) {
      if (block.type === 'text') {
        totalTokens += roughTokenCountEstimation(block.text)
      } else if (block.type === 'tool_result') {
        totalTokens += calculateToolResultTokens(block)
      } else if (block.type === 'thinking') {
        totalTokens += roughTokenCountEstimation(block.thinking)
      }
      // ... 其他块类型
    }
  }
  // 填充系数 4/3,因为是近似值
  return Math.ceil(totalTokens * (4 / 3))
}

注意最后有一个 4/3 的填充系数。这是一个保守估计策略------宁可高估也不低估,因为低估可能导致压缩触发过晚。

16.5 Session Memory 压缩

Session Memory 压缩是一种介于自动压缩和微压缩之间的策略。它利用后台持续提取的会话记忆作为摘要来源,无需发起额外的 API 调用来生成摘要。

16.5.1 Session Memory 的工作原理

Session Memory 是一个后台运行的子代理,定期从对话中提取关键信息并写入一个 markdown 文件(在 src/services/SessionMemory/sessionMemory.ts 中实现)。当自动压缩被触发时,系统首先尝试 Session Memory 压缩:

typescript 复制代码
// src/services/compact/autoCompact.ts
export async function autoCompactIfNeeded(...) {
  // ...
  // 实验性:首先尝试 Session Memory 压缩
  const sessionMemoryResult = await trySessionMemoryCompaction(
    messages,
    toolUseContext.agentId,
    recompactionInfo.autoCompactThreshold,
  )
  if (sessionMemoryResult) {
    setLastSummarizedMessageId(undefined)
    runPostCompactCleanup(querySource)
    markPostCompaction()
    return {
      wasCompacted: true,
      compactionResult: sessionMemoryResult,
    }
  }
  // 回退到传统压缩
  const compactionResult = await compactConversation(...)
}

16.5.2 消息保留策略

Session Memory 压缩的一个关键优势是它能保留最近的原始消息,而不是将所有内容都替换为摘要。calculateMessagesToKeepIndex 函数决定哪些消息被保留:

typescript 复制代码
// src/services/compact/sessionMemoryCompact.ts
export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
  minTokens: 10_000,         // 至少保留 10K token 的消息
  minTextBlockMessages: 5,    // 至少保留 5 条含文本的消息
  maxTokens: 40_000,         // 最多保留 40K token 的消息
}

算法从 lastSummarizedMessageId(Session Memory 上次提取到的位置)开始,向前扩展,直到同时满足最小 token 数和最小文本消息数的要求,或者达到最大 token 上限:

typescript 复制代码
// src/services/compact/sessionMemoryCompact.ts
export function calculateMessagesToKeepIndex(
  messages: Message[],
  lastSummarizedIndex: number,
): number {
  let startIndex = lastSummarizedIndex >= 0
    ? lastSummarizedIndex + 1
    : messages.length

  // 向前扩展直到满足最小要求
  for (let i = startIndex - 1; i >= floor; i--) {
    const msg = messages[i]!
    totalTokens += estimateMessageTokens([msg])
    if (hasTextBlocks(msg)) textBlockMessageCount++
    startIndex = i

    if (totalTokens >= config.maxTokens) break
    if (totalTokens >= config.minTokens &&
        textBlockMessageCount >= config.minTextBlockMessages) break
  }

  // 调整以保持 tool_use/tool_result 配对
  return adjustIndexToPreserveAPIInvariants(messages, startIndex)
}

adjustIndexToPreserveAPIInvariants 是一个至关重要的辅助函数,它确保保留的消息不会破坏 API 的结构约束。具体来说,如果保留的消息中包含 tool_result 块,那么对应的 tool_use 块(可能在更早的消息中)也必须被保留。此外,如果保留的助手消息与更早的助手消息共享同一个 message.id(因并行工具调用产生的消息拆分),thinking 块所在的消息也必须被包含进来。

16.5.3 可用性判断

Session Memory 压缩并非总是可用的。trySessionMemoryCompaction 函数有多个前置检查:

typescript 复制代码
// src/services/compact/sessionMemoryCompact.ts
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) return null  // 文件不存在
  if (await isSessionMemoryEmpty(sessionMemory)) return null  // 内容为空模板

  // ... 计算保留消息

  // 关键检查:压缩后是否仍超过阈值
  if (autoCompactThreshold !== undefined &&
      postCompactTokenCount >= autoCompactThreshold) {
    return null  // 退化到传统压缩
  }
}

最后一个检查尤为重要:如果 Session Memory 的内容加上保留的消息仍然超过自动压缩阈值,说明 Session Memory 不够精简,系统会回退到传统的全量压缩。

16.6 记忆系统

如果说压缩是为了在当前会话中管理上下文,那么记忆系统就是为了在跨会话维度上延续关键信息。Claude Code 的记忆系统(memdir)是一个基于文件的持久化存储,让模型能够在不同会话之间记住用户偏好、项目上下文和工作经验。

Claude Code 的记忆系统通过多级存储实现跨会话的知识持久化。下图展示了记忆的分层架构和加载流程:

flowchart TB subgraph Storage["记忆存储层 (优先级从高到低)"] Enterprise["企业级记忆\n组织统一配置"] User["用户级记忆\n~/.claude/CLAUDE.md"] Project["项目级记忆\n项目根/CLAUDE.md"] Local["本地记忆\n.claude/CLAUDE.local.md"] Subdir["子目录记忆\nsrc/CLAUDE.md"] end subgraph AutoMem["自动记忆"] MemDir["memdir/\nprojects/{hash}/"] MemDir --> Sessions["session-memory\n会话摘要"] MemDir --> UserPref["user-preferences\n用户偏好"] end subgraph Loading["加载与注入"] Discover["遍历目录树\n发现记忆文件"] Filter["相关性筛选\n基于当前工作目录"] Inject["注入系统提示\n按优先级排序"] end Storage --> Discover AutoMem --> Discover Discover --> Filter Filter --> Inject Inject --> Prompt["对话开始时\n模型获得持久化上下文"]

16.6.1 记忆目录结构

记忆系统的核心路径解析定义在 src/memdir/paths.ts 中:

typescript 复制代码
// src/memdir/paths.ts
export const getAutoMemPath = memoize(
  (): string => {
    const override = getAutoMemPathOverride() ?? getAutoMemPathSetting()
    if (override) return override
    const projectsDir = join(getMemoryBaseDir(), 'projects')
    return (
      join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep
    ).normalize('NFC')
  },
  () => getProjectRoot(),
)

默认路径为 ~/.claude/projects/<sanitized-git-root>/memory/,使用 git 仓库的规范根目录(通过 findCanonicalGitRoot 解析)作为项目标识,这样同一仓库的不同 worktree 共享同一套记忆。路径经过 sanitizePath 处理以安全地映射到文件系统路径。validateMemoryPath 函数提供了严格的安全校验,拒绝相对路径、根路径、Windows 驱动器根路径、UNC 路径和包含空字节的路径,防止路径遍历攻击和危险目录的意外写入。此外,来自项目级 .claude/settings.jsonautoMemoryDirectory 设置被有意排除在信任来源之外------恶意仓库可以通过设置 autoMemoryDirectory: "~/.ssh" 来获取对敏感目录的静默写入权限。

记忆目录的结构:

javascript 复制代码
~/.claude/projects/<project-slug>/memory/
  MEMORY.md          -- 入口索引文件
  user_role.md       -- 用户角色记忆
  feedback_testing.md -- 测试偏好记忆
  project_arch.md    -- 项目架构记忆
  ...

16.6.2 记忆的加载与注入

记忆通过 loadMemoryPrompt 函数加载到系统提示词中。这是一个异步函数,在会话初始化时被调用:

typescript 复制代码
// src/memdir/memdir.ts
export async function loadMemoryPrompt(): Promise<string | null> {
  const autoEnabled = isAutoMemoryEnabled()
  if (!autoEnabled) return null

  const autoDir = getAutoMemPath()
  await ensureMemoryDirExists(autoDir)

  return buildMemoryLines('auto memory', autoDir, extraGuidelines).join('\n')
}

记忆提示词包含以下几个核心部分:

  1. 记忆系统说明:告诉模型它拥有一个持久化的文件记忆系统
  2. 记忆类型分类:定义了四种记忆类型------用户偏好、反馈、项目上下文、参考信息
  3. 保存指南:两步保存流程------先写记忆文件,再在 MEMORY.md 中添加索引条目
  4. 不应保存的内容:从当前项目状态可推导的内容(代码模式、架构、git 历史)不应保存
  5. MEMORY.md 内容:如果索引文件已有内容,会被截断后注入

MEMORY.md 的截断策略格外精细:

typescript 复制代码
// src/memdir/memdir.ts
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000

export function truncateEntrypointContent(raw: string): EntrypointTruncation {
  const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES
  const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES

  // 先按行截断,再按字节截断(在最后一个换行符处切割)
  let truncated = wasLineTruncated
    ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
    : trimmed

  if (truncated.length > MAX_ENTRYPOINT_BYTES) {
    const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)
    truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
  }
  // ...
}

存在双重限制:200 行和 25KB。行限制是主要的,字节限制是为了捕获那些行数不多但每行超长的异常情况(在 p97 的观测中,有些索引条目长达数百字符)。

16.6.3 智能记忆检索

除了在系统提示词中注入 MEMORY.md 索引,Claude Code 还实现了基于查询的智能记忆检索。src/memdir/findRelevantMemories.ts 利用一个侧查询(side query)让 Sonnet 模型从所有记忆文件中选出与当前查询最相关的:

typescript 复制代码
// src/memdir/findRelevantMemories.ts
export async function findRelevantMemories(
  query: string,
  memoryDir: string,
  signal: AbortSignal,
  recentTools: readonly string[] = [],
  alreadySurfaced: ReadonlySet<string> = new Set(),
): Promise<RelevantMemory[]> {
  const memories = (await scanMemoryFiles(memoryDir, signal))
    .filter(m => !alreadySurfaced.has(m.filePath))

  const selectedFilenames = await selectRelevantMemories(
    query, memories, signal, recentTools
  )
  return selected.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs }))
}

选择过程有几个值得注意的设计:

  1. 最多返回 5 个记忆:避免注入过多上下文
  2. 排除已展示的记忆alreadySurfaced 参数确保之前已注入的记忆不会被重复选择
  3. 工具感知recentTools 参数传入最近使用的工具列表,选择器会避免选择这些工具的使用文档(因为对话中已经有了活跃的使用示例),但仍会选择包含注意事项和已知问题的记忆

16.6.4 记忆的持久化与压缩的协同

记忆系统与压缩系统之间存在精妙的协同关系。压缩后的上下文丢失了详细的对话历史,但记忆系统可以弥补这个损失:

typescript 复制代码
// src/services/compact/compact.ts
// 压缩后重新注入的附件包括:
const [fileAttachments, asyncAgentAttachments] = await Promise.all([
  createPostCompactFileAttachments(preCompactReadFileState, context),
  createAsyncAgentAttachmentsIfNeeded(context),
])

同时,压缩后的清理流程会重置记忆文件缓存,确保下一轮对话能重新加载最新的记忆内容:

typescript 复制代码
// src/services/compact/postCompactCleanup.ts
export function runPostCompactCleanup(querySource?: QuerySource): void {
  resetMicrocompactState()
  if (isMainThreadCompact) {
    getUserContext.cache.clear?.()
    resetGetMemoryFilesCache('compact')
  }
  clearSystemPromptSections()
  // ...
}

16.7 会话历史

16.7.1 history.ts 的会话管理

src/history.ts 管理的是用户输入的命令历史,类似于 shell 的 history 功能。每次用户提交输入时,内容会被记录到 ~/.claude/history.jsonl 文件中:

typescript 复制代码
// src/history.ts
type LogEntry = {
  display: string
  pastedContents: Record<number, StoredPastedContent>
  timestamp: number
  project: string
  sessionId?: string
}

历史记录采用 JSONL(JSON Lines)格式存储,每行一条记录。写入操作通过异步批处理完成,带有文件锁以支持多个并发会话安全写入:

typescript 复制代码
// src/history.ts
async function immediateFlushHistory(): Promise<void> {
  release = await lock(historyPath, {
    stale: 10000,
    retries: { retries: 3, minTimeout: 50 },
  })
  const jsonLines = pendingEntries.map(entry => jsonStringify(entry) + '\n')
  pendingEntries = []
  await appendFile(historyPath, jsonLines.join(''), { mode: 0o600 })
}

历史读取支持两种模式:普通的上键历史(getHistory)和 Ctrl+R 搜索(getTimestampedHistory)。两者的区别在于:上键历史将当前会话的条目优先展示(避免并发会话之间的历史交错),而搜索历史则按项目去重并展示时间戳,使用延迟解析(resolve() 回调)减少不必要的 paste store 读取开销。

值得一提的是 removeLastFromHistory 函数的设计。当用户按 Esc 在响应到达前撤销输入时,对应的历史条目也需要被撤销。这个操作有一个竞态条件:如果异步的磁盘刷新已经在 Esc 按下之前完成了写入,那么从内存的 pending 缓冲区中移除就无效了。系统通过一个 skippedTimestamps 集合来处理这种情况------将已刷新条目的时间戳加入跳过集合,后续的读取操作会过滤掉这些条目。

16.7.2 粘贴内容的智能存储

一个特别有趣的细节是粘贴内容的处理。用户粘贴的大段文本不会直接存入历史记录,而是使用哈希引用:

typescript 复制代码
// src/history.ts
if (content.content.length <= MAX_PASTED_CONTENT_LENGTH) {
  // 小于 1KB 的内容直接内联存储
  storedPastedContents[Number(id)] = {
    id: content.id,
    type: content.type,
    content: content.content,
  }
} else {
  // 大于 1KB 的内容通过哈希存储到独立的 paste store
  const hash = hashPastedText(content.content)
  storedPastedContents[Number(id)] = {
    id: content.id,
    type: content.type,
    contentHash: hash,
  }
  void storePastedText(hash, content.content)
}

1KB 以下的粘贴内容直接内联,以上则存储到独立的 paste store 中,历史记录只保留哈希引用。这既节省了历史文件的空间,又确保了通过 retrievePastedText 能够按需恢复完整内容。

16.7.3 会话恢复(/resume)

会话恢复是 Claude Code 的一个核心能力,允许用户中断并在稍后继续之前的对话。会话数据存储在 ~/.claude/projects/<project>/ 目录下,使用 JSONL 格式的转录文件。恢复时需要:

  1. 重建消息链 :从 JSONL 文件中读取所有条目,按照 parentUuid 链接重建消息序列
  2. 恢复费用状态 :通过 restoreCostStateForSession 从项目配置中恢复累计费用
  3. 处理压缩边界getMessagesAfterCompactBoundary 只加载最近一次压缩之后的消息,因为之前的消息已被摘要取代
  4. 运行 session start hooks:重新注入 CLAUDE.md 和其他上下文附件

费用恢复的设计值得一提:

typescript 复制代码
// src/cost-tracker.ts
export function getStoredSessionCosts(
  sessionId: string,
): StoredCostState | undefined {
  const projectConfig = getCurrentProjectConfig()
  // 只有 session ID 匹配时才返回费用数据
  if (projectConfig.lastSessionId !== sessionId) {
    return undefined
  }
  return {
    totalCostUSD: projectConfig.lastCost ?? 0,
    totalAPIDuration: projectConfig.lastAPIDuration ?? 0,
    // ...
  }
}

通过 session ID 匹配确保费用数据不会跨会话混淆。每次会话结束(或压缩时)都会通过 saveCurrentSessionCosts 将当前费用持久化到项目配置中。

16.8 上下文窗口策略:全局视角

在理解了各个组件之后,让我们从全局视角审视整个上下文管理策略的运作方式。

16.8.1 多层防线的协同

Claude Code 的上下文管理是一个多层防线体系,每一层在不同的粒度和代价级别上工作:

scss 复制代码
  Token 使用量
  ──────────────────────────────────────────────────────>
  |                    |           |            |       |
  0%                ~83%         ~90%         ~98%   100%
  |                    |           |            |       |
  正常运行          自动压缩    警告阈值     阻塞限制   窗口满
                   触发点       (UI 警示)    (强制压缩)

  ← 微压缩随时可能运行(基于工具数量或时间间隔) →
  ← Session Memory 后台持续提取 →

在查询循环(query loop)的每一轮迭代中,预处理流水线按以下顺序检查和执行:

  1. Snip:如果上一轮有工具错误(如 prompt-too-long 响应),先裁剪部分消息
  2. Microcompact:检查是否需要清理旧的工具结果
  3. Auto-compact:检查 token 使用量是否超过阈值
  4. Session Memory 压缩(在 auto-compact 内部优先尝试)

这个顺序经过精心设计:微压缩是轻量级的,可以在不影响对话流的情况下释放少量空间;如果仍不够,自动压缩接管,先尝试零 API 开销的 Session Memory 方案,最后才动用完整的摘要生成。

16.8.2 渐进式压缩策略

压缩并不总是"一刀切"。系统通过 RecompactionInfo 跟踪压缩链信息:

typescript 复制代码
// src/services/compact/compact.ts
export type RecompactionInfo = {
  isRecompactionInChain: boolean    // 是否在同一链中再次压缩
  turnsSincePreviousCompact: number // 距上次压缩的轮次数
  previousCompactTurnId?: string    // 上次压缩的轮次 ID
  autoCompactThreshold: number      // 自动压缩阈值
  querySource?: QuerySource
}

这些信息被用于诊断和优化。如果压缩后很快又触发新的压缩(isRecompactionInChain: true),说明摘要本身可能太大,或者后续操作产生了过多的上下文增量。系统会将这些信息记录到分析事件中,供团队优化压缩策略。

16.8.3 关键信息的保留策略

压缩过程中,以下信息会被特别保留:

  1. 压缩提示词中的指令:九维度结构化摘要格式确保文件路径、代码片段、函数签名、错误信息等技术细节被完整保留

  2. 压缩后重新注入的内容

    • 最近读取的文件(至多 5 个,每个至多 5K token)
    • 当前计划文件(plan)
    • 已调用的 Skill 内容(每个至多 5K token,总计至多 25K token)
    • 延迟工具定义和 MCP 指令
  3. Session Memory 压缩保留的原始消息:至少 10K token 且至少 5 条含文本消息

  4. Session Start Hooks:压缩后自动运行,重新注入 CLAUDE.md 等项目配置

16.8.4 压缩后的一致性保证

压缩后,系统执行一系列清理操作以确保状态一致性:

typescript 复制代码
// src/services/compact/postCompactCleanup.ts
export function runPostCompactCleanup(querySource?: QuerySource): void {
  resetMicrocompactState()           // 重置微压缩状态
  clearSystemPromptSections()        // 清除系统提示词缓存
  clearClassifierApprovals()         // 清除分类器审批缓存
  clearSpeculativeChecks()           // 清除预测性检查缓存
  clearBetaTracingState()            // 清除跟踪状态
  clearSessionMessagesCache()        // 清除会话消息缓存

  if (isMainThreadCompact) {
    getUserContext.cache.clear?.()    // 清除用户上下文缓存
    resetGetMemoryFilesCache('compact')  // 重置记忆文件缓存
  }
}

注意这里的 isMainThreadCompact 检查。由于子代理(如 AgentTool 创建的子代理)与主线程共享模块级状态,子代理的压缩不应该重置主线程的缓存。这种区分通过 querySource 前缀判断实现。

16.8.5 压缩后的用户体验

自动压缩对用户的呈现方式也经过精心设计。压缩摘要被标记为 isVisibleInTranscriptOnly: true,这意味着它只在转录文件中可见,不会在交互界面上作为一条消息显示。用户看到的是一条简洁的系统消息"Conversation compacted",以及(如果是自动压缩的话)一条提示模型无缝继续工作的指令:

typescript 复制代码
// src/services/compact/prompt.ts
export function getCompactUserSummaryMessage(
  summary: string,
  suppressFollowUpQuestions?: boolean,
  transcriptPath?: string,
): string {
  let baseSummary = `This session is being continued from a previous conversation
that ran out of context. The summary below covers the earlier portion...`

  if (suppressFollowUpQuestions) {
    return `${baseSummary}
Continue the conversation from where it left off without asking the user
any further questions. Resume directly -- do not acknowledge the summary,
do not recap what was happening...`
  }
  return baseSummary
}

自动压缩时 suppressFollowUpQuestions 为 true,指示模型直接从断点处继续,不要确认摘要、不要复述之前在做什么、不要以"我将继续"之类的话开头。这种设计让压缩对用户尽可能透明。

16.9 设计决策

纵观整个上下文管理体系,有几个关键的设计决策值得总结。

双轨 Token 计量:API 精确用量与客户端粗略估算并行使用。这避免了在每次消息变更时都调用 API 计量接口的开销,同时在关键决策点(是否触发压缩)提供足够的精度。粗略估算采用保守的高估策略,宁可提前触发压缩也不冒窗口溢出的风险。

思维链摘要 :压缩提示词要求模型先在 <analysis> 中思考,再在 <summary> 中输出。<analysis> 在最终摘要中被剥离,不占用上下文空间。这是在压缩质量和空间效率之间的精妙平衡。

多级压缩策略的优先级:微压缩 > Session Memory 压缩 > 传统全量压缩,按照代价从低到高排列。这种渐进式策略确保在大多数情况下,系统使用最轻量的方式就能保持在阈值以下。

断路器模式:连续失败 3 次后停止重试。来自生产环境的数据表明,这个看似简单的策略每天节省约 25 万次 API 调用。

压缩边界的语义连续性CompactBoundaryMessage 不仅是一个标记,它携带了完整的元数据(压缩前 token 数、逻辑父消息 UUID、保留段信息),使得会话恢复、消息链重建和诊断分析都有据可依。

记忆与压缩的正交设计:记忆系统(memdir)负责跨会话的长期信息,压缩系统负责当前会话的上下文管理。两者通过"压缩后重新加载记忆"这一时间点协同,但在架构上保持正交,各自独立演进。

16.10 小结

上下文管理是 Claude Code 中最能体现"系统思维"的子系统。它不是一个单一的算法,而是一个由 Token 追踪、微压缩、Session Memory 压缩、自动压缩和持久化记忆五个层次组成的完整体系。每一层解决不同粒度的问题,通过精心设计的优先级和回退策略协同工作。

这个体系的核心洞察是:上下文管理不是一次性的事件,而是贯穿整个会话生命周期的持续过程。 从每一次 API 调用返回时的 token 计量,到工具结果的按需清理,到会话级别的结构化摘要,再到跨会话的持久化记忆------信息在不同的时间尺度上被追踪、整理和保存。

在下一章中,我们将转向 Claude Code 的终端用户界面(Terminal UI),看它如何利用 Ink 框架将这些复杂的内部状态------包括上下文使用量、压缩进度和费用统计------以直观的方式呈现给用户。

相关推荐
杨艺韬9 小时前
Claude Code设计与实现-第10章 Bash 安全与沙箱
agent
杨艺韬9 小时前
Claude Code设计与实现-第14章 多 Agent 协调与 Swarm
agent
杨艺韬9 小时前
Claude Code设计与实现-第9章 多模式权限模型
agent
杨艺韬9 小时前
Claude Code设计与实现-第12章 IDE Bridge 通信架构
agent
杨艺韬9 小时前
Claude Code设计与实现-第11章 MCP 协议集成
agent
杨艺韬9 小时前
Claude Code设计与实现-第18章 设计模式与架构决策
agent
杨艺韬9 小时前
Claude Code设计与实现-第6章 工具类型系统设计
agent
杨艺韬9 小时前
Claude Code设计与实现-第1章 为什么需要理解 Claude Code
agent
杨艺韬9 小时前
Claude Code设计与实现-前言
agent