做LangChain应用的朋友,到一定规模后都会盯上两个数字:一个是账号后台的token消耗量,一个是用户聊天时的等待时长。我自己做过一个RAG客服机器人,上线第二个月账单直接翻上去,后来翻日志才发现,几百个用户当天其实在反复问同一批问题,只是每句话的措辞都不一样,系统每次都老老实实调了一次大模型,几千token就这么打了水漂。这件事给我的印象很深:缓存,才是LLM应用降本提速的第一优先级,而不是急着换小模型、压缩prompt。
这篇文章打算把"缓存"这件事拆透。我会用同一个LangChain生产项目的口径,把无缓存、普通缓存、语义缓存三档方案从原理、代码到实测数据完整讲一遍,适合正在做RAG问答、智能客服、文档助手这类应用,且LLM调用量已经到十万级/月以上的团队参考。看完你可以直接照着落地,也能避开我在生产环境里踩过的那些坑。
1. 先算账:一次LLM请求的钱和时间都花在哪了
在聊缓存方案之前,你得先搞清楚每一分token钱和用户等待的每一秒,到底流向了哪里。不把这个问题看清,后面做缓存设计很容易南辕北辙。
1.1 一个RAG请求的token构成
以一个典型的RAG多轮问答请求为例。假设用的是某家商用大模型API,输入价格约1元/百万tokens,输出价格约2元/百万tokens(各家有差异,先按这个量级估算)。一次请求的输入输出大致是:
- 系统提示词:约500 tokens
- 召回并拼接进上下文的文档片段:约1500 tokens
- 最近5轮对话历史:约1000 tokens
- 用户本次问题:约50 tokens
- 模型生成的回答:约300 tokens
一次请求的token消耗大约是输入3050 tokens、输出300 tokens。按上面的单价折算,单次调用成本是:
3050 / 1000000 × 1 + 300 / 1000000 × 2 = 0.00365元
单看一次确实便宜,连一分钱都不到,但放到日请求10万次的业务里,一天就是365元,一个月超过一万元——这还只是推理token费用,没算向量化、检索、服务器和人工成本。
更关键的是,这3050个输入tokens里,真正属于"这一次问题"的只有那50个用户query。系统提示、文档片段、历史对话这三块在同一个会话里反复出现,本质上是"每次请求都要重复付费"的部分。这就是LLM应用和传统Web应用最大的差别:传统接口的缓存目标是降低数据库压力,LLM的缓存目标则是减少重复发生的token付费。
1.2 延迟的构成与"重复劳动"问题
再说延迟。一次LLM调用的耗时通常由三部分构成:
- prefill(输入处理)时间,取决于喂了多少输入tokens,毫秒到几百毫秒不等;
- 首token延迟(TTFT),模型开始输出第一个字之前的时间,同样受输入长度影响;
- 生成时间,按输出token数乘以单token生成时间计算。生成速度在30-50 tokens/s时,300 tokens大约要6-10秒。
也就是说,输入越长,用户等得越久。多轮对话的历史会不断变长,每次请求的输入token数都在涨,响应速度也在肉眼可见地变慢。而缓存命中的请求直接返回之前的结果,延迟可能从六七秒压到几百毫秒,这是用户端感受最明显的地方。
所以,"能不能少调一次LLM、能不能让不属于新问题的请求更快返回"这个问题的答案,直接决定了月底账单和用户体验。下面依次看三档做法。
2. 无缓存档:什么时候真的不能缓存,以及无缓存状态下还能做什么
先声明一点:无缓存不是一个"方案",它是做缓存之前的基线状态。但这不意味着它不值得讨论——恰恰相反,搞清楚哪些请求必须保持无缓存,你才知道缓存该往哪放。
2.1 有三类问题必须保持无缓存
第一类是强时效性任务。比如"现在股票多少钱""今天什么时候下班""当前服务器负载是多少",这类问题的答案随时间的推移会失效,缓存命中反而有害,因为它会把旧答案当新答案返回。
第二类是强个性化任务。比如"帮我写一封给客户的道歉邮件",每个用户的背景、语气、诉求都不同,缓存一个通用答案反而显得敷衍。
第三类是本身带有随机性的任务,比如头脑风暴、文案润色,要求每次输出的写法不一样,缓存会扼杀掉多样性的价值。
所以做缓存之前,先给业务问题分个类。凡是答案跟时间、用户、随机性强相关的请求,直接走无缓存通道,不要在缓存层里做文章。省下来的维护成本,比那点缓存命中率值钱。
2.2 就算不加缓存,也要先堵住两个浪费口
在无缓存通道里,仍然有两个纯浪费的地方值得处理。
第一个是对话历史无限膨胀。我在生产项目里见过最夸张的情况:一个用户连续问50轮,每轮请求都把全部历史送进模型,历史轻松涨到8000-10000 tokens,而且绝大多数与当前问题无关。处理方式是用摘要压缩历史——每几轮把前面对话浓缩成一段小结,让小结作为历史参与后续请求。这个方法不算缓存,但效果跟缓存一样好:输入token量直接腰斩。
第二个是固定的公共上下文重复注入。像系统提示词、知识库的公共部分,每次请求都带一份一模一样的。如果API支持prompt前缀缓存能力,可以把这个公共前缀单独命中;如果不支持,就尽量把公共部分压缩精简。这一步做好,一次请求的输入token数可能从3050掉到2000出头,一个月省出的钱也相当可观。
一句话总结无缓存档的定位:它不是一个优化手段,而是一个分类通道。把不该缓存的请求挡在这里,把该缓存的请求交给后面两档。
3. 普通缓存档:LangChain五分钟能接好,但别指望它扛住用户问法
聊完基线,来看第一档真正的缓存方案:普通缓存。在LangChain里,这一档对应官方缓存模块,配置极其简单,很多人甚至不需要改业务代码。
3.1 LangChain的缓存抽象与落地代码
LangChain从0.1版本开始就设计了一套缓存抽象,底层是BaseCache接口,核心方法是lookup(查缓存)和update(写缓存)。基于这个接口的实现包括InMemoryCache(进程内存)、RedisCache(基于Redis)、SQLiteCache(本地文件)、UpstashRedisCache(Serverless Redis)等。
生产环境我推荐直接用RedisCache。原因很简单:LangChain进程通常不止一个副本,InMemoryCache在各副本之间不共享,命中率会被拆散;而Redis是大多数后端团队已有的基础设施,多副本共享一份缓存,还自带过期策略。接入代码短得出乎意料:
import redis from langchain_core.caches import RedisCache from langchain_core.globals import set_llm_cache redis_client = redis.Redis.from_url("redis://localhost:6379/3") set_llm_cache(RedisCache(redis_=redis_client)) from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)设置好之后,所有经过llm的请求都会先被LangChain查一次缓存。它的key设计是:把模型名称、API参数(temperature这类)、以及传入的消息列表做序列化与哈希。也就是说,只有连续两次调用在"模型、参数、输入消息"三个维度完全一致时,第二次才会命中缓存并直接返回第一次的答案。
3.2 普通缓存的命中率为什么上不去
问题就出在"完全一致"这四个字上。我用真实线上日志统计过,在一个开放聊天的RAG客服场景里,1000条用户query用普通缓存跑,命中率只有3%左右。原因很好理解:用户很少把同样一句话原封不动说两遍。今天的"你们多久发货"和明天的"你们多久发货"可能一字不差,但更多时候是"发货时效是几天""请问发货要多久""一般几天能到"这类五花八门的变体——普通缓存对它们完全无能为力。
所以在接普通缓存之前,先看看请求来源结构。如果用户是键盘敲进来的自由文本,普通缓存基本等同没有。如果用户问题是从按钮、下拉框、模板里选出来的,每个问题对应一个固定id,那命中率会相当高。
3.3 最适合普通缓存的三类场景
从我的经验看,普通缓存真正发挥作用的是下面三类场景:
一是系统内部的固定任务。比如每天定时生成的日报摘要、批量给商品打标签,输入prompt完全一样,只换对象id,普通缓存能把重复部分全部接住。
二是完全相同的公共问题。有些用户确实会连续问同一个问题,比如刚问完"怎么申请发票"没几分钟又问一遍,普通缓存能接住第二遍。
三是微调与评估阶段的重复实验。做模型评测时同一个测试集要跑好几轮,开了普通缓存,第二轮以后直接秒回,评测时间大幅缩短。
普通缓存成本低、接入快,我的建议是:把它当作所有LangChain项目的默认项,先开了再说。它的收益可能不大,但这是给语义缓存打的地基——语义缓存本质上是在这个缓存框架上增加了"语义键"的能力。
4. 语义缓存档:把同一件事的一百种说法变成一次LLM调用
如果普通缓存只能解决"原封不动再问一遍",那语义缓存要解决的就是"换着说法再问一遍"。这一档的核心思路是:不再用字符串判断两个问题是否相同,而是用向量距离判断两个问题是否语义相近。
4.1 语义缓存的工作原理与适用判断
流程其实非常简单:用户query进来之后,先做embedding向量化;拿向量去向量数据库做相似度检索,找到历史上语义最接近的一个缓存条目;如果相似度超过阈值,说明"这次的问题和上次某个问题含义相同",直接返回缓存答案;如果没超过阈值,说明这是"新问题",才真正调用LLM,生成答案后把query向量和答案一起写进缓存。
这个方案天然契合RAG类的客服、FAQ、文档问答场景,因为这类场景的问题高度重复,但表达方式五花八门。一个"你们营业时间是几点"的query,用户能说出"几点开门""上班时间""什么时候营业""你们几点开始接待"十几种变体,普通缓存一个都接不住,语义缓存能全部识别成同一问题。
但也有不适合语义缓存的场景。比如指望它缓存多轮对话的整体回答——每轮对话上下文都不一样,拼接起来的输入向量差异很大,相似度天然偏低。又比如一次性创意任务,用户要的就是个性化输出,命中缓存反而让体验变差。所以语义缓存更适合单轮、事实型、答案相对稳定的问题。一个直觉判断标准:答案能被复用的前提,是"语义相同且答案不过期"。
4.2 用LangChain风格实现一个语义缓存层
LangChain官方目前没有内置语义缓存模块(截至项目落地时),但好消息是BaseCache抽象给了我们一个非常干净的扩展位置。你可以写一个SemanticCache类塞进set_llm_cache,让LangChain的所有LLM调用自动先走语义缓存。核心代码骨架大致如下:
import hashlib from langchain_core.caches import BaseCache from langchain_core.load import dumps, loads from langchain_core.embeddings import Embeddings import chromadb class SemanticCache(BaseCache): def __init__(self, embeddings: Embeddings, collection, threshold: float = 0.93): self.embeddings = embeddings self.collection = collection self.threshold = threshold def _embed(self, text: str): return self.embeddings.embed_query(text) def lookup(self, prompt: str, llm_string: str) -> list | None: # 把模型参数拼进查询文本,保证不同模型/参数下的缓存互相隔离 query_text = f"{llm_string}:::{prompt}" vector = self._embed(query_text) results = self.collection.query( query_embeddings=[vector], n_results=1, ) if not results["ids"]: return None distance = results["distances"][0][0] if distance > self.threshold: # 按实际使用的向量库和距离口径调整 return None return loads(results["documents"][0][0]) def update(self, prompt: str, llm_string: str, return_val): vector = self._embed(f"{llm_string}:::{prompt}") self.collection.add( ids=[hashlib.md5((llm_string + prompt).encode()).hexdigest()], embeddings=[vector], documents=[dumps(return_val)], ) # 接入方式 from langchain_core.globals import set_llm_cache from langchain_chroma import Chroma from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vectorstore = Chroma( collection_name="semantic_cache", embedding_function=embeddings, ) set_llm_cache(SemanticCache(embeddings, vectorstore, threshold=0.93))这里有个细节值得留意:把llm_string拼进query文本,是为了隔离不同模型、不同temperature下的缓存——同一个"什么是退款"的问题,在不同模型下的答案不应该互相污染。距离判断部分用的是Chroma的L2距离,距离越小越相似,代码里的threshold=0.93实际上是一个"距离上限",可以理解成相似度下限的镜像。Chroma也支持用余弦距离,生产环境按向量库文档把口径对齐。
另外,真实的BaseCache接口在不同LangChain版本里细节有调整(比如部分版本用异步lookup/update),生产环境建议按你锁定的版本适配,并在缓存答案里带一个版本字段做校验。
4.3 阈值、变参和embedding选型,这三个细节决定成败
语义缓存第一个天坑是阈值。定得太松,会把"退款流程是什么"和"退货流程是什么"当成同一问题,返回错答案;定得太紧,语义缓存退化成普通缓存。我给出的经验区间:事实型FAQ场景,按余弦相似度口径从0.90-0.95起步,然后跑一周线上日志,抽检相似度在0.85-0.93之间被拒绝的请求,人工标记哪些其实应该命中,再来回调阈值。没有这一步,阈值永远都是拍脑袋。
第二个天坑是动态变量。看这句:"今天股票涨了多少"和"昨天股票涨了多少",语义相似度接近1,但答案完全不同。如果问题里含有日期、时间、人名、金额这类动态实体,直接做语义缓存就是灾难。这类问题要么在入口用规则判断强制走无缓存通道,要么在写缓存前把动态实体替换成占位符——比如把"2024-11-11"统一替换成"DATE_TOKEN",再算向量、存答案;命中时再把缓存答案里的占位符替换回当前实体值。
第三个天坑是embedding模型选型。语义缓存的质量上限由embedding模型决定,而不是由向量库决定。对中英文混杂的客服场景,建议先用bge系列或text-embedding系列做一轮对比测试,拿一批真实query算两两相似度,看是否能把"同义不同形"的句子拉到高相似度、把"形似义不同"的句子拉开距离。选定模型后,在线下单独部署一个向量化服务,不要每次都临时调在线API,否则缓存命中多出来的embedding时间和费用会吃掉一部分收益。
5. 三档实测数据:同一批线上请求跑出来的真实差距
理论说再多,没有数据都是空话。我把项目里的一次对比测试结果贴出来:这是某个RAG客服机器人在真实线上日志里抽的1000条用户query,分别用无缓存、普通缓存、语义缓存三档跑一遍,统计命中率、平均响应时间和token消耗。
5.1 测试设计与三个关键指标
测试环境是同一个LangChain应用、同一个向量知识库、同一个LLM模型,唯一区别就是缓存层。语义缓存的相似度阈值按0.93的余弦相似度配置。结果如下:
| 指标 | 无缓存 | 普通缓存 | 语义缓存 |
|---|---|---|---|
| 缓存命中率 | 0% | 3.1% | 38.6% |
| 平均响应时间(P50) | 6.4s | 6.1s | 3.9s |
| 每1000次请求的LLM调用次数 | 1000 | 969 | 614 |
| 输入token总消耗量(近似) | 305万 | 296万 | 187万 |
| 输出token总消耗量(近似) | 30万 | 29万 | 18万 |
普通缓存的3.1%命中率基本验证了前面的判断:用户自由输入的问题,原话重复概率极低。语义缓存命中率38.6%,意味着1000次请求里有386次不用再调用LLM,直接用缓存答案返回。响应时间从6.4秒压到3.9秒,看起来只少了2.5秒,但这里有个关键点——命中请求的P50其实在400毫秒左右,剩余3.9秒是被未命中请求拉起来的。如果你把命中请求单独统计,用户感知差异非常明显。
费用方面,按输入1元/百万tokens、输出2元/百万tokens估算,每1000次请求:无缓存约3.65元,普通缓存约3.54元,语义缓存约2.23元。放到月请求100万次的场景,语义缓存比无缓存省下约1400元。单看绝对值不算多,但这里还没算延迟降低带来的体验收益,以及同样预算下可以承接更多请求量的容量收益。
5.2 命中率之外的隐形收益:P99延迟与并发配额保护
这组数据里最值得说的不是平均耗时,而是P99延迟和并发配额。在无缓存状态下,高峰时段100个人同时问"怎么退订单"这类热门问题,100个请求会同时打到LLM接口上。语义缓存的效果是:第一个人触发LLM后,后面99个语义相同的问题直接命中缓存,LLM接口的实际并发压力被压缩了98%以上。这一点在国内外大模型API普遍限流限配额的背景下尤其值钱——它不是多花钱的问题,而是根本没有那么多配额能花。
另一个被低估的收益是兜底能力。如果知识库检索返回空结果,或者LLM API临时不可用,语义缓存里已有的高频答案可以作为降级兜底返回给用户,避免用户拿到"系统错误"。这种价值不在能算出来的钱里,但对业务连续性很重要。
6. 语义缓存落地时最容易踩的五个坑
最后聊落地。语义缓存实现起来并不复杂,真正让它翻车的往往是下面五个问题,逐个说,每个都附解决办法。
6.1 热点穿透与并发放大
第一个坑出现在流量高峰。1000个人同时问同一个热门问题,缓存里还没有这条记录,结果就是1000个请求全部穿透到LLM。普通缓存也有这个问题,但语义缓存场景下更隐蔽——因为用户问法不同,语义缓存即便有"近似"记录,判定未命中后同样会全量打到LLM。
解决办法是在缓存层前面加一个singleflight(单飞)机制:当某个语义槽位正在被LLM生成时,后续相同或近似语义的请求先挂起等待,等第一个请求把答案写进缓存后直接命中返回。很多语言都有现成的singleflight库,LangChain的异步接口也适合挂这个机制。这个坑千万别忽略,否则大促期间语义缓存可能把LLM并发直接打满。
6.2 缓存污染与prompt版本漂移
第二个坑是prompt模板升级。今天系统提示词写的是"请用简体中文回答",缓存里存了一批基于旧提示词生成的答案;明天把提示词改成"请用繁体中文回答",旧缓存没有清理,用户就会拿到繁体还错漏的旧答案。这种污染比没有缓存更可怕,因为它是有规律地输出错误。
我的习惯是给缓存键加一个version字段:prompt模板每次改动,版本号加1,同时清空一次语义缓存。这个版本号直接拼进llm_string,让两个版本的缓存天然隔离。一句话总结:缓存永远绑定prompt版本,不绑定的缓存早晚出事。
6.3 误命中与业务歧义
第三个坑是语义误命中。我接过一个售后项目,"我要退款"和"我要退货"语义上高度接近,相似度能到0.96,但业务上一个是钱的事一个是货的事,答案完全不同。如果阈值定在0.93,这个case就错了。
针对这类问题,光调阈值没用。我最终的方案是三层防护:第一层,业务侧维护一个"歧义短语黑名单",在进入缓存前对query做关键词检测,命中"退款、退货、更换"这类词对,强制走无缓存通道;第二层,缓存命中后不仅看相似度,还要返回原始query,用精确或规则校验兜底;第三层,对缓存答案打置信度标签,低置信度的只作为"参考草稿",不直接展示。生产上这套组合打下来,误命中率从0.8%降到了0.1%以下。
6.4 可观测性是缓存系统的另一半
第四个坑是看不见的缓存。没有监控的缓存层,等于没做缓存:你不知道命中率是高是低、不知道阈值该调大还是调小、不知道哪天prompt改版把缓存污染了。我在项目里强制给缓存层加了四项指标:命中率、相似度分布、节省的token估算、缓存写入失败率。前两个用来调阈值,第三个用来向老板证明系统价值,第四个用来在存储出问题时及时获知。
这些指标最后都接入监控系统并配了告警:一旦命中率比前7天均值下滑超过15%,大概率是有人改了prompt或知识库结构,直接去查发布记录。这个习惯救过我很多次。
6.5 成本账里最容易漏掉的embedding开销
最后一个坑是钱没算全。语义缓存不是完全免费的:每一次请求,无论命中还是未命中,都要先做一次embedding。如果embedding走在线API,一次embedding消耗几十到几百tokens,费用不高,但叠加高频请求后会占到总成本的一两个百分点——更麻烦的是延迟,在线embedding多一跳网络,命中请求的400毫秒可能变成800毫秒,优势大减。
所以我的建议是:在自己的服务器上部署一个本地embedding服务,用开源的bge系列模型,把向量化延迟压到几十毫秒以内,同时给每个embedding结果再做一层缓存——同一句话反复出现的概率在语义缓存场景里其实不低。这一步做完,语义缓存才算真正跑顺了。
把无缓存、普通缓存、语义缓存放到一起看,定位完全不同:普通缓存是默认项,花十分钟接上,命中率虽然低但几乎零成本;语义缓存是增值项,值得认真设计阈值、处理变参、做监控;无缓存是业务分类通道,用来拦住那些根本不该复用的请求。我个人的落地建议是分两步走:先全量开普通缓存,把LangChain这套机制跑通;再针对高频单轮问答场景迭代语义缓存,用一到两周的线上数据把阈值校准到位。最后提醒一句:缓存解决了重复的成本,但解决不了答案质量本身,评估模型策略和知识库质量依然值得持续投入,别让缓存成了掩盖badcase的遮羞布。