ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLM调用缓存三档架构:从Token计费到语义缓存降本实践

LLM调用缓存三档架构:从Token计费到语义缓存降本实践 做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( hostyour-redis-host, port6379, decode_responsesTrue ) 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.51024优秀约1.3GB中文场景首选带指令前缀更准BGE-small-zh-v1.5512良好约300MB性价比高适合轻量部署text-embedding-3-small1536优秀在线不占本地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_nameBAAI/bge-large-zh-v1.5): self.r redis.from_url(redis_url, decode_responsesFalse) self.model SentenceTransformer(model_name) self.sim_threshold 0.86 def _embed(self, text): return self.model.encode(text, normalize_embeddingsTrue).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( fllm_cache:{hash(query)}, mapping{answer: answer, embedding: query_vec} )这段代码里最需要关注的是sim_threshold这个阈值它直接决定缓存命中率和正确率的平衡。阈值过高语义缓存退化成普通缓存命中率上不去阈值过低大量不相关内容被误判为同一问题返回的结果牛头不对马嘴用户反馈会炸。提示不同业务场景相似度阈值差异极大。客服问答的FAQ0.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.86P50响应时间6.2s3.4s0.3sP95响应时间12.8s7.1s2.1s平均输出Token/请求420260130日消耗Token总量约380万约210万约110万命中率0%47%68%同一个业务语义缓存能把日Token消耗砍到无缓存的30%左右同时响应时间下降一个数量级。需要承认的是语义缓存并不能完全替代普通缓存——因为语义层的相似度匹配可能会导致部分Query的有效信息变化却不被察觉比如价格多少和价格包含什么在向量空间里距离过近。5.2 置信度分布才是利润源泉很多人只看平均命中率其实更值得看的是置信度分布。我们统计发现语义缓存命中的答案中相似度在0.93以上的区间人工复审的采纳率能超过95%而相似度在0.86-0.90区间的采纳率只有72%。因此线上配置时我们把相似度低于0.90的命中结果都强制标记为需人工二次校验不对用户直接展示。另外将缓存命中的响应标记为cache_hitTrue后可以单独做一轮缓存答案质量消歧。方法是定期抽取缓存命中的问题令模型以是否完整、是否准确、是否匹配用户意图三个维度重新评分不达标的缓存直接删除同时降低对应区域相似度阈值。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次的转变。
返回列表