乐于分享
好东西不私藏

Claude Code源码揭秘②:万字解析上下文压缩

Claude Code源码揭秘②:万字解析上下文压缩

之前的文章Claude Code源码揭秘①:用户输入处理开启了对Claude Code源码的解读,今天要说的是上下文压缩与替换,也是harness里的重要内容。

今天的内容会涵盖到的要点有:

  • 工具结果替换

  • Micro Compact

  • Snip Compact

  • Context Collapse

  • Auto Compact

  • 提示词缓存

文章较长,建议收藏观看。

概览

除了上图显示的压缩流程外,这里对消息视图这个概念也做一个提纲挈领的简述,有几个容易混淆的概念:
  • Raw messages。初始有效的消息状态,在内存中是一组消息数组。这个数组在每一轮的对话中都会根据对话中的变化,被更新成新的state.messages。
  • Model-Facing View。这是“真正准备发给模型”的那份消息视图。它不是原始消息原封不动拿去发,而是经过投影/过滤后的版本,过滤后的结果往往会成为下一轮对话的初始有效消息。例如:
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
  • API Payload View。在Model-Facing View之后的步骤,它是在API层通过cache editing处理过的最终请求体消息。
  • UI Scrollback View。这是用户在界面里还能滚动看到的历史。它不是单独一份新存储,而更像是对原始消息的另一种渲染投影。它会保留一些其实已经没有发给大模型的消息。
  • Transcript / JSONL View。这是而是正式产出的消息流与元数据事件的 append-only持久化日志。在会话中断,需要恢复会话时,根据这里面的metadata进行重放,以构建最新的消息状态。
下面的解释会时不时地引用这些概念。

工具结果替换

模型调用工具之后,系统会先判断“单个 tool result 是否过大”。如果过大,就把完整的工具结果落盘,并把返回给模型的内容替换成一个较短的预览,在Raw Messages层面改变了消息。
那如果每个结果单看都不算大,多个工具结果总和超标了吗?这一步检查发生在每轮发起对话之前在applyToolResultBudget中进行,会根据工具的大小来依次替换,直到工具结果没有超标,或者所有工具结果都被处理过。这一步在Model-Facing View层面进行。
另外一个值得注意的是,本轮已经被检查过的工具结果会被归入mustReapply或者frozen类别。
const { mustReapply, frozen, fresh } = partitionByPriorDecision(...)
  •  mustReapply:以前已经被替换过,这次必须再用同一个 replacement string
  •  frozen:以前见过但没替换过,以后也不能再动
  • fresh:第一次见到,可以参与新决策
这个机制说明,一旦某个工具结果被模型看过,它的状态已经被决定了,下一轮替换只是幂等沿用。为什么要这么设计呢?这是为了提示词缓存的稳定。我们会在下面解释这个概念。

Micro Compact

Microcompact也是针对工具结果的压缩,与前者不同的是它不会关注结果的内容,而是根据工具的id(tool_use_id)删除。
Microcompact的分类:

改变的消息视图

触发机制

TimeBasedMicrocompact

Model-Facing View

与上一条assistant message的时间间隔超过阈值

cachedMicrocompact

API payload View

count based(工具数量)

这个过程中比较复杂的是cachedMicrocompact,因为它不是直接改变内存的消息数组,而是将要删除的工具结果通过cache_edit发给API,在服务端视图里删除。在选定删除的工具之后,还有一系列的找稳定提示词前缀、重放之前几轮相同的替换结果的操作(与上一节的mustReapply和frozen概念类似)。

它工作的流程如下:

1、当已发送到 API 的 compactable tool results 数量超过某个阈值时,选中最旧的一批。
2、分辨这个工具结果有没有被之前处理过。如果是未被处理过的结果,分类为newCacheEdits,否则pinnedEdits。pinnedEdits是以前请求已经发过、以后每次都要按原位置重发的删除指令。
3、在发给API的时候,要保持提示词缓存的前缀稳定。找到最后一条消息的最后一个合格block,加 cache_control marker。这一步告诉服务端:这个请求的 cached prefix 截止到这里。
4、将cached prefix之前(不包括cached prefix这条记录)的工具结果用cache_reference标记。
5、新增的删除指令插进最后一个 user message,结构是:
{    type'cache_edits',    edits: [        { type'delete', cache_reference: 'toolu_123' }    ]}
这个指令的插入位置规则比较复杂,依最后一个user message有没有tool result分为很多种情况,不过这些都是实现细节。你可以简单理解成就是在最后一个user message的content的尾部block中找位置插入,但是它不会新建一个message。

举例:一轮microcompact

0 user(question)1 assistant(tool_use A)2 user(tool_result A)3 user(comment)=== 第一轮 microcompact ===
分析:
1、A是新的工具结果,要删除。
2、找到最后一条message:3,加上cache_control marker。
3、这之前的工具结果标记上cache_reference。
4、新的cache_edit要插入到最后一条user message的block里
结果:
0 user(question)1 assistant(tool_use A)2 user(tool_result A, cache_reference="toolu_A")3 user(     cache_edits: delete "toolu_A",     text/comment + cache_control  )

举例:两轮microcompact

0 user(question)1 assistant(tool_use A)2 user(tool_result A)3 user(comment)= 第一轮 microcompact =4 user(question)5 assistant(tool_use B)6 user(tool_result B)7 assistant(tool_use C)8 user(tool_result C)= 第二轮 microcompact =
分析:
1、B和C是最新的工具结果,要删除
2、找到最后一条message:8,加上cache_control marker。
3、这之前的工具结果标记上cache_reference。注意:因为tool_result C在最后一条消息里,所以不删除!最后的删除决定就是B。我一开始看这个的时候有点疑惑,为什么要严格在cache_control marker之前而不是包含呢,但是代码的确是这么写的:
// Add cache_reference to tool_result blocks that are strictly before// the last cache_control marker. The API requires cache_reference to// appear "before or on" the last cache_control — we use strict "before"// to avoid edge cases where cache_edits splicing shifts block indices.for (let i = 0; i < lastCCMsg; i++) {....
4、旧的cache_edit(针对工具结果A)还是原样插入,此时是pinnedEdits
5、新的cache_edit(针对工具结果B)插入到最后一条user message中
结果:
0 user(question)1 assistant(tool_use A)2 user(tool_result A, cache_reference="toolu_A")3 user(       cache_edits: delete "toolu_A",   -----> pinned       text/comment    )4 user(question)5 assistant(tool_use B)6 user(tool_result B, cache_reference="toolu_B")7 assistant(tool_use C)8 user(    cache_edits: delete "toolu_B",   -----> new    tool_result C + cache_control    )
你可以看到,这里保留了第一轮microcompact的提示词前缀。这也引出我们接下来的话题:提示词缓存的重要性。

提示词缓存

为什么工具结果替换里,要用mustReapply和frozen来区分消息,达到重放幂等性呢?

为什么cachedMicrocompact不直接改本地消息,要用CacheEdits这样的方式曲线救国呢?

这里涉及到一个概念:提示词缓存(prompt cache)。

当你发送一个prompt时,模型会计算出它的“中间状态”(技术上称为 KV Cache)。

没有缓存时:每次对话,模型都要从第 1 个字符开始重新计算到第 N 个字符。

有缓存时:如果你请求的前缀(Prefix)部分和之前完全一致,模型会直接从硬盘或内存里把之前的计算结果拉出来。

一般来说,缓存部分的 Token 费用通常比正常处理便宜 50% 到 90%。当Agent设定很复杂(比如写了 2000 字的规则),而又需多轮对话的时候,缓存这些内容能让每次对话更省钱、回复更快。

这也是为什么不只claude code,主流的 AI 服务商都在用这个技术。

Snip Compact

刚才提到的技术都是针对工具结果的处理,接下来的技术是针对所有的消息类型。
Snip Compact的特点是:删除消息的中间区间。它是整个消息压缩机制中的第一个轻量级压缩:
  • 精确删除中间区段的历史消息,让这些消息不再进入当前活跃上下文
  • 但又不需要像 autocompact 那样把整段历史总结重写
  • 也不只是像 microcompact 那样只清理旧 tool_result
怎么知道哪一段”中间区间“可以删呢?源码的snipCompact.js中没有暴露具体方法,但是从源码中的蛛丝马迹中,我们可以推测:
1、SnipTool将消息编码成[id:xxxxxx] 标签
2、模型根据对话内容判断,哪些中间内容可以删除
3、之后,通过消息ID和SnipTool 指定某些消息/区段
4、Snip runtime 把这些引用解析成真实 UUID,最后生成 removedUuids,并持久化到transcript主消息流里
被删除的消息,还在Raw Messages和UI Scrollback View里,但是不会出现在Model-Facing View和API payload view里。如果对话中断,需要resume时,需要通过磁盘上的removedUuids来重放还原出最新视图。

Context Collapse

另一个技术,Context Collapase,也是没有改变Raw Messages本身,改变的是Model-Facing View的视图。但它与Snip Compact有一些区别:
  • Snip是在删历史,而Context Collapase是在进行上下文折叠,产生一个总结。
  • Snip生成的removedUuids,持久化到了transcript主消息流里。而Context Collapase生成的commit log,则有一套专门的transcript entry类型(marble-origami-commit,marble-origami-snapshot),更像独立的checkpoint系统,而不是挂靠在主消息流上。我推测是因为Context Collapase本身的metadata和重放规则更复杂,需要一个更定制化的机制。
举例说明它的工作机制:
假设User Message和Assistant Message历史是
[U1, A1, U2, A2, U3, A3, U4, A4, U5]
1、找到一个连续区间,代表已经完成的阶段性任务
2、把这些message放到staged queue,这是候选的折叠项
staged = [    {         startUuid: U2.uuid,         endUuid: A4.uuid,         summary: "之前已经完成了对 query.ts、microcompact、sessionStorage 的分析,结论是 ...",         risk: 0.12,         stagedAt: 1712345678901     }]
3、等 token 压力达到 commit 时机后,将折叠正式生效并落盘,commit log 里记成:
commit = {    firstArchivedUuid: U2.uuid,    lastArchivedUuid: A4.uuid,    summaryUuid: S1.uuid,    summaryContent: "<collapsedid='...'>之前已经完成了对 ... 的分析</collapsed>"}
4、projectView() 把模型视图投影成:
[U1, A1, S1, U5]
但底层 messages[](transcript里的消息) 还是:
[U1, A1, U2, A2, U3, A3, U4, A4, U5]
5、这份视图最终替代messagesForQuery,成为发给模型的最终视图
与Snip Compact一样,在对话中断、需要resume时,也需要从磁盘中找到持久化的commit log和snapshot log,来重建消息和当前状态。这两个log的格式是:
type'marble-origami-commit'collapseIdsummaryUuidsummaryContentsummaryfirstArchivedUuidlastArchivedUuidtype'marble-origami-snapshot'staged:  startUuid  endUuid    summary    risk    stagedAtarmedlastSpawnTokens

Auto Compact

这是整个压缩线路的最后一道防线,也是最重的。当前面的压缩机制都不能把上下文token控制在阈值以下时,会把当前会话压缩成“compact boundary + summary + 少量保留消息”这一套新的上下文基线。

提示词

具体的请求构建在getCompactPrompt函数,以下是完整字段:

请求发送与接收

通过提示词构建完消息后,准备发压缩请求给模型。
第一优先级:如果cache-sharing开启,走提示词缓存的forked-agent路径。forked-agent是从当前主会话上下文分叉出来跑的,本质上是一种subagent,继承父上下文前缀以共享提示词缓存(system, tools, model, messages prefix, thinking config),但内部可变状态(readFileState,setAppState等)默认隔离。
第二优先级:如果 cache-sharing 失败或关闭,走这个路径。这是一条精简请求路径,不走subagent。
接收回复时,用Symbol.asyncIterator作用一个streaming iterator并流式消费:
  • 当第一段文本开始时,把context状态切成responding
  • 之后统计 streamed 字符数,更新 responseLength
  • 收到 event.type === ‘assistant’,把它记成最终 response

准备新一轮对话

拿到模型回复后,还需要一系列操作来做book-keeping,并准备新一轮对话的开启:

1、清理上下文状态
readFileState.clear()loadedNestedMemoryPaths.clear()
2、补充运行环境相关的attachments信息,例如:
fileAttachmentsplanAttachmentskillAttachment...
3、跑SessionStart Hook。这个hook其实在settings.json 里可以配置,比如:
{  "hooks": {    "SessionStart": [      {        "matcher": "compact",        "hooks": [          {            "type": "command",            "command": "bash .claude/hooks/session-start.sh"          }        ]      }    ]  }}
4、构造真正的 compact 产物:boundary + summary message,其中的summary message会被塞入新一轮对话的开头
5、计算 compact 后的 token 和 telemetry 等
6、跑PostCompact hooks
7、最终返回CompactionResult
export interface CompactionResult {  boundaryMarkerSystemMessage  summaryMessagesUserMessage[]  attachmentsAttachmentMessage[]  hookResultsHookResultMessage[]  messagesToKeep?: Message[]  userDisplayMessage?: string  preCompactTokenCount?: number  postCompactTokenCount?: number  truePostCompactTokenCount?: number  compactionUsage?: ReturnType}

我的思考

看完这些压缩的实现方式,我有几点感受,是我看了源码、而不是泛泛的新闻才会有的感受:
1、上下文工程的颗粒度很细。Claude Code做的不愧是顶级harness,从工具结果到历史消息,从删除、预览到总结摘要,为了控制上下文的长度、同时又不过分损失细节,从概览图便可知有多少功夫在里面。其实我还省略了一部分的内容,比如manual compact、partial compact,再展开可能会太冗长了。
2、提示词缓存的工程细节。为了保持前缀稳定,在压缩工具结果的时候,做了很多设计以保证提示词缓存命中,而其中resume、subagent fork的设计又使得transcript回放/重建机制变得必须。
3、联系最近Claude Code Managed Agent的发布,它围绕着session持久化、harness框架等为Agents提供了IAAS服务,其中session持久化与恢复的概念,在上下文压缩的transcript中的运用中可见端倪。

这个系列会持续剖析Agent的Harness范式。

写文不易,感谢点赞、转发、关注

往期阅读:

TPU发展简史 | 万字长文深度解析核心技术

图文拆解X推荐算法:看懂它背后的Transformer(入门友好)