LangChain 大模型缓存实战:无缓存、普通缓存与语义缓存三档对比
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 StringRedis 向量索引命中后返回调模型直接返回缓存值直接返回缓存值额外依赖无RedisRedis 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-small1536 维便宜且够用。如果你要完全本地化可以用bge-small-zh这类中文模型但维度会变索引要重建。4.2 无缓存基线的实现无缓存这档最简单就是标准的 LangChain chainfrom langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser prompt ChatPromptTemplate.from_template(请回答用户问题{query}) llm ChatOpenAI(modelgpt-4o-mini, temperature0) chain prompt | llm | StrOutputParser() result chain.invoke({query: 退货政策是什么})这档的作用是提供成本和延迟的基线。我跑 1000 条测试 query记录总 token 数、总耗时、平均延迟。后面两档都拿这个基线做对比。4.3 普通缓存的实现普通缓存包成一个 Runnableimport hashlib import json import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) 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, definitionIndexDefinition(prefix[llm:semantic:], index_typeIndexType.HASH) )写入和查询import numpy as np from langchain_openai import OpenAIEmbeddings emb OpenAIEmbeddings(modeltext-embedding-3-small) def to_bytes(vec): return np.array(vec, dtypenp.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)}, dialect2 ) 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.82s1.51s1.24s1.19sP99 延迟4.30s3.95s3.10s2.98stoken 消耗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 常见问题速查表现象可能原因排查方向语义缓存全 missembedding 模型不一致核对模型名和维度向量检索报错DIALECT 未设 2加 dialect2 参数命中率异常低阈值过高逐档下调阈值测试返回旧答案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 token100 万条 query 的 embedding 成本约 0.4 美元相比模型调用省下的钱可以忽略。但如果你的 query 特别长或者用的是贵的 embedding 模型这笔账要重新算。7. 我实际用下来的一些体会这套三档对比我前后折腾了大概两周中间踩的坑比预想的多。最大的体会是缓存不是加个 Redis 就完事键的设计、阈值的选择、TTL 的设定每一个都直接影响效果。普通缓存看着简单但归一化没做好命中率能差一倍语义缓存看着高级但阈值没调好误命中能把用户得罪光。如果让我给一个落地顺序的建议先上普通缓存成本几乎为零能吃掉近 20% 的重复请求跑稳了再上语义缓存把阈值从 0.95 慢慢往下试边试边看误命中。两级叠加是我目前认为性价比最高的方案精确命中走快的近似命中走准的剩下的才交给模型。最后分享一个小技巧语义缓存的索引建好之后用 RedisInsight 的查询界面手动搜几条 query看看返回的 top-1 到底是不是你期望的那条。这个动作花不了几分钟但能帮你提前发现阈值和模型的问题比上线后看监控强。