ARTICLE DETAIL

资讯详情

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

用Redis给LLM调用加缓存:告别重复Token消耗,成本直降30%

用Redis给LLM调用加缓存:告别重复Token消耗,成本直降30% 如果你做过LLM应用的后端大概率见过这种场景账单出来吓一跳翻日志发现同一个prompt被完整调用了900多次。大部分团队的第一反应是换更便宜的模型但真正的问题往往是应用层缺少一层cache。我过去大半年一直在做的一件事就是用Redis给LLM调用建缓存把重复请求挡在模型之前效果立竿见影。一次流水线级别的模型调用延迟是毫秒级到秒级、按token计费而Redis读一次缓存是微秒级、几乎免费——这个数量级差距本身就是降本空间。这篇文章就把这套方案的思路、设计和踩过的坑完整拆给你适合正在做LLM应用后端、对成本敏感的开发者和架构师。1. 不要急着换便宜模型先搞清楚账单里有多少重复劳动1.1 后端视角的LLM计费远不止输入加输出很多优化帖子都在讲怎么压缩token、怎么选模型但我发现一个更扎心的问题不少项目连同一个请求调用了几次都没统计过。从后端视角看一次LLM调用的真实成本包含很多被忽略的部分。第一是输出token通常比输入贵价格可能是三四倍所以让模型生成一段根本没有新意的重复回答奢侈程度远超你想象。第二是失败重试网络抖动、上游限流、超时这些都会让一次逻辑上的请求实际被计费多次。第三是流式接口断流重连前面已经吐出来的内容不会退钱重新连上又从头生成。这三笔开销混在一起很难一眼看出钱到底浪费在哪。真正有效的优化动作是先花半小时把生产日志里的prompt拉出来按内容做一次聚类。我敢打赌你会看到足够多的完全相同的prompt这就是缓存能发力的地方。1.2 三类最高频的重复请求我对号入座不是所有重复都来自用户操作项目里最常见的三种场景是这样的。一种是知识库问答。不同用户问退款多久到账进了业务系统之后通常会拼接成几乎相同的system prompt和user prompt本质上是在让模型重新生成同一段标准答案。一种是按钮触发的固定摘要/总结功能比如生成周报摘要这类用户手一抖点了两次或者别人打开同一个页面又触发一次请求体一字不差。第三种最容易漏掉定时任务、监控探活、自动化测试脚本。它们会用固定prompt每隔几分钟打一次API一天下来就是几百次完全冗余的调用。我自己接手过一个报表服务页面每刷新一次就会发一个固定prompt去生成数据解读。上线缓存之前光是这个页面一天就烧掉了几百美元。问题压根不是模型不好是压根不该每次都问。1.3 算一笔粗账光去重就能省出多少我们做个保守估算。假设每天调用量10万次重复率只有20%也就是2万次请求完全多余。每次平均消耗800 tokens其中输入500、输出300。一天的浪费就是1600万tokens。按目前主流商业模型的量级去套不同厂商价格差异大我只说相对值一个月下来这20%的重复开销至少是几千美金的纯流失。如果你的业务里固定模板类功能占比高重复率可能超过40%。我见过极端项目某条运营活动prompt同时被多个任务并发调用一天重复了几千次。这也是为什么我先建议做原始请求重复度分析再决定优化策略。因为如果连重复在哪都不知道后面所有方案都是盲打。1.4 为什么这个场景首选Redis而不是MySQL或本地内存选Redis不是因为它新而是因为它是这个场景下最顺手的工具。内存访问的延迟可以忽略不计吞吐量高到你不用担心缓存本身成为瓶颈。它自带TTL机制每个key都能单独设置过期时间不需要写定时任务去清理垃圾缓存。还有数据结构灵活既能用String存完整响应也能用Hash存结构化的字段。最关键的一点是大部分团队的基础设施里已经有Redis了不需要为缓存这个功能引入任何新组件。相比之下MySQL读写再快也有磁盘和网络开销本地内存虽然更快但各实例各存一份命中率被机器数量摊薄还容易造成实例间不一致。2. 缓存层怎么设计才不亏从缓存键到数据结构2.1 先决定缓存什么缓存完整请求映射不是缓存一句话我建议的缓存单位是一个请求的完整映射输入侧的system prompt、user prompt连同model、temperature、max_tokens这些影响生成结果的参数一起作为输入输出侧的回复内容、token统计、生成时间一起作为输出。一个请求对应一条缓存记录。这里最常见的失误是把prompt原样缓存却忽略了输入清洗。用户输入前后多几个空格、换行符不同、全角半角混用都会让缓存键不一致导致本来能命中的请求全部miss。所以第一步永远是规范化。另外提醒一点如果回复内容里需要动态拼接时间、用户名这类信息别把它们直接写进prompt里。正确做法是把动态部分从prompt里拆出去拿到缓存结果之后在业务层做模板替换。不然缓存永远命中不了因为你每次的prompt都长得不一样。2.2 String还是Hash响应缓存我选StringRedis里有好几个数据结构能用来做缓存String和Hash是最常被比较的两种。我在响应缓存这个场景里最终选了String原因是我的需求模式是一次性写入、整体读取没有更新单个字段的需求。String存的是一个JSON字符串里面打包了响应内容、token统计、缓存写入时间。用SET和GET两个命令就能完成读写逻辑简单内存开销也比Hash小。Hash则适合需要单独更新某个字段的场景比如记录访问次数、修改过期策略标记、或者存一些结构化的元数据。如果你的核心诉求只是按key快速取回完整响应优先String没必要为了看起来更结构化而选Hash。2.3 缓存键生成的三个关键动作规范化、参数纳入、哈希化缓存键设计是这门手艺里最容易被低估的环节。我的生成流程就三步。第一步规范化输入。把prompt做strip把各种换行统一成\n按需折叠连续空白。别做过度清洗比如全角半角统一这种操作要谨慎有可能让两个语义不同的句子被合并成一个键反而出脏数据。第二步把影响结果的参数全部纳入缓存键。model、system prompt、user prompt、temperature、max_tokens任何一个变了结果都可能不一样所以这些字段必须全部参与键的生成。我把它们组装成一个有序字典再序列化保证顺序稳定。第三步用哈希算法生成定长键加可读前缀。我用sha256够快而且定长键在Redis里好管理。键名格式类似llm:cache:{hash}这样线上排查时一眼就能看出来是什么数据。import hashlib import json import time import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def normalize(text: str) - str: return .join(text.strip().lower().split()) def build_cache_key(model, system_prompt, user_prompt, temperature, max_tokens): payload json.dumps({ m: model, s: normalize(system_prompt), u: normalize(user_prompt), t: temperature, mt: max_tokens, }, sort_keysTrue, ensure_asciiFalse) return fllm:cache:{hashlib.sha256(payload.encode()).hexdigest()} def get_llm_response(model, system_prompt, user_prompt, temperature0.7, max_tokens1024): key build_cache_key(model, system_prompt, user_prompt, temperature, max_tokens) cached r.get(key) if cached: return json.loads(cached)[reply] # call_llm_api 在这里替换成你实际使用的厂商SDK response, usage call_llm_api(model, system_prompt, user_prompt, temperaturetemperature, max_tokensmax_tokens) r.set(key, json.dumps({ reply: response, usage: usage, ts: time.time(), model: model, }), ex86400) return response上面这段逻辑的意图很简单先查缓存命中直接返回没命中再调模型拿到结果后回写缓存。注意set的时候带上了ex过期时间这是Redis做缓存的核心优势。2.4 TTL别一刀切不同内容的过期策略差异很大TTL设置直接决定缓存的新鲜度和命中率之间的平衡我按业务类型分了几档。缓存内容类型建议TTL原因知识库FAQ类24小时到7天标准答案轻易不变新闻摘要/实时数据解读5到10分钟数据时效性强用户个性化总结跟随用户会话内容与用户状态绑定运营活动文案72小时左右活动周期短过期风险低总原则是不确定性越高TTL越短。宁可多放几个请求穿透到模型也不要让过期的答案出现在用户面前。对成本敏感的项目TTL可以偏短一些重点是稳定命中如果产品对内容实时性要求很高短TTL更安全。3. 从精确缓存到语义缓存什么时候升级怎么落地3.1 精确缓存的天花板它只解决一字不差的重复精确缓存最大的缺陷是依赖字符串一致性。用户表达千差万别哪怕意思完全相同只差一两个字都会miss。就像你去柜台办业务每次都要把身份证号再念一遍窗口系统就是记不住你。在模板化、规则化的请求场景里精确缓存的命中率可以很高因为请求本身是程序拼接出来的。但到了纯自然语言问答、聊天机器人这类产品里用户说的话几乎不会完全重复精确缓存的收益直线下降。这时候才会开始考虑语义缓存。3.2 语义缓存的核心逻辑把字面匹配升级成意思匹配语义缓存的基本思路是请求进来先把它转成embedding向量然后跟已经缓存的向量做相似度计算超过阈值就判断为同一类问题直接复用对应的响应。好处很明显用户说多久能退款和退款要等几天在向量空间里会被识别为接近不用因为措辞差异就多花一次模型调用的钱。但这个方案有三个坑得先说清楚。第一语义相似不等于应该复用答案。怎么退款和怎么投诉在很多产品里答案截然相反向量距离却很近。第二embedding接口本身也要按token计费虽然单次便宜但会摊薄整体收益。第三一旦引入向量检索等于又多了一个存储和计算组件维护成本从零变成了有。3.3 小规模跑通用Redis的ZSET做暴力检索如果你的缓存量不大比如几千条以内其实没必要一开始就上向量数据库。我自己的过渡方案是把向量序列化之后扔进Redis查询时取出来逐一算余弦相似度。import numpy as np def cosine_similarity(vec_a, vec_b): return float(np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b))) def get_semantic_cache(prompt_embedding, threshold0.92): cached_items r.zrange(llm:semantic:index, 0, -1, withscoresFalse) for item in cached_items: cache_key, emb_hex item.split(|) emb np.frombuffer(bytes.fromhex(emb_hex), dtypenp.float32) sim cosine_similarity(prompt_embedding, emb) if sim threshold: return r.get(cache_key) return None这个方案在几千条缓存里跑得挺顺但上万条以后线性扫描就会变慢。量再大就老老实实把向量索引独立出去Redis继续做它擅长的响应缓存。记住这个过渡方案的设计思路先别为可能很大的规模买单等真的到了那个规模再升级。3.4 我的建议绝大多数项目先做精确缓存就够了语义缓存听起来高级但落地之后麻烦事不少。阈值标定就很头疼调高了大量miss调低了误命中频出而且线上用户的语言习惯会不断变化你需要持续观察调整。以我的经验一个还没做过任何缓存的LLM应用先把精确缓存配合prompt模板化做好命中率就能到30%以上。这已经是一笔非常可观的成本节省了。语义缓存适合的典型场景是高频搜索、高频FAQ这类表达多变但答案收敛的业务。等到精确缓存的收益已经吃透你自然会知道下一步该不该上语义缓存。4. 上线前必须处理的四个场景穿透、雪崩、击穿、脏缓存4.1 缓存穿透恶意或畸形请求会不断打到模型API缓存穿透是指请求的key在缓存里永远不存在每次都穿透到底层模型。比如有人用随机拼接的畸形prompt来试探接口或者业务在持续请求一批注定没有缓存的数据。我在生产里踩过一次一个监控探活脚本因为配置错误每秒发一个带时间戳的prompt后果是缓存永远miss模型API被白白打了几万次。对付穿透最直接的办法是缓存空结果对查无此键的情况也写一条短暂的缓存记录TTL设30秒左右。再配合入口侧的参数校验把明显异常的请求挡在服务之外。注意空值缓存的TTL别设太长否则会导致真正的有效请求也被挡一段时间。4.2 缓存雪崩大量key同时过期请求同一秒涌向模型雪崩通常发生在统一TTL的场景。你给所有缓存都设了86400秒那么第23点59分59秒写入的所有缓存会在第二天同一时刻集体过期。那一瞬间大量请求同时发现缓存miss一起打到模型API账单和延迟双双飙升。解法非常朴素TTL加随机抖动。比如基础值86400再随机加上0到3600秒。这样就可以让过期时间均匀散开避免集中冲击。这个动作成本极低却是很多人最容易忽略的。4.3 缓存击穿热点key过期瞬间并发请求全打穿击穿和雪崩的区别在于规模雪崩是大量key一起过期击穿是某一个热点key在过期瞬间被大量并发请求同时访问。比如一条爆款内容的总结prompt平时命中率很高偏偏在过期后的几毫秒内有几百个用户同时触发。应对办法是分布式锁加请求合并。拿到锁的那个请求去调模型其余请求短暂地等一会儿再回读缓存这样模型API同一时间只收到一次调用。def get_with_mutex(key, rebuild_func): cached r.get(key) if cached: return cached lock_key f{key}:lock if r.set(lock_key, 1, nxTrue, ex30): try: result rebuild_func() r.set(key, result, ex86400) return result finally: r.delete(lock_key) time.sleep(0.2) return r.get(key)这里的逻辑就是Redis分布式锁最常见的用法SET NX EX保证只有一个进程能拿到锁其他进程拿不到就短暂等待再读缓存。我把这个方法也用于请求去重后文会提到。4.4 脏缓存动态参数处理不当缓存层全是废数据脏缓存是隐性杀手。最常见的情况是prompt里拼了时间戳、用户IP、随机数导致每次请求的key都不一样缓存命中率形同虚设。更隐蔽的情况是动态参数恰好拼进了缓存内容里结果不同用户拿到的回复里带着别人的昵称或上次的时间。解决思路是动态内容不进缓存。把个性化信息从prompt里拆出来作为独立参数传递缓存里只存真正稳定的内容。展示之前在业务侧用模板把动态部分拼接回去。这个拼接逻辑可以写得糙一点但一定要保证动态部分永远不进入缓存体的内部。提示识别一条缓存记录是不是脏数据最简单的方法是看它的key在单位时间内被命中了几次。如果key数量特别多、单key命中次数特别少大概率是动态参数污染了键设计。5. 多级缓存怎么搭从Redis单点到一个稳的生产方案5.1 一条完整的请求链路本地缓存、Redis、模型API各干各的单靠Redis做缓存其实已经能解决大部分问题但生产环境我建议再加一层本地缓存。请求进来后的完整链路是本地内存缓存、Redis、模型API。本地缓存负责解决单实例内部的热点读Redis负责跨实例共享缓存数据模型API只接收真正没有被缓存覆盖的请求。层级典型延迟特点L1 本地缓存微秒级单实例内极快但不跨实例共享L2 Redis毫秒级全局共享多语言可访问L3 模型API数百毫秒到数秒成本高按token计费多级缓存带来的新问题是本地缓存和Redis之间的一致性。我的处理方式很简单本地缓存只设置极短的TTL比如30到60秒让它只吸收瞬时热点不承担长时间的数据一致性责任。5.2 Redis侧配置参考内存上限、淘汰策略、持久化缓存数据的特点是丢了可以重建但不能占满内存拖垮其他业务。我在部署时固定了几项配置。maxmemory根据预估缓存量设置。按每条缓存记录1KB左右估算10万条也就占用100MB上下不用给太多。maxmemory-policy allkeys-lru缓存数据没有绝对的新旧区分用LRU淘汰最省事。持久化缓存丢了无所谓我直接把AOF关掉或者设置成everysec避免AOF rewrite产生额外IO抖动。lazyfree-lazy-eviction yesRedis 4.0之后支持异步释放内存淘汰大key时不会阻塞主线程。还有一个日常习惯用redis-cli --bigkeys定期扫描一遍看有没有异常大key。在我的项目里发现过一条Sequence生成的缓存把几MB的图片base64也塞进去了这是需要清理的脏设计。5.3 量化效果响应时间变化和账单变化缓存上线之后我给团队做了个简单报表。缓存命中时首token返回时间从平均900毫秒降到5毫秒左右服务端压力肉眼可见地降下来了。月度token消耗大约减少了20%到35%具体看那个月有没有做模板化改动。Redis这边只多占了不到200MB内存可以忽略不计。这不是什么神奇的优化本质就是把重复计算换成了记忆。省钱不一定靠换更便宜的模型更稳的方式是在架构上把刚性重复去掉。6. Redis除了缓存还能顺手管住的事6.1 请求合并与去重让多个调用方共享同一次模型结果缓存解决的是再次请求的重复但有一种情况是并发瞬间的重复N个服务同时发出相同的请求此刻缓存还是空的如果各调各的模型API就是N次计费。这时可以把同一时间段内的相同请求合并成一个。实现的本质还是分布式锁拿到锁的请求去调模型并写缓存其他请求等待锁释放后直接读缓存。我的生产代码里这个逻辑的等待时间设了5秒超过就放行避免因为合并等待拖垮实时性。6.2 降级策略缓存故障不能拖垮主链路缓存是为了省钱和省延迟但如果Redis自身出问题反而把整个业务拖挂了那就本末倒置了。我在所有Redis读取操作外面都包了一层容错连接异常、超时、或者读到异常数据一律放行到模型API不在缓存这层抛出致命错误。同时做一个简单熔断连续几次读Redis失败就暂时跳过缓存过一段时间再重试。这套逻辑的成本只有几行代码但能让你的系统在缓存组件不稳定时依然保住主流程。6.3 一个提醒别把Redis缓存和大模型推理里的KV Cache搞混每篇聊LLM成本的文章里总能看到朋友问我GPU里不也有KV Cache吗为什么还要Redis这里得把概念掰开。KV Cache是模型推理内部产生的中间产物存放在GPU显存里供单次生成过程使用业务侧不可控也无法复用。Redis缓存的是应用层的输入输出映射它把这个prompt得出过什么答案记录下来。两者的服务对象和存储位置完全不同不是替代关系而是不同层级的两个东西。理解这一点你才不会在团队讨论时把方案带偏。最后补一句我这大半年最深的感受模型能力可以慢慢调但重复开销是当天就能砍掉的。这套方案从精确缓存做起上线后命中率从5%一路调到30%多账单肉眼可见地降下来后来加了语义缓存和请求合并效果又被拉开一截。如果你的应用还没做任何缓存别纠结先把精确匹配做扎实。等缓存量上来了你自然会知道下一步该优化什么。
返回列表