先说结论:
snip_compact (History Snip)就是所谓的“噪声直接删除,低价值的内容(如大量搜索结果中只被使用了几行的内容)直接移除,不做摘要——对噪声做 摘要只是在浪费 token。”:它不生成摘要,而是把历史中价值低的 中间区间 整段物理移除,再修复消息链。
它的调用点在主循环 query.ts:L396-L410 ,排在 microcompact 之前:
核心执行文件 snipCompact.ts / snipProjection.ts 是 ant-only 动态 require ,在当前工作树里缺失(和上次的 cachedMicrocompact.ts 一样)。但它的 全部配套逻辑 都在已存在文件里,足以讲清整条链路。
与 microcompact / autocompact 的本质区别
| 机制 | 处理对象 | 手段 | 是否有摘要 |
|---|---|---|---|
| microcompact | 单个旧 tool_result | 删缓存 / 清内容 | 无 |
| snip | 任意中间消息区间 | 物理移除 + 重连链 | 无 |
| autocompact | 整段前缀 | LLM 摘要 | 有 |
snip 的独特点:它能删掉的不只是工具结果,而是模型自己判定为噪声的 一整段中间对话 ;且它由 模型主动调用 SnipTool 触发,不是纯阈值驱动。这正是“对噪声做摘要只是浪费 token”——直接删更便宜。
第 1 步:给消息注入 6 位短 ID,让模型能“点名”要删的区间
模型要指定删哪段,必须有稳定可引用的 ID。 deriveShortMessageId :
export function deriveShortMessageId(uuid: string): string {
const hex = uuid.replace(/-/g, '').slice(0, 10)
return parseInt(hex, 16).toString(36).slice(0, 6)
}
从 UUID 前 10 位十六进制转 base36,取 6 字符,确定性映射。
然后在组装 API 消息时,给每条非 meta 的 user message 末尾追加 [id:xxxxxx] 标签,见 appendMessageTagToUserMessage :
const tag = `\n[id:${deriveShortMessageId(message.uuid)}]`
关键点( messages.ts:L2345-L2364 ):
// Append message ID tags for snip tool visibility (after all merging,
// so tags always match the surviving message's messageId field).
// Skip in test mode — tags change message content hashes, breaking
// VCR fixture lookup. Gate must match SnipTool.isEnabled() — don't
// inject [id:] tags when the tool isn't available (confuses the model
// and wastes tokens on every non-meta user message for every ant).
if (feature('HISTORY_SNIP') && process.env.NODE_ENV !== 'test') {
const { isSnipRuntimeEnabled } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js')
if (isSnipRuntimeEnabled()) {
for (let i = 0; i < sanitized.length; i++) {
if (sanitized[i]!.type === 'user') {
sanitized[i] = appendMessageTagToUserMessage(
sanitized[i] as UserMessage,
)
}
}
}
}
- 标签只加在 发给 API 的副本 上,不写回存储;
- 在所有消息合并之后才注入,保证 tag 与幸存消息的 messageId 一致;
- 测试模式跳过(tag 会改内容哈希,破坏 VCR fixture);
- 门槛必须与 SnipTool.isEnabled() 一致,否则白白浪费每条 user message 的 token。 模型看到的就是这样:
帮我看下这个报错栈
[id:a3f2k1]
之后它调用 SnipTool 时用这些短 ID 指定“删 a3f2k1 到 b7c9d0 之间”。
第 2 步:视图分离——UI 看全量,模型看剪枝后的投影
这是 snip 最重要的设计。REPL 本地数组 永远保留完整历史 (供滚动回看),只有喂给模型的路径才应用剪枝。见 getMessagesAfterCompactBoundary :
/**
* Returns messages from the last compact boundary onward (including the boundary).
* If no boundary exists, returns all messages.
*
* Also filters snipped messages by default (when HISTORY_SNIP is enabled) —
* the REPL keeps full history for UI scrollback, so model-facing paths need
* both compact-slice AND snip-filter applied. Pass `{ includeSnipped: true }`
* to opt out (e.g., REPL.tsx fullscreen compact handler which preserves
* snipped messages in scrollback).
*
* Note: The boundary itself is a system message and will be filtered by normalizeMessagesForAPI.
*/
export function getMessagesAfterCompactBoundary<
T extends Message | NormalizedMessage,
>(messages: T[], options?: { includeSnipped?: boolean }): T[] {
const boundaryIndex = findLastCompactBoundaryIndex(messages)
const sliced = boundaryIndex === -1 ? messages : messages.slice(boundaryIndex)
if (!options?.includeSnipped && feature('HISTORY_SNIP')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { projectSnippedView } =
require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js')
/* eslint-enable @typescript-eslint/no-require-imports */
return projectSnippedView(sliced as Message[]) as T[]
}
return sliced
}
所以同一份 messages :
- UI View : includeSnipped: true ,完整历史;
- Model View : projectSnippedView(...) ,剪枝视图。 模型不为已删除的噪声支付任何 token,但用户仍能在 transcript 里翻到原文。
第 3 步:Snip Boundary——记录删了哪些 UUID
snip 执行后会产出一个 boundary 系统消息,其 snipMetadata.removedUuids 精确记录被移除的消息 UUID。这个结构在 resume 逻辑里能反查到( sessionStorage.ts:L1986-L1991 ):
type WithSnipMeta = { snipMetadata?: { removedUuids?: UUID[] } }
const removedUuids = (entry as WithSnipMeta).snipMetadata?.removedUuids
UI 侧由 SnipBoundaryMessage 渲染这条边界。
为什么要持久化 removedUuids ?因为 JSONL 是 append-only ——被删的消息仍留在磁盘上,只有 boundary 记住了“它们已被剪掉”。
第 4 步:Resume 时重放删除 + 重连 parentUuid 链(最精妙处)
这是 snip 与 compact 的最大差异。compact 截断的是前缀,而 snip 挖掉的是 中间段 ,导致幸存消息的 parentUuid 指针悬空。核心函数 applySnipRemovals :
(a) 收集所有 boundary 记录的 removedUuids:
for (const entry of messages.values()) {
const removedUuids = (entry as WithSnipMeta).snipMetadata?.removedUuids
if (!removedUuids) continue
for (const uuid of removedUuids) toDelete.add(uuid)
}
(b) 删除前先记住每个被删节点自己的 parentUuid:
const deletedParent = new Map<UUID, UUID | null>()
for (const uuid of toDelete) {
const entry = messages.get(uuid)
deletedParent.set(uuid, entry.parentUuid)
messages.delete(uuid)
}
(c) 重连:对每个 parentUuid 落在空洞里的幸存者,向上回溯到第一个未被删的祖先:
const resolve = (start: UUID): UUID | null => {
let cur = start
while (cur && toDelete.has(cur)) {
cur = deletedParent.get(cur) // 沿被删链继续往上走
if (cur === undefined) { cur = null; break }
}
... // 路径压缩,避免重复回溯
return cur
}
for (const [uuid, msg] of messages) {
if (!msg.parentUuid || !toDelete.has(msg.parentUuid)) continue
messages.set(uuid, { ...msg, parentUuid: resolve(msg.parentUuid) })
}
注释里给出了不修复的后果( sessionStorage.ts:L1962-L1974 ): buildConversationChain 会撞上 messages.get(undefined) 停止,或重建整份未剪枝历史——实测 397K 显示 → 1.65M 实际 ,resume 瞬间 PTL(prompt too long)。
这条链路(短 ID → 边界 removedUuids → resume 重连),即 通过向上追溯父节点重连 parentUuid 链修复消息拓扑。
第 5 步:SDK 长会话里的 replay(防僵尸消息与内存泄漏)
在 QueryEngine 里,收到 snip boundary 信号时会在自己的 store 上重放一次删除,见 QueryEngine.ts:L897-L916 :
const snipResult = this.config.snipReplay?.(message, this.mutableMessages)
if (snipResult !== undefined) {
if (snipResult.executed) {
this.mutableMessages.length = 0
this.mutableMessages.push(...snipResult.messages) // 用剪枝后结果替换
}
break // boundary 是信号不是数据,不 push
}
回调注入见 QueryEngine.ts:L1276-L1284 :
snipReplay: (yielded, store) => {
if (!snipProjection!.isSnipBoundaryMessage(yielded)) return undefined
return snipModule!.snipCompactIfNeeded(store, { force: true })
}
注释点明动机:若不重放,marker 会每轮重复触发, mutableMessages 永不收缩,长 SDK 会话内存泄漏。
第 6 步:把释放的 token 告诉 autocompact,避免误触发
snip 删了消息,但幸存的那条 assistant 的 usage 仍是 剪枝前 的计数, tokenCountWithEstimation 看不到节省。于是 snipTokensFreed 一路传到 autocompact 做抵扣,见 autoCompact.ts:L225 :
const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed
否则 snip 明明腾出了空间,autocompact 却因旧 usage 仍判定超阈值而多余地触发重型摘要。
配套地,当上下文效率偏低时,系统会用 SNIP_NUDGE_TEXT 提示模型去主动 snip,见 messages.ts:L4148-L4160 。
case 'context_efficiency': {
if (feature('HISTORY_SNIP')) {
const { SNIP_NUDGE_TEXT } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js')
return wrapMessagesInSystemReminder([
createUserMessage({
content: SNIP_NUDGE_TEXT,
isMeta: true,
}),
])
}
return []
完整生命周期串起来
1. 组装 API 消息:每条 user msg 注入 [id:xxxxxx] (deriveShortMessageId)
2. 模型判定某中间区间是噪声 → 调 SnipTool 指定短 ID
3. query 主循环:snipCompactIfNeeded 移除区间
├─ 产出 Snip Boundary(记 removedUuids)
└─ 返回 tokensFreed → 抵扣 autocompact 阈值
4. 视图分离:UI 看全量,模型看 projectSnippedView 剪枝视图
5. Resume:applySnipRemovals 删磁盘残留 + 重连 parentUuid 链
6. SDK 长会话:snipReplay 重放,防僵尸 marker 与内存泄漏
一点源码完整性说明
snipCompact.ts (含 snipCompactIfNeeded 、 SNIP_NUDGE_TEXT 、 isSnipRuntimeEnabled )和 snipProjection.ts (含 projectSnippedView 、 isSnipBoundaryMessage )在当前工作树缺失,均为 ant-only 动态 require ,因此无法逐行展示 区间选择算法本身 (比如它如何界定“低价值区间”的具体启发式、保护尾部的边界)。但从注入、投影、边界、resume、autocompact 抵扣这五个已存在的配套模块,可以确认 snip 的规则形态是: 模型点名 + 物理删中间段 + 视图分离 + 链路重连 ,而非基于摘要或 token 大小的自动过期。
附录:一个完整的demo示例:
场景设定
用户先让 CC 排查一个报错,中间跑了大量搜索/读取(噪声),定位到根因后,模型判定中间那堆探索过程已无价值,主动调 SnipTool 剪掉。为看清 parentUuid 重连,我给每条消息标上 uuid 和parentUuid。
阶段 0:原始 transcript(本地 REPL 数组)
每条 user message 在发给 API 时会被 appendMessageTagToUserMessage 注入 [id:xxxxxx] 短 ID( deriveShortMessageId 由 uuid 派生):
uuid=U0 parent=null user "服务启动报 ECONNREFUSED,帮我查" [id:a1b2c3]
uuid=U1 parent=U0 assistant tool_use Grep "ECONNREFUSED"
uuid=U2 parent=U1 user tool_result(Grep) "命中 30 处 ...(4KB)"
uuid=U3 parent=U2 assistant tool_use Read db.ts
uuid=U4 parent=U3 user tool_result(Read) "...(3KB)"
uuid=U5 parent=U4 assistant tool_use Bash "grep -r 5432 ."
uuid=U6 parent=U5 user tool_result(Bash) "...一大堆(6KB)"
uuid=U7 parent=U6 assistant tool_use Read config.ts
uuid=U8 parent=U7 user tool_result(Read) "port: 6543 ← 端口写错了(2KB)"
uuid=U9 parent=U8 assistant "根因找到了:config.ts 里 DB 端口写成 6543,应为 5432"
U1~U6 是探索噪声, U7/U8 才是关键证据, U9 是结论。
阶段 1:LLM 调 SnipTool(第 N 轮)
第 N 轮 callModel 流里,模型在给出结论后,紧接着发起一个 tool_use 点名要删的区间——这一刻就是 query.ts:L659 的流式解析:
uuid=U10 parent=U9 assistant tool_use Snip { from:"a1b2c3"(U0之后), to: U6 的短ID }
它用的正是阶段 0 注入的短 ID 来指定范围。随后 query.ts:L1380-L1400 的 runTools 执行 SnipTool,产出一个 snip marker (UI 侧 Message.tsx:L277-L279 对它 return null 不显示):
uuid=U11 parent=U10 user tool_result(Snip) <snip marker: 待删 U1..U6>
注意 :此刻消息还没被删,只是记下“模型想删 U1~U6”。marker 随 toolResults 进入下一轮状态( query.ts:L1714-L1720 )。
阶段 2:客户端落实删除(第 N+1 轮开头)
递归进入下一轮, query.ts:L400-L410 的 snipCompactIfNeeded 读到 marker,物理移除区间并产出 Snip Boundary :
snipResult = snipCompactIfNeeded(messagesForQuery)
-> 移除 U1,U2,U3,U4,U5,U6
-> 生成 boundary,snipMetadata.removedUuids = [U1,U2,U3,U4,U5,U6]
-> tokensFreed ≈ 4KB+3KB+6KB 对应 token
这一步同步、无 LLM、无摘要。产出的 boundary 大致是:
uuid=B1 parent=U0 system subtype=snip_boundary
snipMetadata.removedUuids=[U1,U2,U3,U4,U5,U6]
tokensFreed 会被带到 autocompact 做抵扣( autoCompact.ts:L225 ),避免旧 usage 误触发摘要。
阶段 3:两种视图分离
同一份底层数据,UI 与模型看到的不同,来自 getMessagesAfterCompactBoundary :
模型这一轮实际看到的有效上下文:
user "服务启动报 ECONNREFUSED,帮我查" [id:a1b2c3]
assistant tool_use Read config.ts
user tool_result "port: 6543 ← 端口写错了"
assistant "根因找到了:config.ts 端口写成 6543"
13KB 噪声消失,关键证据链完整。
阶段 4:Resume 重连 parentUuid(你正看的那段代码)
JSONL 是 append-only,U1~U6 仍躺在磁盘上,重开会话时靠 applySnipRemovals 重放删除。你光标所在的 sessionStorage.ts:L1991 正是收集 removedUuids 的那一步:
const removedUuids = (entry as WithSnipMeta).snipMetadata?.removedUuids
if (!removedUuids) continue
for (const uuid of removedUuids) toDelete.add(uuid) // ← L1991:汇总所有待删 uuid
问题 :删掉 U1~U6 后,幸存的 U7 的 parentUuid 还指向 U6 (已删)——链断了。若不修, buildConversationChain 会撞上 messages.get(undefined) 停止,导致 resume 出错或重建整份未剪枝历史(注释里实测 397K→1.65M PTL,见 sessionStorage.ts:L1962-L1974 )。
重连过程 ( sessionStorage.ts:L2010-L2033 ):
删除前先记住每个被删节点自己的 parent:
deletedParent: U6→U5, U5→U4, U4→U3, U3→U2, U2→U1, U1→U0
U7.parentUuid = U6 悬空,触发 resolve(U6) 沿链回溯:
U6(删) → U5(删) → U4(删) → U3(删) → U2(删) → U1(删) → U0(存活) ✓
于是把 U7.parentUuid 从 U6 改写为 U0 ,链修复:
修复前: U0 → [U1..U6 空洞] → U7 → U8 → U9
修复后: U0 ─────────────────→ U7 → U8 → U9
同时 resolve 做路径压缩( deletedParent.set(p, cur) ),避免多个幸存者重复回溯同一段。最后打 tengu_snip_resume_filtered 事件记 removed_count / relinked_count 。
整条链路回顾
阶段0 注入 [id:] 短 ID messages.ts:1620 / deriveShortMessageId:200
阶段1 LLM 调 SnipTool query.ts:659 (callModel) → runTools query.ts:1380
└─ 产出 snip marker(尚未删)
阶段2 snipCompactIfNeeded query.ts:403
└─ 物理删 U1~U6 + Snip Boundary(removedUuids) + tokensFreed
阶段3 视图分离 getMessagesAfterCompactBoundary:4643
├─ UI: includeSnipped=true → 全量
└─ Model: projectSnippedView → 剪枝
阶段4 resume 重连 applySnipRemovals:1982(你在看的 L1991)
└─ 删磁盘残留 + parentUuid 回溯重连(U7.parent: U6→U0)
一句话: 模型用短 ID 点名中间噪声区间 → 客户端下一轮物理删除并记 removedUuids 边界 → UI 留全量、模型看剪枝 → resume 时按 removedUuids 重删并把断裂的 parentUuid 回溯到最近的存活祖先 。全程无摘要、无额外 LLM 调用。