LLM 缓存不是银弹:我在生产环境踩过的坑
在客服系统实践中总结的 LLM 输入缓存策略,包含命中率优化、模型升级兼容和数据安全等真实踩坑经验。
# LLM 缓存不是银弹:我在生产环境踩过的坑
做 LLM 应用的同学应该都听说过缓存——把相同的输入存下来,下次直接返回结果,省 token、省钱、提速。听起来很美好对吧?我试过,也翻过车,今天把这事儿摊开说。
一开始我们都太乐观了
去年给一个客服系统接大模型,用户问的问题其实高度重复。"你们的退款政策是什么?""怎么用发票报销?"这种问题每天能来几百次。
我们做了个最简单的方案:用输入 prompt 的 hash 做 key,Redis 存结果。效果立竿见影——P99 延迟从 3s 降到了 200ms,Token 用量直接砍了六成。
我当时觉得这方案稳了,甚至跟领导汇报说"性能瓶颈已经解决了"。现在想想,这句话里的每个字都是在给自己挖坑。
直到上线一周后开始收到用户的投诉。
第一个坑:缓存的"相同"不是数学上的相同
用户 A 输入:"你好,我想退款"
用户 B 输入:"请问怎么退款啊"
这两个意图几乎一样,但文本不同,所以分别走了模型推理。反过来,有些用户输入字符几乎一模一样,但因为一个空格或换行符,hash 值完全不一样——缓存没命中。
我们原以为缓存命中率能有七八成,实际只有四成左右。剩下六成的请求继续走模型,钱省得有限,性能提升也没达到预期。
这时候有人建议上语义相似度检索,说用 embedding 算 cosine distance,把相似的问题归到一组。我试了,效果确实比纯文本匹配好不少。但问题来了:每次进来一个新 query,你得先调一次 embedding 模型,这条本身的 cost 可能比直接让大模型回复还贵。
后来我们折中处理了——对最常见的 100 个问题做了关键词模板匹配,剩下的走原始 hash 路由。这个方案简单粗暴但有效。
import re
from hashlib import sha256
# 关键词模板库
QUESTION_TEMPLATES = [
{
"keywords": ["退款", "退货", "how to return"],
"cache_key": "refund_policy_cn",
"description": "退款政策查询"
},
{
"keywords": ["发票", "报销", "invoice", "fapiao"],
"cache_key": "invoice_help_cn",
"description": "发票帮助"
}
]
def build_cache_key(user_input: str) -> tuple[str, str]:
"""根据关键词模板 + hash 构建缓存 key"""
for template in QUESTION_TEMPLATES:
pattern = '|'.join(template['keywords'])
if re.search(pattern, user_input.lower()):
return template['cache_key'], 'template_match'
# 没有匹配到模板,走原始 hash
content_hash = sha256(user_input.encode('utf-8')).hexdigest()[:16]
return f"raw_{content_hash}", "exact_match"
上面的代码不是多精妙,但跑了一年没出过大问题。有时候简单就是王道。
第二个坑:模型在变,缓存还是旧的
我们用的模型版本升级了两次。新模型的回复质量更好、语气更自然。但因为缓存是跟着 input hash 走的,老缓存里的答案继续被返回给用户。
有用户留言说"你们机器人说话怎么越来越前后矛盾了"。我看了一下日志才发现,是缓存里的回复是旧版本的输出。
这个问题最难的地方在于:你没法简单清掉所有缓存。线上跑着的东西不能停。清得太干净,性能瞬间回去;不清,用户看到的就是过期的回答。
我们的解法是在缓存 value 里加一个 model_version 字段:
{
"answer": "我们的退款政策是...",
"model_version": "gpt-4o-2025-03",
"cache_hit_count": 1523,
"last_accessed": "2025-06-10T08:32:00Z"
}
每次模型升级时,不强制清除缓存,而是设置一个"旧版本淘汰期"(默认 7 天)。7 天后,旧版本的缓存条目自动过期。这个方案让过渡变得平缓,用户不会感受到明显的服务抖动。
第三个坑:缓存什么、不缓存什么
这是最考验判断力的。有些返回内容绝对不能缓存:
但一开始我们啥都往缓存里塞。有次缓存里存了一个包含用户手机号的结果,后来这个用户投诉数据泄露。虽然后面查了是缓存服务器本身的权限配置问题,但这事给我们提了个醒。
还有一个容易被忽视的场景:**长 conversation 的缓存**。有些用户的对话上下文很长(几十轮),如果直接把整个 context window 塞进去当 cache key,那基本上不会有命中。一个实用的办法是对长对话做一个摘要式的 fingerprint:
def conversation_fingerprint(messages: list[dict], max_tokens: int = 512) -> str:
"""
对长对话生成摘要指纹用于缓存
思路:取每轮 user 消息的前几个词 + 最近的 tool call 摘要
"""
parts = []
for msg in messages[:5]: # 只看前5轮
if msg.get('role') == 'user':
text = msg['content'][:20].strip()
parts.append(text)
# 加最近一轮的消息做补充
if len(messages) > 5:
last = messages[-1]['content'][:30].strip()
parts.append(last)
fingerprint = ' | '.join(parts)
return sha256(fingerprint.encode()).hexdigest()[:16]
这个方案明显不够精确,但胜在简单且不贵。缓存命中率大约能再提升 15% 左右。
一些实际跑出来的数字
几点实操建议
**别一开始就搞复杂。** 先用最简单直接的方案验证价值,遇到真实问题再针对性优化。
**关注缓存的 TTL 策略。** 知识类内容可以设长一点(7-30天),时效性内容设短一些(几小时),带用户信息的要么不缓存要么设最短 TTL。
**做监控和告警。** 命中率突然下降、缓存命中率超过 90%(说明分类规则太粗,该拆分的没拆分)、缓存占用超出预期——这些都应该有自动告警。
最后想说一句:缓存这东西本质上是在用空间换时间、用存储换算力。但它不是免费的午餐,维护成本、过期策略、数据一致性都要考虑进去。花时间去理解你的业务场景,再决定怎么做缓存,比抄别人的架构靠谱得多。
VkingAI