做LLM中台时间久了会发现一个尴尬的事实:大模型本身的能力天花板没那么快触到,先被击穿的是账单。尤其是RAG类应用和面向C端的Agent服务上线之后,问题几乎都会收敛到同一句疑问——为什么每个请求都在烧钱?之前的项目经历里,我们团队在LangChain体系内把LLM调用缓存做成了三档:无缓存、普通缓存、语义缓存。今天把三档架构的底层原理、工程实现、线上劣化问题和最终收益全部摊开来说,里面有不少是用生产事故换来的经验,建议收藏后细看。
1. 计费机制决定缓存方向:先搞懂Token为什么值得缓存
大部分人第一次接触"缓存"这个概念,习惯性地把它等同于Redis里的KV存储,或者HTTP层的响应缓存。但在LLM场景里,缓存的本质完全不同——它缓存的是"生成的token",而生成token的成本由模型解码阶段决定。
1.1 从Decoder的自回归特性看成本结构
自回归模型在每次生成token时,都需要把已生成的所有token作为上下文重新过一遍网络。这意味着,同一条对话在第十轮被重新请求时,前面九轮的所有token都要再计算一遍注意力。这也是为什么同样的提示词反复调用,成本依然按完整上下文计算——模型没有记忆,每次都是从头来。
从计费角度看,OpenAI、Anthropic、DeepSeek等主流API服务商的定价通常分为输入和输出,而很多国内开源模型的商用计费也采用相同逻辑。当你的应用存在高频重复问题、固定Query、相似语义检索时,如果每次都原样调用模型,等于在替用户重复支付计算成本。
1.2 三种缓存层级的分工逻辑
视频高赞的做法是把缓存分成三个层面。
- 无缓存:每请求必回源,延迟高、成本全量计算,适合不确定性任务,比如Agent的开放式推理。
- 普通缓存:Key完全匹配才命中,适合固定标题、固定代码段、规则问答,实现最简单。
- 语义缓存:通过Embedding向量与距离检索找到"接近"的历史结果,直接复用答案,适合FAQ、客服问答、规范查询等大部分重复度高的生产场景。
这里要强调一个很多人混淆的点:语义缓存不只是缓存向量,它缓存的是"问题与答案的映射"。向量只是用来做相似度检索的索引,真正压成本的是复用生成好的答案文本。
1.3 开源模型场景的额外收益
如果你用的是自部署开源模型,缓存的意义更大——不只是省API费用,还能省显存算力。自建模型服务的QPS和并发余量本来就不高,接入语义缓存后,同样的显存预算能支撑更高吞吐,哪怕是一次请求的命中也能让整体RT(响应时间)从六秒降到几十毫秒级别。
提示:做成本分析之前,先用一个月的真实访问日志跑一遍文本相似度聚类。通常会发现30%-40%的请求在语义层面高度重复,这部分流量才是缓存的核心目标。不做这一步直接上缓存,属于盲人摸象。
2. 无缓存与普通缓存:LangChain调用链上的直连与Key碰撞
先看两条最普遍的路:直接透传与普通缓存。前者只是作为对照组存在——所有请求不加任何Cache中间件,每次原文到达LangChain的.invoke()入口后完整走一遍LLM调用。这套逻辑非常适合开发调试和写Demo,问题是生产环境没人敢这么跑。
2.1 普通缓存的LangChain实现路径
LangChain核心库其实内置了简单的缓存接口,最常用的是langchain.cache.InMemoryCache和RedisCache。前者只存在于进程内存,多实例场景命中率极低,不推荐生产使用;后者可落盘且支持分布式共享。
from langchain.cache import RedisCache from langchain.globals import set_llm_cache import redis redis_client = redis.Redis( host="your-redis-host", port=6379, decode_responses=True ) cache = RedisCache(redis_=redis_client) set_llm_cache(cache)设置了全局缓存后,LangChain在前置阶段会直接检查请求对应的Prompt字符串哈希,如果哈希命中就直接返回缓存的生成结果,完全不进入模型推理。
这条路的坑在于:普通缓存的Key是Prompt的原始文本,语序变一下、标点符号多一个空格,哈希就变得完全不匹配了。实测中,一段FAQ内容只要稍微改写提问方式,命中率直接掉到5%以下。
2.2 普通缓存的"数据污染"问题
更隐蔽的问题是合租效应。LangChain默认的通用缓存会把系统提示词、历史对话记录全拼进同一个Key里面,一旦你的系统提示词做过一次小版本升级,全量缓存几乎瞬间失效。而且这种全局弱缓存并没有区分不同用户、不同上下文,一个用户把回答改了会污染另一个用户的缓存结果。
想在生产环境用好普通缓存,至少要做三件事:
- 缓存Key加上版本号、模型名、温度参数。
- 只对用户输入的正文做正则归一化,剥离多余空白和不可见字符。
- 对Prompt做hash前,先过滤掉动态时间戳这类不可控变量。
即使做完了这三步,普通缓存的命中率依然受限于"完全相同"这个硬条件。这也是为什么项目上线三周后,我们最终把主力切到了语义缓存。
2.3 适合普通缓存的几种高确定性场景
普通缓存不算鸡肋,它最匹配的是结构化程度极高、几乎不允许泛化的任务。比如:把自然语言转成固定SQL的模板、前端代码规范检测、版本化接口文档的问答。这些场景本身就不需要语义理解,精确匹配带来的零误判是最大优点。而且普通缓存的延迟极低,不需要额外调向量数据库,整体架构最轻,特别适合边缘服务快速介入。
3. 语义缓存核心设计:Embedding选型与Redis向量检索的碰撞
语义缓存的关键在于:提问的文字不同,但语义相近。我们需要的是一把"语义尺子",把两句完全不同的中文问法映射到同一个向量空间中的邻近区域,然后用相似度阈值决定是否复用历史答案。
3.1 Embedding模型怎么选
中文语义相似度场景下,我踩过几类模型的坑,最终留下来的方案是BGE系列和text-embedding系列。
| 模型 | 维度 | 中文效果 | 显存占用 | 备注 |
|---|---|---|---|---|
| BGE-large-zh-v1.5 | 1024 | 优秀 | 约1.3GB | 中文场景首选,带指令前缀更准 |
| BGE-small-zh-v1.5 | 512 | 良好 | 约300MB | 性价比高,适合轻量部署 |
| text-embedding-3-small | 1536 | 优秀(在线) | 不占本地 | API调用,数据出网需注意 |
| SentenceTransformer全系列 | 384~1024 | 因模型而异 | 小 | 多语言通用,做二次微调方便 |
需要说明的是,Embedding模型的维度与检索效果不是简单正比关系。BGE-large在长尾语义上的区分度明显优于small,但对CPU和内存的占用也同步提升。如果你的业务QPS不高,我建议直接用BGE-large,换来的是更低的误召回率;如果是高并发边缘节点,优先考虑small级模型加量化加速,毕竟一个请求多花30毫秒做embedding,整体收益会被稀释。
3.2 Redis向量检索到底怎么配置
语义缓存的存储层我选了Redis,因为大多数公司已经把Redis作为基础设施,不需要额外引入专用向量库。Redis从8.0版本开始原生支持向量数据类型,通过FT.CREATE命令建立HNSW索引,实现近邻检索。
下面是生产可用的缓存中间件示例。
import redis import numpy as np from sentence_transformers import SentenceTransformer class SemanticCache: def __init__(self, redis_url, model_name="BAAI/bge-large-zh-v1.5"): self.r = redis.from_url(redis_url, decode_responses=False) self.model = SentenceTransformer(model_name) self.sim_threshold = 0.86 def _embed(self, text): return self.model.encode(text, normalize_embeddings=True).astype(np.float32).tobytes() def get(self, query): query_vec = self._embed(query) # 模拟向量检索,生产建议使用 RediSearch 的 KNN 语法 res = self.r.execute_command( "FT.SEARCH", "llm_cache_idx", f"*=>[KNN 3 @embedding $vec AS score]", "PARAMS", "2", "vec", query_vec, "RETURN", "2", "answer", "score", "SORTBY", "score", "ASC" ) if res and res[0] > 0: # score 越小越相似,这里将距离转相似度 dist = float(res[1]["score"]) sim = 1 - dist if sim >= self.sim_threshold: return res[1]["answer"] return None def set(self, query, answer): query_vec = self._embed(query) self.r.execute_command( "FT.CREATE", "llm_cache_idx", "ON", "HASH", "PREFIX", "1", "llm_cache:", "SCHEMA", "answer", "TEXT", "embedding", "VECTOR", "HNSW", "6", "TYPE", "FLOAT32", "DIM", "1024", "DISTANCE_METRIC", "COSINE" ) # 实际写入逻辑,为避免重复建索引需做幂等处理 self.r.hset( f"llm_cache:{hash(query)}", mapping={"answer": answer, "embedding": query_vec} )这段代码里最需要关注的是sim_threshold这个阈值,它直接决定缓存命中率和正确率的平衡。阈值过高,语义缓存退化成普通缓存,命中率上不去;阈值过低,大量不相关内容被误判为同一问题,返回的结果牛头不对马嘴,用户反馈会炸。
提示:不同业务场景相似度阈值差异极大。客服问答的FAQ,0.85阈值下命中率表现良好;而法律条款咨询、医疗建议必须把阈值拉到0.93以上,宁可少命中也不允许返回错误信息。
3.3 何时该引入独立向量数据库专用组件
Redis在中等规模负载下表现稳定,但如果你满足以下条件之一,需要重新考虑专用向量库:
- 缓存条数超过千万级,向量内存开始吃紧。
- 需要按业务线隔离索引,并对不同业务线动态调整阈值。
- 对查询并发有极高性能要求,需靠GPU加速向量检索。
- 历史缓存需要周期性离线重排、清洗、去重,而Redis的聚合能力太弱。
我们的线上架构里,Redis只是实时查询链路的入口,热数据全量在Redis,冷数据存在对象存储中,需要时再异步回填——这种混合架构比单库存全部数据更可控。
4. 生产级语义缓存的落地细节:从LangChain中间件到一致性保障
把语义缓存从Demo变成线上中间件,远不是往LangChain里挂一个缓存对象那么简单。需要处理的是与业务形态匹配的完整链路。
4.1 缓存中间件该加在哪个环节
很多人把缓存对象挂在ChatOpenAI层面,这样虽然能拦截重复请求,但粒度较粗。我们最终采用的方案是在chain级别插入一个自定义中间层。
from langchain_core.runnables import RunnableLambda def cached_chain(chain, semantic_cache): def invoke_with_cache(inputs): question = inputs["question"] cached_answer = semantic_cache.get(question) if cached_answer: return {"answer": cached_answer, "cache_hit": True} result = chain.invoke(inputs) semantic_cache.set(question, result["answer"]) return {"answer": result["answer"], "cache_hit": False} return RunnableLambda(invoke_with_cache)这样写的原因是:Chain层的输入输出结构更贴近业务语义,比如对话系统通常传的是question和session_id,直接在LLM层缓存会混入很多上下文无关变量。而且中间层可以自由选择哪些字段参与Cache Key,哪些字段不参与——例如用户ID、会话ID都不应该进缓存Key,但问题正文必须保留。
4.2 多级缓存策略的工程实现
线上生产不能只靠一层语义缓存,我们实际搭建的是四级缓存体系。
- 本地内存缓存:用LRU存最近几百条高频问答,QPS峰值时由它扛第一波流量,毫秒级响应。
- 语义缓存主层:Redis承载,主要覆盖中低频请求,解决单机内存放不下全部缓存的问题。
- 普通缓存兜底:对完全相同的关键Query再一次精确匹配,减少语义层embedding计算开销。
- 回源保护:以上三层全部未命中才允许进入LLM调用链,并做了并发合并——同一个问题同时被100个用户问,只允许一个请求回源,剩下99个等结果返回后直接读缓存。
多级缓存里最容易被忽略的是缓存淘汰策略。我们设定语义缓存的有效期为一周,但热问题会让有效期自动延长,核心思路是"越多人问,活得越久"。同时每个缓存条目要有来源标记、响应时间、token消耗,用于成本核算和效果分析。
4.3 缓存一致性:避免"脏数据"和"陈旧答案"
LLM应用的缓存与数据库缓存最大的区别在于:LLM的答案不保证完全确定性。同一问题在模型升级前后,返回质量可能完全不同。因此缓存一致性问题不是"数据过期",而是"答案是否还是当前业务想要的答案"。
处理方案是给每条缓存附上三个版本字段:模型版本、缓存时间、人工复审状态。当模型升级后,系统自动按时间段逐步过期旧缓存,而不是一次性全部清除——一次性清空会瞬间把所有流量压到底层LLM,容易触发限流甚至雪崩。
另一个关键点是:用户长期多轮对话里的缓存应该有上下文范围约束。我们的做法是在语义缓存记录里额外保存上下文摘要向量,只有在当前对话摘要与缓存摘要的相似度也达到阈值时,才允许复用答案。这样能避免跨场景的"串味"回答。
5. 线上性能实测:无缓存、普通缓存、语义缓存的真实成本分歧
理论说得再多,不如直接看线上数据。以下数据来自我们生产环境一个日活约5万的RAG类问答服务,运行了七天。
5.1 响应时间与Token消耗的对照情况
| 指标 | 无缓存 | 普通缓存(命中率约50%) | 语义缓存(阈值0.86) |
|---|---|---|---|
| P50响应时间 | 6.2s | 3.4s | 0.3s |
| P95响应时间 | 12.8s | 7.1s | 2.1s |
| 平均输出Token/请求 | 420 | 260 | 130 |
| 日消耗Token总量 | 约380万 | 约210万 | 约110万 |
| 命中率 | 0% | 47% | 68% |
同一个业务,语义缓存能把日Token消耗砍到无缓存的30%左右,同时响应时间下降一个数量级。需要承认的是,语义缓存并不能完全替代普通缓存——因为语义层的相似度匹配可能会导致部分Query的有效信息变化却不被察觉,比如"价格多少"和"价格包含什么"在向量空间里距离过近。
5.2 置信度分布才是利润源泉
很多人只看平均命中率,其实更值得看的是置信度分布。我们统计发现,语义缓存命中的答案中,相似度在0.93以上的区间,人工复审的采纳率能超过95%;而相似度在0.86-0.90区间的采纳率只有72%。因此线上配置时,我们把相似度低于0.90的命中结果都强制标记为"需人工二次校验",不对用户直接展示。
另外,将缓存命中的响应标记为cache_hit=True后,可以单独做一轮"缓存答案质量消歧"。方法是定期抽取缓存命中的问题,令模型以"是否完整、是否准确、是否匹配用户意图"三个维度重新评分,不达标的缓存直接删除,同时降低对应区域相似度阈值。
5.3 成本收益怎么算才科学
计算语义缓存的投入产出,不能只算API费用。我给出一个通用收益模型:
假设每次未命中请求的平均成本是C_miss,命中请求省下的成本是C_save,语义缓存的综合成本包含Embedding计算费用、向量存储费用、缓存维护与人工校验成本。当满足公式C_save * HIT_RATE > C_miss * (1 - HIT_RATE) + C_maintain时,投入为正收益。
实际我们量化下来,单日成本从无缓存的约75美元降到语义缓存后的约30美元,再叠加多级缓存和并发合并,最终日成本约23美元,节省比例近70%。
6. 语义缓存的适用边界与误用红线
把语义缓存吹得天花乱坠的同时,我必须把它的边界说透。它不是一个万能缓存,使用不当反而会引入严重问题。
6.1 不适用语义缓存的场景清单
- 强时效性内容:股票行情、天气预警、新闻热点、抢购信息。用户问十秒前的"当前价格"和十秒后的"当前价格",语义完全相同,但答案完全不能复用,这类必须强制绕过缓存。
- 个性化强规则内容:用户问"我家楼下有什么好吃的推荐",不同人的位置、口味偏好完全不同,直接用语义缓存会导致"张三的回答给李四"。
- 多轮任务型对话:Agent的每一步action依赖前序状态,即使自然语言表达相同,上下文状态不同时执行路径也不同,需要把状态签名纳入缓存Key判断。
- 合规要求严格的内容:金融、医疗、法律等领域一旦返回陈旧或错误的缓存答案,风险远大于省下的几千块成本。
6.2 我们内部的红线规则
在生产系统里,我们为缓存中间件设置了如下强制约束逻辑。
首先,缓存只允许作用于幂等性自然的场景,即"同样的问题,在任何时间都期望得到同样可靠答案"的知识问答。其次,任何缓存层都不允许绕过安全审核——入库前,答案文本必须经过内容合规检查;出库前,同样需要经历一遍脱敏和合规扫描,防止历史敏感信息卷土重来。最后,全部缓存行为必须有审计日志,一旦用户投诉,可以按缓存时间、命中路径和原始模型调用记录完成全链路回溯。
7. 从实际运营中总结的经验清单
整轮改造下来,把值得记住的几点单独列出来,这些是用真金白银换来的。
关于命中率:不要迷信语义缓存的命中率能无限提高——当阈值调到0.92以上,最理想命中也就在50%左右。合理目标应该定在"比普通缓存高20个百分点且不引入明显误差",而不是追求90%以上命中。
关于冷启动:新业务上线第一天,缓存是空的,前一周命中率会非常难看。建议上线前用历史日志做一次离线预填充,用真实提问语料批量生成缓存条目,把冷启动周期压缩到几个小时。
关于可观测性:缓存不是一锤子买卖,线上监控必须覆盖命中率、回源率、缓存落后时间、语义相似度分布、缓存条目增长率五个核心指标。我在LangSmith之外另外配置了Prometheus和Grafana,统一做仪表盘,任何一项指标异常都能第一时间拉到告警。
关于阈值调参:语义相似度阈值需要按业务模块分桶配置,不能全局一刀切。同一个应用里,FAQ问答用0.85,复杂技术咨询用0.92,内容合规审查用0.95,把不同风险等级的答案用不同置信度门槛隔离,才是最优策略。
最后再分享一个小技巧:给用户的提问做轻量归一化再进入Embedding模型,能显著提升语义缓存的稳定性。比如中文全角半角统一转换为半角,问题里的连字符、下划线全部按空格替换,数字保留占位符(把"3000元"替换为"[NUM]元"),这部分预处理不会消歧义,但可以让向量检索的精度提高三五个百分点。
这套三档缓存体系上线后,我们LLM网关的整体响应成本下降了接近七成。如果你的业务正被高频重复请求压得喘不过气,先从日志里找出最热门的50个问题开始,手动构造它们的语义缓存条目,很快就能感受到从"每次都要问模型"到"问一次,用N次"的转变。