最近半年我一直在带一个AI Agent相关的项目,业务跑起来之后发现一个很有意思的现象:模型层的问题反而好解决,真正让人头疼的是Agent的状态管理、上下文记忆和并发调度。十几个Agent实例同时在线,每个都在维护自己的对话状态,动辄几万字的上下文要频繁读写,Redis几乎成了整个系统里最离不开的组件。这篇就结合我自己的实战过程,聊聊AI Agent开发里Redis缓存那些绕不过去的设计和坑。
1. 从"无状态接口"到"有状态Agent",缓存问题怎么就绕不过去了
1.1 Agent与传统API的本质差异:一切都在"记忆"上
传统后端服务的核心是"无状态"——每次请求进来,该查库查库,该调接口调接口,处理完就完事了。但AI Agent不一样,它天然是"有状态"的。用户和Agent对话,Agent要记住前面聊过什么,要维护当前任务的执行进度,要在多轮工具调用之间保留中间结果。
我见过不少团队把Agent当成普通接口做,结果跑到第三个轮次就开始出问题:上下文丢了、任务重复执行、用户消息乱序。原因很简单——Agent的每个决策都依赖历史状态,而这些状态如果只存在内存里,进程一重启或者负载均衡一转发,整个对话就断了。所以Agent系统对存储的诉求,本质上是对"记忆"的诉求,而且这种记忆要读写够快、容量可控、还能平滑地跨多个实例共享。
1.2 为什么偏偏是Redis:速度、结构、TTL一套三连
选型的时候我也对比过MySQL、MongoDB和本地文件。MySQL够稳但太慢,一次上下文读取在毫秒级网络开销下还能接受,但Agent推理过程中对记忆的读操作极其频繁,动不动就几十次,数据库扛不住也不划算。MongoDB文档模型适合存大块JSON,但每次序列化反序列化开销不小。
Redis胜出靠的是三个特质。
第一是快。所有数据在内存里,读写普遍在微秒到亚毫秒级别,Agent一次推理循环里可能要读写几十次记忆,这个延迟量级完全无感。第二是数据结构丰富。Agent场景不是简单key-value能搞定的——会话要分级、任务要排队、限流要计数、结果要去重,Redis的String、Hash、List、ZSet、Stream几乎覆盖了所有场景。第三是TTL机制。缓存最怕的是无限膨胀,Redis天然支持过期时间,这在做会话记忆时简直是量身定做。
1.3 动手前先想清楚:Agent缓存到底在解决哪三类问题
我在项目里踩过坑之后,把Agent场景下的缓存需求归纳成了三类。如果你正在做类似架构,建议先对照一下自己的需求属于哪一类,不要上来就写缓存。
第一类是记忆存储。这是Agent最核心的诉求,包括会话历史、用户画像、任务上下文、中间推理结果。特征是写入频繁、读取频繁、有一定生命周期。
第二类是并发控制。多个Agent实例并行处理任务时,要解决"同一个任务不能被两个Agent同时执行"、"同一个用户的消息不能被并发处理后乱序"这类问题。这在传统的"分布式锁"场景里就是标准解法,但Agent场景有自己的特殊之处——后面细说。
第三类是性能加速与保护。比如模型结果的缓存、外部API响应的缓存、工具调用结果的缓存,以及用户维度的限流。这类缓存的特征是"可有可无但对体验影响大",也是大多数人最先想到Redis的地方。
2. Agent会话记忆与上下文状态的Redis建模
2.1 会话上下文的存储结构:一个session_id背后的字段设计
先说一个最简单的场景:用Redis存Agent的会话上下文。很多人的第一版设计是这样的——以session_id为key,把整个对话历史序列化成一个大JSON塞进去。这个方案在Demo阶段没问题,但到了生产环境几十万用户一上,马上就会暴露三个问题:全量读写导致每次更新都要重写整个上下文、并发写容易丢更新、消息一长序列化开销受不了。
我现在更推荐的方式是分层存储。
会话元信息单独放一个Hash,比如agent:session:{session_id},里面存user_id、agent_config_id、创建时间、最后活跃时间、当前任务状态这些字段。对话消息单独用List存,比如agent:session:{session_id}:messages,每次追加一条,需要完整上下文时LRANGE取最近N条。任务上下文(比如当前正在执行的tool调用链)用单独的String或者Hash存,因为它的更新频率远高于会话消息,单独存可以有效减少List的频繁修改。
用Hash的好处是可以单独更新某个字段,比如只更新"当前任务状态"时,一条HSET就把事办了,不用像大JSON那样整个读出来再写回去。用List的好处是消息天然有序,而且可以只取尾部的最近N条——实际上大模型根本不需要从头看全部历史,多数场景下取最近20~30条消息就够了,这样既能控制token长度,又能控制Redis内存占用。
2.2 序列化方案选型:别用pickle,也别一上来就JSON堆到底
序列化这块我踩过不少坑。Agent的上下文不像普通缓存那样只是原样存取,它需要经过模型的token化处理,所以序列化的核心诉求是快速、紧凑、可跨语言。
第一优先级是JSON,配好ensure_ascii和自定义的压缩策略。我用的是orjson,比标准库json快5到10倍,序列化bytes直接喂给Redis的SETEX,非常顺手。JSON的缺点是体积偏大,一条带工具调用结果的上下文可能几十KB,但Redis内存就是拿来换速度的,如果把体积当成首要问题,那说明架构设计出了问题。
第二优先级是MessagePack。如果上下文里有大量嵌套结构和数组,MessagePack一般能比JSON省20%到30%的空间,而且反序列化同样很快。缺点是排障时看不到明文,redis-cli里直接GET出来的是一堆二进制,排查问题比较痛苦。
不建议用Python的pickle或者Java原生的序列化。这类方案跨语言完全没法用,而且本质上是在执行不可信的字节码,安全风险极高。Agent系统会接各种各样的上游数据源,一个被污染的序列化数据就能造成反序列化漏洞,这在生产环境里是大忌。
2.3 TTL策略:固定过期时间是最常见的偷懒错误
我看到很多项目的会话TTL就是一个固定值:1800秒,86400秒,拍脑袋定的。但Agent的会话有几个特点:长短混用、活跃期集中、上下文体积大。如果TTL设短了,用户隔一小时回来发现Agent失忆了;设长了,Redis内存里堆满了僵尸会话。
我目前在用的策略是滑动TTL + 分级TTL结合。每个会话的key上不设固定过期时间,而是每次有交互时刷新EXPIRE。同时分成两级:对话活跃期的TTL设短一点(比如30分钟),任务执行中的上下文TTL设长一点(比如2小时)。这样用户聊着聊着不会丢上下文,而真正挂掉的会话又能及时清理。
这里要特别提醒一个看起来隐蔽但实则很常见的坑:如果用SETEX设置整个会话的大JSON,每次追加消息都会重新SET一遍,TTL自然刷新了。但如果像我上面说的那样把消息放在List里,你得在每次LPUSH之后手动EXPIRE,否则List永远不会过期,最终撑爆内存。凡是改动数据的操作,都要记得同步重置TTL。
2.4 向量与嵌入缓存的低频高吞吐玩法
Agent里还有一个容易被忽略的Redis场景——向量与嵌入缓存。比如你做RAG,要查一堆文档的embedding,如果每次都去向量数据库里查,延迟和成本都不可控。把embedding结果按文档块的hash存到Redis里,命中时直接取用,能省掉一次昂贵的相似度计算。
这里的结构建议用Hash:key是agent:embedding:{model_name},field是文档块的hash值,value是向量数组的二进制串。一个embedding通常是1536维的float数组,用array('f')打包成二进制再存入Redis,单条大约6KB,一万条也就60MB,完全吃得住。读取的时候反序列化成numpy数组,整个过程在毫秒级。
3. 并发调度与分布式锁:多个Agent抢同一个任务时的Redis解法
3.1 一个真实的抢单场景:为什么setnx不是全部答案
讲一个我们实际遇到的场景:系统里有多个Agent worker在并行跑任务,上游把任务投递到队列里,多个Agent同时去消费。为了防止同一个任务被两个Agent重复执行,大家很自然就会想到Redis分布式锁。
最原始的做法是SETNX一个key,拿到锁就执行,执行完DEL。Demo里一切正常,上线后立刻出事——Agent执行任务的时间远远超过预期。一次任务可能要调用三四轮大模型,每轮可能好几秒甚至十几秒,如果任务执行到一半锁过期了,另一个Agent就会进来重复执行,造成重复发消息、重复调API、重复扣费。
更隐蔽的是还有锁误删的问题。一个Agent的锁过期后,另一个Agent拿到了新锁,但第一个Agent执行完回来DEL,直接把别人的锁删了。经典解法是在value里存一个唯一标识(比如UUID),DEL之前先GET比对一下再删,但这又引入了原子性的问题——GET和DEL之间可能插入其他操作。
3.2 锁续期与看门狗:大模型慢推理把锁活活等超时
真正可靠的做法是给锁加自动续期。我现在的实现是这样的:加锁时设置一个正常执行周期的过期时间,比如30秒,然后起一个后台守护协程,每隔10秒检查一次,如果任务还在执行就EXPIRE续期,任务结束就主动释放并Stop续期协程。这就是很多人说的"看门狗"机制,跟Java的Redisson看门狗是同一个思路。
但Agent场景有个更特殊的坑:调用大模型API的网络IO可能会长时间阻塞,比如上游服务超时导致一个请求卡了3分钟。如果看门狗只在本进程内续期,而进程本身被一个同步调用卡住了,续期协程照样能跑,这没问题。但如果整个worker被部署成Serverless函数,执行时间有硬上限,看门狗就算续期,上层平台也会掐断进程。这种场景下锁的过期时间就得设计成"能容忍的最大执行时间",并把超时的任务设计成幂等重试,而不是靠锁续期硬扛。
3.3 幂等与去重:缓存键设计比锁本身更重要
分布式锁只能解决"并发冲突",解决不了"重复提交"。Agent任务系统里,最稳的防线其实是幂等键去重。
我在每个任务进队列之前,先按任务的唯一业务键(比如用户ID+消息ID+任务类型)算一个MD5,然后SETNX agent:task:dedup:{md5},拿到一个短期TTL(比如10分钟)。如果SETNX成功,说明这条任务是新的,放行;失败说明之前已经处理过,直接丢弃。这个操作跟分布式锁是两层防线——锁保证同一时刻只有一个Agent在跑,去重键保证即使网络重试、消息重发,任务也不会被执行两遍。
顺带说一个经验:去重键的TTL别设太长。Agent场景很多任务确实需要重试,比如用户点了一下"重试",这是合理的业务需求,你TTL设成24小时就会把正常的再次请求干掉。10分钟到30分钟是个比较合理的区间,既能挡住短时间内重复提交,又不至于误伤合理重试。
4. 缓存之外:Redis数据类型在Agent链路里的花样用法
4.1 用HyperLogLog做用户级限流
Agent的接口调用是有成本的,调用一次模型就要花钱,所以限流是刚需。最常见的需求是"这个用户每分钟最多调用30次模型"。
用Redis的INCR加EXPIRE就能实现滑动窗口之外的固定窗口限流,但我要安利一个更快的方式——HyperLogLog。它可以用很小的空间估算唯一计数,误差在0.81%左右。做法是每分钟一个key,agent:rate:{user_id}:{minute},每来一次请求就PFADD一次,PFCOUNT看当前值有没有超阈值。
它的底层是概率数据结构,所以精确度不如INCR,但优点是真省内存——一个key就算塞进十万个元素,也只要12KB左右。对Agent这种高频低成本的限流场景来说完全够用。
4.2 ZSET做Agent任务优先级队列
Agent系统的任务队列不都是先进先出,很多任务有优先级。比如用户主动触发的一个查询,优先级应该高于后台的预加载任务。如果用List做FIFO,你没法在中间插队。
用ZSET就很优雅:score直接设为优先级加时间戳。比如score = 优先级 * 10^13 + 时间戳,这样先按优先级排,优先级一样时按时间先后。拿出一个任务用ZRANGEBYSCORE配合ZPOPMIN,实现起来非常干净。
配合另一把"正在执行"的ZSET,就能做一个简单的任务进度管理:任务进来放pending ZSET,Agent认领时移动到running ZSET,执行完删除。如果running里的任务超过一定时长没消失,说明对应的Agent挂了,可以由其他实例把这些任务重新放回pending重试。这个做法只用了两个ZSET,没有引入额外组件,但把任务调度的核心状态机全管住了。
4.3 Stream做事件总线:Agent之间的消息传递
如果Agent系统需要事件广播,比如"用户发新消息了,所有订阅了该用户事件的Agent都要感知到",用Redis的Stream比用消息队列中间件更轻。
Stream支持消费者组、ACK确认、消息持久化,体验很接近Kafka,但部署成本低得多。我们内部就把Agent之间的异步事件全部走Stream,比如工具调用结果回传、任务完成通知、上下文失效通知。用的是XADD写入事件,消费者组用XREADGROUP消费并XACK确认。吞吐量虽然比不了Kafka,但对中小规模的Agent编排已经绰绰有余。
4.4 排行榜、计数与布隆过滤器场景速览
- 排行榜:如果要展示"最活跃Agent排名""用户使用频次榜",ZSET按住次INCRBY然后ZREVRANGE直接出榜。
- 计数器:PV/UV、调用次数、成本汇总,INCRBY一把梭。成本统计很重要,Agent系统每轮调用的token数和成本最好实时累加到一个Hash里,方便看板直接读。
- 布隆过滤器:判断一个内容是否已经处理过。比如爬取网页摘要喂给Agent做RAG,用RedisBloom的BF.ADD去重,能挡住绝大多数重复内容,避免每次都要查向量库确认。
5. 缓存穿透、击穿、雪崩与一致性问题:Agent缓存失效的连锁反应
5.1 穿透:恶意刷session和不存在Key的防护
Agent系统里缓存穿透的典型场景是:攻击者或异常调用方不断地用随机session_id请求Agent接口,每次Redis查不到,就会去查数据库或者直接触发一次模型调用——这就是灾难,因为模型调用又贵又慢。
防护思路很简单:查不到的数据也缓存一个占位空值,TTL设短一点,比如30秒。这样同一个不存在的session在30秒内第二次来,直接命中空值缓存,不会穿透到下游。另外在Agent入口处做一个session合法性校验,Redis里存一份已注册的session集合,不在集合内的请求直接拦掉,完全不给穿透的机会。
5.2 击穿:热点Agent实例上的单一Key失效
一个热门的Agent(比如某个爆款的客服机器人)可能每秒被上千个用户并发请求,如果它的配置信息或系统提示词缓存在Redis里的Key刚好失效了,一瞬间所有请求都会同时去重建缓存,把数据库或者配置中心压垮。
解法就是大家常说的互斥锁重建:发现缓存失效时,不是直接去查,而是先尝试获取一个锁,拿到锁的请求去重建缓存,其他请求短暂等待后重新读缓存。因为AI Agent的缓存重建成本很高(可能要拼装很多配置项),这个互斥锁的价值比其他系统更大。
5.3 雪崩:设置不同TTL时随机抖动
我见过一个Agent项目上线当天就雪崩过:所有会话都设置了24小时过期,某个时刻排队在同一秒过期,导致Redis读请求瞬间全部Miss,直接打到慢速存储上,整个服务抖动了几分钟。
解法很简单,但很多人就是不做:TTL加一个随机抖动。比如基础TTL是3600秒,实际设置时加上一个0到600秒的随机偏移。这样即使同一批会话创建时间接近,过期时间也会被均匀打散。
5.4 一致性方案:Cache Aside加上延迟双删在Agent场景的实战评估
Agent缓存的一致性问题比传统业务缓存更微妙。比如用户资料的缓存,用户在对话中修改了自己的偏好设置,如果缓存没同步更新,Agent就还在用旧数据跟用户对话——这不是报错,而是体验灾难,用户会觉得"你怎么不听我的话"。
我目前在用的是**Cache Aside(旁路缓存)**模式配合延迟双删。流程是:读的时候先查Redis,查到直接用,查不到读数据库再写回缓存;写的时候先更新数据库,然后删除缓存,再过一个很短的延迟(比如500毫秒)删除一次缓存。第二次删除是为了处理并发场景下"读线程先读了旧数据写回缓存,写线程又更新了数据库"的竞态。
在Agent场景里需要根据数据分类来选一致性强度。会话消息这种只追加不改的数据,一致性要求低,直接Cache Aside就够了。但用户配置这种高频覆盖的数据,必须用双删。还有一类是外部API的响应缓存,比如天气查询、订单状态,这类数据的时效性要求很高,我建议直接用SETEX,TTL设短,不要做复杂的同步逻辑——因为Agent对这类数据的新鲜度敏感度极高,返回一条过期天气比没返回更让人抓狂。
6. 一套可落地的Agent缓存架构参考(含容量估算)
6.1 分层缓存结构:本地缓存加Redis减薄主存储压力
经过前面这些轮的迭代,我现在的Agent缓存架构是分层的,不是为了炫技,而是每个层有每个层的职责。
第一层是进程内本地缓存(我用Caffeine或Python的cachetools),存的是Agent的模型配置、Prompt模板、常用工具定义这类几乎不变的数据。TTL设5分钟,命中率极高,延迟是纳秒级。这层最大的价值是减少了对Redis的读压力——不要让Redis替进程内缓存背锅。
第二层是Redis主缓存,存会话上下文、任务状态、限流计数、分布式锁、去重键这些需要跨实例共享的数据。TTL和淘汰策略按前面说的规则来。
第三层是冷数据存储,通常是数据库或对象存储,存完整历史消息、审计日志、长周期统计。Redis只存最近N条消息,更早的消息归档到库里。需要完整上下文时,先从Redis拿最近消息,再从库里补更早的,拼装成完整对话树。
这个分层模型的好处是:Redis的容量需求是可计算的,不会因为"把所有历史消息都存在Redis里"这种偷懒写法而失控。
6.2 容量预估的粗算公式
很多团队在做容量规划时没概念,我把我的粗算方法写出来供参考。
假设你有10万活跃用户,平均每个用户每天跟Agent交互20轮,每轮上下文平均5KB,会话保留窗口是24小时。那Redis里的会话缓存体量大约是:
10万用户 x 20轮 x 5KB = 10GB/天
如果只保留24小时,那Redis主缓存至少要准备10GB到12GB的容量,这还没算任务状态、限流计数、向量缓存这些附属数据,加起来大概再乘1.3,也就是13GB到16GB左右。再加上Redis本身会有一些复制缓冲和碎片开销,生产环境建议准备20GB的maxmemory,配合allkeys-lru的淘汰策略兜底。
这里给个数据点做参考:我们一个中等规模的Agent服务,8GB内存的Redis实例,跑10万级日活用户完全没压力。真正涨内存的都是因为设计失误——比如没有分层存储、List没设TTL、向量缓存过期时间设太长。
6.3 监控与治理:可视化客户端和慢查询定位
生产环境的Redis不能黑盒运行。我推荐至少做三件事。
第一,部署一个可视化客户端(比如Another Redis DeskTop Manager,或者如果用了云服务,直接用云厂商的Redis控制台)。别小看这个——线上排查问题时,能直观看到一个Key的数据结构、TTL、占用内存,比在redis-cli里打命令高效太多。
第二,开启slowlog监控。Redis从4.0开始支持slowlog,把执行时间超过某阈值的命令记录下来。在Agent场景,大部分慢命令来自两种情况:一是大Key的批量操作(比如一次性LRANGE几千条消息),二是KEYS这种全量扫描命令。强烈建议把生产环境的KEYS禁用,改用SCAN——Agent系统里经常有按用户维度查所有session的需求,一旦有人用KEYS查,Redis直接卡顿,整个Agent链路跟着抖动。
第三,监控内存淘汰量。Redis的淘汰策略是"宁可丢缓存也不能OOM",但如果淘汰量持续偏高,说明maxmemory设置太小或者TTL设计不合理。淘汰量可以从INFO stats里的evicted_keys字段观察。
6.4 从Redis的版本与线程模型看选型建议
聊到选型,顺便说说Redis的线程IO模型。很多人只知道"Redis是单线程所以快",其实这个说法在6.0以后就不完全准确了。Redis的命令执行是单线程的,但网络IO已经从单线程改成了多线程——6.0之后默认开启了IO多线程。这带来的直接体验是:高并发下的吞吐提升明显,而且跟Agent这种大量小包读写的场景特别契合。
我的建议是生产系统尽量用Redis 6.2或7.x版本。7.x有几个对Agent场景很有用的特性:一是更精细的过期策略,二是FUNCTION(服务端脚本)能力,三是更完善的可观测性。尤其是FUNCTION,如果你要在Redis里做原子性地"读上下文+更新计数器"这种组合操作,以前用Lua脚本还得管脚本分发,7.x直接在服务端管理,爽很多。
另外提一下Windows环境的Agent开发者。本地调试时,Windows没法直接跑官方Redis,可以用Memurai或者Docker方式跑一个Redis容器。Docker方案最干净,一条命令就能拉起来,跟Linux生产环境的行为完全一致,别在本地用那些魔改的Windows Redis发行版,调试行为跟生产不一致会害死人。
写到这里,我还是要啰嗦一句:Redis在Agent系统里不是可有可无的辅助,而是记忆和协作的地基。但地基也不是越复杂越好——我自己从最开始满脑子分布式锁、Lua脚本、Stream事件总线,折腾了一大圈,最后发现80%的场景,老老实实的String加Hash加List,配上合理的TTL和序列化方案,就已经能撑起整个Agent的生产需求了。先把基础打牢,再去秀数据结构,这个顺序不要反了。