为什么 Claude Code 使用 snipCompact 不直接删除旧消息,反而是要绕一圈做「剪枝」?

5 阅读7分钟

为什么 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 的时候,有没有想过「这个设计是给模型用的,还是给用户用的」?欢迎留言分享。