1. 三档缓存方案到底在比什么
大模型应用上线之后,最容易被低估的成本不是模型调用费,而是重复计算。同一个用户问“你们退货政策是什么”,今天问一遍,明天换个说法又问一遍,后台每次都老老实实把整段提示词塞给模型,token 照烧,延迟照等。我见过一个客服场景,日均请求 8 万次,其中约 37% 的 query 在语义上是高度重复的,只是措辞不同。这 37% 如果全部走模型,一个月多花的钱够买一台不错的开发机。
所以这次我拿同一个 LangChain 链路,跑了三档方案做对比:无缓存、普通缓存(精确匹配)、语义缓存(向量相似度匹配)。三档跑的是同一批测试集,同一套 prompt 模板,同一个模型,唯一变量就是缓存策略。目的很直接——搞清楚每档到底省多少、慢多少、准多少,以及生产上该怎么选。
先说结论方向,免得你看到一半才发现不是你要的:无缓存是基线,普通缓存对完全相同的 query命中率极高但覆盖面窄,语义缓存覆盖面最广但引入了一个新的风险——相似不等于等价。这三者不是替代关系,生产上我实际用的是两级缓存叠加,后面会讲怎么拼。
这篇文章适合已经在用 LangChain 搭 RAG 或 Agent、开始被成本和延迟咬到的人。如果你还在纠结 LangChain 怎么入门,建议先把 chain 和 retriever 跑通再回来看,不然有些细节会有点跳。
2. 三档方案的整体设计与选型逻辑
2.1 为什么用 LangChain 做这层对比
缓存这件事本身和框架无关,你完全可以在 FastAPI 的中间件里手写。但选 LangChain 做对比有个实际好处:它的LLMChain、Runnable、Embeddings接口是统一的,我可以在不改业务代码的前提下,只替换缓存层,把变量控制住。如果自己手写,光是保证三档的 prompt 拼接、模型参数、超时设置完全一致就要花不少功夫。
具体做法是把缓存逻辑包成一个Runnable,插在 prompt 之后、LLM 之前。这样无论上游是普通 chain 还是 agent,只要走这个 Runnable,缓存就生效。这个位置很关键——缓存必须放在 prompt 渲染之后,因为很多场景下 prompt 里会拼用户 ID、时间、上下文,如果放在渲染之前,缓存键根本对不上。
2.2 三档方案的核心差异
三档的差异集中在三个维度:缓存键怎么算、命中怎么判定、命中后返回什么。
| 维度 | 无缓存 | 普通缓存 | 语义缓存 |
|---|---|---|---|
| 缓存键 | 无 | query 原文哈希 | query 向量 |
| 命中判定 | 不判定 | 字符串完全相等 | 余弦相似度 ≥ 阈值 |
| 存储介质 | 无 | Redis String | Redis + 向量索引 |
| 命中后返回 | 调模型 | 直接返回缓存值 | 直接返回缓存值 |
| 额外依赖 | 无 | Redis | Redis + Embedding 模型 |
| 主要风险 | 成本高 | 命中率低 | 误命中 |
普通缓存的键我用的是md5(normalized_query),normalized 包括去首尾空格、统一小写、去掉连续标点。这一步看着简单,但实测能把命中率从 12% 拉到 19%——很多人 query 后面带个问号、多个空格,不归一化就全 miss 了。
语义缓存的键是 query 的 embedding 向量,存进 Redis 后用向量相似度检索。这里有个坑:embedding 模型必须和线上一致,我一开始用了个小的本地模型做测试,上线换成线上模型后,向量空间对不上,缓存全废。所以要么统一,要么在缓存键里带上模型版本号。
2.3 为什么阈值不能拍脑袋定
语义缓存最要命的就是相似度阈值。定高了,命中率上不去,等于白做;定低了,把“退货要几天”和“换货要几天”判成同一个,用户直接炸。我试过 0.85、0.90、0.92、0.95 四档,最后落在0.92附近,但这个值高度依赖你的 embedding 模型和业务语料,不能照抄。
判断方法很土但有效:人工标注 200 对 query,标出哪些“应该命中”、哪些“绝对不能命中”,然后跑一遍看不同阈值下的准确率和召回率,找平衡点。这个标注工作大概花我半天,但比上线后出事故强太多。
3. 核心细节解析与实操要点
3.1 普通缓存的键设计细节
普通缓存看着简单,但键的设计直接决定命中率。我踩过的坑是:一开始直接用原始 query 做键,结果“你好”和“你好 ”(带空格)算两个键。后来改成三步归一化:
- 去首尾空白,把中间连续空白压成一个空格
- 统一转小写(中文场景影响不大,但英文 query 影响明显)
- 去掉末尾的标点符号(问号、句号、感叹号)
归一化之后再做 md5,存 Redis 的 String 类型,value 是模型返回的完整 JSON。TTL 我设的是24 小时,因为业务知识库每天会更新一次,缓存太久会返回过期答案。
注意:普通缓存的 value 一定要存完整的结构化响应,不要只存文本。因为下游可能还要用 token 数、finish_reason 这些字段,只存文本会导致下游解析失败。
3.2 语义缓存的向量存储选型
语义缓存的核心是向量检索。Redis 从 4.0 开始支持模块,但原生的 Redis 不带向量能力,需要 Redis Stack 或者自己装 RediSearch 模块。我实测下来,Redis Stack是最省事的,一条 docker 命令就能起,自带向量索引。
docker run -d --name redis-stack \ -p 6379:6379 -p 8001:8001 \ -v /data/redis-stack:/data \ redis/redis-stack:latest8001 是 RedisInsight 的可视化端口,调试的时候很有用,能直接看到向量索引里的数据。生产环境我会把 8001 关掉,只留 6379。
向量索引的创建用FT.CREATE,关键参数是DIM(向量维度)和DISTANCE_METRIC。维度必须和 embedding 模型输出一致,比如text-embedding-3-small是 1536 维。距离度量我选的是COSINE,因为语义相似度用余弦更直观,阈值也更好解释。
3.3 语义缓存的命中判定流程
语义缓存的完整流程是这样的:
- query 进来,先做归一化
- 用 embedding 模型把 query 转成向量
- 拿向量去 Redis 做 KNN 检索,取 top-1
- 如果 top-1 的相似度 ≥ 阈值,命中,返回缓存值
- 否则 miss,调模型,把 query 向量和响应一起写回 Redis
第 3 步的 KNN 检索有个细节:FT.SEARCH的时候要指定DIALECT 2,否则某些版本的 RediSearch 不支持向量查询语法。这个坑我卡了快一小时,报错信息还很含糊。
提示:写回缓存的时候,建议同时存一份原始 query 文本。因为向量检索只能告诉你“相似”,但排查问题时你需要知道“到底和哪条 query 相似”。我一开始没存,出问题只能干瞪眼。
3.4 两级缓存叠加的实际做法
生产上我最终用的是两级:先查普通缓存,miss 了再查语义缓存。原因很实际——普通缓存是 O(1) 的字符串查找,几乎零成本;语义缓存要做 embedding + 向量检索,本身就有开销。如果 query 完全一样,没必要走语义那套。
这个顺序还有个好处:普通缓存命中的结果绝对准确,语义缓存命中的结果可能近似。先走精确的,能命中就命中,命中不了再退而求其次。实测下来,普通缓存能吃掉约 19% 的请求,语义缓存再吃掉约 21%,总命中率约 40%,剩下的 60% 才真正调模型。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先把环境搭起来。Python 侧需要这些包:
pip install langchain langchain-openai redis numpyRedis 用前面那条 docker 命令起 Redis Stack。如果你本地已经有普通 Redis,注意它不带向量能力,语义缓存那档会直接报错。我建议单独起一个 Redis Stack 实例,端口错开,别和业务 Redis 混用。
Embedding 模型我用的是 OpenAI 的text-embedding-3-small,1536 维,便宜且够用。如果你要完全本地化,可以用bge-small-zh这类中文模型,但维度会变,索引要重建。
4.2 无缓存基线的实现
无缓存这档最简单,就是标准的 LangChain chain:
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser prompt = ChatPromptTemplate.from_template("请回答用户问题:{query}") llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) chain = prompt | llm | StrOutputParser() result = chain.invoke({"query": "退货政策是什么"})这档的作用是提供成本和延迟的基线。我跑 1000 条测试 query,记录总 token 数、总耗时、平均延迟。后面两档都拿这个基线做对比。
4.3 普通缓存的实现
普通缓存包成一个 Runnable:
import hashlib import json import redis r = redis.Redis(host="localhost", port=6379, decode_responses=True) def normalize(q: str) -> str: q = " ".join(q.split()) q = q.lower() q = q.rstrip("??。.!!") return q def cache_key(q: str) -> str: return "llm:exact:" + hashlib.md5(normalize(q).encode()).hexdigest() def get_exact(q: str): return r.get(cache_key(q)) def set_exact(q: str, value: str, ttl: int = 86400): r.setex(cache_key(q), ttl, value)在 chain 里插入:
def invoke_with_exact_cache(query: str): cached = get_exact(query) if cached: return json.loads(cached) result = chain.invoke({"query": query}) set_exact(query, json.dumps({"text": result})) return {"text": result}这里我故意把 value 包成 JSON,方便后面扩展存更多字段。TTL 设 86400 秒,也就是一天。
4.4 语义缓存的实现
语义缓存复杂一些,先建索引:
from redis.commands.search.field import VectorField, TextField from redis.commands.search.indexDefinition import IndexDefinition, IndexType schema = ( TextField("query"), VectorField( "vector", "FLAT", {"TYPE": "FLOAT32", "DIM": 1536, "DISTANCE_METRIC": "COSINE"} ) ) r.ft("idx:semantic").create_index( schema, definition=IndexDefinition(prefix=["llm:semantic:"], index_type=IndexType.HASH) )写入和查询:
import numpy as np from langchain_openai import OpenAIEmbeddings emb = OpenAIEmbeddings(model="text-embedding-3-small") def to_bytes(vec): return np.array(vec, dtype=np.float32).tobytes() def semantic_search(query: str, threshold: float = 0.92): vec = emb.embed_query(query) q = ( f"*=>[KNN 1 @vector $vec AS score]" ) res = r.ft("idx:semantic").search( q, query_params={"vec": to_bytes(vec)}, dialect=2 ) if not res.docs: return None doc = res.docs[0] score = float(doc.score) similarity = 1 - score # COSINE 距离转相似度 if similarity >= threshold: return json.loads(doc.value) return None注意similarity = 1 - score这一步。RediSearch 返回的是距离,COSINE 距离越小越相似,所以相似度是 1 减距离。这个转换我一开始搞反了,导致阈值判断完全错乱。
4.5 三档对比的实测数据
测试集是 1000 条真实客服 query,去重后 623 条唯一 query。三档跑下来:
| 指标 | 无缓存 | 普通缓存 | 语义缓存 | 两级叠加 |
|---|---|---|---|---|
| 命中率 | 0% | 19.2% | 38.7% | 40.1% |
| 平均延迟 | 1.82s | 1.51s | 1.24s | 1.19s |
| P99 延迟 | 4.30s | 3.95s | 3.10s | 2.98s |
| token 消耗 | 100% | 80.8% | 61.3% | 59.9% |
| 误命中率 | - | 0% | 2.1% | 1.8% |
几个观察:普通缓存命中率不到 20%,因为用户措辞太发散;语义缓存把命中率拉到近 39%,但代价是 2.1% 的误命中;两级叠加的命中率提升有限,但延迟和 token 都更优,因为精确命中省掉了 embedding 开销。
误命中那 2.1% 我逐条看了,主要是“退货”和“换货”、“发票”和“收据”这类近义词被误判。这也是为什么阈值不能低于 0.92。
5. 常见问题与排查技巧实录
5.1 语义缓存命中率上不去
最常见的原因是阈值定太高或者embedding 模型不匹配。先确认线上用的 embedding 模型和建索引时一致,不一致的话向量空间完全不同,检索结果全是噪声。如果模型一致,就把阈值从 0.95 往下调,每次降 0.01,观察命中率和误命中率的变化。
还有个隐蔽原因:query 里带了动态内容。比如“我的订单 12345 到哪了”,这种 query 每次都不一样,语义缓存也救不了。这类 query 应该在进缓存之前就识别出来,直接跳过缓存。
5.2 Redis 向量检索报语法错误
大概率是DIALECT没设成 2。RediSearch 的向量查询语法在 dialect 1 和 2 之间有差异,不指定的话默认走 1,直接报错。另外确认 Redis 版本,普通 Redis 没有FT.SEARCH,必须 Redis Stack 或装了 RediSearch 模块。
5.3 缓存返回了过期答案
这是 TTL 设置的问题。如果业务知识库更新频繁,TTL 要相应缩短。我见过有人设 7 天,结果政策改了缓存还在返回旧答案。稳妥做法是知识库更新时主动清缓存,用SCAN匹配前缀批量删,而不是干等 TTL 过期。
5.4 误命中导致用户投诉
这是语义缓存最大的风险。除了调高阈值,还有个补救手段:命中后做一次轻量校验。比如把命中的原始 query 和当前 query 一起丢给一个小模型,判断是否真的等价。这会增加一点延迟,但能显著降低误命中。我实测这个校验能把误命中从 2.1% 压到 0.4%,代价是命中延迟增加约 200ms。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 语义缓存全 miss | embedding 模型不一致 | 核对模型名和维度 |
| 向量检索报错 | DIALECT 未设 2 | 加 dialect=2 参数 |
| 命中率异常低 | 阈值过高 | 逐档下调阈值测试 |
| 返回旧答案 | TTL 过长 | 缩短 TTL 或主动清缓存 |
| 误命中多 | 阈值过低 | 上调阈值 + 加校验 |
| 延迟反而变高 | embedding 开销大 | 检查 embedding 模型大小 |
6. 生产落地的几个关键取舍
6.1 成本与准确率的平衡
语义缓存本质是拿准确率换成本。2% 的误命中率听起来不高,但如果你的场景对准确性要求极高(比如医疗、金融),这 2% 可能就是事故。这种场景我建议只用普通缓存,或者语义缓存的阈值提到 0.97 以上,宁可少命中也不误命中。
反过来,如果是闲聊、推荐这类容错高的场景,阈值可以放到 0.88,命中率能再涨一截。
6.2 缓存粒度怎么定
缓存粒度有两个选择:缓存最终答案还是缓存中间结果。我选的是缓存最终答案,因为最简单直接。但如果你的链路里有多个可缓存节点(比如 RAG 的检索结果、Agent 的工具调用结果),可以分层缓存,命中率会更高,但复杂度也上去了。
我的建议是先做最终答案缓存,跑一段时间看数据,如果命中率还是上不去,再考虑分层。
6.3 监控指标不能少
上线缓存之后,这几个指标必须盯着:命中率、误命中率、缓存写入量、Redis 内存占用。命中率掉了说明 query 分布变了,误命中率涨了说明阈值需要调,内存占用涨太快说明 TTL 太长或者 key 设计有问题。
我一般会在 RedisInsight 里建个面板,每天扫一眼。别等用户投诉了才发现缓存出问题。
6.4 一个容易被忽略的细节
语义缓存的 embedding 调用本身也是要花钱的。如果 query 量很大,embedding 成本可能吃掉缓存省下来的钱。我算过一笔账:text-embedding-3-small每百万 token 约 0.02 美元,一条 query 平均 20 token,100 万条 query 的 embedding 成本约 0.4 美元,相比模型调用省下的钱可以忽略。但如果你的 query 特别长,或者用的是贵的 embedding 模型,这笔账要重新算。
7. 我实际用下来的一些体会
这套三档对比我前后折腾了大概两周,中间踩的坑比预想的多。最大的体会是:缓存不是加个 Redis 就完事,键的设计、阈值的选择、TTL 的设定,每一个都直接影响效果。普通缓存看着简单,但归一化没做好,命中率能差一倍;语义缓存看着高级,但阈值没调好,误命中能把用户得罪光。
如果让我给一个落地顺序的建议:先上普通缓存,成本几乎为零,能吃掉近 20% 的重复请求;跑稳了再上语义缓存,把阈值从 0.95 慢慢往下试,边试边看误命中。两级叠加是我目前认为性价比最高的方案,精确命中走快的,近似命中走准的,剩下的才交给模型。
最后分享一个小技巧:语义缓存的索引建好之后,用 RedisInsight 的查询界面手动搜几条 query,看看返回的 top-1 到底是不是你期望的那条。这个动作花不了几分钟,但能帮你提前发现阈值和模型的问题,比上线后看监控强。