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

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

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-07-12
- 原文：https://daiw.org/manual/llm-to-agent/prompt-caching
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

[上一篇](https://daiw.org/manual/llm-to-agent/context-injection)我们看清了上下文由哪些层拼成，也埋下了那条“静态放高处”的原则。这一篇，兑现它的价值——用 Prompt 缓存，把靠前那些稳定的部分从“每轮重算”变成“一次算、多轮复用”。这是整个 Agent 里**投入产出比最高**的一个优化。

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

看清[上一篇](https://daiw.org/manual/llm-to-agent/context-injection)那个分层后，一个浪费触目惊心：

- **系统提示**几千 token(行为规范、工具指南)，每轮不变。
- **工具定义**几十个工具的 schema，又是几千 token，每轮不变。
- 可是[模型无状态](https://daiw.org/manual/llm-to-agent/raw-llm-api)，**每一轮你都得把这几千 token 原样重发**，模型也**每一轮都从头重新处理**它们。

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

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

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

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

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

```ts title="在稳定前缀的末尾打缓存断点"
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` 会告诉你缓存命中情况：

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

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

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

<Callout type="warn">
  **缓存匹配的是从 prompt **最开头**算起、一字不差的**前缀**。** 这有两个硬含义：
  1. 断点之**前**的所有内容必须完全稳定——所以稳定的东西(系统提示、工具)要放最前面(这就是[上一篇](https://daiw.org/manual/llm-to-agent/context-injection)“静态放高处”的兑现)。
  2. 你不能只缓存“中间某段”。缓存永远是“从头到某个断点”的一整段。
</Callout>

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

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

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

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

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

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

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

<Callout type="warn">
  **断点之前的内容，哪怕只变了一个字节，从那一点往后的缓存**全部作废**。** 这条红线，会以各种隐蔽的方式咬你：

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

这条红线直接决定了一个贯穿代码的纪律，也解释了[上一篇](https://daiw.org/manual/llm-to-agent/context-injection)那个“别原地改要复用的消息”的告诫：

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

## 五、和压缩的微妙关系

缓存和[下一篇的压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)是一对需要小心协调的机制：

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

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

## 六、小结

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

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

缓存解决了“重复的部分别重算”。但它救不了另一半问题：当历史本身**膨胀到装不下**时，你不能光靠缓存，你得真的**把它变小**。下一篇[自动压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)，我们正面处理这头猛兽。

---

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