cc中snipCompact实现机制解析

先说结论:

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 :

typescript 复制代码
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 :

ini 复制代码
const tag = `\n[id:${deriveShortMessageId(message.uuid)}]`

关键点( messages.ts:L2345-L2364 ):

javascript 复制代码
  // 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。 模型看到的就是这样:
bash 复制代码
帮我看下这个报错栈
[id:a3f2k1]

之后它调用 SnipTool 时用这些短 ID 指定"删 a3f2k1 到 b7c9d0 之间"。

第 2 步:视图分离------UI 看全量,模型看剪枝后的投影

这是 snip 最重要的设计。REPL 本地数组 永远保留完整历史 (供滚动回看),只有喂给模型的路径才应用剪枝。见 getMessagesAfterCompactBoundary :

php 复制代码
/**
 * 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 ):

UI 侧由 SnipBoundaryMessage 渲染这条边界。

为什么要持久化 removedUuids ?因为 JSONL 是 append-only ------被删的消息仍留在磁盘上,只有 boundary 记住了"它们已被剪掉"。

第 4 步:Resume 时重放删除 + 重连 parentUuid 链(最精妙处)

这是 snip 与 compact 的最大差异。compact 截断的是前缀,而 snip 挖掉的是 中间段 ,导致幸存消息的 parentUuid 指针悬空。核心函数 applySnipRemovals :

(a) 收集所有 boundary 记录的 removedUuids:

(b) 删除前先记住每个被删节点自己的 parentUuid:

(c) 重连:对每个 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 :

回调注入见 QueryEngine.ts:L1276-L1284 :

注释点明动机:若不重放,marker 会每轮重复触发, mutableMessages 永不收缩,长 SDK 会话内存泄漏。

第 6 步:把释放的 token 告诉 autocompact,避免误触发

snip 删了消息,但幸存的那条 assistant 的 usage 仍是 剪枝前 的计数, tokenCountWithEstimation 看不到节省。于是 snipTokensFreed 一路传到 autocompact 做抵扣,见 autoCompact.ts:L225 :

ini 复制代码
const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed

否则 snip 明明腾出了空间,autocompact 却因旧 usage 仍判定超阈值而多余地触发重型摘要。这与你项目记忆里记录的一致。

配套地,当上下文效率偏低时,系统会用 SNIP_NUDGE_TEXT 提示模型去主动 snip,见 messages.ts:L4148-L4160 。

完整生命周期串起来

markdown 复制代码
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 大小的自动过期。

如果你要我把某一层(例如 resume 重连的 resolve 路径压缩,或视图投影与 UI 的协同)再展开到更细的执行示例,我可以继续。

一个完整的demo示例:

场景设定

用户先让 CC 排查一个报错,中间跑了大量搜索/读取(噪声),定位到根因后,模型判定中间那堆探索过程已无价值,主动调 SnipTool 剪掉。为看清 parentUuid 重连,我给每条消息标上 uuid 和 parentUuid。

阶段 0:原始 transcript(本地 REPL 数组)

每条 user message 在发给 API 时会被 appendMessageTagToUserMessage 注入 id:xxxxxx 短 ID( deriveShortMessageId 由 uuid 派生):

U1~U6 是探索噪声, U7/U8 才是关键证据, U9 是结论。

阶段 1:LLM 调 SnipTool(第 N 轮)

第 N 轮 callModel 流里,模型在给出结论后,紧接着发起一个 tool_use 点名要删的区间------这一刻就是 query.ts:L659 的流式解析:

它用的正是阶段 0 注入的短 ID 来指定范围。随后 query.ts:L1380-L1400 的 runTools 执行 SnipTool,产出一个 snip marker (UI 侧 Message.tsx:L277-L279 对它 return null 不显示):

注意 :此刻消息还没被删,只是记下"模型想删 U1~U6"。marker 随 toolResults 进入下一轮状态( query.ts:L1714-L1720 )。

阶段 2:客户端落实删除(第 N+1 轮开头)

递归进入下一轮, query.ts:L400-L410 的 snipCompactIfNeeded 读到 marker,物理移除区间并产出 Snip Boundary :

这一步同步、无 LLM、无摘要。产出的 boundary 大致是:

tokensFreed 会被带到 autocompact 做抵扣( autoCompact.ts:L225 ),避免旧 usage 误触发摘要。

阶段 3:两种视图分离

同一份底层数据,UI 与模型看到的不同,来自 getMessagesAfterCompactBoundary :

视图 参数 看到的内容 UI View(滚动回看) includeSnipped: true U0U11 全量保留 ,用户仍能翻到那 13KB 探索过程 Model View(发给 API) projectSnippedView(...) 仅 U0 → U7 → U8 → U9 →(后续) ,U1U6 不可见、不计费

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

13KB 噪声消失,关键证据链完整。

阶段 4:Resume 重连 parentUuid(你正看的那段代码)

JSONL 是 append-only,U1~U6 仍躺在磁盘上,重开会话时靠 applySnipRemovals 重放删除。你光标所在的 sessionStorage.ts:L1991 正是收集 removedUuids 的那一步:

问题 :删掉 U1~U6 后,幸存的 U7 的 parentUuid 还指向 U6 (已删)------链断了。若不修, buildConversationChain 会撞上 messages.get(undefined) 停止,导致 resume 出错或重建整份未剪枝历史(注释里实测 397K→1.65M PTL,见 sessionStorage.ts:L1962-L1974 )。

重连过程 ( sessionStorage.ts:L2010-L2033 ):

删除前先记住每个被删节点自己的 parent:

U7.parentUuid = U6 悬空,触发 resolve(U6) 沿链回溯:

scss 复制代码
U6(删) → U5(删) → U4(删) → U3(删) → U2(删) → U1(删) → U0(存活) ✓

于是把 U7.parentUuid 从 U6 改写为 U0 ,链修复:

css 复制代码
修复前: U0 → [U1..U6 空洞] → U7 → U8 → U9
修复后: U0 ─────────────────→ U7 → U8 → U9

同时 resolve 做路径压缩( deletedParent.set(p, cur) ),避免多个幸存者重复回溯同一段。最后打 tengu_snip_resume_filtered 事件记 removed_count / relinked_count 。

整条链路回顾

ini 复制代码
阶段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 调用。

源码限制照旧: snipCompact.ts ( snipCompactIfNeeded 、marker 判定)与 snipProjection.ts ( projectSnippedView )是 ant-only 动态 require ,当前工作树缺失,所以阶段 1/2 里"SnipTool 参数如何转成 marker、区间边界怎么裁"这部分我用真实调用点和边界结构合理还原,但无法逐行展示其内部实现。

相关推荐
jsl_jsl_jsl2 小时前
claudecode学习 第 8 章 · Slash Commands
agent
菩提小狗3 小时前
AI每日资讯|AI落地|最新情报|skill精选|2026年07月28日(11案例+10爆款Skill)
大模型·agent·skill·ai资讯·ai落地
x-cmd3 小时前
AI 时代的 Git 革命:多 Agent 并发开发的新基础设施
git·ai·agent·代码管理·开发者工具·多智能体协作·worktree
Lei活在当下10 小时前
如何在Windows环境选择适合自己的 AI Agent
chatgpt·agent·ai编程
To_OC12 小时前
我把《天龙八部》塞进向量数据库后,终于搞懂了 RAG 到底是个啥
人工智能·llm·agent
大模型momo13 小时前
Spring AI 实战:多 Agent 协作实战 —— 分工拆解复杂旅游行程任务
人工智能·spring·ai·agent·旅游
冬奇Lab14 小时前
开源项目第176期:Better Harness — 不审查 diff,审查工作流本身,给 AI 编程 Agent 的五维评估框架
人工智能·开源·agent
用户4693684832018 小时前
kimi-code 深度掌握系列文章-关键架构决策(三)
agent
Loveyourself18 小时前
你知道吗?详细揭秘cc中api层微压缩-microcompact
面试·agent