上下文压缩:重构对话时丢掉缓存的代价

发布时间:

上一篇文章拆了 /compact 的请求结构——compact 是一个 subagent,system prompt 被砍成一句话,27 个工具只剩 1 个 Read,120K token 全额计费缓存命中 0%。但那篇文章只讲到了 compact 本身,没讲 compact 之后发生了什么。这篇接着拆:压缩完成后,主对话的第一条请求长什么样?对话是怎么无缝继续的?

抓包环境

这次抓的是 compact 之后主 agent 的第一条请求。对话背景是 yiya-mw-agents 项目,先打了 /compact,然后我问了一句「压缩了吗」。下面拆的就是这个请求。

一眼对比:compact subagent vs 压缩后的 main agent

把 compact subagent 的请求和压缩后 main agent 的请求放在一起,差异一目了然:

Compact subagent:                    压缩后 Main agent:
┌─────────────────────────┐         ┌─────────────────────────┐
│ system[0]: billing      │         │ system[0]: billing      │  ← 一样
│ system[1]: 身份声明      │         │ system[1]: 身份声明      │  ← 一样(加了 ephemeral 缓存标记)
│ system[2]: 一句话        │         │ system[2]: 千字长文      │  ← 完全不同!
│                         │         │                         │
│ tools: [Read] ×1        │         │ tools: [28个...]        │  ← 完全不同!
│                         │         │                         │
│ messages:               │         │ messages:               │
│  [全部历史消息]          │         │  [summary 替代历史消息]   │
│                         │         │  [/compact 命令回显]     │
│                         │         │  ["压缩了吗"]            │
└─────────────────────────┘         └─────────────────────────┘

compact subagent 的 system prompt 是一句话:「You are a helpful AI assistant tasked with summarizing conversations.」压缩后的 main agent 把千字长文 system prompt 完整恢复了。28 个工具也都回来了。

这很合理——compact subagent 只做总结,不需要知道怎么用 Edit、Bash、Git。但一旦 compact 完成,主对话要继续,模型需要恢复全部的 context:你是谁,你能用什么工具,你该怎么行为。

请求结构逐层拆

system 三个 block 和缓存策略

"system": [
  {
    "type": "text",
    "text": "x-anthropic-billing-header: cc_version=2.1.126.a4b; cc_entrypoint=cli; cch=8b99a;"
  },
  {
    "type": "text",
    "text": "You are Claude Code, Anthropic's official CLI for Claude.",
    "cache_control": { "type": "ephemeral" }     // 
  },
  {
    "type": "text",
    "text": "You are an interactive agent that helps users...",
    "cache_control": {}                           // 
  }
]

两个缓存标记值得注意:

身份声明 block 标记了 “type”: “ephemeral”。ephemeral 缓存存续 5 分钟,这意味着系统预期用户会持续对话,但不会一直复用这个缓存。5 分钟后对话还在,这个 block 过期也无所谓,可以从头编码。

主 system prompt 标记了 {}(无 type,即持久缓存)。这是最长的固定文本,几千字。用持久缓存意味着只要在同一个 session,这部分可以一直复用。相比 compact subagent 请求里 system prompt 完全没有缓存标记(因为是一次性调用),主 agent 显然在为后续对话的缓存做准备。

tools:28 个全部回来

compact subagent 只有 1 个 Read 工具。压缩后 main agent 的 tools 数组里有 28 个 tool 定义:

"tools": [
  {},  // Agent
  {},  // AskUserQuestion
  {},  // Bash
  {},  // CronCreate
  {},  // CronDelete
  {},  // CronList
  {},  // Edit
  {},  // EnterPlanMode
  {},  // EnterWorktree
  {},  // ExitPlanMode
  {},  // ExitWorktree
  {},  // Monitor
  {},  // NotebookEdit
  {},  // PushNotification
  {},  // Read
  {},  // RemoteTrigger
  {},  // ScheduleWakeup
  {},  // Skill
  {},  // TaskCreate
  {},  // TaskGet
  {},  // TaskList
  {},  // TaskOutput
  {},  // TaskStop
  {},  // TaskUpdate
  {},  // WebFetch
  {},  // WebSearch
  {},  // mcp__claude_ai_Google_Drive__authenticate
  {},  // mcp__claude_ai_Google_Drive__complete_authentication
]

28 个工具全部就位。这点很重要——如果工具没有全部恢复,模型看到了 compact 前 call 了 Bash 读文件,compact 后 Bash 突然不可用了,模型就要懵。所以 main agent 的 tool registry 必须和 compact 前完全一致。

messages:历史被 summary 替代

这是压缩后最核心的变化。compact 之前 messages 里有数百条历史消息。compact 之后,这些被替换成了一段 summary:

{
  "role": "user",
  "content": [
    {
      "type": "text",
      "text": "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation. Summary: 1. Primary Request and Intent: ... 2. Key Technical Concepts: ... 3. Files and Code Sections: ... 9. Optional Next Step: ..."
    },
    {
      "type": "text",
      "text": "<local-command-caveat>Caveat: The messages below were generated by the user while running local commands...</local-command-caveat>"
    },
    {
      "type": "text",
      "text": "<command-name>/compact</command-name> <command-message>compact</command-message>"
    },
    {
      "type": "text",
      "text": "Compacted (ctrl+o to see full summary)"
    },
    {
      "type": "text",
      "text": "压缩了吗",
      "cache_control": { "type": "ephemeral" }   // 用户最新消息,标记 ephemeral
    }
  ]
}

这 5 个 content block 拆开看:

  1. Summary 正文:compact subagent 产出的 9 节结构化总结。长度约 4000 字符,覆盖了对话的核心内容——项目背景、技术概念、涉及的文件、修复的 bug、当前状态。4000 字符替代了几万 token 的原始历史。

  2. <local-command-caveat>:系统告诉模型「下面的内容是用户执行本地命令时产生的,除非用户明确提到否则不要响应」。

  3. <command-name>/compact</command-name>:compact 命令本身。

  4. Compacted (ctrl+o to see full summary):终端输出回显。

  5. 「压缩了吗」:用户的新消息,cache_control: { type: “ephemeral” } 标记——这条是新消息,缓存它可以让用户下一条消息的请求复用这块前缀。

其他请求参数

{
  "model": "deepseek-v4-pro",
  "max_tokens": 32000,
  "thinking": { "type": "adaptive", "display": "summarized" },
  "output_config": { "effort": "high" },
  "context_management": {
    "edits": [
      { "type": "clear_thinking_20251015", "keep": "all" }
    ]
  }
}

几个值得注意的点:

  • thinking.display: “summarized” —— thinking 内容会被摘要化而不是完整展示。compact 后的对话 threshold 已经拉高了,模型会更倾向用 shorter thinking。

  • context_management.edits: clear_thinking_20251015 —— 这个参数清除了之前轮次的 thinking 块。上一篇文章提到 compact 请求也有这个参数。说明 compact 后 model 能看到的历史 thinking 被清理了,只保留了 summary 中的关键信息。

  • output_config.effort: “high” —— 和 compact subagent 一样,主 agent 也用了 high effort。

模型的回复

output.json 里模型返回了:

{
  "role": "assistant",
  "content": [
    {
      "type": "thinking",
      "thinking": "The user is noting that the conversation was compacted...",
      "signature": "c833db8d-a491-4138-b7d5-cba0fd0626b6"
    },
    {
      "type": "text",
      "text": "是的,上一轮对话上下文太长,系统自动做了压缩(compact)。我已经拿到了完整的摘要,包括:\n- 你对 CTD M2.4 打标签和 L1/L2 抽取模块的熟悉过程\n- 本地和 K8s 两套环境都测试通过\n- MinerU 并发竞争的修复(commit `35a0fedb`)已在 worktree 中\n- 正确的代码路径在 `/Users/hanwenbo/.config/superpowers/worktrees/yiya-mw-agents/feature-m24-phase4-extract-plugin`\n\n可以继续,有什么需要做的?"
    }
  ],
  "usage": {
    "input_tokens": 35928,
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 0,
    "output_tokens": 157
  }
}

模型确认了压缩,并且从 summary 中提取了关键信息:CTD M2.4 模块、K8s 测试结果、MinerU 修复、代码路径。回答自然流畅,完全没有「上下文丢了」的感觉。对话无缝继续。

缓存的真相比你想的更残酷

看一眼 usage:

compact subagent:      input=120,223  cache_read=0       (一次性的全价)
压缩后 main agent:     input=35,928   cache_read=0       (从零开始积累)
compact 前的正常对话:   input ~几千     cache_read=99.7%   (基本不花钱)

压缩后第一条请求:

  • cache_read 也是 0。虽然 system prompt 上有 cache_control: {} 标记,但这是请求时告诉 API「请缓存这个」,而不是「这次从缓存读」。缓存是本次请求后才建立的。
  • cache_creation 也是 0。这说明 API 可能认为总共才 36K token 不值得建缓存,或者因为其他条件没触发。
  • 好消息是:input 只有 36K token。compact 之前这个对话可能已经吃到了 200K+ token 的 context,compact 把历史压缩到了 ~4K 字符的 summary,一下子就瘦身了。

后续的请求如果能命中系统 prompt 的缓存,每轮 input 就会大幅下降。compact 的本质是用一次昂贵的全量请求(compact subagent 120K)换取后续每次请求的上下文瘦身(从 200K+ 降到 36K 再降到几 K)。

对整体缓存命中率的影响

前面聊的都是单次请求的缓存命中,但如果你在看账单或者 cc-viewer 的统计面板,整体缓存命中率会被这两次 0-hit 请求明显拉低

缓存命中率不是按请求数量平均的,而是按 token 加权。也就是说,一个 120K token 的 0-hit 请求和一个 2K token 的 99% hit 请求对平均值的影响差了 60 倍。

假设一次典型的 compact 前后对话情况:

compact 前(正常对话 20 轮):
  每轮 input ≈ 8,000 tokens(历史累积)
  每轮 cache_read ≈ 7,900 tokens(99% 命中)
  正常对话总 input = 160,000 tokens
  正常对话总 cache_read = 158,000 tokens
  缓存命中率 = 158,000 / 160,000 = 98.75%

然后触发 compact:
  第 1 次(compact subagent):input = 120,223, cache_read = 0
  第 2 次(压缩后主 agent):input = 35,928, cache_read = 0

compact 后的整体统计:
  总 input = 160,000 + 120,223 + 35,928 = 316,151 tokens
  总 cache_read = 158,000 + 0 + 0 = 158,000 tokens
  整体缓存命中率 = 158,000 / 316,151 = 49.97%

一次 compact 直接把整个 session 的缓存命中率从 98.75% 砸到了 49.97%。如果这 20 轮对话后面再有 20 轮正常对话(逐渐恢复缓存命中),整体命中率可能勉强拉回 60%~70%。但要想回到 compact 前的 99%,需要非常长的后续对话来稀释那两次 0-hit 的 15 万 token。

更具体地说:compact 引入的两次 0-hit 合计 156,151 token。要恢复到 95% 整体命中率,后续需要大约 300 万 token 的正常对话(假设每轮 8K input、99% 命中率递增)来稀释。大部分对话根本走不到这么长,可能又触发一次 compact,形成恶性循环。

如果你用 cc-viewer 看 session 级别的缓存命中率统计,一旦看到某次对话命中率断崖下跌,那就是 compact 触发了。

终端里用户看到什么

Compacted 提示

compact 完成后终端里显示 Compacted (ctrl+o to see full summary)。用户不需要理解 subagent、缓存命中率、KV cache 这些概念。TA 只需要知道上下文太长,系统自动(或我手动)压缩了,然后继续问下一个问题。

ctrl+o 展开可以看到完整的 9 节总结,还能看到原始 transcript 的文件路径:

/Users/hanwenbo/.claude/projects/-Users-hanwenbo-PycharmProjects/c07046ac-...jsonl

展开详情

总结:紧凑的代价账单

 compact subagent压缩后 main agent正常对话
system prompt一句话千字长文(带 cache_control)千字长文(带 cache_control)
tools1 个28 个28 个
历史消息全部原始消息summary 替代(~4K 字符)增量追加
input tokens120K36K几 K
cache_read0099.7%
目的产出 summary恢复对话干活
计费全价,一次性全价,后续逐渐恢复缓存命中基本只付新消息

所以 compact 不是一个单次行为,而是一笔交易:(compact subagent 全额 120K)+(压缩后首轮 36K 重建缓存)换后续对话的持续瘦身。 如果上下文确实太长导致每次请求都超预算截断,这笔交易是赚的。如果只是觉得「聊天框有点长想清理一下」——那你就是花了一两百万 token 的钱,买了一个本来不需要的服务。

和上一篇的结论一样:上下文没炸就别碰 compact。