☰
多Agent编排实践:用Redis持久化与状态机织成可复用协作系统
2026/10/2 15:35:54 网站建设 项目流程

整整搞了两个月,我踩了很多Agent编排相关的坑,最后搭起来的那套东西我自己起了个名字,叫OpenRig。说起来也没什么高深的,核心就一句话:让一堆原本各自为战的AI Agent,在同一个持久化底座上协同干活,而不是靠拼手气。

先交代一下背景。我手头有一个业务,需要多个Agent配合完成——有的负责信息抽取,有的负责写摘要,有的负责路由分发,还有的干复核。最开始就是一个个单独调,每个Agent都有自己的一套状态,跑完就丢,互不相通。结果就是产出极其不稳定:同一个任务,换个时间跑,结果能差半条街;Agent之间要传个数据,全靠我手工拼上下文塞进去,链路一长,场面非常难看。

后来我把整个架构重做了,思路从“调多个API”彻底转成“编排一个多智能体系统”。这篇就聊聊我是怎么把离散的AI Agent,通过持久化、任务编排和并发控制织成一个可复用的协作系统。里面不会只有理论,更多的是我踩过的坑、验证过的方案,以及可以照着敲的代码思路。

1. 做Agent的人早晚都会撞上这堵墙:Agent越多,系统越“健忘”

先说个反直觉的现象。很多人以为Agent数量越多,系统能力越强。我的实际感受是:如果你没有一套持久化和编排的机制,Agent数量越多,系统越乱,越“健忘”。

这就好比你招了一堆能力很强的外包员工,但每个人干完活就拍拍屁股走人,不写交接文档,不更新Git,下一个环节的人完全不知道前面发生了什么。你指望他们高效协作?不存在的。你只会得到一堆各自为战的孤岛。

我的第一个项目就是这么翻车的。当时我做了三个Agent:一个提取原始数据,一个做数据清洗,一个生成分析报告。单测的时候每个Agent表现都很好,精度高、响应快。一旦串起来做端到端调用,问题就来了——第二个Agent需要用到第一个Agent在处理过程中的某些中间结果,但我当时的设计是等它完全结束,把最终输出拼进Prompt里传给下一个。表面上逻辑通,实际上中间态大量丢失,很多时候第一个Agent的最终结果里根本没有后面需要的关键字段,因为那是处理过程中的一个分支状态。

轮询也试过,回调也试过,最后还是乱。真正的原因就一条:整个系统没有持久化的中间状态层。每个Agent都只活在“当前这一次调用”里,当次调用结束,状态烟消云散。你想查它是哪个环节出的错?查不到。你想让它断点续跑?不可能。你想做流程回放?门都没有。

所以我的结论是:**AI Agent这件事,真正难的不是让单个Agent干活,而是让一堆Agent在同一个系统里协作时,所有关键状态都能被记录、被传递、被恢复。**这一步不做,后面所有花哨的编排都是空中楼阁。

2. 先把“持久化”这三个字说清楚:你的Agent为什么总在“失忆”

聊持久化之前,得先弄清楚一个概念:Agent和普通函数的最大区别是什么?普通函数是“吃输入、吐输出”,状态在函数内部走一圈就没了。Agent不一样,Agent是有“记忆跨度”的执行体,它需要记录自己的目标、已经完成的任务、当前执行到哪一步、上下文积累到什么程度、以及跟其他Agent的消息往来。

这些状态如果只放在内存里,进程一重启,全没了。如果只放在上下文窗口里,窗口一满,最老的记忆就被挤出去了。这就是“失忆”的根源。

2.1 持久化到底要存什么东西?

我梳理了一下,一个协作型Agent最少要持久化三类数据:

  • 会话状态(Session State):这个Agent从诞生到现在,经历了哪些阶段,当前处于哪个阶段。比如“信息抽取Agent”要记住自己已经从原始文本里抽了哪些字段、哪些还没抽;
  • 中间产物(Intermediate Artifacts):每个Agent处理过程中产生的结构化中间数据。可能是JSON片段、向量索引的ID,也可能是给下游Agent的指令包;
  • 消息日志(Message Log):Agent之间互相发送的、以及Agent与外部系统交互的所有消息记录。这一步既是审计的基础,也是出问题时排查链路的唯一依据。

这三类数据,我一个都没省,全都落到Redis里。有人会问,为什么不直接存MySQL?不是不行,但AI Agent的运转对读写速度特别敏感,每个Agent每执行一步都可能要读写状态,MySQL的连接数和磁盘IO在这种高频率小数据量的场景下优势不大。而Redis本身就以内存速度做读写,加上它有成熟的持久化机制,正好匹配这个场景。

2.2 Redis持久化机制到底是个啥?我用最简单的话捋一遍

既然这一段是很多人会搜“redis持久化机制详解”的重点,我多写几句。Redis的持久化有两条腿,一条叫RDB,一条叫AOF。

RDB(Redis Database):按照你配置的时间间隔,把内存里的全量数据快照到磁盘。好处是恢复快,文件紧凑;坏处是fork子进程做快照时如果数据量大,会有短暂阻塞,而且一旦在两次快照之间宕机,中间的数据就丢了。默认配置是900秒内有1次写入就存一下,这显然不够用。

AOF(Append Only File):把每一次写操作以追加日志的方式记录下来。好处是数据安全性高,可以通过appendfsync配置来控制刷盘频率;坏处是文件会越涨越大,恢复时重放日志比较慢。Redis新版支持AOF文件里的RDB头,这种混合格式体积极为紧凑,恢复速度也快。

两个机制实际用下来,我建议这样配:

配置项我的建议值理由
save关闭或拉长时间窗避免频繁fork阻塞主线程
appendonlyyes以AOF为主保住实时状态
appendfsynceverysec性能和数据安全折中,最多丢1秒
aof-use-rdb-preambleyes文件小,恢复快
maxmemory-policynoevictionAgent状态一律不能丢,禁止淘汰关键键

注意:这里说的“持久化”不只指Redis自身的RDB/AOF。Redis持久化解决的是“Redis进程挂了不丢数据”,而Agent系统的状态还需要在业务层面做一层冗余——关键任务的状态在共享存储里再存一份。两层都做,才算稳。

2.3 状态结构不设计好,后患无穷

落地的时候,我一开始犯了个错误:用一个大JSON把Agent的所有状态全塞进去,一个键通吃。结果就是并发读改的时候互相踩,一个Agent在写current_step,另一个Agent在改memory,整个JSON都被锁住,性能差到离谱。

后来改成按维度拆键,每个Agent一个命名空间,比如:

agent:extract:{session_id}:status agent:extract:{session_id}:fields agent:extract:{session_id}:messages

用Redis的Hash去组织,字段级操作互不影响。这算是一个很小的设计决策,但对后面的并发稳定性和排查效率影响巨大。

3. 从0到1搭OpenRig:注册、落库、跑通第一个Agent网络

下面说点实操。把分散的Agent变成协作系统,我的顺序是“先单机跑通,再上编排”,一共四步。

3.1 第一步:定义Agent的统一生命周期接口

所有Agent必须实现同一个生命周期模型,不能各自为政。我用的是比较朴素的五段式:

  • setup:初始化资源配置,拉取依赖数据;
  • run:执行核心逻辑,可能是调用大模型,也可能是调工具;
  • check:自检结果是否符合预期;
  • save_state:把关键状态写入持久化层;
  • handoff:生成给下游Agent的交接消息。

接口统一之后,编排层就不用关心每个Agent内部怎么实现的,它只需要按阶段去调度。这就像工厂里的工位,不管工位里是什么设备,只要进料口和出料口是标准化的,流水线就能跑起来。

这是OpenRig里最值得做的一层抽象。没有这层,后面写编排代码的时候,每接一个新的Agent就要写一遍特例逻辑,维护成本会失控。

3.2 第二步:用Redis存Agent运行时状态

我直接给一段能跑的代码示例,展示Agent如何通过Redis保存并恢复运行状态。这里用的是Python加redis-py,思路本身跨语言通用。

import json import time import uuid import redis r = redis.Redis( host="localhost", port=6379, db=2, decode_responses=True, ) class AgentSession: """把Agent的状态存储委托给Redis,让Agent天然带有断点能力。""" def __init__(self, agent_name: str): # 每个Agent每轮任务都生成独立session_id self.session_id = f"{agent_name}:{uuid.uuid4().hex[:8]}" self.status_key = f"agent:{agent_name}:{self.session_id}:status" self.data_hash = f"agent:{agent_name}:{self.session_id}:data" def start(self, initial_state: dict): r.hset(self.status_key, mapping={ "created_at": time.time(), "status": "running", "current_step": "init", }) # 初始任务数据直接落到Hash上 for k, v in initial_state.items(): r.hset(self.data_hash, k, json.dumps(v)) def update_step(self, step: str): r.hset(self.status_key, "current_step", step) r.expire(self.status_key, 3600 * 24) # 只保留24小时,防止垃圾状态堆积 def set_result(self, key: str, value): r.hset(self.data_hash, key, json.dumps(value)) def get_result(self, key: str): raw = r.hget(self.data_hash, key) return json.loads(raw) if raw else None def finish(self, ok: bool = True): r.hset(self.status_key, "status", "done" if ok else "failed")

这段代码干的事很简单,但解决了两个大问题:第一,Agent执行到一半宕机了,重启后通过session_id就能拿到之前的current_step和所有中间产物;第二,多个Agent共享同一个Redis实例,编排层随时可以窥探每个Agent的执行进度,而不需要侵入式地改Agent内部代码。

3.3 第三步:消息队列把所有Agent串起来

状态有了,接下来解决“Agent之间怎么传递任务”。我选的是Redis Stream。为什么不用RabbitMQ或Kafka?因为在我的场景里,Agent数量还不到几十个量级,消息体也不大,引入重型MQ反而增加运维负担。Redis Stream天然支持消费组,够用,而且和状态存储共用一个基础设施。

大概的流转模型是这样的:

上游Agent完成任务后,把交接消息推入Stream -> 下游Agent在消费组里监听到消息 -> 下游Agent从Redis里拉取上游中间产物 -> 下游Agent开始执行自己的生命周期

每个Agent一个独立的Stream,Stream的每条消息都带一个全局唯一的task_id。这个task_id是整条协作链路的核心索引,任何环节出问题,我都可以拿它去追查所有Agent的落库状态。

3.4 第四步:手动走一遍完整链路,再谈自动化

四步走完之后,我建议先不要急着做动态编排、动态路由那套花活。手动把三个Agent串一遍:Agent A执行完,手工确认状态落库,手工把消息推到Agent B的Stream,Agent B跑完,再看状态是否正确更新。全链路走通三次以上,证明状态模型和消息模型是稳的,再引入编排引擎。我当时图快,状态模型还没验证稳定就直接上自动编排,结果出了bug根本分不清是编排的问题还是状态模型的问题,排查起来极其痛苦。

4. 把离散Agent编排起来:任务节点、状态机与锁的配合

单链路跑通之后,真正的核心工作才开始:怎么让十几个Agent按照业务规则协作,而不是写死一堆if-else。我把这里的做法展开说说。

4.1 编排的本质:把运行结构画成一张有向图

编排不是什么玄学,它的本质就是把业务流程定义成一张有向图。图中的节点是Agent的执行单元,图中的边是依赖关系和数据流。你的业务流程有多少种执行路径,图就有多复杂。

常见的组合方式就那么几种:

  • 线性编排:Agent A结束后,紧接Agent B,适合流水线式处理。
  • 并行编排:Agent B和Agent C都依赖A的结果,但彼此不依赖,可以同时跑。
  • 条件编排:根据A的输出内容,决定下一步走B还是C。
  • 汇聚编排:B和C都结束后,把两份结果合并,交给D做综合处理。

我在OpenRig里没有引入特别重的图计算引擎,而是用Redis里的一张“任务路由表”自己描述:每个Agent执行完,把自己的产出登记到路由表中;编排器根据路由表中的结果和预设的路由规则,决定下一个要激活的Agent。听起来像工作流引擎对吧?对,但它比传统工作流引擎更灵活的地方在于,每个节点的“执行体”是带有大模型判断的Agent,它可能根据上下文动态修改自己的输出,甚至主动要求重跑某个上游节点。

4.2 任务状态机的设计,决定系统能不能“断点续跑”

每一个task_id在我的系统里都对应一个状态机,贯穿始终:

pending -> running -> waiting_dependencies -> ready -> dispatched -> succeeded \-> failed -> retry

状态机的核心作用是让“流程控制”和“Agent实际执行”解耦。编排器不直接调Agent,而是把状态置为ready并丢进待分发队列;某个Agent空闲时自己去队列取任务,取到后把状态改成running,干完再上报结果。这样哪怕某个Agent进程突然崩了,编排器扫描一遍状态机,发现哪个task_id卡在running超过超时时间,就自动把它重置回ready,交给另一个副本去跑。

这就是持久化给编排带来的最直观收益——系统不再依赖任何一个单点进程的存活。

4.3 用Redis锁压住并发写操作

多Agent并行最怕的,就是两个Agent同时写同一个任务状态。这时候Redis的分布式锁出场:

def acquire_agent_lock(agent_name: str, task_id: str, timeout: int = 30) -> bool: lock_key = f"lock:agent:{agent_name}:{task_id}" # nx=True:只有键不存在时才能设置成功,天然原子 return r.set(lock_key, "1", nx=True, ex=timeout) def release_agent_lock(agent_name: str, task_id: str): lock_key = f"lock:agent:{agent_name}:{task_id}" r.delete(lock_key)

这个锁本身并不复杂,但有几个细节必须注意。锁的过期时间要比任务实际执行的最长耗时更长,否则任务没跑完锁先过期了,另一个副本就会重复执行。另外,不要在Agent代码里直接加各种wait、sleep来抢锁,正确做法是把抢不到锁的任务重新放回队列,等下一轮调度。

4.4 编排器的“心跳”机制

为了不让编排器本身成为单点故障,我给它加了一个心跳上报机制:编排器的每个调度动作都会在Redis里写一条带有时间戳的心跳记录。如果心跳超过两个周期没更新,就认为编排器挂了,备用编排器接管。这套逻辑很笨,但极其可靠,也是持久化底座带来的又一个红利——编排器状态本身就是可恢复的。

5. 并发篇:多个Agent抢资源时,系统凭什么不乱

“AI Agent怎么扛并发”这个话题在网上讨论得很多。很多人一上来就想着提高API并发上限、加机器、调参。我的经验是:**对基于大模型的Agent系统来说,真正的并发瓶颈通常不在模型API,而在状态一致性和资源竞争上。**模型API慢一点只是性能问题,状态写乱了就是正确性问题,后者才是致命的。

5.1 先把“坑”拆清楚:你的并发卡在哪一层?

我总结下来,Agent系统的并发压力分布在三个层面:

  • LLM API层:调用大模型的频率上限,慢,但可控;
  • 工具调用层:Agent去查数据库、调外部接口的频率,容易被打爆;
  • 状态读写层:多个Agent对同一状态键的读写冲突,最容易出bug。

前两层是“资源限制”,用限流和队列就能解决。第三层才是“正确性风险”,必须靠锁和幂等设计来解决。

5.2 给LLM调用装上令牌桶

我的做法是给每个Agent类型配一个独立的令牌桶计数器,以Redis的原子操作限制每秒发往LLM的请求数:

import time def acquire_token(agent_name: str, rate: int = 5) -> bool: key = f"ratelimit:{agent_name}" current = r.get(key) if not current: # 首次调用,直接放行并初始化计数 r.set(key, rate - 1, nx=True, ex=60) return True val = int(current) if val > 0: r.decr(key) return True return False

令牌不够就让任务在队列里等待。这个桶的存在防止了某个Agent的异常循环把API额度秒光,也防止了并发尖峰把下游系统打挂。

5.3 状态写入必须幂等

这是我认为全网文章讲得最少、但实战最重要的一点。**Agent任务重试是常态,不是异常。**网络抖动会重试、进程崩溃会重试、超时会重试。如果状态写入不幂等,同一个任务被分发两次,就会产生两套互相覆盖的中间状态。

我在设计时强制要求:状态变更必须携带task_id和step两个版本号,写入前先检查版本号。只有更高版本号才能覆盖旧状态。这样一来,重试的Agent发现自己的版本号比别人低,就不会覆写别人的结果。

一个简单的版本控制示例:

def set_state_with_version(task_id, step, payload): version_key = f"task:state:{task_id}:version" # lua脚本保证原子比较+更新 lua_script = """ local cur = redis.call('get', KEYS[1]) if cur and tonumber(cur) > ARGV[1] then return 0 end redis.call('set', KEYS[1], ARGV[1]) redis.call('set', KEYS[2], ARGV[2]) return 1 """ ok = r.eval(lua_script, 2, version_key, f"task:state:{task_id}:data", step, json.dumps(payload)) return bool(ok)

用Lua脚本把“比较版本号”和“写入状态”两步压成一个原子操作,杜绝并发下的竞态条件。这种设计能扛住绝大多数Agent并发的正确性压力。

5.4 并行度要留余量,别把系统压到极限

最后说个土经验:Redis的CPU占用、网络带宽、Agent进程数量,不要用到90%以上。Agent系统的负载曲线不是线性的,它会因为LLM推理的随机性和上下文积累而突然跳变。给系统留出30%的余量,你会省掉很多凌晨三点爬起来救火的痛苦。

6. 生产环境里踩过的三个大坑,写出来给后来人

这节全是真金白银的教训。分享三个我在落地OpenRig时实际踩过、并且花了大力气才填上的坑。

第一个坑:只配了RDB快照,结果Redis重启丢了一整轮任务状态。

当时图省事,以为Redis默认配置就够了。结果一次服务器重启,重启前十几分钟内产生的Agent状态全丢了。下游Agent拿不到上游交接消息,整条链路静默失败。排查了三个小时才意识到是持久化策略的问题。后来把appendonly打开,appendfsync设成everysec,同时在业务层加了状态冗余,才算稳住。

第二个坑:所有Agent共用一个Redis逻辑库,键设计没规划,最后根本分不清谁是谁。

前期Agent少,键名随便起,什么extract:123、summary:456都有。后来Agent一多,排查一个问题要redis-cli keys *扫半天,生产环境还差点因为keys命令阻塞了Redis主线程。后来我规范成了agent:{agent_name}:{session_id}:{field}三级命名空间,并且把生产环境Redis的keys命令禁掉,只允许用scan。

第三个坑:一个Agent的慢查询拖垮了整条任务链。

有一个Agent处理数据时要扫描一个很大的中间产物,Redis读多写多,把其他Agent的读写延迟全带起来了。我一开始以为是Redis性能问题,后来仔细分析才发现是单个Agent的执行逻辑里有一个不合理的数据遍历,压根不应该把那么大的数据放到Redis里。处理方案是把它改成磁盘对象存储,Redis里面只放引用地址。这也是一个很重要的经验:Redis不是万能的,它适合高频小对象,不适合大文件。

写在最后:这套模型还能怎么继续扩展

从自己手上这个项目走出来之后,我对“多智能体编排”的认知清晰了很多。它不是一个技术框架能解决的问题,本质上是把Agent当成一类有状态、可恢复、可协作的长期运行服务来设计。持久化是地基,消息队列是动脉,编排器是中枢,并发控制是安全带,少了任何一块,系统都会在某个意想不到的时点崩给你看。

OpenRig这个名字代表的是我自己的实践集合,它不是一个标准答案,也不一定适合所有人的场景。但沉淀下来的这套组合拳——统一生命周期、Redis持久化、Stream消息传递、状态机编排、分布式锁加幂等控制——放到任何需要多Agent协作的领域里,都值得被复用。

如果让我给正在做类似项目的朋友一句最实在的建议,那就是:**先花一个下午把状态模型想清楚,再动代码。**状态模型定了,后面的编排、并发、排查都有迹可循;状态模型烂,代码写得再好也白搭。

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

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

立即咨询