☰
AI Agent 缓存实战:Redis 状态管理与并发治理
2026/10/6 15:08:10 网站建设 项目流程

1. 从一次线上抖动说起:AI Agent 为什么绕不开 Redis

先说一个我亲身经历的场景。去年帮一个团队调优他们的 AI Agent 服务,功能本身跑得挺好——用户提问、Agent 规划任务、调用工具、返回结果,链路清晰。但上线第二周开始,监控上出现了一个很规律的现象:每隔几分钟,接口 P99 延迟就从 300ms 飙到 4s 以上,然后自己恢复。日志里翻来翻去,最扎眼的一行是Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。

这个报错很多人见过,但放在 AI Agent 的语境下,它的成因和传统 CRUD 应用完全不是一回事。传统业务里 Redis 超时,多半是热点 key 或者大 key 拖垮了单节点;而 AI Agent 的超时,往往是因为Agent 的思考过程本身是长耗时、多轮次、带状态的,这些状态如果无脑往 Redis 里塞,很容易把缓存层当成数据库用,最后把连接池和内存一起拖垮。

所以这篇内容我想聊的不是"Redis 怎么装、怎么用"这种入门话题,而是聚焦一个更具体的问题:当 AI Agent 遇上 Redis 缓存,哪些东西该缓存、哪些绝对不能缓存、并发上来之后怎么扛、以及那些只有踩过才知道的坑。适合正在搭建 AI Agent、或者已经上线但被缓存问题折磨的开发者。如果你还在纠结 Redis 的五大基本类型,这篇也能看,但重点不在那儿。

先把结论摆前面:AI Agent 用 Redis,核心不是"缓存数据",而是"缓存可复用的中间态"。这个定位一旦搞错,后面全是坑。

2. AI Agent 的状态到底长什么样,决定了缓存怎么设计

2.1 Agent 的三类状态:会话态、工具态、模型态

要设计缓存,先得把 Agent 运行过程中产生的数据分清楚。我一般把它分成三类,这三类的缓存策略完全不同。

会话态(Session State):用户和 Agent 的对话历史、当前任务进度、已确认的参数。这类数据的特点是读写频繁、生命周期跟会话绑定、必须强一致。比如用户说"帮我订明天下午三点的会议室",Agent 记下了"明天下午三点"这个约束,下一轮对话里用户改口说"改成四点",Agent 必须能读到之前的状态并更新。这类数据我建议放 Redis 的 Hash 结构,一个 session 一个 key,字段存各个槽位。

工具态(Tool State):Agent 调用外部工具(搜索、数据库查询、API 请求)的返回结果。这类数据的特点是可能很大、可能重复、有 TTL 需求。比如 Agent 连续三次调用同一个天气 API,结果完全一样,那第二次第三次就该命中缓存。这类数据适合用 String 存序列化后的 JSON,带明确的过期时间。

模型态(Model State):LLM 的推理结果、embedding 向量、prompt 模板渲染结果。这类数据的特点是计算昂贵、结果确定、可长期复用。比如一段固定的 system prompt 渲染出来的结果,或者同一个问题的 embedding,缓存起来收益极高。embedding 向量我一般用 Redis 的向量检索能力(Redis Stack 的 RediSearch 模块)来存,普通文本结果用 String。

把这三类分清楚,你就知道为什么不能"一把梭"全塞进 Redis 了——它们的读写模式、一致性要求、体积差异太大,混在一起必然出问题。

2.2 为什么"缓存一切"是 AI Agent 最危险的想法

我见过不少团队,图省事,把 Agent 每一步的中间结果都往 Redis 写,想着"反正 Redis 快"。结果呢?内存暴涨、连接池打满、序列化开销比计算本身还大。

这里有个反直觉的点:AI Agent 的瓶颈通常不在计算,而在 I/O 和序列化。LLM 调用本身是网络 I/O,工具调用也是网络 I/O,如果你在中间又插一层 Redis 读写,而且每次读写都做 JSON 序列化/反序列化,那这点开销累积起来非常可观。我实测过一个案例,一个 8KB 的 Agent 状态对象,用 Jackson 序列化一次大约 0.3ms,看起来不多,但如果一个请求链路里读写 20 次,就是 6ms 纯开销,还没算网络往返。

所以我的原则是:只缓存"重新计算成本 > 缓存读写成本"的数据。这个判断需要你对自己的链路有清晰的耗时认知。LLM 调用动辄 1-3 秒,那缓存它的结果绝对划算;但一个内存里的字符串拼接,你缓存它纯属给自己找麻烦。

2.3 一个具体的状态结构设计示例

光说理论太虚,给个我实际用过的结构。假设是一个客服 Agent,session key 设计成agent:session:{sessionId},用 Hash 存:

import redis import json r = redis.Redis(host='localhost', port=6379, decode_responses=True) def save_session(session_id, state): key = f"agent:session:{session_id}" # 只存需要跨请求共享的字段,临时变量不要塞进来 r.hset(key, mapping={ "history": json.dumps(state["history"][-10:]), # 只留最近10轮 "slots": json.dumps(state["slots"]), # 已确认的参数槽位 "stage": state["stage"], # 当前任务阶段 "updated_at": state["updated_at"] }) r.expire(key, 1800) # 30分钟无活动自动清理 def load_session(session_id): key = f"agent:session:{session_id}" data = r.hgetall(key) if not data: return None return { "history": json.loads(data["history"]), "slots": json.loads(data["slots"]), "stage": data["stage"], "updated_at": data["updated_at"] }

注意几个细节:history 只留最近 10 轮,因为更早的对话对当前决策价值极低,全存进去只会让每次读写都变慢;slots 单独存,因为它是 Agent 决策的核心依据,需要频繁读取;整个 key 设 30 分钟过期,避免僵尸会话堆积。这些都是踩过坑之后定下来的。

3. 并发一上来就崩:AI Agent 的缓存并发治理

3.1 缓存击穿在 Agent 场景下的特殊形态

"缓存击穿"这个词大家熟,就是某个热点 key 过期瞬间,大量请求同时打到后端。传统场景下后端是数据库,扛不住就加锁。但 AI Agent 场景下,后端是 LLM,情况更糟——LLM 调用又慢又贵,一旦击穿,不仅慢,还烧钱。

我遇到过一个典型场景:某个热门问题的 embedding 缓存刚好过期,同一秒来了 200 个请求,全部穿透到 embedding 服务,瞬间把配额打满,后续请求全部失败。这种事故的根因不是 Redis 不行,而是没有对"昂贵计算"做并发保护。

解决方案是分布式锁 + 双重检查。但这里有个坑:很多人用SETNX加锁,忘了设过期时间,一旦持锁进程崩溃,锁永远不释放。正确做法是用SET key value NX EX 10这种原子命令,value 用唯一标识(比如 UUID),释放时用 Lua 脚本校验再删,避免误删别人的锁。

import uuid import time def get_embedding_with_lock(text, compute_fn): cache_key = f"agent:embedding:{hash(text)}" cached = r.get(cache_key) if cached: return json.loads(cached) lock_key = f"lock:{cache_key}" lock_value = str(uuid.uuid4()) # NX 保证只有一个能拿到锁,EX 防止死锁 acquired = r.set(lock_key, lock_value, nx=True, ex=10) if acquired: try: # 双重检查:拿到锁后再看一眼缓存,可能别人刚写完 cached = r.get(cache_key) if cached: return json.loads(cached) result = compute_fn(text) r.set(cache_key, json.dumps(result), ex=3600) return result finally: # Lua 脚本保证"校验+删除"原子性 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, lock_value) else: # 没拿到锁,短暂等待后重试读缓存 time.sleep(0.1) cached = r.get(cache_key) if cached: return json.loads(cached) # 兜底:直接计算,避免无限等待 return compute_fn(text)

这段代码的关键在于双重检查和兜底逻辑。双重检查避免重复计算,兜底逻辑避免锁竞争时请求全部卡死。实测下来,200 并发打同一个 key,只有 1 个真正计算,其余全部命中缓存或快速兜底。

3.2 连接池配置:Lettuce 超时的真正原因

回到开头那个RedisCommandTimeoutException。很多人第一反应是"Redis 挂了",其实十有八九是连接池配置不合理。

AI Agent 的特点是请求耗时波动极大——简单问题 200ms,复杂任务可能 30 秒。如果连接池的command timeout设得太短(比如默认的 60ms 在某些客户端里),长耗时操作还没返回,连接就被判定超时了。但设太长又会导致连接被长时间占用,池子很快耗尽。

我的经验配置是这样的(以 Lettuce 为例):

参数建议值理由
max-activeCPU核数 * 4太高反而增加上下文切换
max-idleCPU核数 * 2保持适量热连接
min-idleCPU核数避免频繁创建销毁
command-timeout500ms覆盖绝大多数正常操作
shutdown-timeout200ms优雅关闭

关键点:command-timeout 要区分操作类型。普通的 GET/SET 设 200-500ms 足够,但如果你有耗时的 Lua 脚本或大 key 操作,得单独处理,不能一刀切。我一般会把耗时操作拆出来,用独立的连接配置。

3.3 用 Pipeline 和批量操作减少往返

AI Agent 一个请求链路里,往往要读写多个 key。如果每个 key 都单独发一次命令,网络往返次数会非常可观。这时候 Pipeline 就是救星。

比如加载一个完整 session,需要读 history、slots、stage 三个字段,用 Hash 的hgetall一次搞定;但如果它们分散在不同 key 里,就该用 Pipeline:

def batch_load(session_id): pipe = r.pipeline() pipe.get(f"agent:history:{session_id}") pipe.get(f"agent:slots:{session_id}") pipe.get(f"agent:stage:{session_id}") results = pipe.execute() return results

一次网络往返拿三个值,比三次单独请求快 2-3 倍。这个优化在 QPS 上千之后效果特别明显。但要注意,Pipeline 不是事务,中间某条命令失败不会回滚,所以只适合"读多写少、允许部分失败"的场景。

4. 缓存什么、不缓存什么:一份实战决策清单

4.1 强烈建议缓存的四类数据

结合我自己的实践,AI Agent 里最值得缓存的四类数据是:

第一,embedding 向量。这是收益最高的。同一个文本的 embedding 是确定的,而计算一次 embedding 可能要几十到几百毫秒。缓存起来,重复查询直接命中。用 Redis Stack 的话,还能顺便做向量相似度检索,一举两得。

第二,LLM 的确定性输出。注意是"确定性"——temperature 设为 0 的场景,同样的 prompt 输出基本一致,可以缓存。但如果 temperature 大于 0,每次输出都不同,缓存就没意义了,反而会返回过时结果。

第三,工具调用的幂等结果。比如查询类 API、汇率、天气、知识库检索,这些结果在一定时间内稳定,缓存 TTL 设个几分钟到几小时都合理。

第四,prompt 模板渲染结果。如果模板复杂、变量多,渲染本身有开销,缓存渲染后的结果能省不少 CPU。

4.2 绝对不能缓存的三类数据

反过来,这几类千万别缓存:

第一,带用户隐私的原始对话。除非你有明确的合规方案和加密措施,否则原始对话内容缓存到 Redis 风险极高。我一般只缓存脱敏后的结构化槽位,不缓存原文。

第二,实时性要求极高的数据。比如股票价格、库存数量,缓存 1 秒都可能出错。这类数据要么不缓存,要么 TTL 设到秒级并配合主动失效。

第三,大对象。单个 value 超过 10KB 就要警惕,超过 100KB 基本就是设计问题。大 key 不仅占内存,还会阻塞 Redis 单线程,影响所有请求。我见过有人把整个对话历史(几十 KB)塞一个 key,结果每次读写都卡顿。

4.3 TTL 怎么定:一个可落地的计算方法

TTL 定多少,很多人拍脑袋。我给个可计算的方法:

TTL = 数据可容忍的最大陈旧时间 × 安全系数(0.8)

比如工具调用结果,业务上能容忍 5 分钟陈旧,那 TTL 设 4 分钟。为什么要乘安全系数?因为 Redis 的过期是惰性删除 + 定期删除,不是精确到点就删,留点余量避免边界情况返回过期数据。

另外,TTL 要加随机抖动。如果一批 key 同时创建、同时过期,会造成"缓存雪崩"——同一时刻大量请求穿透。做法很简单:

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)

在基础 TTL 上加 10% 的随机量,就能把过期时间打散,避免集中失效。

5. 那些只有踩过才知道的坑

5.1 序列化选错,性能差十倍

这个坑我踩得最深。早期用 Java 的默认序列化(JDK Serializable)存 Agent 状态,结果发现 CPU 占用异常高。后来换成 JSON,性能提升明显;再后来对高频小对象换成 MessagePack,又提升一截。

不同序列化方案的对比,我实测过一组数据(对象约 2KB):

方案序列化耗时反序列化耗时体积
JDK Serializable1.2ms1.5ms3.1KB
JSON (Jackson)0.3ms0.4ms2.4KB
MessagePack0.15ms0.2ms1.8KB
Protobuf0.1ms0.15ms1.5KB

结论很清楚:能用二进制就别用文本,能用紧凑格式就别用臃肿格式。但也要权衡可读性——调试阶段 JSON 方便看,上线后可以换。我的做法是开发环境用 JSON,生产环境按数据热度分级,热数据用 MessagePack。

5.2 缓存与数据库的一致性:Agent 场景下的取舍

"缓存和数据库怎么保持一致"是经典难题。AI Agent 场景下,我的建议是尽量让 Redis 成为唯一数据源,而不是数据库的缓存。

为什么?因为 Agent 的状态数据大多是"过程性"的,不像订单、用户这种需要持久化的核心资产。会话态、工具态这些,丢了重新生成就行,没必要搞双写一致性那套复杂逻辑。真正需要持久化的(比如用户确认的最终结果),直接落库,不走缓存。

如果确实需要双写,记住一个原则:先更新数据库,再删除缓存,而不是更新缓存。删除比更新安全,因为更新可能因为并发导致旧值覆盖新值。删除的话,下次读自然回源,最多一次脏读。

5.3 内存淘汰策略:别让 Agent 把 Redis 撑爆

Redis 内存满了会触发淘汰。默认的noeviction策略下,写入直接报错,Agent 直接挂。所以生产环境一定要配淘汰策略。

对 AI Agent 场景,我推荐allkeys-lru或volatile-lru。区别在于:allkeys-lru对所有 key 淘汰,volatile-lru只淘汰设了过期时间的 key。如果你所有缓存 key 都设了 TTL,用volatile-lru更安全,因为它不会误删那些没设 TTL 的重要数据。

但更根本的做法是监控内存使用率,提前扩容。我一般设两个告警线:70% 预警,85% 紧急。到 85% 还没处理,就该考虑加节点或者清理冷数据了。

5.4 分布式锁的坑:锁续期与误删

前面提了分布式锁,这里补充两个高频坑。

锁续期问题:如果业务执行时间超过锁的过期时间,锁会自动释放,别的请求就能拿到锁,导致并发。解决方案是"看门狗"机制——后台起个线程定期给锁续期。Redisson 这类库已经内置了,自己实现的话要小心。

误删问题:A 拿到锁,执行超时锁释放,B 拿到锁,这时 A 执行完去删锁,删的是 B 的锁。这就是为什么释放锁必须用 Lua 脚本校验 value。这个坑我见过太多人踩。

6. 从单机到集群:AI Agent 缓存的扩展路径

6.1 什么时候该上集群

单机 Redis 扛不住的时候,第一反应往往是上集群。但先别急,问自己三个问题:内存不够了?QPS 到瓶颈了?还是单纯觉得"该上集群了"?

如果是内存不够,先看能不能优化数据结构、清理冷数据、压缩 value。很多时候优化一轮,内存能省 30% 以上。如果是 QPS 瓶颈,先看是不是有大 key 或者慢命令拖累,优化掉往往能提升数倍。

真正需要集群的信号是:单机内存持续超过 80%,且优化空间已尽;或者 QPS 稳定超过单机处理能力(通常 8-10 万)。这时候再考虑集群。

6.2 集群模式下的 key 设计

Redis Cluster 把数据分到 16384 个槽,key 通过 CRC16 取模决定落在哪个槽。这意味着跨槽的操作(比如事务、Lua 脚本涉及多个 key)会失败。

对 AI Agent 来说,最典型的问题是:session 的多个字段如果分散在不同 key,可能落在不同槽,无法用事务保证原子性。解决方案是用hash tag——用{}包裹 key 的一部分,让相关 key 落到同一个槽:

agent:session:{user123}:history agent:session:{user123}:slots agent:session:{user123}:stage

这样{user123}相同的 key 一定在同一槽,可以安全地用事务和 Lua。这个技巧在集群环境下几乎是必备的。

6.3 主从与读写分离的取舍

AI Agent 的读远多于写,读写分离能显著提升吞吐。但要注意主从延迟——写完立刻读,可能读到旧值。

对 Agent 场景,我的做法是:强一致的读走主节点,弱一致的读走从节点。比如 session 状态这种写完马上要读的,走主;而 embedding 缓存这种写一次读多次的,走从完全没问题。

配置上,Lettuce 支持readFrom策略,可以设成SLAVE_PREFERRED,优先读从节点,从节点不可用时回退主节点。这样既提升吞吐,又保证可用性。

7. 监控与调优:让缓存问题无处遁形

7.1 必须盯住的几个指标

缓存出问题,往往不是突然的,而是有征兆的。我一般盯这几个指标:

  • 命中率:低于 80% 就要查原因,可能是 TTL 太短、key 设计不合理、或者缓存被频繁淘汰。
  • 内存使用率:超过 70% 预警,配合淘汰策略观察。
  • 慢查询:slowlog里超过 10ms 的命令都要关注,往往是大 key 或复杂命令。
  • 连接数:接近 maxclients 就要扩容或优化连接复用。
  • 网络往返延迟:突然升高可能是网络问题或 Redis 阻塞。

这些指标用 Redis 自带的INFO命令就能拿到,配合 Prometheus + Grafana 做可视化,基本能覆盖大部分问题。

7.2 慢查询的定位与优化

SLOWLOG GET 10能拿到最近 10 条慢查询。看到慢查询后,按这个顺序排查:

  1. 是不是大 key?用MEMORY USAGE key看单个 key 大小。
  2. 是不是复杂命令?比如KEYS *、HGETALL大 Hash、SMEMBERS大 Set。
  3. 是不是 Lua 脚本太复杂?脚本执行是阻塞的,越短越好。
  4. 是不是网络问题?看客户端和服务端的延迟。

我遇到最多的就是大 key。一个 1MB 的 Hash,HGETALL一次要几毫秒,高并发下直接拖垮。解决方案是拆分——按字段前缀拆成多个小 Hash,或者用HSCAN分批读。

7.3 一个真实的调优案例复盘

最后分享一个我调过的案例。某 Agent 服务,QPS 2000 左右,P99 延迟 800ms,其中 Redis 相关占 300ms。排查发现三个问题:

问题一:session 用 String 存整个 JSON,每次读写都要全量序列化。改成 Hash 后,只读写需要的字段,序列化开销降了 70%。

问题二:embedding 缓存没有加锁,热点 key 过期时大量穿透。加了分布式锁 + 双重检查后,穿透请求从每秒几百降到个位数。

问题三:连接池 max-active 设成了 500,远超实际需要,导致连接创建销毁频繁。调到 CPU 核数 * 4 后,连接相关开销明显下降。

三个问题解决后,P99 降到 250ms,Redis 相关开销降到 40ms。这个案例说明,大部分缓存性能问题不是 Redis 本身的问题,而是使用方式的问题。

8. 写在最后的一点个人体会

做 AI Agent 的缓存优化,我最大的体会是:别把 Redis 当万能药,也别把它当黑盒。它快,但快是有前提的——前提是你的数据结构合理、key 设计得当、并发保护到位。一旦这些前提不满足,Redis 反而会成为整个链路最脆弱的一环。

另外,AI Agent 这个领域变化太快,今天的最佳实践明天可能就过时。比如向量缓存,以前大家用普通 String 存,现在 Redis Stack 直接支持向量检索,玩法完全不一样了。所以保持学习、保持实测,比记住任何一条"最佳实践"都重要。

如果你正在做类似的事情,建议先从"分清三类状态"开始,把该缓存的、不该缓存的分清楚,再逐步优化并发和扩展性。别一上来就追求集群、追求极致性能,先把基础打牢,后面的事自然水到渠成。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询