尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Redis为LLM应用构建缓存层,节省30%-50% API调用成本

用Redis为LLM应用构建缓存层,节省30%-50% API调用成本 做LLM应用的人前期基本都栽在同一件事上模型API的账单。我见过不少团队辛辛苦苦把产品做上线结果月底一看光是大模型调用费就吃掉了一大半利润。这不是模型选型的问题而是很多重复请求根本没有被拦住——用户把同样的问题换了个说法又问了一遍你的服务就老老实实再调一次大模型API再付一次钱。今天聊的这套方案就是用Redis在前面加一层cache把能拦的重复请求全部拦下。它不能帮你省掉所有LLM开销但正常情况下能砍掉30%到50%的调用成本而且延迟能从几秒降到几十毫秒。这篇内容适合所有正在做LLM应用开发、被API账单困扰、或者想给系统加缓存层的工程师不限定语言和框架核心思路和踩坑经验都是通用的。1. 为什么LLM应用必须做缓存先算一笔成本账1.1 LLM API的成本真相大模型接口的计费方式和传统HTTP接口完全不一样它对“每次对话”都收费。你发一段Prompt上去模型生成的每个token都要花钱而且输出token经常比输入token贵好几倍。我按当前市面上常见的API价格估算了一下假设你每次请求平均消耗2千个输入token加1千个输出token输入大约2元/百万token、输出大约8元/百万token那么一次请求的成本差不多是0.012元。听起来没多少但如果你一天有10万次请求就是1200块钱一个月就是3万6。这还只是用中等价位的模型如果接的是更大参数的模型或者启用了更长的上下文账单翻倍很正常。更隐蔽的问题是很多交互场景根本不产生新价值。比如用户问“你们公司的退款政策是什么”第二天又有个新用户问“退款怎么弄”两个问题语义几乎一致但你的系统每次都乖乖调用大模型推理一遍。这类重复请求占掉了带宽、占掉了时间、也占掉了账单里很大一部分。LLM接口又没有“第二份半价”这种优惠唯一合理的做法就是在应用层加缓存把“已经算过答案”的问题直接复用。1.2 Redis缓存能拦截下的三类重复请求做缓存之前先要搞清楚系统里到底哪些流量是可缓存的。我实际跑过数据把能拦的请求分成了三类。第一类是同一用户的重复提问。用户在对话窗口里问了一遍“怎么导出数据”不满意改了个标点又发一遍或者刷新页面后再问一次。这些请求的文本几乎一样完全没必要让模型再算一遍。第二类是多用户的高频共性问题。客服系统里“你们的API支持哪些语言”“试用版怎么开通”这类FAQ不同用户会反复问。LLM模型每次都会生成一个略有差异但内容基本相同的回答缓存下来之后所有人都命中同一份答案体验一致还省钱。第三类需要进阶处理就是语义相似但表述不同的请求。“推荐一个适合新手的套餐”和“有什么适合新手买的套餐”两句话字面不一样但意图相同。如果做严格文本匹配这两次请求会落到两个不同的缓存key上命中率上不去。更好的方案是给文本做向量化用Redis Search做语义匹配这条后面单独讲。1.3 方案选型完整缓存、语义缓存、自适应缓存确定了哪些流量值得缓存下一步是确定缓存粒度。我见过三类做法完整缓存最简单直接用请求参数计算一个keyvalue存大模型返回的完整内容。优点是实现快、完全不改变原有链路适合绝大多数刚起步的团队。语义缓存多一层设计适合用户提问千奇百怪的开放场景比如综合问答、智能客服。它把用户输入向量化之后在Redis里找最相似的条目相似度高于阈值就直接返回缓存答案。缺点是做不好容易出现“看似相似实则答案不同”的问题后文会仔细说。自适应缓存是我现在线上在用的方式热点问题用本地内存比如Caffeine做一级缓存公共问题用Redis做二级缓存碰到模型更新了还要主动失效相关缓存。分级之后命中率更高Redis压力也小。这套方案的成本和复杂性都高一些适合产品稳定后沉淀下来逐步加。2. Redis数据类型在LLM缓存中的选型2.1 为什么最后选了Redis而不是本地内存聊到缓存一定有人问“Java里用HashMap加个定时清理不就行了为什么要上Redis”。我的经验是本地缓存只在单实例、单进程的场景里成立。一旦服务做了多实例部署每个实例的本地缓存是各自独立的同一个问题在实例A命中、在实例B又得重新调LLM缓存效果直接打了对折。Redis是独立进程所有实例共享一份缓存数据不管流量打到哪个副本命中率是一致的。另外LLM缓存的key天然带TTL语义答案不能永久有效知识库更新之后旧答案要能批量失效。Redis原生支持过期时间、淘汰策略、持久化还带主从和哨兵机制这些能力自己用HashMap实现一遍成本非常高。再加上Spring Cache、redis-py这些客户端生态非常成熟几乎不需要写什么额外封装。我做选型时给过一张对比表方案共享性过期策略数据结构运维成本命中率应用本地内存单实例独享自己写定时器简单低低MySQL等数据库全局共享字段控制简单中中Redis全局共享内置TTL丰富低高外部缓存服务全局共享取决于服务受限中高结论很直接共享性、数据结构、TTL三样都被Redis占了而且它本身就是为这种场景设计的。这也是我把缓存层定为Redis而不是其他方案的根本原因。2.2 String、Hash、ZSet不同缓存粒度的选择Redis数据类型很多但LLM缓存实际用到的核心就那么几个。String、Hash、ZSet用得最多List和Set在普通缓存场景里基本帮不上忙。String是最直接的适合存“一个问题对应一个答案”的场景。Key设计成llm:cache:1:{hash}value存整个响应JSON包含content、finish_reason这些字段。用SETEX命令写入时直接带上过期时间避免分开执行SET和EXPIRE时中间出错导致key永远不过期。Hash适合一个原始问题对应多个生成结果的情况。比如同一个问题你想同时问三个模型对比回答质量用String就得存三份重复的文本而用Hash可以让原始Prompt只存一份field分别放“model:gpt-4o-mini”“model:qwen-plus”value放各自的响应内容。这样既省内存想看某个模型历史答案时也很直观。ZSet我是用来做热点统计和手动淘汰的。Score存最近一次访问的时间戳后台任务定期把超过一定天数没被访问的key从缓存里清掉相当于自己做了一套LRU。做LLM缓存时我不太放心完全交给Redis的内存淘汰策略毕竟不同key的重要程度不一样用ZSet手动控一遍更稳妥。2.3 HyperLogLog与缓存效果统计上面做的缓存只是手段关键是“怎么向老板证明这事有效”。很多时候我们说不清楚到底省了多少钱因为不知道有多少请求被缓存命中了。我在项目里用了一个很轻量的方案Redis的HyperLogLog。每次请求进来不管命中还是回源都把请求标识加入当天的HyperLogLog里。PFADDllm:stat:20240228请求的唯一ID就能用PFCOUNT拿到当天的去重请求数。缓存命中时再维护另外一个计数器两个数一减就是真正的LLM调用量。HyperLogLog有0.81%左右的误差率用来做账单对账和趋势分析完全够用而且固定只需要12KB内存存多少数据都不膨胀。这个统计在其他方案里很难做得这么轻。如果用数据库需要建表记录每天的请求流水还要考虑去重逻辑而Redis一条命令就搞定了。数据可视化时直接读这两个key画曲线命中率变化一眼可见。3. 实操过程与核心环节实现3.1 环境准备Docker一条命令拉起Redis开始写代码之前环境要先就绪。我推荐直接用Docker部署一条命令就能把Redis跑起来避免折腾本机安装问题docker run -d \ --name redis-llm \ -p 6379:6379 \ redis:7.2-alpine \ redis-server --maxmemory 2gb --maxmemory-policy allkeys-lru --appendonly yes几个参数展开说明一下。maxmemory限制Redis最大能用的内存防止缓存写爆服务器。appendonly yes开启AOF持久化Redis重启后缓存数据不会全丢LLM缓存都是钱丢了再慢慢回填也是成本。--maxmemory-policy allkeys-lru表示内存满了以后按最近最少使用策略淘汰key这套策略对LLM缓存很合适冷门答案优先被清掉热门的保留。启动后用redis-cli -h 127.0.0.1 -p 6379 ping验证返回PONG就说明环境没问题。如果你更习惯GUI操作可以装一个RedisDesktopManager或它的社区版后面调试缓存内容会方便很多尤其在排查序列化问题的时候。3.2 第一版精确缓存先用Python跑通主链路环境就绪后我先给你看一个最小可用的精确缓存实现。这个版本只做精确匹配即两次请求的Prompt完全一致才算命中适合快速落地。import hashlib import json import redis from openai import OpenAI r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) client OpenAI(base_urlhttps://your-llm-api.example.com, api_keyyour-key) def llm_call_with_cache(prompt: str, model: str, temperature: float 0.7, ttl: int 3600) - str: # 用请求参数生成缓存key raw json.dumps({prompt: prompt, model: model, temperature: temperature}, ensure_asciiFalse) cache_key llm:cache:1: hashlib.md5(raw.encode(utf-8)).hexdigest() # 1. 先查Redis cached r.get(cache_key) if cached is not None: return json.loads(cached)[content] # 2. 未命中调用LLM resp client.chat.completions.create( modelmodel, messages[{role: user, content: prompt}], temperaturetemperature, ) answer resp.choices[0].message.content # 3. 写入Redis带TTL r.setex(cache_key, ttl, json.dumps({content: answer, model: model}, ensure_asciiFalse)) return answer这个代码的核心点有两个一是缓存key必须把影响生成结果的参数都算进去prompt、model、temperature缺一不可。不同模型回答风格差异大temperature不同生成结果也不同少一个参数就会串答案。二是写入用setex一条命令完成“设值过期”不要自己拆成两步。Redis客户端会尽力保证原子性但跨步骤操作总归存在崩溃窗口。第一版跑通后不要急着上语义缓存先观察日志里的命中率。如果命中率很低重点检查是不是Prompt里拼了动态字段比如时间戳、用户名、session ID这些字段会让同一个问题的key每次都不同。3.3 Java工程落地Spring Cache注解 Redis如果你是Java后端Spring Cache的抽象层会省掉不少样板代码。它的用法非常简单在需要缓存的方法上加一个注解比如Cacheable(cacheNames llmCache, key #prompt : #model : #temperature) public LlmResponse chat(String prompt, String model, double temperature) { // 这里真实调用LLM API return llmClient.complete(prompt, model, temperature); }这个注解表示调用chat方法前先按key查Redis命中直接返回缓存值不进入方法体没命中才执行方法方法返回后自动写入Redis。更重要的是当知识库内容更新后只要调用一次清缓存操作旧答案就全部失效CacheEvict(cacheNames llmCache, allEntries true) public void evictAllLlmCache() { // 清空llmCache下的所有key }但Spring Boot默认的Redis缓存序列化方案是JDK序列化会把数据存成二进制字节流RedisDesktopManager里看到一堆乱码而且跨语言读数据也很痛苦。一定要改成JSON序列化配置RedisCacheManager时指定GenericJackson2JsonRedisSerializer。注意给实体类加上默认构造器否则反序列化时容易报错。这是Java接入Redis缓存最常见的坑没有之一。3.4 进阶版语义缓存向量 Redis Search 识别意图相同的请求精确缓存只能拦完全相同的文本但如果用户问“怎么退款”和“退款流程是什么”精确匹配就会漏掉。语义缓存就是为了解决这个问题。整体思路是把用户输入的Prompt先用Embedding模型转成一个向量然后存到Redis Search里。查询时用向量相似度找最接近的历史问题如果相似度超过0.92就把历史答案当作本次答案返回。Redis Search的向量能力已经内置不必额外引入第三方向量数据库。下面是个Python调用示例FT.CREATE idx_llm ON HASH PREFIX 1 llm:semantic: SCHEMA prompt TEXT answer TEXT embedding VECTOR FLAT 6 DIM 1024 TYPE FLOAT32 DISTANCE_METRIC COSINE建好索引后每个条目对应一个Hash存入字段prompt、answer和embedding。查询时构造相似的向量做KNN搜索results r.execute_command( FT.SEARCH, idx_llm, *[KNN 1 embedding $vec AS score], PARAMS, 2, vec, vector_blob, RETURN, 1, answer, DIALECT, 2 )拿到score后转成余弦相似度如果大于阈值就返回缓存的答案。做语义缓存时有三个实际经验要记住。第一Embedding接口本身也花钱如果每次请求都调一次Embedding成本又上去了。建议小流量时用本地模型或BGE-M3这类轻量模型做Embedding减少对外部API的依赖。第二两个问题虽然相似但答案可能完全不同“今天杭州天气怎么样”和“明天杭州天气怎么样”只剩一个词不同向量相似度会非常高但答案基本不同。这类“时间敏感、实体敏感”的问题需要单独做实体抽取或者干脆不参与语义缓存。第三阈值不要拍脑袋定上线前用人工构造的测试集多试几轮搜索引擎开始出现“答非所问”案例时阈值就是上限了。3.5 成本与命中率的参数估算模型搭建缓存的成本也是成本。我习惯在做之前先算一笔账如果从数字上判断投入产出比不划算那就不该做。核心公式很简单每日节省成本 每天总请求数 × 命中率 × 单次请求平均成本。例如一个系统每天有10万次请求每次请求平均消耗0.01元的API费用。缓存命中率达到30%那么每天就能省下300多元一个月就是近万元。而Redis侧的成本是一个key大约1到5KB100万个key最多占5GB内存按云上Redis价格估算一个月内存开销也就几百块。投入产出比非常明显。我整理了一个快速估算表方便你自己代入算每日请求量单次成本(元)命中率每日节省(元)10万0.0120%20010万0.0140%40050万0.0130%150050万0.0330%4500注意单次请求成本我这里只是粗略估算真实值要根据你使用的模型、上下文长度、输出长度确定但方向和数量级是差不多的。算完这笔账你就会发现花半天把缓存接上是性价比极高的一件事。4. Redis缓存治理与稳定性4.1 缓存穿透、击穿、雪崩怎么影响LLM成本缓存上线之后稳定性问题就开始冒出来了。Redis场景里最怕三件事穿透、击穿、雪崩。这三件事在LLM应用里各有各的表现形式。缓存穿透是指大量请求查询一个“本来就不存在”的内容。比如恶意用户拿一批无意义的Prompt来刷接口每一条都不会命中缓存请求全部打到LLM API上API费用直接起飞。解决办法一是对空结果也做缓存把contentnull的响应也存成短TTL的空值就算刷子来了也只能打一次LLM二是用布隆过滤器把可能存在的Prompt摘要放进去判断不存在就直接返回默认文案连Redis都不查。缓存击穿指某个热点key在过期瞬间被大量并发请求同时命中了空窗口所有请求一起回源调用LLM导致瞬间费用暴增。最简单的手段就是下一小节要讲的分布式锁保证同一时间只有一个请求回源其他人等待结果。缓存雪崩指大量key在同一时间集体过期造成回源流量激增。解决起来也简单设置的TTL不要用一个固定值而是在合理范围内加随机抖动比如300到900秒之间随机选一个值这样不同key的过期时间被分散开不会形成“集体失效”的场面。4.2 内存治理淘汰策略、持久化与热Key定位Redis内存不是无限大的每一条缓存都要钱。内存管理做不好缓存还没帮你省钱先把Redis服务器吃爆了。首先是淘汰策略。启动参数里我建议用allkeys-lru当内存打满时自动淘汰最久没访问的key热点答案基本不受影响。如果业务上某些答案绝对不能丢可以给这些key设置较高的访问频率让LRU算法自然保持它们存活。其次是序列化压缩。存大段文本时JSON原始格式的体积比实际文本大不少。Java侧可以用gzip把value做个压缩再写入读取时先解压再反序列化实测能压掉60%到70%的体积。压缩和解压会消耗一点CPU但相对于API费用和Redis内存费用这点CPU开销可以忽略。最后是热Key定位。用redis-cli --hotkeys就能统计出访问频率最高的key。如果某个key的QPS高得离谱可以考虑把该key下沉到Caffeine本地缓存减少Redis压力。执行前记得给Redis开通相关参数否则命令会直接报错。4.3 Caffeine Redis 多级缓存热Key不再频繁回源单纯用Redis做缓存已经能解决大部分问题但遇到极端热点问题时还是要上多级缓存。我的典型配置是两级实例本地放Caffeine命中优先查本地本地没有再去查RedisRedis也没有才会调用LLM。访问链路变成“本地内存 - Redis - LLM”每一层都能拦掉一部分流量。这样Redis的QPS压力大幅下降LLM回源次数也会变少。Caffeine的写法很简单核心代码大概是CacheString, LlmResponse localCache Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(10)) .build(); LlmResponse answer localCache.get(cacheKey, key - { LlmResponse res redisTemplate.opsForValue().get(key); if (res null) { res llmClient.complete(...); redisTemplate.opsForValue().set(key, res, Duration.ofHours(1)); } return res; });这段代码的含义是先查本地缓存没有就去Redis拿再没有才调LLM。一个get方法完成了三级数据源的回源逻辑。Caffeine的过期时间我设置得比Redis短比如本地缓存10分钟Redis缓存1小时这样Redis里的数据被更新后本地最多10分钟就能感知不会长期保留旧答案。多级缓存最大的坑是一致性。如果你主动清理了Redis里的某个key还得同步清理所有实例的Caffeine本地缓存否则某个副本会一直返回旧答案。这时可以用Redis Pub/Sub发一条“清缓存”通知所有实例收到消息后各自清掉本地缓存的对应key。4.4 分布式锁避免热点Key击穿LLM接口热点key过期时如果多个实例同时发现Cache Miss所有请求会同时去打LLM API。假设一个热点问题有50个并发请求那你一次性就付了50次API费用这是完全没必要的。解决办法就是分布式锁。以Python为例用Redis的SET NX EX实现一把简单的分布式锁lock_key llm:lock: cache_key if r.set(lock_key, 1, nxTrue, ex10): try: # 抢到锁的请求真正调用LLM answer call_llm(prompt) r.setex(cache_key, ttl, json.dumps({content: answer})) finally: r.delete(lock_key) else: # 没抢到锁的请求自旋等待 while True: if r.exists(cache_key): return json.loads(r.get(cache_key))[content] time.sleep(0.05)它的设计思路是当一个请求发现自己要回源时先尝试拿锁只有拿到锁的请求才真正调用LLM其他请求发现锁被占就轮询等待缓存被写入。锁的过期时间设置为10秒正常情况下LLM调用不会超过这个时间万一超了锁自动释放防止死锁。这个方案会让部分请求多等几百毫秒到一秒但对整体成本和系统稳定性来说是值得的。写业务代码时还要注意释放锁之前要校验Value是不是自己设的否则可能误删别人刚抢到的锁如果用了Redisson这类现成库它的看门狗机制还会自动续期更省心。5. LLM缓存落地常见问题与排查技巧实录5.1 缓存命中了但返回格式不对是怎么回事这是我在项目里被问过最多的问题。现象是第一轮调用LLM返回了一长串Markdown格式的答案缓存下来没问题但某个下游业务只想要纯文本拿着缓存内容直接渲染结果样式全乱了以为是缓存坏了。实际上缓存本身没有问题是缓存数据的定义不完整。LLM返回给业务方的往往不只是content字段还有finish_reason、usage统计、模型名、生成时间等元信息。如果只缓存一个裸字符串后续对格式要求不同的业务就无法复用。我的处理方式是把整个响应对象序列化后写入Redis至少包含content、model、finish_reason、created_at四个字段。等业务方拿数据时自己决定用哪个字段。另外用了Spring Cache的项目要把redisTemplate.setValueSerializer配置清楚确认你写入的是JSON而不是JDK序列化字节否则你会在RedisDesktopManager里看到一串以\xac\xed开头的乱码那就是格式不匹配最直接的信号。5.2 缓存命中率死活上不去的排查路径缓存上线了效果却很差命中率只有百分之几。我通常会从三个方向排查。先看Key设计。把日志里的cache_key打印出来凡是带动态随机值的比如时间戳、UUID、session ID、用户ID都是命中率杀手。有人会把userId拼进key里同一个问题不同用户问就是不同key缓存失效。正确做法是把所有不影响LLM答案、不影响业务区分度的字段全部排除。再看Prompt拼接。很多LLM应用会在系统Prompt里动态注入“当前日期”“用户名”“上下文历史”。如果是这样哪怕用户输入的文字一模一样动态部分也会让key变化。这里需要做归一化比如把动态字段替换为固定占位符后再计算缓存key。同时把“上下文历史”里对答案影响不大的部分截断只保留用户当前问题本身。最后看缓存里的实际内容。使用SCAN命令按llm:cache:*前缀批量查看key看看有没有大量只出现过一次的key。如果发现Redis里堆满了访问一次的孤立key说明业务场景本身重复度低或者Key设计有问题。SCAN是必须的生产环境不能用KEYS那个命令会阻塞Redis进程。5.3 TTL应该设置成多少很多刚接触缓存的人会纠结TTL设多长。我的经验是把答案按“易变程度”分层数据类型建议TTL原因产品FAQ、固定知识7~30天内容基本不变可以长期复用运营活动、新闻简介1~6小时内容会短期变化但允许短时延迟天气、股票、时效信息1~5分钟信息敏感过期时间必须短用户敏感数据不缓存涉及隐私和时效性别碰这里的核心原则是“业务容忍度”。如果答案允许有1天的延迟TTL就设一天如果产品经理要求每次信息必须最新那这个业务压根不该走缓存。做LLM应用时我一般开始给一个比较保守的值比如1小时跑几天看业务反馈再逐步放大。不用一上来就追求极致命中率稳定迭代比什么都重要。5.4 别把KV Cache和Redis缓存搞混聊LLM缓存需求时经常出现一个概念混淆这里必须澄清一下标题里的“cache”和“KV Cache”不是同一个东西。KV Cache是LLM推理引擎内部的一种优化机制。生成每个token时模型需要依赖前面所有token的注意力Key和Value这些K/V如果不缓存每走一步都得重新算一遍前面所有历史token的注意力速度会慢好几倍。KV Cache是这个计算过程的中间结果它驻留在GPU显存或推理引擎自己的内存里由推理框架自己管理和Redis没有任何关系。而“Redis built a cache”说的是应用层的响应缓存也就是把LLM API返回的那个最终答案存进Redis。下次遇到同样或相似的问题直接返回答案不再调用推理接口。一个是推理过程中的性能加速一个是应用层的请求拦截层级完全不同。理解这个区别不仅对写代码有帮助面试时也能少闹大笑话。5.5 用RedisDesktopManager辅助排查的几个高效操作命令行虽然全但日常排查时用GUI更高效。我常用的有几个操作第一查看某个key的实际Value。比如想知道缓存里存的到底是纯文本还是JSON对象直接在RedisDesktopManager点开value一看便知。第二批量删除测试key。联调时你常常需要把某类缓存清掉让流程重新走一遍这时用SCAN扫出目标key再逐个DEL比一条条删高效得多。第三查看key的TTL和内存占用。MEMORY USAGE key能精确返回某个缓存占了多少字节用来验证压缩效果很直观。顺便提一句RedisDesktopManager的社区版完全够用不用花钱买企业版。它支持多环境连接配置把开发、测试、生产的连接全配好排查问题的时候切换环境点几下就完成比命令行效率高太多了。我个人在实际项目中最大的体会是LLM成本优化这件事永远先做缓存再考虑换更小更便宜的模型。换模型会改变回答质量用户感知明显风险高而Redis缓存对用户基本透明延迟还更低。做完缓存和分布式锁之后如果还想继续压成本下一步可以按问题难度做模型路由简单意图走廉价小模型复杂推理走大模型前面再套一层Redis做结果复用。不过无论怎么扩展Redis这层缓存始终是整个成本体系中性价比最高的一环。
返回列表