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

资讯详情

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

Redis 扛住 AI Agent 高并发:缓存设计、分布式锁与限流实战

Redis 扛住 AI Agent 高并发:缓存设计、分布式锁与限流实战 AI Agent 上线后最容易翻车的地方在哪不是模型选型不是 Prompt 写得不够好而是并发一上来系统先撑不住了。一个 Agent 任务往往要经历“用户输入 → 大模型推理 → 工具调用 → 再把结果喂给模型 → 继续推理”这样好几轮循环单次请求可能触发三到五次 LLM 调用每次耗时三五秒起步。如果前端一个页面同时来几百个请求后端如果没有缓存层顶着效果就是你看着日志疯狂刷超时用户疯狂刷“怎么这么慢”。这篇博文就围绕我用 Redis 给 AI Agent 扛并发的实践经验把缓存设计、数据类型选型、序列化方式、分布式锁落地这些核心细节全部过一遍。我把这套方案实际用在一个基于 LangGraph 搭建的 Agent 服务上承接了会话上下文管理、LLM 响应缓存、工具调用结果缓存、限流和幂等等多个核心场景。文章不会只讲概念而是把每个场景的 key 设计、TTL 策略、踩过的坑、排查手段都写出来适合正在做 AI Agent 项目、尤其是接入真实业务并开始面对并发压力的朋友参考。1. 先说清楚AI Agent 的并发问题到底出在哪1.1 一条 Agent 请求背后藏着多少次串行依赖很多人以为 AI Agent 就是一个带上下文的大模型 API 调用并发不行就加机器。但真实业务里的 Agent 通常长这样用户说一句话Agent 的编排器先把这句话交给 LLM 判断意图LLM 说“需要查天气”于是 Agent 调用天气 API拿到天气数据后又一次调用 LLM 让模型对结果做总结和回复。如果用户的问题需要多个工具配合比如查天气再查机票再算价格每一轮工具调用后都要让 LLM 看一遍中间结果那么一次任务可能产生 4 到 6 次 LLM 请求。每次 LLM 请求平均耗时 2 到 5 秒每次工具外部调用耗时 0.5 到 2 秒整条链路串行下来单请求时长基本在 10 秒以上。这种情况下系统的吞吐瓶颈根本不在 LLM 并发额度而在每个环节能不能复用中间结果。我接手一个项目时用户在同一会话里多次问同一个问题系统都把整条链路重走一遍既费钱又费时间。这时候缓存的意义就很直白凡是重复的计算哪怕能省掉一轮 LLM 调用都是巨大的提升。1.2 为什么选 Redis 而不是进程内缓存或数据库给 Agent 做缓存方案无非三种进程内本地缓存、Redis、数据库。我的选择是 Redis 为主、本地缓存为辅。先说为什么不纯用本地缓存Agent 服务通常不是单点我在前面挂了 Nginx 做负载均衡请求会落到不同实例上。如果用本地缓存同一个问题在实例 A 上缓存了下次请求被路由到实例 B缓存照样不命中。除非做非常复杂的路由粘滞否则本地缓存的命中率在水平扩展后就变得不可控。再说为什么不用数据库做缓存Agent 会话上下文和工具结果往往需要毫秒级读取尤其是会话上下文每次 LLM 调用前都要把最近几轮消息组装进 Prompt这个过程如果有 20 毫秒以上的查询延迟整条链路的耗时就很明显。Redis 把数据放在内存里读写都在亚毫秒到几毫秒之间API 简单还自带过期时间机制天然适合做 TTL 型缓存。加上它有丰富的数据结构后面说到会话管理、限流、锁的时候都会用到一套组件能解决多个问题依赖面更小。这里也提醒一句不要因为 Redis 性能好就把所有东西都往里塞。Redis 的本质是缓存和中间件不是强一致数据库。对 Agent 场景来说会话状态丢了会很难受但可以用“落数据库 Redis 加速”的组合模式先用 Redis 扛读流量异步把状态刷到 MongoDB 或 MySQL 里兜底。提示我这句话放在最前面说是因为后续所有设计都围绕一个原则——Redis 负责扛热数据和高频读写数据库负责持久化和最终一致两者各司其职。2. Redis 在 Agent 场景里的四大核心用途2.1 会话上下文存储用 Hash 还是 String 别拍脑袋Agent 最基础的需求是多轮会话。用户每次提问系统都要把之前几轮对话记录拼进上下文否则 Agent“失忆”。这个读写频率极高且数据规模适中一般一个会话几 KB 到几十 KB非常适合放 Redis。我见过两种主流做法。一种是直接用 String 存整个会话对象对一条 key 做整体读写类似session:20250101:uuid存一个大的 JSON 串另一种是用 Hash 把会话拆成多个字段比如将messages、tools、user_profile、status分别存成 Hash 的 field每次只更新需要的字段。我实际更推荐 Hash 方案。原因是 Agent 会话的状态是多样化的不只是消息列表还包括运行的元信息、正在等待哪个工具结果、任务状态等。用 Hash 可以只读取其中某个字段避免把整个几十 KB 的 JSON 反序列化一遍。举个例子Agent 编排器每一步都需要读取status字段来判断当前走到哪一步如果这也要把整个会话消息列表加载出来纯属浪费。而且 Hash 的字段级更新能做到并发修改不同字段时互不干扰用 String 的话并发写同一个 key 很容易互相覆盖。会话上下文的 TTL 设置也很有讲究。我这边默认开到 2 小时。太短的话用户隔一会儿回来对话就断了太长则 Redis 内存压力大。对于付费用户或重要会话我会在每次写入时用EXPIRE顺延过期时间保证活跃会话一直存活不活跃会话自动清理。2.2 LLM 响应缓存最直接的成本和耗时杀手LLM 调用是 Agent 链路里最贵、最慢的一环所以对它做缓存收益最大。所谓 LLM 响应缓存就是相同或相似的问题在短时间内再次进来时不再真的调用模型而是直接把上一次的结果返回。我的缓存 key 设计是llm:cache:{model}:{prompt_hash}其中 prompt 是把系统提示词、用户消息、工具返回结果按顺序拼接后的文本然后取哈希值。这里有几个细节值得说。第一prompt 必须做规范化处理比如去掉无意义的空格、统一大小写否则两个完全一样的问题会因为微小差异导致哈希值不同缓存形同虚设第二要对工具结果是否参与哈希做策略控制因为天气这种动态数据如果参与哈希每次结果都不同缓存反而没有意义但如果完全不参与模型输出的内容又会因为输入变化而产生矛盾。TTL 方面我按内容类型做差异化事实类问答缓存 1 小时代码生成缓存 30 分钟创意文案缓存 10 分钟。另外建议对 LLM 响应缓存设置一个“最低缓存时间”哪怕数据马上过期也要保证短时间内的大量重复请求能命中——实践中很多瞬时并发恰恰是几十秒内同一批请求涌进来。注意LLM 响应缓存有一个风险叫“幻觉固化”模型第一次输出可能是错的但缓存后所有人都会拿到这个错答案。为了解决这个问题我会在系统里对存在争议的高风险输出涉及金额、健康建议等直接不缓存并在缓存命中时给响应打一个sourcecache的标记方便排查。2.3 工具调用结果缓存降低外部依赖带来的不确定性Agent 必然要调外部工具比如天气、股票、汇率、知识库检索。这类外部服务有两个问题一是延迟波动大二是调用配额有限。工具结果缓存的意义就是把“外部不可控”变成“内部可控”。这里的关键是 TTL 设计要跟业务数据的时效性匹配。我从上一个项目里抽了一张自己整理的工具缓存 TTL 参考表天气数据缓存 30 分钟股票行情 30 秒到 1 分钟汇率 1 分钟知识库检索结果 1 到 24 小时取决于文档更新频率搜索类结果 5 到 10 分钟。每个工具内部都可以配置自己的缓存开关和 TTL不要在代码里写死。工具结果缓存还需要考虑参数维度。比如两个用户问“上海的天气”和“北京的天气”虽然调用同一个工具但参数不同缓存 key 必须包含参数维度。我是用一个自定义的工具调用指纹来生成 keytool:cache:{tool_name}:{arg_hash}把参数序列化后取哈希。arg_hash 的参数要按字典序排序避免参数顺序不同导致缓存不命中。2.4 限流控制把并发高峰挡在 Redis 前面Agent 项目做高并发缓存只能解决重复请求问题对真正的新请求还是要做限流。我在网关层用 Redis 实现了两种限流方式固定窗口和滑动窗口。固定窗口非常简单用INCR key如果当前计数超过阈值则拒绝然后设置过期时间。滑动窗口则用 ZSet 实现把每个请求的时间戳加入zset清理掉窗口外的旧记录然后看窗口内总数是否超限。为什么推荐滑动窗口Agent 的请求通常计算量大固定窗口在窗口边界会有“流量突刺”问题——比如一分钟限 100 次第 59 秒来了 100 个第 61 秒又能进来 100 个瞬时压力就double了。滑动窗口按秒级粒度统计能把这股压力削平。限流不只是保护 Redis 本身更重要的是保护背后的 LLM API 额度。我按用户维度、接口维度、会话维度三层做了限流单独一层都有独立的 key 前缀。比如接口维度是全局限流防止某个时间段内所有用户的合并请求打爆 LLM 通道用户维度是每人每分钟最多 N 次调用防止单用户刷接口。3. 实操环节序列化、连接池与数据结构的正确用法3.1 序列化方式选型直接决定性能和排查难度Redis 本身只认字节存什么类型的数据取决于客户端序列化方式。我在 Agent 项目里遇到过因为序列化选型不当引发的线上事故有人直接用 Python 的pickle序列化对象丢进 Redis结果一次升级后客户端无法反序列化旧数据所有会话全部失效。还有人用 Java 的 JDK 原生序列化结果 Redis Desktop Manager 上看过去全是乱码排查起来十分痛苦。我的建议是跨语言场景一律用 JSON追求极致性能时用 MessagePack 或 Protobuf但不要用任何语言专属的 pickle 或 Java 原生序列化。JSON 的好处是调试方便redis-cli 里直接能读懂出了问题可以直接改。缺点是比较占空间对大字段场景可以加一层压缩Gzip 压到原来的十分之一很常见。MessagePack 性能好、体积小但调试困难我通常只在数据量特别大的工具缓存里用它。下面是序列化处理的实际代码思路import json import hashlib import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def set_llm_cache(model: str, prompt: str, response: str, ttl: int 3600): prompt_hash hashlib.sha256(prompt.encode(utf-8)).hexdigest() key fllm:cache:{model}:{prompt_hash} # 用 JSON 格式化响应方便后续扩容和排查 value json.dumps({ response: response, created_at: int(time.time()), model: model }, ensure_asciiFalse) r.set(key, value, exttl)提示decode_responsesTrue这个参数值得专门提一下。如果不设置从 Redis 读出来的字符串会自动变成字节对象后面 JSON 解码都要额外转一次很容易写出隐晦的 bug。打开这个参数能省掉一堆不必要的转换。3.2 连接池参数设置不当Redis 再快也扛不住Redis 本身很快但如果客户端连接池没配好高并发下大量线程会卡在“获取连接”这一步。我这边的经验值给一个参考用redis-py时连接池的max_connections设为预计并发峰值乘以 1.5 到 2 倍每个连接的读超时设置为 3 到 5 秒连接超时 2 秒。Lettuce 用户更要注意一个问题网上大量帖子反馈Redis command timed out异常这基本就是连接池耗尽或单条命令执行超过commandTimeout造成的。我遇到的案例是某个大 Key 存储了用户完整的历史会话这段数据有 2 MB 以上每次读取都要反序列化一整串导致命令超时。把 2 MB 大 Key 改成 Hash 分片存储后超时问题直接消失。所以排查超时第一个任务就是用redis-cli --bigkeys看看是不是存在大 Key。连接池还得配合监控。我给项目加了一个定时任务每 30 秒Redis INFO拉一次连接数、内存、命中率超过阈值就告警。这比等到用户投诉再排查要高效得多。3.3 五种 Redis 数据类型在 Agent 场景的选型对照我梳理了一下 Agent 项目里最常用到的数据类型按使用频率排个序并且标注了它们适合和不适合的场景。String 用来存 LLM 缓存、工具结果、和单值配置Hash 用来存会话上下文、用户画像、Agent 运行元信息List 用来做任务队列比如把待处理的异步任务推进队列ZSet 用来做滑动窗口限流和优先级任务队列Set 用来做去重和标签集合。为什么 Hash 适合会话上下文上面已经说过了。这里补充一个 List 的应用如果 Agent 任务的后半段是异步执行的比如生成报告后通知用户我可以把任务 ID 推进 Redis List后台 worker 从 List 里用BRPOP阻塞式取任务天然就实现了简易消息队列。避免引入 RabbitMQ 或 Kafka 给项目增加不必要的基础设施。我用表格给你总结一下各种数据结构和推荐场景的匹配关系数据结构Agent 场景不适合场景StringLLM 响应缓存、工具结果、配置项、验证码频繁改某个字段的复杂对象Hash会话上下文、用户多属性信息需要统一过期的大对象List消息队列、历史消息流随机访问中间元素Set白名单、已读记录、标签去重保持顺序要求的场景ZSet限流窗口、任务优先级、排行榜频繁大范围更新的场景4. 缓存三大灾难穿透、击穿、雪崩在 Agent 项目里的表现4.1 缓存穿透Agent 内部检索和外部参数导致的空回源缓存穿透指的是请求的 key 在 Redis 中不存在在数据库里也不存在导致每一次请求都打到数据库或外部服务。在 Agent 场景里最常见的穿透场景是用户 ID 或会话 ID 非法以及知识库检索的问题在系统里根本不存在匹配结果。处理穿透有一个很经典的方法就是缓存空值。用户问了一个知识库里没有的问题我把这个问题的哈希值和“空结果”一起缓存 5 分钟期间再有相同问题直接返回空不再触发检索。还有一个更高级的方案是布隆过滤器把合法的 ID 列表或问题特征映射到布隆过滤器里请求进来先过过滤器不存在的直接拦掉。布隆过滤器有一个优点是内存占用极小几百万条数据几个 MB 就够缺点是有误判率且无法删除元素。所以我的做法是两种结合布隆过滤器拦截大部分非法 key少量漏网的再用缓存空值兜底。4.2 缓存击穿热点 Agent 任务同时过期后的重建风暴击穿场景很好描述某个热门的 Agent 服务配置、某个爆款知识库文档被大量用户同时并发访问。假设这个热点 key 的缓存刚好在某个时间点过期了于是几百个请求同时回源去查数据库或重新让 LLM 生成下游直接被打垮。解决击穿的核心是互斥重建。当缓存失效时只允许一个请求去回源重建数据其他请求要么等待结果要么先用旧值降级。我用的方法是在缓存 key 之外再加一个分布式锁 key当缓存 miss 时先尝试获取锁拿到了才允许回源拿不到锁的请求等待 100 毫秒后再次尝试读缓存。这就是典型的“缓存 锁”双层保护。对于 Agent 项目这种互斥重建还有一个额外好处防止多个请求同时触发 LLM 生成导致计费翻倍。同一个热点问题同时有 100 个人问如果全部回源调 LLM等于一次烧掉 100 次调用费用。加锁后只有第一个请求真正让模型生成其余 99 个请求复用结果这就是我前面说缓存“扛并发”最直接的经济学账。4.3 缓存雪崩TTL 同时失效导致的全线血崩雪崩是大量 key 在同一时间段集体失效导致请求全部回源打爆数据库。在 Agent 项目里最典型的是“整点任务”我的系统中有一个每小时清理一次会话的超时任务同时会话的 TTL 都设置成 2 小时整导致大量会话在同一时间点集体失效。再加上整点 LLM 调用配额刷新后集中涌来的请求回源压力叠加服务就抖起来了。解决雪崩有不复杂的办法给 TTL 加随机扰动。比如会话 TTL 基础值设为 7200 秒然后加一个 0 到 600 秒的随机偏移。这样原本集中过期的 key 就被打散到 10 分钟的时间窗口里回源压力自然被削平。这个操作成本几乎为零但收益立竿见影。另外我还做了一个主动刷新机制后台有个 Job 周期性扫描即将过期的热点 key如果发现 key 还有较高的访问频率就提前把缓存数据刷新一遍而不是等它过期后再重建。这相当于把“被动过期重建”变成了“主动预热”对核心 Agent 会话的事件非常实用。注意给 TTL 加随机扰动的时候不要搞得太随意。建议用一个固定的偏移算法保证同样类型的 Key 在时间上错开但不会错得太远。我惯用的做法是random.randint(0, 300) base_ttl简单并且有效。5. 分布式锁实战为什么 Agent 的幂等性和锁必不可少5.1 SET NX EX 和 Redisson 看门狗的取舍分布式锁在 Agent 项目里的应用场景我总结了三个最常碰到的。第一个是上面说的缓存击穿重建锁第二个是工具调用的幂等控制第三个是防止同一个 Agent 任务被重复执行。先说最基础的锁写入方式。在 Redis 客户端层面正确且原子的写法是def acquire_lock(redis_client, lock_key: str, request_id: str, expire: int 10): # SET NX EX 是原子操作NX 保证不存在才写入EX 保证锁有自动过期时间 return redis_client.set(lock_key, request_id, nxTrue, exexpire) def release_lock(redis_client, lock_key: str, request_id: str): # 这里用 Lua 脚本保证“校验值一致才删除”的原子性 script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end return redis_client.eval(script, 1, lock_key, request_id)这里有几个关键点不展开说容易踩坑。为什么删除锁的时候要用 Lua 脚本因为常规方式是先get再del这个组合不是一个原子操作如果加了锁的线程刚好卡顿超过过期时间另一个线程抢到了锁原线程再去del就会把别人的锁误删掉。加上 value 校验再删除就能保证只有持有者本人能删除自己的锁。为什么会超时Redis 端到端的网络延迟极端情况可能超过锁的过期时间所以锁的过期时间不能设置得太短一般建议是任务预估最大耗时的 3 到 5 倍。关于 Redisson 的看门狗机制Java 生态中它确实好用自动续期能避免任务没执行完锁就过期的问题。但 Python 生态里没有官方对应的看门狗实现所以我会在写锁后起一个轻微的后台线程每 3 秒检查一次锁是否还在自己手里如果是就续期到 10 秒。这个方案比 Redisson 更朴素但足够可靠。5.2 工具调用的雨伞不要让同一个动作发生两次在真实 Agent 项目里工具调用的幂等问题特别需要关注。比如一个 Agent 被设定为自动下单如果上游因为网络重试导致同一条指令被发送两次那用户就会被系统扣两次款。这时候锁的意义不完全是性能而是业务安全。我的做法是给每次工具执行一个全局唯一的execution_id在真正调用外部服务之前先尝试加锁如果SET execution_lock:{execution_id} NX EX 30成功说明是第一次执行正常调用工具如果失败说明这条指令可能已经被执行过直接丢弃或者走人工确认流程。这里 execute_id 是一次 Agent 运行过程中生成的任务标识一个任务只允许对应一次真实的外部副作用。这个模式同样适用于异步任务队列的场景。集群里多个 worker 同时从 Redis List 里取任务取的时候可能多个 worker 拿到同一条消息。用锁把整个“取任务 → 处理 → 标记完成”的过程包起来能保证同一任务只有一个 worker 真正执行。6. 线上排查实录Redis 超时、缓存不一致、热 Key 打爆单节点6.1 command timed out 的三种常见原因和排查顺序我把线上遇到过的超时问题做了个分类方便你遇到相似问题时按顺序排查。第一种是客户端连接池耗尽表现是大量线程阻塞在获取连接这一层日志里全是等待连接超时。排查方法比较简单看 Redis 客户端的 active connections 监控指标如果长期接近 max_connections 就是池子太小可以调大池子上限或优化代码减少连接占用时间。第二种是 Redis 命令执行慢最常见的原因是单条命令处理的数据量过大。上面说的 2MB 会话对象就属于这一类GET一个 2 MB 的 String对 Redis 来说本身就是一次不小的内存复制操作。用SLOWLOG GET 10命令可以直接看到哪些命令执行时间超标。第三种是网络问题比如跨机房调用、DNS 或者防火墙丢包。这种情况特征不明显需要靠延时监控曲线对比。我这边整理了一个排查顺序表现象优先排查项工具/命令命令执行超时大 Key、慢命令redis-cli --bigkeys、SLOWLOG GET连接获取超时连接池耗尽客户端连接数监控整体响应变慢内存淘汰、命中率下降INFO stats、INFO memory突发性超时热 Key 或网络抖动监控曲线、redis-cli -c逐节点检查6.2 缓存和数据库不一致Agent 改配置后的那一刻最危险缓存一致性是缓存系统永远绕不开的话题。Agent 项目里最典型的情况是上线了一个新工具或者修改了某个系统 Prompt 的行为但 LLM 响应缓存还留在 Redis 里结果用户拿到的是旧版本的答案。这个问题我们遇到过不止一次尤其是模型提示词调整后忘了清理缓存导致线上表现和预期完全不一致。我的方案是版本号强制失效。给每个 Agent 配置一个config_version每次配置更新就递增这个版本号。缓存 key 生成时把版本拼接进去比如llm:cache:config123:model1:{prompt_hash}。配置一旦更新新版本号会自动让旧 key 不可用不需要手动去 Redis 里删简单可靠。对于重要工具配置还可以在发布流程里主动加一步“清理相关缓存”的自动化命令双保险。另一种不一致是主动更新与惰性过期的冲突。比如知识库更新后旧的工具缓存结果还能被读到。这里我给工具缓存引入了一种“双写模式”更新知识库时同时把受影响文档相关的缓存 key 批量删除再配合 TTL 兜底确保数据最多只短暂不一致。6.3 热 Key 打爆单节点把单点压力摊开才是王道Redis 单节点都有性能上限Agent 场景的热 Key 往往出现在两个地方一个爆款知识库文档的检索结果、一个超高流量会话的上下文。当某个 key 被大量并发访问时单节点的 CPU 和带宽会成为瓶颈。热 Key 有两个常见解法。第一个是本地缓存兜底在应用进程内加一层 LRU 本地缓存热点 key 可以被命中在进程内存完全不触达 Redis。这个方案对知识库结果这类只读数据很合适但对会话上下文这种高频改写数据不适合本地缓存容易出现脏读。第二个是 key 分片冗余把一个热 Key 复制成 N 个带后缀的副 Key读请求随机访问其中一个副本把访问压力从单个 key 分摊到多个 key。我实测过热 key 读 QPS 到 2 万以上后用分片方案把单 key 压力降到了一个很舒服的范围。如果你用的是 Redis 集群版还可以考虑把热点 key 单独手工散列到主节点和多个副本节点上让多个节点分担读流量。这个操作有一定成本但要我说热 key 能到这种量级已经是业务上的好问题值得认真规划。7. 我这一路踩坑后的个人体会7.1 关于“AI Agent 怎么扛并发”这件事的最终认知如果要说一个最核心的体会那就是AI Agent 扛并发的关键不在于把每台机器的处理能力拉到极限而在于尽量让每一次请求都“少干活”。缓存命中率高的时候系统的实际吞吐量可以比缓存没命中时高出好几倍。我这边上线完整 Redis 缓存方案后LLM 调用量下降约四成平均响应时间从 6 秒压到 2 秒左右单实例能承载的请求数明显提升效果非常直观。同时我也想给刚入坑 Agent 方向的朋友一个建议不要一上来就引入特别复杂的缓存基础设施。先做最简单的 String 缓存跑通之后再逐步引入 Hash 会话、分布式锁、限流。每一步都保证线上可回滚比一步到位稳得多。7.2 最后补一个很实用的小技巧运维 Redis 的时候强烈建议给 key 前缀做规范管理。我在项目里统一加前缀session:表示会话llm:cache:表示 LLM 缓存tool:cache:表示工具结果缓存lock:表示分布式锁ratelimit:表示限流计数。这个习惯看似简单实际上排查线上问题时救过我好多次。比如用户投诉响应慢我第一个动作就是redis-cli --scan --pattern lock:*看有没有锁长时间没释放会话丢失问题则先看session:*的数量和 TTL 分布。前缀规范加上合理命名能让 Redis 的可观测性上一个台阶这比任何监控工具都实在。Redis 在 AI Agent 项目里不是配角而是真正撑住并发的骨架。希望这篇文章能帮那些正在 Agent 项目里折腾或者打算把 Agent 接到真实业务里的朋友少踩几个坑。
返回列表