1. 从一次账单异常说起:Prompt 缓存到底在解决什么问题
去年年底帮一个做 AI 应用的朋友排查账单问题,他们团队接了一个大模型的 API,做的是文档摘要类的产品。上线第一周还好,第二周开始账单突然翻了三倍,但用户量并没有明显增长。把请求日志拉出来一看,问题很清晰:他们的系统提示词(system prompt)有将近 2000 个 token,每次用户请求都要重新发一遍,而用户实际输入的问题往往只有几十个字。也就是说,每次调用里 95% 以上的 token 都是重复的,但计费系统不管你重不重复,照单全收。
这就是 Prompt 缓存要解决的核心问题。大模型的推理成本里,输入 token 的处理占了相当大一块,尤其是当你的提示词里包含大量固定内容——系统角色设定、few-shot 示例、知识库片段、格式约束——这些内容在多次请求之间几乎不变,却每次都要重新走一遍完整的编码和注意力计算。Prompt 缓存做的事情,就是把这部分不变的前缀在服务端"记住",后续请求命中缓存时直接复用中间计算结果,从而降低延迟、降低费用。
关键词里的cache_control、断点、计费三个词,恰好对应了这项能力的三个核心维度:怎么标记哪些内容该缓存(cache_control)、缓存的作用范围到哪里为止(断点)、以及缓存命中后钱怎么算(计费)。这三个维度不是孤立的,它们互相牵制——断点位置决定了缓存粒度,缓存粒度又直接影响计费模型,而计费模型反过来会指导你怎么设计 cache_control 的标记策略。
这篇文章适合两类人看:一类是正在用大模型 API 做产品、被账单和延迟困扰的开发者;另一类是想搞清楚"缓存"这个概念在 LLM 场景下和传统 Web 缓存、Redis 缓存到底有什么本质区别的技术人。我会尽量把原理讲透,同时给出可以直接抄的实操配置和踩坑经验。需要说明的是,不同厂商的具体实现细节和计费规则会有差异,文中涉及具体参数的地方我会以"常见实践"的方式说明,你落地时务必对照自己所用平台的官方文档。
2. Prompt 缓存的底层机制:它和 Redis 缓存根本不是一回事
2.1 传统缓存 vs Prompt 缓存:缓存的对象完全不同
很多人第一次听到"Prompt 缓存",下意识会往 Redis、Memcached 那套思路上靠——把某个 key 对应的 value 存起来,下次用同样的 key 直接取。这个类比方向对,但细节全错,而且错得很危险,因为它会导致你对命中率产生完全错误的预期。
传统缓存缓存的是结果。比如一个数据库查询,SQL 语句作为 key,查询结果作为 value,下次同样的 SQL 直接返回结果,连数据库都不碰。而 Prompt 缓存缓存的是中间计算状态,具体来说,是 Transformer 模型在处理输入序列时产生的 Key-Value 矩阵,也就是常说的KV 缓存。热词里出现的"kv缓存"正是这个意思。
这个区别带来一个关键后果:传统缓存要求 key 完全一致才能命中,而 Prompt 缓存只要求前缀一致。你的提示词是"你是一个专业的法律助手……(2000字)……用户问题:XX",只要前面那 2000 字一模一样,哪怕后面的用户问题千变万化,前缀部分的 KV 缓存都能复用。这就是为什么它对摘要类、客服类、RAG 类应用效果特别明显——这些场景的固定前缀占比极高。
2.2 为什么必须是前缀:自回归生成的因果性约束
这里有个绕不开的问题:为什么缓存只能作用于前缀,不能缓存中间某一段?
答案藏在 Transformer 的注意力机制里。模型生成每一个 token 时,都要对前面所有 token 做注意力计算。第 N 个 token 的计算依赖于第 1 到 N-1 个 token 的全部信息。这意味着 KV 缓存是严格累积的——你要复用第 500 个 token 的缓存,就必须先有第 1 到 499 个 token 的缓存。中间挖掉一段,后面的缓存就全部失效了。
打个比方,这就像你读一本书做笔记,笔记是逐页累积的。你可以从第 1 页开始复用之前的笔记,但你不能跳过前 100 页直接复用第 101 页的笔记,因为第 101 页的理解建立在前 100 页之上。
这个约束直接决定了 cache_control 的设计逻辑:你标记的缓存断点,实际上是在告诉系统"从这里往前的所有内容,请帮我缓存起来"。断点不是缓存某一段的起点,而是缓存整个前缀的终点。
2.3 断点(breakpoint)的真实含义
"断点"这个词在热词里和"代码断点""断点续训""断点续播"混在一起,容易让人误解成调试用的断点。在 Prompt 缓存的语境下,断点的准确含义是缓存边界标记——你在提示词的某个位置打一个标记,表示"这个位置之前的内容构成一个可缓存的单元"。
一次请求里通常可以打多个断点,形成多级缓存。比如:
- 断点 1:系统角色设定 + 全局约束(几乎永不变)
- 断点 2:few-shot 示例(偶尔调整)
- 断点 3:检索到的知识库片段(每次请求可能不同)
这样设计的好处是,当只有知识库片段变化时,前两级缓存依然有效,你只需要为第三级重新计算。如果只打一个断点放在最末尾,那知识库一变,整个前缀缓存全废。
注意:断点数量通常有上限(常见是 4 个),而且断点之间的内容越稳定,缓存收益越高。不要为了"多缓存"而乱打断点,每个断点都会带来额外的元数据开销。
2.4 缓存的生命周期:TTL 与失效
Prompt 缓存不是永久的。服务端通常会设置一个 TTL(生存时间),常见范围是几分钟到一小时不等。超过 TTL 没有命中,缓存就被回收,下次请求需要重新建立。
这里有个容易被忽略的点:缓存的建立本身是有成本的。第一次请求时,系统不仅要正常推理,还要额外把 KV 状态写入缓存,这个写入操作可能比普通请求稍慢或稍贵。所以缓存策略的本质是一场赌博——你赌这个前缀在 TTL 内会被再次使用。如果某个前缀一天只用一次,那缓存它纯属浪费。
热词里的"缓存失效""缓存一致"在 Prompt 场景下也有对应含义:当你修改了系统提示词的一个字,整个前缀的哈希就变了,之前所有缓存全部失效,需要重新建立。这就是为什么提示词版本管理在 LLM 应用里格外重要——一次不小心的文案微调,可能让缓存命中率从 90% 掉到 0。
3. cache_control 标记实战:怎么打标记才能省钱
3.1 cache_control 的基本语法与放置位置
cache_control 是标记缓存断点的字段,通常以 JSON 对象的形式附加在消息内容块上。以常见的消息结构为例,它的形态大致是这样:
{ "role": "system", "content": [ { "type": "text", "text": "你是一个专业的法律助手,请根据以下规则回答……(此处省略2000字)", "cache_control": { "type": "ephemeral" } } ] }关键点在于cache_control是挂在内容块上的,而不是挂在整条消息上。这意味着你可以对同一条消息里的不同段落分别打标记。ephemeral表示这是临时缓存,遵循服务端的 TTL 策略。
放置位置有一条铁律:标记要打在缓存内容的最后一个块上。因为断点的语义是"到此为止,前面的都缓存",所以你把标记打在系统提示词的末尾,就表示整个系统提示词都被纳入缓存。
3.2 多级断点的划分策略
我一般建议按"变化频率"来划分断点层级,而不是按"内容长度"。下面这张表是我在实际项目里总结的划分参考:
| 层级 | 内容类型 | 变化频率 | 是否打断点 | 预期命中率 |
|---|---|---|---|---|
| 第一级 | 系统角色、全局规则、输出格式约束 | 几乎不变 | 是 | 95%+ |
| 第二级 | few-shot 示例、领域知识模板 | 按版本迭代 | 是 | 70%-90% |
| 第三级 | RAG 检索片段、用户画像 | 每请求变化 | 视情况 | 20%-50% |
| 第四级 | 用户当前问题 | 每请求必变 | 否 | 0% |
第三级要不要打断点,取决于你的检索策略。如果你的 RAG 是"固定知识库 + 动态检索",且检索结果经常重复(比如热门问题总是命中同一批文档),那第三级打断点是划算的。但如果检索结果高度分散,每个用户查到的片段都不一样,那第三级缓存基本不会命中,打断点反而增加开销。
3.3 一个真实的优化案例:从 0 命中到 85% 命中
回到开头那个朋友的案例。他们最初的提示词结构是这样的:
[系统角色 500字] + [few-shot 示例 800字] + [用户上传文档 3000字] + [用户问题 50字]他们一开始把 cache_control 打在了最末尾,也就是用户问题后面。结果命中率极低,因为用户上传的文档每次都不同,导致整个前缀缓存全部失效。
调整后的结构:
[系统角色 500字] ← 断点1 [few-shot 示例 800字] ← 断点2 [用户上传文档 3000字] [用户问题 50字]把断点前移到 few-shot 示例末尾,让系统角色和示例这两块稳定内容独立成缓存单元。调整后,前两级缓存命中率稳定在 85% 以上,账单直接降了六成多。这个案例说明一个道理:断点位置的选择,比缓存本身的技术实现更影响最终收益。
3.4 标记时的常见错误
我见过几种典型的错误标记方式,这里列出来供你对照自查:
- 错误一:把断点打在用户问题上。用户问题每次都变,缓存它等于没缓存,还白白占用一个断点名额。
- 错误二:断点之间内容太短。如果两个断点之间只有几十个 token,缓存收益可能覆盖不了元数据开销。一般建议单个缓存单元至少几百 token 起步。
- 错误三:在动态内容中间打断点。比如在 RAG 片段中间打断点,导致后半段片段每次都要重算,缓存了个寂寞。
- 错误四:忘记同步更新缓存版本。修改了系统提示词却没意识到缓存已失效,还在纳闷为什么命中率突然归零。
提示:每次修改提示词后,建议在日志里记录一个"提示词版本号",方便对照缓存命中率的变化。这个习惯能帮你快速定位"是不是我改了提示词导致的"。
4. 计费模型拆解:缓存到底能省多少钱
4.1 缓存写入、缓存命中、普通输入的三档定价
Prompt 缓存的计费通常分三档,理解这三档的差异是算清账的前提:
| 计费类型 | 说明 | 相对价格(常见区间) |
|---|---|---|
| 普通输入 | 未命中缓存的输入 token | 1x(基准) |
| 缓存写入 | 首次建立缓存时的输入 token | 1.25x 左右 |
| 缓存命中 | 命中缓存复用的输入 token | 0.1x - 0.25x |
注意缓存写入是比普通输入更贵的。这是很多人算账时容易忽略的点——你为了建立缓存,第一次要多付 25% 左右的费用。所以缓存能不能省钱,取决于这个前缀被复用的次数。
4.2 盈亏平衡点怎么算
假设某前缀有 P 个 token,缓存写入溢价为 25%,缓存命中折扣为 90%(即命中价是普通价的 10%)。设这个前缀在 TTL 内被使用 N 次。
- 不用缓存的总成本:
N × P × 1 - 用缓存的总成本:
P × 1.25 + (N-1) × P × 0.1
令两者相等:
N = 1.25 + (N-1) × 0.1 N = 1.25 + 0.1N - 0.1 0.9N = 1.15 N ≈ 1.28也就是说,只要这个前缀在 TTL 内被使用超过 2 次,缓存就开始省钱。这个门槛其实很低,所以对于高频调用的固定前缀,缓存几乎是稳赚的。
但这里有个隐藏变量:TTL。如果 TTL 是 5 分钟,而你的前缀平均 10 分钟才被用一次,那缓存永远等不到第二次命中就失效了,你反而多付了 25% 的写入溢价。所以TTL 长度和调用频率的匹配,是缓存策略里最需要实测的部分。
4.3 输出 token 不参与缓存计费
需要特别澄清一点:Prompt 缓存只作用于输入,输出 token 的计费不受影响。因为输出是模型实时生成的,每个 token 都是新的,没有"复用"的可能。所以如果你的应用输出很长(比如长文生成),缓存能帮你的比例就相对有限;反之,如果你的应用输入很长、输出很短(比如分类、抽取、问答),缓存的收益就非常显著。
这个特性决定了缓存策略的适用边界。我一般会先算一个"输入输出比":输入 token 除以输出 token。这个比值大于 5 的场景,缓存值得重点优化;小于 2 的场景,优化缓存的投入产出比就不高了。
4.4 一个容易踩的计费坑:缓存写入的重复触发
有些平台的缓存写入是按"缓存单元"计费的,如果你在短时间内反复修改提示词,每次修改都会触发一次新的缓存写入,每次都要付 1.25x 的溢价。我见过一个团队在调试阶段频繁改提示词,一天之内触发了上百次缓存写入,调试成本比正常调用还高。
规避方法很简单:调试阶段关掉缓存标记,等提示词稳定后再开启。或者把调试用的提示词和线上提示词分开,调试走不带 cache_control 的通道。
5. 断点续训与断点续播:别被热词带偏了方向
5.1 这些"断点"和 Prompt 缓存没关系
热词列表里出现了"断点续训""断点续播""代码断点错误查询""qtcreator怎么下数据断点"这些词,它们和 Prompt 缓存里的"断点"只是共享了同一个中文词,技术含义完全不同。
- 断点续训:指模型训练过程中保存 checkpoint,中断后从 checkpoint 恢复继续训练。它缓存的是模型权重和优化器状态。
- 断点续播:指视频播放中断后从上次位置继续。它缓存的是播放进度。
- 代码断点:指调试器在指定代码行暂停执行。它是调试工具的功能。
之所以要专门澄清,是因为我见过有人把这几件事混为一谈,以为"断点续训"的 checkpoint 机制能用来优化 Prompt 缓存,结果方向完全跑偏。Prompt 缓存的断点是提示词内部的边界标记,不是训练或调试概念。
5.2 真正相关的邻近概念:KV 缓存与分布式缓存
如果说有哪个概念和 Prompt 缓存最接近,那是KV 缓存。前面讲过,Prompt 缓存缓存的就是 KV 矩阵。在单机推理场景下,KV 缓存是显存里的一块区域;在多机分布式推理场景下,KV 缓存可能被放到分布式存储里共享,这就和"分布式缓存"产生了交集。
热词里的"分布式缓存""redis缓存""java 轻量缓存 ttl"这些,属于传统后端缓存范畴。它们和 Prompt 缓存的关系是"同名不同物"——都是缓存,但缓存的对象、失效机制、命中条件完全不同。理解这个区别,能帮你在技术选型时不被名词误导。
5.3 一个实用的心智模型
我给团队新人讲这块时,会用这样一个类比:
传统缓存像图书馆的借阅记录——你借过的书,下次来直接给你,书的内容没变。 Prompt 缓存像你读书时做的笔记——笔记是你理解过程的中间产物,下次读同一本书的前半部分,你可以直接看笔记跳过重读,但笔记只对"同一本书的前半部分"有效,换本书就作废。
这个类比抓住了两个关键:缓存的是中间状态而非最终结果,以及缓存严格依赖前缀一致性。
6. 落地时的实测经验与避坑清单
6.1 先测量,再优化
我强烈建议在动手改 cache_control 之前,先做一轮基线测量。具体要采集的数据包括:
- 每次请求的输入 token 数、输出 token 数
- 提示词各段的长度占比(系统角色多少、示例多少、动态内容多少)
- 请求的时间分布(判断 TTL 内能否命中)
- 当前的实际命中率(如果平台提供这个指标)
没有这组数据,你的优化就是盲猜。我见过太多人一上来就打断点,结果因为前缀本身就不稳定,命中率上不去,还以为是缓存功能不好用。
6.2 提示词结构要为缓存而设计
缓存友好型的提示词结构,和人类可读的提示词结构,往往不完全一致。为了最大化缓存收益,我通常会把提示词组织成"稳定在前、动态在后"的顺序:
[最稳定的系统角色] → [较稳定的示例] → [半动态的上下文] → [完全动态的用户输入]这个顺序既符合缓存的前缀复用逻辑,也符合模型的理解习惯——先给背景,再给具体任务。反过来说,如果你把动态内容放在前面,稳定内容放在后面,缓存基本无从谈起。
6.3 监控命中率,设置告警
缓存命中率是个会"悄悄劣化"的指标。提示词改一个字、TTL 调短一点、流量模式变化,都可能让命中率下滑。建议把命中率纳入日常监控,设置一个阈值告警(比如低于 60% 就报警)。这样你能在账单暴涨之前发现问题。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 命中率突然归零 | 提示词被修改 | 对比提示词版本号 |
| 命中率持续偏低 | 断点位置不当 | 检查断点是否打在动态内容上 |
| 账单不降反升 | 缓存写入溢价超过命中节省 | 计算盈亏平衡点,检查调用频率 |
| 延迟没有改善 | 缓存未命中或 TTL 太短 | 检查 TTL 设置与请求间隔 |
| 部分请求报错 | 断点数量超限 | 检查断点数量是否超过平台上限 |
6.5 一个反直觉的经验
最后分享一个我踩过的坑:不是所有场景都适合开缓存。有一次我帮一个低频内部工具接缓存,那个工具一天调用不到 20 次,每次间隔几小时。结果缓存从来没命中过,反而每次都要付写入溢价,成本比不开缓存还高。后来我把 cache_control 去掉,账单立刻恢复正常。
这件事让我明白,缓存是个"高频场景的优化手段",不是"万能省钱开关"。判断要不要开,先问自己三个问题:这个前缀稳定吗?它在 TTL 内会被复用吗?复用次数够覆盖写入溢价吗?三个都是"是",再开不迟。
对于提示词工程本身,缓存只是其中一环。真正决定效果和成本的,还是提示词的内容质量和结构设计。缓存能帮你把好的提示词用得更便宜,但救不了一个本身就设计糟糕的提示词。