
最近被几个团队问到同一件事LangChain 应用上线跑了几个月云账单里 token 费用高得扎眼老板问能不能降本。大部分人第一反应是换更便宜的模型换完之后发现效果掉一截然后才有人开始考虑缓存。其实 LLM 缓存这件事在 LangChain 生态里不算新鲜只是很多人被“缓存命中率”这个概念吓住了以为只有完全一模一样的 query 才能省钱。这篇文章用三档方案把问题讲透无缓存、普通缓存、语义缓存。我会把每一档的适用场景、核心实现、成本测算、生产落地细节都拆开讲并在最后给出一份能直接套用的实测方法和常见坑位清单。适合正在做 LLM 应用降本、被 token 账单困扰或者准备优化客服/问答/Agent 类项目延迟的团队参考。1. 为什么 LLM 调用需要缓存成本与延迟的真实账本1.1 一次 LLM 调用到底花多少钱先算一笔账。假设一个客服机器人日请求量 10 万次单次请求平均消耗 1500 个 token系统提示词带上下文、历史对话拼接、用户输入、模型输出都要算进去按某类主流商用模型综合单价 0.03 元/千 token 估算。单次成本 1500 / 1000 × 0.03 0.045 元。日成本 100000 × 0.045 4500 元。一个月按 22 个工作日算就是近 10 万。这还只是一个模型、一类的调用量。很多团队在 LLM 应用上线前根本没算过这层账等账单出来才意识到这不是“几个 API 调用”的小事而是一笔需要认真经营的资源开销。延迟同样重要。一次真实的 LLM 调用从请求发出到流式输出结束通常要 1.5 到 3 秒。这个时间在后台跑无所谓但放在用户直接面对的问答场景里体感就很明显。如果有一批请求能命中缓存直接返回历史答案延迟能压到几十毫秒以内。也就是说缓存不只是省钱更是在救用户体验。1.2 哪些场景值得做缓存并不是所有 LLM 调用都适合缓存。我见过团队不加分析就往所有接口上套缓存最后命中率低得可怜还白搭了一套 Redis。判断一个场景能不能吃缓存核心看两个指标重复浓度和新鲜度容忍度。重复浓度高的是这几类客服 / 售后问答用户问题集中在退款、物流、账号找回、发票等几十个高频意图上表达方式虽然多样但核心诉求高度一致。固定指令类任务抽取、打标、总结模板等输入模板几乎不变只有槽位在换。知识库 FAQ 查询问题集固定答案更新频率低。新鲜度要求高、不适合缓存的是另一类实时行情分析、动态运营数据解读、涉及即时状态判断的 Agent 决策。这些场景强行做缓存轻则返回过时信息重则给出错误结论得不偿失。还有个容易被忽略的点LLM 平台侧也有“前缀缓存”这回事——有些厂商对重复的提示词前缀按折扣计费。但那是平台层面的机制和你自己业务侧的答案缓存不是一回事两者可以叠加使用但别搞混。2. 三档缓存方案的设计与取舍2.1 无缓存最原始也最容易被忽视的基线无缓存方案最简单request 进来拼好 prompt调 LLM返回 answer完事。它的优点是无状态、结果最新鲜、代码最薄。但它有两个毛病一是每一次都完整付费二是模型输出天然有随机性同一道题这次回答和下次回答可能措辞不同。为什么还要单独讲它因为在你评估任何缓存方案之前必然需要一个无缓存的基线数据。很多团队直接上了缓存却说不清省了多少钱就是因为没有基线。我在生产实践里通常要求先裸跑无缓存版本 1 到 2 周记录每天的调用量、token 消耗、平均延迟和 p95 延迟。后面语义缓存上线后拿这套数据做对照降本效果才是有据可依的。而且无缓存并不总是错误的。对重复度极低、每次都是新问题的长文档分析类应用上缓存纯粹浪费资源。先算账再决定。2.2 普通缓存精确匹配简单高效但有大前提普通缓存的逻辑一句话能说完query 字符串完全一致才返回缓存答案否则走一次 LLM 并回填缓存。实现上就是给 prompt 算个哈希作为 keyvalue 是答案和元数据。代码量很小LangChain 也内置了支持。它的优点非常突出实现简单、不会误命中、延迟极低准确率达到 100%因为命中的就是一模一样的输入。缺点同样明显只有“完全重复”的问题才省钱。“今天天气怎么样”和“今天天气如何”在语义上完全等价但普通缓存永远认为它们是两个问题。再加上真实用户的输入往往带大小写、全半角、前后空格、口语语气词等各种差异普通缓存的命中率通常不会太高。所以普通缓存最适用的场景是底层 prompt 模板稳定、用户输入经过强归一化、重复问题占比高的 FAQ 类应用。它适合作为所有缓存方案的第一步因为成本极低、收益无风险。2.3 语义缓存让“差不多”的问题也命中语义缓存的思路是把 query 转成向量然后在向量库里找最相似的已有记录相似度超过阈值就复用缓存答案。用生活类比解释普通缓存是通过“字面一模一样”找人语义缓存是通过“听起来像同一件事”找人。一条 query 进来先用 embedding 模型把它变成一串浮点数然后在向量库中检索。如果和历史记录的余弦相似度高于设定阈值比如 0.92就直接返回缓存答案否则正常调用 LLM并把新的 query 和 answer 写入向量库。semantic cache 在游戏和 Web 开发领域还有另一个含义指的是从服务端向客户端推送数据的那套机制跟 LLM 缓存没有关系。看到“语义缓存”三个字先确认对方说的是哪种避免方案选型时张冠李戴。语义缓存的优势是能吸收同义改写、口语化表达、简单翻译差异。比如“怎么申请退钱”“退款流程是什么”“我想退款怎么操作”这三个 query 字面完全不同但在向量空间里距离很近语义缓存可以让它们共享同一个答案。但这不是免费的午餐。它有三个隐藏代价embedding 调用本身也花钱通常远小于 LLM 推理费用向量检索需要额外的存储和计算资源最麻烦的是“语义相似”不等于“意图相同”误命中可能返回一个语义沾边但实质错误的答案。语义缓存适合用户表达自由度高、等价问题多的客服和电商问答场景但做之前必须想清楚误判成本。2.4 三档方案的对照表对比维度无缓存普通缓存语义缓存命中条件无字符串完全一致向量相似度超过阈值实现复杂度零低中高命中时平均延迟1.5s-3s5ms-15ms20ms-100ms降本效果无取决于完全重复比例取决于同义问题比例误判风险无无有需控阈值典型适用场景低重复、高实时需求FAQ、强归一化输入客服、电商、口语化问答三个方案不是非此即彼生产环境更常见的组合是先走普通缓存做精确匹配没命中再走语义缓存做相似召回两者都不中才真正调用 LLM。这样可以兼顾准确率和命中率。3. LangChain 生产落地从配置到自建的完整方案3.1 普通缓存落地LangChain 内置缓存一行开启LangChain 提供了全局缓存接口只要在服务启动时设置一次后续所有 LLM 调用都会自动先查缓存。以当前稳定的 API 为例from langchain.globals import set_llm_cache from langchain_community.cache import SQLiteCache set_llm_cache(SQLiteCache(database_pathllm_cache.db))设置之后同一个 LLM 对象下完全相同的 prompt 字符串会直接返回之前的结果。SQLite 是默认的存储后端也有 Redis 版本可选适合多实例部署时共享缓存。这里要特别提醒一个坑LangChain 内置缓存的粒度是“完整 prompt 字符串”不是“用户 query”。如果你的 prompt 里拼了时间戳、会话 ID、随机数、检索出的文档片段任何一处细微变化都会让缓存失效。所以普通缓存能不能发挥价值关键不在 LangChain而在你的 prompt 模板是否规范化。把变量槽位抽出来只让真正需要对比的部分参与缓存键计算命中率才会高。另一个隐患是全局缓存会跨用户共享。多租户系统如果直接裸用内置缓存A 租户的答案可能被 B 租户查出来。生产环境必须自己做一层带 tenant_id 的缓存封装这个问题会在第 5 节展开。3.2 语义缓存落地Embedding 向量库 阈值的自建最小实现LangChain 社区有 GPTCache 之类的第三方库可以做语义缓存但版本绑定问题比较烦人我建议团队按需自建一层核心逻辑其实只有几十行。import faiss import numpy as np from sentence_transformers import SentenceTransformer class SemanticCache: def __init__(self, model_name: str BAAI/bge-small-zh-v1.5, threshold: float 0.92, top_k: int 1): self.model SentenceTransformer(model_name) self.threshold threshold self.top_k top_k self.index faiss.IndexFlatIP(self.model.get_sentence_embedding_dimension()) self.prompts [] self.answers [] def get(self, query: str): vec self.model.encode([query], normalize_embeddingsTrue) scores, idxs self.index.search(vec, self.top_k) if scores[0][0] self.threshold: return self.answers[idxs[0][0]] return None def set(self, query: str, answer: str): vec self.model.encode([query], normalize_embeddingsTrue) self.index.add(vec) self.prompts.append(query) self.answers.append(answer)这段代码是给读者理解原理用的最小闭环生产环境建议在此基础上扩展用 pgvector 或 Redis 做向量存储、加持久化、按 tenant 做索引分片、接入监控日志。几个关键点说透normalize_embeddingsTrue是因为 FAISS 的 Inner Product 在向量归一化后等价于余弦相似度直接算余弦也可以但内积快得多。阈值不是固定不变的它应该是你根据业务数据测出来的一个水位线下文有专门的方法。答案缓存要求生成时关闭随机性temperature设 0 或接近 0否则同一问题每次回答都不一样缓存就失去意义。模型输出如果包含引用来源、知识库文档 ID 这类动态信息缓存时要连这些元数据一起存否则命中后给用户的答案会缺头缺尾。3.3 设计缓存条目的 key-query-value 模型这句热词说得很有道理key 是我这个缓存属于谁、query 是我在找什么、value 是答案能提供什么。落到缓存表结构上一个生产级语义缓存条目至少要有这三层信息。以 SQLite 为例我常用的表结构长这样CREATE TABLE llm_cache ( cache_key TEXT PRIMARY KEY, normalized_query TEXT NOT NULL, query_vector BLOB, answer TEXT NOT NULL, model_name TEXT NOT NULL, prompt_version TEXT, tenant_id TEXT, intent_tag TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_tenant_query ON llm_cache(tenant_id, normalized_query);对应的三要素cache_key 就是“我是谁”。由 tenant_id、业务线、模型名、prompt 模板版本拼装而成。它的作用是防止不同租户串数据也保证你改了 prompt 之后旧缓存自动失效。normalized_query 就是“我在找什么”。存的是规范化后的用户问题同时也是向量化的原材料。规范化包括去首尾空格、统一大小写、全半角转换。answer 和元数据就是“我能提供什么”。答案内容之外还要把 token 用量、命中时的模型版本、生成时间一起存下来方便后面做归因和统计。很多团队把缓存表设计成一个只有 key-value 的 Redis看起来简单但后续做多租户隔离、prompt 版本升级、缓存命中分析时全都缺字段。一开始就按这个模型设计后面能省很多事。4. 生产环境里的实测方法与参数调优4.1 命中率、节省率、延迟怎么算先定义指标不然团队里容易吵架。缓存命中率 命中次数 / 总请求次数。成本节省率 命中请求原本要消耗的 token 成本 / 全部请求总 token 成本。缓存命中平均延迟普通缓存、语义缓存分别统计 p50 和 p95。成本节省率比命中率更能说明问题因为模型输出 token 比输入 token 贵得多如果你的高成本长回答正好高频被命中一个请求顶几百个短请求。统计时建议按“输入 token 节省”和“输出 token 节省”分列因为单价不同。拿前面那个每天 10 万次、日成本 4500 元的客服场景举例。普通缓存跑两周后命中率 20%理论上日节省 900 元。语义缓存上线后命中率拉到 45%日节省 2025 元。同时每 10 万次 embedding 大概增加 20-50 元成本向量检索损耗可忽略不计。最终每个月净省大约 4 到 6 万具体取决于你的模型单价和命中分布。从延迟角度看普通缓存命中 p50 大约 5-15ms语义缓存 p50 大约 20-80ms取决于向量库对比无缓存时的 1500ms 以上提升非常明显。4.2 相似度阈值怎么定数据说话而不是拍脑袋语义缓存最核心的参数就是阈值。定高了命中率低定低了误命中变多。我用过的靠谱方法是“标注 分布对比”别拍脑袋定 0.9。操作步骤从线上日志里抽 10000 条真实 query按意图分桶。人工在每桶里标 20 对“语义等价格式不同”的问题以及 20 对“相似但不同意图”的问题。用候选 embedding 模型分别计算这两组里的相似度分数。看分布等价对的中位数通常在 0.92-0.97不等价对的中位数通常在 0.70-0.85。初始阈值取两组中位数的中点附近然后小流量试运行观察误命中率。不同意图可以设不同阈值。退款类问题措辞接近但是高风险阈值往高了调宁可少命中也不要答错物流查询类问题答错了成本低阈值可以适当放宽。给缓存加一个意图维度的配置比全局一个阈值灵活得多。4.3 TTL、版本失效与缓存更新策略缓存不是永远有效的。答案有生命周期prompt 模板会改业务知识库会更新这三类问题都要管。TTL 不要一刀切按内容类型分静态知识型如产品规格、入驻流程7 天或更久。运营信息型如优惠活动、公告5-30 分钟。实时状态型如库存、价格、物流轨迹不缓存或只缓存几十秒。prompt 版本升级是缓存最常见的“暗坑”。你改了一行 prompt 里的措辞历史缓存答案全部默认无效但如果你没有把版本号放进 key旧答案还会被继续命中等你发现时已经返给用户很久了。所以我在 3.3 强调 cache_key 必须包含 prompt_version。版本升级还有个附带问题新 key 下缓存是空的命中率瞬间从 40% 掉到 0。建议升级前做一个预热任务把最近 7 天的高频问题事先在新版本 key 下跑一遍把答案写进去。这个操作我反复做过能显著降低升级后的成本和延迟抖动。5. 实战中遇到的坑与排查速查手册5.1 语义缓存误命中怎么办最典型的场景“如何关闭自动续费”和“如何取消自动续费”在向量空间里相似度经常超过 0.90但前者可能是投诉后者可能是正常操作流程返回同一份答案就可能出事。排查手段给所有命中请求打上cache_hittrue标签日志里同时记录原始 query 和历史命中 query方便事后挑出来做人眼检查。建立高风险动作词表命中这些词关闭、解约、注销、投诉、报警的 query 直接跳过语义缓存。对拿不准的命中可返回缓存结果但附加提示“你可能想问的是xxx”让用户自己确认把错误成本转嫁出去。5.2 敏感数据与多租户隔离缓存库是数据仓库的延伸里面存了大量用户真实提问可能有姓名、手机号、地址。直接入库在隐私合规上风险很大。我的习惯是入库前脱敏把 user 名、手机号、地址替换成占位符比如[USER_NAME]、[PHONE]。敏感问题不缓存或者在缓存值里直接标记sensitivetrue并只缓存固定话术。多租户场景必须把 tenant_id 拼进 cache_key还要保证向量检索时只搜当前 tenant 的索引分片。否则两个租户的相似 query 会互相命中的不是一两次是一类系统性事故。5.3 字符串标准化与双缓存一致性问题普通缓存对输入格式极其敏感。一条 query 带全角冒号和半角冒号就是两个不同的 key。在拼缓存 key 前先做标准化import unicodedata def normalize_text(text: str) - str: text unicodedata.normalize(NFKC, text) # 全角转半角 text text.strip().lower() # 去首尾空格、统一小写 text .join(text.split()) # 压缩中间连续空格 return text这个函数虽小但对普通缓存命中率的提升往往立竿见影。如果你同时开普通缓存和语义缓存还有个一致性问题同一个问题普通缓存存了一套答案语义缓存又存了稍老的一套。我建议命中顺序固定为先普通后语义若普通缓存命中就直接返回若普通缓存未命中但语义命中则把这条 answer 回填进普通缓存让后续完全相同的 query 走更快的通道。反方向也要处理语义缓存更新答案后普通缓存里的旧条目要同步删除。5.4 上生产前的影子模式评估语义缓存上线前先跑一段影子模式。影子模式的逻辑是照常做语义检索但命中时不真正返回缓存答案而是把“如果命中会返回什么”记入旁路日志让真实的 LLM 调用照常执行。跑一周后人工抽检旁路日志看缓存答案和真实答案的一致率。我通常在影子模式里看三个数字潜在命中率有一批请求本来可以不调用 LLM。误命中率缓存答案与真实答案不一致的比例。高风险误命中数那些在退款、注销等敏感意图上的错误命中。只有当高风险误命中数在自己可控范围我的标准通常是每周为 0才允许把语义缓存从 shadow 切换成真实命中。这个流程会拖慢上线节奏但能避免一次线上事故把之前省的几万块钱全部亏回去。我自己走完这些流程后给团队的默认路径基本固定为先无缓存压测取基线再上普通缓存观察两周命中率如果命中率低于 15%才考虑上语义缓存。语义缓存一定先在影子模式跑一周拿真实数据说话。真正把成本降下来的团队靠的不是某一套花哨缓存方案而是把 query 规范化、模板版本化、多租户隔离、阈值评估这些基本功一件件做扎实。