Flashduty AI SRE 是一个专注 SRE 领域、帮你做故障根因分析、日常运维工作的 AI Agent 产品,注册即可免费试用,这是系列文章《Flashduty AI SRE 技术揭秘》第 1 篇,本系列会持续分享我们在 agent 工程上的真实设计、踩过的坑和做过的取舍,为了把它做到生产级水准,可以唠的点还是蛮多的。
我们把 AI Agent 做成了分布式的:7x24 运行,同时服务组织内的所有用户,任务分散到多台服务器上。相比在本机跑一个 Codex 或 Cursor,这带来了很多额外的问题。
本篇讲消息处理机制。每个 AI Agent 会话有三条要求:同一次对话里的消息严格按顺序处理;服务器重启不丢消息,也不丢做了一半的工作;空闲的会话不占资源。我们折腾了挺久,最终方案可以概括为:
给每个会话配一个“专职管家”,用 Redis 里一把 30 秒自动过期的锁,保证同一时刻整个系统里只有一个管家在服务这个会话。
消息怎么排队、服务器挂了怎么办、为什么不用现成的消息队列,都从这一条展开。
问题背景
用户给 agent 发一句话:“帮我看看昨晚的告警是怎么回事。”接下来 agent 可能要跑好几分钟:查监控、翻日志、执行命令、调各种工具。
这和普通 Web 请求差别很大。普通接口几十毫秒返回,出了错,前端重试一次即可。agent 的一轮分析要调用十几次大模型,花的是真金白银的 token 费用;它执行的命令可能改动真实环境;用户就守在屏幕前,看着进展一条一条输出。中途出问题的代价因此高得多,而在这几分钟里,问题一定会出现(唉,该死的墨菲定律):
- 用户会追加消息。“顺便看下数据库的慢查询。”这句话不能打断正在进行的分析:模型正在等上一个工具的结果,这时插进新消息会打乱工具调用的顺序,这一轮的上下文就不能用了。也不能丢,否则用户会看到自己发的消息没有任何回应。它得排队,等这一轮跑完再处理。
- 服务器会重启。每次发布新版本,机器要滚动下线。假如一次分析跑了四分钟,还差一分钟出结论,承载它的进程收到了下线信号。如果没有别的进程接手,用户面前就是一个一直转圈的加载动画,那四分钟花掉的 token 也白花了。
- 我们有很多台服务器(多个 pod)。用户的下一条消息可能被负载均衡送到任何一台机器上,但同一个会话同一时刻只能有一个进程在处理。如果没有协调,两台机器可能同时认领同一个会话:同一轮分析跑两遍,账单翻倍;同一个工单创建两次,同一个重启命令执行两次;两份输出交错写进同一份会话历史,agent 之后会把自己没说过的话当成上下文,开始一本正经地胡言乱语。
三个设计决定
一把锁,保证只有一个管家
每个会话对应一个“管家”(我们内部叫 actor,就是一段专门服务这个会话的代码)。管家开始工作前,先去 Redis 抢一把锁:
- 锁只在不存在时才能创建,30 秒后自动过期(Redis 的
SET NX EX)。抢到锁的进程是这个会话唯一的管家,抢不到的立刻退出。 - 抢到锁后每 10 秒续期一次,表示自己还在工作。
- 会话闲置超过 5 分钟,管家主动释放锁并退出。
如果管家所在的进程崩了,锁最多 30 秒后过期,别的进程抢到锁,接手这个会话。不需要人工介入,也不需要选主协议。
发消息的人只投递,不等待
所有给会话发消息的入口(网页端的消息接口、子 agent 完成任务后的汇报、自动化任务的触发)都只做两件事:把消息追加到这个会话在 Redis 里的队列尾部,然后发一条 Redis Pub/Sub 通知。发完就返回,不等处理结果。
管家这边循环做三件事:从队列里逐条取出消息处理;处理完删掉;队列空了就挂起,等下一条通知。
投递和处理分开之后,生产者的数量、速度和位置都和管家无关。消息可以从任何一台机器、任何进程写进来,顺序由 Redis 队列保证,处理只在管家一处进行。
闲着的管家,不许占资源
这一条最容易被忽略,对成本的影响却最大。
agent 平台上,绝大部分会话在绝大部分时间里都是空闲的。用户问完一个问题就走了,下一句可能半小时后才来。如果每个空闲管家都用阻塞式读取占着一条 Redis 连接等待,Redis 连接数就会随空闲会话数线性增长:一万个空闲会话就是一万条连接,建立和维护这些连接本身就是一笔开销。
我们的做法是:管家处理完队列后不占任何 Redis 连接,在自己进程的内存里挂起,等本机或其他机器发来的通知唤醒它。为防通知丢失,再加一道兜底:每 5 秒检查一次队列,有消息就处理,没有就继续挂起。用户通常几分钟后才回来,5 秒的延迟感觉不到;Redis 连接数也不再随空闲会话数增长,只跟此刻正在处理消息的会话数有关。

多个入口投递,一位管家顺序处理;下方是同一管家的空闲状态。账本保留事件,广播负责即时通知。
管家进程崩溃时,处理到一半的消息怎么办
上面的流程有个缺口:管家从队列里取出一条消息,正在处理时进程崩了。这条消息已经离开队列,它去哪了?
解决办法是分两步记账。管家取消息时不直接取走,而是把消息从待处理队列移动到 Redis 里另一个列表,叫 processing 列表(Redis 的 LMOVE);只有处理成功后,才把它从 processing 列表删掉。
新管家接手后的第一件事,是检查 processing 列表:上一任有没有留下没处理完的消息?有就按原顺序搬回待处理队列的头部(RPOPLPUSH)。消息不会丢,顺序也不会乱。代价是同一条消息可能被处理两次,所以下游的每一步处理都设计成重复执行无害。
还有一种情况:上一任管家没崩,只是卡住了(比如一次处理迟迟不返回),期间没能续期,锁过期后新管家已经接手。等它醒过来处理完手上那条消息,如果照常清空 processing 列表,就会删掉新管家正在处理的消息。所以每任管家接手时都领一个 owner token,清理 processing 列表前先核对,token 已经不是自己的就不动。锁过期只能说明持有者 30 秒没续期,不能说明它已经退出。
这个模式很老,每个消息队列教程里都有。但在 agent 场景里值得单独说:一条消息背后可能是一次已经跑了几分钟、花了不少 token 的分析。丢一条消息,代价不是少发一封邮件,而是用户看着分析中断,token 白花了。
我们故意没做的事
没用 Redis Streams,也没上 Kafka。 主流的分布式队列方案我们几乎都评估过,最后选了最简单的一种:Redis 列表 + Pub/Sub。理由是:会话事件的完整记录存在 MySQL 里,每一条消息、每一次工具调用、每一个模型回复都写入数据库;Redis 里的数据从设计第一天起就被当成可以随时重建的缓存。队列丢了,消息在库里;实时通知丢了,客户端重连后按游标从库里补齐。既然有数据库兜底,就没必要再引入一套需要自己运维的流式存储。那样同一份数据会存两份,之后要一直处理两边不一致的问题。
没做会话的自动迁移和负载均衡。 一个会话由哪个管家处理,取决于谁抢到锁,没有把忙机器上的会话挪到闲机器的机制。agent 会话的负载大头是等待模型 API 返回,不在我们的进程上,没必要为进程负载做迁移。
不做这两件事,系统简单很多:整个运行时只依赖 Redis 和 MySQL,熟悉普通后端服务的工程师一个下午就能看懂全部机制。
这套方案的边界、适用前提
这套设计有明确的适用前提:单个会话的处理严格串行,单会话的吞吐上限就是一个管家的处理能力。如果你的场景是单会话每秒要处理几百条消息,这个模型从第一天起就不适用。
agent 场景正好相反:会话数量多,单个会话的消息稀疏,每条消息的处理时间以分钟计。这种负载下,管家大部分时间是空闲的,成本主要来自空闲时占用的资源,而不是管家的数量。把空闲时不占资源这一条做好,用 Redis 和 MySQL 这两个最常见的中间件,就能支撑一个多租户的 agent 平台。
当然,顺序只是会话运行时要解决的一半问题。另一半更棘手:用户随时会按“停止”,而停止请求到达时,这一轮可能还没开始跑。滚动发布期间,抢锁和停止指令之间存在竞态,谁先到?我们为此出过一次真实事故:用户点了停止,agent 却照常跑完了全程,我们内部管那次事故叫“停了个寂寞”。下一篇讲我们怎么用一块“墓碑”(tombstone)修掉它。


