语义缓存实战:用缓存给大模型应用降本提速

大模型应用上线后最现实的两个问题是:同一个产品问题、相似的客服提问、重复的代码咨询,反复打进 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,00065%约 32500 次推理
RAG 知识库20,00050%约 10000 次推理
Agent 工具调用8,00040%约 3200 次推理

语义缓存不是”省一点”,而是把重复性智力劳动一次性沉淀为可复用资产。配合 大模型推理成本优化 的思路,降本效果会叠加。

七、生产落地的 6 个坑

1) 隐私泄漏:缓存可能落库敏感提问,需脱敏或按租户隔离。2) 缓存污染:错误答案被命中会持续误导,必须配失效与人工纠错。3) 时效性:政策、价格类问题要短 TTL 或主动失效。4) 嵌入模型漂移:换 embedding 模型后旧向量不可比,需重建。5) 冷启动:缓存为空时命中率为 0,可预热高频问题。6) 阈值标定:用离线样本测算准确率/召回后再上线。

八、它和 Prompt Caching、模型网关的关系

三者是不同层次的降本手段,可叠加:语义缓存在应用层拦截”相似问题”,Prompt Caching 在模型层复用长系统提示的 KV,LiteLLM 多模型网关 在路由层做模型选择与配额。缓存命中后根本不进网关,是链路最前端的省钱闸。要衡量整条链路效果,可用 Langfuse 追踪命中率与成本。

存储侧,无论是哈希方案还是向量索引,都常依托 Redis 这类高性能 KV;若要做规模化向量检索,也可把 embedding 落到专门的向量库。语义缓存的价值不在于”新”,而在于把 LLM 应用从”每次都重新算”推进到”相似的只算一次”。

九、适用边界:语义缓存也会帮倒忙

不是所有 LLM 请求都该进缓存。三类场景要谨慎:① 强个性化——回答依赖用户私有上下文(”我的订单到哪了”),缓存会串号;② 强时效——实时行情、即时库存,命中旧答案就是错误;③ 低频长尾——请求几乎不重复,命中率趋近于零,反而徒增写入与检索开销。判断标准很简单:先统计真实流量的语义重复率,超过 30% 再上缓存;否则先把预算花在 大模型推理成本优化 上更划算。

十、小结

语义缓存是大模型应用上线的”性价比第一刀”:实现成本不高,却能同时降本、提速、抗峰。小步快跑可从哈希方案起步,命中率达标后再升级到向量相似度,并把阈值、失效、隐私三件事做扎实。当你的缓存开始稳定拦截重复提问,后端算力才能真正花在”第一次回答”上。

上一篇 Elasticsearch 实战:从全文检索到聚合分析
下一篇 uv 实战:Python 包管理与虚拟环境极速指南