
开篇先从“为什么需要编排”这个最朴素的问题切入比较好不绕圈子。Multi-Agent 系统挂掉的方式千奇百怪某个 Agent 超时、某两个 Agent 互相等待、某个子任务跑了三遍结果却没人回收。这些问题单靠给每个 Agent 写一堆回调、if-else 是解决不了的根子上缺的是一个能把“谁先做、谁后做、做到哪了、怎么互通消息”统一管起来的中枢。我在这篇文章里会拆开三层来看这套中枢状态机管生命周期、DAG 管执行顺序、事件总线管 Agent 之间怎么说话。顺带把通信协议的分层思路也揉进去讲因为很多人在这一层踩坑。这套东西适用范围挺广的你在用 LangGraph、AutoGen 这类框架或者在自己撸 Agent 平台甚至只是在做一个异步任务系统都可以参考。接下来我直接从最容易被忽视的状态机说起因为绝大多数编排事故本质都是状态没管住。1. 先把没说清的“状态”管住Agent 再多也不会乱很多团队做 Multi-Agent 系统时第一步想到的是“怎么让 Agent 互相调用”第二步才想到“这些 Agent 现在到底在干嘛”。但实际跑起来之后你会发现后面这个问题比前面重要得多。没有清晰的生命周期状态你就没法回答三个最基本的问题这个 Agent 是在执行中、还是在等别人的结果、还是已经挂了只是没人发现。1.1 Agent 生命周期状态怎么定才不挖坑我见过不少项目每个 Agent 自己搞一套状态枚举有的叫“busy/free”有的叫“started/completed”五花八门结果在编排层根本没有统一视图。我建议直接在编排中枢抽象出一套最小公共状态集让所有 Agent 都向上对齐。下面这张表是踩过坑之后沉淀下来的你可以直接抄状态含义触发时机异常处理方向IDLE空闲可被调度任务结束、系统启动不需要处理CLAIMED已被调度还没开始跑编排器刚下发指令等待超时后可重新调度RUNNING正在执行Agent 上报 start 消息超时后标记可疑BLOCKED等待外部依赖缺少输入、等待子 Agent 结果依赖事件到达后自动恢复FAILED执行失败Agent 上报 error 消息按重试策略处理DONE执行完成Agent 上报 complete 消息回收结果这里最容易忽略的是 CLAIMED 这个状态。直接 RUNNING 的话编排器下发指令和 Agent 实际开始执行之间有一段真空区如果 Agent 一直没起来你只能等超时排查起来也不清楚到底是网络问题还是 Agent 崩溃。加上 CLAIMED 之后至少能做到“指令已发出、执行未确认”配合超时机制就能区分两种情况是没人接还是接了没干活。状态之间不是随意迁移的必须允许“BLOCKED - CLAIMED - RUNNING”这种带有恢复语义的路径。有些项目把 BLOCKED 直接当成终点Agent 等一下午也没人唤醒它。这不是状态模型的问题是事件机制缺失的问题后面讲事件总线的时候再说。1.2 从嵌入式状态机迁移过来的三条经验我以前做 MCU 固件时写过不少状态机C 语言那种 switch-case 三段式。做 Agent 编排时发现很多经验可以直接平移过来尤其是下面三条第一所有状态变更必须走统一入口。MCU 上叫“状态机引擎”编排中枢里就是一个 stateManager任何 Agent 状态迁移都要经过它不允许某个模块偷偷改全局状态变量。否则过两周你就会发现有个状态不知道被谁改了。第二状态变更必须携带触发事件和上下文。不只是记一个“RUNNING”还要记“从 CLAIMED 过来、因为什么指令、当前任务的 traceId 是什么”。这就是排查现场最有力的证据比日志里零零散散的 print 有用得多。第三超时是状态机最重要的驱动力。没有超时的话状态机几乎是废的因为永远停在那里。我习惯给每个状态配一个最大停留时长比如 CLAIMED 最多 30 秒RUNNING 根据任务类型设不同阈值BLOCKED 反而可以放宽因为它本身就在等事件。超时不等于立刻失败先把状态标为 SUSPICIOUS再触发一次探测确认活着就继续等不确认就 FAILED。这套机制在 Agent 偶发变慢的场景下极其关键直接标记失败会导致无辜重试白白浪费资源。2. DAG 编排真正决定“谁先跑、谁后跑”的不是代码顺序状态机管的是单个 Agent 的生老病死但系统里十几个 Agent 谁先跑、谁后跑、能不能并行这是 DAG 的事。有人觉得我按代码顺序从上往下调用不就行了短流程可以这么干一旦分支多起来靠代码顺序等于把编排逻辑焊死在源码里改一条路径就要改代码。DAG 的核心价值是把“执行顺序”从代码里抽出来变成一份可检查、可调整、可复用的结构。2.1 从“消息链”到“DAG”到底改变了什么拿一个典型的文档分析任务来举例。你有一个“文档加载 Agent”、一个“敏感信息检测 Agent”、两个并行的“摘要 Agent”一个做技术摘要一个做市场摘要最后有一个“汇总 Agent”。如果用最原始的链式调用流程大概是文档加载完了先跑敏感检测再先跑技术摘要再跑市场摘要最后汇总。这个流程天然适合 DAG因为两份摘要之间没有依赖完全可以并行。但链式代码得改成并发调用才能提速而 DAG 结构本身就把“可并行”这件事表达出来了。调度器看到两个摘要节点没有互相依赖、父节点都是敏感检测就能自然地把它们放到同一个批处理槽里。提速不是靠人工写线程而是靠结构。DAG 带来的另一个好处是可重试粒度。失败只重跑失败的那个节点不用把整个流程重新跑一遍。链式调用想做到这一点就得手动加缓存逻辑DAG 天然支持子节点依赖的是上游节点的输出而不是一个不可分割的“流程状态”重跑父节点时子节点可以保留已产出且命中缓存的结果。2.2 构建 DAG 时的依赖声明与环检测DAG 的构建方式我推荐“声明式”也就是每个节点只描述自己依赖哪些上游节点不要描述整个图长什么样。这样新增一个 Agent 时只需要告诉编排中枢“我需要 A 和 B 的结果”调度器自己去算它在图里的位置。声明式构建带来的一个必修课是环检测。DAG 必须是无环的但人的配置总会出错比如 A 说需要 B 的结果B 说需要 C 的结果C 又说需要 A 的结果。这种环用配置写出来非常隐蔽尤其是节点一多肉眼很难发现。我在构建图之后一定会跑一次拓扑排序同时检测环。拓扑排序不复杂用 Kahn 算法就行核心思路是不断删除“入度为零”的节点如果能删完就说明无环删不完就说明存在环剩下的节点就是环上的节点。还有一个很容易踩的坑DAG 的并行度不能只看依赖关系还得看资源池。你画了一个 8 个节点全部并行的大扇出但执行池只有 4 个 Worker那实际并行度就是 4。我建议在 DAG 配置里别写死并行度而是交给调度器根据当前 Worker 池动态决定。规模大了以后甚至要考虑某些节点是否独占资源比如大模型推理 Agent 和普通函数型 Agent 就不应该混在同一个池子里抢 CPU不然推理延迟会被拉得很高。2.3 节点粒度太粗和太细都会出问题DAG 设计里最难拿捏的就是节点粒度。粒度太粗比如一个“处理整个文档”的节点内部其实是串行跑了很多步骤你就没法针对其中某一步做重试和并行优化粒度太细比如把“读取文件”“解析标题”“提取正文”各拆成一个节点光在这些节点之间传递上下文的管理开销就比执行本身还大。我个人的经验是按“可独立重试的最小业务单元”来切。一个节点内部的步骤如果失败必须从头跑那就拆开如果失败之后可以断点续跑那就合并。这个和系统事务的边界很像本质上你是在定义“回滚/重试的最小粒度”。比如“调用大模型生成摘要”是一个粒度合适的行为因为失败重试的成本就是重新调用一次但“打开文件、读取内容、解析格式”这些就应该合并成一个“加载文档”节点因为失败后从头解析整份文档成本完全可控。3. 通信协议分层一个消息从 Agent A 到 Agent B 到底经历了什么编排中枢和数据通路是两码事。DAG 负责安排谁干活但 Agent 之间怎么交换消息那是通信协议层的事。很多人把这两层混在一起结果就是编排逻辑里到处是序列化代码和网络细节想复用都复用不了。3.1 我习惯把 Agent 间通信拆成三层结构以前搞嵌入式时大家爱把通信协议分成物理层、链路层、应用层。到了 Multi-Agent 这里虽然没有网线那么扯皮的事但分层的思路完全适用。我自己实践下来分成下面三层最顺手传输层解决消息怎么到达对端用什么通道HTTP、WebSocket、消息队列怎么保证送达。这里不需要感知消息内容。语义层解决消息内容怎么写怎么描述“我完成了什么任务”“我需要什么数据”用什么字段表达状态和时序。协作语义层解决这块业务相关的协议比如 Agent 请求别人帮忙时用什么话术收到结果是该返回新请求还是结束。传输层最典型的痛点是同步调用和异步事件混用。两个 Agent 之间既能发请求等响应也能发通知不管结果。我在消息体里固定用type字段区分request、response、event、error四类。调度器拿到消息先读 type再决定放行到哪套处理逻辑。这个小小的约定能省掉很多不必要的分支判断。序列化格式上我见过一个比较实用的对比特性JSONMessagePackProtobuf可读性好差差编解码速度慢较快最快定义结构无无需要 schema跨语言调试成本低中高对于中小规模的 Multi-Agent 系统我仍然建议先用 JSON。调试阶段你能直接打印消息内容一眼看出 Agent 传了什么这种直观性带来的效率提升远大于那点序列化开销。真正到了消息量极大、带宽消耗成了瓶颈的时候再切 MessagePack 或者 Protobuf 也不迟。正好有一个实际体验我见过一个项目一开始直接用 Protobuf结果每次联调都要先编译生成代码Agent 之间的字段对不上时排查成本高得离谱后来退回 JSON 反而把问题快速定位了。3.2 消息格式与协议演进版本字段不能省协议设计上我最想强调的一点是版本号一定要从第一天就带上。不管是单个字段version: 1还是更细粒度的schema_version必须出现在消息头里。Multi-Agent 系统最尴尬的时刻就是线上有 20 个 Agent 实例在跑你给其中的 5 个升级了新协议新老版本同时在发消息结果老的收新的消息时报错、新的收老的消息时字段缺失。带上版本号之后编排中枢可以根据版本路由到不同的解析逻辑或者给老版本做字段兼容。不要觉得“我现在系统小就那么几个 Agent不需要考虑版本”等到你发现三个 Agent 的协议已经分叉的时候再回去补版本号成本比一开始就设计高十倍。消息结构我一般是这么设计的{ version: 1.0, type: request, trace_id: 8f2a..., from: summary_agent, to: doc_processor, sequence: 12, payload: { ... } }trace_id也是一个不能省的字段。它是排查整个 DAG 执行链路的关键相当于分布式追踪里的 trace ID日志里所有消息都会带上它否则你要把几十个 Agent 的日志拼出一个完整链路基本靠猜。sequence用来做消息排序尤其当两个 Agent 之间是长连接、消息可能乱序到达时没有这个字段后到的消息可能会被当成新请求处理非常容易引发状态错乱。4. 事件总线让 Agent 之间只报“事实”不搞“指挥”状态机和 DAG 解决的是“任务该在什么时候运行”但 Agent 之间怎么获知“某个事实发生了”这就轮到事件总线出场。很多人以为事件总线只是解耦工具其实它的价值比“解耦”二字大得多。在 Agent 协作里它解决的是“消息传播方式”的问题能不能让一个 Agent 不知道别的 Agent 存在的情况下依然能感知到与它相关的事态变化。4.1 事件总线的四个核心部件缺一个都跑不顺我设计 Agent 间事件总线时至少会放四个东西进去主题Topic、事件体Event、订阅规则Subscription、分发器Dispatcher。主题负责给事件分类比如agent.lifecycle.changed、task.completed、model.quota.exhausted。事件体里面带上时间戳、发生源、上下文载荷。订阅规则决定哪些 Agent 对哪类事件感兴趣分发器负责把事件送达感兴趣的 Agent。分发器这里有个常见的性能陷阱如果每个 Agent 都注册监听所有事件那事件一多就成了广播赛跑。我一般会要求订阅规则尽量具体比如只订阅task.completed而不是订阅所有task.*事件甚至可以在订阅里带上过滤条件只关心agent_id summary_agent的任务事件。这样分发器可以并行、精准地投递而不是每个 Agent 拿到事件后再自己筛一遍。事件总线还承担了一个重要职责粘性事件。意思是某些事件的当前状态要能被后来的订阅者查到。最典型的就是 Agent 的运行状态。新加进来的 Agent 不关心“历史上发生过哪些状态变化”它关心的是“现在文档处理到哪一步了”。如果没有粘性事件新 Agent 只能去问编排中枢现查状态效率很低。事件总线上留一份“事件快照”新订阅者上线时先拉快照再订阅增量体验会好很多不用额外写一套状态查询接口给每个业务方。4.2 事件不是命令这个边界一定要守住整个系统里如果所有的跨 Agent 交互都用事件总线迟早出问题。事件语义是“已经发生的事实”命令语义是“请求对方做某事”。用一个到位的例子Agent A 发出一条事件document.uploaded说明文档已经上传完成但如果 Agent A 希望 Agent B 去分析这份文档这就适合用请求-响应模式的命令消息而不是事件。区别在于请求带期望的响应事件不需要。很多系统最后乱成“事件满天飞哪个 Agent 都没收到”就是因为事件总线上混入了命令型消息带返回值期望结果发完就发完了没人响应。我建议在事件总线上强制只允许event类型消息通行request/response走另一条传输通道。这样职责清楚排查问题时也可以直接看通道类型缩小范围。此外还有重复消费问题。事件总线至少要保证“至少一次”投递但业务上要自己做到“幂等处理”。同样的document.uploaded事件如果被投递了两遍Agent 不应该把同一个文档解析两遍。事件体里带上event_id消费者自己缓存最近处理过的 ID做不到也不难但完全不做的话分布式环境下的重复事件很快会让状态机进入错误状态。5. 把三层串起来之后实际落地时我的调试与维护习惯状态机、DAG、事件总线分开说都比较清楚真正难的是把它们组合到一个系统里形成可观测、可运维的整体。这一节讲一点我实际推进这类项目时的经验不是理论推演是踩过的坑。5.1 每一条消息都要能掏出完整生命轨迹编排中枢维护一张“消息链路表”它不仅记 DAG 节点执行情况还记节点发出过哪些事件、收到过哪些事件、状态机经历了哪几次迁移。这个链路表是排查问题的核心入口。实际调试时最管用的办法是拿 trace_id 查这条链路看每个节点上下游状态流转情况时间轴上一摆发生在哪一环就一目了然。我给每个节点的状态迁移都打了日志格式大概是这样[trace_id] [node_id] [old_state] - [new_state] (reason: timeout/event/data_ready)。这个日志攒多了以后能帮你建立“系统正常运转时的基线状态长什么样”的感觉。我见过一些团队出问题后最先抱怨“日志不够”加日志要花的工夫并不大缺的是加对日志的意识和提前布局这两点前期做比后期补划算得多。可观测性上我还有一个建议不只是看状态还要看“等待的原因”。一个节点停在 BLOCKED 状态要知道它在等哪个上游事件、那个事件应该由哪个 Agent 触发、那个 Agent 当前是什么状态。把这三个信息放到同一张视图中排障效率会高很多。事件总线上留快照也有这个用途直接把等待事件和已消费事件对比就能快速找出卡点。5.2 超时、重试、熔断参数怎么定才算稳写完业务逻辑之后性能调优最花时间的就是参数整定。先说超时不同 Agent 的合理超时范围差异很大处理型 Agent 可以设置 60 秒以上模型调用型 Agent 可能只需要 10 秒到 30 秒外部依赖型比如等待用户审批就不能用普通超时直接杀掉往往会配到几小时甚至更长。别用一套全局超时打天下。重试策略上我建议区分瞬时错误和永久错误。瞬时错误网络抖一下、队列慢可以重试 3 次左右用指数退避比如 1 秒、2 秒、4 秒。永久错误输入数据缺失、参数格式不对重试再多次也是失败直接进 FAILED 写审计日志。我们在项目里就是给每个 DAG 节点配置retryable和max_retry两个元数据节点内部判断错误类型可重试就告诉调度器不可重试就立刻上报失败。熔断是针对某个 Agent 连续失败时的保护机制。单个 Agent 连着失败 5 次说明它可能已经没在健康状态再继续把任务塞给它只会让系统整体变慢甚至拖垮它侧的资源。我会给 Agent 设置熔断阈值超过之后所有指向它的任务暂时路由到别处或者直接失败快速返回。资源池有限的时候不做熔断一个不健康的 Agent 会慢慢吃掉所有 Worker 线程。5.3 从单机到多机别让状态机成为分布式瓶颈单机部署时状态机就是内存里的一个对象简单得很。一旦要把 Agent 分散到多台机器上状态机本身的存储和同步就会变成瓶颈。我在做扩展时一般把状态持久化到 Redis 或者关系型数据库里用事务保证迁移的原子性。每台机器上的编排器节点从同一份状态源读取事件总线负责广播变更通知。这里有种做法状态机用 Redis 存当前状态事件总线负责把“状态变更”这一事件本身广播出去。状态是权威数据事件是善变的消息。两者一致不会出现页面显示一个状态但实际逻辑另一个状态的问题。所有 Agent 的状态变更都走同一条事务链路而不是每个实例各写各的这点在多进程或多机器上特别关键。处理大规模 DAG 时还容易碰上一个问题单个 Worker 线程跑一个节点却长时间阻塞比如一个大模型调用等响应等了一分钟。这个阻塞会让调度器以为节点还在跑其实下游已经空了。我给调度器加了一个“心跳保活”机制节点在跑时会周期性上报heartbeat超过两个心跳周期没上报的节点会自动标记为 SUSPICIOUS再由调度器决定继续等还是重启。这和状态机部分的超时逻辑是配套的都是给系统加一种“确认活着”的手段。6. 一个完整例子三个 Agent 的事件协作流程光讲抽象设计理解起来还是有点隔靴搔痒。我拿一个具体的场景把三层串一遍一个“客服工单处理系统”有“分类 Agent”“技术答主 Agent”“安抚话术 Agent”三个 Agent外加一个编排中枢。工单进来之后的流程大概是分类 Agent 先判断这是技术问题还是情绪化表达然后分别派给技术答主 Agent 或安抚话术 Agent。在状态机层次分类 Agent 的状态从 IDLE 到 CLAIMED 再到 RUNNING最后到 DONE。反馈到编排中枢分类完成事件被记录进入汇总阶段。在 DAG 层次编排中枢定义了一个两阶段结构第一阶段只跑“分类节点”第二阶段并行跑“技术答主节点”和“安抚话术节点”都取决于分类节点产出的类别字段。两个下游节点并行直接体现了 DAG 的提速价值。在事件总线层次分类 Agent 完成时发出workitem.classified事件这个话题下挂载了“技术答主 Agent”和“安抚话术 Agent”两个订阅者。事件体里带着工单分类结果和上下文。每个下游 Agent 收到事件后用幂等逻辑判断自己是否属于该事件的目标对象不是就忽略是就进入后续操作。这样三个 Agent 之间没有任何硬编码调用关系纯粹靠事件本身协作加一个新的“投诉升级 Agent”只需要让它订阅相关事件完全不改其他 Agent 的代码。这条链路里有一个细节容易被忽视事件体的上下文载荷应该携带什么。我一般会让事件尽量轻只携带“业务键值引用”比如工单 ID、分类结果不把整个工单内容塞进事件。下游 Agent 需要完整数据时再去查存储。这样事件总线不会变成数据总线避免大对象持续在网络里来回传也避免订阅者拿到过期数据。7. 关于这套架构边界的一些实话做完整套方案之后回头看这套状态机 DAG 事件总线的组合并非银弹它有明确的适用边界也有比较吃功夫的取舍点。什么叫不适用如果你的 Agent 只有两三个流程固定不变且都很短直接用普通服务端异步任务就能搞定没必要引入 DAG 和事件总线。基础设施成本、调试成本反而比收益更大。但如果你正在往二三十个 Agent、任务依赖动态变化、需要支持并行和重试的方向走这套架构的复杂度是值得付出的。取舍点上最大的一个矛盾是“实时性”和“有序性”。事件总线天然是异步的它的吞吐量高、解耦能力强但牺牲了严格的顺序保证和一个请求一个响应的直接性。有些协作动作确实需要同步等待结果比如分类 Agent 完成后下游要立刻拿到分类结果来决定下一步。这种情况下建议走请求-响应通道而不是硬塞到事件总线里。我的做法是两套并存需要立即得到结果的走同步调用允许事后响应的走事件订阅。这两者在同一个编排中枢里由 DAG 的节点依赖定义区分开上游节点完成事件可以作为下游节点的启动触发器但下游真正拿数据是通过存储层直接读取不依赖事件体内传输。还有一个不愿看到但必须面对的现实问题事件总线一旦成为单个 Redis 实例的 pub/sub性能在 Agent 数量多、事件频次高的时候会明显下降。我们当时的处理是把事件按领域拆成多个 channel 的 topic 分类比如lifecycle、workitem、model每个 topic 落在自己的队列里。这样单个 topic 的压力不会拖垮全系统订阅关系也更清晰。最后分享一个我在实际项目中的强烈感受这套系统上线没问题不算难难的是运行三个月后你还能不能在三分钟内定位一个“工单卡在中间两小时没人处理”的问题。状态机、DAG、事件总线这三个东西配合完整的 trace 链路和可观测性设计能让你在迷茫的时候有个清晰的索引路径。坚持把这些日志、快照、追踪做在前期后期修起问题来会顺很多。