Multi-Agent 系统:拓扑、通信与结果不一致的裁决
五种协作拓扑 · 三层通信机制 · 冲突消解四级阶梯 · MAS 特有失效
🧠 一句话记忆锚点
MAS 的难点从来不是"让多个 Agent 跑起来",是"它们说的话不一致时听谁的"。全篇两个正交维度:协作拓扑(谁指挥谁)× 通信机制(信息怎么流),冲突时以一致性要求为先——强一致共享状态必须走黑板+版本号,不能走广播。冲突消解是四级有序阶梯:先分类(事实/口径/时序/幻觉),再走证据优先 → 置信度加权 → 第三方仲裁 → 人工闸门;投票不在阶梯里,因为 2 个 Agent 平局无解、同源模型投票不独立。裁决必须在主 Agent 装配上下文之前完成,否则就是上下文中毒。MAS 的固定代价是 token O(N²) 与调试面积膨胀,所以先问"能不能不上"。
阅读边界
本篇是 多 Agent 协作的单一事实源:拓扑定义、通信机制、冲突消解协议、MAS 特有失效、任务分派与结果聚合。
单 Agent 的调度状态机、分层记忆读写时机、工具沙箱、并发/预算/幂等实现,全部归 Agent 运行时深水区——那篇的结论以「单机单 Agent」为规模前提,本篇接在它之后。Golden Set 构造、成功率分母口径、轨迹级 judge、span 树归因、影子/灰度/回滚判据归 Agent 评测与线上运营。单 Agent 的范式对照(ReAct / Plan-and-Execute / Reflexion / ToT)与框架选型结论归 Agent 开发。
本篇主骨架是决策矩阵轴:「协作拓扑 × 通信机制」两维对照,每格扎穿一层理由。三层通信机制之下还有一层物理层(投递 / 唤醒 / 中断粒度 / 超时归属),单独一节讲:物理层:消息怎么送到、Agent 被谁叫醒——它不是第四种通信机制,是那三种共同的下层。
名词速查
首次阅读可跳过本表,直接看「场景问题」,遇到生词再回查。
本篇是多 Agent 协作这一组名词的归属页。这一组词对后台工程师格外友好——它本质上是分布式系统那套问题在 Agent 上重演一遍:服务编排、消息中间件、共享状态一致性、脑裂裁决、级联故障,你都见过。区别在于每个"节点"都会自信地胡说八道。
篇幅较长,按 spec 拆成两张表:本篇自有名词(完整版)与引用自其他上下文(只给去处)。
本篇自有名词
| 名词 | 后台类比 | 在系统里干什么 | 类比失效边界 |
|---|---|---|---|
| MAS(多 Agent 系统) | 微服务集群——把单体拆成多个各司其职的服务 | 用多个专职 Agent 分工协作完成单个 Agent 难以胜任的复杂任务 | 微服务拆分后各服务的接口契约是精确的,联调可穷举;MAS 的"接口"是自然语言,上游输出的措辞变化会改变下游行为,没有 schema 能约束住。而且拆分带来的固定代价是 token O(N²) 与调试面积膨胀——所以第一个问题永远是"能不能不拆" |
| 协作拓扑(Topology) | 服务调用关系图——星型(网关放射)、链式(管道)、网状(互相调用)等 | 描述"谁指挥谁"的组织结构:主从 / 流水线 / 对等 / 分层 / 群体。决定了任务怎么下发、结果怎么回收 | 服务拓扑由部署与配置决定,运行时固定可绘图;Agent 拓扑里主从关系是靠 prompt 约定的——下游 Agent 完全可以"不服从"(忽略指令、越权行动、反过来给主 Agent 派活)。拓扑是纸面约定,没有基础设施层面的强制力 |
| 黑板(Blackboard) | 共享数据库 / 带版本号的共享状态——所有参与方读写同一份权威数据 | 所有 Agent 读写同一份共享状态。强一致要求的场景必须走这条,配版本号做乐观锁 | 共享库有事务、隔离级别、约束检查兜底;黑板上写的是自然语言事实,两个 Agent 可以写入语义矛盾但格式都合法的内容("库存充足" vs "库存不足"),数据库层面查不出冲突。冲突只能靠本篇的裁决阶梯在应用层消解 |
| 消息总线(Message Bus) | Kafka / MQ 的发布订阅——异步解耦,各方按需消费 | Agent 间异步传递消息,发送方不阻塞、接收方按需处理,适合松耦合的并行协作 | MQ 保证消息不丢、可重放、顺序可控;这里消息内容是模型生成的自然语言——重放同一条消息,接收方的处理结果可能不同。而且"消息被正确理解"没有 ack 可以确认,投递成功 ≠ 语义送达 |
| 共享状态(Shared State) | 全局配置中心 / 分布式 KV——多方可见的一份状态 | 三层通信机制里最直接的一层:把状态放在公共位置供所有 Agent 读取 | 配置中心的读写语义明确,最终一致有明确收敛时间;这里的风险是上下文分裂——各 Agent 读到状态的不同版本后各自推理,产出互相矛盾的结论,且没有任何一方察觉自己读到的是旧的 |
| 任务分派(Task Dispatch) | 任务队列 + 负载均衡——按能力和负载把活派下去 | 主 Agent 把大任务拆解成子任务,按各下游 Agent 的专长分配 | 负载均衡按可测量的负载与健康度决策,派错了可以重新调度;这里的拆解由模型完成,拆解本身就可能是错的(漏了必要子任务、拆出重叠子任务、拆出无法完成的子任务),而下游会照着错误的拆解认真干完。错误在最上游就固化了 |
| 结果聚合(Aggregation) | 多路查询结果归并——收齐各分片结果后合并返回 | 收集各下游 Agent 的产出,合并成最终答案 | 分片归并有明确的合并规则(union / sum / 排序),结果确定;这里各方产出可能互相矛盾,"合并"首先是裁决(听谁的),其次才是拼装。跳过裁决直接拼接,就是把矛盾原样塞进主 Agent 的上下文——即上下文中毒 |
| 冲突消解四级阶梯 | 故障处置的分级升级流程(自动恢复 → 加权决策 → 仲裁 → 人工介入) | 结论不一致时的有序处置:先分类(事实/口径/时序/幻觉),再依次走证据优先 → 置信度加权 → 第三方仲裁 → 人工闸门 | 分级升级的每一级都有客观判据决定是否升级;这里"证据是否更强""置信度是否可信"都需要判断,而做判断的往往还是模型。所以阶梯的价值在于强制顺序(先找证据,别急着投票),而不在于每级都能给出正确答案 |
| 证据优先(Evidence First) | 以日志/审计记录为准——有原始凭证的一方胜 | 阶梯第一级:谁能给出可验证的原始出处(检索到的原文、工具返回值),就采信谁 | 日志是不可篡改的客观记录;Agent 给出的"证据"可能是它编的(伪造引用、虚构工具返回)。所以这一级必须校验证据真实可溯源(原文能在库里查到、工具调用有对应 span),而不是看谁"给了证据" |
| 置信度加权(Confidence Weighting) | 按副本可信度加权投票——像 Raft 里不同角色的权重差异 | 阶梯第二级:按各 Agent 的历史准确率或自报置信度加权 | Raft 的权重由协议定义、语义明确;模型自报的置信度普遍过高且校准很差——它说 95% 确信时实际准确率可能只有 70%。所以只能用外部统计出的历史准确率做权重,绝不能直接采信自报值 |
| 第三方仲裁(Arbitration) | 引入独立的第三方校验服务打破平局 | 阶梯第三级:让一个未参与争议的 Agent(最好是不同底座模型)来判 | 独立校验服务的逻辑与被校验方无关,判断真正独立;这里若仲裁者与争议方是同一个底座模型,它们会犯相同的错误——看起来独立,实际高度相关。这也是"投票"不在阶梯里的原因:同源模型投票不独立,且 2 个 Agent 平局无解 |
| 人工闸门(Human Gate) | 人工审批节点——自动化流程里的最后一道 | 阶梯第四级:不可逆操作或裁决不下的场景转人工 | 审批流的待办有明确的 SLA 与责任人;Agent 转人工时,人拿到的是一大段自然语言争议,缺少结构化的决策依据,判断成本很高。所以转人工时必须同时给出"争议点 + 双方证据 + 建议",否则这道闸门会成为吞吐瓶颈 |
| 上下文中毒(Context Poisoning) | 脏数据写入主库,后续所有读取都受污染 | 未经裁决的矛盾结论或错误事实进入主 Agent 上下文后,污染其后所有推理 | 脏数据可以定位、修正、回滚;进了上下文的错误事实无法"删掉"——即使后续追加一句"刚才那条是错的",模型仍可能继续沿用。所以裁决必须在装配上下文之前完成,这是时序要求而非质量要求 |
| 责任扩散(Diffusion of Responsibility) | 多个服务都以为对方做了校验,结果谁都没做 | MAS 特有失效:每个 Agent 都假定别人会处理某个环节,该环节实际无人负责 | 服务间的职责可以靠接口契约与集成测试钉死;Agent 的职责是 prompt 里描述的,边界天生模糊,且它们不会像人一样确认"这块归谁"。必须显式给每个子任务指定唯一负责 Agent,并对产出做外部断言 |
| 级联幻觉(Cascading Hallucination) | 级联故障——上游的错误被下游放大,最终雪崩 | 上游 Agent 编造的事实被下游当作输入前提,逐层加工后变成一份"看起来论证充分"的错误结论 | 级联故障有熔断和降级可以阻断,且故障信号明确(超时、错误码);幻觉级联没有任何错误信号——每一层的输出都格式合法、语气自信,越往下游越像真的。只能靠每层的事实性断言拦,不能靠链路层面的熔断 |
| MAS 死锁 | 分布式死锁——各方互相等待对方先行 | 两个 Agent 互相等待对方给出前提("你先确认 A""你先确认 B"),或互相把任务推给对方 | 数据库死锁有检测器与自动牺牲者回滚;这里没有全局等待图可查——Agent 的"等待"表现为不断产出礼貌的追问,看起来是在正常工作。只能靠全局轮数与预算硬闸兜住,见 Agent 运行时深水区 |
| token O(N²) | 全连接广播的通信开销随节点数平方增长 | MAS 的固定成本:N 个 Agent 互相传递上下文,token 消耗随 N 平方膨胀 | 网络广播的开销可以靠拓扑优化(树形、gossip)降到 O(N log N);这里降不下来的部分是——每个 Agent 都需要足够的上下文才能正确工作,砍通信就等于让它们瞎干。这是 MAS 的准入门槛:省不掉,只能评估值不值 |
| inbox(收件箱) | MQ 的队列 / 消费者的待处理缓冲区 | 存放已投递但尚未被消费的消息。推送只能推到这里——推不进一个正在推理的模型 | MQ 的消费者是常驻进程,它自己 select 在队列上等,入队即被消费;inbox 的主人没有自己的线程,消息入队后没有任何人会自动去取,必须有外部唤醒源给它排一个回合。所以 inbox 非空 ≠ 消息会被处理 |
| 唤醒源(Wakeup Source) | 消费者组的触发机制:事件推送 vs 定时拉取 | 决定"谁给这个 Agent 排下一个回合"。三种:事件驱动 / cron 定时扫描 / 回合边界自取 | 常规系统里事件驱动严格优于轮询(低延迟且省空转);这里两者是分工不是替代——事件驱动负责快,cron 负责不丢。因为「超时」不是一个可订阅的事件(不发生的事情发不出信号),而唯一有动机判超时的那个 Agent 此刻不在运行,所以定时扫描的职责事件驱动结构上承担不了 |
| 回合边界(Turn Boundary) | 事务边界 / 协程的让出点 | Agent 唯一能接收外部信息的时刻。一次推理不可中断,所以"消息到达"与"消息被处理"之间必然隔着它 | 事务边界由你的代码决定在哪提交,协程让出点可以密集插;回合边界的位置由模型一次推理的长度决定,你只能选"在哪个边界注入",无法让边界变密。想变密就得付中断粒度的代价(见下条) |
| 中断粒度 | 抢占式 vs 协作式调度的抢占点密度 | 决定消息在哪个边界被注入:回合内不可中断 / 回合边界 / 工具调用边界 / 真·中断流式推理 | OS 抢占后寄存器现场可完整保存与恢复,抢占几乎无语义代价;这里越细的中断点对幂等的要求越硬——LangGraph 与 Eino 恢复时都是整个节点从头重跑,所以中断点之前的副作用必须幂等。而真·中断更彻底:该轮 token 已经付了且状态不一致,只能整轮丢弃 |
| 投递语义(at-least-once) | MQ 的投递保证:at-most-once / at-least-once / exactly-once | 本层能给到的上限是 at-least-once——消息可能被投递多次,重复必须由接收侧吃掉 | MQ 可以靠事务或去重表把 exactly-once 做到"业务视角成立";这里做不到,因为投递确认与模型产生的副作用不在同一个事务里。所以重复只能靠 HandoffToken 三段令牌幂等兜住,而不能靠投递层保证 |
| 背压(Backpressure) | 生产者限速 / 阻塞写:队列满了就让生产者等 | inbox 满时的一种处置——阻塞发送方直到有空位 | 常规系统里背压是优选(阻塞生产者总比丢数据好);这里默认不选它,因为发送方是一个正在烧 token 的推理——阻塞它意味着上下文占着、时长与费用预算都在走而毫无产出,比丢一条消息贵得多,且贵得不透明(只表现为"这个 Agent 怎么这么慢")。默认取拒投 + 把错误当场回给发送方:它还活着,还能在本回合内改派 |
| 孤儿子任务(Orphan Subtask) | 任务队列里被领走又失联的任务 / 无主的在途请求 | 已派出、没回来、也没有人在等——因为等待方那一轮早就结束了 | 常规系统里"无人处理"通常意味着没人领(可从待认领队列查出);这里的孤儿责任是明确的(owner 字段有值),断的是执行链。所以它与「责任扩散」检测手段不同:靠 pending 表 + 定时清算,而不是靠未认领子任务表。而清算必须外部做——等待方不在运行 |
本篇引用的其他上下文名词
| 名词 | 在本篇里的角色 | 完整解释去哪读 |
|---|---|---|
| 调度状态机 / 终止线 / 分层记忆 / 压缩水位 / 工具沙箱 / 预算熔断 / 幂等键 | 单 Agent 运行时实现;本篇只在"压缩窗口归属"一处与它交接 | Agent 运行时深水区 · 名词速查 |
| 回合 / 四条终止线 / 重试与退避策略 | 物理层的「回合边界」「超时后重发」都建立在它们之上;本篇只引用不定义 | Agent 运行时深水区 · 名词速查 |
| ReAct / Plan-and-Execute / Function Calling / MCP | 每个 Agent 内部用的范式与协议 | Agent 开发 · 名词速查 |
| span 树归因 / 成功率分母 / 轨迹级 judge | 多 Agent 轨迹的可观测与判分 | Agent 评测与线上运营 · 名词速查 |
| context(本轮装配结果)/ memory(分层记忆) | 上下文分裂与中毒涉及的两个概念,与推理侧同名异义 | Agent 运行时深水区 · 名词速查 |
| token 经济 / 预算护栏 | O(N²) 成本的计价口径 | 成本与延迟 · 名词速查 |
| 六角色 DAG / 长程一致性 | MAS 在创作场景的落地实例 | 互动影游与长视频创作 Agent · 名词速查 |
场景问题
一个内容审核流程:主 Agent A 收到一条待审内容,分别问两个下游——B(规则检索 Agent,查审核规则库)和 C(案例检索 Agent,查历史判例)。
B 回来说:「该内容违反 3.2 条,应下架。」
C 回来说:「历史上 4 起同类内容全部判为『通过,加提示标签』。」
A 现在拿着两个矛盾的结论。它做了最自然的事——把两段话都塞进自己的上下文,然后问模型「综合判断」。模型输出:「建议下架并加提示标签。」
这个输出是荒谬的(下架了还加什么标签),但它没报错、格式合法、看起来还挺全面。这就是 MAS 最典型的坏法:冲突没被消解,只被拼接了。
再往下看一层,问题更细:
- B 说的「3.2 条」和 C 说的「同类内容」,是不是真的同一类?没人验证过。
- C 说的「4 起」是什么时间范围?如果规则 3.2 是上个月新增的,那 4 起判例全部作废——这是时序冲突,不是事实冲突,两者的处理方式完全不同。
- 假如再叫来一个 D 投票?D 和 B 都是同一个模型跑的,会犯同向错误。投票只会让错的那边变成 2:1。
打个比方
MAS 像一个跨部门评审会。Supervisor 拓扑 = 有个主持人点名发言、汇总决议;群聊拓扑 = 大家在一个会议室自由讨论;黑板拓扑 = 不开会,各自往同一份共享文档里写,看别人写了什么再补。
类比在哪失效:真人开会时,B 和 C 发现结论矛盾会当场互相追问("你说的同类是指哪几起?"),冲突在会议室里就消解了。Agent 不会——B 和 C 各自只对 A 说话,它们看不到对方的结论,也没有动机去核对。所以 MAS 里的冲突消解不是"让它们聊聊",而必须是框架层的一道显式裁决工序:由 A 或一个独立仲裁器在收到全部回复后主动执行。指望 Agent 自己发现矛盾,等于指望没有议程的会议自己得出决议。
这篇要回答的就是:拓扑怎么选、信息怎么流、结论冲突时听谁的。
实现方案
维度一:五种协作拓扑
拓扑决定「谁能指挥谁、谁等谁」。五种,按工业实践频率排序:
| 拓扑 | 一句话 | 适用场景 | Agent 数实际上限 | 典型失效 | token 成本量级 |
|---|---|---|---|---|---|
| Supervisor / 中心调度 | 一个 Planner 分派给多个 Worker,Worker 只对 Planner 说话 | 子任务边界清晰、需要统一验收 | 3~8 | 中心瓶颈:Planner 上下文塞满所有 Worker 的回复 | O(N),最省 |
| Pipeline / 流水线 | 上一个的输出即下一个的输入,单向 | 阶段固定、每阶段产物明确(剧本→分镜→合成) | 3~10 | 错误逐级放大,且下游无法要求上游返工 | O(N),最省 |
| Group Chat / 群聊 | 多 Agent 共享一条消息流,轮转发言 | 需要互相质疑的场景(方案评审、红蓝对抗) | 2~5 | 发言权争夺、跑题、token O(N²) | O(N²),最贵 |
| Blackboard / 黑板 | 不直接对话,各自读写共享状态区 | 长任务、需要共享大量结构化状态 | 5~20 | 写覆盖、读到中间态、状态膨胀 | O(N),但状态区本身可能很大 |
| Contract Net / 市场竞标 | 任务广播 → Agent 出价 → 择优派单 | Agent 能力异构且事先不确定谁最合适 | 理论无上限 | 出价不可信(模型自评能力普遍高估) | O(N) 广播 + O(N) 出价 |
规模锚点:上表的「实际上限」是指单任务内同时活跃的 Agent 数,前提是每个 Agent 有独立上下文窗口。超过上限时的信号不是崩溃,而是「Planner 的上下文里 Worker 回复占比 > 60%」——此时 Planner 已经在做摘要工作而不是决策工作。
市场竞标:为什么工业界少用
Contract Net 在多智能体学术文献里是经典拓扑(源自 1980 年代的分布式问题求解),但工业 Agent 系统里罕见,原因很实在:出价机制依赖 Agent 对自身能力的准确自评,而 LLM 的自评是系统性高估的——你问它"你能不能处理这个任务,置信度多少",它几乎总说能。于是竞标退化成随机派单,还多付了一轮广播 + 出价的 token。
它真正成立的前提是出价可被客观校验(比如 Agent 报的是"我有这个工具的访问权限"这类硬事实,而非"我擅长这个")。把出价从主观置信度换成能力声明的静态匹配,它就退化成了下面的「能力路由」——那就直接用能力路由,省掉竞标这一轮。
三种主要拓扑的消息流向对比:
维度二:三层通信机制
打个比方:这三层就是你搭微服务时选的那三种通信方式——RPC 直连(点对点,知道对方是谁)、Kafka/消息队列(发出去就不管,谁订阅谁消费)、共享数据库/Redis(不互相说话,都往同一份状态里读写)。耦合度从高到低,可观测性也从高到低。类比失效边界:微服务之间传的是结构化 payload,接收方按 schema 解析,传多少就是多少;Agent 之间传的消息要进对方的上下文,于是有两件事变了——一是它会占预算且随 Agent 数平方增长,二是接收方是模型,它会对同一段文本做自己的解读,传过去的不一定是收到的。所以这里的选型压力不只是耦合度,更是 token 与语义保真度。
拓扑说的是「谁跟谁」,通信机制说的是「怎么传」。三层,从松到紧:
1. 消息传递
三种寻址方式,耦合代价递减:
| 寻址 | 语义 | 耦合代价 | 用在哪 |
|---|---|---|---|
| 直接寻址 | A 明确指定发给 B | 最高:A 必须知道 B 存在、知道 B 的能力 | Supervisor 派单、Pipeline 交接 |
| 广播 | 发给所有人,各自决定要不要理 | 中:发送方不知道谁在听 | 任务招标、全局状态变更通知 |
| 发布订阅 | 按 topic 投递,订阅方自行注册 | 最低:双方都不知道对方 | 事件驱动的松耦合系统 |
消息传递的最大陷阱:token O(N²)
只要 Agent 间的消息进入彼此的上下文,token 消耗就随 Agent 数平方增长——N 个 Agent 每人都要读其余 N−1 个人的输出。5 个 Agent 的群聊,第 10 轮时每个 Agent 的上下文里有 45 条别人的发言。
这是 Group Chat 拓扑最贵的地方,也是大多数 MAS demo 在真实任务上跑爆预算的原因。修法只有两条:要么改拓扑(Supervisor 让 Worker 互不可见,降到 O(N)),要么改传递内容(互传摘要与结论而非完整轨迹——但这会引入摘要漂移,见 运行时 · 三种失效)。没有第三条。
2. 共享状态(黑板)
黑板不是"一个 dict"。它是一个带并发语义的存储,最少要有三样东西:
// 黑板:结构化共享状态,非自由文本。
// 关键在 Version —— 没有它,两个 Agent 的并发写必然丢一个。
type Blackboard struct {
mu sync.RWMutex
entries map[string]Entry
}
type Entry struct {
Key string
Value any
Version int64 // 乐观锁:写入时必须带上读到的版本
Author string // 谁写的 —— 冲突归因时唯一的线索
WroteAt time.Time
}
var ErrStaleWrite = errors.New("blackboard: stale write, re-read and retry")
// CompareAndSwap:读到 v3 就必须以 v3 提交,否则拒绝。
// 拒绝后 Agent 必须重读 —— 而不是覆盖。
func (b *Blackboard) CAS(key string, expectVer int64, val any, author string) error {
b.mu.Lock()
defer b.mu.Unlock()
cur, ok := b.entries[key]
if ok && cur.Version != expectVer {
return fmt.Errorf("%w: key=%s want=%d got=%d (held by %s)",
ErrStaleWrite, key, expectVer, cur.Version, cur.Author)
}
b.entries[key] = Entry{
Key: key, Value: val, Version: expectVer + 1,
Author: author, WroteAt: time.Now(),
}
return nil
}
三个必须有的性质:
- 版本号 / 乐观锁:没有它,两个 Agent 并发写同一 key 必然丢一个写,且不会报错——这是 MAS 最难查的 bug 之一。
- Author 字段:冲突发生后要归因「这个结论是谁写的」,没有 author 就只能靠猜。
- 结构化而非自由文本:黑板存
{"protagonist": {"name": "林", "age": 28}},不存「主角叫林,28 岁」。自由文本的黑板等于让每个 Agent 重新做一次信息抽取,抽取偏差会累积。
3. 控制权交接(Handoff)
交接是"我做不了,你来",与派单的区别在于控制权真的转移了——交出去之后自己不再等待结果。
交接时的关键决策是带什么、不带什么:
| 带 | 不带 | 为什么 |
|---|---|---|
| 任务目标与硬约束 | 上游的完整推理轨迹 | 轨迹会把上游的错误假设一起传下去,且爆 token |
| 已确认的结构化事实 | 上游的中间猜测 | 猜测传下去会被下游当作既定事实(级联幻觉的主要入口) |
| 交接令牌(见下) | 上游的凭据 | 凭据只该由工具持有,见 运行时 · 凭据句柄化 |
交接必须幂等,因为交接消息可能重发(网络重试、调度器崩溃恢复、上游超时后误判失败而重发)。如果不幂等,同一个子任务会被下游执行两遍——如果它带副作用(发一条消息、扣一次费),就是真实事故。
令牌三段构成 task_id + from + seq,缺一不可:
// HandoffToken:三段缺一不可。
// task_id 定位是哪个任务(跨任务的同名子任务不能互相去重)
// from 定位是谁交接的(同一任务里 B→D 和 C→D 是两次不同交接)
// seq 定位是第几次(同一对 Agent 之间可能多次交接)
type HandoffToken struct {
TaskID string
From string
Seq int
}
func (t HandoffToken) Key() string {
return fmt.Sprintf("%s/%s/%d", t.TaskID, t.From, t.Seq)
}
type HandoffGuard struct {
mu sync.Mutex
seen map[string]Result // 已执行过的交接 → 上次的结果
}
// Accept:重复交接直接返回上次结果,不重新执行。
func (g *HandoffGuard) Accept(t HandoffToken, exec func() Result) Result {
g.mu.Lock()
if prev, dup := g.seen[t.Key()]; dup {
g.mu.Unlock()
return prev // 幂等:副作用只发生一次
}
g.mu.Unlock()
r := exec()
g.mu.Lock()
g.seen[t.Key()] = r
g.mu.Unlock()
return r
}
三段里最容易被漏掉的是 from:只用 task_id + seq 时,同一任务里 B→D 的第 1 次交接和 C→D 的第 1 次交接会撞成同一个 key,第二次被当成重复而静默跳过——一个子任务凭空消失,且日志上看不出来。
这一节记住三句话
- 只要 Agent 间的消息进了彼此的上下文,token 就是 O(N²)——修法只有改拓扑(让 Worker 互不可见)或改传递内容(只传结论摘要),没有第三条。
- 黑板不是一个 dict,它必须有版本号(否则并发写静默丢一个)、author(否则冲突无法归因)、结构化字段(否则每个 Agent 重做一次信息抽取,偏差累积)。
- 交接要带结论、不带轨迹,且必须幂等。令牌三段
task_id + from + seq缺一不可——漏掉from,两个上游对同一下游的交接会撞 key,一个子任务凭空消失且无日志。
物理层:消息怎么送到、Agent 被谁叫醒
上一节讲的是传什么——三种寻址、黑板的读写协议、交接带什么。这一节讲的是怎么送到、谁叫醒、什么时候能收。三层通信机制全都跑在这一层之上:它不是第四种通信机制,是那三种共同的下层。
打个比方:这一层就是你搭消息系统时要定的那两件事——投递(生产者写进队列)与消费(消费者被唤醒去取)。Kafka 的消费者组、RabbitMQ 的 prefetch、定时任务扫表,做的都是这件事。类比失效边界:常规消费者是一个常驻进程,它自己
select在那儿等,来了就处理;而 Agent 没有自己的线程——它通常只是一次已经返回的函数调用,而且一次模型推理不可中断、不可抢占。于是"消息到了"和"消息被处理"之间必然隔着一个回合边界,而"谁把它再叫起来跑一个回合"成了一个常规 MQ 里根本不存在的问题。
一条约束推出五个结论
先立约束:一次模型推理不可中断、不可抢占,且 Agent 没有独立生命周期。它不是一个能常驻等待的进程,因此它只在回合边界具备接收信息的能力。
五个结论全部由它推出,记住约束就能现场重建:
| 从约束推出 | 结论 |
|---|---|
| 推送推不进一个正在流式输出的模型 | 推送只能推到 inbox。所以任何方案最终都是同一个形状:push-to-inbox + pull-at-turn-boundary——投递是推,消费是拉 |
| 消息躺在 inbox 里,Agent 却不会自己醒 | 「谁给这个 Agent 再排一个回合」成为真问题,这就是下面的唤醒源 |
| 接收动作只能落在某个边界上 | 边界选在哪 = 中断粒度,四档各有代价 |
| 等待回复的 Agent 本身不在运行 | 它判不了自己的超时——超时判定必须外置到定时器 |
| 消息进的是上下文,不是 handler | 跨发送方的乱序不是"丢数据",是模型把到达顺序读成因果顺序 |
三种唤醒源:事件负责快,cron 负责不丢
| 唤醒源 | 负责什么 | 延迟 | 需要的基础设施 | 单独使用时的具体坏法 |
|---|---|---|---|---|
| 事件驱动(inbox 从空变非空即唤醒) | 活跃 Agent 之间的低延迟交接 | 毫秒级 | 常驻调度器 + 就绪队列 | 唤醒信号自身丢失时(信号队列满、调度器重启、进程崩在投递与唤醒之间),消息已经躺在 inbox 里,却再没有人给它排回合 → 子任务静默永停。且 inbox 里有数据、投递方收到了成功、监控一切正常 |
| cron / 定时扫描 | 空闲或挂起 Agent 的兜底唤醒 + 超时清算 | ≈ 扫描周期 / 2 | 一个 ticker | 每一跳都要等一个扫描周期,N 跳任务的延迟线性累积(10 跳 × 30s 周期 ≈ 2.5 分钟纯等待);Agent 多时空扫有固定成本 |
| 回合边界自取 | Agent 每轮收尾时自己拉一次 inbox | 0(零额外设施) | 无 | Agent 不在跑的时候没有人拉。它是前两者的补充,不能单独成立 |
结论一句话:事件驱动负责快,cron 负责不丢。
而 cron 的第二个职责——超时清算——是事件驱动结构上无法承担的。这一条是本节最有区分度的地方,单独说:
事件驱动的前提是"有事件可订阅"。但「超时」不是一个事件,它是"某个事件在期限内没有发生"——不发生的事情发不出信号。你当然可以为每次等待挂一个定时器,但那本身就是 cron,只是换了个名字。
更要命的是谁来挂这个定时器。唯一有动机判"B 超时了"的是正在等 B 的那个 A——而 A 此刻不在运行:它派单之后那一轮就结束了,它没有线程在那里数秒。所以在 MAS 里,超时判定必须由一个活着的外部循环承担。这跟单 Agent 完全不同:单 Agent 的超时是它自己一次工具调用的 deadline,它自己就在栈上等着(见 Agent 运行时深水区)。
一句话记法:事件驱动能告诉你"发生了什么",永远告诉不了你"什么没发生"。
中断粒度四档:越细越及时,对幂等的要求越强
消息只能在边界上被接收,那么边界选在哪:
| 档 | 边界在哪 | 及时性 | 代价 |
|---|---|---|---|
| 1. 回合内不可中断 | 本回合跑完才看 inbox | 最差 | 实现最简,语义最干净。默认档 |
| 2. 回合边界注入 | 下一轮装配上下文前注入 | 中 | 主流档。新信息以"上一轮之后发生的事"身份进入,时序自然 |
| 3. 工具调用边界注入 | 每次工具返回的回填点注入 | 较好 | Agent 会在「已调用工具、尚未 observe」这个中间态收到消息。必须显式定义该状态下的消息暂存规则(暂存到 observe 之后,还是允许它改变对本次工具结果的解读),否则同一条消息在不同时刻到达会产出不同轨迹,离线回放复现不出来 |
| 4. 真·中断正在流式的推理 | 任意时刻 | 最好 | 该轮 token 已经付了,且 Agent 处在"说了一半"的不一致状态,只能整轮丢弃。所以它不是"更强的中断",是"放弃这一轮" |
中断粒度与幂等是绑在一起的
两家框架独立踩到了同一个坑,值得记住:中断点越细,「中断点之前的副作用必须幂等」这个要求就越硬。
LangGraph 的 interrupt() 可以停在节点内的任意一行,但恢复时整个节点从头重跑——官方文档明说"any code that ran before the interrupt() will execute again",因此"side effects called before interrupt must be idempotent",否则会重复写库。Eino 的动态中断同样是"恢复时会再次运行此节点",而且再次运行时输入会被替换为空值,原始输入需要自己提前存进 State。
反过来,粒度越粗,保存状态的责任越往框架走:Eino 明文区分——外部强制中断时节点没机会保存局部状态,所以框架自动保存被中断节点的 input;而节点内部主动中断时,保存 input 是节点自己的职责。
这跟本篇前面的交接幂等是同一件事的两个面:只要存在"可能重跑"的边界,边界之前的副作用就必须幂等。
inbox:为什么默认不背压
inbox 应当有界。无界 inbox 会把"下游跟不上"这个事实藏起来,直到内存爆掉才暴露。有界就要回答队列满时怎么办:
| 处置 | 语义 | 什么时候选 |
|---|---|---|
| 丢最旧 | 保新弃旧 | 消息是状态快照("当前进度 70%")时可选——旧快照本就作废 |
| 拒绝投递 + 回错给发送方 | 投递失败是一个显式结果 | 默认选这个 |
| 背压阻塞发送方 | 发送方等到有空位 | MAS 里基本不选,见下 |
为什么不背压:常规后台里背压是优选——阻塞生产者总比丢数据好。但这里发送方是一个正在烧 token 的推理。阻塞它意味着:上下文占着、时长预算在走、费用预算在走,而它什么产出都没有。阻塞一个推理比丢一条消息贵得多,而且贵得不透明(表现为"这个 Agent 怎么这么慢")。
所以默认是拒投 + 把错误当场回给发送方:发送方立刻知道"没送到",可以在本回合内就改派或降级——它还活着,还能做决策。而背压会让它在一个无法决策的状态里干等。
投递语义与顺序:乱序会被读成因果
at-least-once 是可达上限。exactly-once 在跨进程且下游带模型副作用的情况下做不到——投递确认与副作用不在同一个事务里。
既然会重投,重复就必须被吃掉。不要另立一套机制:本篇前面的 HandoffToken(task_id + from + seq)三段令牌与 HandoffGuard 就是为此准备的,重发直接命中幂等返回上次结果。
顺序只有一条保证成立:「同一发送方 → 同一接收方」按 seq 保序。跨发送方必然交织——B 和 C 同时给 D 发消息,D 看到的先后取决于调度,不可复现。
而这里有一个 MQ 里不存在的坏法:常规消费者拿到乱序消息,最多是业务逻辑要自己排序;而 Agent 的消息进的是上下文,模型会把上下文里的先后当成因果上的先后。"先收到 C 说库存不足、后收到 B 说已下单"和反过来,会推出完全不同的结论——而两者都是合法的到达序。
修法很朴素但必须做:接收方装配上下文时按消息自带的时间戳或 seq 重排,而不是按到达顺序直接拼接。这与 Agent 运行时深水区 里"并行工具结果按发起顺序回填"是同一条原则的跨 Agent 版本。
超时与孤儿子任务
超时判定由外部定时器承担(上面已论证)。判定之后的处置只有三个方向,且不另立判据——直接接本篇前面「结果聚合与验收」那一套:
- 重发:疑似消息丢了或唤醒漏了(inbox 里查得到 / 查不到是关键线索)。
- 改派:下游确实收到了但做不完(对应"能力不匹配 → 改派")。
- 降级:不再等,由主 Agent 或单 Agent 兜底完成(对应活锁触发后的降级)。
重试次数上限与退避策略见 Agent 运行时深水区,本节不重复。
孤儿子任务是这里的特产:派出去了、没回来、也没人在等(等待方那一轮早结束了)。它与「责任扩散」的区别在于责任是明确的、只是执行链断了,所以检测手段也不同——靠 pending 表 + 定时清算,而不是靠未认领子任务表。
三项可观测指标
| 指标 | 怎么读 |
|---|---|
| inbox 深度 | 持续 > 0 说明消费跟不上投递;某个 Agent 的 inbox 单独堆积说明它是瓶颈 |
| 投递 → 消费延迟 | 必须拆两段:入队延迟(投递快不快)+ 回合边界等待(叫醒快不快)。混成一个数就分不清"是投递慢"还是"是没人叫它" |
| 唤醒源分布 | 事件唤醒 vs cron 兜底唤醒的比例。cron 兜底占比上升 = 事件通路在丢唤醒——这是本节唯一一个能提前发现"子任务静默永停"的信号,因为那种故障本身不报错 |
span 树怎么挂、指标分母怎么算,见 Agent 评测与线上运营,本节只给"看什么、为什么看"。
投递与唤醒的时序
调度器骨架
前面 Bus.Run 演示的是路由,这一段演示的是投递。三件事:有界 inbox、两个唤醒源、超时清算。
package mas
// 本节给 Envelope 补两个字段:Seq(同一对 Agent 间的序号,与 HandoffToken 对齐)
// 与 SentAt(发出时刻)。前者用于重排与幂等,后者用于超时判定。
// Inbox:有界是刻意的 —— 无界 inbox 会把"下游跟不上"藏起来,直到内存爆掉。
type Inbox struct {
mu sync.Mutex
msgs []Envelope
cap int
}
var ErrInboxFull = errors.New("mas: inbox full, delivery rejected")
// Push:满了拒投并回错,不阻塞发送方。
// 为什么不背压:发送方是一个正在烧 token 的推理,阻塞它比丢一条消息贵得多,
// 且贵得不透明(只表现为"这个 Agent 怎么这么慢")。拒投至少让它当场能改派。
func (ib *Inbox) Push(e Envelope) error {
ib.mu.Lock()
defer ib.mu.Unlock()
if len(ib.msgs) >= ib.cap {
return fmt.Errorf("%w: to=%s depth=%d", ErrInboxFull, e.To, len(ib.msgs))
}
ib.msgs = append(ib.msgs, e)
return nil
}
// DrainSorted:回合边界拉取。按 (From, Seq) 重排而非按到达序 ——
// 模型会把上下文里的先后当成因果先后,按到达序拼接会推出错误结论。
func (ib *Inbox) DrainSorted() []Envelope {
ib.mu.Lock()
defer ib.mu.Unlock()
out := ib.msgs
ib.msgs = nil
sort.SliceStable(out, func(i, j int) bool {
if out[i].From != out[j].From {
return out[i].From < out[j].From
}
return out[i].Seq < out[j].Seq
})
return out
}
type Scheduler struct {
agents map[string]Agent
inboxes map[string]*Inbox
guard *HandoffGuard // 复用交接幂等守卫:at-least-once 的重发全靠它
ready chan string // 事件唤醒:谁的 inbox 从空变非空
sweep *time.Ticker // cron 兜底:既补漏唤醒,也做超时清算
pending map[string]time.Time // 已派出、等回复的交接 → 派出时刻
timeout time.Duration
}
// Deliver:投递 = push 到 inbox + 发一个唤醒信号。
// 两步都不碰 Agent 本体 —— 它可能正在推理,不可中断。
func (s *Scheduler) Deliver(e Envelope) error {
if err := s.inboxes[e.To].Push(e); err != nil {
return err // 拒投,错误回给发送方,它还活着、还能改派
}
select {
case s.ready <- e.To:
default:
// 唤醒信号被丢弃(ready 满)—— 这不是错误,兜底扫描会捡起来。
// 这一行就是"为什么必须有 cron"的全部理由:没有它,这条消息会
// 永远躺在 inbox 里,而 inbox 有数据、投递成功、监控一切正常。
}
return nil
}
func (s *Scheduler) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case name := <-s.ready:
s.runTurn(ctx, name) // 事件驱动:负责快
case <-s.sweep.C:
for name := range s.inboxes {
s.runTurn(ctx, name) // 兜底:负责不丢(捡被丢掉的唤醒)
}
s.reap() // 超时清算:只有活着的外部循环做得到
}
}
}
// runTurn:一个回合。消息只在回合开始前注入,回合内不再接收。
func (s *Scheduler) runTurn(ctx context.Context, name string) {
for _, e := range s.inboxes[name].DrainSorted() {
// at-least-once:同一条交接可能被投递多次,靠三段令牌去重
tok := HandoffToken{TaskID: e.TaskID, From: e.From, Seq: e.Seq}
s.guard.Accept(tok, func() Result { return s.agents[name].Step(ctx, e) })
}
}
// reap:等待方自己不在运行,判不了自己的超时 —— 必须外部来判。
// 它只把超时的标记成孤儿交回编排器,不在这里决定重发还是改派:
// 那个判据属于「结果聚合与验收」,不该散落在调度器里。
func (s *Scheduler) reap() {
for key, sentAt := range s.pending {
if time.Since(sentAt) >= s.timeout {
delete(s.pending, key)
s.onOrphan(key)
}
}
}
sweep 周期是这一层唯一的调参旋钮,它同时决定两件事:漏唤醒的最坏恢复延迟与超时判定的精度。调小则空扫成本上升,调大则静默永停的子任务要躺更久——没有免费档。
这一节记住三句话
- 一次推理不可中断、Agent 没有独立生命周期,所以它只在回合边界能收消息。由此任何方案都是同一个形状:
push-to-inbox+pull-at-turn-boundary——推送只能推到信箱,推不进正在输出的模型。 - toggle 与 cron 不是二选一,是分工:事件驱动负责快,cron 负责不丢。而 cron 的超时清算职责事件驱动结构上承担不了——「超时」不是一个事件(不发生的事情发不出信号),且唯一有动机判超时的那个 Agent 此刻不在运行。一句话:事件驱动能告诉你"发生了什么",永远告诉不了你"什么没发生"。
- 两个 MAS 特产的坏法:① 纯事件驱动丢了唤醒信号 → 消息躺在 inbox 里再无人排回合 → 子任务静默永停,而 inbox 有数据、投递成功、监控正常;② 跨发送方乱序进的是上下文,模型把到达顺序读成因果顺序 → 必须按
seq重排再装配,不能按到达序拼。另记一条:默认拒投而不背压,因为阻塞发送方等于阻塞一个正在烧 token 的推理。
矩阵:拓扑 × 通信机制
这是本篇的主骨架。两维不是自由组合——同一拓扑配不同通信机制,失效模式完全不同。只填工业实践里真会遇到的格:
矩阵为什么仍是两维:上一节的物理层(投递 / 唤醒 / 中断粒度)不进这个矩阵。它与拓扑基本正交——同一格的失效严重程度会随投递机制变化(
Supervisor × 消息传递在纯 cron 下会额外叠加逐跳的轮次延迟累积),但不产生新的格子。硬塞成三维会得到 5×3×3 = 45 格,其中绝大多数差异不成立,纸面上也画不出来。
| 消息传递 | 共享状态(黑板) | 交接 Handoff | |
|---|---|---|---|
| Supervisor | ✅ 最常见。Planner 直接寻址派单,Worker 回复。 失效点:Planner 上下文被 Worker 回复挤满,退化成摘要机器。修法:Worker 回复走结构化 schema 而非自由文本,Planner 只读字段不读散文。 | ✅ 长任务标配。派单走消息,产物落黑板。 失效点:两个 Worker 并发写同一 key 丢写。修法:CAS + 按子任务划分 key 命名空间,从设计上避免共写。 | ⚠️ 反模式。Supervisor 交出控制权就不再是 Supervisor 了,验收环节凭空消失。除非是显式的"升级到另一个 Supervisor"。 |
| Pipeline | ✅ 天然匹配。上游直接寻址下游。 失效点:下游发现上游产物有问题,但单向流没有回退边。修法:给每级一个 reject(reason) 反向通道,或在级间插评审 Agent。 | ⚠️ 少见。Pipeline 的产物本就是逐级传递的,再加黑板等于两套真相,容易不同步。仅当需要跨级共享设定(如创作流水线的 Story Bible)时才加,且黑板只读不写。 | ✅ 就是 Pipeline 的本质——每级交接控制权。令牌的 seq 即级号。 |
| Group Chat | ✅ 定义上就是消息传递(广播到同一条流)。 失效点:token O(N²) + 跑题 + 发言权争夺。修法:硬限最大轮数、指定主持人决定发言顺序、每轮强制产出结构化结论而非自由讨论。 | ⚠️ 冗余。既然人人互见消息流,黑板提供不了额外信息,只增加一套状态。 | ❌ 语义冲突。群聊里没有"控制权"这个概念可交,人人都在说话。 |
| Blackboard | ⚠️ 冲突组合,见下方裁决。 | ✅ 定义上就是共享状态。 失效点:读到中间态(另一个 Agent 写了一半)、状态无限膨胀。修法:写入原子化(要么整条 entry 可见要么不可见)+ 给 entry 加 TTL 或按任务归档。 | ✅ 可组合。交接时不传数据,只传"去读黑板的哪个 key"——这是最省 token 的交接形态。 |
| Contract Net | ✅ 竞标本身就是广播 + 定向回价。 | ⚠️ 少见。 | ✅ 中标即交接。 |
空缺的格子(❌ / ⚠️ 标注)不是没想到,是这些组合在语义上冗余或自相矛盾——上表已逐格给出理由。
维度冲突时的裁决规则
规则:以一致性要求为先。需要强一致的共享状态,就不能用广播消息传递,必须走黑板 + 版本号。
具体冲突就在上表 Blackboard × 消息传递 那一格。看一个例子——三个 Agent 协作写一份剧本,需要共享「主角当前年龄」这个设定:
- 走广播消息:Agent X 广播「主角改成 32 岁」。问题是广播没有版本语义——如果 Y 在收到广播前已经基于 28 岁写了一段,它的输出就是脏的,而且没有任何机制能发现。更糟的是,如果 Y 和 Z 同时各广播一个新年龄,最终状态取决于消息到达顺序,不可复现。
- 走黑板 + CAS:X 读到
age@v3,以 v3 提交 32。Y 同时也想改,它读到的是 v3,提交时被ErrStaleWrite拒绝,被迫重读拿到 v4=32,再决定要不要改。冲突从"静默丢写"变成"显式报错"——这就是全部的收益。
对照另一个方向的冲突,Supervisor × 黑板 vs Supervisor × 消息传递:
| Supervisor + 消息传递 | Supervisor + 黑板 | |
|---|---|---|
| 主要风险 | 中心瓶颈:所有 Worker 回复进 Planner 上下文 | 状态竞争:Worker 并发写同一 key |
| 风险性质 | 会爆窗、会变慢,但看得见 | 静默丢写,看不见 |
| 怎么选 | Worker 产物小、需 Planner 逐个判断 → 选它 | Worker 产物大、Planner 只需知道"完成了" → 选它,但必须上 CAS |
一句话记法:消息传递的失效是吵(吵到爆窗),黑板的失效是静(静悄悄丢数据)。选黑板就必须付版本号这笔钱。
冲突消解:可判定的四级裁决阶梯
打个比方:这套阶梯就是你值班时的故障定级与升级流程——P3 先让自动化重试一把(看能不能自己好)、P2 值班的人查日志判一下、P1 拉群把相关方叫齐、P0 直接叫老板决策。每一级都有明确的进入判据,而且不允许跳级:不是因为流程官僚,而是因为越高级别越贵,能在低级别解决的问题上升一级就是浪费。类比失效边界:故障定级的输入是客观信号(错误率、影响面),谁都能算出同一个级别;这里的输入是两个 Agent 各自的说法,而其中一方可能在编造依据。所以这套阶梯的第 1 级不是"判断谁更可信",而是先去外部找证据把编造的那方直接踢掉——少了这一步,后面所有级别都是在两份可能都不可信的材料之间做选择。
回到开篇的场景。B 和 C 的结论矛盾,A 该怎么办。
这道题最常见的错答法是给一串并列选项:「可以投票、可以让它们再讨论一轮、可以交给人」。并列清单不是答案,因为它没回答"什么时候用哪个"。正确的结构是有序阶梯 + 每级的进入判据。
第 0 级:先分类,再选手段
不分类就选手段,等于用治骨折的方法治感冒。四类冲突,识别特征与处理方向完全不同:
| 类型 | 识别特征 | 本质 | 处理方向 |
|---|---|---|---|
| 事实冲突 | 对同一客观命题给出相反结论(「违反 3.2 条」vs「不违反 3.2 条」) | 至少一方错了 | 找证据,能复算的复算 |
| 口径冲突 | 结论方向一致但数字/结论不同,且各自的统计口径、单位、时间窗不同 | 两边可能都对 | 统一口径后重算,不是选一边 |
| 时序冲突 | 两方基于不同时刻的状态快照(C 的判例早于规则 3.2 生效日) | 两边在各自时刻都对 | 按时间戳取新,旧结论作废而非"错" |
| 幻觉冲突 | 一方引用的依据无法溯源(引了一个不存在的条款号) | 一方凭空编造 | 直接判负,不进入后续裁决 |
表是四类的并列定义,但真正难的是判断次序——四个问题必须按下面这个顺序问,第一个答「否」的地方就是它的类型:
开篇场景的真实分类:看起来是事实冲突,实际很可能是时序冲突——如果规则 3.2 是新增的,C 的 4 起判例全部作废,根本不需要"综合判断"。
不分类的后果是具体的:把时序冲突当事实冲突处理,你会去比较 B 和 C 谁更可信,然后得出一个"B 置信度 0.8、C 置信度 0.7,听 B"的结论——碰巧对了,但理由完全是错的,下次口径反过来就会错。把口径冲突当事实冲突处理更糟:两边都对,你却强行判了一边负。
分类这一步本身要落地成一个动作:要求每个下游 Agent 的回复必须带上「依据 + 依据的时间戳 + 可溯源标识」三个字段。没有这三个字段,分类无法自动进行,只能靠人看。
第 1~4 级:阶梯与进入判据
| 级 | 手段 | 进入判据 | 代价 | 为什么不能跳过前一级 |
|---|---|---|---|---|
| 1 | 证据优先:可工具复算或可溯源到权威数据的一方胜 | 至少一方的结论能被工具重新算一遍,或能溯源到 SSOT | 一次工具调用,最便宜 | — |
| 2 | 置信度加权:取 self-report 置信度 × 该 Agent 的历史准确率 | 双方都无法客观复算,但能取到置信度或历史准确率 | 需要维护历史准确率表 | 有客观依据时用加权是浪费——而且可能输给一个高置信度的错答 |
| 3 | 第三方仲裁 Agent:独立 Agent 看双方论据后判 | 加权后差距在阈值内(无明显优势方,如 |Δ| < 0.15) | 多一次完整推理,最贵的自动手段 | 无证据时直接仲裁,等于引入第三个幻觉源去评判两个幻觉 |
| 4 | 人工闸门 | ① 冲突涉及不可逆动作;或 ② 仲裁后重试 N 次仍不收敛(同分/反复翻转) | 人工延迟,从秒级变成分钟/小时级 | 人是最贵的资源,必须最后一级 |
逐级说清楚:
第 1 级 · 证据优先。 「可复算」的判定要具体:B 说"违反 3.2 条",可复算 = 拿条款号去规则库查原文,看内容是否真的落在条款描述内。这一步能筛掉的不只是错答,还有幻觉冲突——如果 3.2 条不存在,B 直接判负,裁决在第 1 级就结束了。证据优先能解决的冲突比想象的多,因为 Agent 的冲突里相当大比例是一方在编造依据。
第 2 级 · 置信度加权。 两个坑:其一,LLM 的 self-report 置信度校准很差(普遍高估,且对措辞敏感),单用它不如不用;必须乘上一个外部观测到的历史准确率——即这个 Agent 在这类任务上过去的实际命中率,这个数字来自 评测与线上运营 的离线回放。其二,加权的结果是一个连续分数,必须设阈值把"没有明显优势"这种情况识别出来,而不是硬取较大值——0.71 vs 0.69 判 B 胜,是在把噪声当信号。
第 3 级 · 第三方仲裁。 仲裁 Agent 的 prompt 有个硬要求:只给它双方的论据,不给双方的身份和置信度。给了身份它会偏向"听起来更权威"的那个,给了置信度它会直接抄分高的那个——这两种偏差都会让仲裁退化成第 2 级。另外仲裁 Agent 最好换一个模型(不同厂商或不同规模),理由见下一节。
第 4 级 · 人工闸门。 两个触发条件写死在代码里,不由模型判断:不可逆动作(下架内容、扣费、对外发布——这层分级复用 运行时 · 危险动作三级分级);不收敛(仲裁重试 N 次,出现同分或结论反复翻转)。「反复翻转」这个信号特别要监控——它说明输入本身是模糊的,再仲裁一百次也不会收敛,继续重试只是在烧钱。
为什么「多数投票」不在这个阶梯里
这是这道题真正的区分度所在。两条独立的原因,任一条都足以否掉它:
原因一:A/B/C 场景里实际只有 2 个投票者,2 票在数学上无法产生多数。 A 是提问者和裁决者,不是投票者;投票者只有 B 和 C。两票相反就是 1:1 平局,投票机制不产生任何结论——它需要奇数个投票者才有定义。这不是"投票效果不好",是投票在这个场景下没有输出。想用投票就必须加一个 D,而这引出第二个问题。
原因二:同源模型的投票不独立,会把错误加权放大成"虚假共识"。 投票的全部数学基础是投票者的错误相互独立——独立时多数投票才能把准确率从 p 提升到超过 p。但 B、C、D 如果是同一个模型(同一权重、同一训练数据)的三个实例,对同一段模糊输入,它们会犯同向错误:不是随机分散地错,而是一起错到同一个答案上。
这时投票的结果是最坏的:三票一致,置信度看起来极高,但答案是错的。你不但没纠错,还把一个错答包装成了"三个 Agent 一致确认",让下游更难质疑它。这就是虚假共识——投票把相关性误当成了独立验证。
所以阶梯的第 1 级是证据而不是投票:证据是外部的、与模型无关的,它天然独立于模型的偏差。投票只在一个前提下有效——投票者来自不同模型且各自有独立信息源,这个前提在大多数 MAS 里都不成立(通常是同一个模型换了 system prompt),所以默认不把它放进阶梯。这也是第 3 级仲裁 Agent 建议换模型的理由。
裁决时机:必须在主 Agent 装配上下文之前
这一条是把 MAS 和单 Agent 运行时接起来的关键。
裁决 MUST 在 A 装配自己的上下文之前完成,只有裁决结果进 A 的窗口。
开篇场景里 A 犯的错正是这个:它把 B 和 C 的矛盾结论都塞进了自己的上下文,然后指望模型"综合判断"。后果有两层:
- 立刻的后果:模型面对矛盾输入时倾向于拼接而非取舍("下架并加提示标签"),因为拼接在语言上更像"全面考虑"。
- 长期的后果:矛盾结论一旦进了 A 的上下文,就成了 A 后续所有推理的前提——这就是 运行时 · 上下文中毒 记的那个坑。而且它比普通的上下文中毒更难清:中毒的不是一条错事实,是一对互斥事实,压缩摘要时模型会随机保留其中一条,之后你完全不知道它按哪条在推理。
所以正确的流程是:
图里那两条虚线红叉是全篇最重要的一笔:B 和 C 的原始矛盾结论没有一条直连到 A 的上下文装配。
裁决结果进 A 的窗口时,还要带上裁决理由与被否结论——但形态是「已裁决:采纳 X,因为 Y;C 的相反结论因时序作废」这样一条已闭合的事实,而不是两条待判断的开放信息。这个区别很细但很实在:前者模型不会再去权衡,后者会。
仲裁器代码骨架
把上面四级写成可执行的样子。注意 Resolve 的每个 return 都对应阶梯的一级,升级人工是一个显式返回分支,不是注释:
package arbiter
// Claim:下游 Agent 的一条结论。三个非结论字段是分类的前提 ——
// 没有 Evidence / AsOf / Traceable,第 0 级分类无法自动进行。
type Claim struct {
Agent string
Conclusion string
Evidence string // 依据(条款号、文档 ID、查询语句)
AsOf time.Time // 依据对应的状态时刻 —— 时序冲突全靠它
Traceable bool // 依据能否溯源到 SSOT
SelfConf float64 // 模型自报置信度,校准很差,只作为第 2 级输入之一
}
type Verdict struct {
Level int // 在第几级收敛(1~4),落 metric 用
Winner *Claim // nil 表示需要人工
Rejected []Claim
Reason string // 进 A 上下文的那句话
NeedsHuman bool
}
type ConflictKind int
const (
KindFactual ConflictKind = iota // 事实冲突
KindMetric // 口径冲突
KindTemporal // 时序冲突
KindHallucination // 幻觉冲突
)
type Arbiter struct {
verify func(Claim) (ok bool, err error) // 工具复算/溯源
accuracy func(agent string) float64 // 历史准确率,来自离线回放
judge func(a, b Claim) (winner string, err error) // 换模型的仲裁 Agent
confMargin float64 // 第 2 级的阈值,经验 0.15
maxJudge int // 第 3 级最多重试次数
irreversible func(conclusion string) bool // 是否触发不可逆动作
}
func (r *Arbiter) Resolve(a, b Claim) Verdict {
// ── 第 0 级:分类。不分类就选手段 = 必错。
switch r.classify(a, b) {
case KindHallucination:
// 一方依据不可溯源 → 直接判负,不进入后续裁决。
if !a.Traceable && b.Traceable {
return Verdict{Level: 1, Winner: &b, Rejected: []Claim{a},
Reason: fmt.Sprintf("采纳 %s:%s 的依据 %q 无法溯源", b.Agent, a.Agent, a.Evidence)}
}
if !b.Traceable && a.Traceable {
return Verdict{Level: 1, Winner: &a, Rejected: []Claim{b},
Reason: fmt.Sprintf("采纳 %s:%s 的依据 %q 无法溯源", a.Agent, b.Agent, b.Evidence)}
}
// 双方都不可溯源 —— 没有任何可信输入,不做自动裁决。
return Verdict{Level: 4, NeedsHuman: true,
Reason: "双方依据均无法溯源,无可信输入"}
case KindTemporal:
// 时序冲突:按时刻取新,旧结论「作废」而非「错误」——
// 措辞很重要,写成"错误"会污染该 Agent 的历史准确率统计。
newer, older := a, b
if b.AsOf.After(a.AsOf) {
newer, older = b, a
}
return Verdict{Level: 1, Winner: &newer, Rejected: []Claim{older},
Reason: fmt.Sprintf("采纳 %s(依据时刻 %s);%s 的结论基于更早的 %s,已作废",
newer.Agent, newer.AsOf.Format("2006-01-02"), older.Agent, older.AsOf.Format("2006-01-02"))}
case KindMetric:
// 口径冲突:两边可能都对,不能选一边 —— 必须统一口径重算。
// 这是唯一一个「不产出 Winner 也不需要人工」的出口:回退给调用方重问。
return Verdict{Level: 1, Winner: nil,
Reason: "口径不一致(统计窗口/单位不同),需统一口径后重新查询,不做优劣裁决"}
case KindFactual:
// 走完整阶梯。
}
// ── 第 1 级:证据优先。能复算的复算。
aOK, aErr := r.verify(a)
bOK, bErr := r.verify(b)
if aErr == nil && bErr == nil && aOK != bOK {
w, l := a, b
if bOK {
w, l = b, a
}
return Verdict{Level: 1, Winner: &w, Rejected: []Claim{l},
Reason: fmt.Sprintf("采纳 %s:其依据 %q 经复算成立,%s 的依据复算不成立",
w.Agent, w.Evidence, l.Agent)}
}
// ── 第 2 级:置信度加权。self-report 必须乘历史准确率。
sa := a.SelfConf * r.accuracy(a.Agent)
sb := b.SelfConf * r.accuracy(b.Agent)
if math.Abs(sa-sb) > r.confMargin {
w, l, ws, ls := a, b, sa, sb
if sb > sa {
w, l, ws, ls = b, a, sb, sa
}
return Verdict{Level: 2, Winner: &w, Rejected: []Claim{l},
Reason: fmt.Sprintf("采纳 %s:加权置信 %.2f vs %.2f(差距超阈值 %.2f)",
w.Agent, ws, ls, r.confMargin)}
}
// ── 第 3 级:第三方仲裁。隐去身份与置信度,只给论据。
flips := 0
var last string
for i := 0; i < r.maxJudge; i++ {
winner, err := r.judge(strip(a), strip(b)) // strip: 去掉 Agent 名与 SelfConf
if err != nil {
break
}
if last != "" && winner != last {
flips++ // 结论翻转 —— 输入本身模糊,再判也不会收敛
}
last = winner
if flips == 0 && i+1 >= 2 { // 连续两次同结论才算收敛
w, l := a, b
if winner == b.Agent {
w, l = b, a
}
return Verdict{Level: 3, Winner: &w, Rejected: []Claim{l},
Reason: fmt.Sprintf("采纳 %s:第三方仲裁连续两次一致", w.Agent)}
}
}
// ── 第 4 级:人工闸门。显式返回分支,不是 TODO 注释。
// 两个触发条件都由代码判定,不交给模型。
why := "仲裁未收敛(结论反复翻转,输入本身模糊)"
if r.irreversible(a.Conclusion) || r.irreversible(b.Conclusion) {
why = "冲突涉及不可逆动作,按策略强制人工确认"
}
return Verdict{Level: 4, NeedsHuman: true, Reason: why}
}
三个容易写错的实现细节,都在上面代码里:
- 时序冲突的措辞是「作废」不是「错误」。如果把旧结论记为错误,它会进入该 Agent 的历史准确率统计,把一个本来正确的 Agent 的准确率拉下来——而准确率正是第 2 级的输入。这个反馈回路会慢慢腐蚀整个裁决系统。
- 口径冲突的出口既不是 Winner 也不是人工,而是「回退重问」。它是唯一一个可以自动修复但不产生胜负的分支,漏掉它会导致把两个都对的结论判一个负。
- 第 3 级的收敛判定要求"连续两次同结论",不是"跑一次就信"。跑一次的仲裁和第 2 级的加权一样脆弱,而翻转次数正是"该升级到人工"的最强信号。
规模锚点
上面这套阶梯以「一次冲突涉及 2~3 个 claim、下游 Agent 数 ≤ 8」为前提。claim 数上到十几个时(比如 10 个 Agent 各给一个结论),阶梯本身还成立,但第 3 级的两两仲裁会变成 O(N²) 次推理——此时应先按结论聚类(相同结论归并),把 10 个 claim 压成 2~3 个阵营再进阶梯。
这一节记住三句话
- 先分类再选手段。四类冲突(事实 / 口径 / 时序 / 幻觉)处理方向完全不同:口径冲突两边都对(统一口径重算)、时序冲突两边在各自时刻都对(按时间戳取新、旧的作废不算错)。不分类就选手段,等于用治骨折的方法治感冒。
- 阶梯必须从证据起步,且不能跳级。证据 → 置信度加权 → 第三方仲裁 → 人工闸门,逐级变贵。无证据时直接上仲裁,等于引入第三个幻觉源去评判两个幻觉。
- 多数投票不在阶梯里,两条独立理由各自足够:2 个投票者产生不了多数(A 是裁决者不是投票者);同源模型会犯同向错误,投出"三票一致但全错"的虚假共识——把相关性误当成了独立验证。
任务调度、分派与结果聚合
打个比方:这一节就是你写过的任务编排器——把大活拆成 DAG、按 worker 能力或负载派单、收回结果做验收、失败了决定重试还是换台机器。Airflow、Celery、你自己那套调度框架,做的都是这件事。类比失效边界:常规编排里 worker 的产出是确定的——同样输入跑两遍结果一样,失败会明确抛错。这里 worker 是模型:它可能"成功返回"一个错答案(不抛错、格式还合法),所以验收不能省,且验收方不能是产出方;它的失败也分两类——偶发(该重试)和能力不匹配(该改派),而区分二者要看失败原因,不能一律重试。
任务分解与 DAG
多 Agent 的调度输入是一张 DAG,关键是哪条边是真依赖、哪条是假依赖:
- 真依赖(必须串行):下游需要上游的确定性产物才能开始。分镜必须等剧本定稿——不是"最好等",是没有剧本无法分镜。
- 假依赖(可并行):只是书写顺序上先后,实际无数据依赖。查规则库和查历史判例之间没有依赖,串行只是因为代码是顺着写的。
判定方法很朴素:问「上游产物的内容变化,会不会改变下游的输入」。会 → 真依赖;不会 → 假依赖,拆开并行。这个判定要在编排期做(写 DAG 时),不能交给运行时的模型判断——模型倾向于保守串行,会白白丢掉并行度。
三种分派策略
| 策略 | 怎么派 | 适用条件 | 陷阱 |
|---|---|---|---|
| 能力路由 | 按静态声明的能力/工具权限匹配 | Agent 能力边界清晰且稳定 | 声明与实际能力漂移(工具下线了声明还留着)→ 需定期校验 |
| 负载均衡 | 派给当前最空闲的同类 Agent | 有多个同质 Agent 实例 | 只对无状态Agent 成立;有会话状态的必须黏在同一实例 |
| 竞标 | 广播 → 出价 → 择优 | 能力异构且事先不知谁合适 | 出价基于模型自评 = 系统性高估(见前文)→ 出价必须是可校验的硬事实 |
默认选能力路由。它最简单、最可预测、最好调试。只有当"派给谁"这件事本身需要运行时信息才升级到后两者。
结果聚合与验收
派出去之后,谁负责验收必须显式指定,否则就是下面要讲的「责任扩散」。三条规则:
- 验收方不能是产出方。让 Worker 自己判断自己的产物是否合格,等于没有验收——模型对自己的输出评价普遍偏高。
- 验收标准要在派单时就给出,写进子任务的定义里("返回必须是含 X/Y/Z 三字段的 JSON,且 X 非空"),不是产出后再临时定。
- 验收失败要区分「重派」和「改派」:同一个 Agent 重试(可能是偶发失败)vs 换一个 Agent(可能是能力不匹配)。判据是失败原因——格式错/超时 → 重派;「我做不了这个」→ 改派。重试次数上限与退避策略复用 运行时 · 重试策略。
整体回滚边界:这是 MAS 比单 Agent 难的地方。子任务 1、2 成功且已产生副作用,子任务 3 失败——不能简单地"整体回滚",因为 1、2 的副作用可能不可逆。可行的边界是:把有副作用的子任务尽量后置,编排成「所有只读/可回滚的先跑,不可逆的集中在最后一段」,这样失败时需要补偿的范围最小。这本质上是 Saga 模式在 Agent 编排上的应用。
拓扑无关的 Agent 接口 + 消息总线
这是"模块化可插拔"落到代码上的样子。关键性质:换拓扑不改 Agent 实现——Agent 只认识 Envelope 进、Envelope 出,完全不知道自己身处 Supervisor 还是群聊:
package mas
// Envelope:唯一的传输单元。Agent 只认识它,不认识拓扑。
type Envelope struct {
TaskID string
From string
To string // 直接寻址;广播时为空
Topic string // 发布订阅时使用
Payload any
Trace string // 贯穿全链路的 trace id,归因全靠它
}
// Agent:拓扑无关的最小接口。
// 注意它没有任何「向谁发送」的能力 —— 发送由总线按拓扑决定。
// 这是「换拓扑不改 Agent」的全部秘密:剥夺 Agent 的寻址权。
type Agent interface {
Name() string
Handle(ctx context.Context, in Envelope) ([]Envelope, error) // 返回想发出的消息
}
// Topology:决定一条消息实际投递给谁。换拓扑 = 换这一个实现。
type Topology interface {
Route(out Envelope, registry map[string]Agent) []string // 返回收件人名单
}
type SupervisorTopology struct{ Planner string }
func (t SupervisorTopology) Route(out Envelope, reg map[string]Agent) []string {
// Supervisor:Worker 的输出一律回 Planner,Worker 之间互不可见 —— 这就是 O(N) 的来源
if out.From != t.Planner {
return []string{t.Planner}
}
if out.To != "" {
return []string{out.To}
}
names := make([]string, 0, len(reg))
for n := range reg {
if n != t.Planner {
names = append(names, n)
}
}
return names
}
type GroupChatTopology struct{}
func (GroupChatTopology) Route(out Envelope, reg map[string]Agent) []string {
// 群聊:广播给除自己外所有人 —— O(N²) token 就是在这一行产生的
names := make([]string, 0, len(reg))
for n := range reg {
if n != out.From {
names = append(names, n)
}
}
return names
}
// Bus:调度循环。预算与终止条件在这里,不在 Agent 里。
type Bus struct {
agents map[string]Agent
topo Topology
guard *HandoffGuard
maxHops int // 硬闸:消息传递总跳数上限,防活锁
}
func (b *Bus) Run(ctx context.Context, seed Envelope) error {
queue := []Envelope{seed}
for hops := 0; len(queue) > 0; hops++ {
if hops >= b.maxHops {
return fmt.Errorf("mas: max hops %d exceeded (可能活锁), trace=%s", b.maxHops, seed.Trace)
}
cur := queue[0]
queue = queue[1:]
for _, name := range b.topo.Route(cur, b.agents) {
ag := b.agents[name]
in := cur
in.To = name
outs, err := ag.Handle(ctx, in)
if err != nil {
return fmt.Errorf("mas: agent %s: %w", name, err)
}
queue = append(queue, outs...)
}
}
return nil
}
换拓扑只需要 bus.topo = GroupChatTopology{},六个 Agent 的实现一行不动。代价也要说清:这层抽象让"消息去哪"从 Agent 代码里消失了,调试时你不能再从 Agent 代码读出数据流向,必须去看 Topology + trace。这是可插拔的固定成本。
这段骨架的边界
Bus.Run 是同步单线程的广度优先遍历(一个队列顺序跑完),它演示的是 Topology.Route 的拓扑无关性与 maxHops 活锁硬闸,不是生产投递模型——它把"消息怎么送到、Agent 被谁唤醒、超时谁来判"这三个问题全绕开了(因为单线程里没有"等待"这回事)。照它的形状做并发 MAS,会在超时、孤儿子任务、inbox 背压三处同时踩坑。投递侧的骨架见前面 物理层:消息怎么送到、Agent 被谁叫醒。
这一节记住三句话
- 拆 DAG 的唯一判据是"上游产物变了会不会改下游输入"。会 → 真依赖必须串行;不会 → 假依赖拆开并行。这个判定要在编排期做完,交给运行时的模型它会保守串行、白丢并行度。
- 验收方不能是产出方,且验收标准要在派单时就写进子任务定义里。让 Worker 自评等于没有验收——模型对自己的输出评价普遍偏高。
- 有副作用的子任务尽量后置。子任务 1、2 已产生不可逆副作用而 3 失败时,没法"整体回滚";编排成"只读/可回滚的先跑、不可逆的集中在最后一段",失败时的补偿范围最小——本质是 Saga 模式在 Agent 编排上的应用。
为什么这么做
为什么冲突消解必须是阶梯而不是策略池
策略池("有这几种方法可选")的问题不在于方法不对,在于它把选择权留给了运行时——而运行时的选择者是模型。让模型选"这次用投票还是用仲裁",你就得到一个不可复现的系统:同一个冲突两次运行走了不同路径,线上出问题无法回溯。
阶梯把选择权收回到代码里的判据:能不能复算是一次工具调用的返回值,差距是否超阈值是一个减法。这两件事对同一输入永远给同一答案,所以整条裁决路径是确定性的、可回放的。落 metric 时 Verdict.Level 直接告诉你「本周有多少冲突在第 1 级就解决了、多少升到了人工」——这个分布是 MAS 健康度最直接的指标,比成功率灵敏得多。
为什么裁决要放在装配之前,而不是让模型自己权衡
反直觉的点在于:模型确实有能力识别矛盾。你给它两段冲突文本并明确问"这两段矛盾吗",它答得很好。
但在真实的 Agent 循环里,A 的任务不是"识别矛盾",是"完成审核"。矛盾输入只是它上下文里的一部分材料,而模型面对材料冲突的默认行为是找一个能同时容纳两者的表述——因为在语言概率上,"综合考虑 X 和 Y" 比 "Y 是错的" 更常见、更安全。于是你得到"下架并加提示标签"这种荒谬输出。
把裁决前移,本质是把一个模型不擅长的判断(在有任务压力时取舍矛盾)换成一个框架擅长的判断(按判据走阶梯)。这跟 运行时 里"不能让模型自己判断操作危险不危险"是同一个道理:不是模型能力不够,是这个判断不该由带着任务倾向的一方来做。
为什么黑板要付版本号这笔钱
不加版本号的黑板在 demo 里跑得很好——两个 Agent 很少真的同时写同一个 key。问题是这个 bug 的发生率随并发度上升而上升,而可发现性始终为零:丢写不报错、不留痕,只表现为"某个 Agent 的修改好像没生效"。
版本号买到的唯一东西是把静默失败变成显式报错。ErrStaleWrite 一抛,你至少知道发生了并发写;Agent 重读重试,语义上正确。这笔钱必须付的判据很简单:只要有两个以上 Agent 可能写同一个 key,就必须付。如果 key 空间按子任务严格划分(Agent X 只写 shot_1.*),可以不付——但那要靠命名规范保证,而规范会腐化。
为什么别的选择不行
MAS 特有的五种失效
这五种只在多 Agent 下出现,单 Agent 再怎么坏也坏不出来:
| 失效 | 表现 | 检测手段 | 兜底 |
|---|---|---|---|
| 死锁 / 活锁(含「礼貌性死锁」) | 两方都在等对方先出方案("你先提,我配合"↔"还是你先");或消息在两个 Agent 间无限往返但无进展 | 消息总跳数硬闸(上面 maxHops)+ 检测消息内容指纹重复(同一对 Agent 间连续 N 轮语义无新增) | 指定 tie-breaker:编排期就规定"冲突时 X 先发言";活锁触发即降级为单 Agent 兜底完成 |
| 责任扩散 | 子任务无人执行——每个 Agent 都以为别人会做。广播派单最容易出 | 派单后维护未认领子任务表,超时未认领即告警;每个子任务必须有唯一 owner 字段 | 禁止无 owner 的广播派单:广播只用于通知,派单必须直接寻址 |
| 上下文分裂 | 同一事实在两个 Agent 窗口里是不同版本(A 以为主角 28 岁,B 以为 32 岁),各自输出都自洽但拼不起来 | 关键共享事实做跨 Agent 一致性断言:定期抽取各 Agent 的 facts,对同名 key 比对 | 共享事实只存黑板不进各自可压缩区(详见 运行时 · 多 Agent 窗口归属);Agent 读黑板而非记在自己上下文里 |
| 级联幻觉 | A 的猜测被 B 当作既定事实引用,B 的输出又被 C 引用——三跳之后没人知道源头是个猜测 | 每条 claim 强制带 Traceable 与来源标识;跨 Agent 传递时保留来源链而非只传结论 | 交接时只传已确认事实,不传中间猜测(前文交接表);裁决器对不可溯源 claim 直接判负 |
| token 组合爆炸 | 成本随 Agent 数平方增长,5 个 Agent 的任务比单 Agent 贵 20 倍 | 按 Agent 维度分别计量 token(不是只看总量),看互传消息占各自上下文的比例 | 改拓扑(群聊 → Supervisor)或改传递内容(全文 → 结构化结论)。这两条之外没有第三条 |
它们与单 Agent 失效的共同点:多数不报错,只表现为"结果变差"——和 运行时的三种记忆失效 一样,必须靠主动断言发现,不能等异常。
差异在于:单 Agent 的失效是纵向的(一条轨迹上前面污染后面),检测只需看一条 trace;MAS 的失效是横向的(两个 Agent 的状态互不一致),检测必须跨 trace 比对。这是 MAS 可观测性真正难的地方,也是为什么 span 必须挂同一个 Trace id(上面 Envelope.Trace)——归因手段见 评测 · span 树归因。
何时不该上 MAS
MAS 是有固定代价的:token 组合爆炸(互看输出 O(N²))与调试面积膨胀(N 个 Agent 的状态组合 + 跨 trace 比对)。付这笔钱之前先过三条否决判据:
三条都否 → 用单 Agent + 工具,不要上 MAS:
- 子任务之间需要并行吗? 如果本来就是顺序执行,多 Agent 只是把一个 for 循环拆成了几个进程。
- 需要不同的 system prompt 隔离吗? 如果所有子任务都能在同一个 system prompt 下完成,拆成多个 Agent 只是给同一个模型换了几个名字——这是最常见的伪 MAS。
- 需要不同的权限边界吗? 如果所有子任务用同一批工具、同一套凭据,"多 Agent"提供不了任何隔离价值。
看起来该上 MAS、实际不该的典型反例
「代码审查系统:一个 Agent 查安全、一个查性能、一个查风格,最后汇总」。
看起来完美符合 MAS——三个专业角色、职责清晰、可以并行。实际上过一遍判据:三者需要并行吗?可以并行,但同一个 Agent 顺序跑三遍也就是三次调用,延迟差异在几秒级,对代码审查场景无意义。需要 prompt 隔离吗?三个视角的差异完全可以用同一个 Agent 的三次不同 prompt 调用(或一次调用里的三段 XML 分区)覆盖。需要权限隔离吗?三者都只是读代码,权限完全相同。
三条全否,所以不该上 MAS。正确做法是单 Agent + 三次带不同 system 段的调用,成本 O(3)、无跨 Agent 状态、无冲突消解需求、trace 是一条线。拆成三个 Agent 之后你反而多了一个新问题——三者结论冲突时听谁的(比如安全说要加校验、性能说这个校验在热路径上),得把上面那整套阶梯搬进来。为一个不存在的问题引入了一套裁决机制,这就是伪 MAS 的典型代价。
那什么时候它真该上 MAS?当"查安全"这个子任务需要独立的工具权限(能调漏洞库、能读生产配置)而其他两个不该有这个权限时——此时权限隔离判据成立,MAS 的钱花得值。
主流框架的核心抽象对照
只对照核心抽象,不写 API——API 半年一变,抽象不变。选型结论见 Agent 开发 · 框架对比(枢纽页给"该选哪个"),本节只给"它们各自把什么做成了一等公民":
| 框架 | 语言 | 核心抽象 | 哪种拓扑是一等公民 | 值得学的一点 |
|---|---|---|---|---|
| Eino(字节) | Go | 分层编排:Chain(线性)/ Graph(有环、可分支)/ Workflow(字段级映射),组件用 Go 泛型定型 | Pipeline + Supervisor | 编排期类型安全——上下游节点的输入输出类型在编译期校验,接错直接编不过。Python 框架只能等运行时炸 |
| LangGraph | Python/JS | 显式状态图:节点 = 函数,边 = 转移条件,全局 State 通过 reducer 合并 | Blackboard(全局 State 就是黑板) | 状态图 + checkpointer 让「断点续跑」成为框架能力而非自己实现 |
| AutoGen(MSFT) | Python | ConversableAgent + GroupChat + GroupChatManager | Group Chat | 把"发言权轮转"抽象成可替换的 speaker selection 策略 |
| CrewAI | Python | Agent(role/goal/backstory) + Task + Crew(process: sequential/hierarchical) | Pipeline / Supervisor | 角色-任务-流程三件套的心智模型极轻,从 0 到跑通最快 |
| MetaGPT | Python | SOP 驱动:把软件公司流程(PM→架构→开发→测试)编码成固定角色与产出物 | Pipeline(SOP 即固定流水线) | 「用 SOP 约束 Agent」这个思路本身——固定流程比自由讨论稳定得多 |
同一批框架的投递与唤醒实现
上表讲"抽象长什么样",这张讲前面物理层那四个问题各自怎么答的。拆成两张表是因为九列读不了,不是因为它们是两回事:
| 框架 / 系统 | 投递方式 | 唤醒源(谁排回合) | 中断粒度 | 状态持久化 |
|---|---|---|---|---|
| Eino | Graph 的边;节点间直接传值 | runner.Invoke 同步驱动(编排期写死) | 四档里有三档:静态 WithInterruptBefore/AfterNodes(节点边界)、动态 Interrupt/StatefulInterrupt(节点内)、CompositeInterrupt(ToolsNode 包住 tool,即工具边界)、外部 WithGraphInterrupt(强制) | CheckpointStore 是 Get/Set(string→[]byte)接口,官方不提供默认实现,须自行实现;自定义类型要 schema.RegisterName 注册 |
| LangGraph | 状态图的边;全局 State 经 reducer 合并 | 图执行器驱动(编排期写死) | interrupt() 停在调用处那一行,但恢复时整个节点从头重跑 | checkpointer 持久化 thread 的状态快照(InMemorySaver / SqliteSaver / PostgresSaver);另有 Store 管跨 thread 长期记忆 |
| AutoGen(core) | 真 mailbox:actor 式消息驱动,支持定向与 topic 发布订阅 | 运行时代投,分 standalone(SingleThreadedAgentRuntime)与 distributed(host servicer + workers + gateways),runtime 管 Agent 的 lifecycle | 上层 team 是回合制;外部终止时当前发言者仍把这一回合跑完 | 上层 team 有状态可续跑(续跑时"从轮转顺序里的下一个 Agent"接上);持久化后端自行接 |
| CrewAI | Task 的 context:上一个 task 的输出即下一个的输入 | crew.kickoff() 同步驱动 | 无显式中断点 | 无内置 checkpoint |
| MetaGPT | 发布订阅,但按"消息的 cause_by 动作类型"订阅(_watch([SomeAction]))而非按地址 | Team.run(n_round=N) 的回合循环;Role 在自己那一轮里 _observe 拉 Environment | 回合边界(_observe 就是回合边界拉取) | 无内置 checkpoint;另有 invest() 作为预算闸 |
| 长时会话团队模式类系统 (可观察行为归纳) | 表现为定向单播 + 广播;广播在契约上被描述为"给每个成员各投一次",成本线性于成员数 | 表现为框架代投——"来自同伴的消息会自动送达,不需要手动检查收件箱";且回合结束(idle)是一个对外可观察的事件 | 表现为回合边界注入:消息作为下一轮的输入出现,不打断进行中的推理 | 表现为按 Agent 名/ID 续跑且保留完整上下文(会话 transcript 级);共享任务列表即黑板 |
这张表的来源性质
前五行来自各框架官方文档的机制描述(只取机制,不取 API——API 半年一变)。最后一行是对该类产品对外暴露的工具契约与交互表现的可观察行为归纳,用"表现为"而非"实现为",不涉及任何未公开的内部结构。具体 API 与参数请以各自官方文档为准。
横向结论:没有任何一个框架让 Agent 自己去轮询收件箱。
区别不在"有没有 mailbox",而在谁来给这个 Agent 排下一个回合——只有两类答案:
- 编排期写死(Eino / LangGraph / CrewAI / MetaGPT):Agent 是被调用的函数,回合由
runner.Invoke/ 图执行器 /kickoff/Team.run(n_round)同步驱动。好处是延迟可预测、trace 是一条线、崩溃点明确;代价是拓扑在编排期固定,运行时来一个新 Agent 塞不进去。 - 运行时动态决定(AutoGen core、长时会话团队模式类系统):有 mailbox 与 lifecycle 语义,能跨进程跨语言扩展。但仍然是 runtime 把消息送进 handler——官方原文是 "the runtime manages the lifecycle of agents"。代价是 lifecycle、持久化、崩溃恢复都得自己管。
根因是同一条,也就是前面物理层那条约束:Agent 没有独立生命周期,它不是一个能常驻 select 的进程。所以"谁排回合"在任何框架里都躲不过,只是被推给了编排期或运行时。
抽象层面的三条结论
- Go 生态的差异化优势是编排期类型安全(Eino 的泛型定型)。这不是语法糖:多 Agent 编排的接错概率随节点数上升,能在编译期拦住的错误就不会在第 20 轮烧掉预算之后才暴露。
- 框架把哪种拓扑做成一等公民,决定了它的默认成本曲线。AutoGen 的 GroupChat 好用,代价是默认 O(N²);选它就要主动限轮数。
- 没有框架内置冲突消解阶梯。GroupChat 的"讨论到共识"、CrewAI 的 hierarchical 都不是裁决协议——本篇那套阶梯目前都得自己写。这也是面试里可以主动讲的差异化点。
补一条与物理层有关的:恢复语义是各框架差异最大、也最容易被忽略的一处。LangGraph 恢复时整节点重跑、Eino 动态中断恢复时节点重跑且 input 被替换为空值——两家独立踩到同一个坑,共同结论是「中断点之前的副作用必须幂等」。选框架时这一条比 API 好不好用重要得多。
具体 API 与参数请以各框架官方文档为准。
沉淀结论
面试常见问题清单
拓扑与通信
- Q:Multi-Agent 有哪几种协作方式,怎么选? A:五种——Supervisor(中心调度,最常用,O(N))、Pipeline(阶段固定)、Group Chat(需互相质疑,O(N²) 最贵)、Blackboard(长任务共享大状态)、Contract Net(学术常见工业罕见,因为出价靠模型自评而自评系统性高估)。选型看三件事:子任务边界是否清晰(清晰→Supervisor)、是否需要互相质疑(需要→Group Chat 但限轮数)、共享状态是否大(大→Blackboard)。
- Q:Agent 之间怎么通信? A:三层——消息传递(直接寻址/广播/发布订阅,耦合递减)、共享状态(黑板,必须带版本号做 CAS)、控制权交接(Handoff,令牌
task_id+from+seq,必须幂等)。最大陷阱是消息进彼此上下文导致 token O(N²)。 - Q:拓扑和通信机制冲突时怎么裁决? A:以一致性要求为先——需要强一致的共享状态就不能用广播(广播无版本语义,并发改同一事实会静默丢写且不可复现),必须黑板 + 版本号。一句话:消息传递的失效是吵到爆窗,黑板的失效是静悄悄丢数据。
- Q:Agent 之间在物理上怎么通信?是事件驱动还是定时轮询(cron)?(高频追问)A:先立约束——一次推理不可中断、Agent 没有独立生命周期(它只是一次已返回的调用),所以它只在回合边界能收消息。由此任何方案都是同一个形状:
push-to-inbox+pull-at-turn-boundary,推送只能推到信箱、推不进正在输出的模型。所以不是二选一,是分工:事件驱动负责快(毫秒级唤醒活跃 Agent),cron 负责不丢。而 cron 有一个事件驱动结构上无法承担的职责——超时清算:「超时」不是一个事件(不发生的事情发不出信号),且唯一有动机判"B 超时了"的那个 A 此刻不在运行(它派单那轮早结束了,没有线程在数秒)。只有事件驱动的具体坏法:唤醒信号一丢,消息躺在 inbox 里再没人排回合 → 子任务静默永停,而 inbox 有数据、投递返回成功、监控一切正常。一句话记法:事件驱动能告诉你"发生了什么",永远告诉不了你"什么没发生"。 - Q:Agent 间消息乱序有什么特别的? A:顺序只在「同一发送方 → 同一接收方」按
seq保序,跨发送方必然交织。特别之处在于消息进的是上下文而不是 handler——模型会把上下文里的到达顺序当成因果顺序,"先收到库存不足、后收到已下单"和反过来会推出完全不同的结论,而两者都是合法到达序。所以装配上下文时必须按seq或时间戳重排,不能按到达序拼接。这是单 Agent"并行工具结果按发起顺序回填"的跨 Agent 版本。 - Q:inbox 满了该背压吗? A:默认不背压,拒投 + 把错误当场回给发送方。常规后台里背压优于丢数据,但这里发送方是一个正在烧 token 的推理:阻塞它等于上下文占着、时长与费用预算都在走而毫无产出,比丢一条消息贵得多且贵得不透明(只表现为"这个 Agent 怎么这么慢")。拒投则让它在还活着的时候就知道没送到,能在本回合内改派或降级。
冲突消解(高频)
- Q:A 分别问 B 和 C,两者结果有出入怎么办? A:先分类再走四级阶梯。第 0 级分类:事实(一方错)/ 口径(两边都对,统一口径重算)/ 时序(基于不同时刻快照,取新,旧的作废不是错)/ 幻觉(依据不可溯源,直接判负)。四级阶梯:证据优先 → 置信度加权(self-report × 历史准确率,差距需超阈值)→ 第三方仲裁(换模型、隐去身份、要求连续两次一致)→ 人工闸门(涉不可逆动作,或仲裁反复翻转)。关键:裁决必须在 A 装配上下文之前完成,只有裁决结果进 A 的窗口,否则就是上下文中毒。
- Q:为什么不用多数投票? A:两条独立原因。① A/B/C 场景实际只有 B、C 两个投票者,1:1 平局时投票在数学上没有输出,它需要奇数投票者。② 即使凑到奇数,同源模型的投票不独立,对模糊输入会犯同向错误——三票一致但全错,还把错答包装成"三方确认",这叫虚假共识。投票的数学基础是错误独立,而这前提在同模型换 prompt 的 MAS 里不成立。所以第 1 级是证据(外部的、与模型无关)。
- Q:为什么不让 A 自己综合判断? A:模型有能力识别矛盾,但在有任务压力时面对矛盾材料的默认行为是找一个能同时容纳两者的表述("下架并加提示标签")——拼接在语言概率上比否定更安全。前移裁决,是把"带任务倾向的一方做取舍"换成"框架按判据走阶梯",与"不让模型自判操作危险"同理。
失效与选型
- Q:MAS 特有的坑有哪些? A:五个——死锁/活锁(含礼貌性死锁:都等对方先出方案,靠 maxHops 硬闸 + 编排期 tie-breaker)、责任扩散(无人认领,靠子任务唯一 owner + 禁止无 owner 广播派单)、上下文分裂(同一事实两个版本,靠共享事实只存黑板 + 跨 Agent 一致性断言)、级联幻觉(猜测被当事实多跳传递,靠保留来源链 + 只交接已确认事实)、token 组合爆炸(改拓扑或改传递内容,没有第三条路)。共同点是都不报错;与单 Agent 失效的差异是它们是横向的,检测必须跨 trace 比对。
- Q:什么时候不该上 MAS? A:三条否决判据全否就别上——无需并行、无需 system prompt 隔离、无需权限边界隔离。固定代价是 token O(N²) + 调试面积膨胀。典型反例:代码审查拆成安全/性能/风格三个 Agent——三条全否,单 Agent 三次不同 prompt 即可;拆开反而凭空多出"三者冲突听谁的"这个新问题。答案反转的条件:「查安全」需要独立工具权限而另两个不该有时。
- Q:Eino 和 LangGraph / AutoGen 的区别? A:抽象层面——Eino 是 Go 的分层编排(Chain/Graph/Workflow)+ 泛型带来的编排期类型安全,上下游类型接错编译期就报;LangGraph 是显式状态图 + 全局 State(本质是黑板)+ checkpointer 内置断点续跑;AutoGen 把 GroupChat 与发言权轮转策略做成一等公民,代价是默认 O(N²)。共同缺口:没有框架内置冲突消解阶梯。
心法总结
MAS 的价值不在"更多 Agent 更聪明",在于用隔离换可控——不同权限、不同上下文、不同 prompt 的隔离。而所有隔离的代价都由同一件事偿付:信息一致性。 拓扑决定信息流多贵,通信机制决定信息一致不一致,裁决协议决定不一致时怎么收场。三者里最容易被跳过的是第三个,而它恰好是唯一会输出荒谬结果而非报错的那个。
本域延伸
- 枢纽结论、单 Agent 范式对照与框架选型:Agent 开发
- 单 Agent 运行时(状态机 / 分层记忆 / 工具沙箱 / 预算幂等 / 多 Agent 下的压缩窗口归属):Agent 运行时深水区
- 跨 trace 归因、历史准确率的来源、轨迹判分:Agent 评测与线上运营
- 这套拓扑与裁决在内容创作流水线的落地:互动影游与长视频创作 Agent
- token 计价与 O(N²) 成本的实际量级:成本与延迟
记忆口诀
- 五拓扑:Supervisor(中心 O(N))/ Pipeline(单向)/ Group Chat(互见 O(N²))/ Blackboard(共享态)/ Contract Net(竞标,工业罕见)
- 三层通信:消息传递(直接/广播/订阅)→ 共享状态(黑板 + CAS)→ 交接(令牌
task_id+from+seq,必幂等) - 物理层一句话:推理不可中断 → 只在回合边界能收 → 所以都是
push-to-inbox+pull-at-turn-boundary - toggle 还是 cron:不是二选一,是分工——事件负责快,cron 负责不丢;且超时必须外置(「超时」不是事件,等待方自己不在运行)
- 事件驱动的盲区:能告诉你"发生了什么",永远告诉不了你"什么没发生"
- inbox 默认拒投不背压:阻塞发送方 = 阻塞一个正在烧 token 的推理
- 乱序的真实危害:消息进的是上下文,模型把到达顺序读成因果顺序 → 按
seq重排再装配 - 矩阵裁决一句话:强一致共享状态必走黑板+版本号,不走广播
- 两种失效的性格:消息传递失效是吵(爆窗,看得见);黑板失效是静(丢写,看不见)
- 冲突第 0 级:先分类——事实(一方错)/ 口径(都对,统一重算)/ 时序(取新,旧的作废)/ 幻觉(不可溯源,直接判负)
- 四级阶梯:证据优先 → 置信度加权 → 第三方仲裁 → 人工闸门
- 投票双否:2 个投票者平局无解 + 同源模型投票不独立(虚假共识)
- 裁决铁律:裁决在装配上下文之前,只有结果进主 Agent 窗口
- MAS 五失效:死锁活锁 / 责任扩散 / 上下文分裂 / 级联幻觉 / token 爆炸——都不报错,且都是横向的(须跨 trace 比对)
- 不上 MAS 三否:无需并行 + 无需 prompt 隔离 + 无需权限隔离 → 单 Agent 加工具
内容来源
综合整理:Contract Net Protocol(Smith, 1980)的任务分派思想、AutoGen / LangGraph / CrewAI / MetaGPT / Eino 各自公开文档中的编排抽象、分布式系统的乐观并发控制(CAS / 版本号)与 Saga 补偿模式在 Agent 编排上的迁移、以及站内 Agent 运行时深水区 的单 Agent 结论。
框架生态更新快,具体 API 与参数请以官方文档为准;文中阈值(置信度阈值 0.15、仲裁连续两次一致、Agent 数上限区间)均为经验值,需按自身任务的冲突分布与成本预算重新标定。
自测:合上资料能说清楚吗?
- A 分别问 B 和 C,拿回两个矛盾结论。完整说出你的处理流程,包括第 0 级在做什么、为什么裁决不能放在 A 推理之后。
参考答案
第 0 级先分类(不分类就选手段等于用治骨折的方法治感冒):事实冲突(至少一方错,找证据)/ 口径冲突(统计窗口或单位不同,两边可能都对,统一口径重算而非选一边)/ 时序冲突(基于不同时刻快照,两边在各自时刻都对,按时间戳取新,旧结论记为"作废"而非"错误"——记成错误会污染该 Agent 的历史准确率,而准确率正是第 2 级的输入)/ 幻觉冲突(依据不可溯源,直接判负不进后续裁决)。分类要能自动化,前提是每条回复强制带「依据 + 依据时刻 + 可溯源标识」三字段。
然后走四级阶梯:① 证据优先——能工具复算或溯源到 SSOT 的一方胜;② 置信度加权——self-report × 历史准确率,且差距必须超阈值(0.71 vs 0.69 判胜是把噪声当信号);③ 第三方仲裁——换模型、隐去双方身份与置信度(给了它会偏向听起来权威的或直接抄分高的,退化回第 2 级)、要求连续两次同结论才算收敛;④ 人工闸门——涉及不可逆动作,或仲裁结论反复翻转(翻转说明输入本身模糊,再判一百次也不收敛)。
裁决必须在 A 装配上下文之前:立刻的后果——模型面对矛盾材料时倾向拼接而非取舍("下架并加提示标签"),因为"综合考虑"在语言概率上比"Y 是错的"更安全。长期的后果——矛盾结论进了上下文就成为后续所有推理的前提,即上下文中毒;且比普通中毒更难清,因为中毒的是一对互斥事实,压缩时模型会随机保留一条,之后你不知道它按哪条在推理。进 A 窗口的应该是「已裁决:采纳 X,因为 Y」这样一条已闭合的事实,不是两条待判断的开放信息。
- 为什么不用多数投票?说出两条独立的原因,以及投票在什么前提下才成立。
参考答案
原因一(数学上没有输出):A/B/C 场景里 A 是提问者和裁决者,不是投票者,实际只有 B、C 两票。1:1 平局时投票机制不产生任何结论——多数投票需要奇数个投票者才有定义。这不是"效果不好",是没有输出。
原因二(同源不独立):投票的全部数学基础是投票者的错误相互独立,独立时多数投票才能把准确率从 p 提到超过 p。但同一模型(同权重、同训练数据)的多个实例对同一段模糊输入会犯同向错误——不是随机分散地错,而是一起错到同一个答案上。结果是最坏的:三票一致、置信度看起来极高、答案是错的,还把错答包装成"三个 Agent 一致确认",让下游更难质疑。这叫虚假共识——投票把相关性误当成了独立验证。
成立前提:投票者来自不同模型且各自有独立信息源。这个前提在大多数 MAS 里不成立(通常是同一模型换 system prompt),所以默认不放进阶梯。这也是为什么第 3 级仲裁 Agent 建议换一个模型。
- 对比 Group Chat + 消息传递 与 Supervisor + 黑板 两种组合:各自的主要失效是什么、性质有何本质不同、分别在什么场景下选?
参考答案
Group Chat + 消息传递:主要失效是 token O(N²) + 跑题 + 发言权争夺。5 个 Agent 到第 10 轮时每人上下文里有 45 条别人的发言。性质是「吵」——爆窗、变慢,但看得见,成本曲线会直接告诉你。修法:硬限最大轮数、指定主持人决定发言顺序、每轮强制产出结构化结论而非自由讨论。适用:真正需要互相质疑的场景(方案评审、红蓝对抗),且 Agent 数 ≤ 5。
Supervisor + 黑板:主要失效是 状态竞争(两个 Worker 并发写同一 key 丢写)。性质是「静」——静默失败、不报错、不留痕,只表现为"某个修改好像没生效",且发生率随并发度上升而可发现性始终为零。修法:CAS + 版本号(把静默丢写变成显式 ErrStaleWrite)+ 按子任务划分 key 命名空间从设计上避免共写。适用:Worker 产物大、Planner 只需知道"完成了"的长任务。
本质区别:一个的代价是成本与延迟(可观测、可预算),另一个的代价是正确性(不可观测、需要主动付版本号这笔钱才能观测)。选黑板就必须付版本号——判据是"只要有两个以上 Agent 可能写同一个 key"。
- 一个「代码审查系统:安全 Agent + 性能 Agent + 风格 Agent,最后汇总」的设计,该不该上 MAS?用判据回答,并说明什么条件下答案会反转。
参考答案
不该。过三条否决判据:① 需要并行吗?可以并行,但单 Agent 顺序跑三次也就是三次调用,延迟差异秒级,对代码审查无意义。② 需要 system prompt 隔离吗?三个视角完全可以用同一 Agent 的三次不同 prompt 调用(或一次调用里的三段 XML 分区)覆盖。③ 需要权限隔离吗?三者都只读代码,权限完全相同。三条全否 → 单 Agent + 三次带不同 system 段的调用,成本 O(3)、无跨 Agent 状态、trace 是一条线。
拆开的额外代价:凭空多出"三者结论冲突时听谁的"这个新问题(安全说要加校验、性能说这个校验在热路径上),得把整套四级裁决阶梯搬进来——为一个不存在的问题引入一套裁决机制,这就是伪 MAS。加上 MAS 的两项固定代价:token 组合爆炸与调试面积膨胀。
答案反转的条件:当「查安全」需要独立的工具权限(能调漏洞库、能读生产配置)而另两个不该有这个权限时——第 3 条判据成立,权限隔离是单 Agent 给不了的,此时 MAS 的钱花得值。
- 交接令牌为什么是
task_id + from + seq三段?去掉from会发生什么?
参考答案
三段各定位一个维度:task_id 定位是哪个任务(跨任务的同名子任务不能互相去重)、from 定位是谁交接的(同一任务里 B→D 和 C→D 是两次不同交接)、seq 定位是第几次(同一对 Agent 之间可能多次交接)。
去掉 from 的后果:同一任务里 B→D 的第 1 次交接和 C→D 的第 1 次交接会撞成同一个 key。第二个到达时被幂等守卫当成重复交接,直接返回上一次的结果并静默跳过执行——一个子任务凭空消失,而且日志上看不出异常(幂等命中通常不打 error)。这个 bug 表现为"C 派的活好像没人做",排查时最容易怀疑 C 或 D 的实现,而根因在守卫的 key 构造。
交接为什么必须幂等:交接消息可能重发——网络重试、调度器崩溃恢复、上游超时后误判失败而重发。不幂等时同一子任务被下游执行两遍;如果它带副作用(发消息、扣费),就是真实事故。
- 一个纯事件驱动的 MAS(inbox 非空就唤醒,没有任何定时扫描)会出现什么它自己发现不了的故障?以及为什么"等着下游回复"的那个 Agent 判不了自己的超时?
参考答案
先说共同的前提:一次模型推理不可中断、不可抢占,且 Agent 没有独立生命周期——它通常只是一次已经返回的函数调用,不是一个能常驻 select 的进程。所以推送只能推到 inbox,推不进正在输出的模型;消息能否被处理,取决于有没有人给这个 Agent 再排一个回合。
纯事件驱动的故障:子任务静默永停。唤醒信号本身会丢(信号队列满、调度器重启、进程崩在"已 push 进 inbox"与"已发出唤醒"之间)。一旦丢了,消息已经躺在 inbox 里,却再没有任何人给它排回合。为什么自己发现不了:inbox 里查得到数据、投递方收到的是成功、没有异常也没有错误码、各项指标都正常——它不是"失败",是"什么都没再发生"。唯一能提前暴露它的信号是唤醒源分布(cron 兜底唤醒占比上升 = 事件通路在丢唤醒),而纯事件驱动方案里连这个分母都不存在。
为什么等待方判不了自己的超时,两层理由:
- 「超时」不是一个事件,它是"某个事件在期限内没有发生"。不发生的事情发不出信号,所以没有东西可订阅。你当然可以为每次等待挂一个定时器——但那本身就是 cron,只是换了个名字。
- 更要命的是谁来挂。唯一有动机判"B 超时了"的是正在等 B 的那个 A,而 A 此刻不在运行:它派单之后那一轮就结束了,它没有线程在那里数秒。这跟单 Agent 完全不同——单 Agent 的超时是它自己一次工具调用的 deadline,它自己就在栈上等着。
所以结论是分工而非二选一:事件驱动负责活跃 Agent 的低延迟交接(负责快),cron 负责空闲 Agent 的兜底唤醒 + 超时清算(负责不丢)。一句话记法:事件驱动能告诉你"发生了什么",永远告诉不了你"什么没发生"。
顺带一提代价:sweep 周期是这一层唯一的旋钮,它同时决定漏唤醒的最坏恢复延迟与超时判定的精度——调小则空扫成本上升,调大则静默永停的子任务躺更久,没有免费档。