从去年开始,我一直在做AI Agent相关的落地项目。刚把Agent接入线上流量的时候,遇到过一个很典型的状况:用户数一旦上来,接口响应时间从几百毫秒直接飙到好几秒,甚至几十秒。排了半天,发现瓶颈根本不在模型推理,而是每个请求都在重复调用同一个工具、把同样的会话上下文拼了又拼,第三方API配额被疯狂消耗。后来把Redis作为中间件引入Agent架构,才把这些坑逐个填平。
这篇东西就是把这段实践梳理清楚:AI Agent为什么需要Redis、缓存层怎么设计、会话状态和记忆怎么存、分布式锁和限流怎么做、以及那些真正踩过的坑。不管你是还在搭Agent原型,还是已经在做并发治理,应该都能用上。
1. Agent系统的三类性能瓶颈:模型延迟往往不是罪魁祸首
很多人误以为Agent慢是因为大模型推理慢。但实际上,当你把完整的调用链路拉出来看,模型推理只是其中一环。一个典型Agent请求要经过路由、上下文拼装、工具调度、多次LLM调用、最终整合,每一环都可能成为瓶颈。我总结下来,至少有三类瓶颈跟Redis这种缓存中间件直接相关。
1.1 工具调用被重复执行:最隐蔽的资源黑洞
Agent和普通接口最大区别在于,LLM本身是无状态的,它每次面对请求都要重新推理。这意味着同一个查询,在多轮对话里极可能被反复执行。举一个我项目的真实case:用户问"今天北京天气怎么样",Agent第一步要调用天气API拿数据,再组织语言返回。但下一轮用户追问"那明天呢",Agent可能再次调用同一个天气API,甚至因为工具描述不够清晰,连"今天"的数据又查了一遍。
这些重复的工具调用不仅是浪费外部API配额,还会让整体耗时成倍增加。天气接口还相对快,如果Agent经常调用的是一些耗时服务,比如搜索、文档检索、数据库查询,那就更麻烦了。
当时我用Redis做了第一层缓解:把工具名 + 入参摘要作为key,把工具返回结果缓存在Redis里,TTL根据数据时效性来定。天气这种数据十分钟内基本不变,缓存命中后Agent直接拿结果,不会再反复打扰上游服务。这个改动非常小,但效果立竿见影,接口响应直接下降了一个量级。
1.2 会话上下文被反复组装:同样一个长记忆,拼了又拼
Agent每轮对话都要把历史聊天记录取出来,拼成上下文喂给模型。用户会话一长,这个上下文就很占存储和带宽。如果Agent还要带上一些长期记忆,比如用户偏好、历史订单、知识库片段,那就更重了。
常规做法是把会话存数据库,每次请求实时查。但这套逻辑在并发量上来之后会出现明显的瓶颈:同一时间大量用户都在做相似的查询、相似的组装,数据库压力倍增,响应时间也不稳定。Redis在这里的价值是"热会话缓冲层":把最近活跃的会话状态放Redis,访问速度快,再配合TTL自动清理,不活跃的会话慢慢淘汰,数据库只负责持久化兜底。
1.3 上游API配额与限流:外部依赖比想象中脆弱
Agent再强也离不开外部依赖:LLM API、搜索API、支付查询、天气服务,几乎每个都有调用配额或速率限制。一旦并发突增,上游直接开始返回限流错误,Agent拿不到结果,就只能重试,重试又进一步消耗配额,形成恶性循环。
这个点很多人到了生产环境才意识到。我在项目里就碰到过LLM API被限流,整个Agent服务跟着雪崩的事。Redis在这里的主要职责是"限流闸门":每个上游接口都设置一套调用频率限制,同时把工具返回结果缓存住,尽量减少对上游的无效请求。
所以综合来看,Agent不是"要不要Redis"的问题,而是"Redis要承担几个角色"的问题:缓存、状态存储、限流底座、任务队列,四个角色缺一不可。
2. 缓存层怎么设计:不是所有数据都值得塞进Redis
把Redis引入Agent系统,第一个任务就是做缓存设计。但缓存设计绝不是简简单单"把结果set进去,下次get出来"这么粗暴。哪些数据值得缓存、用什么Key、TTL给多长,每一层都要想清楚,否则缓存命中率上不去,还会处理一大堆一致性问题。
2.1 值得缓存的四类数据
根据我的实践经验,Agent系统里有四类数据非常适合放到Redis里:
第一类是工具/API返回结果。天气、汇率、股票行情、商品信息这类数据有明确的时效性,重复查询的比例非常高,用Redis缓存能显著降低外部依赖压力。
第二类是系统提示词和固定模板。大型Agent的系统提示词往往几千字,每次请求都完整传给模型是浪费。虽然在很多框架里模板是代码内常量,但如果你的提示词是配置化的、运营可调的,缓存一份在Redis,按版本号管理会非常方便。
第三类是短期会话快照。多轮对话的中间状态,用Redis存,读写都快,比每次查数据库合适得多。
第四类是高频知识库检索结果。很多Agent会做RAG,同一个问题被不同用户反复问是常态。如果问题本身完全一样,那就没必要每次都对向量库做检索,直接返回缓存结果就行。
这四类数据,我刚接入Redis时都没怎么区分,一股脑都往里塞,结果发现TTL设置混乱,有的缓存过期太慢导致数据明显滞后,有的太短导致命中率很差。后来专门按类型梳理了策略。
2.2 不值得缓存的场景
我也交过学费,后来明确划出了"不要缓存"的几类数据:
- 实时性要求极高的数据:比如用户当前余额、库存余量。这类数据宁可让Agent多查一次数据库,也不要缓存一秒钟。
- 强个人隐私的数据:涉及用户明文敏感信息的,尽量不要往Redis放,Redis只是内存数据库,不是安全边界。
- 一次性极低频的数据:缓存一个只有两三个人会访问的数据,没有任何意义,反而增加了维护成本。
一句话:缓存的价值等于访问频率乘以构建成本,不要对低频数据做缓存。
2.3 TTL设计:按数据时效性与业务容忍度来定
TTL(过期时间)我觉得是整个缓存设计里最需要动脑子的一环。设计TTL时不要拍脑袋,我习惯先列一张表,把每个缓存数据源的更新频率、业务容忍度列清楚,再往下定。
| 数据种类 | 推荐TTL | 缓存Key设计思路 |
|---|---|---|
| 天气、汇率等常规API | 5-10分钟 | agent:tool:weather:{city} |
| 股票行情 | 30-60秒 | agent:tool:stock:{code} |
| 系统提示词模板 | 永久+版本号 | agent:prompt:sys:v{version} |
| 短期会话快照 | 30分钟-2小时 | agent:session:{sessionId} |
| 固定问题检索结果 | 24小时 | agent:rag:ask:{sha256(question)} |
关键点在于:TTL不能统一设成一样的。我见过很多团队把所有缓存都设成十分钟过期,业务上有些数据十分钟是合理的,有些数据十分钟早就失真了。TTL要在"数据成本和业务新鲜度"之间取平衡,没有放之四海而皆准的值。
另外,热点缓存建议在过期时间上加一个随机抖动。比如TTL设10分钟,实际缓存可以设成8到12分钟随机值。这个做法是为了避免大量hot key同时过期,进而引发缓存雪崩,后面第5章会详细说。
2.4 命中判定:等值匹配与语义匹配
缓存命中不一定是等值判定。对于工具调用参数,我采用的是"参数序列化后做hash"的方式,也就是sha256(参数JSON),简单可靠,任何语言都能复现。
但对于用户自由输入的问题(比如RAG场景),同一意思的不同表达会造成大量缓存miss。比如"今天天气怎么样"和"今天天气如何"其实是同一个问题,等值缓存完全识别不了。我实测下来,如果Agent高频回答的是一小类固定问题,可以引入语义缓存:把用户query先embedding,用向量相似度去匹配历史问题,相似度超过0.92直接返回对应结果。
不过要提醒一下,语义缓存不是默认选项。embedding本身也有成本,如果Agent并不是面向大规模同质化问题,引入它反而会拖慢链路。我在一个客服Agent项目里试过语义缓存,命中率确实上去了,但响应时间也被embedding和向量检索拖慢了。后来只在Prompt分类这个环节用了语义信息,工具场景全部改回等值缓存,效果反而更好。
3. 会话状态与长期记忆:用Redis搭建Agent的临时大脑
Agent的会话管理是Redis另一个核心用武之地。和普通Web会话不同,Agent会话往往需要承载更多信息:多轮对话历史、中间推理片段、临时记忆、已调用的工具结果。这些数据如果全放内存,服务重启就丢了;全放数据库,读写效率不够高。Redis刚好介于两者之间,适合承担"临时大脑"的角色。
3.1 会话数据到底用Hash还是String
这是我被问得最多的问题。很多人在Redis里存储会话,喜欢直接SET session:{id} {大JSON字符串},简单直接。但这种做法有几个隐患:第一,整个JSON串只有一个key,你想单独更新某一天的历史消息,必须把整个大JSON读出来、反序列化、改完再写回去;第二,字符串类型在Redis里对内存的利用相对低效,尤其是这种频繁变动的结构化数据。
我的建议是:用Hash来存多轮会话。一个会话ID对应一个Hash,每一轮对话作为Hash里的一个field,field名是消息ID或时间戳,field值是消息JSON。优点很明显:
- 追加一轮新对话,只要
HSET一个新field,不需要动整个会话。 - 要取最新N轮,
HGETALL拿全部,代码里再截断,或者维护一个Sorted Set做分页。 - 可以针对某一条历史消息单独过期或删除。
下面是一个简单的存储示意:
HSET agent:session:abc123 \ round:001 '{"role":"user","content":"今天天气怎么样"}' \ round:002 '{"role":"assistant","content":"北京今天晴,12到22度"}' \ round:003 '{"role":"user","content":"明天呢"}' \ round:004 '{"role":"assistant","content":"我查一下明天的情况..."}'每次对话追加新内容时再HSET新的field即可,多个实例同时操作同一个会话也不会互相覆盖(因为Redis命令是原子性的)。
3.2 用Sorted Set来管理记忆时间线
除了即时会话,Agent系统还有一个"长期记忆"的需求:用户过去一周问了什么、偏好什么语气、历史订单等都可能会影响当前回答。这类记忆具备两个特点:和时间强相关、需要按需裁剪遗忘。最适合的数据结构就是Sorted Set。
思路是:以记忆类型为key,以记忆内容为member,以时间戳为score。比如我存用户感兴趣的话题:
ZADD agent:memory:user:123:interests 1700000000 "户外跑步" ZADD agent:memory:user:123:interests 1705000000 "摄影器材"需要取近期记忆时,用ZREVRANGE按score倒序拉取最近N条;需要遗忘太久远的记忆时,用ZREMRANGEBYSCORE把某个时间戳之前的记录清掉。这个模型非常贴近Agent"记忆"的真实语义:不是把全部历史都灌给模型,而是按时间窗口取最相关的一部分。
我在实际项目里还会给记忆加一个访问计数,放在ZSET的score里混合编码,用来决定哪些记忆进入Long-term storage,这个后面有时间再展开。
3.3 过期策略:Agent服务的Redis别用allkeys-lru
会话数据本质上是有生命周期的,不可能无限膨胀。Redis提供了多种内存淘汰策略,我建议在Agent场景里谨慎选择。
allkeys-lru是默认常见配置,它的意思是:当内存满了,不管key有没有设过期时间,一律按LRU淘汰。这对缓存工具结果来说没问题,但对会话数据可能是灾难:一个正在进行的会话可能因为内存压力直接被淘汰,用户对话到一半Agent"失忆"了。
我推荐用volatile-lru,只淘汰那些设置了TTL的key,不设TTL的关键配置数据(比如限流版本号、白名单)永远不会被动淘汰。对应地,会话快照、缓存结果这些要显式设置过期时间,把淘汰决策权交给Redis的TTL机制。
这里有一个我一直用的配置组合:
maxmemory 1gb maxmemory-policy volatile-lru另外,必要的持久化也要开。Agent会话丢了很影响体验,AOF的appendfsync everysec配置通常是个可接受的中间点:最多丢一秒的会话数据,但换来了不错的性能。
3.4 会话一致性与跨实例同步
实际线上Agent很少是单机部署,基本都是多实例负载均衡。如果没有统一的状态层,用户的两次请求落到不同实例,Agent会完全忘记上一轮说了什么。Redis在这里承担"共享状态层"的角色:所有实例都从同一个Redis读写会话,天然解决了多实例会话一致性问题。
但也正因如此,Redis一定要用带高可用方案的部署形态,比如哨兵或者集群。会话数据服务挂了,整个Agent的服务能力会瞬间归零,这个重要性怎么强调都不为过。
4. 并发治理:分布式锁、限流与任务队列的实战配方
"AI Agent怎么扛并发"这个问题在这些热度词里排得非常高,说明大家真正关心的是:Agent不仅仅是跑得通,还要在流量上来之后依然跑得稳。好消息是,Redis在这块已经有非常成熟的套路,只是需要针对Agent场景做一些定制。
4.1 分布式锁:解决"同一个任务被并发触发"的问题
Agent场景比普通接口更容易出现重复执行问题。举个例子:用户点了"帮我查一下公司年收入",然后因为页面卡顿又点了一次。这两个请求会同时到达系统。如果Agent内部没有锁控制,两次查询就会并行执行,浪费双倍外部API配额,甚至可能对下游产生重复扣款等不可控后果。
更典型的场景是定时任务与用户指令“撞车”:晚上七点的定时总结任务触发了,用户刚好在那个时间点手动问了同样的问题。
Redis分布式锁是最简单可靠的方案。核心命令:
SET lock:agent:task:{userId} unique_token NX EX 30NX保证同一把锁只能被一个实例拿到EX 30设锁自动过期,防止持有锁的实例挂掉导致死锁unique_token是每个请求生成的唯一ID,释放锁时只允许持有者释放
释放锁一定不能用DEL,要配合Lua脚本比对token,防止误删别人的锁。我在工程上用的释放逻辑:
-- KEYS[1]: 锁名 -- ARGV[1]: 当前请求的唯一token if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end还有一个值得注意的细节:锁超时时间不能小于Agent单轮任务的预估最长时间。Agent的执行链路跟普通接口很不一样,它可能调用两三次LLM,中途还会做工具调用,整个流程很容易超过5秒。锁设30秒是比较安全的起步值,但如果Agent内部出现重试,单轮任务可能超过这个时间,建议用Redisson或自己做一个看门狗续期,在任务未完成时自动延长锁的过期时间。
4.2 限流:令牌桶挡住突发流量
Agent服务最怕突发流量。这里的突发既指用户侧突发(比如一场营销活动带来大量用户),也指Agent内部的自我请求放大(一次用户请求可能触发多次LLM调用)。如果不对上游API调用做限流,上游会直接把你的Agent服务限流甚至封禁。
我在项目里用Redis实现的是一个基于Lua的令牌桶,逻辑是:每个桶有一个容量和填充速率,请求到达时如果桶里有令牌就放行,否则直接拒绝。
-- KEYS[1]: 桶名称 -- ARGV[1]: 桶容量 -- ARGV[2]: 每秒填充令牌数 -- ARGV[3]: 当前时间戳(毫秒) local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local last = redis.call("get", KEYS[1] .. ":last") if not last then last = now end local tokens = tonumber(redis.call("get", KEYS[1] .. ":tokens") or capacity) local elapsed = math.max(0, (now - last) / 1000) tokens = math.min(capacity, tokens + elapsed * rate) if tokens >= 1 then redis.call("set", KEYS[1] .. ":tokens", tokens - 1) redis.call("set", KEYS[1] .. ":last", now) return 1 else redis.call("set", KEYS[1] .. ":tokens", tokens) redis.call("set", KEYS[1] .. ":last", now) return 0 end这套逻辑跑在Redis里天然是原子的,限流判断和令牌扣减不会出现并发竞态。我在Agent网关层对LLM API调用、搜索API调用分别建了不同桶,限额按上游配额表来配。实测下来最明显的变化是:上游限流报错几乎消失了,系统也不再因为API重试进入死循环。
4.3 幂等与任务队列:把同步请求拆成异步流水线
Agent的很多任务其实不需要同步返回。比如"总结一下我这周所有邮件",这个操作要调搜索、调邮件服务、再组织语言,耗时可能几十秒甚至几分钟,绝不适合让用户HTTP请求一直挂着。
我倾向把这类任务拆成异步流水线,Redis的List正好能当任务队列用。LPUSH把任务塞进队列,Worker用BRPOP阻塞消费:
# 入队 redis_client.lpush("agent:tasks:summary", json.dumps({ "user_id": 123, "task_type": "weekly_summary", "created_at": time.time() })) # Worker 消费 while True: _, payload = redis_client.brpop("agent:tasks:summary", timeout=10) task = json.loads(payload) execute_task(task) # 真正执行任务这里面有一个可靠性技巧我想强调:直接用BRPOP出队后,如果Worker执行中崩溃,任务就丢了。我采用额外List做"处理中队列",用RPOPLPUSH把任务从待办列表原子地移动到处理中列表,任务完成后再确认删除。如果超时还没确认,就把任务重新放回待办列表。这套机制不复杂,但能让Agent的异步任务基本达到"at least once"的交付保证。
5. 才踩过的坑:缓存穿透、序列化、连接池和Key命名
技术方案初具雏形还不够,生产环境真正要命的全是细节。我在这条路上踩过的坑,绝对比成功经验值得写。
5.1 缓存穿透和负缓存
Agent场景非常容易出现缓存穿透。用户问一个不存在的股票代码、一个错误的地名,Agent对上游发起请求,拿到一个空结果,但因为结果为空,你下意识不会去缓存,下次相同请求来了,又穿透一次。
真实翻车案例是:我做过一个基金问答Agent,用户问"帮我查一下xx基金净值",但用户打错代码,Agent查不到数据,每次都会真去调用第三方接口。偏偏这个错误代码被几个用户连续问了几十次,第三方接口的当日调用量直接被刷高。
解决方案是负缓存:即使上游返回空结果,也缓存一个临时空标记,TTL给短一点(比如30到60秒),表示"这个key短时间内查不到值"。这样同样的错误问题再涌来时,Agent直接命中空缓存,不会再穿透到上游。
5.2 缓存击穿与雪崩:热点会话和集体过期
击穿和雪崩是两个不同问题,但Agent场景里都会遇到。
缓存击穿:某个热点问题(比如"怎么查账单")的缓存刚好过期,一瞬间大量同质化请求涌入,全部miss,然后同时打到上游服务。解决方案上面的TTL抖动只能算辅助,真正可靠的还是前面提的分布式锁:在缓存miss后,只让一个请求去加载真实数据,其他请求等待并复用那个加载结果。这个模式也叫"singleflight",我实际测试过,能把峰值打到上游的压力降掉90%以上。
缓存雪崩:大量key在同一时间过期,导致流量同时落入底层。我见过有人把系统里所有缓存统一设成"缓存1小时",结果每个整点,所有key集体失效,定时任务一定点出发,数据库和API就被打穿。解决办法是过期时间加上随机数,比如10分钟的TTL实际设为8到12分钟。这一点看起来不起眼,却是线上最能保命的小技巧。
5.3 序列化:别用JDK默认序列化,尤其是跨语言团队
序列化这个问题,平时开发根本不会注意,直到线上出了问题才明白。我见过某团队用Java的JDK默认序列化往Redis里存对象,Java侧读写没问题,但后来同一个Redis被Python写的Agent消费,直接报反序列化错误。跨语言协作在AI Agent时代几乎必然发生,一套通用的序列化格式极其重要。
我现在统一用JSON字符串存储,内部字段名固定,加了一个版本号,这样可以控制演进。压缩方面,超过几KB的大文本会考虑GZIP压缩。有一说一,目前很多Agent项目的响应体都是KB级别的文本,不太需要压缩,但如果上下文非常大(比如几十KB级别的RAG片段),压缩能省不少内存。
小建议:如果多个服务共用一个Redis,一定不要把序列化逻辑藏在各自的代码里,而是在公共模块里约定一种统一格式。这个约定最好写成文档,面试聊到Redis序列化的时候,也是能加分的点。
5.4 连接池与超时参数
连接池问题经典但常被忽略。Agent并发上来后,如果每个请求都新建Redis连接,系统会先被打垮的就是连接数,而且单条命令执行时间一旦变长,会占着一个连接不放。
我在生产里遇到过"redis command timed out"的报错,根因不是Redis没响应,而是连接池的等待时间太长,大量连接被慢命令占住,新来的命令拿不到连接。后来在客户端层面做了三个调整:
- 合理设置连接池大小,标准是
maxTotal略大于预期并发数,同时设置maxIdle不小于minIdle - 打开连接池等待超时,连接不够时快速失败,而不是无限等待拖垮整个线程池
- 给单个Redis命令设置超时时间,单个命令超过几百毫秒就要告警
另外,体系里尽量少用KEYS这种全量扫描命令,用SCAN替代。一次KEYS user:*在生产Redis上可能会阻塞几秒,这个时间点在Agent高并发场景下足以引发连环故障。
5.5 Key命名规范与可观测性
最后一项不涉及性能,但直接决定排查效率。我最初写代码时Redis key是随便取的,比如user:123,后来发现不同业务模块之间互相覆盖,或者定位问题根本不知道这个key在哪个环节写的。
现在我统一按这个规范命名:agent:{环境}:{业务模块}:{对象类型}:{ID}。举个例子:
agent:prod:session:abc123 agent:prod:tool:weather:beijing agent:prod:lock:task:user_456好处是通配筛选的时候特别直观,SCAN agent:prod:session:*就能扫出所有会话缓存。配合INFO KEYSPACE里面的expired_keys指标,以及SLOWLOG检查慢命令,线上Redis运行状态基本都在掌握了。
Redis的监控不能全靠人肉盯。建议把used_memory、expired_keys、evicted_keys、connected_clients这些关键指标接入Prometheus+Grafana或者云监控,设置告警阈值。Agent项目跑起来之后,你会非常庆幸这些指标是自动报警的,而不是等用户先反馈。
最后再分享一个经验:分布式锁、限流、缓存、队列,这些东西我第一次用Redis实现的时候也走了不少弯路。但一旦把Agent的这些基础能力从"散落在代码里"收拢到"Redis统一承载",并发和稳定性的思考框架就清晰很多了。上线前一定记得做压测,把Agent的每一个工具调用、每一轮LLM请求都算进耗时时,才能真的知道你搭的这套缓存和限流方案扛不扛得住。