Claude Code 源码深度拆解④ | 三层压缩策略:把Context Window变成可管理资源

上下文不是越大越好,是越干净越好。3272次连续失败的熔断器故事,从这里开始。
一、引言:Context Window的本质焦虑
在上一期拆解QueryEngine时,我们留下了一个悬念:当TAOR循环不断累积消息,token数量逐渐逼近模型上限时,系统该怎么办?
这个问题背后是一个根本性的焦虑——Context Anxiety(上下文焦虑)。
Anthropic的研究团队发现:当模型感知到上下文窗口即将耗尽时,它会倾向于“草草收尾”——跳过验证步骤、省略边缘情况、给出不完整的方案。就像一个知道自己只剩5分钟考试时间的学生,开始跳步骤、走捷径。
200,000 token的窗口看起来很宽裕,但在实际编程会话中消耗极快:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
一个500行的TypeScript文件大约消耗3,000 token。一次典型的编程会话,读取10-20个文件、进行几轮工具调用、接收模型的长响应,很容易在30分钟内逼近上限。
核心矛盾:
- 必须保留历史——AI需要知道之前做了什么、用户的需求是什么
- 必须压缩历史——token有硬上限,不可能无限增长
- 压缩有代价——每次压缩都要调用AI写摘要,消耗时间和费用
Claude Code的答案是三层递进式压缩策略,从轻量到重量依次尝试,能不用AI就不用AI,能少处理就少处理。
二、三层压缩架构全景
2.1 压缩触发链路
┌─────────────────────────────────────────────────────────────────┐│ QueryEngine 主循环 ││ ││ 每轮迭代前 → TokenBudgetManager.checkBudget() ││ ↓ ││ ┌──────────────────┐ ││ │ 预算状态判断 │ ││ └──────────────────┘ ││ / | \ ││ <80% / 80-95%| >95% \ ││ / | \ ││ 正常执行 预警标记 触发压缩流程 ││ | ↓ ││ | ┌────────────────┐ ││ | │ 三层压缩策略 │ ││ | │ 依次尝试 │ ││ | └────────────────┘ ││ | ↓ ││ └──────→ 压缩后继续循环 │└─────────────────────────────────────────────────────────────────┘
对于200K上下文的模型,自动压缩在约167,000 token时触发(预留约13,000 token缓冲)。
2.2 三层策略对比
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
设计哲学:能不调用AI就不调,能复用已有摘要就复用,最后迫不得已才让AI现场写摘要。
三、第一层:MicroCompact —— 零成本的“轻打扫”
3.1 触发机制与设计思路
MicroCompact是最轻量的一层,有两个触发场景:
场景一:时间触发当距离上次AI响应超过一定时间(源码中是5分钟左右),Anthropic服务端的Prompt Cache已经失效。继续保留那些旧的工具调用结果只会浪费空间,因为缓存失效后它们不再有任何复用价值。
场景二:缓存感知触发在正常响应过程中,如果检测到消息中有大量工具调用结果且token数较高,也会触发此层。
3.2 核心实现逻辑
// microCompact.ts 核心逻辑(基于源码推断)const COMPACTABLE_TOOLS = [ 'FileRead', // 文件读取结果(可能很长) 'Bash', // 命令行输出(可能很长) 'Grep', // 搜索结果 'Glob', // 文件列表 'WebSearch', // 网络搜索结果];function microCompact(messages: Message[]): Message[] { const now = Date.now(); for (const msg of messages) { // 只处理工具结果消息 if (msg.role !== 'tool') continue; // 找到对应的工具调用 const toolCall = findToolCallByTimestamp(msg.tool_call_id); if (!toolCall) continue; // 判断是否应该清空 const age = now - toolCall.timestamp; const shouldClear = age > CACHE_EXPIRY_MS || // 时间过期(约5分钟) isCompactableTool(toolCall.name); // 或属于可压缩工具 if (shouldClear && msg.content.length > 0) { // 关键:不删除消息,只替换内容 msg.content = '[Old tool result content cleared]'; } } return messages;}
3.3 为什么“不删除消息”?
这是一个精妙的设计细节:
// 消息结构的不变量interface MessagePair { assistant: { role: 'assistant', tool_calls: [...] }; // 工具调用请求 tool: { role: 'tool', tool_call_id: string, content: string }; // 工具结果}// ❌ 错误做法:删除tool消息// API会报错:tool_use without corresponding tool_result// ✅ 正确做法:保留消息结构,只清空内容// API检查通过,但token消耗大幅降低
AI模型API要求每个tool_use(工具调用请求)必须有对应的tool_result(工具调用结果),它们必须成对出现。删除消息会破坏这个不变量。所以只能清空内容,保留消息骨架。
Java类比:类似于把Map<String, Object>中的value设为null,但保留key——否则外部代码遍历key时会出错。
3.4 设计评价
好在哪:
- 零API成本
:纯本地字符串操作,不消耗任何配额 - 结构安全
:保留消息骨架,不破坏API协议 - 智能判断
:结合时间和工具类型,不盲目清空 - 透明高效
:用户无感知,自动在后台完成
四、第二层:SessionMemory Compact —— 复用已有摘要
4.1 设计动机
MicroCompact只能清理工具结果,无法压缩对话的核心内容(用户的指令、模型的决策、关键的错误信息)。当上下文真正逼近上限时,需要更彻底的压缩。
但直接调用AI生成摘要有一个问题:重复劳动。
Session Memory是一个独立的后台服务,它在会话进行过程中持续提取关键信息,维护一份结构化的会话摘要。当需要压缩时,如果这份摘要已经存在,直接拿来用就行了——何必再调用一次AI?
4.2 SessionMemory Compact的执行流程
// sessionMemoryCompact.ts(基于源码推断)async function sessionMemoryCompact( messages: Message[], context: ExecutionContext): Promise<CompactResult | null> { // 1. 检查Session Memory是否可用 const sessionMemory = await getSessionMemory(); if (!sessionMemory || !sessionMemory.hasContent()) { return null; // 不可用,让上层走Full Compact } // 2. 获取“上次总结到哪条消息”的标记 const lastSummarizedId = getLastSummarizedMessageId(); // 3. 计算需要保留的近期消息 const recentMessages = getMessagesAfter(lastSummarizedId, messages); // 保护机制:至少保留5条消息、至少10,000 token const minMessages = 5; const minTokens = 10000; let protectedMessages = recentMessages; if (recentMessages.length < minMessages) { protectedMessages = takeLast(messages, minMessages); } const protectedTokens = estimateTokens(protectedMessages); if (protectedTokens < minTokens) { // 往前多取一些消息直到满足最低token保护 protectedMessages = expandToMinTokens(messages, protectedMessages, minTokens); } // 4. 组装压缩后的消息列表 const compactedMessages: Message[] = [ // 首先:Session Memory摘要(作为系统消息或用户消息) { role: 'user', content: `[Previous session summary]\n${sessionMemory.content}`, }, // 然后:CompactBoundaryMessage(标记压缩边界) { role: 'system', content: '--- Context compacted here ---', isBoundary: true, }, // 最后:保留的近期消息(原封不动) ...protectedMessages, ]; // 5. 重置位置标记(因为旧消息UUID已不存在) setLastSummarizedMessageId(undefined); return { messages: compactedMessages, method: 'sessionMemory' };}
4.3 关键设计:保护近期消息
// 保护机制的核心逻辑const PROTECTION_RULES = { minMessages: 5, // 至少保留5条消息 minTokens: 10000, // 至少保留10,000 token preserveUserMessages: true, // 用户消息优先保留 preserveErrors: true, // 错误消息优先保留};// 模型最终看到的是:// [Session Memory摘要] + [边界标记] + [保留的近期消息]// // 这意味着:最近的对话上下文没有丢失,// 模型既能看到“历史摘要”,又能看到“最近发生了什么”
这个设计好在哪里?
- 零额外API成本
:复用Session Memory后台服务已经生成的摘要 - 保留连贯性
:近期消息原封不动保留,模型不会感到“断片” - 智能降级
:如果Session Memory不可用,自动fallback到Full Compact - 双重保护
:minMessages + minTokens确保不会因压缩过度丢失上下文
4.4 与Session Memory后台服务的协同
// SessionMemory服务的独立工作流程class SessionMemoryService { private lastSummarizedMessageId: string | undefined; private extractionInProgress = false; // 后台定期触发(由QueryEngine在空闲时调用) async maybeExtract(messages: Message[]): Promise<void> { // 触发条件判断 if (!this.shouldExtract(messages)) return; // 并发控制:防止多个提取任务同时运行 if (this.extractionInProgress) { await this.waitForExtraction(); return; } this.extractionInProgress = true; try { // Fork一个子Agent来提取记忆(不阻塞主循环) const extracted = await this.runForkedAgent(messages); // 更新记忆文件 await this.updateMemoryFile(extracted); // 更新位置标记 this.lastSummarizedMessageId = getLastMessageId(messages); } finally { this.extractionInProgress = false; } } private shouldExtract(messages: Message[]): boolean { // 累计token超过10,000 // 且距上次提取新增token超过5,000 // 且距上次提取的工具调用超过3次 // 且最近一轮没有工具调用(说明对话进入总结阶段) return checkConditions(messages, this.lastSummarizedMessageId); }}
这个设计体现了关注点分离:压缩是压缩,记忆提取是记忆提取,两者独立运行,但在压缩时可以协同。
五、第三层:Full Compact —— 最后的防线
5.1 触发场景
当SessionMemory Compact不可用时(比如手动压缩时用户提供了自定义指令,或Session Memory文件损坏),系统走Full Compact路径。
// compact.ts 中的标准压缩(基于源码推断)async function fullCompact( messages: Message[], options: CompactOptions): Promise<CompactResult> { // 1. 构建压缩提示词 const compactPrompt = buildCompactPrompt(options); // 2. 调用AI生成摘要 const summary = await callModelForSummary(messages, compactPrompt); // 3. 全部替换:只保留摘要,不保留近期消息 const compactedMessages: Message[] = [ { role: 'user', content: `[Conversation summary]\n${summary}`, }, { role: 'system', content: '--- Context compacted here ---', isBoundary: true, }, ]; // 4. 重新注入关键信息 await reinjectContext(compactedMessages, { recentlyAccessedFiles: getRecentlyAccessedFiles(5), // 最近5个文件,每个最多5000 token activePlans: getActivePlans(), skillSchemas: getRelevantSkillSchemas(), }); // 5. 重置工作预算 context.tokenBudgetManager.reset(50000); // 压缩后预算重置为50,000 token return { messages: compactedMessages, method: 'full' };}
5.2 结构化摘要的9段式模板
Full Compact生成的摘要是高度结构化的,确保关键信息不丢失:
## 核心请求用户要求实现用户认证模块,包括登录、注册、JWT验证## 关键概念- JWT with refresh token rotation- bcrypt for password hashing- Middleware pattern for route protection## 文件和代码- src/auth/login.ts: 实现登录逻辑- src/auth/register.ts: 实现注册逻辑 - src/middleware/auth.ts: JWT验证中间件## 错误和修复- TS2304: Cannot find name 'jwt' → 安装jsonwebtoken类型定义- 密码哈希问题 → 改用bcrypt.compare而非直接比较## 解决过程1. 创建auth模块结构2. 实现基础登录/注册3. 添加JWT中间件4. 修复类型错误5. 完成单元测试## 所有用户消息[完整保留所有用户原始消息——因为用户的每句话都可能包含隐性偏好]## 待办任务- 实现密码重置功能- 添加登录限流## 当前工作正在调试JWT刷新token逻辑## 下一步行动修复refresh token的过期时间设置
最关键的规则:所有用户消息必须完整保留。AI可能在第3轮被用户纠正了某个做法,压缩时若丢弃这条纠正,后续就会重蹈覆辙。
5.3 与SessionMemory Compact的关键区别
|
|
|
|
|---|---|---|
|
|
|
|
|
|
保留
|
不保留 |
|
|
|
|
|
|
|
|
设计意图:SessionMemory Compact更“温和”——模型不会感到上下文突然断裂。Full Compact更“彻底”——当需要完全重置上下文时使用。
六、熔断器:3272次失败教会我们的
6.1 问题的发现
2026年3月10日之前,Claude Code的自动压缩功能没有重试上限。
源码中的BigQuery数据分析注释记录了这个惨痛的教训:
// 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.const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3;
翻译一下:
-
1,279个会话出现了50次以上的连续压缩失败 -
最严重的一个会话连续失败了3,272次 -
全局每天浪费约25万次API调用
为什么会连续失败?因为上下文已经“坏掉了”——压缩操作本身需要消耗token来调用AI生成摘要,但如果上下文已经处于某种异常状态(比如消息结构损坏、token计数错误),压缩会一直失败,然后系统会一直重试,形成无限循环。
6.2 熔断器的实现
// compact.ts 中的熔断器(基于源码推断)class CompactionCircuitBreaker { private failureCount: number = 0; private readonly maxFailures: number = 3; private lastFailureTime: number = 0; private readonly cooldownMs: number = 60000; // 1分钟 async compact( messages: Message[], compactor: Compactor ): Promise<Message[]> { // 检查熔断状态 if (this.failureCount >= this.maxFailures) { const timeSinceLastFailure = Date.now() - this.lastFailureTime; if (timeSinceLastFailure < this.cooldownMs) { // 熔断器打开,拒绝执行 throw new Error( `Compaction circuit breaker is OPEN. ` + `${this.failureCount} failures in a row. ` + `Try again in ${Math.ceil((this.cooldownMs - timeSinceLastFailure) / 1000)}s.` ); } // 冷却期已过,进入半开状态 this.failureCount = 0; } try { const result = await compactor.compact(messages); // 成功,重置计数器 this.failureCount = 0; return result; } catch (error) { // 失败,增加计数 this.failureCount++; this.lastFailureTime = Date.now(); console.warn( `[CircuitBreaker] Compaction failed (${this.failureCount}/${this.maxFailures}): ${error}` ); throw error; } }}
6.3 这个设计教会我们什么?
第一,demo只需要跑通,生产系统需要知道失败时怎么止损。
很多Agent原型在处理上下文超限时,要么直接崩溃,要么简单粗暴地截断。但在生产环境中,你需要考虑:压缩失败了怎么办?连续失败怎么办?如何防止雪崩?
第二,用数据驱动设计决策。
熔断器的阈值3不是拍脑袋定的,而是基于BigQuery中真实的失败数据分析得出的。如果阈值设得太高(比如10次),仍会浪费大量API调用;设得太低(比如1次),可能因为偶发故障导致压缩过早放弃。
第三,失败是常态,优雅降级是能力。
熔断器打开后,系统不会崩溃——它只是告诉上层“压缩暂时不可用”,让上层决定是等待还是用其他方式继续。
七、完整压缩流程串联
将三层压缩策略串联起来,完整的自动压缩流程如下:
┌─────────────────────────────────────────────────────────────────┐│ AutoCompact 完整流程 │└─────────────────────────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ Token预算检查:接近上限? │ └───────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ │ ▼ ▼ 未达上限 达到上限(约167k token) 正常执行 │ ▼ ┌───────────────────────────────┐ │ 熔断器检查:允许压缩? │ └───────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ │ ▼ ▼ 熔断器打开 熔断器关闭 抛出异常 │ ▼ ┌───────────────────────────────┐ │ 第一层:MicroCompact │ │ (已在每轮自动执行) │ └───────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ 第二层:SessionMemory Compact│ │ 检查Session Memory是否可用 │ └───────────────────────────────┘ │ ┌───────────────┴───────────────┐ │ │ ▼ ▼ 可用 不可用 使用已有摘要 继续下一层 │ │ │ ▼ │ ┌───────────────────────────────┐ │ │ 第三层:Full Compact │ │ │ 调用AI生成摘要 │ │ └───────────────────────────────┘ │ │ └───────────────┬───────────────┘ │ ▼ ┌───────────────────────────────┐ │ 更新熔断器状态 │ │ - 成功:计数器归零 │ │ - 失败:计数器+1 │ └───────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ 重新注入关键上下文 │ │ - 最近访问文件 │ │ - 活跃计划 │ │ - 技能Schema │ └───────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ 重置Token预算为50,000 │ │ 继续TAOR循环 │ └───────────────────────────────┘
八、对Agent开发的核心启示
8.1 四条可迁移的设计原则
原则一:分层压缩,能省则省
// ❌ 错误做法:每次压缩都调用AIasync function compress(messages) { return await ai.summarize(messages); // 昂贵!}// ✅ 正确做法:分层尝试async function compress(messages) { // 先尝试轻量级本地清理 const micro = microCompact(messages); if (estimateTokens(micro) < threshold) return micro; // 再尝试复用已有摘要 const cached = getCachedSummary(); if (cached) return buildWithCached(cached, messages); // 最后才调用AI return await ai.summarize(messages);}
原则二:保护近期上下文
// 压缩时永远不要丢弃最近N条消息const RECENT_PROTECTION = { minMessages: 5, minTokens: 10000,};// 模型应该看到“摘要 + 近期消息”,而非“只有摘要”
原则三:熔断器是生产系统标配
// 任何可能失败的操作都应该有熔断器class CircuitBreaker { private failures = 0; private readonly maxFailures = 3; async execute<T>(fn: () => Promise<T>): Promise<T> { if (this.failures >= this.maxFailures) { throw new Error('Circuit breaker open'); } try { const result = await fn(); this.failures = 0; return result; } catch (e) { this.failures++; throw e; } }}
原则四:压缩后重新注入环境
// 压缩清空了上下文,但项目环境信息需要重新注入async function afterCompact(messages: Message[]): Promise<Message[]> { // 重新告诉模型它在哪个项目、有哪些文件 await injectProjectContext(messages); await injectRecentFiles(messages); await injectActivePlans(messages); return messages;}
8.2 一个简化的压缩实现
// 你可以立刻使用的简化压缩策略interface CompactStrategy { name: string; cost: 'free' | 'cheap' | 'expensive'; compact: (messages: Message[]) => Promise<Message[]>;}class ContextCompactor { private strategies: CompactStrategy[] = [ { name: 'micro', cost: 'free', compact: async (msgs) => this.microCompact(msgs), }, { name: 'cached', cost: 'cheap', compact: async (msgs) => this.cachedCompact(msgs), }, { name: 'full', cost: 'expensive', compact: async (msgs) => this.fullCompact(msgs), }, ]; private circuitBreaker = new CircuitBreaker(3, 60000); async compact(messages: Message[], threshold: number): Promise<Message[]> { const currentTokens = estimateTokens(messages); if (currentTokens < threshold) return messages; for (const strategy of this.strategies) { try { const result = await this.circuitBreaker.execute(() => strategy.compact(messages) ); const newTokens = estimateTokens(result); if (newTokens < threshold) { console.log(`Compact success via ${strategy.name}: ${currentTokens} → ${newTokens} tokens`); return result; } } catch (e) { console.warn(`Strategy ${strategy.name} failed:`, e); // 继续尝试下一个策略 } } // 所有策略都失败了 throw new Error('All compaction strategies failed'); } private microCompact(messages: Message[]): Message[] { // 清空旧的工具结果 return messages.map(msg => { if (msg.role === 'tool' && isOldToolResult(msg)) { return { ...msg, content: '[Content cleared]' }; } return msg; }); } private async cachedCompact(messages: Message[]): Promise<Message[]> { const cached = await this.getCachedSummary(); if (!cached) throw new Error('No cached summary'); const recent = this.getRecentMessages(messages, 5); return [ { role: 'user', content: `[Summary]\n${cached}` }, { role: 'system', content: '---' }, ...recent, ]; } private async fullCompact(messages: Message[]): Promise<Message[]> { const summary = await this.callAIForSummary(messages); return [ { role: 'user', content: `[Summary]\n${summary}` }, ]; }}
九、小结与下一期预告
三层压缩策略是Claude Code最具工程价值的模块之一。它告诉我们:
- Context Window是需要主动管理的稀缺资源
——不是越大越好,是越干净越好 - 分层策略是成本与效果的平衡
——能不用AI就不用,能复用就复用 - 熔断器是生产系统的标配
——3272次失败的教训值得铭记 - 压缩不是终点,压缩后要重建环境感知
——重新注入文件、计划、Schema
这套设计之所以值得学习,不是因为它用了多么复杂的算法,而是因为它对边界情况的处理——缓存失效了怎么办、压缩失败了怎么办、连续失败了怎么办。demo只需要跑通happy path,生产系统需要知道所有unhappy path怎么处理。
下一期,我们将进入多Agent编排的世界,看Claude Code如何用Coordinator-Worker架构+邮箱模式,让多个子Agent并行工作而不失控。那个验证Agent的自我欺骗检测列表(“reading is not verification”“the implementer is an LLM,verify independently”),将成为第五期的亮点。
上一篇回顾:Claude Code 源码深度拆解③ | QueryEngine:46000行的TAOR循环如何运转
下一篇预告:Claude Code 源码深度拆解⑤ | 多Agent编排:Coordinator-Worker+邮箱模式详解
延伸思考:如果你的Agent需要支持无限长的会话,除了压缩,还有什么其他策略?提示:想想Sub-Agent隔离、结果磁盘化、LRU缓存……欢迎在评论区分享你的思路。
夜雨聆风