LLM 应用上线之后,最容易被低估的成本不是模型调用本身,而是那些"看起来无害"的重复请求。同一个用户反复问相似的问题、不同用户问语义几乎一致的问题、系统内部多个模块各自调用一次模型——这些流量叠加起来,账单会以肉眼可见的速度往上走。我最近在一个基于 LangChain 的生产项目里,把缓存从"完全不做"到"普通精确缓存"再到"语义缓存"完整跑了一遍,三档方案在响应延迟、命中率、成本、实现复杂度上的差距,比预想中大得多。这篇就把这套对比的完整落地过程拆开讲清楚,包括 Redis 的选型与部署、LangChain 里怎么接缓存、语义缓存怎么用向量相似度做匹配、以及生产环境里那些文档不会告诉你的坑。不管你是刚接触 LangChain 的新手,还是已经在跑 RAG 流程、想进一步压成本的老手,都能从里面找到能直接抄的部分。
1. 为什么 LLM 缓存这件事值得单独拿出来做
1.1 一次模型调用的真实成本结构
很多人对 LLM 成本的直觉停留在"按 token 计费"这一层,但真正跑起来会发现,成本由三块构成:输入 token、输出 token、以及被忽略的等待时间成本。输入 token 通常便宜,输出 token 贵好几倍,而等待时间虽然不直接计费,却直接决定了用户体验和并发承载能力。一个平均响应 3 秒的接口,在并发上来之后,线程池、连接数、上游限流全都会成为瓶颈。
我实测过一个典型场景:一个客服问答接口,平均每次请求输入 800 token、输出 300 token。单看一次调用不贵,但当天有 40% 的请求是重复或高度相似的。也就是说,接近一半的钱花在了"已经回答过的问题"上。这个比例在 To C 场景里非常常见,因为用户的表达方式有限,问来问去就是那几类问题。
缓存要解决的核心问题,就是把这部分重复流量拦在模型调用之前。拦得越准,省得越多。但"拦得准"这三个字,恰恰是三档方案拉开差距的地方。
1.2 三档方案的分界线到底在哪
我把三档方案的分界线定义得很清楚,方便后面逐一对比:
- 无缓存:每次请求都直接打到模型,没有任何拦截。
- 普通缓存:以请求的原始文本(或它的哈希)作为 key,精确匹配。命中就返回,不命中就调模型并写入缓存。
- 语义缓存:把请求文本转成向量,在向量库里找相似度超过阈值的已有问题,命中就复用它的答案。
这三档的本质区别在于匹配粒度。无缓存是零匹配,普通缓存是字符串级精确匹配,语义缓存是语义级模糊匹配。粒度越粗,命中率越高,但误命中(把不该复用的问题当成同一个)的风险也越大。生产落地的全部难点,几乎都围绕"如何在提高命中率的同时控制误命中"展开。
1.3 什么样的项目适合上语义缓存
不是所有项目都值得上语义缓存。我的判断标准有三条:第一,请求的语义重复率高,比如客服、FAQ、知识问答、代码助手;第二,答案对时效性不敏感,或者说时效性可以用 TTL 控制;第三,能接受"相似但不完全相同"的答案被复用。
反过来说,如果每个请求都带强个性化上下文(比如"帮我总结我今天的日程"),或者答案必须严格对应精确输入(比如代码执行、数值计算),那语义缓存基本没用,甚至有害。这一点在选型阶段就要想清楚,否则后面调阈值会调到怀疑人生。
2. 无缓存基线:先把真实开销测出来
2.1 基线测试的搭建方式
做任何优化之前,先要有基线。我用 LangChain 搭了一个最小的问答链,模型走标准的 Chat 接口,不做任何缓存,然后用一组真实脱敏后的用户问题跑压测。问题集我准备了 500 条,其中大约 200 条是语义重复的(换了个说法问同一件事),用来模拟真实分布。
测试指标我盯四个:平均响应延迟(P50/P95)、每秒处理请求数、模型调用次数、以及折算后的 token 成本。这四个指标后面每一档都要重新测一遍,才能形成可比对的表格。
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser prompt = ChatPromptTemplate.from_template("请回答用户问题:{question}") model = ChatOpenAI(model="gpt-4o-mini", temperature=0) chain = prompt | model | StrOutputParser() # 无缓存基线:每次直接调用 def ask_no_cache(question: str) -> str: return chain.invoke({"question": question})这段代码没有任何花哨的地方,但它是后面所有对比的锚点。我建议你在自己的项目里也先跑一遍这个基线,因为不同业务的问题分布差异极大,别人博客里的命中率数字对你未必有参考价值。
2.2 基线跑出来的数字说明了什么
500 条请求跑下来,模型调用次数是 500 次,一次没省。P50 延迟在 1.8 秒左右,P95 到了 4.2 秒,因为有几条问题触发了较长的输出。折算成本按当时的单价算,这一轮大概花掉了一个可以量化的金额。
真正让我警觉的是 P95。无缓存方案下,延迟完全由模型决定,你没有任何调节空间。用户量一上来,P95 会迅速恶化,因为上游有并发限制,排队时间会叠加进去。也就是说,无缓存不只是贵,它的稳定性也最差。
提示:基线测试一定要用真实分布的问题集,不要用随机生成的字符串。语义重复率是决定缓存收益的核心变量,用假数据测出来的命中率没有任何意义。
2.3 无缓存并非一无是处
说句公道话,无缓存也有它的适用场景。如果请求量很小(比如每天几百次),或者每个请求都高度个性化、几乎不可能重复,那上缓存的复杂度收益比很低,直接裸调反而最省心。我见过一些团队为了"技术先进"硬上语义缓存,结果命中率不到 5%,还引入了向量库的运维负担,纯属给自己找事。
所以我的建议是:先测基线,算出你的语义重复率。如果重复率低于 15%,普通缓存可能就够了;低于 5%,老老实实无缓存。只有当重复率明显偏高、且精确匹配覆盖不了的时候,语义缓存才真正划算。
3. 普通缓存:Redis 接入 LangChain 的正确姿势
3.1 为什么选 Redis 而不是本地内存
普通缓存最简单的做法是用一个 Python 字典存在进程内存里。但生产环境基本不能这么干,原因有三个:多实例部署时各进程缓存不共享,命中率被稀释;进程重启缓存全丢;内存无法统一管理和淘汰。
Redis 解决的正是这三点。它是独立进程,所有应用实例共享同一份缓存;支持 TTL 自动过期;有成熟的内存淘汰策略。LangChain 官方也直接提供了 Redis 的缓存适配,接起来非常顺。
安装这块,Linux 上直接包管理器装就行,macOS 用 Homebrew 也很方便。Windows 用户如果不想折腾,用 Docker 起一个是最省事的:
docker run -d --name redis-cache -p 6379:6379 redis:7-alpine生产环境我一般会配主从或者哨兵,避免单点。但如果只是做缓存,数据丢了可以重建,其实对持久化的要求没那么高,配个 AOF 保证重启不丢太多就够了。
3.2 LangChain 里接 Redis 缓存的完整代码
LangChain 的缓存接口设计得很干净,只要把 cache 对象传给模型就行。下面是我实际用的版本:
import hashlib from langchain_redis import RedisCache from langchain_openai import ChatOpenAI from langchain_core.globals import set_llm_cache # 初始化 Redis 缓存,设置 TTL 为 1 小时 redis_cache = RedisCache( redis_url="redis://localhost:6379", ttl=3600, prefix="llm_cache:" ) set_llm_cache(redis_cache) model = ChatOpenAI(model="gpt-4o-mini", temperature=0)这里有几个细节值得说。ttl一定要设,否则缓存会无限增长,Redis 内存迟早爆掉。prefix建议加上,方便你在 Redis 里区分不同业务的缓存,也方便批量清理。temperature=0是缓存的前提,如果温度不为零,同一个问题每次答案都不一样,缓存就失去意义了。
3.3 精确缓存的命中率天花板
普通缓存的命中逻辑是:把整个请求(包括 prompt 模板渲染后的完整文本)做哈希,拿哈希去 Redis 查。这意味着只要有一个字符不同,就算不命中。
实测下来,那 200 条语义重复的问题里,普通缓存只命中了大约 60 条。为什么?因为用户换个说法,比如"怎么退款"和"退款流程是什么",哈希完全不同,缓存直接穿透。这就是精确缓存的命中率天花板——它只能拦住字面完全一致的请求。
不过普通缓存有个巨大优势:零误命中。字面一样,答案必然可以复用,没有任何风险。而且实现简单、延迟极低(一次 Redis 查询,通常 1 毫秒以内),运维成本也低。所以它适合作为第一道防线,先上,再考虑语义层。
| 对比项 | 无缓存 | 普通缓存 |
|---|---|---|
| 命中率(本次测试) | 0% | 约 12% |
| 平均延迟 | 1.8s | 1.6s |
| 误命中风险 | 无 | 无 |
| 实现复杂度 | 极低 | 低 |
| 额外运维 | 无 | Redis |
3.4 普通缓存踩过的两个坑
第一个坑是缓存键包含了不该包含的东西。LangChain 默认会把整个 prompt 序列化后做 key,如果你的 prompt 里带了时间戳、用户 ID 这类每次都变的内容,那缓存永远不命中。我一开始就在系统提示里塞了当前日期,结果命中率直接归零,排查了半天才发现。
第二个坑是序列化不一致。不同版本的 LangChain 对 prompt 的序列化方式可能有细微差别,导致升级后旧缓存全部失效。这个不算 bug,但要有心理预期,升级前最好评估一下缓存重建的成本。
注意:如果你的 prompt 里确实需要动态内容(比如用户昵称),考虑把动态部分从缓存键里剥离出来,或者干脆接受这部分请求不缓存。
4. 语义缓存:用向量相似度换命中率
4.1 语义缓存的工作原理拆解
语义缓存的核心思路是:不再比较字符串,而是比较含义。具体流程是,请求进来先转成向量,然后去向量库里找最相似的历史问题,如果相似度超过阈值,就认为它们是同一个问题,直接返回历史答案。
这里有个很形象的类比。普通缓存像查字典,必须拼写完全一样才能查到;语义缓存像问一个理解你的人,你说"我钱包丢了怎么办"和"银行卡不见了该找谁",他都知道你在问同一件事。
实现上,语义缓存需要三个组件:一个 embedding 模型(把文本转向量)、一个向量存储(存历史问题和答案)、一个相似度阈值(判断是否算命中)。LangChain 生态里这几个都有现成的,但把它们串起来并调好阈值,才是真正花时间的地方。
4.2 向量存储与 embedding 的选型
向量存储我选了 Redis 的向量能力(Redis Stack 自带),原因是它和普通缓存共用一套基础设施,不用额外引入新的中间件。如果你已经在用 Milvus、Qdrant 或者 pgvector,也完全可以,逻辑是一样的。
embedding 模型的选择要权衡成本和效果。生产上我一般用轻量级的 embedding 模型,因为语义缓存对精度的要求没有 RAG 那么高,只要能把"意思相近"的问题聚到一起就行。用大模型做 embedding 属于杀鸡用牛刀,成本还高。
from langchain_redis import RedisSemanticCache from langchain_openai import OpenAIEmbeddings from langchain_core.globals import set_llm_cache embeddings = OpenAIEmbeddings(model="text-embedding-3-small") semantic_cache = RedisSemanticCache( redis_url="redis://localhost:6379", embeddings=embeddings, distance_threshold=0.2, # 距离越小越相似 ttl=3600 ) set_llm_cache(semantic_cache)注意这里的distance_threshold是距离,不是相似度。距离越小表示越相似,0 表示完全相同。这个参数是整个语义缓存里最需要反复调的东西。
4.3 阈值调优:命中率和误命中的拉锯战
阈值调得太松(距离阈值调大),命中率上去了,但会把不相关的问题也匹配上,用户会收到答非所问的回复。阈值调得太紧,命中率又掉下来,等于白做。
我的调优方法是:准备一批标注好的问题对,明确哪些是"应该命中"的,哪些是"绝对不能命中"的,然后扫一遍阈值,看哪个值能在保证零误命中的前提下命中率最高。这个过程没有捷径,必须用你自己的数据跑。
实测下来,那 200 条语义重复的问题,语义缓存命中了大约 170 条,命中率从普通缓存的 12% 提升到了 34%(含精确命中)。延迟方面,命中时只需要一次 embedding 加一次向量检索,大约 200 毫秒,比调模型的 1.8 秒快了一个数量级。
| 对比项 | 无缓存 | 普通缓存 | 语义缓存 |
|---|---|---|---|
| 命中率 | 0% | 约 12% | 约 34% |
| 命中时延迟 | 1.8s | <10ms | 约 200ms |
| 误命中风险 | 无 | 无 | 中(依赖阈值) |
| 实现复杂度 | 极低 | 低 | 中高 |
| 额外成本 | 无 | Redis | Redis + embedding 调用 |
4.4 语义缓存不是免费的午餐
语义缓存每次请求都要先算一次 embedding,这本身也是要花钱和花时间的。如果命中率不够高,embedding 的成本可能反而超过省下来的模型调用成本。所以语义缓存有个隐形的盈亏平衡点,命中率必须超过某个值才划算。
另外,向量检索本身也有延迟,虽然比调模型快,但在高并发下向量库也会成为瓶颈。我建议语义缓存只对"高价值、高重复"的请求开启,而不是全量开启。比如可以把请求先过一遍普通缓存,没命中再走语义缓存,形成两级缓存。
5. 三级缓存串联:生产环境的最终形态
5.1 为什么最终要串成三级
单独用任何一档都有短板。无缓存太贵,普通缓存命中率低,语义缓存有误命中风险和 embedding 开销。生产环境里我最终采用的是三级串联:先查普通缓存(精确、零风险、极快),没命中再查语义缓存(模糊、高命中),还没命中才调模型。
这个顺序不能反。如果先查语义缓存,那些本来能精确命中的请求也要走一遍 embedding,白白增加延迟和成本。精确匹配永远是第一优先级,因为它又快又准。
5.2 串联的实现与请求流转
实现上,LangChain 的缓存机制本身是单层的,要做三级需要自己包一层。我的做法是写一个统一的查询函数,按顺序尝试各级缓存:
def ask_with_tiered_cache(question: str) -> str: # 第一级:精确缓存 exact_key = hashlib.md5(question.encode()).hexdigest() cached = redis_client.get(f"exact:{exact_key}") if cached: return cached.decode() # 第二级:语义缓存 semantic_hit = semantic_cache.lookup(question, llm_string="default") if semantic_hit: return semantic_hit[0].text # 第三级:调模型 answer = chain.invoke({"question": question}) # 回写两级缓存 redis_client.setex(f"exact:{exact_key}", 3600, answer) semantic_cache.update(question, "default", [answer]) return answer这段代码把三级串起来了,逻辑很直白。回写的时候两级都写,保证下次精确请求能走最快的路径。
5.3 缓存失效与数据一致性
缓存最大的敌人是过期数据。如果底层知识库更新了,但缓存里还是旧答案,用户就会拿到错误信息。我的处理方式是给不同类别的缓存设不同的 TTL:时效性强的(比如库存、价格)设短一点,几分钟;知识性的(比如产品说明)设长一点,几小时甚至一天。
另外,当知识库发生重大更新时,我会主动清理相关前缀的缓存,而不是等它自然过期。Redis 的SCAN加DEL可以按前缀批量清理,但要注意别用KEYS,那个在大数据量下会阻塞。
注意:语义缓存的失效比精确缓存麻烦,因为一个答案可能对应多个相似问题。清理时要么按 embedding 的元数据批量删,要么干脆整段清空重建。
5.4 监控指标:怎么知道缓存有没有在干活
上线缓存之后,必须监控几个指标,否则你根本不知道它有没有生效。我盯的是:各级缓存的命中率、平均命中延迟、embedding 调用次数、以及缓存内存占用。
命中率如果突然掉下来,通常是 prompt 变了或者 TTL 设太短。内存占用如果持续上涨,说明 TTL 或者淘汰策略有问题。这些指标我一般接到现有的监控面板上,设个告警阈值,异常了能第一时间发现。
6. 生产落地中那些文档不会写的事
6.1 并发写入导致的缓存击穿
高并发下有个经典问题:同一个问题在缓存还没写入的瞬间,被多个请求同时打到模型,这叫缓存击穿。结果就是模型被重复调用,缓存的意义被削弱。
解决办法是加锁。第一个请求发现缓存未命中时,先抢一把分布式锁,抢到的去调模型并写缓存,没抢到的等一会儿再查缓存。Redis 的SET NX就能实现这个锁。虽然增加了一点复杂度,但在高并发场景下非常必要。
6.2 语义缓存的"答案漂移"
语义缓存有个隐蔽的问题:随着缓存里积累的问题越来越多,新问题可能匹配到一个"看起来相似但语境不同"的旧问题。比如"如何取消订单"和"如何取消订阅",字面很像,但答案完全不同。如果阈值没调好,就会串答案。
我的应对策略是给缓存条目加上分类标签,匹配时不仅看向量相似度,还要求分类一致。这样能大幅降低跨类误命中。代价是缓存键变复杂了,但换来的是准确性。
6.3 成本核算:省了多少要算清楚
上了缓存之后,一定要算清楚到底省了多少。我的算法是:统计命中次数,乘以单次调用的平均成本,就是省下的钱;再减去 embedding 调用成本和 Redis 的运维成本,才是净收益。
实测下来,三级缓存方案把模型调用次数从 500 次降到了 330 次左右,省了约三分之一。考虑到 embedding 的成本远低于主模型,净收益是正的。但如果你的命中率只有 10% 出头,那语义缓存这一层可能就不划算了,不如只保留普通缓存。
6.4 什么时候该放弃语义缓存
最后说个反直觉的结论:语义缓存不是越早上越好。如果你的业务还在快速迭代,prompt 和知识库天天变,那缓存会频繁失效,维护成本很高。这种情况下,先上普通缓存,等业务稳定了再考虑语义层。
我见过团队在 MVP 阶段就上语义缓存,结果每次改 prompt 都要重建整个向量库,开发效率被拖垮。缓存的本质是"用空间和复杂度换成本",在业务没定型之前,这个交换往往不划算。
7. 三档方案的选型决策表
把前面的经验浓缩成一张决策表,方便你对照自己的场景快速判断:
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 请求量小、个性化强 | 无缓存 | 复杂度收益比低 |
| 请求量大、字面重复多 | 普通缓存 | 零风险、实现简单 |
| 语义重复多、答案时效性弱 | 语义缓存 | 命中率显著提升 |
| 高并发、成本敏感 | 三级串联 | 兼顾速度、命中与准确 |
| 业务快速迭代期 | 普通缓存 | 避免频繁重建向量库 |
选型没有标准答案,核心是搞清楚你的语义重复率和误命中容忍度这两个变量。前者决定收益上限,后者决定你能把阈值调多松。
8. 我个人的几点实操体会
跑完这三档对比,最大的感受是:缓存这件事,收益和复杂度是强相关的,没有银弹。普通缓存几乎零成本就能拿到 10% 左右的命中率,性价比极高;语义缓存能把命中率翻两三倍,但引入的 embedding 成本、阈值调优、误命中风险都是实打实的负担。
如果让我给一个落地顺序,我会是:先上普通缓存,跑一段时间看真实命中率;如果命中率明显偏低且语义重复率高,再上语义缓存;最后用三级串联把两者结合起来。每一步都要有数据支撑,别凭感觉上。
还有个细节值得强调:缓存键的设计比缓存本身更重要。我踩过的坑里,一大半都是因为缓存键里混进了动态内容,导致命中率异常。上线前一定要把缓存键打印出来看几遍,确认它只包含真正影响答案的部分。
最后,别忘了给缓存设 TTL 和监控。缓存不是设完就不管的东西,它需要持续观察和调优。一个没人管的缓存,迟早会变成内存泄漏或者错误答案的来源。