1. 从一次线上事故说起:AI Agent 为什么绕不开 Redis
去年冬天,我负责的一个 AI Agent 项目在凌晨两点突然开始大面积超时。用户发来的每一条指令,Agent 都要花十几秒才响应,有的干脆直接报错。运维群里炸了锅,我爬起来看监控,发现 Agent 的推理服务本身 CPU 和内存都很正常,问题出在它调用外部工具的那一层——每次工具调用都要重新查一遍用户会话状态、重新拉一遍知识库片段、重新算一遍权限校验。这些操作单次只要几十毫秒,但一个复杂任务链路里 Agent 可能要循环调用几十次,累积起来就把整个响应时间拖垮了。
那次事故之后,我给这个 Agent 加了一层 Redis 缓存,把会话状态、工具调用结果、权限校验结果都缓存起来,响应时间从十几秒降到了两秒以内。这件事让我意识到,很多人聊 AI Agent 的时候,注意力全在模型选型、Prompt 工程、工具编排上,却忽略了一个最基础也最致命的问题:Agent 是有状态的,而状态管理做不好,再聪明的 Agent 也会被拖死。
这篇内容就是围绕"AI Agent + Redis 缓存"这个组合展开的。我会讲清楚 Agent 在哪些环节需要缓存、Redis 的哪些数据结构和特性适合这些场景、缓存一致性怎么保证、分布式锁在 Agent 并发场景下怎么用,以及我自己踩过的那些坑。不管你是刚接触 Agent 开发的新手,还是已经在做线上 Agent 服务的工程师,应该都能从里面找到能直接抄作业的东西。
2. AI Agent 的状态到底存在哪:先搞清楚缓存的对象
2.1 Agent 的三种状态类型
在动手加 Redis 之前,必须先想明白一件事:Agent 到底有哪些状态需要管理?我把它分成三类。
第一类是会话状态(Session State)。用户和 Agent 的对话历史、当前任务进度、已经执行到哪一步、下一步该干什么,这些都属于会话状态。这类数据的特点是读写极其频繁,几乎每一轮交互都要读一次、写一次,而且有明确的过期时间——用户关掉对话窗口之后,这些状态就没必要长期保留了。
第二类是工具调用结果(Tool Call Result)。Agent 调用外部工具(比如查天气、查数据库、调 API)拿到的结果,很多是可以复用的。比如用户问"北京今天天气怎么样",Agent 调了一次天气 API,五分钟后用户又问"北京适合穿什么衣服",Agent 完全没必要再调一次天气 API,直接用缓存的结果就行。这类数据的特点是读多写少、有 TTL、对一致性要求相对宽松。
第三类是中间计算结果(Intermediate Result)。Agent 在做复杂推理时,可能会把一个大任务拆成若干子任务,每个子任务的输出需要暂存起来供后续步骤使用。比如一个做竞品分析的 Agent,它可能先爬取十个竞品的信息,再逐个分析,最后汇总。这十个竞品的原始信息就是中间结果,需要缓存到任务结束。
2.2 为什么不用本地内存而用 Redis
有人会问,这些状态我直接存在 Agent 进程的内存里不行吗?单机跑当然可以,但一旦你的 Agent 要部署多个实例、要做水平扩展,本地内存就彻底不够用了。用户第一次请求打到实例 A,第二次请求打到实例 B,实例 B 根本不知道实例 A 存了什么,会话就断了。
Redis 在这里的价值就是把状态从进程内存里抽出来,变成一个所有实例都能访问的共享层。而且 Redis 本身是内存数据库,读写性能极高,单机轻松扛住十万级 QPS,对于 Agent 这种状态读写密集的场景非常合适。再加上 Redis 天然支持 TTL 过期、原子操作、发布订阅、分布式锁,这些特性几乎就是为 Agent 状态管理量身定做的。
2.3 一张表看清三类状态的缓存策略
| 状态类型 | 读写频率 | 一致性要求 | 推荐 TTL | 推荐数据结构 |
|---|---|---|---|---|
| 会话状态 | 极高 | 强一致 | 30分钟到2小时 | Hash |
| 工具调用结果 | 高 | 最终一致 | 5分钟到24小时 | String / Hash |
| 中间计算结果 | 中 | 最终一致 | 任务结束后删除 | List / Hash |
这张表是我在实际项目里反复调整后总结出来的,不是拍脑袋定的。会话状态之所以用 Hash,是因为它字段多、需要单独更新某个字段(比如只更新"当前步骤"而不动"对话历史"),Hash 的 HGET/HSET 正好合适。工具调用结果用 String 存 JSON 最简单,如果结果结构复杂也可以用 Hash。中间结果用 List 是因为它往往是有序的,比如按爬取顺序排列的竞品信息。
3. Redis 在 Agent 里的四个核心落点
3.1 会话上下文缓存:让 Agent 记住"我们聊到哪了"
Agent 和普通 Chatbot 最大的区别在于,Agent 要执行多步任务,每一步都依赖上一步的结果。这就意味着会话上下文不只是"聊天记录",还包括任务执行状态。
我的做法是给每个会话分配一个session_id,然后用一个 Redis Hash 存这个会话的所有状态:
HSET agent:session:{session_id} \ user_id "u_12345" \ task_status "in_progress" \ current_step "3" \ total_steps "5" \ context "{\"query\":\"分析竞品\",\"competitors\":[\"A\",\"B\"]}" \ updated_at "1703000000" EXPIRE agent:session:{session_id} 3600这里有几个细节值得说。第一,context字段我存的是 JSON 字符串而不是拆成多个字段,因为上下文结构经常变,拆字段会导致每次改结构都要改代码。第二,updated_at字段用来做"滑动过期"——每次会话有更新就重置 TTL,这样活跃会话不会中途失效。第三,task_status和current_step单独拆出来,是因为 Agent 的调度逻辑需要频繁读这两个字段来判断下一步动作,拆出来可以用 HGET 精准读取,不用把整个 context 拉出来解析。
注意:会话 Hash 的 key 一定要加业务前缀(比如
agent:session:),否则多个业务共用 Redis 时 key 冲突会让你排查到怀疑人生。
3.2 工具调用结果缓存:省下的不只是时间
Agent 调用外部工具是有成本的——可能是 API 调用费用,可能是数据库查询压力,也可能是网络延迟。把可复用的结果缓存起来,收益非常直接。
工具调用结果的缓存 key 设计有个讲究:要把工具名和参数一起哈希进去。比如查天气这个工具,参数是城市名,那 key 就应该是agent:tool:weather:{city}。这样同一个城市在 TTL 内重复查询就直接命中缓存。
import hashlib import json import redis r = redis.Redis(host='localhost', port=6379, decode_responses=True) def cached_tool_call(tool_name, params, ttl=300): # 把参数排序后序列化,保证相同参数生成相同 key param_str = json.dumps(params, sort_keys=True) param_hash = hashlib.md5(param_str.encode()).hexdigest()[:8] cache_key = f"agent:tool:{tool_name}:{param_hash}" cached = r.get(cache_key) if cached: return json.loads(cached) # 缓存未命中,真正调用工具 result = actual_tool_call(tool_name, params) r.setex(cache_key, ttl, json.dumps(result)) return result这段代码里有个容易被忽略的点:sort_keys=True。如果不排序,{"city":"北京","date":"今天"}和{"date":"今天","city":"北京"}会生成两个不同的 key,缓存命中率直接腰斩。这个坑我踩过,当时排查了半天为什么同样的查询命中不了缓存。
TTL 的设置也要看工具类型。天气这种变化快的,5 分钟就够了;汇率可以设 1 小时;而像"某公司的注册信息"这种几乎不变的数据,可以设 24 小时甚至更长。我一般会在配置里给每个工具单独配 TTL,而不是全局一个值。
3.3 中间结果暂存:复杂任务的"草稿纸"
Agent 做复杂任务时,中间结果的管理是个大问题。如果全放在内存里,任务一多内存就爆;如果每步都落库,又太重。Redis 在这里扮演的是"草稿纸"的角色——任务进行时暂存,任务结束后清理。
我通常用 List 来存有序的中间结果,比如:
RPUSH agent:task:{task_id}:steps "step1_result" RPUSH agent:task:{task_id}:steps "step2_result" EXPIRE agent:task:{task_id}:steps 7200用 List 的好处是可以按顺序读取,Agent 回溯的时候直接 LRANGE 就行。如果中间结果需要按 key 查找,那就用 Hash,字段名用步骤 ID。
这里有个经验:中间结果的 TTL 要设得比任务预期时长长一些。比如一个任务预计最多跑 30 分钟,TTL 就设 2 小时,留足缓冲。否则任务还没跑完缓存就过期了,Agent 会拿到空数据,行为变得不可预测。我见过有团队把 TTL 设成和任务超时时间一样,结果任务在超时边缘完成时缓存刚好失效,最后一步汇总拿不到数据,整个任务白跑。
3.4 分布式锁:防止多个 Agent 实例打架
当你的 Agent 部署了多个实例,或者同一个用户可能触发多个并发任务时,就会出现资源竞争。最典型的场景是:用户快速点了两次"开始分析",两个 Agent 实例同时启动同一个任务,重复调用工具、重复写数据,甚至互相覆盖状态。
Redis 的分布式锁就是解决这个问题的。核心命令是SET key value NX PX milliseconds,NX 保证只有 key 不存在时才设置成功,PX 设置过期时间防止死锁。
import uuid import time def acquire_lock(lock_name, expire_ms=10000): lock_key = f"agent:lock:{lock_name}" lock_value = str(uuid.uuid4()) # NX 保证互斥,PX 保证不会死锁 acquired = r.set(lock_key, lock_value, nx=True, px=expire_ms) return lock_value if acquired else None def release_lock(lock_name, lock_value): lock_key = f"agent:lock:{lock_name}" # 用 Lua 脚本保证"判断值 + 删除"的原子性 lua_script = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ r.eval(lua_script, 1, lock_key, lock_value)释放锁必须用 Lua 脚本,不能先 GET 再 DEL。因为 GET 和 DEL 之间有时间窗口,如果锁在这期间过期了、被别的实例拿到了,你再去 DEL 就会误删别人的锁。这个细节很多人第一次写分布式锁都会忽略,线上出问题的时候特别难查。
提示:锁的过期时间要大于任务的最长执行时间,但也不能太长。我的经验是设成任务 P99 耗时的 2 到 3 倍。如果任务确实可能跑很久,那就用"锁续期"机制,起一个后台线程定期给锁续命。
4. 缓存一致性:Agent 场景下最容易翻车的地方
4.1 为什么 Agent 的一致性比普通业务更棘手
普通 Web 业务的缓存一致性,无非是"数据库更新了,缓存要删掉"。但 Agent 场景复杂得多,因为 Agent 的状态是多步演进的,而且可能被多个并发任务同时修改。
举个例子:一个 Agent 正在执行任务 A,把会话状态更新到了第 3 步。这时候用户又发了一条消息,触发了任务 B,任务 B 也去读会话状态,读到的是第 3 步,然后基于这个状态做了决策。但任务 A 紧接着又把状态更新到了第 4 步。任务 B 的决策就基于了一个已经过时的状态。
这种问题在单步业务里很少见,但在 Agent 这种长链路、多步执行的场景里非常普遍。
4.2 三种一致性策略的取舍
我实践下来,Agent 场景下有三种策略可选,各有适用场景。
策略一:写穿透 + 版本号。每次更新会话状态时,同时更新一个version字段。Agent 在读取状态时也读版本号,写回时用 Lua 脚本检查版本号是否变化,变了就拒绝写入并重新读取。这个策略适合对一致性要求高的会话状态。
策略二:延迟双删。更新数据后先删缓存,等一小段时间(比如 500ms)再删一次。这是为了应对"更新数据库后、删缓存前,有读请求把旧数据又加载回缓存"的情况。适合工具调用结果这类最终一致就够用的场景。
策略三:发布订阅通知。数据变更时通过 Redis 的 Pub/Sub 通知所有 Agent 实例,让它们主动失效本地缓存。适合多实例部署、且实例本地也有一层缓存的场景。
| 策略 | 一致性强度 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 写穿透 + 版本号 | 强一致 | 中 | 会话状态 |
| 延迟双删 | 最终一致 | 低 | 工具调用结果 |
| 发布订阅通知 | 最终一致 | 高 | 多实例本地缓存 |
4.3 一个真实的一致性翻车案例
我之前做过一个 Agent,负责帮用户管理待办事项。用户说"把明天下午的会议改到后天",Agent 会先读会话状态拿到"明天下午的会议"这个上下文,然后调用日历工具修改。
有一次用户连续发了两条指令,第一条是"把明天下午的会议改到后天",第二条是"把后天上午的评审取消"。两条指令几乎同时到达,被分到了两个 Agent 实例。实例 A 读到的会话状态里"明天下午的会议"还是原始信息,实例 B 读到的也是。结果实例 A 改了会议时间,实例 B 基于旧状态去取消评审,但它以为的"后天上午"其实是基于旧日历算的,最后取消错了会议。
这个问题的根因就是会话状态没有做并发控制。后来我加了版本号机制,实例 B 在写回时会发现版本号已经变了,于是重新读取最新状态再执行,问题就解决了。
5. 性能调优:让 Redis 在 Agent 高并发下不成为瓶颈
5.1 连接池:别每次操作都新建连接
Agent 调用 Redis 的频率极高,如果每次操作都新建连接,光是 TCP 握手和认证的开销就够呛。必须用连接池。
import redis pool = redis.ConnectionPool( host='localhost', port=6379, max_connections=50, socket_timeout=2, socket_connect_timeout=1, retry_on_timeout=True, decode_responses=True ) r = redis.Redis(connection_pool=pool)max_connections的设置要看 Agent 的并发量。我的经验是设成"峰值 QPS 除以单连接能承载的 QPS"再留一倍余量。单连接大概能承载几千 QPS,所以 50 个连接对大多数 Agent 服务足够了。socket_timeout一定要设,否则 Redis 卡住的时候 Agent 会一直等,把整个服务拖死。
5.2 Pipeline:批量操作减少往返
Agent 有时候需要一次性读写多个 key,比如加载一个会话的所有相关状态。这时候用 Pipeline 把多个命令打包发送,能大幅减少网络往返。
pipe = r.pipeline() pipe.hgetall(f"agent:session:{session_id}") pipe.get(f"agent:tool:weather:beijing") pipe.lrange(f"agent:task:{task_id}:steps", 0, -1) results = pipe.execute()实测下来,三个命令用 Pipeline 比逐个执行能快 2 到 3 倍,命令越多差距越明显。但要注意 Pipeline 不是事务,中间某条命令失败不会回滚,所以只适合"批量读"或者"失败可接受"的批量写。
5.3 大 key 和热 key 的治理
Agent 场景特别容易产生大 key。比如会话上下文,如果对话轮次很多,整个 JSON 可能几百 KB。大 key 的问题是读写慢、删除时阻塞、迁移时卡顿。
治理大 key 的办法是拆分。把会话上下文按轮次拆成多个 key,比如agent:session:{id}:turn:1、agent:session:{id}:turn:2,只把最近几轮放在主 key 里。或者把上下文里的不同部分拆开,对话历史一个 key,任务状态一个 key。
热 key 的问题在 Agent 场景也很突出。比如某个热门工具的结果被大量并发查询,这个 key 就成了热 key,可能把单个 Redis 节点的 CPU 打满。解决办法是在 key 后面加随机后缀做分片,比如agent:tool:weather:beijing:{0-9},读的时候随机选一个后缀,写的时候十个都写。这样把压力分散到多个 key 上。
注意:热 key 分片会让缓存失效变复杂,因为要删十个 key。所以只对真正的热 key 用这招,别滥用。
6. 踩坑实录:那些让我半夜爬起来的问题
6.1 缓存雪崩:TTL 设成一样导致的连锁反应
早期我给工具调用结果设 TTL 的时候,图省事全设了 300 秒。结果上线后发现,每到整点过 5 分钟,Agent 的响应时间就会飙升。排查后发现,大量缓存 key 是在同一时间写入的,TTL 又一样,所以它们会在同一时间集体过期。过期瞬间所有请求都穿透到后端工具,后端扛不住就慢了。
解决办法是给 TTL 加随机抖动。比如基础 TTL 是 300 秒,实际设置时加上 0 到 60 秒的随机值:
import random ttl = 300 + random.randint(0, 60) r.setex(cache_key, ttl, value)这样缓存过期时间就分散开了,不会出现集体失效。这个改动很小,但效果立竿见影。
6.2 缓存穿透:不存在的 key 被反复查询
Agent 有时候会查询一些不存在的数据,比如用户问"查一下张三的订单",但系统里根本没有张三这个用户。如果每次查询都穿透到数据库,数据库压力会很大。
解决办法有两个。一是缓存空值,查询不到也往 Redis 里写一个特殊标记,TTL 设短一点(比如 60 秒)。二是布隆过滤器,把所有存在的 key 提前加载到布隆过滤器里,查询前先过一遍,不存在直接返回。
我一般用缓存空值,因为实现简单。但要注意空值的 TTL 不能太长,否则数据后来真的存在了,缓存还是空的,会导致误判。
6.3 序列化踩坑:JSON 和 Pickle 的选择
工具调用结果我一开始用 Pickle 序列化,因为 Python 对象直接 dump 就行,方便。后来发现两个问题:一是 Pickle 有安全风险,反序列化不可信数据可能执行任意代码;二是 Pickle 跨语言不兼容,后来 Agent 用 Go 重写了一部分,读不了 Python 写的 Pickle 数据。
后来全部改成 JSON。JSON 虽然对复杂对象的支持不如 Pickle,但胜在通用、安全、可读。对于 Agent 场景,工具调用结果基本都是 JSON 友好的结构,用 JSON 完全够。
如果确实需要存二进制数据,可以用 MessagePack,它比 JSON 紧凑,又比 Pickle 安全。
6.4 连接超时:那个让我误以为 Redis 挂了的 bug
有一次线上报警说 Redis 连接超时,我第一反应是 Redis 挂了,赶紧去查 Redis 监控,发现 Redis 本身好好的,CPU、内存、连接数都正常。后来仔细看日志,发现是 Agent 服务所在的机器网络抖动,导致连接 Redis 超时。
这个问题的教训是:连接超时不一定是对端的问题,也可能是网络问题。所以客户端一定要配retry_on_timeout=True,让它在超时后自动重试。同时socket_connect_timeout要设得比socket_timeout短,因为连接建立阶段的超时通常意味着网络不通,重试也没用,快速失败更好。
7. 从单机到集群:Agent 规模扩大后的 Redis 演进
7.1 什么时候该上 Redis 集群
单机 Redis 的性能其实很强,十万 QPS 不在话下。但有两个情况必须上集群:一是数据量超过单机内存,二是写入量超过单机承载。
Agent 场景下,数据量增长主要来自会话状态和中间结果。如果日活用户上万,每个用户平均有几十 KB 的会话状态,一天下来就是几百 MB,加上中间结果可能到几个 GB。这时候单机内存就开始吃紧了。
上集群的方式有几种:Redis Cluster 是官方方案,支持分片和故障转移;Codis 是第三方方案,兼容性好但维护成本高;还有云厂商的托管 Redis 集群,省心但贵。我一般推荐 Redis Cluster,因为它是官方维护的,生态最完善。
7.2 集群下的 key 设计注意事项
Redis Cluster 把数据分到 16384 个槽上,key 通过 CRC16 计算落到哪个槽。这里有个坑:如果多个 key 需要一起操作(比如用事务或 Lua 脚本),它们必须在同一个槽上。
解决办法是用 hash tag。在 key 里用{}包住一部分,Redis 只对{}里的内容做哈希。比如agent:session:{user_123}:state和agent:session:{user_123}:history,因为{user_123}一样,它们会落到同一个槽。
Agent 场景下,同一个会话的多个 key 经常需要一起操作,所以 key 设计时一定要考虑 hash tag。我一般把session_id或task_id放在{}里。
7.3 主从和持久化:数据丢了怎么办
Redis 是内存数据库,重启会丢数据。虽然缓存数据丢了可以重建,但 Agent 的会话状态丢了,用户体验会很差——用户聊到一半,Agent 突然"失忆"了。
所以持久化必须开。RDB 适合做冷备,AOF 适合做实时持久化。我的配置是 RDB 每小时一次,AOF 每秒同步一次。这样即使 Redis 挂了,最多丢一秒的数据。
主从复制也要配,主节点挂了从节点能顶上。但要注意主从复制是异步的,主节点写入后还没同步到从节点就挂了,这部分数据会丢。如果对一致性要求极高,可以用 WAIT 命令强制等待同步,但会牺牲性能。Agent 场景下,会话状态可以接受秒级的数据丢失,所以异步复制够用。
8. 写在最后:一些不成体系的个人经验
做 Agent 这几年,我在 Redis 上踩的坑比在模型上踩的还多。模型选错了大不了效果差一点,缓存设计错了是真的会让服务挂掉。
有几个习惯是我现在做每个 Agent 项目都会坚持的。第一,所有缓存 key 必须带业务前缀和版本号,比如agent:v2:session:{id},这样改缓存结构的时候可以平滑过渡,不用一次性清空所有缓存。第二,所有 TTL 必须加随机抖动,这个成本极低但收益极高。第三,所有分布式锁必须用 Lua 脚本释放,别图省事用 GET+DEL。第四,上线前一定要做缓存穿透和雪崩的压测,这两个问题在低并发下根本看不出来,一上量就爆。
还有一点,Redis 虽然快,但它不是银弹。有些状态其实不适合放 Redis,比如需要复杂查询的、需要事务保证的、数据量特别大的。这些该落库就落库,Redis 只做它擅长的事——高速读写、TTL 管理、原子操作。把合适的活交给合适的工具,系统才能稳。
如果你也在做 Agent 相关的开发,欢迎交流你在缓存上踩过的坑。这个领域变化太快,很多经验都是踩出来的,多交流能少走不少弯路。