Claude Code 上下文压缩(一):Snip Compact 消息精简化删除

这是 Claude Code 源码学习系列的第一篇。Snip Compact 是四级压缩体系中最先执行的一级——让 AI 模型主动删除对话中不再需要的消息。

一、它在哪一层?

Claude Code 的上下文压缩是一个四级递进式防护体系。每轮对话开始前,按顺序执行:

Snip Compact(删除整条消息)      ← 本文
    ↓
Microcompact(清除工具结果内容)
    ↓
Context Collapse(异步折叠历史上下文)
    ↓
AutoCompact(LLM 兜底总结)

二、它解决什么问题?

2.1 Compact 做不到的事

传统的 Compact(对话总结)只能做一件事:把整个对话前缀替换为一段摘要

Compact 必须做的事:
  原始: [msg1][msg2][msg3][msg4][msg5][msg6]
  结果: [boundary][summary][msg5][msg6]

  问题:msg5 之前的消息全部被 summary 替代
        即使 msg4 很重要、不应被总结

Snip 可以从对话中间删除任意消息,不影响前后的消息:

Snip 可以做的事:
  原始: [msg1][msg2][msg3][msg4][msg5][msg6]
  结果: [msg1][msg2][msg5][msg6]

  msg3 和 msg4 被干净地移除
  前后消息保持原样,parentUuid 链重新连接

2.2 场景举例

场景 Snip 的作用
早期方案被废弃 删除讨论废弃方案的那段对话
调试过程不再需要 删除 echo "111" 之类的调试操作
重复操作 删除第一次失败的尝试,只保留最终成功的
错误探索方向 删除走错方向的对话分支

三、核心机制:AI 模型主动删除

Snip 和所有其他压缩方式的最大不同:是 AI 模型自己决定删什么

1. 系统: 在每条发往 API 的用户消息末尾注入 [id:xxx] 短标签
   → 模型能看到每条消息的 ID

2. 模型: 调用 SnipTool({ message_ids: ["a3k7x2", "b9f4y1"] })
   → 告诉系统删除这两条消息

3. 系统: 删除消息 + 生成 snip boundary + 修复消息链

3.1 标签注入

// utils/messages.ts:1620-1625
function appendMessageTagToUserMessage(message: UserMessage): UserMessage {
  const tag = `\n[id:${deriveShortMessageId(message.uuid)}]`
  // 将标签追加到消息最后一个 text block 的末尾
}

// deriveShortMessageId: UUID前10位hex → base36 → 6字符短ID
// "a1b2c3d4-e5f6-..." → "a3k7x2"

模型看到的消息示例:

我需要修复 foo.ts 的 login 函数,之前的方案不对,请重新分析。

[id:a3k7x2]

3.2 模型主动调用 SnipTool

模型推理:
"前面的对话 [id:x7b2m1] 是我第一次失败的尝试,
那个方案已经被用户否决了,可以删掉。"

→ snip({ message_ids: ["x7b2m1"] })

3.3 系统执行删除

SnipTool 执行:
1. 从 mutableMessages 数组移除 msg-x7b2m1
2. 生成 snip boundary 记录:
   {
     type: "system",
     subtype: "snip_boundary",
     snipMetadata: { removedUuids: ["uuid-of-x7b2m1"] }
   }
3. boundary 推入 mutableMessages
4. 后续 API 请求: projectSnippedView() 过滤掉被删消息

四、核心机制二:内存删除 vs 磁盘保留

4.1 三份数据,两种处理方式

┌──────────────────────────────────────────┐
│ mutableMessages (内存数组)                 │
│ m1 → m2 → boundary → m5 → m6             │
│ 被删的 m3、m4 已不存在                     │
│                                          │
│ 作用:作为真相源,提供 API 请求的消息快照    │
└──────────────────┬───────────────────────┘
                   │
                   │ recordTranscript() 记录 boundary
                   ▼
┌──────────────────────────────────────────┐
│ JSONL 磁盘文件 (append-only,不可改写)      │
│ m1, m2, m3!, m4!, boundary, m5, m6       │
│ m3 和 m4 的 JSON 行永远留在磁盘上          │
│ boundary 记录了 removedUuids: [u3, u4]    │
└──────────────────┬───────────────────────┘
                   │
                   │ Resume 时 applySnipRemovals()
                   ▼
┌──────────────────────────────────────────┐
│ Resume 加载后的视图                        │
│ 1. 读 JSONL → Map<UUID, Message>          │
│ 2. 根据 boundary 删除 m3、m4               │
│ 3. 修复 parentUuid 链跳过间隙              │
│ 4. buildConversationChain() 重建          │
└──────────────────────────────────────────┘

4.2 parentUuid 链修复

删除消息后,链中有缺口。applySnipRemovals() 修复:

// utils/sessionStorage.ts:1982-2038
function applySnipRemovals(messages: Map<UUID, TranscriptMessage>): void {
  // 1. 收集被删除的 UUID
  const toDelete = new Set<UUID>()
  for (const entry of messages.values()) {
    const removedUuids = entry.snipMetadata?.removedUuids
    if (removedUuids) for (const uuid of removedUuids) toDelete.add(uuid)
  }
  
  // 2. 删除消息
  for (const uuid of toDelete) messages.delete(uuid)
  
  // 3. 修复 parentUuid 链
  for (const [uuid, msg] of messages) {
    if (msg.parentUuid && toDelete.has(msg.parentUuid)) {
      // 向上追溯找到第一个非删除祖先
      messages.set(uuid, { ...msg, parentUuid: resolve(msg.parentUuid) })
    }
  }
}

修复示例:

修复前: m6.parentUuid → m5 → m4 → m3 → m2
                          ↑ m5和m4/m3都被删了

修复后: m6.parentUuid → m2
         直接跳到第一个非删除祖先

五、核心机制三:双重视图(投影层)

在对话进行中,REPL 需要保留完整历史供用户滚动回看,但发给 API 的必须是删除后的精简版。

mutableMessages (完整,REPL 用):
  [m1][m2][m3][m4][boundary][m5][m6]

projectSnippedView():
  ↓ 根据 boundary.removedUuids 过滤

messagesForQuery (精简,API 用):
  [m1][m2][boundary][m5][m6]

projectSnippedView() 是纯函数——不修改 mutableMessages,只返回过滤后的新数组。


六、语义标签 (Nudge)

当 token 快超限时,系统还会注入一个提示,引导模型考虑主动清理:

// utils/messages.ts:4148-4158
case 'context_efficiency': {
  const { SNIP_NUDGE_TEXT } = require('../services/compact/snipCompact.js')
  return wrapMessagesInSystemReminder([
    createUserMessage({ content: SNIP_NUDGE_TEXT, isMeta: true })
  ])
}

模型会看到类似这样的 meta 消息:

[注意:上下文窗口接近上限。你可以使用 Snip 工具删除已完成 
 任务中不再需要的消息来节省空间。]

七、与 QueryEngine 的协作

SDK 模式下,QueryEngine 通过 snipReplay 回调来本地重放 snip 操作:

// QueryEngine.ts:1278-1282
snipReplay: (yielded: Message, store: Message[]) => {
  if (!snipProjection.isSnipBoundaryMessage(yielded))
    return undefined
  return snipModule.snipCompactIfNeeded(store, { force: true })
}

// 当收到 snip boundary → 在本地 mutableMessages 上重放删除
// boundary 本身不 push 到数组,重放结果替换整个数组

八、小结

Snip Compact 的设计巧妙之处在于:

  1. 模型自主决策 — 不是启发式规则,而是让模型判断语义冗余
  2. 双重视图 — REPL 保留完整历史,API 层按需过滤
  3. 磁盘不可变 — boundary 记录删除意图,resume 时重建
  4. 链修复 — parentUuid 追溯跳过被删消息,保证链完整
  5. 零 API 费用 — 纯客户端操作,不增加 API 调用

下一章将介绍第二级压缩:Microcompact。


本文全部来自博主学习 Claude Code 源码时的笔记和与 AI 的问答整理。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐