
最近在调一个 AI Agent 项目同事一脸认真地问我Agent 服务扛不住并发的时候第一件事应该做什么我说别急着加机器先把 Redis 用明白。他愣了一下因为他们项目里的 Redis 只用来存登录 token。这大概是很多 Agent 项目的缩影——LLM 相关能力搞得风生水起Redis 却一直在干最没有技术含量的活儿。今天这篇东西我想把“AI Agent Redis 缓存”这个组合拆开聊一聊从缓存治理、并发控制、分布式锁到序列化和部署排障把我实际踩过的坑和验证过的方案一次说清楚。如果你正在做一个要上线的 Agent 服务或者准备把 LangChain、Spring AI 之类的项目从 demo 推向生产这篇文章应该能帮你少走不少弯路。1. 为什么 AI Agent 项目离不开 Redis1.1 Agent 高并发压力的真正来源先还原一个真实场景你做了一个客服 Agent用户提问后服务端要完成“理解意图→规划步骤→调用工具→生成回复”这一整条链路。普通 CRUD 接口几十毫秒就能返回而 Agent 每次可能要等 LLM 生成 2 到 5 秒工具调用还要额外消耗网络 IO。如果 100 个用户同时问“今天天气怎么样”不做任何缓存就是 100 次完整的 LLM 调用。这带来的问题不只是慢还有钱。模型按 token 计费同样的一个问题被重复算 100 遍等于把预算丢进了碎纸机。所以 Agent 扛并发核心思路不是让大模型去扛而是把“重复计算”这件事在到达 LLM 之前就拦截掉。谁来拦截就是 Redis。有人会问用本地内存缓存不行吗单实例部署当然可以但 Agent 服务一旦横向扩展成多实例本地内存彼此不互通。用户第一次请求落在 A 实例第二次请求被负载均衡转发到 B 实例缓存就丢了。Redis 作为独立中间件正好是所有实例共享的“公共内存”。1.2 Redis 在 Agent 架构里的三层定位我在实际项目里习惯把 Redis 在 Agent 架构中的角色分成三层每层解决不同的问题定位解决什么常用数据结构典型应用缓存层LLM 调用慢、费用高String、ZSet结果缓存、语义缓存、Prompt 模板缓存、工具结果缓存状态层Agent 运行需要记忆和上下文Hash、String、Stream会话状态、多轮上下文、任务步骤记录并发控制层多实例之间的资源竞争和流量突增String(NX)、List、ZSet分布式锁、任务队列、限流令牌桶缓存层最直观把 LLM 的结果存起来下次直接返回。状态层很容易被忽略但 Agent 不比普通接口它是有“记忆”的。同一个 session 的多轮对话上下文要能跨请求读取一个复杂任务拆成多个步骤执行到哪一步也要记下来。第三层并发控制解决的是“多个 Agent 实例同时操作同一个资源”的问题重复任务要去重、热点缓存要防止击穿、用户请求要限流。这三层加起来才是 Redis 在 Agent 项目里的完整价值。如果只拿它当 token 仓库确实浪费。2. 缓存治理先把 LLM 调用的开销打下来2.1 先做准确缓存再做语义缓存缓存治理是 AI Agent 项目里最值得投入的部分因为效果立竿见影。第一步是准确缓存相同的 prompt 直接命中缓存不经过 LLM。这里的关键是缓存 key 的设计。我一般不建议直接把用户原始文本当 key因为用户可能只是多打了一个空格key 就变了。最好先对 prompt 做归一化去掉首尾空白、统一标点然后取哈希。import hashlib import redis r redis.Redis.from_url(redis://localhost:6379/0) def get_llm_response(prompt: str) - str: normalized .join(prompt.strip().split()) cache_key cache:llm: hashlib.sha256(normalized.encode()).hexdigest() cached r.get(cache_key) if cached: return cached.decode(utf-8) result call_llm(normalized) r.setex(cache_key, 300, result) # 5 分钟过期 return result上面这段是最基础的准确缓存。实际项目中准确缓存能拦下多少请求取决于用户的问法是否一致。但我很快发现一个问题用户换一种说法问同一件事准确缓存就失效了。比如“今天上海天气怎么样”和“上海今天天气如何”字面不一样含义一样。这就需要用语义缓存。语义缓存的思路是新请求进来先算它的向量表示embedding然后在已有缓存里查找相似度超过阈值的记录。如果相似度足够高直接返回缓存结果不用再调 LLM。我这里给一个简化实现思路import numpy as np def semantic_cache_get(query_embedding: list[float]) - str | None: # 从 Redis 里取回所有候选记录每条记录包含 query_embedding 和 result candidates get_all_cache_candidates() best_score 0 best_result None for item in candidates: score cosine_similarity(query_embedding, item[embedding]) if score best_score: best_score score best_result item[result] if best_score 0.92: return best_result return None生产环境如果缓存量很大不建议全量遍历算余弦相似度一般会引入向量检索能力Redis 的 Search 模块也支持部分向量检索或者把相似度计算放到外部向量库。当数据量不大时上面的简化方案完全够用至少能让你快速理解语义缓存的原理。提示语义缓存的阈值很关键。阈值设得太高命中率低设得太低机器返回的答案和真实答案差了十万八千里用户会觉得你在敷衍。我一般是先在测试集上跑一遍 PK找一个准确率能接受的平衡点上线后再根据日志微调。2.2 TTL 与缓存失效里的真实教训缓存设了过期时间不等于万事大吉。Agent 场景里最典型的问题是工具数据变了旧缓存还在。比如你有一个查天气的工具用户 5 分钟前问过一次缓存了结果5 分钟后天气变了如果直接命中旧缓存用户看到的还是之前的天气。这不是缓存过期没到的问题而是“逻辑失效”。我的经验是两条腿走路一是主动失效当工具执行结果产生更新时主动删除相关缓存 key二是版本号兜底在缓存 key 里塞一个业务版本号知识库或工具逻辑升级时直接改版本号旧 key 自然失去意义。VERSION v3 def cache_key_for(prompt: str) - str: digest hashlib.sha256(prompt.encode()).hexdigest() return fcache:llm:{VERSION}:{digest}另一个容易踩的坑是同一时刻大量 key 一起过期。比如系统里统一给所有缓存设 300 秒 TTL定时任务一跑缓存大面积失效所有请求瞬间全部打到 LLM 上去造成“雪崩”。解决办法很简单过期时间加随机抖动。ttl 300 random.randint(0, 60)让 key 的过期时间错开。这也符合 Redis 官方社区里一直提倡的实践。2.3 穿透、击穿、雪崩Agent 场景下的三种异常Redis 缓存的三个经典异常在 AI Agent 场景里表现不太一样缓存穿透。在普通业务中key 是有限的商品 ID、用户 ID。但在 Agent 场景中用户输入几乎无限。恶意用户只要稍微变换问法就能绕过准确缓存每次都打到 LLM 上。语义缓存能缓解一部分但语义缓存本身也有计算成本。更实用的兜底方案是空结果缓存LLM 没给出可用答案时也把它缓存起来短 TTL 比如 60 秒避免同类问题反复触发 LLM 调用。缓存击穿。某个热点 prompt比如新品发布会直播间里大量用户同时问“这个产品多少钱”。这个 key 的缓存一旦过期瞬间可能有几百个请求同时发现没命中然后一起调 LLM。这时要用互斥重建只允许一个请求去调 LLM其他请求先等待等缓存写回后再读。互斥重建依赖分布式锁具体做法留在第三章细说。缓存雪崩。大量 key 同时失效上面已经说过用随机 TTL 解决同时要注意别把所有业务缓存都放到同一个 Redis 实例有条件的按业务拆库、拆集群。问题Agent 场景下的表现核心对策穿透恶意用户变换问法绕过缓存直接打 LLM空结果缓存、语义缓存、布隆过滤器击穿热点 prompt 缓存过期瞬间大量并发调 LLM分布式锁 互斥重建雪崩大量缓存 key 同时过期请求涌向 LLMTTL 随机抖动、业务拆分3. 实战用 Redis 给 Agent 扛并发3.1 会话上下文别一股脑塞 StringAgent 是有状态的这一点和传统接口差异很大。多轮对话中用户上一句问了什么、Agent 上一轮给出了什么工具结果下一轮都要能接上。很多初学者图省事把整个上下文 JSON 序列化后直接存 Redis String。我见过线上出问题的一个长会话的 JSON 达到几百 KB每次读取都要整个反序列化Redis 吞吐直接被打低。更合理的做法是用 Hash 拆维度。一个 session 对应一个 Hash 结构字段存用户 ID、会话开始时间、最后活跃时间、历史消息引用、token 用量等元信息。历史消息这种大头不要全塞 Redis可以放到对象存储或数据库里Redis 里只保留一个摘要指针真正的长文本按需加载。session_key session:user:10086 r.hset(session_key, mapping{ user_id: 10086, agent_type: customer_service, history_ref: history:user:10086:20250115, message_count: 12, token_used: 3042, }) r.expire(session_key, 1800) # 30 分钟不活跃自动清理这种设计的优势很明显单个字段读写非常轻不涉及整个大对象的序列化。更重要的是Hash 结构天然适合做 TTL 管理——整个 session 过期时间和单个字段的更新互不干扰。我在项目里还加了滑动续期逻辑用户每次发消息就刷新一下 expire这样活跃用户的会话不会因为 30 分钟固定 TTL 而意外中断。3.2 任务队列与分布式锁解决重复干活Agent 服务多实例部署后有一个问题非常隐蔽同一个定时任务或者用户重试请求可能被两个实例同时处理。比如一个生成周报的 Agent定时任务触发后实例 A 和实例 B 同时读到了待处理任务各自跑了一遍用户收到两份周报。要避免这种情况核心是两个组件任务队列和分布式锁。任务队列用 Redis List 实现生产者把任务 IDLPUSH到队列消费者用BRPOP阻塞读取。BRPOP 的好处是阻塞期间不占用太多连接资源任务一进来就能立刻被消费。每个 worker 拿到任务后先尝试获取分布式锁抢到锁的才真正执行抢不到的跳过。分布式锁的经典实现是SET lock:task:10086 req_id NX PX 30000NX 表示只有 key 不存在时才能设置成功PX 设置 30 秒过期。释放锁时不能简单 DEL因为可能锁已经过期被别的线程拿到了你再去 DEL 就会误删别人的锁。正确做法是 Lua 脚本原子比较后删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endARGV[1] 存的是每个线程自己的唯一标识比如request_id或uuid。这样能保证只有持有锁的线程才能释放自己的锁。我在实际项目里还遇到过锁过期时间设置太短导致任务没跑完锁就没了的情况这时候别的实例会再抢到锁重复执行。建议锁的过期时间设置成任务预估耗时的 23 倍或者做锁续期但续期逻辑复杂能不加尽量不加优先把任务拆小让单次加锁时间可控。3.3 限流给账单和系统上双保险AI Agent 的限流目的有两个。第一是保护下游 LLM 服务供应商对 RPM每分钟请求数和 TPM每分钟 token 数都有硬限制超了直接报错。第二是保护自己的成本无限放量等于无限烧钱。最简单的固定窗口限流用 INCR EXPIRE 就能实现def rate_limit(user_id: str, limit: int 10) - bool: window int(time.time() // 60) key frate:user:{user_id}:{window} count r.incr(key) if count 1: r.expire(key, 60) return count limit固定窗口的缺点是窗口切换瞬间可能涌进两倍流量比如 59 秒时来了 10 个请求第 60 秒窗口重置后又来 10 个。要求严苛的场景可以用滑动窗口用一个 ZSet 存储每个请求的时间戳查询时先移除窗口外的时间戳再统计窗口内的数量。def rate_limit_sliding(user_id: str, limit: int 10, window_sec: int 60) - bool: key frate:sliding:{user_id} now time.time() pipe r.pipeline() pipe.zremrangebyscore(key, 0, now - window_sec) pipe.zadd(key, {str(now): now}) pipe.zcard(key) pipe.expire(key, window_sec) _, _, count, _ pipe.execute() return count limit除了按用户限流还要做全局并发限制。比如给某个模型分配的最大并发请求数是 20超出的请求直接排队或返回提示。Redis 的计数器在全局并发控制里很好使用一个 key 做并发计数请求进来先 INCR检查不超过上限就放行结束后 DECR。所有实例共享这一个计数器比本地计数可靠得多。4. 部署、序列化与超时排查的实战细节4.1 序列化选型不要用 pickle 存线上数据这是我在生产环境里踩过的一个很深的坑。redis-py 默认的序列化器是 pickle开发环境下很方便但上线后立刻暴露两个问题第一pickle 有反序列化安全风险如果 Redis 里的数据被恶意篡改反序列化可能执行任意代码第二pickle 序列化出来的数据只有 Python 能读团队里如果有人用 Java 或 Go 写的服务也要读同一批缓存直接无从下手。我的建议是统一用 JSON 序列化跨语言、安全、可调试。别小看“可调试”这一点在 Redis 客户端里能看到明文缓存内容排查问题效率高很多。如果担心中文存储空间和传输带宽可以在 JSON 基础上加压缩层或者用 msgpack 这种二进制格式。import json class JsonSerializer: def dumps(self, obj): return json.dumps(obj).encode(utf-8) def loads(self, data): return json.loads(data.decode(utf-8))对于 LLM 返回的超长文本我建议在序列化前做一次压缩。文本数据的压缩比非常可观经常能压到原来的 30%。代价只是一点点 CPU比让 Redis 内存翻倍划算得多。另外类对象升级导致的旧缓存反序列化失败是我换了 JSON 之后才彻底解决的——JSON 反序列化时多字段不是致命的而 pickle 会因为找不到原类直接炸掉。4.2 Docker 搭建 Redis 主从的最小实践Agent 服务上线后Redis 要做到高可用不能单点裸奔。我的实际方案是先做一主一从条件允许再加哨兵。用 Docker 搭建主从入门成本很低# 主节点 docker run -d --name redis-master -p 6379:6379 redis:7-alpine redis-server --appendonly yes # 从节点 docker run -d --name redis-slave -p 6380:6379 redis:7-alpine redis-server --slaveof 127.0.0.1 6379注意上面这种启动方式只适合同一台机器的本地验证。如果从节点容器和主节点容器不在同一个网络命名空间127.0.0.1指向的是容器自己连接会失败。生产环境建议用 Docker Compose 或直接给容器指定 DNS 名称让从节点通过主节点容器名访问。# docker-compose.yml 片段 redis-master: image: redis:7-alpine command: redis-server --appendonly yes redis-slave: image: redis:7-alpine command: redis-server --slaveof redis-master 6379 depends_on: - redis-master主从建好之后用redis-cli info replication查看角色信息role:master或role:slave再确认从节点有个slave_repl_offset如果两个节点的 offset 能追上说明主从同步是正常的。再多说一句哨兵。哨兵的作用是主节点挂了自动把从节点提升为主节点客户端不用手动切换。生产环境最少需要 3 个哨兵实例因为要避免误判需要大多数哨兵同意主节点不可用才会触发切换。用 Docker 部署 Sentinel 时几个容器要在同一网络里并且配置要指向真实的主节点地址。哨兵本身不算复杂但很多人第一次配置会被各种细节卡住我的建议是先用一主一从跑顺再叠加哨兵别一口吃成胖子。4.3 连接超时排查RedisCommandTimeoutException 怎么办Spring AI 项目里用 Lettuce 作为 Redis 客户端时线上最容易遇到的问题就是RedisCommandTimeoutException。一开始我看到这个报错也头大后来总结了一套固定排查链路。常见的触发原因有这么几个连接池被耗尽请求在池里排队超时Redis 实例上有慢命令比如KEYS *、超大 value 的读取把后面的命令全部阻塞网络链路抖动客户端到 Redis 服务器之间的延迟突然升高Redis 实例内存打满频繁淘汰 key导致命令响应变慢。排查顺序我建议从轻到重redis-cli --latency看客户端到服务器的实际延迟持续观察几十秒看有没有明显波动。redis-cli info clients看当前连接数和blocked_clients判断是不是连接泄漏。redis-cli slowlog get 20看慢命令尤其是 O(N) 级别的操作比如KEYS *、大范围ZRANGE。如果以上都正常再看程序端的连接池参数。Lettuce 在 Spring Boot 里的连接池默认值并不激进并发稍微上来就容易排队。以 Spring Boot 3 为例参数前缀是spring.data.redis.lettuce.pool可以参考下面的调整spring.data.redis.lettuce.pool.max-active64 spring.data.redis.lettuce.pool.max-idle32 spring.data.redis.lettuce.pool.min-idle8 spring.data.redis.timeout2sPython 端用 redis-py 时连接池和超时参数同理pool redis.ConnectionPool( hostlocalhost, port6379, max_connections128, socket_timeout2, socket_connect_timeout2, ) r redis.Redis(connection_poolpool)socket_timeout一定不能省。不设超时的话如果 Redis 真的一段时间内无响应你的服务线程会全部挂在那里等看起来像“死锁”其实只是超时没设。我遇到过几次线上事故最后都是这个参数补上之后才恢复。5. 常见问题速查与排障技巧实录5.1 缓存一致性Agent 场景下的取舍说到缓存就会扯出一致性问题缓存里的数据和“真实数据”不一致了怎么办在 Agent 场景里我的观点是不要太追求强一致。LLM 生成结果本身就不是一个强一致的东西同一个问题两次回答可能内容不完全相同用户也默认接受这种随机性。所以缓存一致性做到“最终一致”完全够用。实际操作上我采用 Cache Aside 模式读的时候先查缓存没命中再走业务逻辑然后写回缓存更新的时候先更新数据库或工具数据再删除相关缓存。为什么是删除而不是更新因为更新缓存要单独写一套逻辑而删除之后下次读取自然会把新结果写回去简单可靠。如果担心删除失败导致旧缓存长期存在可以加一个短 TTL 兜底比如 5 分钟即使删缓存失败最多也就多脏 5 分钟。知识库场景还要多考虑一步文档更新了旧文档相关的语义缓存也要失效。我用的方案是在语义缓存里带上知识库版本号版本号变了查询语义缓存的 key 就变旧缓存自然无人问津。相当于一次逻辑上的“批量失效”。5.2 Key 命名、慢查询和内存治理Redis 用久了就会发现规范比优化更重要。key 命名不统一排查问题时想死的心都有。我一般用这种格式业务:模块:对象:ID:字段比如agent:session:user:10086:history_count一眼能看清这个 key 属于什么业务、什么对象。线上问题定位时可以直接用前缀SCAN MATCH agent:session:*来分批遍历千万别用KEYS *阻塞整个 Redis 就是一瞬间的事。慢查询治理要盯住两个方向一是大 key单个 String 超过 1 MB 或者 Hash 有海量字段读写都会拖慢 Redis二是命令时间复杂度比如SORT、ZREMRANGEBYRANK这类命令在大集合上很耗时。建议每隔一段时间跑一次SLOWLOG GET把慢命令收集成台账逐个优化。内存淘汰策略上Agent 场景我建议用volatile-lru只淘汰带 TTL 的 key。为什么不用allkeys-lru因为会话状态和分布式锁这类数据如果被内存淘汰无情清掉用户对话上下文就丢了更高级别的服务一致性就无从谈起。带 TTL 的缓存数据没了问题不大顶多重算一次。5.3 高频问题速查表下面这张表是我几个 Agent 项目里遇到频率最高的 Redis 问题直接整理成速查卡遇到问题可以对着查。现象可能原因落地操作Lettuce 报 command timed out连接池耗尽、慢命令阻塞、网络抖动调大连接池、查 slowlog、检查 maxclients热点缓存过期瞬间 LLM 被打爆缓存击穿加分布式锁做互斥重建同一个任务被两个实例重复执行缺少幂等控制List 队列 SET NX 分布式锁主从切换后数据不一致哨兵配置不完整或同步延迟检查info replication确认 repl_offset 追平重启后缓存大量“丢失”RDB/AOF 持久化策略不当开启 AOF配置appendfsync everysec内存涨得很快key 无 TTL、大 key 过多统一设置 TTL用redis-cli --bigkeys扫描大 key 并拆分缓存删除后立刻被旧数据覆盖并发读写的竞态使用版本号或写操作加锁读后写回前二次判断最后补一个我的个人检查习惯。每次 Agent 版本发布前我会把线上 Redis 的INFO memory、INFO stats和SLOWLOG GET拉出来过一遍重点看有没有expired_keys暴增、evicted_keys上涨、慢命令积累。这套动作看起来不起眼但救过我很多次。缓存治理这件事没有一劳永逸它更像一个持续调优的过程随着 Agent 业务不断变化你会反复遇到新的缓存问题。但只要底层思路对了Redis 这套工具用顺手了再复杂的问题也能拆成“结构选型”“TTL 策略”“并发控制”这几件事来解。