Back to Blog

Claude Code Harness 第9章:自动压缩——上下文何时以及如何被压缩

2026-04-05
Claude Code Context Management Token Optimization Compression

Claude Code Harness 第9章:自动压缩——上下文何时以及如何被压缩

"The best compression is the one the user never notices."

每一个 Claude Code 的长会话用户都经历过这个时刻:你正在让模型逐步重构一个复杂模块,突然你注意到模型的回答变得"健忘"——它忘记了五分钟前你明确要求保留的接口签名,或者重复建议你已经否决过的方案。这不是模型变笨了,而是上下文窗口满了,自动压缩刚刚发生

压缩(compaction)是 Claude Code 上下文管理的核心机制。它决定了你的对话历史在什么时刻、以什么方式被浓缩为一份摘要。理解这个机制,意味着你可以预测它何时触发、控制它保留什么、以及在它"出错"时知道该怎么做。

本章将从源码层面完整拆解自动压缩的三个阶段:阈值判定(何时触发)、摘要生成(如何压缩)、失败恢复(出错怎么办)。

9.1 阈值计算:何时触发自动压缩

9.1.1 核心公式

自动压缩的触发条件可以用一个简单的不等式表达:

当前 token 数 >= autoCompactThreshold

autoCompactThreshold 的计算涉及三个常量和两层减法。让我们从源码中逐步推导。

第一层:有效上下文窗口

// src/services/compact/autoCompact.ts:30
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000

// src/services/compact/autoCompact.ts:33-48
export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,
  )
  let contextWindow = getContextWindowForModel(model, getSdkBetas())

  const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
  if (autoCompactWindow) {
    const parsed = parseInt(autoCompactWindow, 10)
    if (!isNaN(parsed) && parsed > 0) {
      contextWindow = Math.min(contextWindow, parsed)
    }
  }

  return contextWindow - reservedTokensForSummary
}

这里的逻辑是:从模型的原始上下文窗口中扣除一块"压缩输出预留区"。MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 这个值来自 p99.99 的实际压缩输出统计——99.99% 的压缩摘要都在 17,387 tokens 以内,20K 是带安全余量的上界。

注意 Math.min(getMaxOutputTokensForModel(model), MAX_OUTPUT_TOKENS_FOR_SUMMARY) 这个取小值操作:如果某个模型的最大输出上限本身就低于 20K(比如某些 Bedrock 配置),则使用模型自身的上限。

第二层:自动压缩缓冲区

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

// src/services/compact/autoCompact.ts:72-91
export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)
  const autocompactThreshold =
    effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS

  const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
  if (envPercent) {
    const parsed = parseFloat(envPercent)
    if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
      const percentageThreshold = Math.floor(
        effectiveContextWindow * (parsed / 100),
      )
      return Math.min(percentageThreshold, autocompactThreshold)
    }
  }

  return autocompactThreshold
}

AUTOCOMPACT_BUFFER_TOKENS = 13_000 是一个额外的安全缓冲——它确保在阈值触发到实际执行压缩之间,还有足够的空间容纳当前轮次可能产生的额外 tokens(工具调用结果、系统消息等)。

9.1.2 阈值计算公式表

以 Claude Sonnet 4 (200K 上下文窗口) 为例:

计算步骤 公式
原始上下文窗口 contextWindow 200,000
压缩输出预留 MAX_OUTPUT_TOKENS_FOR_SUMMARY 20,000
有效上下文窗口 contextWindow - 20,000 180,000
自动压缩缓冲 AUTOCOMPACT_BUFFER_TOKENS 13,000
自动压缩阈值 effectiveWindow - 13,000 167,000
警告阈值 autoCompactThreshold - 20,000 147,000
错误阈值 autoCompactThreshold - 20,000 147,000
阻塞硬限制 effectiveWindow - 3,000 177,000

用更直观的方式表达:

|<------------ 200K 上下文窗口 ------------>|
|<---- 167K 可用 ---->|<- 13K 缓冲 ->|<- 20K 压缩输出预留 ->|
                      ^               ^
                自动压缩触发点     有效窗口边界

这意味着在默认配置下,当你的对话消耗了约 83.5% 的上下文窗口时,自动压缩就会触发。

9.1.3 环境变量覆盖

Claude Code 提供了两个环境变量让用户(或测试环境)覆盖默认阈值:

CLAUDE_CODE_AUTO_COMPACT_WINDOW — 覆盖上下文窗口大小

// src/services/compact/autoCompact.ts:40-46
const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
if (autoCompactWindow) {
  const parsed = parseInt(autoCompactWindow, 10)
  if (!isNaN(parsed) && parsed > 0) {
    contextWindow = Math.min(contextWindow, parsed)
  }
}

这个变量取的是 Math.min(实际窗口, 设置值)——你只能缩小窗口,不能扩大。典型用例:在 CI 环境中设置一个较小的窗口值,强制更频繁地触发压缩以测试其稳定性。

CLAUDE_AUTOCOMPACT_PCT_OVERRIDE — 按百分比覆盖阈值

// src/services/compact/autoCompact.ts:79-87
const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
if (envPercent) {
  const parsed = parseFloat(envPercent)
  if (!isNaN(parsed) && parsed > 0 && parsed <= 100) {
    const percentageThreshold = Math.floor(
      effectiveContextWindow * (parsed / 100),
    )
    return Math.min(percentageThreshold, autocompactThreshold)
  }
}

例如设置 CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=50,阈值就变成有效窗口的 50%(90,000 tokens),但同样取 Math.min——这个覆盖值不能高于默认阈值,只能让压缩更早触发。

9.1.4 完整判定流程

shouldAutoCompact() 函数(src/services/compact/autoCompact.ts:160-239)在比较 token 数之前,还有一系列前置守卫:

flowchart TD
    A[shouldAutoCompact 调用] --> B{querySource 是
session_memory 或 compact?} B -->|是| Z[返回 false
防递归] B -->|否| C{querySource 是
marble_origami?} C -->|是| Z C -->|否| D{isAutoCompactEnabled?
检查环境变量和用户配置} D -->|false| Z D -->|true| E{REACTIVE_COMPACT
实验模式激活?} E -->|是| Z E -->|否| F{Context Collapse
激活?} F -->|是| Z F -->|否| G[tokenCount >=
autoCompactThreshold?] G -->|是| H[返回 true
触发压缩] G -->|否| Z

注意源码中对 Context Collapse 的详细注释(src/services/compact/autoCompact.ts:199-222):autocompact 在有效窗口的约 93% 处触发,而 Context Collapse 在 90% 开始提交、95% 执行阻塞——如果两者同时运行,autocompact 会"抢跑"并销毁 Collapse 正准备保存的细粒度上下文。因此当 Collapse 开启时,主动式 autocompact 被禁用,只保留 reactive compact 作为 413 错误的兜底。

9.2 熔断器:连续失败保护

9.2.1 问题背景

在理想情况下,压缩完成后上下文会显著缩小,下一轮就不再触发。但现实中存在一类"不可恢复"的场景:上下文中包含大量不可压缩的系统消息、附件或编码数据,压缩后的结果仍然超过阈值,导致下一轮立刻再次触发压缩——形成无限循环。

源码注释记录了一个真实的规模数据(src/services/compact/autoCompact.ts:68-69):

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.

1,279 个会话中,有会话连续失败了 3,272 次,全局每天浪费约 25 万次 API 调用。这不是边缘情况——这是一个需要硬性保护的系统性问题。

9.2.2 熔断器实现

// src/services/compact/autoCompact.ts:70
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

熔断器的逻辑极其简洁——整个机制不到 20 行代码:

// src/services/compact/autoCompact.ts:257-265
if (
  tracking?.consecutiveFailures !== undefined &&
  tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
) {
  return { wasCompacted: false }
}

状态追踪通过 AutoCompactTrackingState 类型在 queryLoop 的迭代之间传递:

// src/services/compact/autoCompact.ts:51-60
export type AutoCompactTrackingState = {
  compacted: boolean
  turnCounter: number
  turnId: string
  consecutiveFailures?: number  // 熔断器计数器
}
  • 成功时src/services/compact/autoCompact.ts:332):consecutiveFailures 重置为 0
  • 失败时src/services/compact/autoCompact.ts:341-349):递增计数,达到 3 次后记录警告日志并不再尝试
  • 熔断后:该会话后续所有轮次的 autocompact 请求直接返回 { wasCompacted: false }

这个设计体现了一个重要原则:宁可让用户手动执行 /compact,也不要用注定失败的重试浪费 API 预算。熔断器只阻止自动压缩,用户仍然可以通过 /compact 命令手动触发。

9.3 压缩提示词剖析:9 段模板

当阈值触发后,Claude Code 需要向模型发送一条特殊的提示词,要求它将整个对话浓缩为一份结构化摘要。这个提示词的设计是压缩质量的关键——它直接决定了摘要中保留了什么、丢失了什么。

9.3.1 三种提示词变体

源码中定义了三种压缩提示词变体,分别对应不同的压缩场景:

变体 常量名 使用场景 摘要范围
BASE BASE_COMPACT_PROMPT 完整压缩(手动 /compact 或首次自动压缩) 整个对话
PARTIAL PARTIAL_COMPACT_PROMPT 部分压缩(保留早期上下文,只压缩新消息) 最近的消息(保留边界之后)
PARTIAL_UP_TO PARTIAL_COMPACT_UP_TO_PROMPT 前缀压缩(cache hit 优化路径) 摘要之前的对话部分

三者的核心区别在于摘要的"视野范围"

  • BASE 告诉模型:"Your task is to create a detailed summary of the conversation so far"——总结全部
  • PARTIAL 告诉模型:"Your task is to create a detailed summary of the RECENT portion of the conversation — the messages that follow earlier retained context"——只总结新增部分
  • PARTIAL_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"——总结前缀,为后续消息提供上下文

9.3.2 模板结构分析

BASE_COMPACT_PROMPT 为例(src/services/compact/prompt.ts:61-143),整个提示词由 9 个结构化段落组成。下面逐段分析其设计意图:

段落 标题 设计意图 关键指令
1 Primary Request and Intent 捕获用户的显式请求,防止压缩后"跑题" "Capture all of the user's explicit requests and intents in detail"
2 Key Technical Concepts 保留技术决策的语境锚点 列出所有讨论过的技术、框架和概念
3 Files and Code Sections 保留文件和代码的精确上下文 "Include full code snippets where applicable" —— 注意是 full code snippets,不是摘要
4 Errors and fixes 保留调试历史,防止重复犯错 "Pay special attention to specific user feedback"
5 Problem Solving 保留问题解决过程,不只是结果 "Document problems solved and any ongoing troubleshooting efforts"
6 All user messages 保留所有用户消息(非工具结果) "List ALL user messages that are not tool results" —— ALL 大写强调
7 Pending Tasks 保留未完成任务列表 只列出显式被要求的任务
8 Current Work 保留当前工作的精确状态 "Describe in detail precisely what was being worked on immediately before this summary request"
9 Optional Next Step 保留下一步行动(带防护条件) "ensure that this step is DIRECTLY in line with the user's most recent explicit requests"

9.3.3 <analysis> 草稿块:隐藏的质量保证机制

在 9 段摘要之前,模板要求模型先生成一个 <analysis> 块:

// src/services/compact/prompt.ts:31-44
const DETAILED_ANALYSIS_INSTRUCTION_BASE = `Before providing your final summary,
wrap your analysis in  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.
   For each section thoroughly identify:
   - The user's explicit requests and intents
   - Your approach to addressing the user's requests
   - Key decisions, technical concepts and code patterns
   - Specific details like:
     - file names
     - full code snippets
     - function signatures
     - file edits
   - Errors that you ran into and how you fixed them
   - Pay special attention to specific user feedback...
2. Double-check for technical accuracy and completeness...`

这个 <analysis> 块是一个草稿空间(drafting scratchpad)——模型在生成最终摘要之前,先按时间顺序遍历整个对话。关键词是"Chronologically analyze each message",这迫使模型按序处理而不是跳着总结,减少遗漏。

但这个草稿块不会出现在最终上下文中formatCompactSummary() 函数(src/services/compact/prompt.ts:311-335)会将其完全剥离:

// src/services/compact/prompt.ts:316-319
formattedSummary = formattedSummary.replace(
  /[\s\S]*?<\/analysis>/,
  '',
)

这是一个巧妙的"思维链"(chain-of-thought)应用:利用 <analysis> 块提升摘要质量,但不让它消耗压缩后的上下文空间。草稿块的 tokens 只在压缩 API 调用的输出中产生,不会成为后续对话的上下文负担。

9.3.4 NO_TOOLS_PREAMBLE:防止工具调用

所有三种变体在最前面都会注入一段"禁止工具调用"的强硬前言:

// src/services/compact/prompt.ts:19-26
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.

- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an  block followed by a  block.
`

并且在结尾还有一个呼应的 trailer(src/services/compact/prompt.ts:269-272):

const NO_TOOLS_TRAILER =
  '\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' +
  'an  block followed by a  block. ' +
  'Tool calls will be rejected and you will fail the task.'

源码注释解释了为什么需要如此"激进"的禁令(src/services/compact/prompt.ts:12-18):压缩请求使用 maxTurns: 1 执行(只允许一轮响应),如果模型在这一轮中尝试了工具调用,工具调用会被拒绝,导致没有文本输出——整个压缩失败,回退到流式后备路径(streaming fallback),在 Sonnet 4.6 上该问题的发生率达到 2.79%。首尾双重禁令将这个问题压缩到可忽略的水平。

9.3.5 PARTIAL 变体的差异

PARTIAL_COMPACT_PROMPTBASE_COMPACT_PROMPT 的主要差异在于:

  1. 视野限定:"Focus your summary on what was discussed, learned, and accomplished in the recent messages only"
  2. 分析指令DETAILED_ANALYSIS_INSTRUCTION_PARTIAL 用 "Analyze the recent messages chronologically" 替换了 BASE 版本的 "Chronologically analyze each message and section of the conversation"

PARTIAL_COMPACT_UP_TO_PROMPT 更为特殊——它的第 8 段从 "Current Work" 变成了 "Work Completed",第 9 段从 "Optional Next Step" 变成了 "Context for Continuing Work"。这是因为 UP_TO 模式下,模型看到的只是对话的前半段(后半段会作为保留消息原样追加),所以摘要需要为"接续者"提供上下文而不是规划下一步。

9.4 压缩执行流程

9.4.1 compactConversation() 主流程

compactConversation() 函数(src/services/compact/compact.ts:387-704)是压缩的核心编排器。其主流程可以概括为:

flowchart TD
    A[开始压缩] --> B[执行 PreCompact Hooks]
    B --> C[构建压缩提示词]
    C --> D[发送压缩请求]
    D --> E{响应是否为
prompt_too_long?} E -->|是| F[PTL 重试循环] E -->|否| G{摘要是否有效?} F --> D G -->|否| H[抛出错误] G -->|是| I[清除文件状态缓存] I --> J[并行生成附件:
文件/计划/技能/工具/MCP] J --> K[执行 SessionStart Hooks] K --> L[构建 CompactionResult] L --> M[记录遥测事件] M --> N[返回结果]

几个值得注意的细节:

预清除与后恢复src/services/compact/compact.ts:518-561):压缩完成后,代码首先清空 readFileState 缓存和 loadedNestedMemoryPaths,然后通过 createPostCompactFileAttachments() 恢复最重要的文件上下文。这是一个"先忘后想起"的策略——与其在摘要中保留所有文件内容(不可靠),不如压缩后重新读取最关键的几个文件(确定性高)。文件恢复预算:最多 5 个文件,总计 50,000 tokens,单文件上限 5,000 tokens。

附件重新注入src/services/compact/compact.ts:566-585):压缩吃掉了之前的 delta 附件(延迟工具声明、agent 列表、MCP 指令)。代码在压缩后以"空消息历史"为基线重新生成这些附件,确保模型在压缩后的第一轮就拥有完整的工具和指令上下文。

9.4.2 压缩后的消息结构

压缩产生的 CompactionResult 通过 buildPostCompactMessages() 组装为最终消息数组(src/services/compact/compact.ts:330-338):

[boundaryMarker, ...summaryMessages, ...messagesToKeep, ...attachments, ...hookResults]

其中:

  • boundaryMarker:一个 SystemCompactBoundaryMessage,标记压缩发生的位置
  • summaryMessages:用户消息格式的摘要,包含 getCompactUserSummaryMessage() 生成的前言("This session is being continued from a previous conversation that ran out of context")
  • messagesToKeep:部分压缩时保留的最近消息
  • attachments:文件、计划、技能、工具等附件
  • hookResults:SessionStart hooks 的结果

9.5 PTL 重试:当压缩本身也太长

9.5.1 问题场景

这是一个"递归"难题:你的对话太长需要压缩,但压缩请求本身也超过了 API 的输入限制(prompt_too_long)。在极长会话(比如消耗了 190K+ tokens 的会话)中,将整个对话历史发送给压缩模型时,压缩请求的输入 tokens 可能已经逼近甚至超过上下文窗口。

9.5.2 重试机制

truncateHeadForPTLRetry() 函数(src/services/compact/compact.ts:243-291)实现了一个"丢弃最旧内容"的重试策略:

flowchart TD
    A[压缩请求] --> B{响应以
PROMPT_TOO_LONG
开头?} B -->|否| C[压缩成功] B -->|是| D{ptlAttempts <= 3?} D -->|否| E[抛出错误:
Conversation too long] D -->|是| F[truncateHeadForPTLRetry] F --> G[解析 tokenGap] G --> H{tokenGap
可解析?} H -->|是| I[按 tokenGap
丢弃最旧的
API 轮次组] H -->|否| J[回退: 丢弃
20% 的轮次组] I --> K{至少保留
1 个组?} J --> K K -->|否| L[返回 null → 失败] K -->|是| M[prepend PTL_RETRY_MARKER] M --> N[用截断后的消息
重新发送压缩请求] N --> B

核心逻辑分三步:

步骤 1:按 API 轮次分组

// src/services/compact/compact.ts:257
const groups = groupMessagesByApiRound(input)

groupMessagesByApiRound()src/services/compact/grouping.ts:22-60)将消息按 API 轮次边界分组——每当出现一个新的 assistant 消息 ID 时,就开始一个新组。这确保了丢弃操作不会拆散一个 tool_use 和它对应的 tool_result。

步骤 2:计算丢弃数量

// src/services/compact/compact.ts:260-272
const tokenGap = getPromptTooLongTokenGap(ptlResponse)
let dropCount: number
if (tokenGap !== undefined) {
  let acc = 0
  dropCount = 0
  for (const g of groups) {
    acc += roughTokenCountEstimationForMessages(g)
    dropCount++
    if (acc >= tokenGap) break
  }
} else {
  dropCount = Math.max(1, Math.floor(groups.length * 0.2))
}

如果 API 的 prompt_too_long 响应中包含了具体的 token 差额(tokenGap),代码会精确地从最旧的组开始累加,直到覆盖这个差额。如果差额不可解析(某些 Vertex/Bedrock 错误格式不同),则回退到丢弃 20% 的组——一个保守但有效的启发式方法。

步骤 3:修复消息序列

// src/services/compact/compact.ts:278-291
const sliced = groups.slice(dropCount).flat()
if (sliced[0]?.type === 'assistant') {
  return [
    createUserMessage({ content: PTL_RETRY_MARKER, isMeta: true }),
    ...sliced,
  ]
}
return sliced

丢弃最旧的组后,剩余消息的第一条可能是 assistant 消息(因为原始对话的 user 前言被分在了组 0 中被丢弃了)。API 要求第一条消息必须是 user 角色,所以代码会插入一个合成的 user 标记消息 PTL_RETRY_MARKER

9.5.3 防止标记累积

注意 truncateHeadForPTLRetry() 开头的一个精妙处理(src/services/compact/compact.ts:250-255):

const input =
  messages[0]?.type === 'user' &&
  messages[0].isMeta &&
  messages[0].message.content === PTL_RETRY_MARKER
    ? messages.slice(1)
    : messages

在进行分组之前,如果消息序列的第一条是上一次重试插入的 PTL_RETRY_MARKER,代码会先将其剥离。否则这个标记会被分到组 0 中,而 20% 回退策略可能"只丢弃这个标记"——零进展,第二次重试陷入死循环。

9.5.4 重试上限与缓存穿透

// src/services/compact/compact.ts:227
const MAX_PTL_RETRIES = 3

最多重试 3 次。每次重试不仅截断消息,还更新 cacheSafeParamssrc/services/compact/compact.ts:487-490)以确保 forked-agent 路径也使用截断后的消息:

retryCacheSafeParams = {
  ...retryCacheSafeParams,
  forkContextMessages: truncated,
}

如果 3 次重试后仍然失败,抛出 ERROR_MESSAGE_PROMPT_TOO_LONG 错误,用户会看到提示:"Conversation too long. Press esc twice to go up a few messages and try again."

9.6 Session Memory 压缩:实验性的细粒度策略

传统的压缩机制会产生一份摘要,丢弃所有早期消息。但这个全有全无的策略有一个明显的缺陷:摘要可能遗漏关键细节,导致后续任务需要重新阅读完整转录。

Session Memory 压缩是一种实验性的替代策略,它利用会话记忆系统(Session Memory)保留更细粒度的上下文,同时仍然释放足够的 token 空间。

9.6.1 核心思想

Session Memory 压缩的基本思路是:

  1. 保留最近的消息(按 token 数和文本块消息数双重指标)
  2. 用 Session Memory 中提取的知识作为"摘要"
  3. 调整保留边界,确保不拆散 tool_use/tool_result 对

9.6.2 配置参数

Session Memory 压缩通过远程配置(GrowthBook)控制,默认参数为:

// src/services/compact/sessionMemoryCompact.ts:35-40
export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
  minTokens: 10_000,           // 最少保留 10K tokens
  minTextBlockMessages: 5,     // 最少保留 5 条包含文本的消息
  maxTokens: 40_000,           // 最多保留 40K tokens(硬上限)
}

这个配置体现了渐进式保留策略:

  • lastSummarizedMessageId 之后开始计算
  • 向前扩展直到满足 minTokensminTextBlockMessages 两个最低要求
  • 但不超过 maxTokens 硬上限
  • 调整边界确保不拆散 API 消息对

9.6.3 边界调整算法

adjustIndexToPreserveAPIInvariants() 函数(src/services/compact/sessionMemoryCompact.ts:176-266)实现了一个精巧的边界调整算法,分为两个步骤:

步骤 1:处理 tool_use/tool_result 对

// 收集所有保留消息中的 tool_result IDs
const allToolResultIds: string[] = []
for (let i = startIndex; i < messages.length; i++) {
  allToolResultIds.push(...getToolResultIds(messages[i]!))
}

// 向前查找包含这些 tool_use 的 assistant 消息
for (let i = adjustedIndex - 1; i >= 0 && neededToolUseIds.size > 0; i--) {
  const message = messages[i]!
  if (hasToolUseWithIds(message, neededToolUseIds)) {
    adjustedIndex = i
    // 移除已找到的 tool_use_ids
    // ...
  }
}

步骤 2:处理共享 message.id 的 thinking 块

// 收集保留范围内所有 assistant 消息的 message.id
const messageIdsInKeptRange = new Set()
for (let i = adjustedIndex; i < messages.length; i++) {
  const msg = messages[i]!
  if (msg.type === 'assistant' && msg.message.id) {
    messageIdsInKeptRange.add(msg.message.id)
  }
}

// 向前查找具有相同 message.id 的消息(可能包含 thinking 块)
for (let i = adjustedIndex - 1; i >= 0; i--) {
  const message = messages[i]!
  if (
    message.type === 'assistant' &&
    message.message.id &&
    messageIdsInKeptRange.has(message.message.id)
  ) {
    adjustedIndex = i
  }
}

这个算法修复了一个关键的 bug 场景:

flowchart LR
    subgraph Before [Before Fix]
        A1[msg N: assistant, thinking] --> A2[msg N+1: assistant, tool_use] --> A3[msg N+2: user, tool_result]
        A3 -.X.-> A4[API Error: orphan tool_result]
    end
    
    subgraph After [After Fix]
        B1[msg N: assistant, thinking] --> B2[msg N+1: assistant, tool_use] --> B3[msg N+2: user, tool_result]
        B3 --> B4[Success: properly merged]
    end

9.6.4 与传统压缩的对比

特性 传统压缩 Session Memory 压缩
保留策略 全部摘要 最近消息 + Session Memory
细粒度 低(摘要可能遗漏细节) 高(保留原始对话)
Token 效率 高(摘要紧凑) 中等(保留更多原始内容)
适用场景 一般会话 需要精确上下文恢复的会话
实验状态 稳定 实验中(需要 Feature Flag)

Session Memory 压缩目前通过 tengu_session_memorytengu_sm_compact 两个 Feature Flag 控制,只在内部测试环境启用。

9.7 autoCompactIfNeeded() 的完整编排

将上述所有机制串联起来,autoCompactIfNeeded()src/services/compact/autoCompact.ts:241-351)是 queryLoop 在每轮迭代中调用的入口。它的完整流程如下:

flowchart TD
    A["queryLoop 每轮迭代"] --> B{"DISABLE_COMPACT?"}
    B -->|是| Z["返回 wasCompacted: false"]
    B -->|否| C{"consecutiveFailures >= 3?
(熔断器)"} C -->|是| Z C -->|否| D["shouldAutoCompact()"] D -->|不需要| Z D -->|需要| E["尝试 Session Memory 压缩"] E -->|成功| F["清理 + 返回结果"] E -->|失败/不适用| G["compactConversation()"] G -->|成功| H["重置 consecutiveFailures = 0
返回结果"] G -->|失败| I{"是用户中止?"} I -->|是| J["记录错误"] I -->|否| J J --> K["consecutiveFailures++"] K --> L{">= 3?"} L -->|是| M["记录熔断警告"] L -->|否| N["返回 wasCompacted: false"] M --> N

注意一个有趣的优先级:代码首先尝试 Session Memory 压缩src/services/compact/autoCompact.ts:287-310),只有当 Session Memory 不可用或无法充分释放空间时,才回退到传统的 compactConversation()。Session Memory 压缩是一种更细粒度的策略(通过修剪消息而不是全量总结),但其适用性受限于 Session Memory 的可用性和质量。

9.8 用户能做什么

理解了自动压缩的内部机制后,以下是你作为用户可以采取的具体行动:

9.8.1 观察压缩时机

当你在长会话中看到一个短暂的"compacting..."状态指示器时,自动压缩正在进行。根据阈值公式,在 200K 上下文窗口下,这大约发生在你使用了 167K tokens(约 83.5%)时。

9.8.2 提前手动压缩

不要等到自动压缩触发。在你完成一个子任务、准备开始下一个子任务之前,主动执行 /compact。手动压缩允许你传入自定义指令:

/compact 重点保留文件修改历史和错误修复记录,代码片段要完整

这些自定义指令会被追加到压缩提示词的末尾,直接影响摘要内容。

9.8.3 利用 CLAUDE.md 中的压缩指令

在项目的 CLAUDE.md 中可以添加压缩指令段,它们会在每次压缩时被自动注入:

## Compact Instructions
When summarizing the conversation focus on typescript code changes
and also remember the mistakes you made and how you fixed them.

9.8.4 用环境变量调整阈值

如果你发现自动压缩触发得太早(导致不必要的上下文丢失)或太晚(导致频繁的 prompt_too_long 错误),可以用环境变量微调:

# 让压缩在 70% 时就触发(更保守,更少 PTL 错误)
export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=70

# 或者直接限制"可见窗口"为 100K(适合网络慢/预算紧张的场景)
export CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000

9.8.5 禁用自动压缩(不推荐)

# 只禁用自动压缩,保留手动 /compact
export DISABLE_AUTO_COMPACT=1

# 完全禁用所有压缩(包括手动)
export DISABLE_COMPACT=1

完全禁用意味着你必须手动管理上下文,否则会在上下文窗口耗尽时遇到无法恢复的 prompt_too_long 错误。

9.8.6 理解压缩后的"遗忘"

压缩后模型"遗忘"了什么,完全取决于 9 段摘要模板的覆盖范围。最容易丢失的信息类型:

  1. 精确的代码差异:虽然模板要求 "full code snippets",但极长的差异列表会被截断
  2. 被否决的方案的具体原因:模板侧重于"做了什么",对"为什么不做"的覆盖较弱
  3. 早期对话中的细微偏好:如果你在对话开头提过一次"不要用 lodash",这可能在多次压缩后消失

应对策略:将关键约束写入 CLAUDE.md(不受压缩影响),或者在压缩指令中显式列出需要保留的信息。

9.8.7 熔断后的恢复

如果你注意到模型不再自动压缩(连续 3 次失败后熔断),可以:

  1. 手动执行 /compact 尝试压缩
  2. 如果仍然失败,开始一个新会话——某些情况下上下文已经不可恢复

9.9 实战案例分析

9.9.1 案例 1:大型重构项目

场景:你正在使用 Claude Code 重构一个包含 50+ 文件的 monorepo,整个会话持续了 4 小时,经历了 5 次自动压缩。

问题:第 3 次压缩后,模型开始建议你已经否决过的方案。

分析:这是典型的"上下文漂移"问题。每次压缩都通过 9 段模板生成摘要,但模板的第 6 段虽然要求 "List ALL user messages",在极长会话中会被强制截断。

解决方案

  1. CLAUDE.md 中添加项目级约束:
## Project Constraints
- Do NOT use lodash for any new code
- Always use TypeScript strict mode
- Follow the existing naming conventions in each package
  1. 在关键决策点手动压缩并明确指令:
/compact 保留以下关键决策的完整记录:
1. 为什么否决了方案 A(性能问题)
2. 为什么选择方案 B(可维护性)
3. 文件迁移顺序的重要性
  1. 利用转录文件恢复细节:

压缩后的摘要会包含完整转录路径,当需要精确代码片段时:

请读取转录文件中的具体内容,我需要第 3 次压缩前的函数签名

9.9.2 案例 2:图像密集型会话

场景:你正在让 Claude Code 分析一系列 UI 截图,每次请求包含 3-5 张图片。

问题:会话在约 120K tokens 时就触发了自动压缩,远早于预期的 167K 阈值。

分析:虽然图片本身在发送给压缩 API 时会被剥离(stripImagesFromMessages()),但图片的标记 [image] 和工具结果仍然占据大量 token。更重要的是,图片的 base64 编码在之前的对话中已经消耗了大量 token。

解决方案

  1. 使用环境变量调整阈值:
export CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=60  # 在 60% 时就触发
  1. 分阶段处理图片:
# 第一阶段:只分析前 5 张图片
/compact 保留图片分析结果

# 第二阶段:分析后 5 张图片,并要求模型参考第一阶段的结果
  1. 创建图片摘要文件:
请将所有图片的分析结果写入一个 markdown 文件,压缩后我们可以读取这个文件而不是依赖上下文

9.9.3 案例 3:连续失败熔断

场景:你在一个包含大量二进制附件的会话中,连续 3 次自动压缩失败,熔断器触发。

分析:从日志中看到 ERROR_MESSAGE_PROMPT_TOO_LONG,说明即使压缩请求本身也太长。附件占据了不可压缩的 token 空间。

解决方案

  1. 手动触发 /compact 并观察是否成功
  2. 如果手动压缩也失败,采用"分而治之"策略:
# 开始新会话 A:处理前半部分代码
# 开始新会话 B:处理后半部分代码,并要求读取会话 A 的转录
  1. 清理不必要的附件:
/remove-attachments  # 移除不再需要的附件
/compact  # 再次尝试压缩

9.10 最佳实践总结

基于对自动压缩机制的深入理解,以下是长会话的最佳实践:

9.10.1 预防性策略

  1. 阶段性压缩:在每个子任务完成后手动压缩,而不是等到自动触发
  2. 关键决策外置:将架构决策、设计文档写入项目文件,而不是依赖对话历史
  3. CLAUDE.md 约束:所有必须遵守的约束都应该在 CLAUDE.md 中,不受压缩影响

9.10.2 压缩时策略

  1. 明确指令:使用自定义压缩指令突出需要保留的内容
  2. 代码优先:如果可能,要求模型将代码写入文件而不是留在上下文中
  3. 转录路径:记住压缩后的摘要包含转录路径,可以随时回溯

9.10.3 压缩后策略

  1. 验证摘要:快速检查压缩摘要是否遗漏了关键信息
  2. 重新加载上下文:如果发现摘要不完整,手动读取关键文件
  3. 恢复约束:如果 CLAUDE.md 中的约束被遗忘,提醒模型重新读取

9.11 小结

自动压缩是 Claude Code 最关键的上下文管理机制之一,它的设计体现了几个重要的工程原则:

  1. 多层缓冲:20K 输出预留 + 13K 缓冲区 + 3K 阻塞硬限制,三层防线确保系统在任何竞态条件下都不会溢出
  2. 渐进降级:Session Memory 压缩 → 传统压缩 → PTL 重试 → 熔断,每一层都是上一层的兜底
  3. 可观测性tengu_compacttengu_compact_failedtengu_compact_ptl_retry 三个遥测事件覆盖了成功、失败和重试路径
  4. 用户可控:环境变量覆盖、自定义压缩指令、手动 /compact 命令,给予高级用户足够的控制权

下一章我们将探讨压缩后的文件状态保留机制——压缩可以"忘记"对话历史,但不应该"忘记"它正在编辑哪些文件。


参考资源

版本信息

  • Claude Code 版本:基于 v2.1.91 源码分析
  • 最后更新:2026-04-05
  • 作者:Claude Code Harness 系列教程
Enjoyed this article? Share it with others!