问题引入
一个典型的 Claude Code 编码会话可以持续数小时。用户要求重构一个模块,Claude 读取了 20 个文件、执行了 30 次 shell 命令、做了 15 次文件编辑——这些交互产生了数十万 token 的对话历史。即便 Claude 的上下文窗口已经达到 200K token,在密集的编码会话中,窗口也会在 30-60 分钟内被填满。
问题的核心矛盾是:不压缩,历史超出窗口无法继续;压缩,又可能丢失关键信息——比如用户明确纠正的一个 bug fix、某个函数的精确签名、或者一条"以后都用这种风格"的指令。
Claude Code 的解决方案不是一个单一的压缩算法,而是一套多层次、多策略的上下文管理系统。从轻量级的工具结果清理(microcompact),到基于会话记忆的快速压缩(session memory compact),再到完整的 LLM 摘要压缩(full compact),每一层在不同的压力水平下介入,以最小的信息损失维持对话的可持续性。
本文将深入 services/compact/ 目录下的实现,逐层解析这套系统的工程设计。
Token 估算:一切的基础
在决定"何时压缩"之前,首先要回答一个看似简单的问题:当前对话消耗了多少 token?
粗略估算 vs API 精确计数
Claude Code 使用两种 token 计数策略:
- 粗略估算:基于字符长度除以一个字节/token 比率
- API 精确计数:调用 Anthropic 的
countTokens API
粗略估算的核心函数在 src/services/tokenEstimation.ts 第 203-208 行:
203// 粗略估算文本内容的 token 数量
204// 原理:大多数英文文本中,平均 4 个字节约对应 1 个 token
205// 这种估算虽然不精确,但计算速度极快(O(1)),适合高频调用场景
206export function roughTokenCountEstimation(
207 content: string,
208 bytesPerToken: number = 4, // 默认比率,不同文件类型可覆盖此值
209): number {
210 return Math.round(content.length / bytesPerToken)
211}
默认使用 4 字节/token 的比率。但对于不同文件类型,这个比率需要调整——JSON 文件中有大量单字符 token({、}、:、,、"),真实比率更接近 2:
215// 根据文件扩展名返回该类型文件的字节/token 比率
216// 不同格式的文件具有不同的 token 密度特征
217export function bytesPerTokenForFileType(fileExtension: string): number {
218 switch (fileExtension) {
219 case 'json':
220 case 'jsonl':
221 case 'jsonc':
222 // JSON 文件包含大量单字符 token({、}、:、,、"),
223 // 每个字符就是一个 token,因此比率降低到 2
224 return 2
225 default:
226 // 普通代码和文本文件使用默认的 4 字节/token 比率
227 return 4
228 }
229}
消息级别的 Token 估算
对于完整的消息数组,估算需要处理多种 content block 类型。microCompact.ts 中的 estimateMessageTokens 函数(第 164-205 行)展示了这种复杂性:
164// 估算消息数组的总 token 消耗量
165// 需要分别处理不同类型的 content block,因为它们的 token 特征各不相同
166export function estimateMessageTokens(messages: Message[]): number {
167 let totalTokens = 0
168
169 for (const message of messages) {
170 // 只统计用户和助手消息,跳过系统消息等其他类型
171 if (message.type !== 'user' && message.type !== 'assistant') {
172 continue
173 }
174
175 // 非数组内容(纯字符串)不在此处理
176 if (!Array.isArray(message.message.content)) {
177 continue
178 }
179
180 // 遍历每个 content block,按类型选择不同的估算策略
181 for (const block of message.message.content) {
182 if (block.type === 'text') {
183 // 纯文本:直接用字符长度除以字节比率
184 totalTokens += roughTokenCountEstimation(block.text)
185 } else if (block.type === 'tool_result') {
186 // 工具返回结果:可能包含嵌套的文本和图片,需要专门的计算函数
187 totalTokens += calculateToolResultTokens(block)
188 } else if (block.type === 'image' || block.type === 'document') {
189 // 图片和文档:无法通过字符长度估算,使用固定值 2000 token
190 totalTokens += IMAGE_MAX_TOKEN_SIZE
191 } else if (block.type === 'thinking') {
192 // 模型的思考过程:与普通文本相同的估算方式
193 totalTokens += roughTokenCountEstimation(block.thinking)
194 } else if (block.type === 'tool_use') {
195 // 工具调用:将工具名和序列化后的输入参数合并估算
196 totalTokens += roughTokenCountEstimation(
197 block.name + jsonStringify(block.input ?? {}),
198 )
199 }
200 // ...其他类型(如 redacted_thinking 等)
201 }
202 }
203
204 // 乘以 4/3(约 1.33)作为安全系数
205 // 原因:粗略估算天然偏低,加上这个系数避免"以为还有空间但实际已溢出"的问题
206 return Math.ceil(totalTokens * (4 / 3))
207}
注意最后的 4/3 安全系数——由于粗略估算天然偏低,乘以 1.33 来避免低估导致的"以为还有空间但实际已经溢出"的问题。
混合策略:tokenCountWithEstimation
真正在自动压缩判断中使用的是 tokenCountWithEstimation(src/utils/tokens.ts 第 226 行),它结合了两种策略:
- 从最后一个有 API usage 数据的 assistant 消息获取精确的 token 数
- 对该消息之后的新消息使用粗略估算
- 将两者相加得到当前总量
这个设计巧妙地避免了两个极端:纯 API 计数太慢(每次需要一个网络请求),纯粗略估算太不准确。通过利用 API 响应中已有的 usage 数据作为锚点,只对增量部分做粗略估算,实现了精度和性能的平衡。
上下文压力检测:多级阈值体系
知道了"用了多少 token"之后,下一个问题是:什么时候该开始压缩?
Claude Code 定义了一套精密的多级阈值体系,在 autoCompact.ts 中实现。
有效上下文窗口
首先,不是所有窗口空间都能用于对话。系统需要为输出预留空间:
30// 压缩摘要输出的最大 token 预留量
31// 基于生产数据:p99.99 的压缩摘要输出为 17,387 token,向上取整到 20,000 留余量
32const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
33
34// 计算有效上下文窗口大小——即实际可用于对话内容的空间
35// 必须从模型的总窗口中扣除压缩摘要输出所需的预留空间
36export function getEffectiveContextWindowSize(model: string): number {
37 // 取模型最大输出 token 数和摘要预留量的较小值
38 // 避免在输出限制本身就小于预留量的模型上过度扣除
39 const reservedTokensForSummary = Math.min(
40 getMaxOutputTokensForModel(model),
41 MAX_OUTPUT_TOKENS_FOR_SUMMARY,
42 )
43 let contextWindow = getContextWindowForModel(model, getSdkBetas())
44
45 // 支持通过环境变量覆盖窗口大小(用于测试和调试)
46 // 取环境变量和模型窗口的较小值,防止设置超出模型实际限制
47 const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
48 if (autoCompactWindow) {
49 const parsed = parseInt(autoCompactWindow, 10)
50 if (!isNaN(parsed) && parsed > 0) {
51 contextWindow = Math.min(contextWindow, parsed)
52 }
53 }
54
55 // 有效窗口 = 总窗口 - 预留空间
56 return contextWindow - reservedTokensForSummary
57}
对于一个 200K 的上下文窗口,有效空间约为 180K。
四级阈值
calculateTokenWarningState 函数(第 93-145 行)定义了四个压力级别:
62// 四级阈值的缓冲区定义——每个值代表距离有效窗口上限的"安全距离"
63export const AUTOCOMPACT_BUFFER_TOKENS = 13_000 // 自动压缩触发:距上限 13K 时启动压缩流程
64export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000 // 警告阈值:距自动压缩线再留 20K,UI 显示黄色警告
65export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000 // 错误阈值:进一步的红色警告区域
66export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000 // 阻塞限制:仅留 3K 余量,阻止发送新消息,强制用户压缩
用一个具体例子来说明(假设有效窗口为 180K token):
| 阈值级别 | 计算方式 | 大约 Token 值 | 触发行为 |
|---|
| 自动压缩 | 有效窗口 - 13,000 | ~167K | 触发自动压缩流程 |
| 警告阈值 | 阈值 - 20,000 | ~147K | UI 显示黄色警告 |
| 错误阈值 | 阈值 - 20,000 | ~147K | UI 显示红色警告 |
| 阻塞限制 | 有效窗口 - 3,000 | ~177K | 阻止发送新消息,强制压缩 |
熔断机制
一个关键的工程细节:自动压缩不是无限重试的。autoCompact.ts 第 68-70 行定义了熔断器:
68// 熔断器:连续自动压缩失败的最大次数
69// BQ 2026-03-10: 1,279 个会话出现了 50+ 次连续失败(最高达 3,272 次),
70// 每天在全球范围内浪费约 25 万次 API 调用。引入此熔断器后问题得到控制。
71const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
这个注释揭示了一个真实的生产事故:在引入熔断器之前,有 1,279 个会话出现了 50 次以上的连续压缩失败(最多达到 3,272 次!),每天浪费约 25 万次 API 调用。现在,连续失败 3 次后就停止重试:
257// 熔断检查:如果连续失败次数已达上限,直接放弃本次压缩
258// 避免在持续失败的场景下无意义地消耗 API 调用和用户等待时间
259if (
260 tracking?.consecutiveFailures !== undefined &&
261 tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
262) {
263 return { wasCompacted: false }
264}
/compact 命令与反应式压缩:主动 vs 被动
Claude Code 的上下文压缩有两种触发模式:
主动模式:用户手动触发
用户在对话中输入 /compact,可以选择附带自定义的压缩指令(例如 /compact 重点保留测试相关的代码修改)。手动触发时,compactConversation 被直接调用,isAutoCompact 参数为 false。
被动模式:自动触发
shouldAutoCompact 函数(第 160-239 行)是自动压缩的守门人。它有多个短路条件,防止在不该触发的场景下触发:
160// 自动压缩的守门人函数——判断当前是否需要触发自动压缩
161// 通过多个短路条件层层过滤,只有真正需要压缩时才返回 true
162export async function shouldAutoCompact(
163 messages: Message[],
164 model: string,
165 querySource?: QuerySource, // 请求来源标识,用于防止递归
166 snipTokensFreed = 0, // microcompact 已释放的 token 数,需要从总量中扣除
167): Promise<boolean> {
168 // 1. 递归保护:压缩过程本身和会话记忆提取使用的 forked agent 不应再触发压缩
169 // 否则会导致无限递归——压缩 agent 的上下文也超限,触发再次压缩...
170 if (querySource === 'session_memory' || querySource === 'compact') {
171 return false
172 }
173
174 // 2. 全局开关检查:用户可通过配置禁用自动压缩
175 if (!isAutoCompactEnabled()) {
176 return false
177 }
178
179 // 3. 核心判断:计算当前 token 用量(减去已释放的部分),与阈值比较
180 const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
181 const threshold = getAutoCompactThreshold(model)
182
183 const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
184 tokenCount, model,
185 )
186
187 return isAboveAutoCompactThreshold
188}
第一个短路条件特别值得注意:querySource === 'compact' 防止了压缩过程中的自我递归。因为压缩本身是通过一个 forked agent 执行的(将整个对话作为上下文发送给 Claude 生成摘要),如果不加这个保护,forked agent 自身的上下文也可能触发压缩,导致无限递归。
自动压缩的完整流程
autoCompactIfNeeded 是每个 query loop 迭代中都会调用的函数。它的执行逻辑是分层的:
注意优先级:Session Memory Compaction 优先于完整的 LLM Compaction。这是因为 Session Memory Compaction 不需要额外的 API 调用,速度更快,成本更低。
压缩策略详解
第一层:Microcompact——轻量级工具结果清理
Microcompact 是最轻量的压缩策略,它不调用任何 LLM,而是直接清理对话中旧的工具调用结果。核心思路:FileRead、Bash、Grep 等工具的返回结果通常很大(一个文件可能有数千 token),但在对话进行一段时间后,这些结果的信息价值会递减。
可压缩的工具类型
microCompact.ts 第 41-50 行定义了哪些工具的结果可以被清理:
41// 可被 microcompact 清理的工具集合
42// 选择标准:输出量大、信息密度随时间递减的"读取类"和"输出密集型"工具
43// 注意:TodoRead、ToolSearch 等输出小、信息密度高的工具故意不在此列
44const COMPACTABLE_TOOLS = new Set<string>([
45 FILE_READ_TOOL_NAME, // 文件读取——返回结果可能有数千行代码
46 ...SHELL_TOOL_NAMES, // Shell 命令——编译日志、测试输出等可能非常冗长
47 GREP_TOOL_NAME, // 搜索结果——匹配内容可能很多
48 GLOB_TOOL_NAME, // 文件匹配列表
49 WEB_SEARCH_TOOL_NAME, // 网页搜索结果
50 WEB_FETCH_TOOL_NAME, // 网页抓取内容
51 FILE_EDIT_TOOL_NAME, // 文件编辑的 diff 输出
52 FILE_WRITE_TOOL_NAME, // 文件写入的确认输出
53])
注意这个集合的选择:只包含"读取类"和"输出密集型"的工具。像 TodoRead、ToolSearch 这类输出较小、信息密度高的工具不在其中。
两种 Microcompact 路径
Claude Code 实际有两种 microcompact 实现:
1. 基于时间的 Microcompact(Time-based MC)
当用户离开一段时间后回来继续对话时,服务端的 prompt cache 已经失效,整个 prompt 前缀都会被重新写入。这时清理旧的工具结果是"免费的"——反正 cache 都要重建,不如趁机瘦身。
evaluateTimeBasedTrigger 函数(第 422-444 行)检测时间间隔:
438// 计算当前时间与最后一条助手消息的间隔(分钟)
439// 如果用户离开后回来,这个间隔会很大,说明 prompt cache 已经失效(通常 5 分钟 TTL)
440const gapMinutes =
441 (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000
442// 间隔未超过阈值,不触发时间触发的清理
443if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
444 return null
445}
446// 间隔足够大,返回触发信息供后续清理逻辑使用
447return { gapMinutes, config }
当间隔超过阈值时,保留最近 N 个工具结果,将其余的内容替换为 [Old tool result content cleared]:
476// 对需要清理的工具结果执行内容替换
477if (
478 block.type === 'tool_result' &&
479 clearSet.has(block.tool_use_id) && // 该工具结果在清理目标集合中
480 block.content !== TIME_BASED_MC_CLEARED_MESSAGE // 避免重复清理已经清过的结果
481) {
482 tokensSaved += calculateToolResultTokens(block) // 记录释放的 token 数
483 touched = true
484 // 用占位文本替换原始内容,保持 tool_result 结构完整性(API 要求 tool_result 必须存在)
485 return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
486}
2. 基于缓存编辑的 Microcompact(Cached MC)
这是更精巧的路径。它不修改本地消息内容,而是通过 API 的 cache_edits 机制告诉服务端"请在缓存中删除这些工具结果"。这样可以在保持 prompt cache 有效的同时,减少实际发送的 token 数。
369// 返回未修改的消息——cache_reference 和 cache_edits 在 API 层添加
370// 关键区别:Cached MC 不修改本地消息内容,而是通过 API 的缓存编辑机制
371// 在服务端删除指定工具结果,这样既节省了 token 又保持了 prompt cache 的有效性
这两种路径的选择逻辑:时间间隔大(cache 已冷)用 time-based MC 直接修改内容;间隔小(cache 还热)用 cached MC 通过 API 层面删除。
Microcompact 的入口函数
microcompactMessages(第 253-293 行)是统一入口:
253// Microcompact 的统一入口函数
254// 按优先级依次尝试两种清理路径,第一个成功的路径短路返回
255export async function microcompactMessages(
256 messages: Message[],
257 toolUseContext?: ToolUseContext,
258 querySource?: QuerySource,
259): Promise<MicrocompactResult> {
260 // 1. 优先尝试时间触发的清理——当 prompt cache 已冷时,直接修改内容是免费的
261 // 如果成功则立即返回,不再尝试其他路径
262 const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)
263 if (timeBasedResult) {
264 return timeBasedResult
265 }
266
267 // 2. Cache 仍热时,使用缓存编辑路径——通过 API 层面删除而非修改本地消息
268 // 需要 feature flag 开启才可用
269 if (feature('CACHED_MICROCOMPACT')) {
270 // ...条件检查...
271 return await cachedMicrocompactPath(messages, querySource)
272 }
273
274 // 3. 两种路径都不适用(间隔太短且 cached MC 未启用),原样返回不做任何清理
275 return { messages }
276}
第二层:Session Memory Compaction——零 API 调用压缩
Session Memory Compaction 是一种巧妙的优化。Claude Code 在后台持续维护一份"会话记忆"(Session Memory),记录对话中的关键信息——用户的偏好、技术决策、错误修复等。当需要压缩时,可以直接使用这份已有的记忆作为摘要,而不需要再调用 LLM 来生成摘要。
工作原理
sessionMemoryCompact.ts 中的 trySessionMemoryCompaction(第 514-630 行)是核心:
514// Session Memory Compaction 的核心函数
515// 尝试使用已有的会话记忆作为摘要,避免额外的 LLM API 调用
516// 返回 null 表示此策略不可用,调用方应回退到 Full Compact
517export async function trySessionMemoryCompaction(
518 messages: Message[],
519 agentId?: AgentId,
520 autoCompactThreshold?: number, // 自动压缩阈值,用于计算需要保留多少消息
521): Promise<CompactionResult | null> {
522 // 功能开关检查——可通过配置禁用此策略
523 if (!shouldUseSessionMemoryCompaction()) {
524 return null
525 }
526
527 // 等待正在进行的会话记忆提取完成
528 // Session Memory 由后台 agent 异步维护,这里需要确保拿到最新版本
529 await waitForSessionMemoryExtraction()
530
531 const lastSummarizedMessageId = getLastSummarizedMessageId() // 上次总结到哪条消息
532 const sessionMemory = await getSessionMemoryContent() // 获取当前会话记忆内容
533
534 // 没有会话记忆,或者记忆内容为空模板(刚初始化还没积累信息)
535 // 此时 Session Memory 无法提供有意义的摘要,回退到传统的 LLM 压缩
536 if (!sessionMemory || await isSessionMemoryEmpty(sessionMemory)) {
537 return null
538 }
539 // ...
540}
消息保留策略
关键设计:Session Memory Compaction 不是丢弃所有旧消息,而是智能地保留一部分近期消息。calculateMessagesToKeepIndex(第 324-397 行)实现了这个逻辑:
57// Session Memory Compaction 的消息保留策略配置
58// 核心思想:不是丢弃所有旧消息,而是在摘要之后保留一部分近期消息
59// 保留近期消息的好处:保持对话的连贯性,避免模型"失忆"刚才的讨论
60export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
61 minTokens: 10_000, // 最低保留量:至少保留 10K token 的近期消息
62 minTextBlockMessages: 5, // 最低消息数:至少保留 5 条有实际文本内容的消息
63 maxTokens: 40_000, // 保留上限:最多保留 40K token,避免保留过多导致压缩无效
64}
保留策略从 lastSummarizedMessageId(上次总结到哪条消息)开始,向前扩展,直到满足最低保留条件(至少 10K token 或 5 条文本消息),但不超过 40K token 的上限。
工具对完整性保护
保留消息时有一个微妙但关键的工程细节:不能破坏 tool_use/tool_result 的配对关系。adjustIndexToPreserveAPIInvariants(第 232-314 行)处理了这个问题。
考虑这个场景:
1Index N: assistant, message.id: X, content: [thinking]
2Index N+1: assistant, message.id: X, content: [tool_use: ORPHAN_ID]
3Index N+2: assistant, message.id: X, content: [tool_use: VALID_ID]
4Index N+3: user, content: [tool_result: ORPHAN_ID, tool_result: VALID_ID]
如果 startIndex = N+2,我们保留了 tool_use: VALID_ID 和两个 tool_result,但 ORPHAN_ID 的 tool_use 被丢弃了,API 会因为孤儿 tool_result 报错。adjustIndexToPreserveAPIInvariants 会检测到这种情况,自动将 startIndex 调整到 N+1 来包含匹配的 tool_use。
第三层:Full Compact——LLM 驱动的完整摘要
当 microcompact 和 session memory compaction 都不足以解决问题时(或者根本不可用时),系统使用完整的 LLM 压缩:将整个对话历史发送给 Claude,让它生成一份结构化的摘要。
压缩 Prompt 的设计
prompt.ts 中的 BASE_COMPACT_PROMPT(第 61-143 行)定义了摘要的结构要求。这个 prompt 要求生成包含 9 个部分的摘要:
- Primary Request and Intent - 用户的明确请求和意图
- Key Technical Concepts - 关键技术概念
- Files and Code Sections - 文件和代码片段
- Errors and fixes - 遇到的错误和修复方法
- Problem Solving - 问题解决过程
- All user messages - 所有用户消息(非工具结果)
- Pending Tasks - 待完成的任务
- Current Work - 当前正在进行的工作
- Optional Next Step - 可选的下一步
第 6 条("列出所有非工具结果的用户消息")特别值得注意。它的目的是确保用户的反馈和方向修正不会在压缩中丢失——这些往往是最关键的信息。
analysis/summary 两阶段生成
prompt 使用了一个巧妙的两阶段结构:先让模型在 <analysis> 标签中整理思路,然后在 <summary> 标签中输出最终摘要。formatCompactSummary(第 311-335 行)会剥离 analysis 部分,只保留 summary:
311// 格式化 LLM 生成的压缩摘要,提取最终输出并丢弃中间思考过程
312// LLM 生成的原始输出包含 <analysis> 和 <summary> 两个阶段
313export function formatCompactSummary(summary: string): string {
314 let formattedSummary = summary
315
316 // 第一步:剥离 <analysis> 部分
317 // analysis 是模型的"草稿纸"——帮助模型整理思路以提高摘要质量,
318 // 但摘要完成后它就没有保留价值了,反而会浪费 token
319 formattedSummary = formattedSummary.replace(
320 /<analysis>[\s\S]*?<\/analysis>/,
321 '',
322 )
323
324 // 第二步:提取 <summary> 标签内的内容,转换为纯文本格式
325 // 将 XML 标签替换为简洁的 "Summary:" 前缀
326 const summaryMatch = formattedSummary.match(/<summary>([\s\S]*?)<\/summary>/)
327 if (summaryMatch) {
328 const content = summaryMatch[1] || ''
329 formattedSummary = formattedSummary.replace(
330 /<summary>[\s\S]*?<\/summary>/,
331 `Summary:\n${content.trim()}`,
332 )
333 }
334
335 return formattedSummary.trim()
336}
这个设计利用了 LLM 的一个特性:先"思考"再"输出"通常能产生更高质量的结果,但思考过程本身不需要保留在最终的上下文中。
防止工具调用的多重保护
压缩请求被发送到一个 forked agent,这个 agent 需要生成纯文本摘要而不是调用工具。prompt.ts 中的 NO_TOOLS_PREAMBLE(第 19-26 行)展示了防止模型调用工具的严格指令:
19// 防止压缩 agent 调用工具的严格指令(前置版本)
20// 背景:压缩 agent 是一个 forked agent,理论上可以调用工具
21// 但压缩任务只需要生成文本摘要,调用工具是错误行为且会导致压缩失败
22// 在 Sonnet 4.6+ 上,模型有 2.79% 的概率会尝试工具调用(vs 4.5 的 0.01%)
23const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
24
25- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
26- You already have all the context you need in the conversation above.
27- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
28- Your entire response must be plain text: an <analysis> block followed by a <summary> block.
29
30`
中文翻译:
关键指令:仅以纯文本回复。不要调用任何工具。
- 不要使用 Read、Bash、Grep、Glob、Edit、Write 或任何其他工具。
- 上面的对话中已经包含了你需要的所有上下文。
- 工具调用将被拒绝,并且会浪费你唯一的回合——你将无法完成任务。
- 你的整个回复必须是纯文本:一个
<analysis> 块,然后是一个 <summary> 块。
并且在 prompt 末尾还有一个 NO_TOOLS_TRAILER 再次强调:
269// 防止工具调用的尾部再次提醒(与 NO_TOOLS_PREAMBLE 首尾呼应)
270// 双重保险:在 prompt 的开头和结尾都强调不要调用工具
271// 这种"三明治"式的约束对降低模型的工具调用倾向非常有效
272const NO_TOOLS_TRAILER =
273 '\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' +
274 'an <analysis> block followed by a <summary> block. ' +
275 'Tool calls will be rejected and you will fail the task.'
中文翻译:
提醒:不要调用任何工具。仅以纯文本回复——一个 <analysis> 块,然后是一个 <summary> 块。工具调用将被拒绝,你将无法完成任务。
注释(第 16-17 行)解释了为什么需要这么强硬:在 Sonnet 4.6+ 的 adaptive-thinking 模型上,仅依靠 maxTurns: 1 不够——模型有 2.79% 的概率会尝试工具调用(对比 4.5 版本的 0.01%),而被拒绝的工具调用意味着没有文本输出,整个压缩就失败了。
Partial Compact:部分压缩
除了全量压缩,Claude Code 还支持部分压缩——用户可以选择一条消息作为分割点,只压缩前面或后面的部分。partialCompactConversation(compact.ts 第 772 行起)实现了两种方向:
from:压缩选定消息之后的部分,保留之前的部分。Prompt cache 可以保持有效。
up_to:压缩选定消息之前的部分,保留之后的部分。Prompt cache 会失效(因为前缀变了)。
Prompt-Too-Long 重试
压缩请求本身也可能因为上下文过长而失败(这看似矛盾但完全可能——对话太长导致连发送给压缩 agent 的请求都超限了)。truncateHeadForPTLRetry(第 243-291 行)处理这种情况:
243// 当压缩请求本身因 Prompt-Too-Long 失败时的重试策略
244// 思路:从对话头部丢弃部分消息组,缩短上下文后重新发送压缩请求
245// 这是有损操作——被丢弃的历史将不会被纳入摘要
246export function truncateHeadForPTLRetry(
247 messages: Message[],
248 ptlResponse: AssistantMessage, // API 返回的错误响应,可能包含具体的 token 超出量
249): Message[] | null {
250 // 按 API 轮次将消息分组,每组是一个完整的交互回合
251 const groups = groupMessagesByApiRound(input)
252 if (groups.length < 2) return null // 只有一组消息时无法再裁剪
253
254 // 从 API 错误响应中提取具体的 token 缺口(超出了多少 token)
255 const tokenGap = getPromptTooLongTokenGap(ptlResponse)
256 let dropCount: number
257 if (tokenGap !== undefined) {
258 // 精确模式:根据缺口大小,从头部逐组累计 token 直到填补缺口
259 let acc = 0
260 dropCount = 0
261 for (const g of groups) {
262 acc += roughTokenCountEstimationForMessages(g)
263 dropCount++
264 if (acc >= tokenGap) break // 累计释放量已足够
265 }
266 } else {
267 // 保守模式:API 未提供具体缺口时,一次性丢弃 20% 的组
268 dropCount = Math.max(1, Math.floor(groups.length * 0.2))
269 }
270
271 // 安全兜底:至少保留一个组,否则没有内容可以生成摘要
272 dropCount = Math.min(dropCount, groups.length - 1)
273 // ...
274}
最多重试 3 次(MAX_PTL_RETRIES = 3),每次丢弃对话最开头的部分。这是有损的,但至少能让用户继续工作。
文件缓存与去重:readFileState
Claude Code 在压缩后需要恢复关键文件的上下文。readFileState 是一个 LRU 缓存,记录了对话期间读取过的文件内容和时间戳。
FileStateCache 的设计
src/utils/fileStateCache.ts 实现了一个带路径规范化的 LRU 缓存:
30// 文件状态缓存——基于 LRU(最近最少使用)策略的缓存实现
31// 用途:记录对话期间读取过的文件内容和时间戳,压缩后用于恢复关键文件上下文
32export class FileStateCache {
33 private cache: LRUCache<string, FileState>
34
35 constructor(maxEntries: number, maxSizeBytes: number) {
36 this.cache = new LRUCache<string, FileState>({
37 max: maxEntries, // 最大条目数限制(默认 100 个文件)
38 maxSize: maxSizeBytes, // 最大总字节数限制(默认 25MB)
39 // 以文件内容的字节长度作为每个条目的"大小"
40 // Math.max(1, ...) 确保空文件也至少占 1 字节,避免 LRU 库的零大小边界情况
41 sizeCalculation: value => Math.max(1, Buffer.byteLength(value.content)),
42 })
43 }
44
45 get(key: string): FileState | undefined {
46 // 所有操作都通过 normalize() 规范化路径
47 // 确保 /foo/../bar 和 /bar 命中同一个缓存条目
48 return this.cache.get(normalize(key))
49 }
50 // ...
51}
关键设计决策:
- 双重限制:最多 100 个条目,总大小最多 25MB。
sizeCalculation 以文件内容的字节长度为基准,防止一个巨大文件撑爆缓存。
- 路径规范化:所有
get/set/has/delete 操作都通过 normalize(key) 处理路径,确保 /foo/../bar 和 /bar 命中同一个缓存条目。
- 部分视图标记:
isPartialView 字段标记那些被自动注入(如 CLAUDE.md)但内容不完整的条目——HTML 注释被剥离、frontmatter 被去掉、MEMORY.md 被截断。Edit/Write 工具看到这个标记时,会要求先执行一次显式 Read。
压缩后的文件恢复
compactConversation 在压缩前保存文件状态快照,压缩后选择性恢复最重要的文件:
517// 压缩前快照:将当前文件状态缓存序列化保存
518// 压缩会重建消息历史,旧的缓存条目与新消息不再对应,必须清空后选择性恢复
519const preCompactReadFileState = cacheToObject(context.readFileState)
520
521// 清空两类缓存:
522// 1. 文件状态缓存——压缩后会根据预算从快照中选择性恢复最重要的文件
523// 2. 嵌套 memory 路径——避免引用压缩前已不存在的 memory 条目
524context.readFileState.clear()
525context.loadedNestedMemoryPaths?.clear()
恢复时有预算限制(compact.ts 第 122-130 行):
122// 压缩后文件恢复的预算限制——防止恢复过多文件导致上下文再次膨胀
123export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 // 最多恢复 5 个文件(按 LRU 顺序选取最近使用的)
124export const POST_COMPACT_TOKEN_BUDGET = 50_000 // 文件恢复的总 token 预算
125export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 // 单个文件的 token 上限(超出的文件会被跳过)
126export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 // 单个 skill 文件的 token 上限
127export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 // skill 类文件恢复的独立预算
最多恢复 5 个文件,总预算 50K token,单文件不超过 5K token。这个设计确保压缩后的上下文不会因为文件恢复而再次膨胀。
FILE_UNCHANGED_STUB:去重优化
当模型在压缩后重新读取一个之前读过且内容没变的文件时,FileReadTool 不会返回完整内容,而是返回一个占位符 FILE_UNCHANGED_STUB。这避免了在上下文中重复出现相同的大文件内容。
System Prompt 的 Token 预算
System prompt 在每次 API 请求中都会被发送,它消耗的 token 直接影响可用于对话的空间。Claude Code 通过精心的预算管理来控制这个开销。
压缩时为输出预留的空间(autoCompact.ts 第 29-30 行)是基于真实数据的:
29// 压缩摘要输出的预留空间,基于生产统计数据校准:
30// p99.99 的摘要输出为 17,387 token,取 20,000 留出安全余量
31// 这个值直接影响有效上下文窗口的计算——预留越多,可用空间越小
32const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
p99.99 意味着在 99.99% 的情况下,压缩摘要的输出不超过 17,387 token。预留 20,000 token 提供了额外的安全余量。
压缩后的摘要格式也考虑了 token 效率。getCompactUserSummaryMessage(prompt.ts 第 337-374 行)生成的摘要消息包含:
- 格式化后的摘要正文
- transcript 文件路径(用户可以在需要时读取完整历史)
- 是否保留了近期消息的标记
- 续作指令(自动压缩时告诉模型"不要问问题,直接继续")
345// 压缩后注入的摘要消息模板
346// 明确告知模型"当前会话是从之前的对话延续的",帮助模型理解上下文边界
347// 这样模型不会困惑于为什么对话历史突然从一段摘要开始
348let baseSummary = `This session is being continued from a previous conversation
349that ran out of context. The summary below covers the earlier portion of the
350conversation.
351
352${formattedSummary}`
中文翻译:
本会话是从之前的一次对话延续而来,之前的对话因上下文空间耗尽而中断。以下摘要涵盖了之前对话的内容。
{格式化后的摘要内容}
压缩后清理与状态重置
压缩不仅是"用摘要替换旧消息"那么简单。runPostCompactCleanup(postCompactCleanup.ts)和 compactConversation 本身做了大量的善后工作:
- Prompt Cache Break Detection:通知缓存监测系统"刚刚做了压缩,接下来的 cache miss 是正常的,不要告警"
- Session Metadata 重追加:将会话标题、标签等元数据追加到 transcript 末尾,确保
--resume 命令能正确显示会话名称
- 工具发现状态迁移:通过
preCompactDiscoveredTools 保存压缩前发现的动态工具列表,确保压缩后模型仍然可以使用这些工具
- Hook 执行:依次执行
PreCompact hooks、SessionStart hooks 和 PostCompact hooks
- Microcompact 状态重置:时间触发的 microcompact 会重置 cached MC 的全局状态,避免尝试编辑已不存在的 cache 条目
压缩策略的层级架构
让我们用一张完整的图来总结整个压缩系统的层级关系:
工具调用摘要(Tool Use Summaries)
在压缩过程中,工具调用和结果的处理是最复杂的部分之一。一个典型的 Claude Code 会话中,工具调用可能占据 70-80% 的 token 总量——一次 FileRead 可能返回几千行代码,一次 Bash 可能输出大量编译日志。
图片和文档的剥离
stripImagesFromMessages(compact.ts 第 145-200 行)在发送给压缩 agent 之前剥离所有图片和文档块,替换为文本标记 [image] 或 [document]。原因很实际:图片对生成文本摘要没用,但可能导致压缩请求本身超出 token 限制。
157// 将图片和文档块替换为文本占位符
158// 原因:图片对生成文本摘要没有帮助,但会大量消耗 token
159// 如果不剥离,可能导致压缩请求本身超出上下文限制
160const newContent = content.flatMap(block => {
161 if (block.type === 'image') {
162 hasMediaBlock = true
163 return [{ type: 'text' as const, text: '[image]' }] // 用文本标记替代
164 }
165 // ...
166})
API 轮次分组
groupMessagesByApiRound(grouping.ts)按 API 轮次将消息分组。这比按"用户消息"分组更细粒度——在 SDK/CCR 等场景下,整个工作负载可能只有一条用户消息但有数十个 API 轮次。分组边界定义为:当出现一个新的 assistant message.id 时,开始新的一组。
这个分组被用于两个地方:
- Prompt-too-long 重试时决定丢弃多少历史
- Reactive compact 中按轮次逐步减少上下文
压缩后的上下文重建
压缩完成后,新的消息序列包含:
- Compact Boundary Marker - 系统消息,标记压缩发生的位置
- Summary Messages - 格式化后的摘要
- Messages to Keep - 保留的近期消息(Session Memory Compact 独有)
- File Attachments - 恢复的关键文件
- Hook Results - SessionStart hooks 的输出(如 CLAUDE.md 内容)
这个顺序是经过精心设计的:boundary marker 在最前面,让后续的加载器能快速定位压缩点;摘要紧随其后,为保留的消息提供上下文;文件附件和 hook 结果在最后,提供最新的工作环境信息。
可迁移模式
Claude Code 的上下文管理策略包含多个可以迁移到其他 LLM 应用中的通用模式:
1. 混合 Token 计数
不要只用 API 计数(太慢)或只用启发式估算(太不准确)。利用 API 响应中已有的 usage 数据作为锚点,只对增量部分做估算。这个模式适用于任何需要实时 token 预算的场景。
2. 多级阈值 + 渐进式压缩
不同压力水平触发不同的压缩策略:
- 低压力(microcompact):清理旧工具输出,几乎无信息损失
- 中压力(session memory):利用已有的结构化记忆,无额外 API 成本
- 高压力(full compact):调用 LLM 生成全面摘要,有信息损失但最彻底
这比单一的"到了阈值就压缩"灵活得多。
3. 熔断器模式
对于可能失败的自动化操作(不仅限于压缩),设置最大连续失败次数来避免无效重试浪费资源。Claude Code 的 3 次失败熔断是基于生产数据(每天 25 万次无效 API 调用)校准的。
4. 分析-输出分离
让模型先在草稿区分析,再输出最终结果。通过 XML 标签区分两个阶段,最终只保留输出部分。这个模式在任何需要高质量 LLM 输出的场景中都适用。
5. 工具配对完整性保护
在任何涉及消息修剪的操作中,都需要确保 tool_use/tool_result 的配对关系不被破坏。这是 Claude API 的硬性约束,但同样的原则适用于任何使用 function calling 的 LLM 应用——任何 tool_result 都必须有对应的 tool_use 在上下文中。
6. 压缩后状态恢复
压缩不仅是替换消息。需要考虑:
- 缓存失效通知(避免误告警)
- 文件状态恢复(保持工作上下文)
- Hook 重执行(恢复环境配置)
- 元数据迁移(保持会话标识)
7. 利用缓存生命周期
Time-based microcompact 利用了一个洞察:当 prompt cache 已经因为时间过期而失效时,修改消息内容的成本为零(反正都要重写)。在设计任何涉及 prompt caching 的系统时,都应该考虑缓存冷/热状态对操作成本的影响。
总结
Claude Code 的上下文管理系统是一个精密的多层工程:
- Token 估算层用混合策略平衡精度和性能
- 压力检测层用四级阈值实现渐进式响应
- Microcompact层以最低成本处理工具结果膨胀
- Session Memory Compact层利用已有的结构化记忆避免额外 API 调用
- Full Compact层用精心设计的 prompt 确保高质量的有损压缩
- 状态恢复层确保压缩不会破坏工作环境的连续性
每一层的设计都来自生产环境的经验:p99.99 数据驱动的缓冲区大小、基于真实事故的熔断器、模型版本特定的防护措施。这不是理论上的优雅架构,而是在百万用户规模下打磨出来的实用工程。
下一篇,我们将探讨 Claude Code 的启动性能优化——另一个在大规模使用中至关重要的工程挑战。