snip-compact-deep-dive
·
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 的设计巧妙之处在于:
- 模型自主决策 — 不是启发式规则,而是让模型判断语义冗余
- 双重视图 — REPL 保留完整历史,API 层按需过滤
- 磁盘不可变 — boundary 记录删除意图,resume 时重建
- 链修复 — parentUuid 追溯跳过被删消息,保证链完整
- 零 API 费用 — 纯客户端操作,不增加 API 调用
下一章将介绍第二级压缩:Microcompact。
本文全部来自博主学习 Claude Code 源码时的笔记和与 AI 的问答整理。
更多推荐


所有评论(0)