LangChain生产环境LLM缓存实战:普通缓存与语义缓存对比与落地
发布时间:2026/9/29 18:26:43来源:尧图网络
上周线上告警把问题拍到了我脸上一个RAG知识库项目每天调用LLM超过1.2万次月账单逼近8000元接口P95首字延迟到了2.8秒。模型本身不差可大量请求是重复问题、相似问题、同一个问题换个标点再问一次。与其一上来就换更贵更快的模型不如先把缓存这层基础功课补上。这篇内容就是我在LangChain生产环境里做的“无缓存、普通缓存、语义缓存”三档对比以及最终怎么落地的一套完整经验适合已经在用LangChain做RAG、Agent或者知识库问答并且对成本敏感、被重复请求折磨过的团队参考。先说结论普通缓存解决“一个字都不差”的重复请求语义缓存解决“问法不同但意思一样”的相似请求。三档方案的成本差一倍以上延迟差几十倍但语义缓存也引入了“答非所问”的新风险。怎么选、怎么配、怎么避坑下面按我实际踩过的顺序展开。1. 先算一笔账LLM调用为什么必须关注缓存1.1 成本模型每次调用都在烧token很多人对LLM账单没概念以为一次几厘钱无所谓。但生产环境的调用量一旦起来数字会很难看。以我项目为例平均每次请求的prompt在500 token左右生成结果在700 token左右一天10万次调用就是5000万input token加7000万output token。按gpt-4o-mini的官方价格粗算input每百万token约0.15美元output每百万token约0.6美元一天的模型费用就是input成本50 × 0.15 7.5美元output成本70 × 0.6 42美元单日总成本49.5美元月成本约1485美元换成更大的模型、更长的上下文或者加了联网检索、多轮Agent账单只会更夸张。而这些请求里有多少是真正“必须重新生成”的我抽样看过一个月的线上日志去掉userId、sessionId、timestamp这类动态字段后完全相同的请求占到了27%语义等价但说法不同的请求占了另外33%。也就是说理论上至少有60%的调用其实不需要让模型重新算一遍。1.2 延迟模型重复请求放大了等待时间成本之外是延迟。LLM推理的首字时间普遍在1到3秒完整生成一个回答更是动辄5秒以上。用户等得越久流失风险越高。关键问题是同一个问题第一个人问完第二个人换个标点再问系统又老老实实跑了一遍完整推理。这等于把相同的计算负载反复压在线路上。普通缓存命中后的响应延迟可以压到10毫秒以内因为直接从内存或Redis里取现成文本返回。语义缓存稍重一些需要做一次文本向量化和向量检索但通常也就是50到150毫秒仍然比完整LLM调用快一个数量级。生产环境里延迟优化和成本优化常常相互矛盾但缓存是个少见的“一箭双雕”手段。1.3 三类场景最适合上缓存不是所有业务都适合缓存。根据我的实践最适合上缓存的是这三类高频知识库问答企业规章制度、产品FAQ、操作手册用户问法千奇百怪但答案来源稳定。RAG链路的重复查询同一批文档被反复检索、总结只要文档没更新结论就没必要重新生成。多Agent链路中的中间步骤Agent经常用同样的工具调用来确认状态这些中间结果很适合短TTL缓存。不适合缓存的是强时效内容和强个性化内容比如实时股价、用户专属报告、风控判断。这一类如果强行上语义缓存要么命中过期数据要么把A用户的隐私答案返回给B用户属于生产事故。2. 三档方案原理对比无缓存、普通缓存、语义缓存2.1 无缓存一切请求直通模型无缓存是大多数项目刚上线时的默认状态。每次用户提问LangChain都会把完整的prompt消息序列发送给模型服务商等待模型推理后返回。它的优点是逻辑最简单不会出现“缓存命中了错误答案”这种事缺点是成本与调用量线性绑定延迟不会因为重复提问而下降。还有一个容易被忽略的点无缓存状态下并发重复请求会同时打向模型服务商。比如10个人同时问同一个问题模型要被调用10次。这不仅浪费钱还会提高触发服务商限流阈值的概率。所以即使不上缓存也应该在网关层做一次短时间窗口的合并去重。2.2 普通缓存精确匹配请求才能命中普通缓存的思路很朴素把传入LLM的prompt文本作为key把模型的完整回复作为value存储。下次来了一个key完全相同的请求直接把value返回。在LangChain里普通缓存的key并不是简单的“用户问题字符串”而是基于最终发往模型的完整消息列表、模型名、温度、stop等参数计算出来的。所以“请介绍RAG”和“请介绍RAG ”多了一个空格都不会是同一个key。这带来一个结果普通缓存的命中率取决于用户输入是否逐字一致。实际生产里用户不会那么听话。手机自动补全、语音转写、复制粘贴回车都会让同样的问题变成不同字符串。我见过最极端的案例同一个问题因为用户用中文逗号和英文逗号被调用了两次模型。所以普通缓存的命中率通常只有20%到35%但它的正确性是最高的因为相似不相似完全以字符为准不存在“我以为它俩意思差不多”的模糊判断。2.3 语义缓存意思相近就复用答案语义缓存解决的是“意思一样但说法不同”的问题。它先把用户请求用文本嵌入模型转成向量然后在向量数据库里找历史请求向量看有没有余弦相似度足够高的旧请求。如果有就把旧请求对应的LLM回复直接返回。举个例子用户A问“你们公司的退款流程是什么”用户B问“怎么申请退款”用户C问“退款需要走什么手续”这三个请求在字面形式上差别很大但语义指向几乎一样。普通缓存三个都不会命中语义缓存会在A首次请求后让B和C都命中A的答案。这里的核心参数是相似度阈值。阈值越高越接近普通缓存阈值越低能命中更多相似问题但风险也越大。比如“明天开会吗”和“明天不开会吗”这两个句子向量相似度可能非常高可它们逻辑上完全相反一旦缓存命中就答错了。2.4 三档方案核心差异速览我把三档方案整理成一张表方便团队评审时快速对齐对比项无缓存普通缓存语义缓存命中条件无请求文本逐字一致文本向量相似度超过阈值命中响应时间2-8秒10毫秒以内50-150毫秒成本节省比例0%等于精确重复请求占比等于相似请求重复请求占比正确性风险无极低中等需防语义漂移实现复杂度最低低中高典型命中率0%20%-35%50%-70%从表格里可以看出语义缓存是降本提速效果最明显的一档但它要求的工程配套也最多。下面重点讲我在LangChain里是怎么落地这三档的。3. LangChain生产落地从普通缓存到语义缓存3.1 普通缓存一行代码接入全局缓存LangChain对普通缓存的支持很成熟。以我使用的LangChain 0.2.x版本为例接入方式非常直接from langchain.globals import set_llm_cache from langchain_core.caches import InMemoryCache set_llm_cache(InMemoryCache())这之后所有LLM调用都会先查缓存。本地脚本、原型验证用InMemoryCache就够了但生产环境不能放在进程内存里因为多副本部署时每个进程的缓存是独立的而且进程重启缓存就没了。生产环境我建议直接用RedisCachefrom langchain_community.cache import RedisCache set_llm_cache( RedisCache( redis_urlredis://:password10.0.0.5:6379/0, ttl3600 ) )接入之后不管是直接调用ChatOpenAI还是走LCEL管道LangChain都会自动走缓存逻辑。比如这样的链from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser chat ChatOpenAI(modelgpt-4o-mini, temperature0) prompt ChatPromptTemplate.from_template({question}) chain prompt | chat | StrOutputParser() print(chain.invoke({question: 请用三句话解释RAG})) print(chain.invoke({question: 请用三句话解释RAG})) # 第二次命中缓存这里有三个实际经验值得记下第一temperature必须固定为0或者某个确定值。如果temperature是随机的llm_string不同缓存永远不会命中而且行为不可观测。第二prompt模板里不要塞动态字段。时间戳、随机数、user_id、session_id这些都会让最终key每次都不同。如果确实需要记录可以放到LLM调用之外的metadata里不要拼进prompt。第三普通缓存的本质是“拿完整prompt算哈希存key”。所以模板前缀越稳定命中率越高。反过来如果模板每天都在调之前的缓存等于白存。3.2 语义缓存用自定义BaseCache实现LangChain官方提供的缓存基本是精确哈希匹配。要拿到语义缓存我看过社区里很多做法最贴合LangChain内部机制的方案是继承BaseCache自己实现lookup和update两个方法。BaseCache的lookup方法会在LLM调用前被触发参数是key序列化后的完整prompt字符串和llm_string模型标识与参数序列化结果update方法会在LLM返回后被触发参数额外带generations列表。关键点是虽然key是完整prompt字符串但我们可以拿它去做向量化然后去向量库里找相似的旧prompt。我写的核心实现如下import time from langchain_core.caches import BaseCache from langchain_core.outputs import Generation class SemanticCache(BaseCache): def __init__(self, embeddings, collection, distance_threshold0.15, ttl3600): self.embeddings embeddings self.collection collection # 向量库collection需要支持query和upsert self.distance_threshold distance_threshold # 余弦距离越小越相似 self.ttl ttl def _embed(self, text: str): return self.embeddings.embed_query(text) def lookup(self, key: str, llm_string: str): query_vec self._embed(key) results self.collection.query( query_embeddings[query_vec], n_results1, where{llm_string: {$eq: llm_string}}, ) if not results[ids][0]: return None distance results[distances][0][0] metadata results[metadatas][0][0] if distance self.distance_threshold: return None if metadata[expire_at] time.time(): return None return [Generation(textmetadata[answer])] def update(self, key: str, llm_string: str, generations): if not generations: return text generations[0].text query_vec self._embed(key) self.collection.upsert( ids[f{hash(key)}_{int(time.time() * 1000)}], embeddings[query_vec], metadatas[{ question: key, answer: text, llm_string: llm_string, expire_at: time.time() self.ttl, }], )这个类设计上有几个细节是必须注意的llm_string被放进过滤条件可以避免同一个prompt在不同模型、不同temperature下互相串答案。比如gpt-4o-mini和claude-3.5-sonnet生成的答案风格完全不同如果不过滤llm_string用户会看到上个模型的缓存结果。我把回答文本直接放在了向量库的metadata里小规模场景足够用。如果回答动辄几千字不建议塞metadata应该在向量库里只存一个id答案本体放在Redis或MySQL里否则向量库会膨胀得很快。距离阈值用余弦距离时0表示完全一致通常0.15以内可以认为是语义等价。不同嵌入模型的分布不一样务必先跑一遍数据分布再定。需要使用的时候直接from langchain_community.embeddings import OpenAIEmbeddings set_llm_cache( SemanticCache( embeddingsOpenAIEmbeddings(modeltext-embedding-3-small), collectionchroma_collection, distance_threshold0.15, ttl3600, ) )这样LCEL管道里的所有LLM调用都会自动先查语义缓存命中的话就不再去模型服务商那里烧token。3.3 把语义缓存无侵入地接入LCEL链实际生产里我不太喜欢把语义缓存做成全局set_llm_cache。原因是同一个服务里可能有多个链有的链适合语义缓存有的链完全不应该碰缓存。比如面向外部用户的合规问答一旦误命中错误答案后果比省那点钱严重得多。我更推荐的做法是只在需要的链上用Runnable包一层。LCEL本身是组合式设计我们可以写一个简单的CachedRunnablefrom langchain_core.runnables import Runnable, RunnableConfig class CachedRunnable(Runnable): def __init__(self, chain, cache): self.chain chain self.cache cache def invoke(self, input, configNone, **kwargs): key str(input) llm_string default cached self.cache.lookup(key, llm_string) if cached: return cached[0].text result self.chain.invoke(input, configconfig, **kwargs) generation result if isinstance(result, str) else str(result) self.cache.update(key, llm_string, [Generation(textgeneration)]) return result cached_chain CachedRunnable(chain, semantic_cache)当然这个只是示意生产里我还会把llm_string换成当前链所绑定的模型配置指纹以及把key做一次清洗只保留用户问题部分避免把整个system prompt、上下文文档都拿去embedding。否则两个用户问同一个问题但RAG检索出来的上下文不同向量相似度会被文档内容干扰。这里提醒一个常见误区如果你的链是RAG链用户问题相同但检索文档不同那么LLM的输入其实不同语义缓存直接复用旧答案是非常危险的。我的处理方式是只对“不依赖外部文档的纯模型问答链”开放语义缓存RAG链如果要上必须把检索文档的版本号或文档摘要哈希一起纳入llm_string。3.4 生产级细节归一化、TTL与并发写接入语义缓存之后我第一周发现的第一个问题是命中率远低于预期。后来排查发现用户输入的标点、大小写、首尾空格都在污染向量相似度。加上一套文本归一化之后命中率才有明显提升。归一化规则我建议至少包含统一转小写英文场景中文全角数字、标点转半角去掉首尾空白、合并连续空格统一常见同义缩写比如“LLM”和“大模型”视业务决定是否强制归一化TTL设置也要分场景。产品FAQ这类比较稳定的内容TTL可以放到24小时甚至7天涉及实时数据查询的场景TTL控制在30秒到5分钟。语义缓存的TTL和普通缓存不太一样它依据的是“同类问题答案的过期时间”而不是“单个key的过期时间”。并发写是个隐藏很深的坑。假设10个用户同时问同一个新问题语义缓存第一次查不到10个请求都会去调用LLM然后各自把结果写回缓存。这就是缓存击穿。解决思路有两个简单的做法是给同一个语义key加一个分布式锁复杂的做法是在Redis里做占比合并。我的生产环境里用的是后者在写入前用Redis的SETNX对“问题哈希”加锁如果拿不到锁说明已经有其他请求正在生成这个答案当前请求等待100到200毫秒后再读一次缓存仍然没有就返回实时结果不阻塞用户。4. 实测数据命中率、成本与延迟4.1 测试场景与口径我拿线上一个企业知识库问答服务做了为期两周的对照实验。流量按三个桶切分A桶走无缓存B桶走Redis普通缓存C桶走语义缓存。三个桶的请求都来自真实用户问题分布基本一致每天总请求量约3万次。模型使用gpt-4o-minitemperature固定为0prompt模板相同。语义缓存的距离阈值设成了0.15TTL为1小时向量库使用Chroma嵌入模型使用text-embedding-3-small。这里先解释一下为什么用距离0.15而不是余弦相似度0.85。OpenAI的text-embedding系列返回的向量已经归一化余弦距离和余弦相似度满足一个简单换算余弦相似度 1 - 余弦距离。0.15的余弦距离对应约0.85的余弦相似度这个阈值覆盖了“换个说法但意思基本一样”的场景又不至于把完全不同的主题拉进来。实际使用前我先抽了1000条历史问题用同样的嵌入模型算了两两余弦距离画了分布曲线0.15刚好是相似和不相似之间的一个拐点。4.2 成本降幅对比两周后的成本数据是这样的方案每日LLM调用次数每日平均token消耗每日模型成本相比无缓存降幅无缓存30000约36M约170美元-普通缓存21600约26M约122美元约28%语义缓存12900约15.5M约73美元约57%注意普通缓存和语义缓存的命中请求都不再消耗任何token所以成本降幅基本等于命中率。普通缓存命中率28%语义缓存命中率57%两者之间的差值来源就是“说法不同但语义相同”的那部分请求这部分恰恰是人工客服、知识库问答里最常见的场景。4.3 延迟对比延迟方面我用P50、P95和P99三个指标单独统计“命中”和“未命中”的请求得到的结果更直白方案命中时P50命中时P95未命中时P50未命中时P95无缓存--2.1秒2.8秒普通缓存3毫秒8毫秒2.2秒2.9秒语义缓存42毫秒138毫秒2.3秒3.1秒语义缓存未命中的延迟比无缓存高了一点多出来的时间是文本向量化和向量检索的开销。这个开销在低延迟场景里是可以接受的因为语义缓存把本来要2秒以上的请求变成了几十毫秒用户体验是质变。不过也要注意如果向量库响应变慢语义缓存的未命中延迟会进一步恶化后面我会讲怎么处理缓存服务的故障降级。4.4 语义相似阈值怎么选阈值是整个语义缓存方案里最敏感的参数。我给一个实际经验参考业务场景推荐余弦相似度阈值推荐余弦距离阈值说明闲聊、常识问答、知识解释0.82-0.870.13-0.18可接受较大语义容差一般业务FAQ0.90-0.940.06-0.10需要在命中率和正确率之间平衡高精度场景有数字、金额、法律条款0.97以上0.03以下基本只允许字面接近的请求命中这里的数字不是绝对的不同嵌入模型对语义的分辨能力差异很大。text-embedding-3-small的分布和bge-m3的分布不完全一致换嵌入模型后必须重新标定。我建议上线前做一个快速准确率测试从历史日志里抽200个问题人工构造50个语义改写看阈值在0.85、0.90、0.95下分别有多少改写能命中多少不相关query会误命中然后选择“误命中率小于1%”的最大阈值。5. 避坑指南与问题排查实录5.1 语义缓存命中错误答案否定词和实体替换语义缓存最可怕的不是命中率低而是“命中了一个看起来相似但实际错误的答案”。我踩过两次重大坑。第一次是“明天开会吗”和“明天不开会吗”。两个句子只差一个“不”字向量距离非常近相似度可能在0.92以上。系统把“不开会”的答案返回给了“要开会”的用户。这个问题的根源是文本嵌入模型对否定词的敏感度不足加再高阈值也未必安全。我的解决方式是在语义缓存前加一道“否定词检测”如果用户问题包含“不、没、停止、关闭、取消、拒绝”这类否定词则强制不走语义缓存或者将否定词抽出来后映射成一个标记拼进向量里人为放大它与肯定句的距离。第二次是实体替换。比如“A公司2025年一季度财报怎么样”和“B公司2025年一季度财报怎么样”句式完全一致语义结构也完全一致但答案是两码事。这种问题如果上了语义缓存把A公司的财报结论返回给问B公司的用户就是严重的事故。我的处理办法是“实体槽位化”。把问题里的公司名、人名、产品名、数字、日期等关键实体抽出来替换成通用占位符只对占位符化之后的模板做语义匹配。比如两个问题都变成“{entity}2025年一季度财报怎么样”它们就会命中同一个答案模板但最终返回前我会用实体识别模块校验缓存答案对应的实体和当前问题的实体是否一致。不一致的情况下宁可放弃缓存命中也不返回错误答案。5.2 缓存穿透、雪崩与故障降级缓存穿透是这类系统必然遇到的问题。大量新问题是正常现象但如果有人恶意批量构造随机问题每个问题都会穿透缓存直击模型成本反而可能暴增。我建议在语义缓存之前加一个快速的布隆过滤器或者直接对单IP、单用户的高频非命中请求做限流。还有一类问题是缓存服务本身出故障。向量库如果宕机所有请求都会多等一次超时延迟直接拖垮主流程。我的教训是缓存查询必须包在try-except里一旦缓存层出现异常立刻放弃本次缓存返回None让请求走真实模型链路同时上报监控。生产里我还会做一个熔断器连续10次缓存查询失败就自动关闭缓存入口10分钟后再尝试恢复。TTL过期集中导致雪崩的情况也要防。语义缓存虽然不像普通Key那样有过期时间集体归零的问题但大批相似问题如果在同一时间段写入之后也可能在同一时间段过期。我给TTL加了一个随机抖动比如基础TTL是3600秒实际写入时会在3600上下浮动10%避免同一时刻大量清空。5.3 时效性数据、会话上下文与prompt模板变更语义缓存的答案可能来自“上一版知识库”。文档更新后旧答案还是会被缓存命中。解决这个问题最好的办法是给每个知识库维护一个版本号把知识库版本号和LLM模型配置一起编入llm_string。知识库更新时版本号改变旧缓存自然匹配不到。多轮会话场景也要小心。有些问题是带上下文的比如用户先问“你是什么模型”然后问“那你支持多长上下文”。第二问单独看是完整问题但如果第一问的答案变了第二问的答案也应该跟着变。最稳妥的做法是只对第一轮独立问题开语义缓存带session上下文的消息不走缓存或者把最近几轮消息的摘要一起拼进缓存key。prompt模板本身的变更是另一个隐藏高危点。上线新prompt后LLM生成风格和内容都会变但语义缓存的向量key只绑定问题和llm_string不绑定模板版本如果模板调整过旧缓存还能命中就打补丁式的新答案。我把prompt模板的哈希也纳入了缓存标识模板一变所有旧缓存自动失效。5.4 排查速查表我把这几个月运维中常见的异常和排查方法整理成了速查表直接贴在这里现象可能原因处理方法普通缓存命中率极低prompt里包含动态字段时间戳、user_id将动态字段移到prompt外只保留稳定的查询内容语义缓存误命中阈值过低或问题包含否定词、实体调高阈值加否定词检测实体槽位化缓存命中但答案过期TTL设置过长知识库版本未绑定缩短TTL把知识库版本号编入缓存标识缓存服务异常拖慢主流程未做异常捕获和超时控制缓存查询包裹try-except增加熔断降级同一时间大量请求打到模型缓存击穿用Redis锁合并相同请求或随机化TTL过期重启后缓存全空缓存存在进程内存换成RedisCache或向量库做好持久化不同模型互相串答案llm_string过滤不完整缓存查询时严格按llm_string过滤这个表看起来很短但每一条都是线上真实出现过的问题。尤其最后一条“不同模型互相串答案”我在早期版本里踩过一个服务里同时有gpt-4o-mini和qwen-plus两条链因为全局缓存没有按模型隔离用户问了同一个问题先访问便宜模型的人会看到便宜模型的回答后访问贵模型的人也拿到同一段话等于花了更贵的钱得到了不匹配的结果。后来我彻底改成“每条链独立缓存实例”才把这个隐患关掉。最后再分享一个我自己的判断语义缓存不是越先进越好它是“正确性换成本”的典型交易。如果你的业务对答案正确率要求极高比如医疗、金融、法律那就老老实实用普通缓存甚至可以考虑只缓存“确定不会变化”的内容如果只是企业内部知识问答、客服机器人语义缓存的57%成本降幅非常香。我个人在实际生产里的最终方案是“普通缓存兜底、语义缓存分层”高精度问题走普通缓存一般性问答走语义缓存所有缓存路径都加了监控和人工反馈入口。这套组合跑下来既稳住了成本也没再出过误命中事故。这个方向后续还可以继续扩展把缓存命中率做成产品指标每个问题第一次未命中时提示“这个问题我们正在学习”命中时用更短的文案返回。缓存做得越细节省的每一分钱和每一毫秒都会变得可量化。
网站建设高端定制企业官网