为什么 Claude Code 使用 snipCompact 不直接删除旧消息,反而是要绕一圈做「剪枝」?
autocompact 之后,旧消息明明可以直接删——Claude Code 偏偏不这么做,而是把完整历史留在内存里,每轮发给模型前再动态裁剪(即「剪枝」)。这个「多此一举」的设计,背后藏着一个你可能忽视的需求。
压缩之后,旧消息去哪了?
你有没有想过:Claude 做了一次上下文压缩之后,那些被压缩掉的旧消息,还在吗?
直觉上,压缩 = 删除。旧消息应该被替换成摘要,然后消失。
但我扒了 Claude Code 的源码,发现事情没这么简单:旧消息并没有被删除,而是完整保留在内存里。
为什么要这么绕?直接删不行吗?
snipCompact 之后,消息流长什么样?
先看 autocompact 做了什么。
触发时,它先调用 LLM 把历史对话压缩成一段摘要,再在消息流里插入一个特殊的边界标记 compact_boundary:
// messages.ts
function createCompactBoundaryMessage(...): SystemCompactBoundaryMessage {
return {
type: 'system',
subtype: 'compact_boundary', // ← 纯工程标记,模型看不到
content: 'Conversation compacted',
...
}
}
注意:
compact_boundary是纯工程标记,只给代码用。模型看到的不是这个标记,而是摘要前面的一段自然语言说明:「This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion...」
压缩后,state.messages(内存里的完整历史)长这样:
┌─────────────────────────────────────────────────────────┐
│ state.messages(完整历史,给用户看) │
│ │
│ [旧消息 A][旧消息 B]...[compact_boundary][摘要][新消息...]│
│ ↑ │
│ 分界线锚点 │
└─────────────────────────────────────────────────────────┘
注意:boundary 在前,摘要在后。旧消息还在,完整保留,没有被删。
那发给模型的是什么?
state.messages 是给人看的完整历史。但发给模型的,不是这份完整历史。
每轮 query 开始时,Claude Code 先做一次切片,再做动态裁剪:
state.messages(完整历史)
↓ getMessagesAfterCompactBoundary()
│ └─ 找到最近的 boundary,slice 取它之后的部分
│ └─ 同时过滤掉被 snip 工具标记为「已删除」的消息
messagesForQuery(初步切片结果)
↓ snipCompactIfNeeded()
│ └─ 运行时动态裁剪:处理多 boundary 残留、边界情况
↓ autocompact()
└─ token 超阈值时触发 LLM 摘要
↓ 发给 API
两个函数的分工不同,不是重复:
getMessagesAfterCompactBoundary():静态切片 + snip 标记过滤。它调用projectSnippedView()把被 snip 工具标记删除的消息过滤掉,是每轮必跑的基础处理。snipCompactIfNeeded():运行时动态裁剪。处理state.messages里可能存在多个 boundary 的情况,确保发给模型的内容只保留最近一次 boundary 之后的部分,并向模型发出「历史已裁剪」的通知。
实际发给模型的内容:
┌─────────────────────────────────────────────────────────┐
│ messagesForQuery(发给模型) │
│ │
│ [compact_boundary][摘要][新消息...] │
│ ↑ │
│ boundary 本身会被 normalizeMessagesForAPI 过滤掉, │
│ 模型只看到摘要 + 新消息 │
└─────────────────────────────────────────────────────────┘
旧消息对模型不可见,但它们还在 state.messages 里,用户可以在界面上滚动回看。
为什么不直接删?——答案在用户体验
现在回到最开始的问题:旧消息为什么不直接删?
源码注释里有一段对比,把 Claude Code 和一种「直接删除」的轻量实现做了对比:
直接删除旧消息的实现,compact 后
currentMessages只保留 boundary + 摘要 + 新消息,没有「完整历史」的概念。代价是用户无法滚动回看 compact 之前的内容。
这句话说清楚了一切。
Claude Code 保留完整历史,是因为用户需要能滚动回看——压缩之前说了什么、讨论过哪些方案、走过哪些弯路,这些对用户来说都是有价值的。
直接删,工程上最简单;但用户体验上,历史就永远消失了。
所以 Claude Code 选择了两套视图并存:
| state.messages(内存全量) | messagesForQuery(发给模型) | |
|---|---|---|
| 包含什么 | 完整历史,包括旧消息 | 只含最近 boundary 之后的有效内容 |
| 为谁服务 | 用户界面、历史回放 | 模型推理 |
| 何时清理 | 不主动清理 | 每轮动态裁剪 |
用一张图来理解这个「滑动窗口」的工作方式:
state.messages(完整历史,不删除):
──────────────────────────────────────────────────────────▶
[旧消息...][boundary₁][摘要₁][新消息...][boundary₂][摘要₂][最新消息...]
↑
getMessagesAfterCompactBoundary
找到最近的 boundary₂,从这里切
发给模型的窗口(每轮动态移动):
[boundary₂][摘要₂][最新消息...]
boundary 就像一个「锚点」,随着对话推进不断向右移动,发给模型的窗口跟着滑动——旧内容对模型不可见,但在 state.messages 里永远保留,用户随时可以滚动回看。
写在最后
扒完这段源码,我最大的感受是:Agent 不只是一个工程系统,它还是一个用户在用的产品。
纯工程视角会说「旧消息没用了,直接删」——实现最简单,token 也最省。
但 Claude Code 选择了更复杂的路:完整历史留给用户,动态裁剪留给模型,两套视图各司其职。多出来的这一层复杂度,换来的是用户能随时滚动回看历史的体验。
这个取舍值不值?从产品角度看,绝对值。
你在做 Agent 的时候,有没有想过「这个设计是给模型用的,还是给用户用的」?欢迎留言分享。