缓存复用减少重复调用:两层缓存策略让成本趋近于零
上周有个做客服机器人的朋友找我吐槽:同样一批”怎么退货""物流到哪了”这类高频问题,一天被用户问几百遍,每次都老老实实调一次 API,账单蹭蹭往上涨。我问他有没有做缓存,他说”没啊,每个用户问的话措辞都不一样,怎么缓存”——这就是没分清两种缓存该管的是哪一层。完全相同的输入才归应用层缓存管,措辞不同但前缀相同(比如同一份产品知识库)归 Prompt Caching 管,两者分工不同,配合起来才能把边际成本压到接近零。
缓存是成本优化中唯一能把边际成本降到零的手段——如果相同的请求已经处理过,直接返回缓存结果,API 调用次数为零。结合 Prompt Caching,可以构建两层防护,覆盖从”完全相同请求”到”相同前缀请求”的不同场景。
两层缓存策略
| 缓存层级 | 作用范围 | 成本降幅 | 实现复杂度 |
|---|---|---|---|
| 应用层结果缓存 | 完全相同的输入 → 跳过 API 调用 | 100%(零费用) | 低(Redis/内存) |
| Prompt Caching | 相同前缀(system prompt 等)→ 折扣计费 | 75–90% | 低(API 参数配置) |
两层配合:命中应用缓存 → 免费;未命中但有相同前缀 → 折扣价;完全新请求 → 正常价。
第一层:应用层结果缓存
适用场景: 相同或近似的用户输入会重复出现,例如 FAQ 问答、固定模板填充、高频搜索词的 AI 摘要。
实现要点:
import hashlib, json, redis
cache = redis.Redis(host="localhost", port=6379, decode_responses=True)
CACHE_TTL = 3600 # 缓存 1 小时,根据内容时效性调整
def cached_llm_call(model: str, messages: list, **kwargs) -> str:
# 生成缓存 key:基于模型 + 完整消息列表的哈希
cache_key = "llm:" + hashlib.sha256(
json.dumps({"model": model, "messages": messages}, sort_keys=True, ensure_ascii=False).encode()
).hexdigest()
# 命中缓存,直接返回
cached = cache.get(cache_key)
if cached:
return cached
# 未命中,调用 API
result = call_llm_api(model, messages, **kwargs)
# 写入缓存
cache.setex(cache_key, CACHE_TTL, result)
return result
缓存 key 设计建议:
- 包含模型名,避免切换模型后返回旧模型的结果
- 对 messages 做规范化(如去除无关空格)再哈希,提高命中率
- 对用户输入做轻量语义归一化(大小写统一、去除标点变体)可进一步提升命中率
为什么一定要把 model 塞进哈希里? 我见过一个真实事故:团队把默认模型从旧版本切到新版本做灰度,缓存 key 只用了 messages 内容做哈希,没带模型名。结果灰度期间一部分用户命中的是旧模型跑出来的缓存结果,新模型的效果测试数据全被污染,排查了大半天才发现是缓存在”偷懒”。血的教训:凡是会影响输出内容的参数(model、temperature、system prompt 版本号)都得算进 key 里,宁可命中率低一点,也不能让缓存把不同配置的结果混在一起返回。
TTL 怎么定,不要拍脑袋: TTL 太短,等于白建缓存,命中率上不去;太长,内容更新了用户还在看旧答案。我的经验是按内容”保鲜期”倒推——FAQ 类文案一周才改一次,TTL 设 6–24 小时完全没问题;涉及库存、价格、排队人数这种分钟级变化的数据,TTL 给 60–300 秒就够了,多了没意义。如果同一个 key 对应的源内容更新是”事件驱动”而不是”定时”的(比如运营改了一条 FAQ),比起等 TTL 过期,更靠谱的做法是在内容更新的地方主动 cache.delete(cache_key) 或者给这批 key 加个统一前缀版本号(如 llm:v3:...),发布新内容时只需要把版本号加一,旧缓存自然全部失效,不用一个个去删。
缓存穿透、击穿、雪崩——三个经典坑在 LLM 场景一样躲不掉:
| 问题 | 现象 | 应对 |
|---|---|---|
| 缓存穿透 | 恶意或异常输入产生的 key 永远不存在于缓存,每次都穿透到 API | 对空结果也做短 TTL 缓存(如 60 秒),或对输入长度/格式做前置校验拦截 |
| 缓存击穿 | 某个热点 key 突然过期,瞬间大量并发请求同时打到 API | 用分布式锁(如 Redis SETNX)保证同一时刻只有一个请求去调用 API 重建缓存,其余请求等待或读旧值 |
| 缓存雪崩 | 大批 key 在同一时刻集中过期,请求集中冲击 API 触发限流 | TTL 加随机抖动,比如 CACHE_TTL + random.randint(0, 300),把过期时间打散 |
这三个问题在传统数据库缓存里是常识,但很多做 LLM 应用的团队是从零开始搭基础设施,容易漏掉。尤其是击穿——如果你的应用有明星内容(比如首页展示的某条 AI 生成摘要),一旦它的缓存过期,瞬间涌入的并发请求会同时判断”未命中”,然后同时去调用付费 API,那一刻的成本和延迟都会打脸。加一把分布式锁,用不到十行代码就能避免。
第二层:Prompt Caching
Prompt Caching 是 API 侧的缓存,不需要你缓存结果,只需确保相同的 prompt 前缀能被识别:
OpenAI(自动缓存): 前缀长度 ≥1,024 token 且内容一致,自动触发缓存,命中时输入 token 折扣约 50%(截至 2026-06,以官方为准)。
Anthropic(显式标记): 需要在 system prompt 或 messages 的对应位置添加 cache_control 标记:
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "# 产品知识库\n" + product_docs, # 这部分固定,适合缓存
"cache_control": {"type": "ephemeral"} # 显式标记
},
{
"type": "text",
"text": user_question # 这部分每次不同,不标记
}
]
}
]
命中 Prompt Cache 的输入 token 定价约为正常价的 10%(Anthropic),或 50%(OpenAI),截至 2026-06,以各厂商官方为准。
这里最容易踩的坑是”顺序颠倒导致缓存不生效但接口不报错”。 你不会收到任何报错信息,账单也不会提示你,唯一的线索是响应里的用量统计字段(Anthropic 是 cache_read_input_tokens,OpenAI 是 usage.prompt_tokens_details.cached_tokens)一直是 0。排查思路:
- 先确认固定内容确实排在 messages 数组的最前面,动态内容排在后面——哪怕只在固定内容前面多塞了一个当前时间戳,前缀哈希就变了,缓存直接失效;
- 确认固定内容长度达到最低门槛(Anthropic 通常要求 1024 token 起,不同模型门槛不同,以官方文档为准);
- 多轮对话场景要注意:每一轮都要把同样的固定前缀原样带上,不能为了省 token 去裁剪或改写这部分内容,哪怕只改一个字,缓存也会从这里断开重新计算。
我见过一个团队做多轮客服对话,为了控制单次请求体积,写了个”历史裁剪”逻辑,会把开头的知识库摘要做动态压缩——结果每一轮压缩出来的文本都略有不同,Prompt Cache 命中率常年是 0,却一直没人发现问题出在这,因为接口调用本身完全正常,只是白白多付了本可以打折的那部分钱。
什么内容适合放在可缓存前缀中
| 适合缓存 | 不适合缓存 |
|---|---|
| 固定的 system prompt | 包含当前时间/日期的动态内容 |
| 产品说明、知识库文档 | 用户个性化信息(动态) |
| Few-shot 示例(固定) | 每次请求的具体问题 |
| 角色扮演背景设定 | 随机种子或会话 ID |
关键原则: 把固定内容放在 messages 最前面,变动内容放在最后面。顺序颠倒会让缓存完全失效。
进阶:语义缓存,解决”意思一样但字不一样”
前面说了应用层缓存靠精确哈希匹配,“怎么退货”和”退货流程是什么”这种意思相同但字面不同的问题,哈希值天差地别,完全不会命中。想让这类高频重复问题也吃到零成本,得上语义缓存:先把新请求转成向量,去缓存里找最相似的历史请求,相似度过阈值就直接复用答案。
import numpy as np
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
def semantic_cached_call(query: str, embed_fn, threshold: float = 0.92) -> str:
query_vec = embed_fn(query) # 调用 Embedding API 得到向量
# 遍历缓存里的历史 (向量, 答案) 对,找相似度最高的一条
best_score, best_answer = 0.0, None
for cached_vec, cached_answer in semantic_cache_store:
score = cosine_similarity(query_vec, cached_vec)
if score > best_score:
best_score, best_answer = score, cached_answer
if best_score >= threshold:
return best_answer # 命中,零成本
# 未命中,走正常调用,再把结果存入语义缓存
answer = call_llm_api(query)
semantic_cache_store.append((query_vec, answer))
return answer
这段代码是简化演示,真到生产环境上,遍历式匹配扛不住量级,得换成向量数据库(比如 pgvector、Milvus 或 Redis 自带的向量检索)做近似最近邻搜索,否则历史记录一多,每次查询都要算几万次余弦相似度,反而比直接调 API 还慢。另外阈值 threshold 不是拍脑袋定的:定得太低(比如 0.8),“多少钱”和”怎么退款”这种风马牛不相及的问题也可能被误判为相似,答非所问;定得太高(比如 0.98),又基本退化成精确匹配,起不到作用。实际项目里我一般是先离线跑一批历史问答对,人工标注哪些应该算”同一个问题”,再用这批标注数据网格搜索出最适合业务的阈值,而不是凭感觉设一个数字。
语义缓存额外增加了一次 Embedding API 调用的成本,但 Embedding 通常比对话模型便宜一到两个数量级,只要命中率能补上这部分开销,仍然是划算的。适合放在应用层缓存之后作为”第二道网”:先查精确哈希,没命中再查语义相似度,两道网都没接住才真正调用大模型。
缓存命中率的监控
建立缓存监控,才能知道优化效果:
# 在 cached_llm_call 中加埋点
if cached:
metrics.increment("llm_cache_hit")
else:
metrics.increment("llm_cache_miss")
# 定期查看命中率
hit_rate = hits / (hits + misses)
健康的命中率参考:
- FAQ Bot:≥60% 表现良好
- 通用对话:20–40% 属正常
- 完全个性化场景:应用层缓存命中率可能接近 0,此时重点转向 Prompt Caching
命中率数字本身不够,得配合成本一起算才有意义。 举个例子方便你套自己的数字:假设某场景日调用量 10 万次,单次平均成本 0.01 元,应用层缓存命中率从 20% 提到 50%,一天就能少调 3 万次 API,省下 300 元;这笔账按月算是 9000 元,一年下来超过 10 万元——这也是为什么命中率这个指标值得单独开个监控面板盯着,而不是上线之后就不管了。命中率一旦出现异常波动,通常能对应到具体原因,排查时可以按下面这张表去对:
| 命中率异常表现 | 大概率原因 |
|---|---|
| 突然从正常水平跌到接近 0 | 上游改了 messages 结构或加了动态字段(时间戳、请求 ID),导致哈希 key 每次都变 |
| 逐渐缓慢下滑 | TTL 设置偏短,或业务侧内容变化频率变高,重复请求变少 |
| 一直是 0,从未命中过 | 缓存写入逻辑有 bug(比如 key 生成和查询用的哈希函数不一致),或 Redis 连接配置错了写到了另一个库/环境 |
| 命中率正常但账单没降 | 可能命中的是应用层缓存但没有真正跳过 API 调用(代码里判断逻辑写反了),务必用日志或计数器交叉验证,而不是只看命中率这一个数字 |
最后一种情况我踩过:团队上线缓存后 dashboard 显示命中率 35%,看着挺健康,但账单丝毫没降。回头查代码才发现,if cached: 分支里确实取到了缓存值,但忘了 return,往下继续执行到了调用 API 的代码,缓存值直接被扔掉没用上。这提醒你:监控数字要跟真实账单对得上,只信一个指标容易被”看起来很美”的假象骗过去。
常见问题
缓存结果会过时吗?
会。对于时效性强的内容(如价格、库存),需要设置较短的 TTL 或主动失效机制。对于稳定内容(FAQ、产品说明),可以设置较长 TTL,更新文档时手动清除对应 key。
Prompt Caching 的缓存多久失效?
OpenAI 的 Prompt Cache 通常在 5–10 分钟无访问后失效(非服务承诺,仅参考);Anthropic 的 ephemeral cache 约 5 分钟。频繁访问的场景会持续续期。
语义相似但不完全相同的问题能命中缓存吗?
应用层的精确哈希匹配无法命中。如需语义缓存,可在查缓存前先用 Embedding 计算相似度,相似度超过阈值则返回缓存结果——这是”语义缓存”方案,复杂度更高但命中率更好。
多实例部署下,Redis 缓存会不会出现不一致?
只要各实例连的是同一个 Redis(而不是各自本地内存缓存),数据本身是一致的,Redis 单线程处理命令保证了同一个 key 的读写不会乱序。真正容易出问题的是前面提到的”缓存击穿”场景:多个实例同时判断某个热点 key 未命中,同时发起重建请求。解决办法是用 Redis 的 SET key value NX PX 30000 语义抢一把分布式锁,抢到锁的实例负责调用 API 重建缓存,没抢到的实例可以选择短暂等待后重新读缓存,或者直接读一份稍微过期但可用的旧值(如果业务能接受)。
要不要给缓存加个兜底,防止 Redis 挂了拖垮整个服务?
一定要加。缓存组件的定位是”锦上添花”,不该成为单点故障。实践中我会给缓存操作包一层 try/except,Redis 连接异常或超时时直接降级为”跳过缓存,走正常 API 调用”,同时打一条告警日志。宁可这段时间成本高一点,也不能让缓存故障波及到核心业务不可用——这个优先级判断很重要,很多团队一开始没想清楚,把缓存查询做成了阻塞式强依赖,结果一次 Redis 抖动直接让线上服务跟着雪崩。
延伸阅读:
- 计费原理总览:大模型 token 计费完全指南
- 全面优化清单:大模型 API 成本优化 10 招
- Prompt Caching 详解:Prompt Caching 省钱原理与实践
- 上下文压缩配合:上下文压缩与裁剪
- Token 用量估算:Token 计算器
- token 成本专题:token 成本 Hub