ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LLM 应用缓存实战:从无缓存到语义缓存的延迟与成本对比

2026/10/3 5:39:16 拓冰建站 浏览量
LLM 应用缓存实战:从无缓存到语义缓存的延迟与成本对比 LLM 应用上线之后最容易被低估的成本不是模型本身而是重复计算。同一个问题换个说法问两遍模型就要重新推理一遍token 照烧延迟照等。我最近在一个 LangChain 项目里把缓存从零做到语义级前后跑了三档对比延迟和成本差距大到让我重新审视了整个调用链路的设计。这篇就把这三档方案——无缓存、普通缓存、语义缓存——的落地细节、实测数据和踩过的坑完整摊开讲一遍。如果你正在用 LangChain 搭问答、客服、知识库或者 Agent并且已经感受到账单和响应时间的压力这篇内容基本可以当成一份可复现的改造手册。我会从最朴素的无缓存基线讲起再到 Redis 精确缓存最后到基于向量相似度的语义缓存每一档都给出为什么这么选、怎么落地、实测差多少以及那些文档里不会写的坑。1. 先把三档方案的成本账算清楚在动手改代码之前我习惯先把钱花在哪这件事量化。很多人一上来就说加缓存但不知道缓存到底能省多少也就无法判断值不值得投入语义缓存那套复杂度。所以这一节先把三档方案的账算明白。1.1 无缓存每一次调用都是全额付费无缓存的意思很直白——用户问什么就直接把 query 丢给 LLM拿到结果返回。这是最原始的形态也是绝大多数 Demo 的默认状态。它的成本结构是这样的每次请求都要消耗输入 token 输出 token。输入 token 包括你的 system prompt、历史对话、检索到的上下文输出 token 就是模型生成的内容。假设你的 system prompt 有 500 token每次检索回来 1500 token 上下文用户问题 50 token输出平均 300 token那么单次请求大约是 2350 token。按主流模型的价格粗算如果每百万 token 综合成本在几十元量级单次请求成本看起来微不足道。但问题在于量。一个日活几千的问答应用每天几万次调用一个月下来就是百万级 token 的消耗。更关键的是这里面有大量重复——用户问怎么退款和退款流程是什么在语义上几乎是同一个问题但无缓存方案会老老实实算两遍。延迟方面无缓存每次都要走完整的推理链路首 token 延迟通常在几百毫秒到一两秒完整响应几秒起步。用户体验的上限就被这个延迟锁死了。提示无缓存不是错误方案它是基线。没有基线你无法证明缓存带来了多少收益。我建议任何要上缓存的项目都先跑一周无缓存的数据把 QPS、平均 token、P95 延迟记录下来。1.2 普通缓存精确匹配能省多少普通缓存也就是精确缓存exact cache逻辑是把 query 做一次哈希或者规范化作为 key 去 Redis 里查命中就直接返回没命中就调 LLM 并把结果写回去。这一档的收益完全取决于重复率。在真实业务里精确重复的比例往往比想象中低。用户很少一字不差地问同一个问题更多的是意思一样但措辞不同。我实测下来纯精确匹配的命中率在 10% 到 25% 之间浮动取决于业务场景。FAQ 类、按钮引导类的问题命中率高开放式咨询命中率低。但即便只有 15% 的命中率收益也是实打实的这 15% 的请求完全不走模型延迟从秒级降到毫秒级成本直接归零。而且精确缓存的实现极其简单几乎零维护成本是性价比最高的一档。它的局限也很明显语义相同但字面不同的问题它一律视为新问题。这就是为什么需要第三档。1.3 语义缓存把意思一样也命中语义缓存的核心思路是不再用字面匹配而是把 query 转成向量去向量库里找相似度超过阈值的历史问题命中就返回对应答案。这一档的命中率能显著提升。同样一个业务场景精确缓存命中 15%语义缓存往往能做到 35% 到 50%甚至更高。原因很简单它抓住了换个说法问同一件事这个真实用户行为。但代价也随之而来每次请求都要先做一次 embedding虽然比 LLM 推理便宜得多要做向量检索要维护阈值还要处理相似但答案不该一样的误命中问题。复杂度上了一个台阶。下面这张表是我实测三档方案的核心对比数据来自一个中等规模的客服问答场景样本约 5 万次真实请求维度无缓存普通缓存语义缓存命中率0%约 15%约 42%平均延迟2.8s2.4s1.6s单次综合成本基准 1.0约 0.85约 0.58实现复杂度极低低中高维护成本无低中误命中风险无无存在这张表里最值得琢磨的是延迟。语义缓存虽然多了一次 embedding 和检索但因为命中率高整体平均延迟反而最低。这就是用一点点额外开销换大量请求免推理的典型收益。2. 无缓存基线的埋点与数据采集要对比就得先有可信的基线数据。这一节讲怎么在 LangChain 里把无缓存状态的埋点做扎实否则后面的对比都是拍脑袋。2.1 在 Chain 的关键节点打时间戳LangChain 提供了 callback 机制这是做埋点最自然的位置。我一般会自定义一个 callback handler在on_llm_start、on_llm_end、on_chain_start、on_chain_end这些钩子里记录时间戳和 token 用量。from langchain_core.callbacks import BaseCallbackHandler import time class MetricsCallback(BaseCallbackHandler): def __init__(self): self.start_time None self.token_usage {} def on_llm_start(self, serialized, prompts, **kwargs): self.start_time time.time() def on_llm_end(self, response, **kwargs): elapsed time.time() - self.start_time usage response.llm_output.get(token_usage, {}) self.token_usage usage # 这里把 elapsed 和 usage 写入你的监控系统 print(fLLM 耗时: {elapsed:.3f}s, token: {usage})这段代码看着简单但有个细节很多人会踩on_llm_end里的response.llm_output结构在不同模型提供商之间并不统一有的放在token_usage有的放在usage字段名可能是prompt_tokens也可能是input_tokens。我建议先打印一次完整结构再取值别直接照抄网上的字段名。2.2 记录 query 的规范化指纹光有时间还不够要分析重复率必须给每个 query 生成一个可比较的指纹。最直接的做法是规范化后哈希import hashlib import re def normalize_query(q: str) - str: q q.strip().lower() q re.sub(r\s, , q) q re.sub(r[。,.!?], , q) return q def query_fingerprint(q: str) - str: return hashlib.md5(normalize_query(q).encode(utf-8)).hexdigest()规范化这一步决定了你精确缓存的命中率上限。去掉标点、统一大小写、压缩空白这些操作能把你好和你好归为同一个指纹。但要注意别过度规范化比如把不要退款和要退款处理成一样那就出大事了。否定词、数字、专有名词这些必须保留。我跑了一周基线数据后发现规范化之后精确重复率从原始的 9% 提升到了 15%光是这一步规范化就多赚了 6 个百分点几乎零成本。2.3 基线数据的分析维度采集完数据别只看一个总数。我一般会按这几个维度切按 query 指纹聚合看哪些问题被问得最多这是缓存收益的主要来源。按时间段分布高峰期和低谷期的重复模式可能完全不同。按用户分层新用户和老用户的提问习惯差异很大。按 token 消耗排序找出那些又长又重复的请求它们才是降本的重点。这里有个反直觉的发现最该缓存的不是最高频的问题而是高频且高 token的问题。有些问题问得频繁但答案很短缓存它省不了多少钱有些问题问得没那么频繁但每次都要塞进去一大堆上下文缓存一个顶十个。这个视角切换之后我的缓存策略从按频次改成了按频次乘以 token 成本。3. 普通缓存的落地Redis 精确匹配的工程细节基线跑清楚之后第一档改造就是上精确缓存。这一档看着简单但工程细节不少尤其是 key 设计和失效策略。3.1 为什么选 Redis 而不是进程内字典有人会问既然只是 key-value为什么不用 Python 的字典或者functools.lru_cache答案有三个第一多实例共享。生产环境你的服务大概率是多副本部署的进程内缓存各存各的命中率会被稀释成 1/N。Redis 是共享的所有实例命中同一份缓存。第二持久化和过期控制。进程重启缓存就没了而 Redis 可以配置持久化还能给每个 key 设置 TTL精确控制答案的新鲜度。第三可观测性。Redis 自带命中率统计、内存占用、key 数量等指标方便你做缓存治理。进程内字典你得自己埋点。所以除非你的应用是单机且对延迟极度敏感否则 Redis 是更合理的选择。安装和连接这块redis官方镜像用 Docker 起一个最省事docker run -d --name redis-cache -p 6379:6379 redis:7-alpinePython 侧用redis-py连接配合 LangChain 的RedisSemanticCache或者自己封装都行。我倾向于自己封装一层因为业务里往往要控制 key 的命名空间和序列化方式。3.2 key 的设计命名空间与版本号key 设计是精确缓存里最容易被忽视、又最容易埋雷的地方。我见过太多项目直接用 query 的哈希当 key结果改了 prompt 之后旧答案还在返回用户一脸懵。正确的 key 至少应该包含这几部分def build_cache_key(query: str, model: str, prompt_version: str) - str: fp query_fingerprint(query) return fllm:cache:{model}:{prompt_version}:{fp}model换了模型答案质量会变缓存必须隔离。prompt_version改了 system prompt等于换了产品逻辑旧缓存必须失效。fpquery 指纹。这个设计的好处是当你升级 prompt 时只要把prompt_version从v1改成v2所有旧缓存自动逻辑失效不需要手动去删 key。等旧 key 的 TTL 自然到期内存也就释放了。这比手动DEL一堆 key 优雅得多也更安全。注意千万不要用KEYS *去批量删除缓存这个命令会阻塞 Redis。要用SCAN渐进式遍历或者干脆靠版本号隔离加 TTL 自然过期。3.3 序列化与 TTL 的取舍存进 Redis 的 value 是 LLM 的完整响应可能包含文本、结构化数据、引用来源等。序列化方式我一般用 JSON可读性好调试方便。如果对内存敏感可以用msgpack或者pickle但 pickle 有安全风险跨语言也不友好我不推荐。TTL 的设置是个权衡。设太短缓存频繁失效命中率上不去设太长答案可能过时。我的经验是事实型问答TTL 可以长一些比如 7 天因为事实变化慢。时效型问答比如涉及价格、库存、活动TTL 要短几小时甚至几分钟。通用兜底如果分不清设 24 小时是个稳妥的起点。更好的做法是按业务类型分桶设置 TTL而不是一刀切。你可以在 key 里带上业务标签然后针对不同标签配置不同的过期时间。3.4 精确缓存的命中率天花板精确缓存做完之后我盯着监控看了一周命中率稳定在 15% 左右和基线预测一致。这时候我意识到一个关键问题精确缓存的天花板是由用户的措辞习惯决定的你无法通过优化代码突破它。想再往上走只有两条路一是引导用户用标准化的话术比如提供快捷问题按钮二是上语义缓存。前者是产品手段后者是技术手段。大多数情况下两条路要一起走。4. 语义缓存向量相似度匹配的完整实现语义缓存是这三档里最有技术含量的一档也是最容易翻车的一档。这一节把实现链路拆开讲包括 embedding、向量存储、阈值调优和误命中处理。4.1 语义缓存的工作链路先讲清楚它每一步在干什么不然后面的参数你没法调。一次语义缓存查询的完整流程是用户 query 进来先做一次 embedding得到一个向量。拿这个向量去向量库做相似度检索找出最相近的历史 query。如果最高相似度超过阈值取出对应的答案返回。如果没超过阈值走正常 LLM 推理然后把 query 向量和答案一起写回向量库。这里有个关键点语义缓存存的是query 向量 → 答案的映射而不是query 文本 → 答案。检索时比的是向量距离不是字面。用生活化的类比精确缓存像查字典必须拼写完全一致才能查到语义缓存像问一个理解力很强的前台你说我想退钱和怎么把钱要回来他都知道你在问同一件事。4.2 embedding 模型的选择与成本embedding 是语义缓存的固定开销每次请求都要做一次。所以选型时要平衡质量、速度、成本三个维度。我的选型原则是优先用轻量级 embedding 模型。语义缓存只需要判断是不是同一个意思不需要理解特别细微的语义差别轻量模型完全够用而且快。维度不要太高。高维向量检索慢、占内存对缓存场景是浪费。几百维通常足够。和你的向量库匹配。有些向量库对特定维度有优化。成本上embedding 比 LLM 推理便宜一到两个数量级。假设 LLM 单次推理成本是 1embedding 可能只有 0.01 到 0.05。所以即便语义缓存没命中你多花的也就是这点 embedding 钱完全可以接受。但要注意embedding 也是有延迟的。如果你的 embedding 服务是远程调用网络往返可能就有几十毫秒。我建议把 embedding 服务部署在离应用近的地方或者用本地小模型。4.3 向量库的选型Redis 也能做向量检索很多人一提向量库就想到专门的向量数据库但其实Redis 从 2.4 版本之后Redis Stack就支持向量检索了用的是RediSearch模块。对于语义缓存这种场景Redis 做向量库有几个明显优势和精确缓存共用一套基础设施运维简单。支持 TTL向量数据也能自动过期。支持混合查询可以同时按标签过滤和向量检索。如果你已经在用 Redis 做精确缓存直接升级到 Redis Stack 就能把语义缓存也塞进去不用再引入一套新系统。这对中小团队特别友好。当然如果你的向量规模到了千万级或者需要复杂的多路召回专门的向量数据库可能更合适。但对绝大多数 LLM 应用的缓存场景Redis 足够了。4.4 相似度阈值的调优方法阈值是语义缓存的命门。设太高命中率上不去等于白做设太低误命中频发用户会收到驴唇不对马嘴的答案体验比慢一点还糟糕。我的调优方法是用真实数据做标注从基线数据里抽 200 到 500 对 query人工标注它们是否应该命中同一个答案。用不同的阈值跑一遍计算准确率和召回率。找到那个准确率可接受、召回率尽量高的平衡点。实测下来余弦相似度阈值在0.85 到 0.92之间是比较常见的甜点区。低于 0.85 误命中明显增多高于 0.92 命中率掉得厉害。但阈值不是固定的不同业务类型应该用不同阈值。比如业务类型建议阈值理由通用 FAQ0.88问题边界清晰可以稍宽松涉及金额/数字0.95差一点可能就是另一个答案开放式咨询0.85语义弹性大可适当放宽多轮对话上下文0.92上下文敏感需谨慎这个表是我踩过坑之后总结的。最开始我用统一阈值 0.85结果在涉及金额的问题上翻过车——退款 100 元和退款 1000 元被判定为相似直接返回了错误答案。后来给这类问题单独设了高阈值才解决。4.5 误命中的兜底策略即便阈值调好了误命中也不可能完全避免。所以必须设计兜底机制。我的做法是在答案里保留来源标记。当语义缓存命中时返回的答案里带上此答案基于相似问题xxx。这样一旦用户反馈不对你能快速定位是哪个历史问题污染了缓存。另一个兜底是设置缓存的新鲜度权重。如果命中的历史问题太久远比如超过 30 天即便相似度很高也降级为重新推理。因为业务可能已经变了旧答案未必还适用。还有一个实用技巧对高价值或高风险问题禁用语义缓存。比如涉及账户安全、支付操作的问题宁可慢一点、贵一点也要保证答案准确。你可以在 query 进入缓存前先做一次意图分类命中高风险意图就直接跳过缓存。5. 三档方案的实测数据与调优过程前面讲了原理和实现这一节把实测数据和调优过程完整呈现包括我踩过的几个大坑。5.1 实测环境与压测方法我的测试环境是一个客服问答应用LangChain 编排底层模型走 APIRedis 用 Docker 部署在同一内网。压测用真实历史 query 回放样本 5 万条覆盖一周的真实流量分布。压测时我关注四个指标命中率、P50 延迟、P95 延迟、单次综合成本。之所以看 P95 而不只看平均是因为用户体验由长尾决定平均延迟好看但 P95 拉胯用户照样骂。5.2 三档方案的延迟分布对比实测结果如下指标无缓存普通缓存语义缓存P50 延迟2.6s2.3s1.4sP95 延迟5.1s4.8s3.2s命中率0%15.2%42.3%缓存查询开销0约 2ms约 35ms这里有个细节值得说语义缓存的缓存查询开销是 35ms 左右包括 embedding 和向量检索。相比 LLM 推理的秒级延迟这 35ms 几乎可以忽略。但如果你用的是远程 embedding 服务这个数字可能翻几倍要实测。P95 延迟从 5.1s 降到 3.2s这个改善对用户体验是质变的。因为 5 秒是很多用户的心理忍耐极限降到 3 秒出头跳出率会明显下降。5.3 成本下降的归因分析成本从基准 1.0 降到 0.58降了 42%。这个数字怎么来的拆开看精确缓存贡献15.2% 的请求完全免推理直接省掉这部分成本。语义缓存额外贡献在精确缓存基础上又多命中 27.1% 的请求。embedding 新增成本所有未命中精确缓存的请求都要做 embedding这部分是新增开销。向量存储成本可忽略相比 LLM 推理微不足道。净效果就是 42% 的下降。如果业务场景的重复率更高这个数字还能更好。5.4 我踩过的三个坑第一个坑embedding 模型和检索模型不一致。我一开始写入缓存用的是一种 embedding 模型后来换了另一种结果新旧向量不在同一个语义空间里检索全乱套。教训是换 embedding 模型必须清空重建缓存或者用版本号隔离。第二个坑阈值调太低导致误命中。前面提过0.85 的统一阈值在金额类问题上翻车。后来改成分类阈值才解决。这个坑的代价是几个用户投诉值得所有人警惕。第三个坑缓存穿透。有些恶意或异常的 query 反复请求每次都命中不了缓存还每次都写缓存导致向量库膨胀。解决办法是对未命中的 query 也做频次限制超过一定次数才写入缓存避免被刷。提示缓存穿透在语义缓存里比精确缓存更隐蔽因为向量库的写入成本更高。建议对写入做限流并且定期清理低质量的缓存条目。6. 生产环境的缓存治理与监控缓存上线不是终点而是起点。没有治理的缓存会慢慢变成技术债。这一节讲生产环境里怎么管好这套东西。6.1 必须监控的核心指标我建议至少监控这几个指标缺一不可命中率分精确命中和语义命中分开看才能知道哪一档在起作用。缓存查询延迟embedding 和向量检索的耗时这是新增开销。误命中反馈率用户点答案不对的比例这是语义缓存质量的直接体现。缓存条目数量与内存占用防止无限膨胀。TTL 分布看缓存的新鲜度结构是否合理。这些指标最好做成看板每天扫一眼。命中率突然下降、误命中率突然上升都是需要立刻排查的信号。6.2 缓存失效的几种触发方式缓存失效不只是 TTL 到期还有几种主动失效的场景prompt 版本升级通过 key 里的版本号隔离旧缓存自然淘汰。知识库更新如果答案依赖的知识库变了相关缓存要主动清理。可以给缓存条目打上知识库版本标签。模型切换同 prompt 版本隔离换模型就换命名空间。人工干预发现某条缓存答案有问题要能快速定位并删除。人工干预这块我建议做一个简单的管理接口输入 query 就能查到它命中了哪条缓存、对应的答案是什么然后一键删除。这个工具在出问题时能救命。6.3 缓存预热与冷启动服务刚上线或者缓存刚清空时命中率是 0所有请求都走 LLM压力会很大。这时候需要缓存预热。预热的方法是从历史 query 里挑出高频、高价值的问题提前跑一遍 LLM把答案写进缓存。这样服务一上线就有基础命中率不会出现冷启动的延迟尖峰。预热的量不用太大覆盖 Top 几百到几千个高频问题就够了。关键是选对预热的问题用前面说的频次乘以 token 成本来排序优先预热那些最值得缓存的。6.4 语义缓存的定期评估语义缓存不是设好阈值就一劳永逸的。业务在变用户的提问方式也在变阈值和策略需要定期评估。我的做法是每月做一次缓存质量评估抽样一批缓存命中的请求人工判断答案是否正确计算准确率。如果准确率下降就要考虑调高阈值或者清理低质量缓存。同时也要看命中率的变化趋势。如果命中率持续下降可能是用户提问方式变了或者缓存里的历史问题过时了需要更新缓存内容。这套评估机制听起来麻烦但比起误命中带来的用户流失这点投入完全值得。我见过太多项目上线缓存后就不管了半年后缓存里全是过时答案反而成了负资产。7. 不同业务场景下的档位选择建议三档方案不是越高级越好而是要看业务场景。这一节给出我的选择建议。7.1 什么时候精确缓存就够了如果你的业务满足以下条件精确缓存可能就够了不必上语义缓存问题高度标准化比如按钮引导、菜单选择用户点击的就是固定话术。对准确性要求极高任何误命中都不可接受宁可慢也不能错。流量规模不大缓存收益有限不值得投入语义缓存的复杂度。团队运维能力有限语义缓存需要持续调优没人维护不如不做。精确缓存的性价比在简单场景下是无敌的别为了技术而技术。7.2 什么时候必须上语义缓存反过来这些场景语义缓存几乎是刚需开放式问答用户措辞千变万化精确匹配命中率极低。高并发高成本每次调用成本高命中率提升带来的收益巨大。有专门的算法或运维资源能持续调优阈值和评估质量。用户体验敏感延迟每降一点都能带来转化提升。我自己的项目就是典型的开放式问答精确缓存命中率只有 15%上语义缓存后翻到 42%收益非常明显。7.3 混合策略分而治之实际生产里我推荐的是混合策略而不是三选一先走精确缓存命中直接返回这是最快最省的路径。精确未命中再走语义缓存用向量检索兜底。语义也未命中才走 LLM 推理并把结果写回两级缓存。这个链路的好处是精确缓存负责处理那些一字不差的高频问题成本几乎为零语义缓存负责处理换个说法的问题用少量 embedding 开销换取大量免推理。两级配合命中率和成本都最优。实现上精确缓存和语义缓存可以共用 Redis精确缓存用普通 key-value语义缓存用向量索引互不干扰。8. 从缓存到更进一步的降本思路缓存做到位之后如果还想继续降本提速还有几个方向可以延伸。这些是我在项目里陆续尝试过的分享出来供参考。8.1 请求合并与批处理当多个请求在短时间内涌入且它们的 query 相似时可以把它们合并成一次 LLM 调用然后分发结果。这在高峰期特别有效能显著降低调用次数。实现上可以用一个短时间的缓冲窗口比如 50ms把窗口内的相似请求聚在一起。但要注意这会增加一点延迟适合对实时性要求没那么极致的场景。8.2 分级响应简单问题走小模型不是所有问题都需要大模型来回答。可以用一个轻量分类器先判断问题难度简单问题走小模型甚至规则引擎复杂问题才走大模型。这一层和缓存是互补的缓存解决重复分级解决简单。8.3 流式输出与感知延迟有时候真实延迟降不下来但感知延迟可以降。开启流式输出让用户第一时间看到内容在生成即便总时长没变体验也会好很多。这和缓存结合使用效果叠加。8.4 缓存与 RAG 的协同如果你的应用用了 RAG检索增强生成缓存和检索是可以协同的。检索到的上下文如果高度相似也可以缓存检索结果避免重复的向量检索。这一层的优化空间往往被忽视但收益不小。我在实际项目里的体会是缓存这件事没有做完的一天。业务在变用户在变模型在变缓存策略也得跟着迭代。最开始我追求的是命中率越高越好后来发现准确率优先、命中率其次才是正道。一个误命中带来的用户流失可能抵消掉几百次命中的成本节省。所以阈值宁可保守一点兜底机制宁可多一层也别为了好看的数字牺牲答案质量。最后再分享一个小技巧把缓存的命中情况透传到日志里每条请求都标记它是精确命中语义命中还是未命中。这样当你排查问题时能一眼看出这条请求走的是哪条路径定位效率会高很多。这个字段我一开始没加后来补上之后排查缓存相关问题的速度快了不止一倍。