前阵子帮一个团队排查线上LLM服务,账单出来那会儿,负责人盯着月度成本曲线说了句:再这样下去,项目还没跑通,钱先烧完了。同时另一个问题也很扎眼——用户等结果的平均延迟超过两秒。降本和提速,几乎是所有LLM应用在生产阶段绕不开的两件事。我给出的第一板斧,不是急着换便宜模型,而是先看流量里有多少调用属于“重复劳动”。于是围绕“缓存”这个主题,我把无缓存、普通缓存(精确匹配)、语义缓存(向量近似)三档方案都实测了一遍,最终在LangChain里把组合方案落到了生产环境。如果你正在做客服问答、RAG知识库、Agent这类高频调用LLM的工程,这篇内容值得完整看完;就算短期不打算上缓存,至少能搞清楚哪一档适合你的场景。
1. 为什么LLM调用值得做缓存:先想清楚“重复”这件事
1.1 LLM调用和数据库查询的“查重”逻辑是相通的
做后端的朋友对缓存都不陌生,MyBatis的二级缓存、Spring的三级缓存、Redis分布式缓存,本质都是在不同层级复用数据,把昂贵计算换成廉价查询。LLM缓存只是把“复用对象”从数据库行换成了“大模型的生成结果”。换句话说,如果一个请求之前已经让模型算过一次,并且答案仍然有效,那就没必要再付一次钱、再等一次生成。
这里要区分一个关键差异:数据库查询的输入输出是强确定性的,同样的SQL每次返回同样的结果;LLM则不同,即便你输入完全相同的Prompt,在temperature大于0时每次生成的文本也可能不一样。但这恰恰让缓存有了大量发挥空间——很多业务场景需要的就是稳定答案,比如固定话术、报错解释、规章制度问答。既然用户要的是“这个问题的标准答案”,那第一次生成完后把结果存起来,后续直接返回,就是最朴素的降本提速。
1.2 三档缓存的本质区别:从“一模一样”到“意思相近”
很多朋友一上来就问我“上哪个缓存”,我的回答是:先搞清楚你的重复形态长什么样。
- 无缓存:所有请求都打到模型,一步都不省。
- 普通缓存(Exact Match Cache):输入文本必须和缓存里的文本完全一致,哈希一致才命中。
- 语义缓存(Semantic Cache):输入先做Embedding,向量距离足够近就算命中,哪怕问法和原文完全不一样。
打个比方。无缓存就像每次去图书馆都让管理员重新翻一遍书;普通缓存是管理员发现“这本书刚才有人借过”,直接把上次的答案抄给你;语义缓存则是管理员听完你的问题,觉得“这跟昨天那个人问的差不多”,把昨天的答案递了过来。
所以三档方案没有绝对的好坏,只有匹配密度和误判风险的取舍。普通缓存命中条件苛刻,但基本不会给错答案;语义缓存命中率高,却要承担“语义相近但实际答案不同”的错配风险。
1.3 缓存命中率背后的流量画像
决定选哪一档之前,我建议你先做一次流量画像分析。拉一周的线上日志,把请求体里的Prompt字段拿出来聚类看看:相同的Prompt占据多大比例?语义相近但措辞不同的Prompt又占多大比例?
我见过不少团队跳过了这一步,直接上了最复杂的语义缓存,结果发现90%的请求本来就是完全相同的模板提问,普通缓存就能覆盖,白花了Embedding和向量库的钱;也见过反过来的情况——业务上全是用户自由输入,普通缓存命中率不到3%,上了语义缓存后成本直接砍掉三分之一。缓存方案选型本质上是“对重复密度做投资”,先看清牌面再下注。
2. 档位零:无缓存基线,什么场景下“裸奔”反而合理
2.1 无缓存时的成本与延迟基线怎么算
上缓存之前,先要把“不缓存”的基线情况摸清楚,不然你根本没法证明缓存带来了多少收益。成本计算很简单:
单次调用成本 = 输入Token单价 × 输入Token数 + 输出Token单价 × 输出Token数
假设你用的是中等价位模型,一次常规问答平均消耗总Token约1500个,折算人民币约0.2元。每天1万次调用,日成本就是2000元,月成本约6万元。这个数字看着不算什么,但放到Agent场景里会吓人一跳——一个Agent任务往往要调5到10次模型,每完成一次用户请求,背后的模型调用成本会被放大一个数量级。
延迟基线也建议做一个简单的压测记录:网络往返、排队等待、生成时长分别占多少毫秒。我见过一些系统模型生成只需要800毫秒,却因为上游重试、超时设置不合理,端到端延迟拖到5秒以上。无缓存阶段不只是“省事”,它其实是你衡量后续所有缓存方案收益的尺子。
2.2 不设缓存时,这些前置能力必须有
可能有人觉得“无缓存”就是什么都不用做,这是误解。不缓存不等于不做保护。恰恰相反,没有缓存兜底时,每一个请求都会真实地打到模型供应商那里,你的系统必须具备以下能力:
- 超时控制:模型接口偶发变慢是常态,超时设置不能一把梭,要按接口分开配置。
- 重试策略:遇到5xx或限流错误要指数退避重试,但重试次数要克制,否则会放大成本和压力。
- 幂等设计:重试不能导致业务侧重复处理,请求ID要在链路中透传。
- 限流熔断:模型供应商的配额随时可能被打满,本地要有快速失败的开关。
2.3 哪些场景应该老老实实保持无缓存
有些场景真的不适合缓存。一是强个性化输出,比如给不同用户生成不同的营销文案、个人周报、定制化代码建议,结果差异很大,缓存几乎帮不上忙;二是流式输出体验,用户看到的是一个字一个字蹦出来的过程,这种交互形态下缓存命中时反而会因为“瞬间出结果”和“逐字输出”的体验不一致造成困惑;三是临时性任务,比如一次性翻译、一次性摘要,这些请求没有重复消费的可能,挂在缓存上只会白白占用存储。
我的实践经验是:新功能上线的前两周先保持无缓存,跑真实流量、看日志分布、统计重复度。不要一上来就铺缓存,因为你连业务会以什么姿势重复都不知道,设计出来的缓存方案大概率要返工。
3. 档位一:普通缓存(精确匹配),实现最简单、成本最低
3.1 普通缓存的工作原理与适用点
普通缓存的实现逻辑是:把请求的Prompt文本取出来,做归一化处理后计算哈希,作为Redis的Key;调用LLM时先去查这个Key,命中就直接返回缓存值;没命中就调用模型,然后把结果写回Redis,同时设置一个合理的TTL过期时间。
适用这个方案的是“业务上天然存在完全重复提问”的场景。我见过命中率特别高的几个例子:企业内部的固定制度问答、政府办事指南的模板化咨询、在线教育里的标准习题解答、以及定时任务循环调用同一批Prompt做数据清洗。这些场景的提问文本几乎不变,普通缓存就能吃到大部分重复流量。
但要说句实在话:在用户自由输入的场景里,普通缓存的命中率普遍不高。我自己的线上项目统计下来,通常在5%到15%之间。原因很简单——用户不会每次都敲一模一样的字,多一个空格、换一种标点、调整一下语序,哈希就变了。
3.2 LangChain中普通缓存的三种落地方式
LangChain对普通缓存的支持很完整,最简单的方式是全局设置:
from langchain.globals import set_llm_cache from langchain_community.cache import RedisCache from redis import Redis redis_client = Redis.from_url("redis://localhost:6379/0") set_llm_cache(RedisCache(redis_client))看到set_llm_cache这个名字就要警惕:它是全局生效的,会影响进程内所有LLM实例。如果你只想让某一个模型实例走缓存,就不要用全局设置,直接给LLM对象绑定缓存实例,或者自己写一个薄的包装类来接管“先查缓存、后调模型”的逻辑。不同LangChain版本的API细节有差异,实操时先确认你锁定的版本。
单机开发调试时可以用内存缓存:
from langchain.cache import InMemoryCache set_llm_cache(InMemoryCache())生产环境多实例部署时,内存缓存会面临每台机器各自为政、缓存互相不通的问题,所以务必用Redis这类集中式存储。
3.3 普通缓存的三个典型坑
第一,缓存Key默认不包含模型参数。
LangChain的普通缓存默认对Prompt做哈希,但temperature、model、system消息这些因素不会自动拼进Key里。这就可能导致你用高temperature模型生成的结果,被另一个要求低temperature稳定输出的请求命中了。生产环境建议自定义Key生成逻辑,把model、temperature、system prompt都拼进去。
第二,文本归一化不做,命中率肉眼可见地下降。
用户手滑多打了一个空格、结尾多了一个换行符,这些看似微小的差异都会导致哈希失配。建议在生成Key之前对Prompt做一次normalize:去除首尾空白、把连续多个空格合并、统一换行符、甚至做一层简单的大小写归一。
第三,流式输出和一次性返回混用会出问题。
流式接口吐出的是增量片段,缓存的是最终完整文本。如果业务里既有流式调用又有非流式调用,缓存层一定要统一以“最终完整结果”为准,不要让流式的中间片段写进缓存。
4. 档位二:语义缓存(向量近似匹配),命中率更高的进阶方案
4.1 语义缓存的工作原理:从字面匹配到语义距离
语义缓存的核心思想是:不再要求文本一模一样,而是把用户问题映射成向量,向量距离接近就算命中。实现链路分四步:先让用户问题经过Embedding模型变成一个向量;然后在向量数据库里做最近邻检索;接着计算检索结果与当前提问的相似度,超过阈值就判定命中;最后把缓存中对应的历史回答返回给用户。
这里要弄清楚“距离”是怎么回事。文本被Embedding模型映射到高维向量空间后,语义相近的文本在向量空间里会靠得很近。两个向量的余弦相似度越高,说明语义越接近。LangChain的RedisSemanticCache里有个distance_threshold参数,默认值是0.2,它表示的是“余弦距离”的阈值(余弦距离 = 1 - 余弦相似度)。也就是说,阈值0.2约等于要求两个问题的语义相似度达到0.8以上才命中。阈值越小越严格,越大越宽松。
4.2 LangChain接入RedisSemanticCache的生产代码
下面是我在项目中实际使用过的语义缓存配置,中文场景下Embedding模型选的是BGE系列:
from langchain.globals import set_llm_cache from langchain_community.cache import RedisSemanticCache from langchain_community.embeddings import HuggingFaceBgeEmbeddings from redis import Redis bge_embeddings = HuggingFaceBgeEmbeddings( model_name="BAAI/bge-small-zh-v1.5", encode_kwargs={"normalize_embeddings": True}, ) redis_client = Redis.from_url("redis://localhost:6379/1") set_llm_cache( RedisSemanticCache( redis=redis_client, embedding=bge_embeddings, distance_threshold=0.2, ) )需要注意几个前置条件。RedisSemanticCache依赖Redis的Search模块,自建Redis时要确认已经加载了RediSearch;云Redis部分版本自带该模块,但低版本不支持就会直接报错。生产环境建议把缓存单独用一个Redis实例或独立db编号,避免和业务缓存互相挤占。
再提醒一句:embedding模型的服务化部署要提前做好。上面代码用的是本地推理方式,适合中小流量;如果每秒查询量很大,建议把Embedding模型做成独立推理服务,用HTTP接口调用,避免每次缓存查询都进行一次本地模型推理,CPU和GPU都会被拖累。
4.3 阈值怎么标定?我的实测经验
distance_threshold到底设置多少,这是语义缓存上线前必须回答的问题。我建议拿出真实的用户问题样本,用如下步骤标定:
- 准备200到500条真实历史问题。
- 挑出其中“语义相同但措辞不同”的问题对,计算所有向量距离,画出分布图。
- 再挑出“语义相近但答案确实不同”的问题对,同样计算向量距离。
- 阈值取两组分布的交叉区域偏严格一侧,宁可漏命中,不要误命中。
我在客服类业务里踩过这样的坑:阈值开到0.3之后,用户问“怎么退货”和“怎么退款”被判定为同一问题,直接返回了同一个答案。这俩在语义上的确接近,但一个讲退货流程,一个讲退款时限,内容完全不同。后来把阈值收紧到0.15后,误命中率明显下降。
4.4 语义缓存的成本和一致性风险
语义缓存不是免费的。
- Embedding计算本身有CPU/GPU开销,也有API费用。
- 向量检索比Redis普通GET慢一个数量级,一次查询通常几十毫秒。
- 向量数据占内存比普通字符串大得多,要对Redis的内存规划和淘汰策略做单独评估。
所以语义缓存并不是“加了就一定更快”。只有当模型生成时间远大于缓存检索时间时,净收益才是正的。如果你的模型生成只需300毫秒,而语义缓存的Embedding加检索已经花了200毫秒,那省下来的延迟就有限了,这时候需要仔细评估值不值。
更需要注意的是“错误答案的长期污染”。普通缓存命中条件严格,几乎不会错配;语义缓存一旦阈值设置不当,错误回答会被反复命中,而且因为是“语义匹配”,你很难从日志里直观看出它答错了。我的做法是:写入缓存前加一道质量校验,输出为空、输出长度过短、包含特定拒绝词的结果,一律不写入缓存。
4.5 进阶:普通缓存和语义缓存怎么组合
生产环境中我最终采用的是“两级缓存”策略:先做精确匹配,不命中再做语义匹配。这样一个请求在高重复场景下走最快的普通缓存;在语义近似场景下走稍慢的语义缓存;完全没见过的请求才真正打给模型。
class HierarchicalCache: def __init__(self, exact_cache, semantic_cache): self.exact_cache = exact_cache self.semantic_cache = semantic_cache def lookup(self, key, embedding_key): exact_hit = self.exact_cache.lookup(key) if exact_hit is not None: return exact_hit return self.semantic_cache.lookup(embedding_key) def update(self, key, embedding_key, value): self.exact_cache.update(key, value) self.semantic_cache.update(embedding_key, value)上面是简化后的伪代码,真实落地时还要处理两个缓存的过期时间一致性、Embedding缺失时的降级策略等问题。但总体的分层思想是明确的:让便宜的缓存挡在最前面,让昂贵的语义匹配只在必要时出手。
5. 三档对比汇总与选型决策
5.1 三档缓存核心指标对照表
| 对比维度 | 无缓存 | 普通缓存(精确匹配) | 语义缓存(向量近似) |
|---|---|---|---|
| 匹配粒度 | 无 | 文本完全一致 | 向量距离低于阈值 |
| 实现复杂度 | 无 | 低 | 中高,涉及Embedding和向量库 |
| 命中率(我的实测范围) | 0% | 5%-15%,模板化场景可超30% | 25%-50%,取决于场景和阈值 |
| 命中时额外耗时 | 无 | 10-50ms | 100-300ms(Embedding+检索) |
| 缓存存储成本 | 无 | 低 | 中高 |
| 误命中风险 | 无 | 几乎为零 | 有,阈值过宽时明显 |
| 典型适用场景 | 个性化、流式、一次性任务 | 固定模板、定时任务、重复Prompt | 用户自由提问、知识库问答、Agent多轮确认 |
这张表每次我给团队分享时都会贴出来。它至少说明三件事:第一,每个档位都有明确的适用边界;第二,命中率不是越高越好,因为语义缓存的命中率附带着误命中风险;第三,实现复杂度和维护成本是递增的,选型时要算总账。
5.2 流量特征决定缓存档位:一个简单的决策思路
我习惯用三个问题来给项目选定缓存档位:
- 问题一:你们的请求文本是否高度重复,像模板一样固定?如果是,直接上普通缓存,别折腾语义缓存。
- 问题二:用户是不是在用自然语言自由提问,同义改写非常多?如果是,普通缓存只够塞牙缝,要上语义缓存。
- 问题三:业务能不能容忍偶发的“语义相近但答案不同”的错配?如果完全不能容忍,比如医疗、法律、金融合规场景,语义缓存请谨慎使用,或者把阈值调到极高。
更稳妥的落地节奏是:先上普通缓存,跑两周看命中率日志;如果精确命中率低于10%,再考虑叠加语义缓存。不要一上来就追求“最先进方案”,工程上按数据说话永远比按直觉靠谱。
5.3 用一张算账表看真实ROI
假设每天1万次调用,单次成本0.2元,日成本2000元,月成本约6万元。
| 方案 | 假设命中率 | 每月节省 | 新增成本 | 净节省 | 说明 |
|---|---|---|---|---|---|
| 无缓存 | 0% | 0 | 0 | 0 | 无额外投入 |
| 普通缓存 | 10% | 6000元 | 几乎为0 | 约6000元 | Redis存储开销可忽略 |
| 语义缓存 | 35% | 21000元 | 含Embedding算力约3000元 | 约18000元 | 命中率越高,算力成本也上升 |
| 两级缓存 | 40%(普通10%+语义30%) | 24000元 | 约3000元 | 约21000元 | 普通缓存优先挡掉最快的一批 |
当然,以上数字依赖你的业务重复度。但算账的方法可以通用:每月节省 = 月调用量 × 单次成本 × 提高的命中率。每次方案评审时,把这个公式摆出来,比讲一百句“缓存很重要”都有说服力。
6. 生产落地的隐藏工程问题:缓存治理与一致性
6.1 缓存一致性:LLM结果也会过时,而且过时得很频繁
很多人天然觉得“模型生成的结果不会过期”,这是生产环境里最大的认知误区。我归纳过三类典型的缓存失效场景:
- 模型升级:底层模型版本切换后,同一个Prompt生成的答案可能发生明显变化。
- Prompt模板调整:业务方改了Prompt措辞或加了新的few-shot示例,之前缓存的结果不再适用。
- 上游知识变化:RAG场景中知识库文档更新了,旧回答里的信息可能已经错误。
所以LLM缓存的TTL不能设置太长。我见过团队把TTL设为7天,结果政策类问答在宣传页更新后继续输出旧答案,出了事故。更稳妥的做法是:基础问答类缓存TTL控制在1小时到24小时之间;涉及政策和时效性内容,要用“主动失效”机制,文档更新后按知识库分类标签批量清理对应缓存。
6.2 Redis统一缓存的配置要点
既然普通缓存和语义缓存都压在Redis上,Redis自身的治理就变得重要。
- Key命名规范要提前定好:
llm:cache:exact:{hash}和llm:cache:semantic:{hash}这种前缀隔离,避免互相污染。 - 内存淘汰策略要按业务配置。普通缓存可以接受LRU淘汰,语义缓存的向量数据体积大,要实时关注内存占用。
- 避免大Key。如果一次性把长Prompt生成的长回答完整塞进一个Key里,容易触发慢查询,建议按业务类型拆分。
- 序列化格式要统一。不同服务写入的value格式不一致,取出来反序列化会莫名报错。
这些细节看起来琐碎,但都是我在线上真实遇到过的坑。缓存跑了几个月后,最大的风险往往不是“没生效”,而是“内存涨到OOM”或者“淘汰策略把热数据清掉了”。
6.3 可观测性:没有命中率看板,缓存就是一笔糊涂账
缓存上线后,必须把“命中率、节省成本、平均节省延迟”三个指标暴露出来。我常用的做法是在缓存查询路径上加几个计数器:
llm_cache_calls_total:总请求数。llm_cache_hits_total:缓存命中次数。llm_cache_hit_ratio:命中率 = 命中数 / 总请求数。llm_cache_save_cost_money:预估节省金额,按单次调用成本 × 命中次数计算。llm_cache_save_latency_ms:命中时相比直连模型节省的平均毫秒数。
有了这些指标,你才能回答管理层最关心的两个问题:缓存到底省了多少钱?用户体验快了多少?把命中率接进月度成本报表,技术价值就能转成业务语言,后续申请缓存升级的算力资源也更有底气。
7. 常见问题与排查技巧实录
7.1 缓存一直不命中,命中率接近0%
先查Key的组装逻辑。最常见的原因是请求体里混入了随机参数,每次Prompt文本都不同,哈希自然对不上。我见过一个线上事故:前端把本次的traceId拼进了系统提示词里,导致每一次调用都是全新Prompt,缓存形同虚设。
排查顺序建议是:先打印出实际用来计算哈希的文本,看是不是预期内容;再做归一化处理,去掉多余空白;最后看多实例部署时是不是每个节点用了不同的缓存Key规则。
7.2 语义缓存“答非所问”,把不同意图匹配到了一起
语义缓存命中错误答案,九成原因是阈值太宽。如果你发现“退货流程”和“退换货规则”被匹配到了一起,先把distance_threshold从0.3往下调,调到0.1试试。
但阈值不是唯一原因。Embedding模型本身的中文语义区分能力也有关,BGE系列通常比通用多语言模型表现更稳。还有一类情况是业务本身需要强区分,比如“取消订单”和“修改订单”在后台是完全不同的操作路径,这类场景建议先做意图分类,再按意图分组去做语义匹配,而不是直接拿原始问题做全局匹配。
7.3 缓存了错误回答之后连环污染
写入缓存前缺少质量校验是主因。我的做法是三层校验:输出非空且长度达标;输出不含模型拒绝话术;关键业务字段存在。校验不通过,不写缓存。
同时加入“负反馈淘汰”机制:如果用户对某条缓存回答点了“不赞同”,就把对应Key和相似语义Key一起从缓存中移除。这个功能在客服场景里尤其有用,能避免一个错误答案被反复命中。
7.4 LangChain版本升级后缓存API报错
LangChain的迭代速度大家都有体会,langchain.cache模块里的类经常被挪到langchain_community.cache,构造函数参数也可能变化。升级前一定要看changelog,升级后用上面那些基础代码跑一遍冒烟测试。
我的建议是:生产环境锁定LangChain的次要版本号,不要跟着最新版追。缓存这类基础设施最怕的行为就是“升级后静默改变Key生成逻辑”,一旦Key规则变化,线上命中率可能一夜归零。
7.5 命中缓存后的延迟反而比直连模型还高
这个情况主要出现在语义缓存上。一条用户问题先过了Embedding模型推理,又做了向量检索,如果这两段耗时加一起超过模型生成时间,那缓存就失去了提速的意义。
遇到这种问题,优先优化Embedding推理服务,把它做成常驻GPU服务而不是临时加载模型;其次在缓存的Key上做热点预取,把高频问题的语义向量提前算好放在内存里;最后只有在“单次模型生成时间 > 300ms”的场景下才值得继续使用语义缓存。
最后说几句实在话
三档缓存我都跑过生产,最终的配置是“普通缓存 + 低阈值语义缓存”的双层结构,并且给语义缓存加了动态开关。开关的作用是:如果某个业务线对答案质量要求突然变高,我可以一键暂时关闭语义缓存,只保留精确匹配,把误命中风险先降下来。
我个人体会最深的一点是:缓存不是越高级越好,等级越高,背后的维护成本也越高。语义缓存确实厉害,但它引入的不只是向量库,还有阈值调参、Embedding模型运维、误命中治理这一整条链路。你愿意为这套复杂度买单的,应该是足够高的命中率收益,而不是“听说别人都在用”。
以后再有朋友问我到底该不该上缓存,我一般不会直接给方案,而是先问一句:你的流量画像里,重复调用到底占多少?这个数字,比任何缓存框架的选择都更重要。