12 · Prompt 缓存:省钱省延迟最立竿见影的一招

系统提示和工具定义每轮重发、重算,又贵又慢。Prompt 缓存让稳定的前缀被复用,cache-read 的价格常只有正常输入的十分之一。讲清缓存断点怎么放、滚动断点如何跟住增长的历史,以及那条“改一个字节、缓存全废”的字节稳定性红线。

作者 David更新于

12 · Prompt 缓存:省钱省延迟最立竿见影的一招

上一篇我们看清了上下文由哪些层拼成,也埋下了那条“静态放高处”的原则。这一篇,兑现它的价值——用 Prompt 缓存,把靠前那些稳定的部分从“每轮重算”变成“一次算、多轮复用”。这是整个 Agent 里投入产出比最高的一个优化。

一、背景:你在为同一段文字反复付钱

看清上一篇那个分层后,一个浪费触目惊心:

  • 系统提示几千 token(行为规范、工具指南),每轮不变。
  • 工具定义几十个工具的 schema,又是几千 token,每轮不变。
  • 可是模型无状态,每一轮你都得把这几千 token 原样重发,模型也每一轮都从头重新处理它们。

一个几十轮的 Agent 会话,同一段系统提示被重新计算了几十遍。你在为完全相同的一段文字,反复付钱、反复等待。 这纯属浪费——它每轮都一模一样,凭什么要重算?

二、解决方案:缓存稳定的前缀

Prompt 缓存的思路:让 API 把 prompt 的某个前缀缓存下来;下一个请求只要前缀一字不差,就直接复用缓存的计算结果,跳过重算。

  • 命中缓存的部分,按 cache-read 计价——通常只有正常输入 token 价格的约十分之一,而且延迟大幅降低(模型不用重读它)。
  • 第一次建立缓存,有一点点额外的 cache-write 开销(略高于正常输入),但后续每一轮都在赚回来。

用法上,你在 prompt 里打若干缓存断点(cache breakpoint),告诉 API “缓存到这里为止”:

在稳定前缀的末尾打缓存断点
await client.messages.create({
  model,
  // 系统提示以"块"形式给出,末尾打一个缓存断点
  system: [
    { type: 'text', text: bigSystemPrompt, cache_control: { type: 'ephemeral' } },
  ],
  // 工具定义也很稳定,在最后一个工具上打断点,缓存整个工具区
  tools: [...tools.slice(0, -1), { ...lastTool, cache_control: { type: 'ephemeral' } }],
  messages,
})

响应的 usage 会告诉你缓存命中情况:

usage 里的缓存账单
{
  cache_creation_input_tokens: 5200,  // 这次写入缓存的量(第一轮)
  cache_read_input_tokens: 0,
  // ——下一轮——
  cache_creation_input_tokens: 0,
  cache_read_input_tokens: 5200,      // 这次命中缓存的量(便宜十倍!)
  input_tokens: 130,                  // 只有真正新增的部分按全价算
}

三、关键机制:前缀缓存 + 滚动断点

缓存是“前缀”的,不是“任意片段”的

缓存匹配的是从 prompt 最开头算起、一字不差的前缀**。** 这有两个硬含义:

  1. 断点之前的所有内容必须完全稳定——所以稳定的东西(系统提示、工具)要放最前面(这就是上一篇“静态放高处”的兑现)。
  2. 你不能只缓存“中间某段”。缓存永远是“从头到某个断点”的一整段。

滚动断点:让缓存跟住增长的历史

系统提示和工具好办(它们不变)。但消息历史是一直增长的——难道历史部分就没法缓存了?

有办法:在历史里也放一个“滚动”的断点,让它随对话往前移。 关键洞察是——一轮结束后,本轮的历史会成为下一轮历史的稳定前缀。所以你把断点打在“当前历史的末尾附近”,下一轮这段历史没变,就整段命中缓存,只有最新增的一两条消息按全价算。

滚动断点:历史越长,被缓存的前缀越长
第 N 轮:  [系统][工具][—— 历史 1..N ——|断点]  ← 缓存写入到这
第 N+1 轮:[系统][工具][—— 历史 1..N ——][新增 N+1|断点]
                       └─ 这段整个命中缓存 ─┘  └ 只有这点按全价

于是一个长会话里,绝大部分上下文每轮都在走 cache-read,只有最新的增量按全价。成本和延迟被压到极低。

四、那条不容触碰的红线:字节稳定性

现在讲这一篇、也是整个上下文工程里最凶险的一个坑。缓存靠“字节级前缀匹配”,这意味着:

断点之前的内容,哪怕只变了一个字节,从那一点往后的缓存全部作废**。** 这条红线,会以各种隐蔽的方式咬你:

  • 在系统提示开头放了动态数据(精确到秒的时间戳、一个随机 ID)——每轮都变,缓存从头就废,你以为开了缓存,其实一次都没命中。
  • 顺手“规范化”了一条要复用的历史消息(把相对路径改成绝对路径、重新格式化了 JSON)——从那条消息往后,缓存全废。
  • 工具的顺序不稳定(每轮动态拼工具列表,顺序还会变)——工具区的缓存废掉。
  • 同一段内容,这轮用了紧凑 JSON、下轮用了带缩进的——字节不同,缓存不认。

这条红线直接决定了一个贯穿代码的纪律,也解释了上一篇那个“别原地改要复用的消息”的告诫:

凡是会流回下一轮 API 的消息,必须保持字节不变;要给观察者(UI、日志)加工过的版本,请拷贝一份去改,别动原件。 这就是为什么成熟 Agent 的代码里,处处能看到“克隆一份用于展示/序列化,原始消息原封不动送回 API”的写法——不是洁癖,是每一次原地修改,都可能是一次静默的缓存击穿,而缓存击穿在账单和延迟上都是实打实的损失,还极难排查(功能正常,就是莫名其妙又贵又慢)。

五、和压缩的微妙关系

缓存和下一篇的压缩是一对需要小心协调的机制:

  • 压缩会重写历史(把一大段老消息换成一段摘要)。这必然改变字节,所以压缩点之后的缓存会失效——压缩省了上下文,却付出了一次缓存重建的代价。
  • 正因如此,精细的做法会尽量让压缩只动很老、本来也快过期的那部分缓存,而保住近期的热缓存;甚至有“按 tool_use_id 精确删除某几条工具结果、不碰其余字节”的技巧(下下篇会讲),就是为了在减负的同时,尽量少破坏缓存。

“省”(缓存)和“减”(压缩)这两条路,在这里交汇并互相牵制——这也是上下文工程之所以难的原因:它不是几个独立技巧,而是一组互相拉扯、需要通盘权衡的约束。

六、小结

Prompt 缓存是那种“投入极小、回报极大”的优化:给稳定前缀打几个断点,长会话的成本和延迟就能降一大截。但它有一条不容触碰的红线——字节稳定性:

  1. 稳定的放前面(系统提示、工具),动态的放后面。
  2. 在增长的历史里用滚动断点,让缓存跟住。
  3. 凡是回流 API 的消息,一个字节都别原地改;要改就拷贝。

缓存解决了“重复的部分别重算”。但它救不了另一半问题:当历史本身膨胀到装不下时,你不能光靠缓存,你得真的把它变小。下一篇自动压缩,我们正面处理这头猛兽。


本篇对应代码:配套仓库给系统提示与工具定义加上缓存断点,并在 usage 里打印缓存命中(src/agent.ts)。git checkout article-12。

本页目录