cc中snipCompact实现机制解析

54 阅读10分钟

先说结论:

snip_compact (History Snip)就是所谓的“噪声直接删除,低价值的内容(如大量搜索结果中只被使用了几行的内容)直接移除,不做摘要——对噪声做 摘要只是在浪费 token。”:它不生成摘要,而是把历史中价值低的 中间区间 整段物理移除,再修复消息链。

它的调用点在主循环 query.ts:L396-L410 ,排在 microcompact 之前:

image.png

核心执行文件 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)
}

image.png

从 UUID 前 10 位十六进制转 base36,取 6 字符,确定性映射。

然后在组装 API 消息时,给每条非 meta 的 user message 末尾追加 [id:xxxxxx] 标签,见 appendMessageTagToUserMessage :

image.png

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 渲染这条边界。

image.png 为什么要持久化 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
}

image.png 回调注入见 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 :

image.png

模型这一轮实际看到的有效上下文:

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 调用。