用Redis给LLM调用加缓存:告别重复Token消耗,成本直降30%
2026/9/13 6:11:29 网站建设 项目流程

如果你做过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:响应缓存我选String

Redis里有好几个数据结构能用来做缓存,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(host="127.0.0.1", port=6379, decode_responses=True) 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_keys=True, ensure_ascii=False) return f"llm:cache:{hashlib.sha256(payload.encode()).hexdigest()}" def get_llm_response(model, system_prompt, user_prompt, temperature=0.7, max_tokens=1024): 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, temperature=temperature, max_tokens=max_tokens) r.set(key, json.dumps({ "reply": response, "usage": usage, "ts": time.time(), "model": model, }), ex=86400) 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, threshold=0.92): cached_items = r.zrange("llm:semantic:index", 0, -1, withscores=False) for item in cached_items: cache_key, emb_hex = item.split("|") emb = np.frombuffer(bytes.fromhex(emb_hex), dtype=np.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", nx=True, ex=30): try: result = rebuild_func() r.set(key, result, ex=86400) 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 yes:Redis 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%多,账单肉眼可见地降下来,后来加了语义缓存和请求合并,效果又被拉开一截。如果你的应用还没做任何缓存,别纠结,先把精确匹配做扎实。等缓存量上来了,你自然会知道下一步该优化什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询