Claude Code 的上下文压缩:五层级联与免费摘要的艺术
200K context window 听起来很大, 但一个中等复杂度的编程 session, 读几十个文件, 跑几轮 grep, 执行一些 bash 命令, 就能轻松吃掉大半个窗口. 压缩是必须的, 但压缩本身又要花钱: 你需要一次 LLM 调用来生成摘要, 而这次调用的输入就是你要压缩的那整段上下文. 这形成了一个有趣的工程权衡: 压得太早浪费信息, 压得太晚窗口爆了, 压缩本身的成本也不能忽略. Claude Code 对此给出的答案是一套多层级联系统: 能不压就不压, 能便宜压就便宜压, 实在不行才动用 LLM.
第零层: 大结果落盘
在压缩发生之前, Claude Code 先从源头控制进入 context 的数据量. 当工具返回结果超过阈值(默认 50K 字符)时, 完整结果写入磁盘文件, context 中只保留一个约 2KB 的预览加文件路径. 阈值可以通过远程配置按工具名单独覆盖, 但有一个全局兜底值. 写入采用 O_CREAT|O_EXCL 模式, 因为 tool_use_id 是唯一的, 同一个结果不会被写两次, 避免了 microcompact 重放消息时的重复写入. 伪代码大致如下:
1 | def maybe_persist_large_result(tool_result, tool_name, threshold): |
这里有一个细节值得注意: Read 工具明确把自己的阈值设为 Infinity, 从而豁免了落盘机制. 原因很直白: 把 Read 的输出持久化到一个文件, 然后模型再用 Read 去读这个文件, 这就成循环了. Read 自身通过 maxTokens 参数控制输出大小, 不需要外层再管.
同样的思路还有一个消息级别的聚合预算: 单条 user message 中所有 tool_result 块的总大小上限是 200K 字符. 这防止了 N 个并行工具各返回 40K, 合在一起变成 400K 的情况. 聚合预算有一个精心设计的状态管理机制: 每个 tool_result 一旦被"看过"(无论是否被替换), 其命运就被冻结. 之前没被替换的结果以后也不会被替换, 因为那会改变已缓存的 prompt 前缀. 之前被替换的结果每次都使用完全相同的替换文本(从缓存的 Map 中读取, 零 I/O), 保证字节级一致性.
第一层: Cached Microcompact
这是从行为推断出的最精巧的一层. Claude Code 使用了 Anthropic API 的 cache_edits 能力, 在服务端缓存中直接删除旧的工具结果, 而不会让缓存前缀失效. 本地消息完全不修改.
1 | API Server Cache |
只有特定工具的结果会被清理: Bash, Read, Grep, Glob, WebFetch, WebSearch, FileEdit, FileWrite. 系统维护一个按时间排序的工具调用 ID 列表, 当数量超过阈值时, 保留最近的 N 个, 把更早的通过 cache_edits 删除. 由于只修改服务端缓存而不改本地消息内容, 这个操作对 prompt cache 的命中率几乎没有影响, 这正是它的价值所在.
有一个隔离方面的考虑: cached microcompact 只在主线程上运行. 如果 forked agent(比如 session_memory 或 prompt_suggestion)也往全局状态里注册 tool_result, 主线程就会尝试删除自己对话里不存在的工具, 造成混乱. 删除成功后还会通知 prompt cache break detector, 告诉它接下来 cache read 下降是正常的, 不要报警.
1 | def cached_microcompact(messages, state, config): |
第二层: Time-Based Microcompact
当用户离开超过 60 分钟再回来时, 服务端的 prompt cache 基本已经过期了(Anthropic 的缓存 TTL 是 1 小时). 既然缓存已经冷了, 整个前缀都要重新写入, 那不如趁机把旧的工具结果直接清空, 减少重写的数据量.
在工具结果清理之外, API 层面还有一个 thinking block 的清理策略.
正常情况下保留所有 thinking block(它们是模型推理过程的记录,
对后续回复质量有帮助), 但当检测到超过 1 小时的空闲后, 只保留最近一轮的
thinking. 这通过 API 的 clear_thinking_20251015 context
edit 实现, 和工具清理是独立的两个策略, 可以组合使用.
1 | def time_based_microcompact(messages, query_source): |
跟 Cached Microcompact 不同, 这一层直接修改本地消息内容. 因为缓存反正是冷的, 没有前缀一致性需要保护. 两层的触发条件也互斥: time-based 先运行, 如果触发了就跳过 cached MC, 避免在冷缓存上做无意义的 cache_edits 操作. 触发后还会重置 cached MC 的全局状态, 因为之前注册过的工具 ID 对应的服务端缓存条目已经不存在了, 留着只会造成后续的幽灵删除.
第三层: Session Memory Compact
这一层是整个系统中最有意思的设计. Session Memory
是一个后台进程, 在会话进行过程中持续维护一份结构化的 markdown 笔记文件.
当需要压缩时, 直接拿这份笔记当摘要用, 不需要额外的 LLM 调用. 提取过程被
sequential() 包装, 保证不会有两个提取并发运行.
模板支持用户自定义: 把自己的模板放在
~/.claude/session-memory/config/template.md, 提取 prompt
也可以覆盖.
笔记文件的模板长这样:
1 | # Session Title |
后台提取进程通过 forked agent 运行, 和主会话共享 prompt cache 前缀. 它被限制只能对笔记文件使用 Edit 工具, 不能干别的. 提取的触发条件是 token 阈值和工具调用次数的双重门槛, 还要求最近一轮 assistant 回复中没有工具调用(在对话间隙提取, 不打断工作流). 初始化阈值确保会话刚开始时不会过早触发提取, 更新间隔则通过远程配置动态调整.
计算"保留哪些消息"时有一个 API 不变量需要维护: tool_use 和 tool_result 必须成对出现. 如果切分点恰好落在一对 tool_use/tool_result 之间, 就需要往前扩展到包含对应的 tool_use. 类似地, streaming 产生的同一 message.id 的多个 assistant 消息(thinking, tool_use 等)也不能被拆散, 否则 normalizeMessagesForAPI 合并时会丢失 thinking 块.
压缩时的逻辑也很精细, 不是丢弃所有旧消息, 而是保留最近的一段:
1 | def session_memory_compact(messages, last_summarized_id): |
这里有一个边界条件的处理比较巧妙: 如果是 resumed session(用户重新打开了一个之前的会话), lastSummarizedMessageId 不存在, 但笔记文件可能有内容. 这时把所有消息都当作待压缩的, 但仍然用已有的笔记作为摘要.
每个 section 的大小也有限制(每节 2000 token, 总共 12000 token), 超出的部分会被截断, 并附上完整笔记文件的路径让模型自行查阅.
第四层: Full Compact
当 Session Memory 不可用或者压缩后仍超阈值时, 系统回退到完整的 LLM 摘要. 这是最昂贵但最彻底的压缩方式. Session Memory Compact 可能失败的原因包括: feature flag 未开启、笔记文件还是空模板(会话太短还没有提取过)、上次提取的消息 ID 在当前消息列表中找不到(消息被修改过)、或者压缩后的 token 数仍然超过自动压缩阈值(笔记加保留消息太大).
Full Compact 使用 forked agent 来执行,
这个设计的核心目的是共享主会话的 prompt cache 前缀. Fork 继承了主会话的
system prompt, tools, 以及完整的消息历史作为 context,
只在末尾追加一条压缩指令. 这样 API 调用的大部分输入都能命中已有的缓存,
只需为压缩指令和输出付费. 一个关键约束是 fork 不能设置 maxOutputTokens,
因为这个参数会通过 Math.min(budget, maxOutputTokens-1) 影响
thinking config 中的 budget_tokens, 而 thinking config 是缓存 key
的一部分, 任何不匹配都会导致缓存失效. 如果 forked agent
路径失败(比如没有返回文本), 系统回退到普通的 streaming 路径,
这时才可以安全设置 maxOutputTokens, 因为不再共享缓存.
摘要 prompt 采用两阶段结构. 第一阶段是一个
<analysis> 思考块, 让模型按时间线梳理整个对话;
第二阶段是 <summary> 正式摘要, 分 9 个 section:
Primary Request, Key Technical Concepts, Files and Code Sections, Errors
and Fixes, Problem Solving, All User Messages, Pending Tasks, Current
Work, Optional Next Step. 关键的是, analysis 块在提取摘要后会被删掉,
它的作用纯粹是提高摘要质量的草稿纸:
1 | def format_compact_summary(raw_summary): |
发送给压缩 API 的消息还会经过预处理: 图片块被替换为
[image] 文本标记, 文档块替换为 [document].
图片对生成文本摘要没有帮助, 而在 CCD(Claude Code Desktop)session
中用户频繁贴截图, 不剥离的话压缩请求本身就可能超长. 已经被压缩过的旧
skill 发现/列表 attachment 也会被过滤掉, 因为压缩后会重新注入.
压缩 prompt 的开头有一段非常强硬的 NO TOOLS 声明:
1 | CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. |
这不是多余的. 因为 fork 继承了主会话的完整工具集(为了缓存 key 匹配), 模型有时候忍不住想调用工具. 在 maxTurns: 1 的限制下, 一次被拒绝的工具调用意味着没有文本输出, 整个压缩就失败了. 从行为观察来看, 这个问题在 Sonnet 4.6 上尤其明显, 失败率约 2.79%, 而在 4.5 上只有 0.01%. 所以 prompt 的开头和结尾都有工具禁用声明, 首尾呼应.
压缩请求自身也可能触发 prompt-too-long 错误——毕竟你是把即将溢出的 context 发给 API 做摘要, 再加上压缩指令本身, 总长度可能超过 API 限制. 处理方式是按 API round 分组, 从最旧的 round 开始丢弃, 直到释放够的 token. 最多重试 3 次, 每次丢弃掉的消息不会再恢复. 如果连丢弃都不够, 就只能报错了. 这是一个有损但比卡死好的 escape hatch.
级联决策流
理解了每一层之后, 来看它们如何组合. autoCompactIfNeeded 是自动压缩的入口, 在每次 query 循环结束后被调用. 它的设计体现了一个重要原则: session memory 和 compact 的 forked agent 不应该触发递归压缩(querySource 为 session_memory 或 compact 时直接返回), 否则会死锁.
1 | autoCompactIfNeeded(messages, context): |
自动压缩的阈值计算是
context_window - max_output_tokens - 13K buffer. 对于 200K
窗口, 大约在 167K 左右触发. 手动 /compact 命令走类似的路径,
但不受 circuit breaker 限制, 且支持自定义压缩指令.
circuit breaker 的引入有一个具体的数据支撑: 曾经有 1279 个 session 出现过 50 次以上的连续压缩失败(最多达到 3272 次), 全局每天浪费约 25 万次 API 调用. 设置 3 次上限后, 失败的 session 会停止无谓的重试. 成功一次就清零, 这样不影响正常的间歇性失败恢复.
microcompact 和 autoCompact 之间的关系也值得说清楚. microcompact(第一层和第二层)在每次 API 调用之前运行, 作为预处理; autoCompact(第三层和第四层)在每次 API 调用之后运行, 检查是否需要更激进的压缩. 两者的职责是互补的: microcompact 做轻量的增量清理, autoCompact 做重量级的全局压缩.
压缩过程本身也需要保活. 一次 full compact 的 API 调用可能持续 5-10 秒甚至更长, 在这段时间内没有其他消息流经 WebSocket 连接. 对于远程 session, 服务器可能因为空闲超时而断开连接. 系统每 30 秒发送一次心跳信号: 既包括 PUT /worker 的 HTTP 心跳, 也包括重新发射 compacting 状态事件以保持 SDK 事件流活跃. 压缩结束后摘要消息中还包含了 transcript 文件的路径, 告诉模型如果需要压缩前的精确细节(代码片段、错误消息), 可以去读完整记录.
压缩后的恢复
压缩不只是删除旧消息那么简单. 一次 full compact 之后, 模型的上下文中只剩下一段摘要文本和少量保留的消息. 所有之前读过的文件状态、加载过的工具指令、plan 文件内容, 全部丢失. 如果不做恢复, 模型的第一个动作大概率是重新 Read 刚才还在看的文件.
恢复操作包括: 重新读取最近访问的文件(最多 5 个, 总 token 预算 50K, 单文件上限 5K token), 这样模型不需要再手动 Read 刚才还在看的代码. 文件选择基于 readFileState 中的时间戳排序, 且会排除 plan 文件和 CLAUDE.md 这类通过其他机制恢复的文件. 如果保留消息中已经包含某个文件的 Read 结果, 也会跳过, 避免重复注入浪费 token.
重新注入 plan 文件(如果有的话), 重新注入已加载的 skill 内容(按最近使用排序, 每个 skill 限 5K token, 总限 25K), 重新注入 deferred tools 和 MCP 指令的 delta attachment, 以及执行 SessionStart hooks 恢复 CLAUDE.md 等上下文. skill 的截断策略是保留文件头部, 因为使用说明通常写在开头.
还有一个容易被忽略的细节: 压缩后重新追加 session
metadata(自定义标题、标签), 确保它落在 16KB 的尾部窗口内.
否则后续消息会把 metadata 推出窗口, 导致 --resume
显示自动生成的标题而非用户设置的名字. 另一个细节是压缩后通知 prompt
cache break detector 重置基线, 避免压缩导致的 cache read
下降被误判为异常.
设计哲学
1 | Cost |
整个系统的核心思想可以概括为"尽可能延迟, 尽可能便宜, 分层递进". 从零成本的磁盘落盘, 到近零成本的缓存编辑, 到零 LLM 成本的 Session Memory, 最后才动用完整的 LLM 摘要. 每一层都在试图避免调用下一层. 这种分层策略的一个额外好处是容错: 如果某一层失败, 系统自然回退到下一层, 而不是直接报错.
另一个值得注意的设计选择是对 prompt cache 的极度尊重. Cached Microcompact 的整个存在理由就是不破坏缓存前缀. Full Compact 通过 forked agent 共享缓存. Session Memory 的后台提取也通过 fork 共享缓存. Time-Based Microcompact 只在确认缓存已过期时才修改内容. 几乎每一个设计决策的背后都有"这样做会不会让缓存 miss"这个考量. 在 token 按量计费的世界里, prompt cache 命中率直接影响成本, 而压缩系统如果为了节省 context 反而破坏了缓存, 那就是在拆东墙补西墙.
还有一类不太起眼但很实际的工程决策: 空工具结果的处理.
有些工具(静默成功的 shell 命令、返回 content:[] 的 MCP
服务器)会产生空输出. 这看似无害, 但在某些模型上, prompt 尾部的空
tool_result 会让模型误判为对话边界并提前终止回复.
系统把所有空结果替换为一条
(toolName completed with no output) 的标记,
给模型一个明确的锚点.
Session Memory 的设计尤其值得玩味. 它本质上是把理解会话内容这个昂贵操作的成本, 摊薄到了整个会话的生命周期中——每隔一段时间做一小块增量提取, 到需要压缩时, 这笔投资的回报就是一次免费的压缩. 它把 summarization 从"如何压缩 200K context"变成了"如何维护一份始终更新的笔记". 如果你觉得这听起来像是 LLM 版本的增量备份, 那大概就是这个意思.
当然, 在这套精致的级联系统之外, 还有一个朴素的事实: 200K 窗口在重度编程 session 中就是不够用的, 压缩永远意味着信息损失. 所有这些工程努力的终极目标, 不过是让"不够用"这件事发生得晚一点, 代价小一点, 用户感知弱一点. 某种意义上, 这整套系统是对 context window 有限性的一封工程情书——写得越精美, 越说明问题本身无解.