一个常见的直觉是:agentic coding 工具会"偷偷"管理对话历史。MCP 工具在第一轮取回了一大段数据库查询结果,模型分析完毕,过了十轮对话之后,工具应该已经悄悄把那段原始数据替换成了一句"此前查询了一段数据"。输入 token 因此减少,成本随之下降。

实际情况并非如此。不开 compact 或 prune,messages 数组就原封不动地增长。那段数据库查询结果会从第一轮一直带到第一百轮,每次 API 调用都完整发送,按全价计费。

这引出了一个工程问题:面对一个只增不减的数组,各家 agentic coding 工具到底有哪些操纵手段?这些手段如何与 prompt cache 机制交互?不同选择会导致怎样的成本和信息保真度差异?

本文是对 Claude Code 源码深度解析Agentic Coding 深度解析 的差异化补位。前两篇分别覆盖了 Claude Code 的五级压缩流水线和 messages 数组的五维操纵模型(注入 / 定位 / 保护 / 清理 / 重复)。本文专注于跨工具的策略全景,以 prompt cache 的前缀匹配原理为统一约束条件,建立一套不漏不重的分类体系。

两个前提

本文的分析建立在两个前提之上。两者的详细论述分别在前两篇文章中,此处只做最简回顾。

前提一:messages 数组是 Agent 的唯一状态载体。 所有主流 LLM API 的对话状态都存储在一个 messages 数组里。历史对话、推理过程、工具调用请求、工具返回结果,全部是这个数组的元素。Agent 框架在做的核心事情,就是决定往这个数组里追加什么、保留什么、丢弃什么。

前提二:prompt cache 基于前缀匹配。 以 Anthropic 为例,缓存按 tools → system → messages 的顺序做字节级前缀匹配。增长型多轮对话里,只要旧的 message blocks 保持不变,新一轮请求就能从缓存中读取前面所有轮次的内容,只为新增部分付费。缓存读取成本是正常输入的 10%。反过来说,任何打破前缀的操作都会导致缓存失效,下次请求要重新全价写入。

三家主流 LLM 提供商的缓存机制存在根本性差异:

维度 Anthropic OpenAI Google Gemini
控制方式 显式断点 + 自动 全自动 隐式 + 显式
读取折扣 90%(0.1× 正价) 50% 按模型浮动
写入成本 1.25×(5min TTL)/ 2×(1h TTL) 无额外成本 按存储时长计费
最低 token 阈值 512-4096(按模型) 1024 2048-4096
默认 TTL 5 分钟 5-10 分钟至 24 小时 1 小时
开发者可控性 高(最多 4 个断点)

Anthropic 的缓存机制给了开发者最大的控制空间,代价是操纵 messages 数组的每一步都需要考虑缓存影响。OpenAI 的全自动方案省心但不可调优。Gemini 的按时长计费模型引入了一个额外维度——缓存不仅有命中率问题,还有存储成本问题。

六种操纵策略的分类学

对 messages 数组的操纵,按照干预强度从弱到强,可以归纳为六种基本操作。这不是某个工具的专有概念,而是所有 agentic coding 工具在工程实践中收敛出的通用模式。

1
2
3
4
5
6
7
8
9
干预强度

重放 |████████████████████████████████████████ 完全重建数组,无缓存可言
摘要 |██████████████████████████████████ 数组重写,全量缓存失效
丢弃 |████████████████████████ 移除消息,局部缓存失效
截断 |████████████████ 裁剪单条消息,通常不影响缓存
外化 |████████ 搬到数组外,缓存零影响
追加 |████ 默认行为,缓存最友好
└────────────────────────────────────────→ 缓存友好度

追加(Append)

追加是默认行为。每一轮对话在数组末尾添加新的 user / assistant / tool 消息,不对已有内容做任何修改。

这是对 prompt cache 最友好的操作。前缀单调增长,第 n+1 轮请求天然命中前 n 轮的缓存。以 Anthropic 的机制为例,第 10 轮请求只需为第 10 轮新增的消息付全价,前 9 轮的内容以 0.1× 的缓存价读取。

所有工具在 context window 未打顶之前都采用这个策略。问题在于,追加是单调的——数组只增不减,终会撞上 context window 的天花板。一个典型的 agentic coding 会话,每轮工具调用可能产生数千到数万 token 的返回内容(读文件、grep 结果、编译输出),十几轮之后 context window 就开始吃紧。

追加策略的有效距离取决于两个因素:模型的 context window 大小,以及每轮的 token 产出密度。context window 越大(如 Claude 的 200K)、工具调用越轻量,追加策略的有效期就越长。

截断(Truncate)

截断的作用对象是单条消息。当某一条 tool result 的内容超过预设的 token 预算时,只保留前 N 个 token,丢弃其余部分。

Claude Code 的 Tool Result Budget 是这种策略的典型实现。一次 Read 工具调用返回了一个 10000 行的文件,Tool Result Budget 会将其截断到预算范围内。被截断的是这一条 tool result 消息的尾部,数组中的其他消息不受影响。

截断对 prompt cache 的影响取决于被截断消息在数组中的位置。实践中,截断几乎总是作用于当轮的新消息(即数组末尾),因此前缀不变,缓存完全不受影响。如果某种实现对历史消息做回溯截断,则被截断位置之后的缓存都会失效——但这种做法在主流工具中并不常见。

截断的局限在于它只能限制增量。十轮之前的一条 5000 token 的 tool result,已经进入数组、已经被缓存前缀覆盖,截断机制管不着它。

丢弃(Prune / Drop)

丢弃是从数组中移除整条消息。根据移除策略的不同,分为两种模式。

选择性丢弃只移除特定类型的消息,通常是旧的 tool result。opencode 的 compaction.prune = true 就是这种模式——启用后,compaction 过程中会移除旧的 tool output 来节省 token,但保留 user / assistant 消息的对话骨架。Claude Code 的 Microcompact 层(由 API 原生的 context_management 触发)也属于这一类,它会裁掉最旧的若干 tool_result。

选择性丢弃的逻辑是:工具返回的原始数据(文件内容、grep 结果)通常可以重新获取,丢掉原始数据但保留"调用了什么工具、在哪个文件上操作"的记录,信息损失相对可控。

滑动窗口丢弃的做法更简单粗暴:当 context window 满时,直接丢弃数组头部最老的消息,不区分类型。Cursor 和 Windsurf 在 context 满时都采用这种方式。Windsurf 的文档明确记载了这一行为:“when a model’s context window grows too long in Cascade sessions, earlier contexts can be dropped”——没有摘要,没有选择性保留,直接从头部截掉。

两种模式对 prompt cache 的影响是相同的:被移除位置之后的所有缓存失效。从数组头部移除一条消息,等于改变了整个前缀,全部缓存要重建。

两种模式的信息损失则差异很大。选择性丢弃保留了对话骨架(谁问了什么、模型答了什么、调用了哪些工具),只丢掉工具返回的原始数据。滑动窗口丢弃则可能把早期的关键决策连同上下文一起丢掉,导致模型在后续轮次"忘记"早期的架构约定或设计选择。

摘要(Compact / Summarize)

摘要是用 LLM 生成一段凝练的概述,替换数组中的原始消息。这是对 messages 数组最强力的干预,也是工程上最复杂的操作。

操作的核心逻辑:

1
2
3
if token_count(messages) > threshold:
summary = llm.summarize(messages[:-preserve_count])
messages = [system_prompt, summary_as_user_msg] + messages[-preserve_count:]

各家工具的实现差异集中在触发方式、摘要执行者、摘要内容的可见性三个维度。

Claude Code 的 Autocompact 是三段式流程:剥离非文本内容(图片、大型 trace)→ 调用 LLM 生成历史摘要 → 恢复关键文件引用和 skill 状态。摘要后保留最近 N 轮(社区估计 5-10 轮)的原文(preservedSegment),更早的内容被摘要替换。用户可以通过 /compact 命令手动触发,也可以等系统在 context 使用率接近阈值时自动触发。整个过程对用户不可见。

Aider 的 ChatSummary 采用递归摘要策略。它将消息历史按 token 预算切成 head(旧)和 tail(新)两段,在 assistant 消息边界切割,避免切断一组配对的 tool_call / tool_result。head 部分交给 LLM 摘要,如果摘要 + tail 仍然超限,则递归处理(深度上限 3 层)。摘要失败时会尝试多个备选模型作为 fallback。Aider 没有手动触发命令,摘要完全自动,阈值由 --max-chat-history-tokens 控制。

opencode 提供了两个层面的摘要能力。内置 compaction(compaction.auto = true)会在 context 接近上限时自动触发,compaction.reserved = 30000 指定了 compaction 后为新内容保留的 token 预算。此外,opencode Server 暴露了 /session/:id/summarize HTTP 端点,供平台层在 token 超阈值或 phase 结束时主动调用。这个设计反映了 opencode 作为可嵌入后端的定位——上下文管理的决策权可以交给调用方。

Cursor 的 /compress 命令存在但几乎没有文档。社区中更常见的做法是直接开一个新对话。Cursor 的上下文管理哲学更偏向"短对话 + 强检索",而不是"长对话 + 强压缩"。

摘要对 prompt cache 的影响是最大的:数组被重写,前缀完全改变,所有缓存失效,下一次请求要从零开始付 cache write 成本。这就是为什么 Claude Code 的 Autocompact 触发阈值设在较高的位置(社区分析约 75-80% context 容量)而不是 50%——过早压缩意味着更频繁地为缓存重建付费,总成本反而更高。

摘要还有一个根本局限:信息损失不可控。LLM 生成的摘要无法保证保留所有关键信息。它可能漏掉某个微妙的类型约束、某个隐含的并发假设、某段 SDD 流程中的必需字段。在实际项目中观察到的现象是:compact 能减少旧 tool output 和历史对话的体积,但不能保证把 37 万 token 压到 7 万,也不能保证摘要保留了业务流程的必需字段。

摘要只能作为兜底手段,不能当作主要的上下文管理策略。

外化(Externalize)

外化是把信息从 messages 数组搬到外部存储,在需要时通过工具调用或系统注入重新取回。这不是对数组的直接操纵,而是绕过数组限制的间接策略。

Claude Code 的 memory tool 是典型的外化机制。Agent 在对话过程中把关键决策、架构约定、进度状态写入文件系统(CLAUDE.md、memory 文件、TODO list)。即使上下文被压缩甚至清空,这些外化的信息可以通过文件读取重新注入。

Aider 的 repo map 是另一种外化形态。它用 tree-sitter 解析整个代码仓库的结构(类、函数、类型、调用签名),生成一份 token 预算可控的结构摘要(默认约 1K token,通过 --map-tokens 调节)。repo map 作为独立的系统级上下文随每次请求发送,不是 chat history 的一部分——chat history 被摘要压缩时,repo map 不受影响。repo map 使用图排序算法(文件为节点、依赖为边),优先保留被引用最多的标识符,在固定 token 预算内最大化信息密度。

Windsurf 的 Memories 系统把自动生成的上下文笔记存储在 ~/.codeium/windsurf/memories/ 目录下,按工作区隔离。这些 memory 跨对话持久存在,Cascade 判断相关时自动注入。Windsurf 没有 compaction 机制,它的上下文管理哲学就是:短对话 + 外化记忆 + 强检索。

外化对 prompt cache 完全无影响,因为它不修改 messages 数组的内容。外化的代价在别处:外化的信息需要通过工具调用取回,每次取回都会产生新的 token 消耗,且取回的内容会追加到数组中、占据 context window 空间。这是一种"把持久存储成本转换为按需检索成本"的策略。

重放(Replay / Reconstruct)

重放是每次请求时从头重建 messages 数组,而不是在已有数组末尾追加。这是六种策略中对 prompt cache 最不友好的一种。

opencode 的 run 模式就是重放策略的实例。每次执行 opencode run -- "$PROMPT" 时,如果平台层想实现多轮对话,需要将历史消息 replay 成一个文本 prompt:

1
2
3
4
5
6
7
以下是历史对话:
[user] 第一个问题
[assistant] 第一个回答
[user] 第二个问题
[assistant] 第二个回答
...
[user] 当前问题

这段文本作为一个单独的 user message 发送给 opencode。表面上看,文本的前半部分每次都一样,"应该"能命中缓存。但 prompt cache 的前缀匹配是结构化的——它匹配的是 tools → system → messages 中每个 block 的内容和边界,不是一个大文本字符串内部的子串前缀。每次 run 都构造一个新的 message block(内容持续变长),从缓存角度看,每次都是一个全新的请求前缀。

与之对比,opencode 的 serve 模式(opencode serve)维护一个持久的 session,每轮通过 /session/{id}/prompt_async 发送新增的 user message。历史上下文由 session 内部维护,messages 数组单调增长——这就是追加策略,天然适配 prompt cache 的前缀匹配。

run 模式和 serve 模式在功能上等价(都能完成多轮对话),但在 prompt cache 效率上差距巨大。假设每轮产生 3000 token,对话进行到第 20 轮时,run 模式每次请求都要全价发送 60000 token 的历史;serve 模式则只需为最新一轮的 3000 token 付全价,前 57000 token 以 0.1× 读取。在 Anthropic 的定价模型下,run 模式的输入成本可以达到 serve 模式的 5-8 倍。

缓存影响矩阵

六种操纵策略对 prompt cache 的影响汇总:

策略 缓存前缀影响 缓存重建成本 适用场景
追加 无(前缀只增不减) context window 未打顶的常规对话
截断 通常无(只改最新消息尾部) 单条 tool result 过大
选择性丢弃 被移除位置之后全部失效 一次性 cache write context 接近上限且需保留对话骨架
滑动窗口丢弃 全部失效(从头部移除) 一次性 cache write context 打顶且无摘要能力
摘要 全部失效(数组重写) cache write + 摘要 LLM 调用 context 打顶且需保留语义连续性
外化 无(不改数组) 跨对话持久化关键信息
重放 完全无缓存(每次全新构造) 每次请求都是全价 无 session 管理能力的 run 模式

核心权衡是:干预强度与缓存友好度负相关。越强力的操纵(摘要、重放),对缓存的破坏越大。而缓存命中带来的成本节省(Anthropic 场景下高达 90%)往往超过操纵本身节省的 token 量。

Claude Code 宁可让 messages 数组增长到接近 context window 上限才触发 Autocompact,而不是在 50% 时就积极压缩。在 50% 到触发阈值这个区间里,每一轮请求都在享受缓存命中的 90% 折扣。过早压缩意味着放弃这段"缓存有效期",反而推高了总成本。

跨工具对比

五个主流 agentic coding 工具的策略选择:

维度 Claude Code Aider opencode Cursor Windsurf
默认策略 追加 → 自动摘要 追加 → 自动摘要 追加 → 可配置 compact 追加 → 截断 追加 → 截断
触发方式 自动 + 手动 /compact 自动 自动 + API 端点 手动 /compress + 自动截断 自动截断
摘要能力 Autocompact 三段式 + 五级降级 递归摘要(深度上限 3) 内置 compact + /summarize 端点 /compress(文档极少)
选择性丢弃 Microcompact(丢旧 tool_result) prune=true(丢旧 tool output)
外化机制 memory + CLAUDE.md + TODO repo map(tree-sitter 图排序) session 持久化 .cursorrules + notepads Memories 系统
缓存感知 高(围绕 cache_control 构建) 中(--cache-prompts 可选) 取决于底层模型和集成方 依赖提供商侧缓存 依赖提供商侧缓存

五个工具的策略选择分成两条路线。

Claude Code 和 Aider 走"长对话 + 强压缩"路线——允许对话持续很长,通过摘要在 context window 内腾挪空间。代价是摘要机制的工程复杂度高,且每次摘要都会击穿缓存。

Cursor 和 Windsurf 走"短对话 + 强检索"路线——不投入工程复杂度做摘要,而是通过 codebase 索引(Cursor 的 Merkle tree + 双索引架构、Windsurf 的语义知识图谱)确保每次新对话都能快速检索到相关上下文。这条路线把"记住历史"的责任从 messages 数组转移到了外部索引系统,用检索成本替代压缩成本。

opencode 的定位比较特殊。它既支持 serve 模式下的长 session(追加 + 自动 compact),也支持 run 模式下的短 session(重放)。compaction 配置的三个参数给了平台层灵活的调优空间:

1
2
3
4
5
6
7
{
"compaction": {
"auto": true,
"prune": true,
"reserved": 30000
}
}

auto 控制是否自动触发压缩,prune 控制是否在压缩过程中移除旧的 tool output(默认关闭),reserved 指定压缩后为新内容保留的 token 预算。平台层还可以通过 /session/:id/summarize 端点在 phase 切换时主动触发一次摘要。

但 compact 本身的局限——信息损失不可控、无法保证压缩到目标大小——意味着 compaction 只能是长 session 管理中的兜底手段。真正的主解是在架构层面让对话走 serve 模式的增量追加路径,充分利用 prompt cache 的前缀命中。

决策指南

选择哪种策略组合,取决于三个参数:对话预期长度、context window 大小、是否需要跨对话保持状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
对话预期长度
├── 短(< 10 轮)
│ └── 追加即可,不需要任何干预
│ └── prompt cache 自然命中,成本最优

├── 中(10-50 轮)
│ ├── context window 够大(200K)
│ │ └── 追加 + 截断(限制单条 tool result 大小)
│ │ └── 摘要作为安全网
│ └── context window 较小(32K-128K)
│ └── 追加 + 选择性丢弃(prune 旧 tool result
│ └── 自动 compact 兜底

└── 长(50+ 轮)/ 跨会话
└── 追加 + 外化(关键信息写入外部存储)
+ 自动 compact 周期性触发
└── 新会话通过外化信息恢复上下文

一条贯穿所有场景的原则:尽量推迟对 messages 数组的破坏性操作。每一次丢弃或摘要都会击穿 prompt cache,引发一次缓存重建的成本。在缓存带来 90% 输入折扣的定价模型下,保持数组的前缀稳定性,往往比积极压缩更省钱。

另一个容易忽视的决策点是会话模式的选择。如果底层工具支持 serve 模式(如 opencode serve),走 serve 模式的增量追加路径远优于 run 模式的历史重放。这个选择发生在架构层面,效果却比 compaction 配置的调优大一个数量级。

参考资料