月初拉账单的时候,我盯着后台那串数字愣了几秒。一个日活在几百人的客服机器人,光是大模型接口的调用费,一个月就顶得上我原来运维两台云服务器的开销。仔细一拆才发现,问题根本不在模型生成的内容,而在每次请求都在重复发送的那一整段几千字的“背景说明”——系统提示词、工具定义、示例对话,它们一字不差地被模型从头读了一遍又一遍,每一遍都按输入 token 全价计费。解决这个问题的方法,就是标题里说的提示缓存(prompt caching)。这篇内容我会从计费机制讲起,把提示缓存的原理、接入方式、提示词结构设计和踩坑排查链路完整过一遍,适合正在做 LLM 应用、每天被 token 账单追着跑的后端开发者。
1. 先从账单说起:大模型 API 的“原材料”到底按什么收费
1.1 输入 token 和输出 token:价格差在哪
大模型 API 的计费单位是 token,可以粗略理解成一个字或半个词。一次请求的费用由两部分组成:输入 token(你发给模型的那段 prompt)和输出 token(模型生成的内容)。以目前主流的商用旗舰模型为例,输入单价通常在 30 元/百万 token 左右,输出单价则是 90 到 120 元/百万 token,输出贵出三到四倍是很常见的事。
这个价格结构本身没什么问题,模型生成内容确实更“贵”。但很多人盯着输出价格做优化,却忽略了一个更隐蔽的大头:输入 token 的重复消耗。你每调一次接口,整段 prompt 都要重新计费,哪怕其中 90% 的内容和上一次请求一模一样。
我见过很多团队把 system prompt 写得特别长,恨不得把整个业务手册都塞进去,然后抱怨“模型调用好贵”。他们没意识到,开销主要不是模型“想得久”,而是模型每次都要把你那本手册重新“读”一遍。读,也是按字数收钱的。
1.2 真正的问题:每次请求都在重复支付“固定成本”
举个例子你就明白了。假设你的客服机器人每次请求的 prompt 构成是这样的:
- 系统提示词(角色设定 + 行为规则):800 token
- 工具/函数定义(查订单、退换货、开发票等):600 token
- few-shot 示例对话:400 token
- 历史消息摘要:300 token
- 用户当前问题:50 token
五次请求的用户问题完全不一样,但只有最后那 50 token 是真正“新”的内容,前面 2100 token 全是重复的。如果平台上没有缓存机制,这 2100 token 每次都要按原价重新算一遍。也就是说,你为用户的每一次提问支付了 42 倍的“阅读成本”。
1.3 一个客服机器人的账单拆解
我们还是用这个客服机器人来算一笔实在的账。假设日请求量 1 万次,平均每次请求输入 1800 token,输出 300 token,输入单价 30 元/百万 token,输出单价 120 元/百万 token:
| 项目 | 计算方式 | 月度费用 |
|---|---|---|
| 输入部分 | 1800 × 1万 × 30天 = 5.4 亿 token | 540 × 30 = 16,200 元 |
| 输出部分 | 300 × 1万 × 30天 = 9000 万 token | 90 × 120 = 10,800 元 |
| 合计 | 27,000 元 |
再看输入部分的构成。系统提示词、工具定义和 few-shot 加起来大概 1500 token,占每次输入的四分之三以上。按比例折算,这两万七千里有大约 13,500 元是花在重复阅读上的“固定成本”。
如果提示缓存命中,这部分重复 token 的单价通常能降到原价的 10%,甚至更低。那么每个月能省下来一万两千多块,一年就是十四万五。这就是提示缓存的价值——不需要换模型,不需要改业务逻辑,只靠“让模型少读几遍相同的字”,就能把成本直接砍下一大块。
2. 提示缓存的工作原理:重复内容为什么能只算一遍钱
2.1 自回归模型预填充阶段:为什么输入要重新算一遍
要理解提示缓存,得先知道模型处理输入时到底做了什么。以 GPT 这类自回归 decoder 模型为例,它生成每个 token 时,注意力机制都会重新扫描一遍当前序列里所有的 token,计算出每个位置的 K 向量和 V 向量。这个过程俗称“预填充”(prefill),是整个推理里计算量最大的环节。
同一个会话内,你把“你好,帮我查一下订单”发给模型,它返回答案后,服务端其实已经把这段输入的 K/V 状态缓存住了。所以下一句“再帮我查下退款进度”接着问时,模型不需要重新读“你好,帮我查一下订单”,直接复用之前的中间状态就行。
但问题在于,不同的 HTTP 请求之间默认是相互独立的。如果你的业务只是简单地把系统提示词和用户问题拼在一起发给接口,每次请求都会被视为一个全新的会话,服务端不得不把整段 prompt 从头到尾重算一遍。明明是同样的字,却因为请求彼此独立,被迫反复预填充。
2.2 前缀精确匹配:缓存不是“模糊记忆”,是“逐字对账”
提示缓存要解决的就是跨请求复用的问题。服务端会把请求 prompt 的前缀对应的 K/V 状态存储一段时间,新请求到达时,如果它的 prompt 开头和缓存的前缀完全一致,就直接把存储的中间计算结果接上,只对新增的后半段做预填充。
注意这里的两个关键词:前缀、完全一致。
缓存匹配不是语义匹配,不会因为“意思差不多”就命中。要求的是字节级的一致。只要系统提示词里有一个字符变了,哪怕只是多了一个空格,从那个位置往后的所有内容都会被视为新内容,缓存整体作废。
你可以把它类比成一本词典的检索规则:先比对首字母,首字母一样再看第二个字母,逐字往下对。第一个字不一样,后面就算完全一样也白搭。
2.3 三种主流的缓存实现模式
我接触过的商用大模型 API,提示缓存的实现大致分成三类。摸清你用的平台属于哪一类,接入姿势完全不同。
| 实现模式 | 是否需要配置 | 典型命中条件 | 折扣力度 | 适用场景 |
|---|---|---|---|---|
| 自动缓存 | 不需要 | 重复前缀足够长且逐字一致 | 命中部分按原价 10% 计费 | 提示词稳定的常规应用 |
| 显式声明缓存 | 需要手动标记 | 标记的段落命中且未过期 | 命中部分按原价 10% 计费 | RAG、Agent、多轮对话 |
| 按长度分层计费 | 不需要 | 命中前缀越长单价越低 | 阶梯式折扣 | 超长系统提示词 |
自动缓存模式最省心,平台在后台自行判断哪些前缀能复用,你唯一要做的就是保持提示词稳定。显式声明模式则是你在请求体里告诉平台“这一段可以缓存,帮我留着”,平台会给你一个明确的缓存有效期。按长度分层计费的模式介于两者之间,前缀越长折扣越深。
不管哪种模式,有三个共同点需要记牢:
- 命中的 token 仍然会计费,只是价格大幅降低,不是免费。
- 通常有最小长度门槛,比如前缀要超过 1024 个 token 才参与缓存计价,小请求根本享受不到。
- 缓存有过期时间,常见的滑动窗口是 5 分钟到 1 小时。每次命中都会刷新计时,但如果你的应用请求间隔太长,缓存早就被回收了。
3. 接入提示缓存的实操:接口参数、代码示例与命中验证
3.1 自动缓存模式:不改代码,只改请求体质
如果你的平台是自动缓存模式,代码层面几乎什么都不用改。目前主流的商用大模型接口普遍采用POST /v1/chat/completions这个格式,请求体长这样:
import httpx url = "https://api.your-llm-provider.example/v1/chat/completions" headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", } payload = { "model": "your-model", "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_question}, ], "temperature": 0.3, } resp = httpx.post(url, json=payload, headers=headers, timeout=30) data = resp.json() print(data["usage"])返回结果里的usage字段会告诉你这次请求到底花了多少 token。如果平台支持缓存,响应里通常会多出类似这样的结构:
{ "prompt_tokens": 1800, "completion_tokens": 300, "total_tokens": 2100, "prompt_tokens_details": { "cached_tokens": 1500 } }这里的cached_tokens就是命中的 token 数。注意prompt_tokens计的还是 1800,但账单上只会按“1500 个折扣价 + 300 个原价”来算。看到这个字段大于 0,说明缓存已经生效了。
3.2 显式声明模式:手动标记可缓存区域
另一类平台需要你在请求体里显式声明哪些内容应该被缓存。做法是在消息内容块里加一个cache_control字段:
messages = [ { "role": "system", "content": [ { "type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"} } ] }, {"role": "user", "content": user_question}, ]ephemeral的意思是“临时缓存”,平台通常承诺这个段落会在一定时间内(常见的是 5 分钟滑动窗口)被保留。每次请求只要打中了这个标记段,平台就会把保留时间刷新一次。
这类平台更灵活的地方在于,你可以只标记系统提示词和工具定义,不标记 those 每轮都会变的 few-shot 示例。标记段在 prompt 里的位置也很关键:缓存段必须连续且位于开头附近,因为服务端是按前缀匹配的。如果你在系统提示词后面先放了一段动态的用户信息,再放缓存段,那缓存段永远不会命中。
3.3 怎么确认缓存真的命中了
接入之后,验证命中情况是必须做的一步。我一般从三个维度交叉确认:
- 看响应日志里的 usage 字段。
cached_tokens或类似的cache_read_input_tokens字段有值,就是命中了。 - 看首 token 延迟。命中时服务端跳过了预填充阶段,首 token 响应时间会明显下降,通常能快上百毫秒甚至更多。这个指标在自动缓存模式下尤其好用,因为你没法直接控制缓存开关,但延迟变化骗不了人。
- 到平台控制台看缓存命中率报表。多数平台会提供每日的缓存命中统计,用来做整体趋势分析很直观。
我自己踩过的一个小坑是:当时用某个官方 SDK,它会在请求里自动插入一些额外字段,导致最终发出去的 messages 和我在代码里写的并不完全一样。服务端按实际请求体做缓存匹配,如果 SDK 的序列化结果不一致,缓存就永远命中不了。所以排查时务必在接入层 log 一下真正发出去的请求体,别只看自己写的代码。
4. 提示词结构设计:缓存命中率不是靠运气,是排出来的
4.1 黄金法则:静态内容前置,动态内容收尾
提示缓存是前缀精确匹配,这条性质直接决定了提示词的组织方式:静态的不变内容要放在 messages 数组的最前面,动态的会变化的内容统一放到最后面。
这个顺序不是风格偏好,是技术约束。缓存匹配从第一个字符开始逐字比对,任何出现在前缀中段的动态内容都会打断整条链。哪怕你只是把用户昵称插在系统提示词和 few-shot 之间,那么每一个用户的请求前缀都不一样,系统提示词后面的内容全部无法命中。
所以正确做法是把提示词结构固定成这样:
- 静态区:角色设定、行为规则、输出格式说明、工具定义、通用指令模板
- 动态区:用户问题、当前时间、会话 ID、检索结果、上一轮 Agent 的输出
一个可供参考的模板结构:
静态区(永远保持一字不差) 1. system: 角色设定 + 行为规则 + 输出格式 2. system: 工具定义列表(JSON Schema) 3. user: few-shot 示例(用固定分隔符包裹) 动态区(每次请求可以不同) 4. user: 检索结果 / 历史摘要 5. user: 当前用户问题4.2 系统提示词、工具定义和 few-shot:缓存区的“钉子户”
系统提示词、工具/函数定义和固定 few-shot 示例,是天然最适合进缓存区的三类内容。
系统提示词不用多说,它描述的是模型长期要遵守的角色和规则,业务不变它就不变。工具定义在 Agent 场景里尤其值得关注:一个接入了查订单、退换货、开发票等多套工具的 Agent,仅仅工具 JSON Schema 就能轻松超过 2000 token。以前我们为了省 token 拼命压缩工具描述,现在有了提示缓存,你可以放心地把工具说明写详细、写完整,因为这部分只会在缓存未命中时付一次全价,后续几万次请求全都按折扣价计费。
另一个容易忽视的点是 few-shot 示例。很多人习惯在示例里加入“用户 A 说”“用户 B 说”这类会因人而异的词,这又是典型的缓存杀手。few-shot 应当使用完全固定的示例文本,连标点符号和分隔符都不要变。如果确实需要针对不同用户展示不同示例,把这些示例放到动态区,别放在前缀里。
4.3 RAG 和多轮对话:最容易踩的两种结构问题
RAG 场景的缓存设计要特别注意检索结果的放置位置。常见的错误做法是把检索到的文档插在系统提示词后面,结果每次查询的文档不同,整个前缀经常性作废。
正确的做法是:把“如何使用检索结果”的指令写死在系统提示词里,把检索到的文档内容拼在用户消息的末尾。这样系统的指令部分保持稳定,变化的部分全部集中在最后。检索结果再怎么千变万化,前缀缓存依然能命中系统提示词那一大段静态内容。
多轮对话则要小心历史记录的“截断导致前缀失效”问题。假设你的应用只保留最近 10 轮对话,当对话超过 10 轮后,第 11 轮请求的 messages 里会丢掉最早的那轮内容。丢掉的这一轮恰好位于前缀中段,就会导致新一轮请求的前缀和上一轮完全不同,缓存作废。
显式声明缓存模式下,解决思路是只对最前面那段完全静态的系统提示词做标记,不标记会话历史。历史对话的缓存价值本来就不大,每轮都会追加新内容,真正值得锁住的是开头那段稳定的系统规则。
5. 缓存不命中的排查链路:五个常见坑和我的定位过程
5.1 坑一:系统提示词里藏了一个“每天都会变的日期”
我接手过的一个项目,系统提示词开头写的是“今天是 2026 年 5 月 18 日,你是某电商平台的客服助手”。一开始我看cached_tokens一直是 0,以为是平台不支持,后来才发现真凶就是这句话。日期每天一变,意味着每天第一次请求会创建一个新前缀,之后的一整天里,只要请求间隔超过缓存有效期,前缀就会反复重建,命中率自然惨不忍睹。
修正方式是把日期从系统提示词里拆出去,放到用户消息的末尾。模型照样能读到日期,但这段变化内容不再污染前缀。
5.2 坑二:模板渲染环境不统一,前缀悄悄多了空格
第二个坑更加隐蔽。项目里用模板引擎渲染系统提示词,本地开发时模板文件里没有缩进,线上环境却因为打包配置不同,每一行开头多出了四个空格。系统提示词的文本看上去完全一样,实际发出去的内容却不一样了。
这类问题最恶心的地方在于:你肉眼比对根本发现不了,只有对真正发出的请求体做哈希比对才能看出端倪。解决办法是统一模板渲染后的规范化流程,例如统一用strip()去掉首尾空白、把换行符统一成\n、对 JSON 序列化时固定sort_keys=True。然后把这份规范化结果作为基线放进 CI,每次发布前比对一次。
5.3 坑三:SDK 包装层偷偷改了请求体
前面提过官方 SDK 会自动插入字段的情况。具体来说,有些封装库会在messages之外附加user字段、会话标识甚至时间戳,有些会在序列化 JSON 时改变键的顺序。这些变化不影响模型消费内容,但会影响服务端对前缀的哈希比对。
排查这个坑的方法很笨但很有效:接一层日志中间件,把每次发出去的完整请求体记录下来,然后直接拿日志里的内容重新发一次同样的请求。如果cached_tokens从 0 变成了正常值,说明问题就在 SDK 封装层。换用更薄的 HTTP 客户端往往就解决了。
5.4 完整的排查方法:从日志到二分定位
如果前面几个坑都排除了,命中率还是上不去,我建议按下面的链路系统性地定位:
- 把响应的
usage里的缓存字段落日志,按小时统计缓存命中率。没有数据支撑的排查都是瞎猜。 - 抓一条未命中的请求,和一条你认为应该共享前缀的请求,提取 messages 数组的前 1024 个字符做哈希对比,找出第一个不一致的位置。这一步能快速判断是前缀本身变了,还是缓存机制的问题。
- 二分定位。临时把 messages 拆成“静态前缀”和“动态后缀”两段,先只缓存静态段,看是否命中;再逐步把后面的内容加进来,找到那个导致前缀不一致的字段。
- 确认请求间隔是否超过了缓存有效期。低流量的内部工具最容易在这个环节翻车,一天就几十次调用,每次间隔都远超 5 分钟,缓存基本没机会复用。
- 确认静态前缀长度是否达到平台的最小缓存门槛。有的平台要求前缀超过 1024 token 才参与缓存计价,你的系统提示词如果只有几百 token,平台压根不会给你缓存。
一个辅助定位的小脚本,直接对比两批请求的同位置前缀:
import hashlib import json def prefix_hash(messages: list[dict], length: int = 1024) -> str: raw = json.dumps(messages, ensure_ascii=False, sort_keys=True) return hashlib.sha256(raw[:length].encode("utf-8")).hexdigest()另外补充一个现象:如果应用是多实例部署,且命中率在流量高峰期忽高忽低,很可能是因为负载均衡把请求分散到了不同的计算节点,节点之间的缓存不共享。部分平台有分布式缓存,但并不是所有平台都有。对策是把相同业务的请求尽量固定到同一批节点上,或者干脆选择明确提供分布式缓存的平台。
6. 缓存之外的架构思路:把“少算重复账”变成系统设计
6.1 共享前缀池:多租户应用的全局设计
提示缓存的价值上限取决于“同一个前缀被复用的次数”。所以架构上值得专门设计一个“公共前缀池”,把尽可能多的稳定内容集中到一起。
以多租户 SaaS 为例:产品级别的行为规范、合规声明、通用工具定义,完全可以做成全局共享的静态前缀。租户自己的 FAQ 和业务规则作为次级前缀,单个用户的具体对话作为动态后缀。理想状态下,全局公共前缀被所有租户的所有请求复用,命中次数随业务量线性增长,这部分 token 的边际成本趋近于零。
这个思路放到 Agent 应用里同样成立。Agent 的公共“人格设定”和全套工具定义拉成静态前缀,每轮循环只把上一轮的思维链结果和最新工具返回值追加到末尾。缓存收益在 Agent 场景表现是最夸张的,因为一次多步任务可能要调用十几次模型,每次都背着整段工具定义。
6.2 与语义缓存、模型蒸馏的协同
注意区分提示缓存和语义缓存:提示缓存省的是输入 token 的重复计算,应用层语义缓存则是把完全相同的用户问题和对应回答直接存起来,连输出 token 都省了。两者不冲突,可以在不同层面叠加。
我的做法是:提示缓存负责解决“prefix 重复”的问题,语义缓存负责解决“整段请求重复”的问题。完全相同的用户提问,短期内直接命中应用层缓存,根本不需要再调模型;提问不同但前缀相同的,靠提示缓存冲淡成本。
模型蒸馏则是另一条降本路径。如果业务场景允许,把大模型迁移到参数小得多的模型上,成本下降是数量级的。但蒸馏模型在复杂指令跟随能力上通常有折扣,属于模型层面的取舍。实操中我的经验是先用提示缓存把“重复读取”的浪费清掉,再评估是否真的需要换模型。很多时候,缓存优化后成本已经降了八成,换模型反而没那么有吸引力了。
6.3 什么时候不该指望提示缓存
提示缓存不是万能的,下面这些场景它帮不上忙:
- 每次请求前缀都不同的应用。比如完全靠用户输入的临时指令生成的 prompt,没有任何公共静态部分,缓存无从谈起。
- 请求间隔远超缓存有效期。日调用量只有几十次的内部工具,每次请求之间隔了几个小时,缓存早就回收,命中率上不去。
- 输入 prompt 本来就特别短。不仅可能达不到平台的最低缓存长度门槛,就算命中了,省下的金额也微乎其微,不值得为它重构提示词结构。
还有一个经常被忽略的边界:提示缓存只降低输入 token 的单价,输出 token 依然是全价。不要因为缓存便宜了就往 prompt 里狂塞静态内容,塞进去的那一刻,缓存未命中的第一批请求会按全价买单;你只是把这笔一次性成本摊到了后续的命中请求上。只有当同一前缀的复用次数足够多时,这个摊薄才划算。
我给团队定的一个经验值是:某个静态段预计会被复用 5 次以上,就放心把它加长;复用次数低于这个数,保持原来的长度限制更稳。
最后再分享一个实战小习惯。我在项目 CI 里加了一条提示词稳定性测试:用脚本渲染一份线上环境的完整 messages 样本,和上一轮发布时的基线做逐字符比对,任何前缀变化都会让构建失败。这个检查看起来不起眼,但它能在缓存命中率暴跌之前就拦住一堆“以为没人会动、结果改了一行字”的意外。提示缓存的收益很实在,但前提是你得先保证那些“重复的字”真的一个字都不差。