五个智能体都交了活,为什么系统还不知道任务结束
多智能体协作有两条路:让大家读写同一块共享状态,或者让每个智能体只盯自己的收件箱、靠显式消息同步。换成消息传递后耦合确实松了,但"任务是否已经结束"从一个可以直接读到的变量,变成了需要协议才能判定的全局性质。最常见的误区是把"没有消息在飞"当成结束、把同步调用的超时当成失败、把消息队列当成有全局顺序。读完你能说清消息信封该带哪些字段、幂等键该落在哪一层、静止检测为什么会误判,以及什么规模才值得把单个智能体拆成多个。
是什么
多智能体协作要先回答一个工程问题:几个智能体之间靠什么共享进度。一种做法是共享状态——所有智能体读写同一块黑板(blackboard)或同一份任务看板,谁改了什么立刻对所有人可见;另一种是消息传递——每个智能体有自己的收件箱(mailbox),只能看到别人发给它的消息,状态分散在各自的上下文里。
直觉上的差别,像会议室白板与传纸条:白板前所有人都能看到全局,但谁都能擦掉别人的字;传纸条责任清晰、可留痕、能跨楼层,可没人天生知道"事情办完没有"。典型场景是写作流水线(调研 → 起草 → 审稿 → 定稿)、客服工单在技能组之间转交,以及编码流水线里规划、实现、评审三个角色的分工。
核心原理
消息不是调用,是一次状态拷贝。 函数调用把参数压栈,调用方和被调方共享同一片内存;消息传递则要求发送方把"对方需要知道的那部分状态"序列化出去。这带来两个直接后果:接收方看不到发送方没写进消息的隐式约定;每转交一次,上下文就重建一次,token 与延迟随跳数增长。黑板上写的是指针,消息里装的是复印件。也正因如此,"把需求口头交代给同事"那种默契在消息链上不成立——没说出口的约束等于不存在。
信封比正文更重要。 一条可运维的消息至少要有四类字段:身份(task_id、parent_id、from/to)、意图(要对方做什么,而不是"看看这个")、关联(这条回复属于哪个请求,用于把多轮往返拼成一棵树)、时限(deadline 与 attempt)。A2A 这类智能体间协议把任务显式建模成状态机——已提交、处理中、需要补充输入、完成、失败、取消——就是为了让"同步调用返回"和"任务真的完成"不再混为一谈。
路由方式决定了耦合的形状。 点对点最简单,但发送方必须知道接收方是谁,新增一个角色就要改调用方;消息总线用发布订阅把双方拆开,代价是广播容易失控,一条"请大家重新评估"可能触发全体重跑;层级式(协调者管执行者)最好排查,却让协调者成了单点。工程上还要配背压:慢的下游必须能让上游停下来,否则队列只是把过载从内存挪到了磁盘,延迟照样一路涨。
为什么"没有消息在飞"不等于结束。 分布式系统里这叫静止(quiescence)判定:每个智能体只看得见自己的收件箱,而"整个系统不会再产生新消息"是一个全局断言。朴素做法是让协调者维护在途计数:派出任务加一,收到终态减一,归零即收敛。它有两个漏洞:重试定时器、延迟投递、智能体自己给自己排的后续任务,都可能在计数归零之后再生出新消息,形成假静止;而某个智能体卡在等一个永远不会来的回复时,计数又永远不归零。经典解法是 Dijkstra–Scholten 那类终止检测算法——沿生成树把"我已空闲"逐级上报,根节点收齐所有子树的确认才判定终止;工程上更常见的是"计数 + 封板 + 看门狗"的组合:先停止接受新委派并封板,再等计数归零,超时则由看门狗介入。
import itertools
ids = itertools.count(1)
class Coordinator:
def __init__(self):
self.pending, self.sealed, self.done = 0, False, set()
def dispatch(self, to, payload):
if self.sealed:
raise RuntimeError("sealed") # 封板后不再接新活
task_id = f"t{next(ids)}"
self.pending += 1
send(to, {"task_id": task_id, "payload": payload})
return task_id
def on_result(self, msg):
if msg["task_id"] in self.done:
return # 重复投递丢弃
self.done.add(msg["task_id"]); self.pending -= 1
if self.sealed and self.pending == 0: finalize()
至少一次,所以必须幂等。 消息系统为了保证不丢,通常允许重复;跨智能体的"恰好一次"只能靠业务侧去重实现。幂等键取 task_id 加 attempt 往往不够,如果同一个任务会被不同角色重放,键里还得带上真正产生副作用的那个动作。同理,队列只在单分区内保序,跨智能体没有全局顺序,状态机不能依赖"回复一定在通知之后到达",要靠版本号或因果依赖判断新旧。
常见考点与误区
- 误区一:"消息传递就是远程调用,调通就完事":一次智能体任务可能跑几分钟到几小时,同步阻塞的调用方只能等到超时,然后把"还在跑"误判成"失败"并重试,制造重复副作用。正确做法是拿到 task_id 后转为轮询或订阅状态,把超时当"未知"而不是"失败"。
- 误区二:"发出去了就等于送到了":fire-and-forget 没有确认,进程崩溃时消息随内存一起消失。至少要加确认与有限重投,并配置死信队列,否则失败是静默的。
- 误区三:"队列空了就说明干完了":队列空只说明当下没有积压,不代表没有定时重试、延迟消息和尚未落库的后续动作。静止判定必须把"计划中的未来消息"计入,或者先封板再计数。
- 误区四:"多智能体一定比单智能体强":拓扑全连接时消息通道是 n² 量级,每条消息还要重传一遍上下文,错误也会顺着消息链放大。一个智能体加几个工具能解决的事,拆开只增加协调成本。
- 考点五:"顺序与死锁":跨智能体没有全局序,A 等 B 的结果、B 等 A 的确认就会互相挂起;用 deadline 加取消传播打断等待环,比事后检测更省事。
实践建议
- 固定消息信封:task_id、parent_id、intent、deadline、attempt、schema_version 全部放外层,payload 放内层;日志按 task_id 串联,否则多智能体的排障基本没法做。
- 把副作用和消息分开:消息可以重放,写库、发邮件、扣款这类动作按幂等键只执行一次,去重表设过期时间而不是永久保留。
- 终止判定用三件套:封板(不再接新活)、计数(在途归零)、看门狗(超时兜底);只靠"静默 N 秒"会在慢任务上误判。
- 拓扑从星型开始:一个协调者加若干执行者,别一上来就全连接;给委派设深度上限和总预算,防止角色之间互相递归转包。
- 共享状态留给读多写少、需要全局一致视图的东西(代码库、任务看板);消息留给跨进程、需要审计与重放的链路。两者混用时要指定唯一权威源,别让黑板和收件箱各存一份真相。
一句话总结:消息传递松开了耦合,也把"任务是否结束"从一次读取变成了需要协议才能回答的问题。