晚上十点告警群弹出一条费用预警。我爬了下当天日志发现一个知识库问答接口被同一句话问了三十多次——用户从文档里复制了一行报错信息反反复复贴进对话里三十多次请求全部打到了大模型上另外还有一批把“报销流程”说成“报销步骤”的语义完全一致也各自付了全款。这类事情凡是亲手做过LLM应用落地的人都懂Token账单里很大一部分钱花在了重复和近似重复的请求上。要处理这个问题最直接的技术方案就是缓存。但缓存不是一个简单的开关而是分档位的无缓存、普通缓存、语义缓存。三者之间的差距非常大尤其是你想在LangChain里做生产落地时选错档位要么省钱效果不明显要么上线之后误命中一堆。这篇文章我把三档的执行逻辑、实现代码、实测数据和坑都摊开讲讲适合正在做LLM应用、知识库问答或者想把Token成本真正压下来的同学参考。1. 账单爆炸的第一步先看重复请求在真实业务里占了多少1.1 LLM天生无状态每一次调用都是重新付钱大模型API本身不保存任何关于“这个用户上次问了什么”的信息。你调用一次它就做一次完整的模型推理你再调用一次相同的问题它还会按照同样的推理路径重新算一遍。这跟普通Web服务完全不一样——普通服务背后有数据库、有索引同样一个查询第二次进来时可以走缓存秒回但LLM没有这个概念除非你自己在应用层加。拿自动售货机来类比你投三块钱买一瓶水机器不会记得你五分钟前刚买过一瓶同样的水你再次投币它还是照样出饮料照样收全款。LLM就是这台自动售货机唯一的区别是它收的不是三块钱而是按Token数量结算的真金白银。1.2 重复请求最集中的三类业务场景我观察下来重复请求并不是均匀分布在所有业务里的它有非常明显的“聚集效应”。最典型的三类场景知识库和文档问答。这是重复率最高的地方。用户本质上是想找文档里的同一段答案但问法千奇百怪“发票多久能开”“开发票需要几天”“发票开具时长”指向的都是同一句话。文档更新频率低答案稳定非常适合缓存但偏偏很多人没做。Agent多轮任务。Agent里的工具调用失败后往往会重试同一个查询会因为某一步失败而反复发起日志里能看到大量“几乎一样”的任务请求。这类重复往往不是用户故意制造的而是流程设计导致的缓存能帮你在机制上兜住。内容批量生成。比如用同一个模板给几百个商品写卖点产品名变一下其余部分高度相似。这种情况下Prompt的公共前缀很长不缓存的话每一单都在重复花钱。1.3 我抽了日志后的结论字面重复和语义重复是两回事我抽过一个中等规模项目的调用日志做分析一周内的有效请求大概10万次出头。把请求做简单正规化去掉首尾空格、统一换行后再算完全重复占比大约23%接着用Embedding算了一下语义相似度相似度大于0.9的请求占比接近42%。这个数字不一定适用于所有业务但它说明了一个规律完全重复只是冰山一角语义重复才是大头。普通缓存只能接住那23%想接住40%以上的相似请求必须上语义缓存。这也是为什么很多团队上了普通缓存之后发现账单几乎没怎么动——因为真正烧钱的是那些“看着不一样、其实是一个问题”的请求。2. 第一档无缓存你的每一分Token都花在“重算”上2.1 没有缓存时的完整请求链路无缓存是最朴素的架构用户提问应用接收拼好Prompt调LLM拿到回复返回给用户。整个过程里没有任何一层复用。在没有缓存的情况下每一次用户请求都会完整经过四个成本点。第一是输入Token费用长上下文尤其明显第二是输出Token费用模型需要重新生成一遍完整回答第三是时间成本一个稍长的回答可能要让用户等好几秒第四是并发资源占用重复请求越多你的API配额消耗越快后面真正的热点请求反而可能因为限流被卡住。在生产环境里无缓存往往不是主动选择而是“忘了考虑”的默认状态。很多项目上线第一天没问题大家开心地用着月底一看账单才发现钱不是被新功能烧掉的而是被用户反反复复问了相同的问题烧掉的。2.2 无缓存方案唯一的优势结果永远是“新鲜”的抛开成本谈缓存无缓存也有不可替代的好处每次响应都是模型基于当前输入重新生成的结果不会被旧答案“污染”。如果你的业务要求响应内容必须实时反映最新状态比如查库存、查订单状态、看实时汇率那缓存的风险就很大。我见过有团队给一个查物流的接口上了普通缓存结果同一个运单号的查询在五分钟内被重复命中用户看到的是旧物流信息差点投诉。再比如文案生成场景用户可能第一次让模型写一个促销语不满意改了个措辞又让它“换个风格再写一版”这种场景如果加了语义缓存可能直接命中第一次的旧文案体验非常糟糕。无缓存的“新鲜”是它的安全底线也是你在做缓存设计时永远不能丢掉的判断标准——这条回答会不会因为时间的推移而过期如果会缓存就必须带TTL或者干脆不缓存。2.3 什么情况下可以继续裸奔我个人的判断标准是三个数字日请求量、完全重复率、业务对新鲜度的要求。如果日请求量低于1000次Token费用基数很小缓存的收益不够明显可以先不加。如果完全重复率低于5%说明用户问题高度分散普通缓存命中不了多少语义缓存又要引入额外基础设施性价比不高。如果业务对时效性极其敏感比如高频交易相关的数据问答那缓存带来的风险远大于收益裸奔反而是最优解。当然“暂时裸奔”不等于永远裸奔。你至少应该在日志里把query统计起来等到某一天费用开始肉眼可见地涨了这些统计就是你决定上哪一档缓存的最有力依据。3. 第二档普通缓存字面命中率为什么上不去3.1 普通缓存的工作机制从请求到哈希键普通缓存的核心逻辑是把一次LLM调用的所有参数拼在一起算出哈希值以哈希值为键存储结果。后续请求进来时LLM SDK会重新计算哈希如果键完全一致就直接返回之前的结果不再发起网络请求。在LangChain里这个哈希值通常由模型名称、消息内容、温度、停止符号等参数共同生成。它的判断标准是“严格相等”多一个空格、换一个标点、换一个近义词哈希值都会变缓存直接失效。这种方案的优点是简单理解成本为零不需要额外的Embedding模型也没有阈值可调。缺点也很明确它只能识别完全相同的请求对“近似重复”完全无能为力。3.2 LangChain里的普通缓存落地代码LangChain提供了现成的缓存模块接起来非常快。先用最简单的InMemoryCachefrom langchain.globals import set_llm_cache from langchain.cache import InMemoryCache set_llm_cache(InMemoryCache())当你在本地调试或者跑测试脚本时用这一行就够。只要进程不退出相同请求会直接命中。不过生产环境里通常有多个服务实例每台机器的内存缓存是互相隔离的同一个请求打到不同机器就会各付一次模型费用。所以我更推荐直接上RedisCachefrom langchain.globals import set_llm_cache from langchain.cache import RedisCache from redis import Redis redis_client Redis(hostlocalhost, port6379, db0) set_llm_cache(RedisCache(redis_redis_client))这样一来所有实例共享同一个缓存池命中率不再受流量分发影响。LangChain不同版本的缓存API略有差异我用的是当前社区版常用的写法如果你的版本较老注意看下官方文档的迁移说明。3.3 实测出来的痛点标点、换行与近义词普通缓存上线之后我做过一段时间的命中率统计结果不算太理想。一个重要原因是真实用户输入太“脏”了。同样是问发票开错了怎么处理日志里至少能看到四种形态发票的开票日期错了怎么处理发票开票日期错误怎么办发票 开票日期 错了 如何办how to handle incorrect invoice date前三条在语义上几乎没区别但哈希值完全不同普通缓存只能命中一次。再加上大小写、全角半角、中英文标点、前后空格、换行符这些细微差异字符串完全相等的概率被摊薄得很厉害。我在某个项目里的实测数据是完全重复率在20%左右但普通缓存的实际命中率经常只有15%上下。为什么比20%还低因为很多人复制粘贴的内容带了不可见字符或者每次请求自动携带的会话ID不一样导致缓存键比我们想象的更严格。3.4 普通缓存的定位简单可靠但覆盖有限普通缓存并不是没有价值它只是覆盖范围有限。从定位上说它更适合那些“系统自己生成输入”的场景。比如后台管理面板里的下拉框选项查询每次请求拼接出来的Prompt格式完全一致再比如定时任务里的批量处理逻辑模板固定、参数枚举值固定还有联调测试环境同一个测试用例反复跑普通缓存可以帮你节省大量调试费用。如果你做的是开放输入类型的知识库问答指望普通缓存把成本降下来不太现实。它更像是“保底方案”实现成本极低至少能把完全重复的那部分接住。真正想处理语义近似得靠第三档。4. 第三档语义缓存从“字面相等”走向“意思相同”4.1 语义缓存的核心Embedding加向量相似度语义缓存的核心思路是不再比较字符串是否完全相同而是比较两句话在语义空间里是否足够接近。具体做法分三步。第一步把用户的问题通过Embedding模型转换成向量第二步在向量数据库里搜索与当前向量最相似的缓存记录计算相似度第三步如果相似度超过预设阈值就直接返回缓存中的答案否则调用LLM生成新答案并把答案连同问题向量一起写入缓存。可以把它理解成给每个问题做了一份“语义指纹”。字符串相同不一定指纹相同但意思相近的问题在向量空间里会落在附近这样就能被缓存命中。它解决的是普通缓存最痛的那个点——“怎么申请报销”和“报销申请流程”明明是同一个问题普通缓存就是不认。4.2 LangChain内置SemanticCache的生产用法LangChain社区版里提供了一个SemanticCache类接法和普通缓存一样只是需要额外传入Embedding模型和向量库。下面是一个基于Redis向量库的示例from langchain.globals import set_llm_cache from langchain_community.cache import SemanticCache from langchain_community.vectorstores import Redis from langchain_openai import OpenAIEmbeddings embedding_model OpenAIEmbeddings(modeltext-embedding-3-small) vector_store Redis.from_existing_index( embeddingembedding_model, index_namellm_semantic_cache, redis_urlredis://localhost:6379 ) llm_cache SemanticCache( embeddingembedding_model, cachevector_store, similarity_threshold0.90 ) set_llm_cache(llm_cache)这里有个生产环境里非常容易踩的细节SemanticCache的检索结果只返回最相似的单个结果不会告诉你这个结果来自哪个用户、哪个会话。所以它更适合“公共静态知识”的问答不适合强上下文依赖的个性化对话。如果你不想被内置实现束缚也可以在应用层自己包一层。下面是一个极简的自定义版本思路比代码本身更重要from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain.schema import HumanMessage embedding OpenAIEmbeddings(modeltext-embedding-3-small) vector_store Redis.from_texts( [], embeddingembedding, redis_urlredis://localhost:6379, index_nameapp_semantic_cache ) def cached_chat(question: str, llm: ChatOpenAI, threshold: float 0.90): q_vec embedding.embed_query(question) docs vector_store.similarity_search_with_score_by_vector(q_vec, k1) if docs and docs[0][1] threshold: return docs[0][0].metadata[answer] answer llm.invoke([HumanMessage(contentquestion)]).content vector_store.add_texts( [question], metadatas[{answer: answer, created_at: 2025-01-01}] ) return answer生产版本还需要处理TTL过期、用户隔离、事务保证、异常重试等细节但核心逻辑就是上面这几行。理解了这个结构你再看LangChain的内置实现就不会觉得它有多神秘了。4.3 缓存键设计别只把裸query丢进去语义缓存最容易被低估的一个问题是缓存键到底该由什么组成很多人第一反应是“把用户的问题拿去Embedding”。这在单轮公共问答里够用但一旦涉及多轮对话或者个性化场景就会出问题。比如用户先说“我要退货”模型给了退货政策用户再说“那运费谁出”这里面的语义依赖前文如果你只Embedding“那运费谁出”这五个字极有可能命中完全不相关的缓存记录。我建议至少把下面几个维度纳入缓存键模型名称。同一个问题不同模型回答质量不同不能混着缓存。采样参数。Temperature等参数会影响回答风格建议每个参数组合一套缓存。System Prompt标识。System Prompt变了回答口径就变了必须参与键计算。用户ID或会话ID。公共知识可以不用但涉及个人数据的场景绝对不能省。多轮上下文。最简单可靠的做法是把最近几轮消息拼接成文本后再Embedding而不是只Embedding最后一句话。这几点缺失任何一个都可能在线上制造“串答案”事故。尤其是用户ID我见过有团队把A用户的订单查询结果缓存了结果B用户问了类似的问题直接命中A的订单信息这已经不是钱的问题了。4.4 相似度阈值怎么定0.9和0.8的差别能有多大语义缓存里最敏感的参数是相似度阈值它直接决定命中率和准确率的平衡。阈值越低越容易命中但误命中率也越高。阈值低于0.8之后不同主题的问题也可能被算成相似“发票丢失怎么办”和“发票作废怎么办”这两件事在字面上很像语义上却有明确区别如果阈值太松就会答非所问。阈值越高结果越安全但接近0.97以上时语义缓存的效果几乎退化成普通缓存。我比较推荐的经验区间中文公共知识库问答阈值在0.88到0.93之间英文FAQ可以放到0.90到0.95。这个区间不是拍脑袋定的最好在实际数据上验证。你可以抽几百对业务问题人工标出“应该命中”和“不应该命中”算一个相似度分布找两类问题之间的分界点。上线时先从一个偏高的阈值跑比如0.95观察命中率再逐步下调每调一次就抽查一批命中pair直到误命中开始变多再退回来。5. 三档实测对比成本、延迟、命中率与翻车记录5.1 我这边的实测环境我用一个中等规模的文档问答接口做了七天对比测试。业务背景是静态知识库问答用户问题大多是“XX流程怎么走”“XX出错了怎么办”日均请求大约12000次模型用的是入门级高性价比型号Embedding用了一个通用的中文向量模型向量库存放在Redis里。为了避免不同业务的数据干扰我只统计接口层面能明确判断的指标缓存命中率、Token费用估算、命中后的响应延迟、误命中情况。下面是七天数据的汇总。5.2 三档方案的对比结果方案缓存命中率Token成本降幅命中后P95响应延迟额外基础设施主要风险无缓存0%0约1.2秒无费用随流量线性涨普通缓存约23%约20%约60毫秒Redis语义近似完全漏掉语义缓存约56%约50%约210毫秒Redis加Embedding阈值误命中、上下文串味表格里的Decimal需要强调一点成本降幅小于命中率因为省掉的只是重复生成的Token而Embedding本身的调用、向量库检索、缓存写入操作都还有小额成本。但即便如此语义缓存在这个场景里把Token成本削掉一半左右效果已经很可观了。5.3 语义缓存最容易翻车的四个地方翻车点一阈值调太低导致答非所问。我在测试阶段把阈值降到0.82结果“发票丢失怎么办”命中了“发票作废流程”的缓存用户收到的回答里有一句“如果发票已作废请重新申请”完全不适用。这个事故提醒我阈值本质上是个风险旋钮不是性能旋钮。翻车点二带tool_calls的响应被缓存Agent执行时拿到过期的工具参数。有一次测试LangChain Agent模型返回的响应里带着工具调用信息被语义缓存完整保存了。后续一个相似请求命中了这段缓存但工具参数列表已经过期下游执行时直接报错——日志里那行“request failed: provider rejected the request schema or tool payload”一度让我以为是模型配置问题排查半天才发现是缓存把旧的工具调用连带存了下来。解决办法很简单纯文本回答才走语义缓存带工具调用信息的响应明确跳过缓存如果必须缓存命中后要重新校验工具参数和当前系统里可用的工具列表。翻车点三写入缓存前的去重逻辑有缺陷。我最早实现语义缓存时每次新问题都直接写入向量库没有检查库里是否已有几乎一样的记录。结果同一个问题产生了好几条内容接近但答案可能不同的记录向量检索返回的往往是最早写入那条而不是最新有效那条导致用户得到旧信息。后来我在写入前加了一次“相似度大于0.99的记录覆盖”逻辑并给每条缓存带上时间戳和版本号才把问题压住。翻车点四更换Embedding模型导致缓存整体失效。向量模型一换所有历史缓存的向量分布都变了新查询向量和旧向量之间的相似度会全面下降等于缓存全部废掉。这跟普通缓存的键格式变更很像但更难发现因为它不会报错只是命中率突然暴跌。生产里要把Embedding模型的版本当成数据库Schema一样管理升级前先清空缓存或者做向量迁移。5.4 生产环境落地建议组合使用才是正解实战跑下来我发现三档缓存并不是互斥关系反而可以组合使用。对于静态公共知识库问答最合理的是语义缓存加普通缓存的组合普通缓存接住完全重复的请求延迟最低语义缓存接住近似重复的请求把命中率拉到一个理想水平。语义缓存的阈值按我前面说的方法调TTL根据文档更新频率来定文档不常更新就把TTL设长一点。对于用户个性化对话缓存键必须带上用户ID和上下文或者干脆只缓存那些“有标准答案”的子问题。不要试图把整段多轮对话塞进缓存那样误命中率会高到让你怀疑人生。对于Agent工具调用场景我的态度是保守一点工具调用的完整输出原则上不缓存最多把一些纯文本知识片段做语义缓存。工具执行结果涉及实时数据时间一过就失效缓存了反而危险。把“缓存什么、不缓存什么”这条边界写清楚比单纯追求命中率更重要。最后说点个人体会。很多人一看“语义缓存能省一半成本”就直接上马结果在Embedding选型和阈值调节上耗了两周误命中又差点把线上问答搞砸。我的习惯是先花一天统计重复请求的量级再决定上哪一档。完全重复率不到10%普通缓存都算多余语义重复率超过30%语义缓存才值得投入。上线之后每周抽查一次命中pair把阈值、TTL和隔离策略慢慢调稳这比一上来就追求高命中率稳妥得多。缓存这件事永远是先算账再动手。