← 返回博客
·ai-tech

LLM 缓存不是银弹:我在生产环境踩过的坑

在客服系统实践中总结的 LLM 输入缓存策略,包含命中率优化、模型升级兼容和数据安全等真实踩坑经验。

#LLM#Cache#Production

# 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% 左右。

    一些实际跑出来的数字

  • 优化前:缓存命中率 40%,平均每笔请求消耗 1200 tokens(mostly input)
  • 优化后:缓存命中率 65%,平均每笔请求消耗 420 tokens
  • 月 Token 费用从 $3,200 降到了 $1,400,降幅超过 55%
  • Redis 缓存服务器只用了 16GB 内存,存了 200 多万个 key,QPS 峰值 3000 也没出问题
  • 几点实操建议

    **别一开始就搞复杂。** 先用最简单直接的方案验证价值,遇到真实问题再针对性优化。

    **关注缓存的 TTL 策略。** 知识类内容可以设长一点(7-30天),时效性内容设短一些(几小时),带用户信息的要么不缓存要么设最短 TTL。

    **做监控和告警。** 命中率突然下降、缓存命中率超过 90%(说明分类规则太粗,该拆分的没拆分)、缓存占用超出预期——这些都应该有自动告警。

    最后想说一句:缓存这东西本质上是在用空间换时间、用存储换算力。但它不是免费的午餐,维护成本、过期策略、数据一致性都要考虑进去。花时间去理解你的业务场景,再决定怎么做缓存,比抄别人的架构靠谱得多。