AI Agent 缓存架构实战:Redis 会话状态、工具调用与分布式锁设计
1. 为什么 AI Agent 绕不开 Redis 这层缓存
做过 AI Agent 项目的人大概都有过这种体验:本地跑个 demo 顺风顺水,一旦把并发量拉起来,或者把会话状态放到多实例部署的环境里,整个系统就开始变得又慢又乱。模型调用本身已经够贵了,结果每次用户发一句话,Agent 还要重新去查一遍历史对话、重新拼一遍工具调用结果、重新算一遍向量检索的中间态——这些重复劳动堆在一起,延迟和成本直接失控。
这就是 Redis 在 AI Agent 架构里真正的位置。它不是"顺便加个缓存",而是 Agent 的短期记忆层和状态协调层。一个典型的 Agent 循环是这样的:接收用户输入 → 规划(Planning)→ 调用工具(Tool Calling)→ 观察结果(Observation)→ 再规划 → 输出。这个循环里,每一步产生的中间状态如果都靠数据库或者内存变量扛着,多轮对话一长、并发一高,系统就会崩。Redis 的定位,就是把这些高频读写、生命周期短、结构灵活的数据接过来。
我先把这篇要讲清楚的东西摆出来:AI Agent 场景下 Redis 到底缓存什么、怎么设计 key、怎么处理缓存一致性、分布式锁怎么用、序列化怎么选、线上常见的超时和失效问题怎么排查。适合正在搭 Agent 后端、或者已经把 Agent 跑起来但被性能和状态问题折磨的开发者。如果你只是想让 Agent 跑通一个 demo,那 Redis 可以先放一放;但只要涉及多轮会话、多实例部署、工具调用结果复用,这层缓存基本躲不掉。
有一点要先说明白:AI Agent 和传统 Web 后端的缓存需求不完全一样。传统缓存大多是"读多写少、结果稳定",比如商品详情、用户资料。而 Agent 的缓存对象往往是会话态、工具调用结果、规划中间态,这些东西变化快、和具体会话强绑定、还经常需要设置较短的 TTL。用传统那套"缓存整个接口响应"的思路来做 Agent 缓存,很容易踩坑。下面我按实际项目里的顺序,一层层拆。
2. Agent 里到底哪些数据该进 Redis
很多人一上来就问"Redis 缓存怎么配",但更该先问的是"我到底要缓存什么"。选错了缓存对象,后面 key 设计、过期策略、一致性方案全是白费功夫。我按 Agent 运行时实际产生的数据分几类说。
2.1 会话上下文:最核心也最容易失控的部分
多轮对话的上下文是 Agent 缓存的第一大头。用户和 Agent 聊了 20 轮,第 21 轮进来时,你需要把前面的对话历史喂给模型。如果每次都从数据库全量拉取再拼接,延迟会随着轮次线性增长。
常见的做法是把会话上下文以列表结构存在 Redis 里,比如用List或者Stream。每轮对话追加一条,读取时按需截取最近 N 轮。这里有个关键决策:是缓存完整历史,还是只缓存最近窗口。我的经验是,Redis 里只保留"热窗口"——比如最近 10 到 20 轮,更早的历史落库,需要时再按需回捞。原因很简单,模型的上下文窗口是有限的,你缓存再多它也吃不下,反而白白占内存。
import redis import json r = redis.Redis(host='localhost', port=6379, decode_responses=True) def append_turn(session_id, role, content): key = f"agent:ctx:{session_id}" turn = json.dumps({"role": role, "content": content}, ensure_ascii=False) r.rpush(key, turn) # 只保留最近 20 轮,控制内存 r.ltrim(key, -20, -1) r.expire(key, 3600) # 1 小时不活跃就回收 def get_context(session_id): key = f"agent:ctx:{session_id}" turns = r.lrange(key, 0, -1) return [json.loads(t) for t in turns]这段代码看着简单,但ltrim那行是精髓。我见过太多项目忘了裁剪,结果一个长会话把 Redis 内存吃满,最后触发淘汰策略把别的 key 挤掉,引发连锁故障。
2.2 工具调用结果:省下的是真金白银
Agent 调用外部工具(搜索、数据库查询、第三方 API)往往又慢又贵。同一个会话里,用户可能反复问相关的问题,触发相同的工具调用。这时候把工具调用结果按"工具名 + 参数指纹"缓存起来,命中一次就省一次外部调用。
这里要注意的是参数指纹怎么算。不能简单用参数的 JSON 字符串,因为字段顺序、空格差异会导致同样的语义算出不同的 key。稳妥的做法是对参数做规范化排序后再哈希。
import hashlib def tool_cache_key(tool_name, params: dict): normalized = json.dumps(params, sort_keys=True, ensure_ascii=False) digest = hashlib.sha256(normalized.encode()).hexdigest()[:16] return f"agent:tool:{tool_name}:{digest}"TTL 设置上,工具结果的新鲜度要求不同。搜索类结果可能几分钟就过时,设 300 秒;而一些静态查询(比如汇率换算的固定公式结果)可以设几小时。别一刀切。
2.3 规划中间态与向量检索缓存
Agent 的 Planning 阶段经常会产生一些中间结构,比如任务分解列表、候选工具排序。这些数据生命周期极短,可能就几秒到几十秒,但读写频率很高。用 Redis 的String存 JSON 或者Hash存字段都行,关键是 TTL 要短。
向量检索这块要单独提一句。RAG 场景下,同一个 query 的 embedding 和检索结果可以缓存。embedding 计算是 CPU/GPU 密集操作,缓存命中能显著降延迟。但要注意,embedding 缓存对 query 的精确匹配要求高,稍微改一个字就是另一个 key,所以命中率取决于用户提问的重复度。实际项目里我会给 embedding 缓存设较长的 TTL(比如 24 小时),因为语义相近的 query 复用价值高。
| 缓存对象 | 推荐结构 | 典型 TTL | 备注 |
|---|---|---|---|
| 会话上下文 | List / Stream | 30min - 2h | 需裁剪窗口 |
| 工具调用结果 | String (JSON) | 5min - 数小时 | 按工具特性定 |
| 规划中间态 | String / Hash | 10s - 60s | 极短生命周期 |
| Embedding | String (二进制) | 数小时 - 24h | 注意内存占用 |
| 限流计数 | String (INCR) | 与窗口对齐 | 配合过期 |
3. Key 设计与序列化:那些看起来不重要却天天出事的地方
缓存对象定了,接下来是 key 怎么设计、value 怎么序列化。这两件事在 demo 阶段无所谓,上了生产就是天天出问题的地方。
3.1 Key 命名要有"层级感"和"可扫描性"
我推荐用冒号分隔的层级命名,比如agent:ctx:{session_id}、agent:tool:{tool}:{hash}。好处是可以用SCAN按前缀批量操作,也方便在监控里按前缀统计内存占用。
但有个坑必须提醒:不要用KEYS agent:ctx:*去扫线上 Redis。KEYS是 O(N) 操作,会阻塞整个实例。线上要用SCAN游标迭代。我见过有人写了个定时任务用KEYS清理过期会话,结果每次执行 Redis 就卡顿几秒,Agent 请求大面积超时。
def scan_keys(pattern, batch=100): cursor = 0 while True: cursor, keys = r.scan(cursor=cursor, match=pattern, count=batch) for k in keys: yield k if cursor == 0: break另外,key 里如果包含用户可控的内容(比如 session_id 直接来自前端),一定要做校验或哈希,防止有人构造超长 key 或者恶意 key 撑爆内存。
3.2 序列化选型:JSON、MessagePack 还是 Pickle
序列化这块,我的排序是:JSON 优先,MessagePack 次之,Pickle 尽量别用。
JSON 的好处是可读、跨语言、调试方便。缺点是体积大、数字精度有损。对于会话上下文这种需要人肉排查的数据,JSON 是首选。
MessagePack 体积能压到 JSON 的 60% 左右,序列化速度也快,适合工具调用结果这种量大且不需要人读的场景。代价是调试时得额外解码。
Pickle 我基本不用。一是跨语言不友好,二是反序列化有安全风险,三是 Python 版本升级容易出兼容问题。Agent 项目经常要和其他语言的服务(比如 Rust 写的推理服务)交互,Pickle 直接出局。
import msgpack def set_tool_result(key, data, ttl=300): packed = msgpack.packb(data, use_bin_type=True) r.set(key, packed, ex=ttl) def get_tool_result(key): raw = r.get(key) if raw is None: return None return msgpack.unpackb(raw, raw=False)提示:如果 value 里包含二进制数据(比如 embedding 向量),别硬塞进 JSON,用 MessagePack 或者直接存 bytes。JSON 处理二进制会先 base64 编码,体积膨胀 33%。
3.3 大 key 与热 key 的预防
Agent 场景特别容易产生大 key。比如把整个会话的所有工具调用结果塞进一个 Hash,聊得越久这个 Hash 越大。大 key 的危害是:读写慢、删除时阻塞、集群模式下数据倾斜。
预防手段有几个:一是拆分,把工具结果按调用 ID 单独存 key,会话里只存引用;二是限制单个 value 大小,超过阈值就落库;三是监控,用redis-cli --bigkeys或者MEMORY USAGE定期排查。
热 key 则是另一个极端:某个 key 被高频访问,把单个分片的 CPU 打满。Agent 里常见的热 key 是"全局配置"或者"热门工具的结果"。解决办法是本地缓存加一层,或者给 key 加随机后缀做分片读。
4. 缓存一致性:Agent 状态和数据库怎么不打架
这是最容易出事故的部分。Agent 的会话状态既在 Redis 又在数据库,两边不同步就会出现"用户看到的历史和实际存的不一样"这种诡异问题。
4.1 先搞清楚你的读写模式
一致性方案没有银弹,得先看你的读写模式。Agent 场景大致分两种:
写少读多:比如工具调用结果、embedding,写一次读很多次。这种用 Cache-Aside 就行,读时先查缓存,没有再查库并回填。
写多读多:比如会话上下文,每轮都在写。这种如果还用 Cache-Aside,每次写都要更新库再删缓存,延迟高。更实际的做法是以 Redis 为主存储,异步落库。也就是说,会话的实时状态以 Redis 为准,后台任务定期把数据同步到数据库做持久化。
def save_turn(session_id, turn): # 先写 Redis,保证实时性 append_turn(session_id, turn["role"], turn["content"]) # 异步落库,不阻塞主流程 async_persist_queue.put({"session_id": session_id, "turn": turn})这个方案的风险是 Redis 挂了会丢最近的数据。所以关键会话要么用 Redis 的 AOF 持久化,要么接受"最多丢最近几轮"的业务容忍度。这个取舍必须和产品方对齐,不能自己拍脑袋。
4.2 删除缓存还是更新缓存
经典问题。我的结论是:优先删除缓存,而不是更新缓存。原因是更新缓存容易在并发下产生脏数据——两个请求同时更新,后写的可能先到,缓存里就留下了旧值。删除缓存则让下次读自然回源,逻辑更简单。
但删除也有坑,就是"删除后瞬间大量请求回源"造成缓存击穿。Agent 场景里,如果某个热门会话的缓存被删,紧接着几十个请求同时回源查库,数据库可能扛不住。这时候要用互斥锁或者逻辑过期来兜底。
4.3 延迟双删与最终一致
如果业务对一致性要求高,可以用延迟双删:更新库 → 删缓存 → 延迟几百毫秒再删一次。第二次删是为了清掉在"更新库到第一次删缓存"这个窗口内被其他请求回填的旧值。
不过说实话,Agent 场景里大部分数据对强一致的要求没那么高。会话上下文差一两轮、工具结果稍微旧一点,用户基本无感。真正需要强一致的是计费、配额、权限这类数据,这些我建议直接走数据库或者用 Redis 的原子操作,别掺和缓存那一套。
5. 分布式锁:Agent 并发下的状态保护
Agent 多实例部署后,同一个会话可能被多个实例同时处理。比如用户快速发了两条消息,两个实例同时读到同一份上下文,各自追加,最后就丢了一条。这时候需要分布式锁。
5.1 用 SET NX EX 实现基础锁
Redis 分布式锁最基础的实现就是SET key value NX EX ttl。NX 保证只有第一个请求能设置成功,EX 保证锁会自动过期,避免死锁。
import uuid def acquire_lock(lock_key, ttl=10): token = str(uuid.uuid4()) ok = r.set(lock_key, token, nx=True, ex=ttl) return token if ok else None def release_lock(lock_key, token): # 用 Lua 保证"判断 token 再删除"的原子性 lua = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ r.eval(lua, 1, lock_key, token)释放锁必须用 Lua 脚本,先比对 token 再删除。否则可能出现:A 的锁过期了,B 拿到了锁,这时 A 执行完来删锁,把 B 的锁删了。这个 bug 非常隐蔽,线上排查起来很痛苦。
5.2 锁的粒度:别锁太大也别锁太小
锁的粒度是个经验活。锁整个会话(agent:lock:session:{id})能保证同一会话串行处理,但会降低并发。锁单个操作(比如只锁"追加消息"这一步)并发高,但可能保护不全。
我的做法是:会话级的写操作锁整个会话,读操作不加锁。因为读多写少,锁写不锁读能兼顾一致性和吞吐。如果会话内还有更细的并发点(比如多个工具并行调用),再单独加细粒度锁。
5.3 锁超时与续期
锁的 TTL 设多长是个难题。设短了,业务没执行完锁就过期,别的请求进来乱套;设长了,万一持锁进程崩了,锁要等很久才释放。
稳妥方案是看门狗续期:持锁期间起一个后台线程,每隔 TTL/3 的时间检查业务是否还在执行,是就续期。Redisson 这类客户端内置了这个机制。如果自己实现,要注意续期和释放的竞态,别续期续到一个已经被别人拿走的锁上。
注意:分布式锁不是万能的。如果业务本身能设计成幂等,很多场景根本不需要锁。加锁之前先问自己:这个操作重复执行会怎样?如果无害,就别加锁,省得引入复杂度和性能损耗。
6. 线上高频故障:超时、失效与内存告警
前面讲的都是设计,这一节讲线上真会遇到的故障。我把最常见的几类列出来,附上排查思路。
6.1 command timed out:别急着怪 Redis
Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException这个报错,很多人第一反应是"Redis 挂了"。实际上大部分情况是客户端侧的问题。
排查顺序我一般这样走:
- 先看 Redis 服务端慢日志
SLOWLOG GET,确认有没有慢命令。 - 再看客户端连接池配置,是不是连接数不够导致排队。
- 检查有没有大 key 操作,比如
HGETALL一个几万字段的 Hash。 - 看网络,尤其是跨机房访问,RTT 高的时候默认超时很容易触发。
Lettuce 默认命令超时是 60 秒,但很多框架会覆盖成更短的值。Agent 场景里如果某个工具调用结果特别大,序列化反序列化耗时也会算进超时。我遇到过一次,就是工具返回了几 MB 的 JSON,反序列化慢,导致偶发超时。后来改成只缓存必要字段就好了。
6.2 缓存失效引发的雪崩
如果大量 key 在同一时刻过期,请求会同时涌向数据库。Agent 场景里,如果所有会话的 TTL 都是固定 1 小时,且用户集中在某个时段活跃,就可能在整点附近触发雪崩。
解决办法是给 TTL 加随机抖动:
import random def set_with_jitter(key, value, base_ttl): jitter = random.randint(0, int(base_ttl * 0.1)) r.set(key, value, ex=base_ttl + jitter)这样过期时间就分散开了。另外,热点数据可以用逻辑过期:value 里存一个过期时间戳,物理上不过期,读到逻辑过期时异步刷新,避免大量请求同时回源。
6.3 内存告警与淘汰策略
Redis 内存满了会触发淘汰。Agent 场景下,如果淘汰策略设成allkeys-lru,可能把正在用的会话上下文淘汰掉,导致用户"失忆"。如果设成noeviction,写入直接报错。
我的建议是:会话上下文这类不能丢的数据,单独放一个实例或用单独的 DB,配置volatile-lru(只淘汰设了过期时间的 key)。而工具结果、embedding 这类可重建的数据,可以容忍淘汰。物理隔离比在一个实例里靠策略区分更可靠。
| 故障现象 | 常见根因 | 排查入口 |
|---|---|---|
| command timed out | 连接池不足 / 大 key / 网络 RTT | SLOWLOG、连接池监控 |
| 缓存雪崩 | TTL 集中过期 | 检查 TTL 是否加抖动 |
| 用户会话丢失 | 内存淘汰 / key 被误删 | 淘汰策略、内存监控 |
| 数据不一致 | 双写时序问题 | 检查删除/更新顺序 |
| 锁失效 | TTL 过短 / 未续期 | 锁监控、业务耗时统计 |
6.4 主从与集群下的坑
如果用了主从或者集群,有几个坑要提前知道。主从复制是异步的,主节点写入后还没同步到从节点就宕机,锁可能丢失。集群模式下,涉及多个 key 的操作(比如 Lua 脚本里操作多个 key)要求这些 key 在同一个 slot,否则会报 CROSSSLOT 错误。解决办法是用 hash tag,比如agent:ctx:{session123}:turns和agent:ctx:{session123}:meta,大括号里的内容相同就会落到同一个 slot。
7. 我踩过的几个真实坑和对应经验
理论讲完了,说几个我自己项目里真踩过的坑,这些在文档里基本看不到。
第一个坑:用 List 存会话,读取时全量拉取。一开始图省事,LRANGE key 0 -1把整个会话拉出来。会话短的时候没事,聊到几十轮后,每次读取都要传输几百 KB,延迟肉眼可见地涨。后来改成只拉最近 N 轮,更早的按需回捞,延迟立刻降下来。教训是:缓存读取要按需,别图省事全量拉。
第二个坑:工具结果缓存没做参数规范化。同样的查询,前端传参字段顺序不一样,算出来的 key 就不一样,缓存命中率极低。加了sort_keys=True之后命中率从 20% 涨到 70% 多。这个改动一行代码,收益巨大。
第三个坑:分布式锁 TTL 设太短。有个工具调用偶尔要跑 15 秒,锁 TTL 设了 10 秒,结果锁提前过期,两个请求同时写会话,数据错乱。后来加了续期机制才解决。教训是:锁 TTL 要大于业务 P99 耗时,且要有续期兜底。
第四个坑:监控缺失。上线初期没做缓存命中率监控,出了问题只能靠猜。后来加了命中率、平均耗时、大 key 数量这几个指标,排查效率完全不一样。Agent 缓存这种动态性强的场景,没有监控基本等于盲开。
第五个坑:把 embedding 向量用 JSON 存。一个 1536 维的向量,JSON 序列化后体积膨胀好几倍,内存消耗远超预期。改成二进制存储后,内存占用直接砍半。涉及数值数组的场景,一定要用二进制序列化。
8. 一套可落地的 Agent 缓存分层方案
最后把我实际项目里用的分层方案整理出来,可以直接参考。
第一层:本地缓存(进程内)。存全局配置、工具元数据这类几乎不变的数据。用 LRU 结构,容量小,命中快,不占 Redis 资源。
第二层:Redis 热数据。存会话上下文、工具结果、规划中间态。按数据类型分 DB 或分实例,配置不同的淘汰策略和 TTL。
第三层:数据库持久化。会话历史、重要工具结果落库,作为最终真相来源。Redis 里的数据丢了可以从这里重建。
读写路径上,读优先本地,再 Redis,最后数据库;写以 Redis 为主,异步落库。锁只在会话级写操作上加,读不加锁。TTL 全部加抖动,热点数据用逻辑过期。
这套方案不是最优解,但在多实例、中等并发、成本敏感的 Agent 项目里跑得比较稳。具体参数还得根据你的 QPS、数据量、一致性要求去调。我个人的体会是,Agent 缓存治理这件事,设计阶段多想一步,比上线后救火省十倍力气。尤其是 key 设计和 TTL 策略,改起来牵一发动全身,一开始就定好规矩,后面会轻松很多。