前言
线上 LLM 服务账单里,如果出现这种怪象——QPS 没涨多少,输入 token 费用却翻倍;或者 Dashboard 里cached_tokens长期接近 0——多半不是模型「变笨了」,而是Prompt Caching(输入前缀缓存)根本没吃上。
先别和推理侧的KV Cache搞混:历史稿「KV Cache 与自回归推理加速」讲的是 decode 阶段省算力;本文讲的是API/网关层对相同输入前缀的缓存复用——目标是少算 prefill、降 latency、省输入 token 钱。
下面按工程师视角:先分清概念 → 看指标 → 找根因 → 改架构,文末有可直接贴进团队的排查清单。
一、先给结论:输入缓存到底在缓存什么
Prompt Caching / Prefix Caching的核心规则只有一句:
同一段输入前缀(从第一个 token 开始、逐字节一致)在缓存有效期内再次被发送,后续请求可以跳过这部分的 prefill 计算,并按厂商规则享受cache read计费(通常比全价 input 便宜)。
它缓存的是「已经算过的前缀」,不是「语义相似」的段落——改一个空格,前缀链就断了。
| 概念 | 层级 | 你控制的杠杆 |
|---|---|---|
| KV Cache | 推理引擎 / 单次会话内 | 序列长度、批处理、有状态服务 |
| Prompt Caching | 云 API / 网关 | 消息拼装顺序、前缀稳定性、模型与 endpoint 一致 |
二、怎么判断「命中率低」——先看这三类指标
1)厂商返回的 usage 字段(最可靠)
OpenAI 兼容 API(以官方文档为准,字段名随 SDK 版本略有差异):
{"usage":{"prompt_tokens":4200,"completion_tokens":180,"prompt_tokens_details":{"cached_tokens":0}}}cached_tokens == 0且 prompt 很长 → 基本可判定本轮没命中- 连续多轮对话应看到
cached_tokens随历史增长(前缀变长、稳定部分被复用)
Anthropic常见字段:
cache_creation_input_tokens:本轮新写入缓存的 tokencache_read_input_tokens:本轮从缓存读取的 token
理想情况:稳定 system + 文档放前缀后,第二轮起cache_read应显著 > 0。
2)业务侧自建比率
输入缓存命中率 ≈ cache_read_tokens / (cache_read_tokens + cache_miss_tokens)在网关层对每次请求打日志:
# 伪代码:统一封装 LLM 调用并打点deflog_cache_usage(usage:dict,route:str):cached=usage.get("prompt_tokens_details",{}).get("cached_tokens",0)prompt=usage.get("prompt_tokens",0)hit_rate=cached/promptifpromptelse0metrics.histogram("llm.cache_hit_rate",hit_rate,tags={"route":route})别只靠「感觉变快了」——账单和 metrics 不会骗人。
3)延迟侧面验证
命中前缀缓存时,长 system prompt 场景的 TTFT(首 token 时间)often 明显下降。
但若你 prompt 只有 200 token,本来也进不了缓存门槛,TTFT 变化不明显——所以要结合 token 数一起看。
三、命中率低的 7 个高频根因
根因 1:可变内容放在了前缀(最致命)
现象
- 每条请求 system 里带
当前时间:2026-06-20 09:03:17 - user 消息里塞
request_id=uuid()再拼 RAG 文档 - 多租户把
tenant_id、user_id写在 system 最前面
为什么 miss
缓存按前缀字节级匹配。第一行变了,后面 8000 token 的文档即使完全相同,也算新前缀。
修复
静态在前,动态在后——固定模板:
[固定] system:角色、规则、工具 schema(版本化) [固定] 检索文档 / 知识库片段(按 session 稳定排序) [可变] user:本轮问题、request_id、时间戳messages=[{"role":"system","content":STATIC_SYSTEM_V3},# 不变{"role":"user","content":f"{rag_context}\n\n---\n{user_query}"},]# 时间戳、trace_id 只出现在最后一段 user 里根因 2:未达最小可缓存 token 门槛
现象
- system prompt 只有 300 token,抱怨「从没命中过」
- 文档很短,每轮都 re-prefill
为什么 miss
主流云厂商对 Prompt Caching 有最小前缀长度(例如 OpenAI 侧常见门槛约1024 tokens量级,Anthropic 常见2048,以各厂商当前文档为准)。低于门槛不会建立缓存条目。
修复
- 把可复用的长内容凑到门槛以上:system 规则 + 工具定义 + 领域术语表 + RAG 片段
- 若业务 prompt 天然很短,别强行为了缓存堆废话——算 ROI,短 prompt 本来 prefill 成本就低
- 合并多个小请求为「共享前缀 + 不同 suffix」架构(见第五节)
根因 3:多轮对话每轮「重开无状态请求」
现象
- 客户端每轮只发
{user: 最新一句},历史由后端拼但不稳定 - 或每轮重新 fetch RAG,文档顺序、空格、JSON 字段顺序随机
为什么 miss
多轮要命中,需要前缀包含上一轮及更早的稳定历史,或至少system + 文档块完全一致。
修复
- 会话级固定 RAG 块:同一会话内检索结果缓存 5~30 分钟,顺序 deterministic
- 历史消息 append-only,不要在中途改写过旧 assistant 内容
- 网关维护
session_id → 已缓存前缀 hash,变更时打 debug 日志
根因 4:JSON / 模板序列化不稳定
现象
- 工具
tools参数用 Pythondict直接json.dumps,key 顺序随机 - 多语言团队,有时中文有时英文 system,A/B 混发
- Prompt 从数据库读出,尾随空格、
\r\n不一致
为什么 miss
人眼看「一样」,模型输入 token 序列不同。
修复
importjson tools_json=json.dumps(tools,ensure_ascii=False,sort_keys=True,separators=(",",":"))- system prompt版本号写进常量
STATIC_SYSTEM_V3,发版才 bump - CI 里对
prompts/*.md做snapshot test,防止无意 diff - 统一 UTF-8、统一换行符(
.gitattributes管prompts/** text eol=lf)
根因 5:模型、endpoint、参数不一致
现象
- 负载均衡在
gpt-4o和gpt-4o-mini间轮询 - 预发走官方,生产走中转,缓存不共享
- 同一前缀但
temperature从 0 改成 0.7——部分厂商缓存 key 含采样参数
修复
- 缓存友好的路由:长前缀场景固定 model + endpoint(至少在一个 session 内)
- 读文档确认:哪些参数进入 cache key(model、tools、response_format 等)
- 中转站场景:问清楚是否透传厂商 Prompt Caching;很多聚合层会打断缓存
根因 6:RAG 把「每次检索结果不同」放在前缀
现象
- TopK 向量检索,分轻微波动就换文档
- 把「检索 query + 10 篇 chunk」全塞 system,query 每轮都变
修复
分层拼装:
Layer A(稳定,可缓存):system 规则 + 工具 + 静态 FAQ Layer B(半稳定):会话内固定的知识包(首次检索后锁定) Layer C(可变):本轮 user query + 少量动态补充检索(放后缀)- 对高价值文档:按 doc_id 排序后再拼接,避免分数相同顺序乱
- 评测集里加指标:同问复问 cache_read 是否 > 0
根因 7:缓存 TTL 过期 + 流量太散
现象
- 用户间隔 2 小时再来,缓存已过期(常见 TTL 约 5~60 分钟,厂商而定)
- 10 万个独立 system prompt(每客户定制),每个前缀只出现 1 次
修复
- 抽高频 system 模板做标准化,个性化字段下沉到 suffix
- 对「慢会话」产品,在 TTL 内做keep-warm(定时 ping 同前缀,需评估成本)
- 监控「每前缀出现次数」分布:长尾场景别指望高命中率
四、推荐架构:「稳定前缀 + 可变后缀」模板
# prompts/rag_v1.py — 纳入 Git 版本管理STATIC_SYSTEM=open("prompts/system_v3.md").read()TOOLS=json.loads(open("prompts/tools_v1.json").read())defbuild_messages(session:Session,user_query:str)->list:# Layer B:会话内锁定,避免每轮重排rag_block=session.get_or_fetch_rag_block()# 内部 cache 5~30minreturn[{"role":"system","content":STATIC_SYSTEM},{"role":"user","content":(f"<knowledge>\n{rag_block}\n</knowledge>\n"f"<question>\n{user_query}\n</question>"),},]Agent / 多步工具调用同样适用:工具 schema 和 system 放最前;每步 observation 追加在后缀,别插入前缀中间。
五、10 分钟排查顺序(照着做)
- 打日志:
prompt_tokens、cached_tokens(或 Anthropic 的 read/create) - 抽一条 miss 请求,逐段 diff 与前一次 hit 请求的输入(二进制级)
- 检查可变字段是否在前缀:时间戳、UUID、tenant_id
- 量前缀 token 数,是否低于厂商最小门槛
- 确认 model / base_url / tools JSON 是否 session 内一致
- RAG:同 session 文档块是否 stable sort + 锁定
- 仍低:看 TTL 与流量是否过于长尾
六、和开源 / 自建链路怎么衔接
| 场景 | 建议 |
|---|---|
| OpenAI 兼容网关(LiteLLM、One API 等) | 查是否开启并透传 cache 相关 header/字段;自建网关要打 usage 转发 |
| 本地 Ollama | 无云厂商 Prompt Caching 概念;靠KV 复用 + 有状态会话优化,见 KV Cache 科普稿 |
| Prompt 版本管理 | prompts/入 Git,PR review;配合 GitHub Actions 跑 snapshot test |
| 可观测 | Prometheus + Grafana 面板:cache_hit_rateby route / model |
# .github/workflows/prompt-snapshot.yml 最小示意name:prompt-snapshoton:[pull_request]jobs:check:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-run:python scripts/hash_prompts.py--check七、常见误区
| 误区 | 事实 |
|---|---|
| 「语义一样就能命中」 | 必须前缀 token 级一致 |
| 「缓存 = 不用付钱」 | cache read 通常仍计费,只是更便宜;create 可能额外收费 |
| 「命中率 100% 才正常」 | 长尾 query、TTL、多租户会导致合理偏低 |
| 「调 temperature 不影响缓存」 | 以厂商文档为准,部分参数在 cache key 内 |
| 「Prompt Caching 等于 KV Cache」 | 两层优化,排查方向完全不同 |
结语
输入缓存命中率低,90% 是工程拼装问题,不是模型问题:把静态长前缀放最前、动态信息沉后缀、序列化 deterministic、会话内锁定 RAG 块——再配合 usage 指标持续看cached_tokens,通常一两轮迭代就能从「几乎全 miss」拉到「可接受水平」。