1. 从一个被忽视的账单说起:Prompt 缓存到底在解决什么问题
如果你最近半年在调用大模型 API 做产品,大概率经历过这样的场景:一个多轮对话的 Agent,每轮都要把系统提示词、工具定义、历史对话重新塞进请求里。用户聊到第十轮,输入 token 已经堆到两三万,账单跟着水涨船高,延迟也肉眼可见地变长。更让人头疼的是,这些内容里有大量重复——系统提示词一个字没变,工具定义一个字没变,变的只是最后那句用户提问。
Prompt 缓存(Prompt Caching)就是冲着这个痛点来的。它的核心逻辑非常朴素:如果请求的前缀部分和上一次完全一致,服务端就直接复用上次已经算好的中间状态,不再重复计算,同时按更低的费率计费。这件事在工程上的价值,等同于给一个反复读取同一张大表的查询加了物化视图。
但真正落地的时候,问题就来了。缓存不是"开了就省钱"这么简单,它牵扯到三个必须搞清楚的机制:计费怎么算、断点在哪里、cache_control 怎么标。我见过太多团队兴冲冲开了缓存,结果账单没降反升,或者命中率低得可怜,根本原因就是没吃透这三件事之间的关系。
这篇文章适合三类人看:一是正在做 LLM 应用、被 token 成本压得喘不过气的后端和算法工程师;二是负责成本优化、需要给老板解释"为什么这个月账单降了 40%"的技术负责人;三是刚接触 prompt engineering、想搞清楚缓存这块到底怎么玩的新手。我会把计费模型、断点机制、cache_control 的标注方法、命中率排查这几块掰开揉碎讲清楚,尽量做到你看完就能上手改自己的代码。
先给一个最直观的结论:Prompt 缓存省钱的本质,是用"写入缓存的溢价"换"后续读取的折扣",只有当同一段前缀被复用的次数超过盈亏平衡点,你才是真的赚了。这个平衡点具体是多少,后面会算给你看。
2. 计费模型拆解:缓存写入、缓存读取、普通输入的三档价差
要理解 Prompt 缓存的计费,先得接受一个反直觉的事实:缓存不是免费的,写入缓存本身要加钱。这跟很多人想象的"缓存 = 省钱"完全不一样。服务商的逻辑是:我帮你把这段内容的中间状态存下来了,占了我的存储和内存资源,所以写入时收你一点溢价;但你后续读取时,我不用重新算,所以给你一个大折扣。这是一个典型的"先投入后收益"模型。
2.1 三档计费口径的对比
以目前主流服务商的公开口径为例(具体数字各家不同,但比例关系大同小异),输入 token 通常被分成三档:
| 计费类型 | 相对基准价 | 触发条件 | 典型倍率 |
|---|---|---|---|
| 普通输入(cache miss) | 1.0x | 前缀未命中缓存 | 基准 |
| 缓存写入(cache write) | 1.25x | 首次写入或缓存过期后重建 | 溢价约 25% |
| 缓存读取(cache read) | 0.1x | 前缀命中已有缓存 | 折扣约 90% |
这张表是理解一切的钥匙。注意几个关键点:
第一,缓存写入的溢价通常只在"建立缓存"那一次发生。也就是说,你第一次发请求,那段前缀被写入缓存,这次按 1.25 倍计费;第二次发同样的前缀,命中缓存,按 0.1 倍计费。如果你只发一次,那你就白付了 25% 的溢价,一点没省。
第二,缓存读取的折扣力度非常大,通常是 90% 左右。这意味着只要命中一次,就能把写入的溢价赚回来还有富余。算一笔账:写入溢价 0.25,读取省下 0.9,那么命中一次就净赚 0.65。命中两次净赚 1.55。所以命中率是王道。
第三,缓存有存活时间(TTL)。主流实现里,缓存通常存活 5 分钟左右,每次命中会刷新这个计时。如果你的请求间隔超过 TTL,缓存就失效了,下次又得重新写入。这一点对低频调用的场景非常致命——你以为开了缓存,实际上每次都在重建。
2.2 盈亏平衡点怎么算
我把公式写出来,你可以直接套:
设普通输入单价为 P,缓存写入溢价为 0.25P,缓存读取节省为 0.9P 设一段前缀被复用 N 次(含首次写入) 总成本 = 1.25P(首次写入) + (N-1) × 0.1P(后续读取) 不开缓存成本 = N × P 盈亏平衡:1.25 + 0.1(N-1) < N 解得:N > 1.167也就是说,同一段前缀只要被复用超过 1.17 次,也就是复用 2 次以上,缓存就开始省钱。这个门槛低得惊人,几乎任何多轮对话场景都能轻松跨过。但前提是——你得真的命中,而不是每次都 miss。
注意:这里的倍率是行业常见口径的近似值,不同服务商、不同模型的具体数字会有差异,务必以你实际使用的服务商文档为准。但比例关系(写入溢价小、读取折扣大)是普遍规律。
2.3 为什么写入要收溢价
很多人不理解为什么写入要加钱。从服务商角度想就通了:缓存写入意味着要把这段内容的 KV 状态(注意力机制里的 key-value 张量)持久化到高速存储里,还要维护索引、处理过期、保证一致性。这些都是有成本的。而读取时,这些状态已经现成,直接拿来用,计算量几乎为零,所以能给出极低的折扣。
从你的角度,这个溢价其实是一种"押金"——你赌这段前缀会被复用,赌赢了就大赚,赌输了就多付 25%。所以判断一段内容值不值得缓存,本质上是在判断它的复用概率。
3. 断点机制:缓存到底在哪里"断"开
理解了计费,接下来是最容易踩坑的部分:断点。Prompt 缓存不是把整个请求都缓存起来,而是按前缀匹配,从开头一直匹配到某个断点为止。断点之后的内容,哪怕只差一个字,也会导致整段缓存失效。
3.1 前缀匹配的严格性
缓存匹配是逐 token 严格比对的。这意味着:
- 系统提示词里多一个空格、少一个换行,缓存直接 miss
- 工具定义的顺序换了一下,缓存直接 miss
- 时间戳、随机 ID、用户昵称这类动态内容如果放在前缀里,缓存永远命中不了
我见过最典型的翻车案例:有人在系统提示词里塞了当前时间:2024-xx-xx xx:xx:xx,结果每次请求时间都不一样,缓存命中率 0%。这种错误看起来低级,但在实际项目里非常常见,因为大家习惯性地把"上下文信息"都堆在开头。
3.2 断点标记 cache_control 的作用
断点是通过cache_control这个字段来标记的。它的语义是:"从这里往前的所有内容,请缓存起来"。也就是说,你在请求体里某个内容块的末尾打一个cache_control: {"type": "ephemeral"},服务端就会把从请求开头到这个块结尾的所有内容作为一个缓存单元。
一个典型的结构长这样:
{ "system": [ { "type": "text", "text": "你是一个专业的客服助手,负责处理订单查询...(此处省略 2000 字系统提示词)", "cache_control": {"type": "ephemeral"} } ], "tools": [ { "name": "query_order", "description": "根据订单号查询订单状态...", "input_schema": {...} } ], "messages": [ {"role": "user", "content": "帮我查一下订单 12345"} ] }这里cache_control打在系统提示词块的末尾,意味着系统提示词这段会被缓存。下次请求如果系统提示词一字不差,就能命中。
3.3 多个断点与缓存层级
高级用法是打多个断点,形成缓存层级。比如:
- 断点 1:系统提示词(最稳定,几乎不变)
- 断点 2:工具定义 + 系统提示词(较稳定)
- 断点 3:历史对话 + 工具定义 + 系统提示词(随对话增长)
这样设计的好处是,即使历史对话变了,系统提示词和工具定义那两层缓存依然能命中。服务端会从最长的匹配前缀开始复用,逐层回退。
但要注意,断点数量通常有上限(常见是 4 个),而且每多一个断点就多一次写入成本。所以不是越多越好,要按"稳定性分层"来设计:越靠前的内容越稳定,越靠后的越易变。
3.4 断点位置的实操判断
怎么决定断点打在哪?我的经验是问自己三个问题:
- 这段内容在多次请求间是否逐字节一致?
- 这段内容的体量是否足够大(通常建议至少几百 token,太小不值得)?
- 这段内容的复用频率是否够高(间隔是否在 TTL 内)?
三个都 yes,就打断点。有一个 no,就别浪费写入溢价。
4. cache_control 实战:从零改造一个多轮对话应用
光讲原理不够,我拿一个真实的多轮客服 Agent 场景,把改造过程完整走一遍。假设你原来有一个请求长这样:
import time def build_request(user_input, history): system_prompt = """你是一个电商客服助手。 当前时间:{now} 用户等级:{level} ...(此处省略 1500 字规则说明) """.format(now=time.strftime("%Y-%m-%d %H:%M:%S"), level="黄金会员") messages = [{"role": "system", "content": system_prompt}] messages.extend(history) messages.append({"role": "user", "content": user_input}) return messages这段代码有两个致命问题:系统提示词里塞了动态时间,导致每次都不一样;没有打 cache_control,服务端根本不知道要缓存。
4.1 第一步:剥离动态内容
把动态内容从系统提示词里挪出来,放到消息末尾或者用户消息里:
def build_request(user_input, history, user_level): # 静态部分:完全不变,适合缓存 static_system = """你是一个电商客服助手。 ...(此处省略 1500 字规则说明,不含任何动态内容) """ # 动态部分:单独放在后面 dynamic_context = f"当前用户等级:{user_level},当前时间:{time.strftime('%Y-%m-%d %H:%M:%S')}" messages = [ {"role": "system", "content": static_system}, *history, {"role": "user", "content": f"{dynamic_context}\n\n用户问题:{user_input}"} ] return messages这一步做完,系统提示词就变成了逐字节一致的静态内容,具备了缓存的前提。
4.2 第二步:打上 cache_control 断点
以支持 cache_control 的 API 格式为例,把系统提示词块标记为可缓存:
def build_request_with_cache(user_input, history, user_level): static_system = """你是一个电商客服助手。 ...(1500 字规则说明) """ dynamic_context = f"当前用户等级:{user_level},当前时间:{time.strftime('%Y-%m-%d %H:%M:%S')}" request = { "system": [ { "type": "text", "text": static_system, "cache_control": {"type": "ephemeral"} } ], "messages": [ *history, {"role": "user", "content": f"{dynamic_context}\n\n用户问题:{user_input}"} ] } return request4.3 第三步:给历史对话也加断点
多轮对话里,历史对话是逐轮增长的。如果只缓存系统提示词,那历史对话部分每次都要重新算。更好的做法是在历史对话的最后一个消息上也打一个断点:
def build_request_full(user_input, history, user_level): static_system = "...(1500 字)" dynamic_context = f"当前用户等级:{user_level}" messages = list(history) if messages: # 在历史对话的最后一个消息上打断点 messages[-1] = { **messages[-1], "cache_control": {"type": "ephemeral"} } messages.append({"role": "user", "content": f"{dynamic_context}\n\n用户问题:{user_input}"}) return { "system": [{"type": "text", "text": static_system, "cache_control": {"type": "ephemeral"}}], "messages": messages }这样设计后,缓存形成两层:系统提示词一层,系统提示词 + 历史对话一层。当用户继续对话时,历史对话那层会增长,但系统提示词那层始终命中。
4.4 第四步:验证命中情况
改造完必须验证。大多数服务商的响应里会返回缓存相关的用量字段,类似:
{ "usage": { "input_tokens": 150, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 1800, "output_tokens": 220 } }cache_read_input_tokens大于 0,说明命中了;cache_creation_input_tokens大于 0,说明这次在写入。连续两次相同前缀的请求,第二次应该看到cache_read有值、cache_creation为 0。如果第二次还是cache_creation有值,说明前缀没匹配上,回去检查是不是有隐藏的动态内容。
实操心得:我习惯在开发环境加一个断言,如果连续两次请求的
cache_read_input_tokens都是 0,就直接抛异常。这样能在 CI 阶段就发现缓存失效问题,而不是等到月底看账单才发现。
5. 命中率排查:为什么你的缓存总是不生效
缓存改造做完,最常遇到的问题就是"明明打了断点,命中率还是上不去"。我把踩过的坑整理成一张速查表,基本覆盖 90% 的场景。
5.1 常见失效原因速查表
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 命中率 0% | 前缀含动态内容(时间、ID、随机数) | 打印两次请求的完整前缀做 diff | 剥离动态内容到断点之后 |
| 命中率忽高忽低 | 请求间隔超过 TTL | 记录请求时间戳,看间隔 | 缩短调用间隔或接受重建成本 |
| 首次命中后失效 | 前缀里有浮点数精度问题 | 对比序列化后的字符串 | 统一数字格式,避免精度漂移 |
| 部分命中 | 断点位置不合理 | 看 cache_read 的 token 数 | 调整断点,按稳定性分层 |
| 完全没命中 | cache_control 字段格式错误 | 检查 API 版本和字段名 | 对照官方文档核对字段 |
| 命中但没省钱 | 复用次数太少 | 统计每段前缀的复用次数 | 复用 < 2 次的内容不要缓存 |
5.2 动态内容的隐蔽来源
动态内容不只有时间戳。我遇到过几个特别隐蔽的:
JSON 序列化顺序不稳定。Python 的 dict 在 3.7 之后保序,但如果你从数据库查出来的字段顺序不固定,序列化后的字符串就不一样。解决办法是显式排序 key。
浮点数精度。比如工具定义里有个默认值0.1 + 0.2,不同环境算出来可能是0.30000000000000004或0.3。这种差异肉眼看不出来,但缓存比对是逐字节的,直接 miss。
换行符差异。Windows 的\r\n和 Linux 的\n混用,在跨平台部署时特别容易出问题。统一用\n。
工具定义的顺序。如果你用 set 或者无序结构存工具,每次序列化顺序可能不同。改成有序列表。
5.3 用 diff 定位问题
最有效的排查手段就是把两次请求的完整前缀打印出来做 diff。我一般这么干:
import difflib import json def diff_prefix(req1, req2): s1 = json.dumps(req1, sort_keys=True, ensure_ascii=False) s2 = json.dumps(req2, sort_keys=True, ensure_ascii=False) if s1 == s2: print("前缀完全一致,缓存应该命中") return for line in difflib.unified_diff( s1.splitlines(), s2.splitlines(), lineterm="" ): print(line)跑一次就能看到到底哪个字符不一样。这个方法帮我定位过好几次"看起来一样但就是不命中"的诡异问题。
5.4 TTL 与调用频率的匹配
缓存 TTL 通常是 5 分钟,每次命中会刷新。这意味着:
- 高频调用场景(比如用户连续对话):缓存几乎一直有效,命中率极高
- 低频调用场景(比如每天跑一次的批处理):缓存基本没用,每次都在重建
- 中等频率(比如每隔几分钟一次):要看具体间隔,接近 TTL 时命中率会掉
对于低频场景,我的建议是要么不用缓存,要么把多次调用合并成一次批处理,让它们在同一个 TTL 窗口内完成。
注意:不要为了命中缓存而人为加快调用频率,那可能触发服务商的速率限制,得不偿失。缓存是优化手段,不是目的。
6. 成本核算与监控:把缓存收益量化出来
改造完、排查完,最后一步是把收益量化。没有数据支撑的优化都是自嗨,你需要一套能持续监控的指标。
6.1 核心监控指标
我一般盯这几个数:
- 缓存命中率=
cache_read_input_tokens / (cache_read + cache_creation + 普通 input) - 单请求平均成本= 总成本 / 请求数
- 缓存节省金额= 假设不缓存的总成本 - 实际总成本
- 写入/读取比=
cache_creation / cache_read,这个比值越低越好
命中率健康值我个人的经验线是60% 以上。低于这个数,说明断点设计或者调用模式有问题,值得回头排查。
6.2 一个真实的成本对比
拿一个日均 10 万次调用的客服 Agent 举例,系统提示词 2000 token,平均历史对话 3000 token,用户输入 200 token。
不开缓存:每次输入约 5200 token,按基准价算。
开缓存后(假设系统提示词层命中率 95%,历史对话层命中率 70%):
| 项目 | 不开缓存 | 开缓存 | 节省 |
|---|---|---|---|
| 系统提示词成本 | 2000 × 1.0 | 2000 × (0.05×1.25 + 0.95×0.1) | 约 88% |
| 历史对话成本 | 3000 × 1.0 | 3000 × (0.3×1.25 + 0.7×0.1) | 约 55% |
| 用户输入成本 | 200 × 1.0 | 200 × 1.0 | 0% |
| 综合 | 5200 | 约 1750 | 约 66% |
综合成本降了三分之二。这个数字在真实项目里是可信的,前提是命中率达标。
6.3 监控落地方式
最简单的做法是在调用层包一个装饰器,把每次响应的 usage 字段打到日志或者时序数据库里:
def log_cache_usage(response, request_id): usage = response.get("usage", {}) metrics = { "request_id": request_id, "input": usage.get("input_tokens", 0), "cache_read": usage.get("cache_read_input_tokens", 0), "cache_write": usage.get("cache_creation_input_tokens", 0), "output": usage.get("output_tokens", 0), } # 打到你的监控系统 print(json.dumps(metrics))然后按天聚合,画一条命中率曲线。曲线掉了就说明有问题,及时排查。
6.4 别忽略的隐性成本
缓存优化不是只有省钱这一面。有几个隐性成本要算进去:
开发维护成本。为了命中缓存,你得把动态内容剥离、统一序列化、维护断点逻辑,这些都是代码复杂度。如果团队小、调用量不大,可能不值得。
调试难度。缓存命中与否会影响响应内容(理论上不该影响,但实践中偶有边界情况),调试时多了一层变量。
缓存失效的雪崩。如果大量请求共享同一段前缀,缓存一失效,所有请求同时重建,可能造成瞬时压力。这个在超大规模场景才需要担心。
我的建议是:调用量日均低于 1 万次的场景,先别急着上缓存,把 prompt 本身精简一下可能收益更大。缓存是规模化的优化手段,规模不够时性价比不高。
7. 进阶玩法:多断点分层与跨请求复用
基础玩法掌握后,可以看看进阶场景。这些是我在实际项目里验证过有效的。
7.1 多租户场景的缓存隔离
SaaS 产品里,不同租户的系统提示词可能不同。这时候缓存要按租户隔离,否则 A 租户的缓存被 B 租户命中就出大事了。做法是把租户 ID 作为前缀的一部分,但要注意——租户 ID 放在最前面会导致每个租户独立缓存,命中率按租户分摊。
更好的做法是把公共部分(所有租户共享的规则)放在最前面缓存,租户特有部分放在后面。这样公共层命中率极高,租户层按各自频率命中。
7.2 长文档问答的缓存策略
RAG 场景里,检索到的文档片段每次可能不同。如果直接把文档塞进 prompt,缓存基本没用。我的做法是把文档内容放在断点之后,只缓存系统提示词和固定的指令模板。文档部分虽然不缓存,但系统提示词那部分能稳定命中,整体还是省。
如果文档本身是固定的(比如产品手册问答),那就把文档也纳入缓存,在文档末尾打一个断点。
7.3 批处理任务的缓存复用
批量跑任务时,如果每条任务共享同一段指令,可以把它们放在同一个 TTL 窗口内连续发送。这样第一条写入缓存,后面全部命中。我做过一个数据标注任务,5000 条数据共享 3000 token 的标注规则,命中率 99.8%,成本降了 90% 以上。
7.4 缓存与 prompt engineering 的配合
缓存优化和 prompt engineering 是相辅相成的。一个结构清晰的 prompt,天然就适合缓存:静态规则在前,动态上下文在后,边界清晰。反过来,如果你的 prompt 写得一团乱,动态静态混在一起,那缓存怎么调都调不好。
所以我的建议是,在做 prompt engineering 的时候就把缓存友好性考虑进去。把"这段内容会不会变"作为一个设计维度,和"这段内容该不该放"同等重要。
8. 我踩过的几个坑和最后的经验
聊了这么多机制和方法,最后分享几个我实际踩过的坑,都是文档里不会写的。
第一个坑:以为缓存是自动的。早期我以为只要请求前缀一样,服务端就会自动缓存。实际上不是,你必须显式打cache_control,否则服务端根本不知道你想缓存。这个认知差让我白白多付了一个月的钱。
第二个坑:断点打太靠后。我一开始把断点打在历史对话的末尾,想着"缓存越多越好"。结果历史对话每轮都变,导致整个缓存单元每轮都失效,连系统提示词那部分都跟着重建。后来改成两层断点,系统提示词单独一层,问题才解决。断点要打在"最稳定的边界"上,而不是"最长的内容"上。
第三个坑:忽略 TTL。有个内部工具是每天早上跑一次,我给它加了缓存,结果每次都是重建,白付写入溢价。后来干脆去掉缓存,改成把多次调用合并成一次批处理,反而更省。
第四个坑:没监控。上线后没盯命中率,过了两周才发现命中率只有 20%,一直在做无效优化。没有监控的优化等于没优化,这句话在缓存这件事上体现得淋漓尽致。
如果让我给一个最实用的建议,那就是:先把监控搭起来,再动手优化。你只有看到真实的命中率和成本数据,才知道该往哪个方向使劲。盲目调断点、改 prompt,很可能是在优化一个根本不存在的瓶颈。
缓存这件事,说到底是一个"理解你的请求结构"的过程。当你能清楚地回答"我的请求里哪些部分是不变的、哪些是变的、变的频率有多高",缓存优化就水到渠成了。技术手段都是次要的,对业务请求模式的理解才是核心。