为什么 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. 全程加超时与熔断:缓存异常时直接放行到模型
效果评估与参数调优
语义缓存上线后,要用指标说话。核心指标有三个:命中率(缓存命中的请求占比)、节省成本(命中请求数乘单次 Token 成本)、平均延迟(命中请求的响应时间)。命中率不是越高越好——它是阈值和业务重读率共同作用的结果,过高的命中率可能意味着阈值太低、答案质量在悄悄下滑。所以还要配合一个质量指标:对命中的样本抽样人工复核,确认返回的旧答案确实贴合新问题。
调优的方向是数据驱动的。第一步,记录每个请求的相似度和是否命中,画出分布图,观察同一意图和不同意图的相似度重叠情况,据此调阈值。第二步,分析未命中样本,看清洗规则能不能再兜住一批(比如去掉更多寒暄词)。第三步,分析误命中样本,针对容易混淆的意图补充缓存键字段或排除规则。每调整一次,都要回到命中率和质量指标上验证,不要凭感觉拍阈值。
语义缓存优化的目标不是「命中率最高」,而是「在保证答案质量的前提下,把可去重的请求尽可能拦下来」。
适用边界与风险
语义缓存不是所有场景都适用。它最适合「问题重复度高、答案相对稳定、对延迟敏感」的问答类应用。反过来,下面几类场景要谨慎:答案随时变化的(实时行情、动态库存)、问题高度个性化的(每个人的上下文都不一样)、对准确率要求极高的(医疗、金融决策建议),这类场景语义缓存带来的风险大于收益。带随机性输出的创意生成场景也不适合——同一句提示词生成两版不同的文案是产品特性,不是该省的成本。
最后说风险控制。一是缓存污染:如果某次模型输出质量很差(比如触发了幻觉或违规),这条答案被写进缓存后会被反复复用,污染面会放大。解法是给写入加质量闸门,明显异常的输出不写缓存。二是隐私:用户问题里可能带敏感信息,缓存存储要遵循与主数据同等的加密和脱敏要求,并且支持按用户维度定向清理。把这些边界想清楚,语义缓存才是真正「降本提速」的工程能力,而不是埋雷。