为什么 LLM 应用需要语义缓存

LLM 应用上线之后,你会发现一个很普遍的现象:用户问的问题高度重复。同一个知识库问答系统,每天几千次请求里,可能有三分之一到一半的问题是换着说法问同一个意思。传统缓存只能做「一模一样」的精确匹配,用户把「介绍一下退款流程」改成「请问怎么申请退款」,就匹配不上了,只能老老实实再调一次大模型,既花钱又慢。

语义缓存要解决的就是这个问题:把用户问题转成向量,用相似度判断「这次的问题和之前哪次问题意思相近」,相近就直接返回当时的结果,不再调用大模型。命中一次省下的,不只是一次 API 调用费,还有一次可能长达几秒的生成延迟。对用户量大、问题重复度高的客服、问答、助手类应用来说,语义缓存是投入产出比很高的优化手段。

语义缓存的本质是「按意思去重」:用户换一百种说法问同一件事,模型只被调用一次。
本文核心知识点
  • 向量化把问题文本转成 Embedding 向量,语义相近则向量相近。
  • 命中判定相似度超过阈值视为命中,阈值需要结合业务调优。
  • 存储设计缓存条目的键、过期时间与淘汰策略直接影响命中率。
  • 旁路实现缓存查不到再走模型,查得到直接返回,对主流程无侵入。
语义缓存配图:中央堆叠立方体缓存仓库,请求图标合并复用,环绕流动光效箭头表示快速响应,蓝色渐变扁平风格
语义缓存的示意:相同语义的请求合并复用,命中后直接返回结果。

语义缓存的核心原理:向量化与相似度

语义缓存的技术基础是文本向量化。用一个 Embedding 模型把用户问题编码成一个高维向量,向量之间的空间距离就代表了语义距离:「怎么申请退款」和「如何办理退款」的向量会挨得很近,而它和「怎么申请加薪」的向量会离得很远。向量化的质量直接决定缓存好不好用:Embedding 模型选型、是否需要针对领域微调,这些和向量检索那篇讲的原则是一样的,这里不再重复。

有了向量之后,剩下的问题就是「怎么判断足够相似」。最常用的是余弦相似度,取值范围从负一到一,越接近一说明方向越一致、语义越相近。也有用欧氏距离或内积的,取决于向量是否归一化。实际工程里一般先对 Embedding 做归一化,这样余弦相似度和内积等价,计算更快,很多向量数据库也默认按余弦相似度来建索引。

# 语义缓存的相似度判定(伪代码)
# 1. 用户问题 q 转成向量 v = embed(q)
# 2. 在缓存向量库里检索 top-1 最相似的条目
# 3. 计算余弦相似度 sim = dot(v, cached_v) / (|v| * |cached_v|)
# 4. 若 sim >= THRESHOLD(例如 0.95),判定命中
# 5. 命中则返回缓存结果;未命中则走大模型并写入缓存
def semantic_lookup(q):
    v = embed(q)
    best, sim = vector_db.top1(v)
    return best if sim >= THRESHOLD else None

命中判定:阈值、边界与校验策略

阈值是语义缓存最关键的旋钮,没有完美值,只有取舍。阈值设高了(比如 0.98),只有几乎复述原句才命中,省不了多少成本;阈值设低了(比如 0.85),会把「意思不完全一样」的问题也当成重复,返回一个并不贴切的旧答案,伤害用户体验。合理做法是先跑一段真实流量,统计相似度分布,看「同一意图」和「不同意图」在相似度上分得开不开,再挑一个尽量分开两类分布的位置。

阈值之外,还要考虑边界情况的校验。有些场景光看问题向量不够:同样问「今天天气怎么样」,北京和上海答案完全不同,如果缓存只看问题本身,就可能张冠李戴。所以缓存键要把关键上下文一起编码进去——城市、用户身份、产品线、当前页面等,可以拼进文本再向量化,也可以做成多个字段组合成缓存键。另外,带变量的问题(比如「订单 12345 的状态」)天然不适合语义缓存,需要规则识别并跳过。

# 缓存键设计:把影响答案的上下文都纳入
# 1. 基础键:用户问题文本
# 2. 附加字段:城市、用户分组、产品版本、会话场景
# 3. 组合方式:附加字段直接拼接进向量化文本,或单独作为过滤条件
# 4. 排除规则:包含订单号、金额等变量的请求不缓存、不查缓存
# 5. 校验策略:命中结果可附带版本号,业务规则更新后整体失效

缓存存储:键设计、过期与淘汰

缓存存哪里?小型应用直接用内存里的向量索引(比如 faiss 的 IndexFlatIP)加一张结果表就够了;请求量上来之后,可以换成 Redis 加向量模块,或者直接用一个向量数据库,把向量和原始文本、结果、元数据放一起。存储方案要和检索量匹配:每天几千次查询用内存完全没问题,几百万次就需要认真评估索引和分片了。

过期与淘汰是缓存系统逃不掉的功课。答案会过时:知识库更新了、政策变了、产品改了,旧缓存必须能失效。常用手段是给每条缓存设置 TTL(比如 24 小时),业务变更时按标签批量失效,或者给缓存带上版本号整体刷新。容量上限也要定:达到上限后按 LRU 或 LFU 淘汰旧条目,避免缓存库无限膨胀导致检索变慢、内存吃紧。

存储方案适合规模优点注意点
内存 faiss + 结果表小流量 / 单机零依赖、延迟最低重启丢缓存
Redis 向量模块中流量复用已有 Redis内存占用需规划
向量数据库大流量 / 分布式容量大、可持久化额外运维成本

工程实现:旁路缓存与请求改写

把语义缓存接入现有 LLM 应用,最稳妥的方式是做成旁路(sidecar)组件:在应用调用大模型之前插一层缓存查询,命中就直接返回,没命中才继续走原来的调用链,调用完成后把结果写回缓存。这样缓存逻辑和业务逻辑解耦,即使缓存服务挂了,也只是退回「每次都调模型」的原始状态,不影响可用性。注意一定要给缓存查询设置超时和降级开关,不能让缓存本身拖慢主流程。

请求改写是另一个实用技巧。很多问题带着「包装词」:「你好,我想问一下……」「麻烦帮忙看看……」这些前缀对语义没有贡献,却可能拉低相似度。可以在向量化之前做一层轻量清洗:去掉寒暄前缀、统一标点、规范简繁和大小写。清洗后的文本再向量化,命中率会明显提升。清洗规则要保守,只做安全的归一化,别把关键信息洗掉。

# 旁路缓存的接入流程
# 1. 应用收到用户请求 q
# 2. 缓存中间件:清洗文本 → 组装缓存键 → 查向量库
# 3. 命中:返回缓存结果(记录 cache_hit 指标),结束
# 4. 未命中:调用大模型拿到 answer
# 5. 把 (q, answer, 时间戳, 元数据) 写入缓存
# 6. 全程加超时与熔断:缓存异常时直接放行到模型