大模型应用上线后最现实的两个问题是贵和慢:同一个产品问题、相似的客服提问、重复的代码咨询,反复打进 LLM 接口,既烧 token 又堆延迟。语义缓存(Semantic Cache)正是为此而生——它不只按字面匹配,而是把”意思相近”的问题映射到同一份答案,命中即直接返回。本文拆解原理,并给出基于 Redis 与向量相似度两种可落地的代码。
一、为什么大模型应用需要语义缓存
传统接口靠缓存扛流量,但 LLM 的入参是自由文本,用户换种说法问同一件事,精确匹配就失效了。而真实业务里,大量请求语义高度重复:客服机器人 70% 的问题可能只是 30 个意图的变体,RAG 问答里”怎么退款””退款流程是什么””我要申请退款”本质是一个问题。
语义缓存的收益有三点:① 降本,命中缓存不消耗生成 token;② 提速,跳过推理直接返回,P99 从秒级降到毫秒级;③ 抗峰,突发流量先被缓存吸收,保护后端算力。
二、语义缓存 vs 传统缓存:本质区别
| 维度 | 传统 KV 缓存 | 语义缓存 |
|---|---|---|
| 匹配方式 | 精确 key(哈希相等) | 语义相似度(余弦/距离) |
| 换种说法 | 全部穿透 | 大概率命中 |
| 典型延迟 | 亚毫秒 | 亚毫秒(向量检索) |
| 失效风险 | 低 | 需防语义漂移/污染 |
| 适用场景 | 幂等查询、静态页 | 问答、对话、Agent 工具调用 |
三、语义缓存的三种落地路线
按精度与成本从低到高,常见三档:① 归一化哈希——把问题小写、去标点、去括号后哈希,最简单,适合表述高度固定的场景;② 向量相似度——对 query 做 embedding 再算余弦,能识别”换种说法”,是语义缓存的主流;③ 商用方案——Redis Stack 的 vector 索引、GPTCache、LangChain 的 SemanticCache 等开箱即用。
四、路线一:哈希归一 + Redis 极简实现
当问题表述相对固定(如内部工具问答),哈希方案零依赖、延迟最低。核心是把 query 规整成稳定 key:
import hashlib, redis, json
r = redis.Redis(host="127.0.0.1", port=6379, db=0)
TTL = 60 * 60 * 24 # 缓存一天
def norm(q: str) -> str:
# 小写、去标点与多余空白,得到稳定的规整串
q = q.lower().strip()
for ch in ",。!?!?;;::()()【】[]\"'":
q = q.replace(ch, " ")
return " ".join(q.split())
def get_cached(q: str) -> str | None:
key = "semcache:" + hashlib.md5(norm(q).encode()).hexdigest()
val = r.get(key)
return json.loads(val) if val else None
def set_cached(q: str, answer: str):
key = "semcache:" + hashlib.md5(norm(q).encode()).hexdigest()
r.set(key, json.dumps(answer), ex=TTL)
调用侧逻辑:先 get_cached,命中直接返回;未命中才请求 LLM,并把答案写回。注意只对确定性、可复用的回答缓存,避免把个性化结果误存。
五、路线二:向量相似度语义命中
要识别”换种说法”,必须用语义相似度。流程是:query 向量化 → 与已缓存向量算余弦 → 超过阈值(如 0.92)即视为命中。下面用轻量实现演示核心计算(生产可换成 pgvector 或 Chroma 存储与检索):
import numpy as np, redis, json, hashlib
def cosine(a, b) -> float:
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
THRESHOLD = 0.92
def semantic_get(query_vec, query_text):
# 遍历缓存集合,找最相似且超阈值的条目
for key in r.scan_iter("vecache:*"):
rec = json.loads(r.get(key))
sim = cosine(query_vec, rec["vec"])
if sim >= THRESHOLD:
return rec["answer"], sim
return None, 0.0
def semantic_set(query_vec, query_text, answer):
key = "vecache:" + hashlib.md5(query_text.encode()).hexdigest()
r.set(key, json.dumps({"vec": list(map(float, query_vec)),
"answer": answer}), ex=60*60*24)
阈值是一道工程权衡:定太高会漏命中(缓存形同虚设),定太低会把不同问题答错(缓存污染)。建议从 0.9 起步,结合业务评估逐步标定,并用可观测手段监控命中率曲线。
嵌入模型的选择直接决定命中质量:通用中文场景应优先选用支持中文的句向量模型(选型可参考 Embedding 模型选型与文本向量化实战),并固定模型版本——一旦升级,旧向量与新向量不可比,缓存需整体重建。存储侧若坚持用 Redis,可启用 Redis Stack 的向量索引(FT.CREATE 配合 VECTOR 字段类型),把”遍历全量算余弦”升级为”近似最近邻检索”,即便缓存规模涨到百万级,命中查询仍是毫秒级。
六、命中率与成本测算
| 场景 | 日均请求 | 命中率 | 省下生成 token/日 |
|---|---|---|---|
| 客服问答 | 50,000 | 65% | 约 32500 次推理 |
| RAG 知识库 | 20,000 | 50% | 约 10000 次推理 |
| Agent 工具调用 | 8,000 | 40% | 约 3200 次推理 |
语义缓存不是”省一点”,而是把重复性智力劳动一次性沉淀为可复用资产。配合 大模型推理成本优化 的思路,降本效果会叠加。
七、生产落地的 6 个坑
1) 隐私泄漏:缓存可能落库敏感提问,需脱敏或按租户隔离。2) 缓存污染:错误答案被命中会持续误导,必须配失效与人工纠错。3) 时效性:政策、价格类问题要短 TTL 或主动失效。4) 嵌入模型漂移:换 embedding 模型后旧向量不可比,需重建。5) 冷启动:缓存为空时命中率为 0,可预热高频问题。6) 阈值标定:用离线样本测算准确率/召回后再上线。
八、它和 Prompt Caching、模型网关的关系
三者是不同层次的降本手段,可叠加:语义缓存在应用层拦截”相似问题”,Prompt Caching 在模型层复用长系统提示的 KV,LiteLLM 多模型网关 在路由层做模型选择与配额。缓存命中后根本不进网关,是链路最前端的省钱闸。要衡量整条链路效果,可用 Langfuse 追踪命中率与成本。
存储侧,无论是哈希方案还是向量索引,都常依托 Redis 这类高性能 KV;若要做规模化向量检索,也可把 embedding 落到专门的向量库。语义缓存的价值不在于”新”,而在于把 LLM 应用从”每次都重新算”推进到”相似的只算一次”。
九、适用边界:语义缓存也会帮倒忙
不是所有 LLM 请求都该进缓存。三类场景要谨慎:① 强个性化——回答依赖用户私有上下文(”我的订单到哪了”),缓存会串号;② 强时效——实时行情、即时库存,命中旧答案就是错误;③ 低频长尾——请求几乎不重复,命中率趋近于零,反而徒增写入与检索开销。判断标准很简单:先统计真实流量的语义重复率,超过 30% 再上缓存;否则先把预算花在 大模型推理成本优化 上更划算。
十、小结
语义缓存是大模型应用上线的”性价比第一刀”:实现成本不高,却能同时降本、提速、抗峰。小步快跑可从哈希方案起步,命中率达标后再升级到向量相似度,并把阈值、失效、隐私三件事做扎实。当你的缓存开始稳定拦截重复提问,后端算力才能真正花在”第一次回答”上。




