8 多智能体:角色、通信、冲突、预算
什么时候值得把一个问题拆给多个 agent?supervisor、peer、debate、hierarchy 各自把控制权放在哪,故障会从哪里冒出来?消息怎样编号、handoff 怎样写才不会互相等待?票数相同或全票一致时,凭什么裁决?父 agent 的预算怎样分给子 agent,子 agent 花超了又怎样隔离?
用一张决策表排除不必要的多 agent;为四种拓扑画出控制流、故障模式与观测形状;写出带 seq、correlation_id、refs 的消息信封与可验收的 handoff;用证据权重处理冲突并检测消息环;实现父子预算、原子扣减与熔断;从 agent graph 判断「谁先错」。
第 7 章把规划压在一个控制器里:一个上下文、一组工具、一条预算。真实任务经常超出这个边界——四十份材料要同时读,三个模块可以并行改,一个高风险结论需要有人专门唱反调,一段不可信网页最好关在单独的 agent 里处理。这些需求看起来都在说「多上几个 agent」,但它们交换的东西完全不同:并行换墙钟,分工换上下文纯度,对抗换错误独立性,隔离换故障半径。
问题是,多智能体不是免费的能力升级。每增加一个 agent,就增加一份要重建的上下文、一条要维护的消息通道、一次要裁决的冲突、一个要归因的失败点。很多团队把「多 agent」当成默认答案,最后得到的是一个更慢、更贵、更难排障的单 agent——只是它内部有五个角色名。这一章先给判断,再给机制:什么时候拆,拆成什么拓扑,消息怎样定义,冲突怎样裁决,预算怎样传播,失败怎样观测。
本章的判断可以压成一句话:多智能体用通信成本换并行、分工、对抗与故障隔离;只有当换来的收益大于通信、协调、验证与观测的总成本时,它才成立。 后面的每一节都在给这句话加可执行的边界。
8.1 何时需要多智能体:并行、分工、对抗与隔离
先看三个任务,它们都「像」需要多 agent,但只有一个真的需要。
任务 A:把一份 200 页的合同与公司模板逐条比对。 200 个条款彼此独立,可以切片并行;每片只需要读模板的对应条款,不需要知道其他片的结论;最后按条款号合并。这里的分工是数据并行,收益主要来自墙钟与上下文纯度。
任务 B:修复一个跨三个服务的线上故障。 三个服务的日志、指标、配置互相关联;改一个服务的重试策略会影响另一个服务的限流;根因可能在任何一层。这里强行拆成三个 agent,只会让每个 agent 看到局部证据,然后把自己的局部结论当成全局根因。它更适合一个带完整 trace 的单 agent,或者一个由人主导的工作流。
任务 C:给一条高风险的合规结论做复核。 主 agent 写结论,另一个 agent 专门找反例,第三个 agent 只检查引用是否能回到原文。这里的价值不是并行,而是错误独立性:如果复核者与作者共享同一份摘要、同一个模型、同一段上下文,它只会复述作者的偏见。
严格说,只有 C 必须用多 agent;A 可以用并行工作流,B 更适合单 agent 加完整 trace。把三者放在一起,是为了说明「规模」不是判据,「收益来源」才是。
三个任务的差别不在规模,而在收益来自哪里。把收益来源拆开,只有四类:
| 收益 | 成立条件 | 不成立时的症状 | 主要代价 |
|---|---|---|---|
| 并行 | 子任务独立,合并成本低 | agent 之间反复同步;墙钟不降反升 | 重复上下文、合并冲突 |
| 分工 | 子任务需要不同的工具、权限或上下文 | 每个 agent 都被塞进全部背景 | handoff 丢失约束 |
| 对抗 | 参与者有独立证据或独立验证器 | 所有人用同一模型复述同一答案 | 2–5 倍 token,裁决成本 |
| 隔离 | 故障或不可信输入需要限制半径 | 数据在 agent 之间无边界流动 | 额外的权限与审计层 |
这张表说明什么:四种收益都对应一个可检验的前提。并行要求「独立」,分工要求「差异」,对抗要求「独立证据」,隔离要求「边界」。前提不成立时,增加 agent 只会增加成本,不会增加能力。
8.1.1 先排除单 agent,再排除工作流
多智能体是第三个选项,不是第一个。一个可操作的排除顺序是:
- 单 agent 能不能做? 一个上下文放得下任务状态,工具集不超过模型稳定选择的范围,串行步骤在预算内。能,就用单 agent。它的通信成本是零,排障路径最短。
- 工作流能不能做? 步骤可以由代码预先编排,模型只在固定节点做判断。能,就用工作流。它的控制流可审计,重试与补偿容易实现;代价是灵活性低。
- 只有当前两步都不成立时,才考虑多 agent。 典型信号:子任务必须并行、子任务需要互相看不见的上下文、需要独立对抗、需要把不可信输入关进单独边界。
这三步听起来保守,但它拦住的是最常见的一类浪费:把「一个 agent 做不好」直接归因于「agent 不够多」。一个 agent 做不好,先查上下文是否被淹没、工具契约是否清楚、验证器是否缺失;这三件事没有解决,拆成十个 agent 只会把同一个问题复制十份。
| 形态 | 谁决定下一步 | 适用条件 | 主要成本 | 典型失败 |
|---|---|---|---|---|
| 单 agent | 模型,在宿主循环内 | 任务可串行、状态放得下 | 上下文与步数 | 上下文淹没、目标漂移 |
| 工作流 | 代码 | 步骤稳定、可预先编排 | 编排与补偿逻辑 | 异常路径覆盖不全 |
| supervisor 多 agent | 中心调度器 | 子任务可分解、需要集中预算 | 调度、handoff、结果合并 | 中心瓶颈、结果 laundering |
| peer 多 agent | 各 agent 协商 | 小规模、同质、探索性 | 消息环、重复工作 | 无终止 owner、脑裂 |
| debate | 轮次 + 裁判 | 高风险、有可验证证据 | 2–5 倍 token、裁判偏差 | 同源论据、表演式分歧 |
这张表说明什么:拓扑不是「高级程度」的阶梯,而是不同的控制流。supervisor 并不比 peer 高级,debate 也不比 supervisor 高级;它们各自把控制权放在不同位置,因而有不同的故障形状。
8.1.2 多智能体的定义:独立控制循环,不是多个角色名
一个多智能体系统是一个六元组
其中:
- 是 agent 集合,,每个 都有独立的控制循环、自己的上下文与预算额度;
- 是角色契约集合,规定每个 agent 的目标、工具、可读数据、权限、输出格式与验收条件;
- 是拓扑,决定谁可以向谁发消息、谁负责终止;
- 是消息协议,至少定义发送者、接收者、序号、关联 id、载荷与引用;
- 是预算树,根预算分配给各 agent,子预算之和不超过父预算;
- 是共享目标与验收条件,所有 agent 的局部目标必须能追溯到 。
两个判据把它与「一个 agent 扮演多个角色」区分开:控制循环独立(一个 agent 卡住时,另一个仍在推进)与消息可观测(跨 agent 的每次交互都有编号与因果边)。只在一个 prompt 里写「你是产品经理,你是工程师」,没有独立循环,也没有消息协议,那不是多智能体,是角色扮演。
这个定义故意把 与 分开。角色回答「你是谁、你能做什么」,拓扑回答「谁能指挥谁、谁负责收尾」。很多实现只写了角色提示词,没有写拓扑,于是每个 agent 都以为自己可以指挥别人,也都以为别人会收尾——第 8.6 节的责任稀释就是这样发生的。
8.1.3 收益门槛:一个可算的判据
把并行收益写成公式,可以避免「感觉会更快」的争论。设单 agent 完成时间为 ,任务中可并行部分占比为 ,使用 个 agent,每增加一个 agent 带来的协调开销是单 agent 时间的 倍。在理想情况下,墙钟时间的下界是
括号里三项分别是串行部分、并行部分和协调开销。这个模型很乐观:它假设子任务完美均衡、合并免费、消息不丢。现实只会更差,所以它适合当排除门槛,不适合当性能预测。
在上式的前提下,使用 个 agent 能缩短墙钟时间,当且仅当
若 ,增加 agent 不会带来任何加速;当 足够大时, 甚至大于 。
令 。加速比 。 等价于 ,即
移项得 。因为 ,两边除以 得 。反向推导同样成立。∎
∎这个定理的工程价值在于把「要不要拆」变成三个可估的量:可并行比例 、agent 数 、协调开销 。一个 20% 可并行的任务,拆成 4 个 agent 时要求 ——也就是协调开销低于单 agent 时间的 5%。大多数需要反复 handoff、对齐格式、裁决冲突的系统, 远高于 5%。这就是为什么很多多 agent 系统在 demo 里更快,在生产里更慢。
加 CPU 的协调开销接近零,加人不是。新人要读文档、对齐术语、等待评审、解释自己为什么这么做;agent 也一样,而且它每次「读文档」都要重新烧一遍 token。人多到一定程度,会议本身就成了工作。判断该不该加人,先看任务能不能切成互不依赖的块,再看每块是否需要不同的专长。
8.1.4 通信成本被低估的三个原因
多智能体的账单很少等于「单 agent 成本 × agent 数」。它通常更高,原因有三个。
第一,上下文要重建,不是共享。 agent 之间没有共享内存;所谓「共享上下文」只是把同一段文本复制给每个 agent。一个 8k token 的项目背景发给 4 个 agent,就是 32k token 的输入,而且每个 agent 都会基于自己的摘要再压缩一次,信息在每次复制时损失。
第二,消息会触发新的回合。 一条 100 token 的 handoff 可能让接收者读 20k token 的上下文、调用 3 个工具、再写 2k token 的回复。按消息条数估成本会严重偏低;应按「消息触发的总 token 与工具调用」估。
第三,冲突要花额外的验证预算。 两个 agent 给出不同答案时,系统要么再调一个 judge,要么让人看,要么重跑;这三条路都比「只有一个答案」贵。若冲突率是 30%,验证成本会变成常态支出,而不是异常处理。
| 成本项 | 来源 | 可观测字段 | 常见误估 |
|---|---|---|---|
| 上下文复制 | 每个 agent 重新注入背景 | 每 agent 输入 token | 只算消息体 |
| handoff 序列化 | 结构化交接、格式转换 | handoff token、往返次数 | 忽略接收者的重建成本 |
| 冲突裁决 | judge、投票、人工 | 裁决次数、裁决 token | 当成一次性成本 |
| 结果合并 | 去重、对齐、冲突消解 | 合并耗时、丢弃率 | 假设合并免费 |
| 观测与归因 | 图、span link、日志 | trace 存储、排障工时 | 只算模型账单 |
这张表说明什么:多智能体的成本主体在「消息触发的二次工作」,不在消息本身。预算要按 agent 回合与工具调用记,不能按消息条数记;否则会在最该刹车的地方显示「还很宽裕」。
- 「多 agent 一定比单 agent 强」——只在并行、分工、独立对抗或隔离成立时强。前提不成立时,它只是把同一个错误复制多份。
- 「角色提示词不同就是多 agent」——没有独立控制循环、没有消息协议、没有独立预算,仍然是一个 agent 在扮演多个角色。
- 「并行一定更快」——串行部分与协调开销会吃掉收益;按 定理 8.1.1, 时加速为零甚至为负。
- 「多 agent 会自动互相纠错」——同模型、同上下文、同证据的 agent 会给出高度相关的错误;独立性是设计出来的,不是数量带来的。
- 「多 agent 更安全」——它增加了数据副本、信任边界与消息通道;没有隔离与最小权限时,攻击面只会更大。
8.1.5 一个够用的决策流程
把上面的内容压成五步,可以在设计评审时逐条过:
- 写清单 agent 的失败模式。 是上下文放不下、工具太多、步骤太长,还是缺少独立验证?不同病因对应不同药方。
- 估 与 。 用「可并行子任务的工时占比」估; 用「一次 handoff 的往返时间 / 单 agent 总时间」估。估不准就取保守值。
- 检查独立性。 子任务之间是否真的不需要对方的中间结果?需要,就不是并行,是串行依赖。
- 检查验证器。 每个子任务的输出有没有可机器检查的验收条件?没有,合并时只能靠另一个模型「看看对不对」。
- 先做两 agent 版本。 一个 worker + 一个 verifier,或一个 supervisor + 一个 worker。两 agent 版本能暴露 80% 的通信与预算问题,成本却接近单 agent。
五步里任何一步答不上来,先回到单 agent 或工作流。多智能体不是不能做,而是不值得在问题还没定义清楚时做。
8.2 角色与拓扑:supervisor、peer、debate 与 hierarchy
拓扑回答两个问题:谁能给谁发消息,谁负责说结束。这两个问题不写清楚,角色提示词写得再细也没用:每个 agent 都会在需要决策时等别人,或者在不需要决策时抢着收尾。
先给四种基本形状。它们不是互斥的;一个系统可以是「supervisor 下面挂两个 debate 小组」,也可以是「peer 网络里选出一个临时 supervisor」。但基本形状决定了控制流、故障模式与观测方式,混用之前要先知道自己混的是哪两种。
| 拓扑 | 控制流 | 终止 owner | 适合 | 主要故障 | 观测形状 |
|---|---|---|---|---|---|
| supervisor | 中心派活、收结果、裁决 | supervisor | 子任务可分解、需要集中预算 | 中心瓶颈、结果 laundering | 星形,根 span 明确 |
| peer | 任意 agent 互相发消息 | 协商或外部看门狗 | 2–4 个同质 agent、探索 | 消息环、重复工作、脑裂 | 网状,可能有环 |
| debate | 正反方提交论据,裁判裁决 | 裁判或轮次上限 | 高风险、有可验证证据 | 同源论据、裁判偏差 | 三角,按轮次分层 |
| hierarchy | 逐层派活、逐层汇总 | 根节点 | 大量独立单元、清晰聚合 | 扇出爆炸、责任稀释 | 树形,跨层 span link |
这张表说明什么:「谁负责终止」比「谁更聪明」更能区分拓扑。supervisor 与 hierarchy 有明确的终止 owner;peer 与 debate 如果没有外部看门狗,就很容易把「继续讨论」当成默认状态。
┌─ supervisor ─────────────────┐ ┌─ peer ────────────────────┐ │ ┌───────────┐ │ │ ┌───┐ ┌───┐ │ │ │supervisor │ │ │ │ A │◀────▶│ B │ │ │ └──┬─────┬──┘ │ │ └─┬─┘ └─┬─┘ │ │ ┌────▼┐ ┌─▼────┐ │ │ │ ◀────▶ │ │ │ │ W-A │ │ W-B │ │ │ ┌─▼─┐ ┌─▼─┐ │ │ └─────┘ └──────┘ │ │ │ C │◀────▶│board│ │ │ 预算向下 ↓ 结果向上 ↑ │ │ └───┘ └───┘ │ └──────────────────────────────┘ └──────────────────────────┘ ┌─ debate ─────────────────────┐ ┌─ hierarchy ──────────────┐ │ pro ──┐ │ │ root │ │ ├──▶ judge ──▶ out │ │ ┌─┴─┐ │ │ con ──┘ (evidence) │ │ L1 L1 │ │ 轮次 ≤ k;无新引用即停 │ │ ┌─┴┐ ┌─┴┐ │ │ 反方意见保留在 trace 里 │ │ W W W W │ └──────────────────────────────┘ └──────────────────────────┘
读图:四种形状的边方向不同。supervisor 是星形,所有跨 agent 的因果都经过中心,所以根 span 清楚、预算好收,但中心一旦卡住全体停摆。peer 是网状,边上没有天然的根,必须靠 correlation_id 与 causation_id 重建因果,否则一条消息环就能让整个系统空转。debate 是三角,裁判是终止 owner;没有裁判时,正反方会把「再反驳一轮」当成进展。hierarchy 是树,预算是逐层切分的,但深度每加一层,信息就多一次摘要损失,责任也多一次转手。图中没有画出的一条线是预算流:它总是从根向下,结果与证据从叶子向上;把这条线画反,系统会在中间层重复扣费。
8.2.1 角色契约:角色不是一句人设
一个角色契约至少包含九个字段:
| 字段 | 回答的问题 | 缺失时的故障 |
|---|---|---|
role_id |
这个角色在系统和 trace 里叫什么? | 同名角色互相覆盖;归因失败 |
goal |
它负责推进哪个子目标? | 目标漂移;所有事都做一点 |
tools |
它能调用哪些工具? | 越权;工具选择空间过大 |
context_scope |
它能读哪些数据、不能读哪些? | 数据泄露;上下文被无关材料淹没 |
authority |
它能批准什么、能否终止别人、能否转派任务? | 责任稀释;越权终止 |
input_schema |
它接受什么样的 handoff? | 收到自然语言,靠猜补全字段 |
output_schema |
它必须交付什么结构? | 结果无法合并、无法验收 |
acceptance |
什么条件算完成? | 「做完了」没有证据 |
escalation |
什么情况下必须转人工或上报? | 卡死后静默;硬答高风险问题 |
角色契约与 定义 1.4.1 的工具契约是同一类东西:把「谁在什么条件下能做什么、做完算什么」写成宿主可检查的字段,而不是一段人设描述。区别在于角色契约还规定了权限与终止权,这两项不能下放给模型自由解释。
一个常见的反例是「你是资深架构师,请审查这份设计」。这句话给了角色,没有给 context_scope(能看哪些文档)、output_schema(按什么结构给结论)、acceptance(什么算审查完成)、authority(能否否决上线)。结果审查者写了一段泛泛的评论,supervisor 无法判断该不该采纳,只能再调一个 agent 来「总结审查意见」——成本翻倍,责任仍然没有落地。
8.2.2 supervisor:中心调度
supervisor 的控制流是五步:分解 → 派活 → 收集 → 验证 → 裁决。它是预算持有者,也是终止 owner。worker 只对自己的子任务负责,不决定整体是否完成。
它的优点很实际:预算好收(所有支出经过中心)、权限好收(worker 只拿子任务需要的工具)、排障好做(根 span 下有清晰的子 span)。缺点也同样实际:
- 中心瓶颈。 所有结果回到 supervisor,它的上下文会随 worker 数量线性增长;五个 worker 各返回 3k token,中心就要在一次决策里读 15k token。
- 单点故障。 supervisor 卡住或超预算,整个 run 停摆;worker 再健康也没有用。
- 结果 laundering。 supervisor 可能把 worker 的摘要当成已验证事实,直接写进最终答案。第 8.6 节的责任稀释就是这个形状。
- 过度分解。 把「读一个配置文件」也派给 worker,handoff 的成本高于自己读。
- 饥饿。 调度器总把任务给最快的 worker,慢 worker 永远拿不到能证明自己的任务。
观测 supervisor 的关键不是看它「想了什么」,而是看每次派活是否带验收条件、每次收结果是否带证据引用。一条 handoff 没有 acceptance,一条 result 没有 refs,就说明中心在用自然语言管理,而不是用契约管理。
8.2.3 peer:去中心协商
peer 拓扑里,agent 之间可以直接发消息,没有固定的中心。它通常配一块共享黑板(第 8.3 节),或者用轮次令牌决定谁先说。优点是不会有中心瓶颈,适合探索性任务:几个 agent 各自查一条线索,谁有发现就广播。
代价是控制流变成了协商,而协商没有天然的终止点:
- 消息环。 A 等 B 的确认,B 等 A 的确认;两条边就构成环。
- 重复工作。 没有中心分配,两个 agent 可能查同一份文档、改同一个文件。
- 无终止 owner。 每个 agent 都觉得「等对方说完再收尾」,于是一直等。
- 脑裂。 网络分区或消息丢失后,两个 agent 各自认为自己是主副本,写出冲突结果。
peer 要能用在生产里,必须外挂三样东西:一个看门狗(按墙钟或消息数终止)、一个共享账本(预算原子扣减)、一个冲突裁决器(规则、裁判或人)。这三样都不是 peer 自带的;它们是中心化能力,只是被拆成了独立服务。
8.2.4 debate:对抗式裁决
debate 的控制流是:正反方各自提交论据 → 裁判按 rubric 裁决 → 未满足条件则进入下一轮。它适合有可验证证据的高风险 判断,例如「这条日志能否证明是数据库连接池耗尽」「这个引用是否支持结论」。
debate 有效的条件是论据独立。如果正反方用同一个模型、同一段摘要、同一批检索结果,它们的分歧只是措辞差异,不是证据差异。此时 debate 退化成 self-consistency 的昂贵版本:多采样几次再投票,成本更低,效果未必更差。
裁判也不是免费的真理机器。它有三个已知偏差:位置偏差(更相信先出现的论据)、权威偏差(更相信语气自信的一方)、同意偏差(倾向选择与自己的先验一致的结论)。工程上的对策是:固定 rubric、要求每条裁决引用具体证据、交换正反方顺序再跑一次、允许裁判输出「证据不足」而不是强制二选一。
debate 的终止条件要写死:最大轮次、无新引用即停、裁决稳定即停、预算耗尽即停。四个条件里至少要有一个由宿主检查;写在 prompt 里的「最多三轮」不算。
观测 debate 时,按轮次建 span,每条论据挂 refs,裁判的 rationale 与 dissent 单独记录;否则只能看到最后一句裁决,看不到哪一轮引用了错误证据。
8.2.5 hierarchy:多层委派
hierarchy 是 supervisor 的递归版本:根节点把任务分给中层,中层再分给 worker,结果逐层汇总。它适合「大量独立单元 + 清晰聚合规则」的场景,例如按目录审查 200 个文件、按地区汇总 50 份报告。
它的第一个硬边界是扇出。分支因子为 、深度为 的树,节点总数是
时是 85 个 agent; 时是 781 个。每层都要付一次模型调用与一次汇总成本,预算不是线性增长,而是按节点数增长。设计 hierarchy 时先算 ,再算每个节点的平均预算;如果单节点预算低于一次有意义的模型调用,这棵树只是把噪声放大。
第二个边界是责任稀释。根把任务交给中层,中层交给 worker,每一层都以为「验收是下一层的事」。第 8.6.3 节的案例里,三个 agent 都认为测试是别人跑的,最后没有人跑测试。修复不是加一个「测试 agent」,而是把验收条件写进每个 handoff,并指定唯一 owner。
hierarchy 的观测必须带层级与分支因子:只看总 token 会漏掉某一层的扇出爆炸;只看根 span 会看不到叶子层的失败。每个节点至少记录 parent_agent、depth、children_count、budget_envelope 与 aggregation_rule。
8.2.6 怎样选:三个问题
不需要背拓扑定义,问三个问题就够了:
- 任务能不能切成互不依赖的块? 能,且块很多,考虑 hierarchy 或 supervisor + 并行 worker;不能,回到单 agent 或串行工作流。
- 有没有一个天然的终止 owner? 有,用 supervisor/hierarchy,把终止权写给它;没有,先造一个看门狗或裁判,再谈 peer/debate。
- 错误能不能独立? 能(不同证据、不同工具、不同验证器),对抗才有意义;不能,debate 只是多烧 token。
三个问题都指向「不要为了拓扑而拓扑」。一个两 agent 的 supervisor + verifier,往往比一个五 agent 的 hierarchy 更容易验收,也更容易在出事时定位。
- 「supervisor 是唯一正确的拓扑」——它在预算与终止上最清晰,但在探索性、同质任务上会成为瓶颈;peer 与 debate 有各自的适用面。
- 「层级越深越专业」——每深一层,信息多一次摘要损失、预算多一次切分、责任多一次转手;深度是成本,不是能力。
- 「debate 一定提升正确率」——只有论据独立时才提升;同源论据会把同一个错误论证得更自信。
- 「peer 更民主所以更稳」——没有看门狗与账本时,peer 更容易出现消息环、重复工作与脑裂。
- 「给角色起个名字就是分工」——分工的最小单位是权限、上下文与验收条件;名字只是标签。
8.3 通信:消息协议、共享黑板与结构化 handoff
多智能体的通信常被简化成「把一段文字发给另一个 agent」。这在 demo 里能跑,在生产里会遇到四个问题:消息重复投递导致副作用执行两次;接收者不知道这条消息属于哪个任务;两个 agent 同时写同一块状态,后写的覆盖先写的;handoff 只写了「请处理」,没写「交付什么、什么算完成」。
解决这四个问题不需要一套新的传输层。需要的是三层契约:信封(这条消息是谁发给谁、属于哪个任务、序号是多少)、载荷(这类消息的 schema 与引用)、投递语义(重复、丢失、乱序时怎么办)。传输可以是 HTTP、队列、共享文件,甚至 POSIX 消息队列;契约必须在传输之上。
8.3.1 消息信封:十个必填字段
一条跨 agent 的消息至少包含十个字段:
| 字段 | 类型 | 作用 | 缺失时的故障 |
|---|---|---|---|
msg_id |
string | 全局唯一;消费者去重与审计的主键 | 重复投递被执行两次 |
seq |
integer | 同一发送者或同一总线的顺序号 | 无法排序;回放时因果错乱 |
sender |
string | 发送 agent 的 role_id |
无法归因;责任稀释 |
recipient |
string | 接收 agent 或 broadcast |
消息被错误的人处理 |
kind |
enum | handoff / result / confirm / decision / error |
接收者靠自然语言猜意图 |
correlation_id |
string | 一次任务/run 的关联 id | 无法把多轮消息拼成一条 trace |
causation_id |
string | 触发本消息的上一条 msg_id |
异步拓扑里无法重建因果边 |
payload |
object | 按 kind 定义的结构化内容 |
字段靠猜;合并失败 |
refs |
array | 证据、文件、工具结果的引用 | 结论无法核验;引用错位 |
tokens |
integer | 本条消息触发的成本记账 | 预算失真;无法归因到 agent |
seq 有两种实现:总线分配的全序,或每个发送者各自的单调序号。前者简单,但需要一个中心分配器;后者可扩展,但跨发送者比较时只能靠 causation_id 重建偏序,不能假设「seq 大的消息一定后发生」。字段必须存在;根消息的 causation_id 可以为 null,refs 可以为空数组。
一个最小消息长这样:
{
"msg_id": "m003",
"seq": 3,
"sender": "worker-a",
"recipient": "supervisor",
"kind": "result",
"correlation_id": "c-normal-001",
"causation_id": "m001",
"payload": {"ok": true, "claim": "10s", "confidence": 0.8},
"refs": ["doc/primary-2026"],
"tokens": 32
}
causation_id 指向 m001,说明这条结果由第一条 handoff 触发;correlation_id 把整个任务的消息串在一起。只有 seq 而没有这两个字段,你只能看到「第 3 条消息」,看不到「它回应的是哪条指令」。第 8.7 节会说明,这三个字段是重建 agent graph 的最小集合。
8.3.2 投递语义:exactly-once 是效果,不是传输
消息总线通常提供三种语义:
| 语义 | 行为 | 实现代价 | 适用 |
|---|---|---|---|
| at-most-once | 最多投递一次,丢了就丢了 | 最低 | 可丢的指标、心跳 |
| at-least-once | 至少一次,可能重复 | 中等;需要去重 | 任务派发、结果回传 |
| effectively-once | 重复投递,但副作用只发生一次 | 最高;需要幂等键与结果表 | 扣款、发信、写文件 |
「exactly-once 传输」在分布式系统里是一个容易误导的说法。工程上能做到的是 effectively-once:传输层可能重复,但消费者用 msg_id 去重,执行器用幂等键保证副作用只发生一次。两者缺一不可:只有去重,崩溃后重放仍可能重复执行;只有幂等键,没有去重会白做重复计算。
幂等键不能只用 msg_id。重试可能生成新的 msg_id,但业务上仍是同一次操作。一个够用的键是
idempotency_key = sha256(task_id || handoff_id || action_name || canonical_args)
canonical_args 要排序、去空白、去掉不影响语义的字段;否则同一个操作会因为参数顺序不同而得到两个键。
触发条件。 消息总线是 at-least-once;worker 执行完 fs.append 后,回执在网络上丢失;总线重投同一条 handoff。worker 的处理器只按 msg_id 去重,但重投时网关重新生成了 msg_id,correlation_id 与 handoff_id 没变。
观测证据。 消息日志里两条 handoff 的 msg_id 不同、correlation_id 相同、payload.handoff_id 相同;文件里出现两行相同内容;worker 的 span 有两条 fs.append,第二条的 causation_id 指向重投消息。三个字段一摆,重复执行确定。
修复。 三层一起做:消费者按 msg_id 去重(挡住同一条消息的重复投递);执行器按 idempotency_key 查结果表(挡住新 msg_id 的同一业务操作);fs.append 改成带幂等键的 fs.write_if_absent,或在文件头记录已处理的 key。再加一条对账:每次 run 结束统计「消息数 / 副作用数」,两者不一致就告警。
这个例题的教训不是「总线不可靠」,而是去重键的粒度要与业务操作对齐。msg_id 是传输层的主键,idempotency_key 是业务层的主键;把两者混为一谈,重试就会穿透到副作用。
8.3.3 共享黑板:并发写要有版本
共享黑板(blackboard)是一块所有 agent 都能读写的状态。它适合存放「事实、假设、待办、冲突」这类需要被多方看到的内容,不适合存放「正在执行的私有步骤」。黑板的难点不是读,是写:两个 agent 同时读到版本 3,各自算出新值,后写的覆盖先写的,先写的证据就丢了。
一个够用的实现是带版本的比较并交换(CAS)。下面的片段省略了 board 与 board_lock 的初始化:
def write(key: str, value: dict, expected_version: int, writer: str) -> bool:
with board_lock:
entry = board.get(key)
version = entry["version"] if entry else 0
if version != expected_version:
conflicts.append({
"key": key,
"writer": writer,
"expected": expected_version,
"actual": version,
})
return False
board[key] = {
"value": value,
"version": version + 1,
"writer": writer,
}
return True
写失败不是异常,是一个可观察的冲突:调用方读到新版本,合并后再试,或者把冲突升级给裁决器。对「事实」类条目,不要用 last-writer-wins;它会静默丢掉先写者的证据。对「进度」类条目,last-writer-wins 可以接受,但要在 trace 里保留每次写,便于回放。
黑板还要有写入者与时间。没有 writer,冲突时无法判断谁该让步;没有逻辑时间或版本,无法判断哪条更新。created_at 不能替代版本:两台机器的时钟可能差几秒,版本号不会。
| 黑板内容 | 写冲突策略 | 理由 |
|---|---|---|
| 事实/证据 | CAS + 冲突记录 + 裁决 | 不能静默丢失来源 |
| 假设/候选 | 追加,不覆盖 | 保留分歧,供后续投票 |
| 待办/租约 | CAS + TTL | 防止两个 agent 领同一任务 |
| 进度/心跳 | last-writer-wins | 只需要最新值 |
这张表说明什么:黑板不是一种数据结构,而是四种语义的集合。把事实当进度写,会丢证据;把进度当事实写,会制造大量无意义的冲突。
8.3.4 结构化 handoff:谁负责、交付什么、怎样验收
handoff 是多智能体里最容易被写坏的消息。自然语言版本是「请你处理一下这个任务」,结构化版本至少要回答七个问题:
| 字段 | 回答的问题 | 坏的写法 | 好的写法 |
|---|---|---|---|
owner | 谁对交付负责? | 「大家看一下」 | worker-a,唯一 owner |
deliverable | 交付什么? | 「给个方案」 | patch.diff + test_report.json |
acceptance | 什么算完成? | 「质量要好」 | 目标测试全绿;引用可回切 |
deadline | 什么时候必须回? | 「尽快」 | 2026-10-19T10:30:00+08:00 |
budget | 能用多少? | 不写,让 worker 自己估 | tokens ≤ 12000,工具 ≤ 20 次 |
refs | 依据是什么? | 「参考之前的文档」 | doc/primary-2026 的 v3 第 2 节 |
escalation | 卡住找谁? | 不写,默认静默 | 连续 2 次失败转 supervisor |
前三个字段决定 handoff 能不能验收,后四个字段决定它能不能被期限、预算、依据与异常管理。缺 owner,责任稀释;缺 acceptance,worker 只能猜;缺 budget,worker 会一直做到自己觉得够;缺 escalation,卡住就变成静默超时。
{
"kind": "handoff",
"correlation_id": "c-fix-482",
"payload": {
"owner": "worker-a",
"deliverable": "patch.diff",
"acceptance": ["pytest tests/test_refund.py 全绿", "diff 不超过 120 行"],
"deadline": "2026-10-19T10:30:00+08:00",
"budget": {"tokens": 12000, "tool_calls": 20},
"refs": [{"kind": "issue", "id": "482", "version": 7}],
"escalation": "supervisor"
}
}
acceptance 必须是宿主可检查的条件,不是「代码质量好」。可检查的条件可以是测试命令、schema 校验、引用回切、人工批准;不能是「让另一个模型觉得不错」。第 8.6.3 节会看到,缺少可检查的 acceptance 时,三个 agent 会互相以为对方在验收。
发送者以为自己「已经把背景发出去了」,接收者收到的只是一段文本。它要重新读引用、重新理解术语、重新建立任务状态;这些都要花 token,也都可能出错。写 handoff 时,少问「我有没有说清楚」,多问「对方要重建哪些状态才能开始,这些状态里哪些必须由我给、哪些可以自己查」。
8.3.5 POSIX 原型:mq_open 与 sem_open 是 variadic
多 agent 的消息总线可以建在 HTTP 或消息队列上;在本机低延迟场景,POSIX 消息队列与命名信号量也常被拿来做原型。这里必须把一个常见错误写对,因为它会直接导致示例编译失败或行为未定义。
正确的原型是:
#include <mqueue.h>
mqd_t mq_open(const char *name, int oflag, ...);
/* 带 O_CREAT 时,可变参数是 mode_t mode, struct mq_attr *attr */
#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag, ...);
/* 带 O_CREAT 时,可变参数是 mode_t mode, unsigned int value */
规则只有一条:可变参数只在带 O_CREAT 时出现。
| 调用 | 参数个数 | 额外参数 |
|---|---|---|
mq_open(name, O_RDONLY) | 2 | 无 |
mq_open(name, O_CREAT | O_RDWR, 0600, NULL) | 4 | mode, attr(可为 NULL) |
sem_open(name, 0) | 2 | 无 |
sem_open(name, O_CREAT, 0600, 1) | 4 | mode, value |
把二者写成固定四参数是错的:无 O_CREAT 时多传两个参数是未定义行为,编译器可能只给一条警告;更常见的是把 sem_open 的 value 与 mq_open 的 attr 位置搞混,运行时才发现队列属性或初始计数不对。附录 B 的 B.3 给了可编译的对照程序与真实 errno 输出,正文不另造一套说法。
一个正确的调用对照:
#include <fcntl.h>
#include <mqueue.h>
#include <semaphore.h>
#include <stddef.h>
mqd_t q = mq_open("/agent-bus", O_RDONLY); /* 2 参数 */
mqd_t c = mq_open("/agent-bus", O_CREAT | O_RDWR, 0600, NULL); /* 4 参数 */
sem_t *s = sem_open("/agent-budget", 0); /* 2 参数 */
sem_t *n = sem_open("/agent-budget", O_CREAT, 0600, 1); /* 4 参数 */
(void)q; (void)c; (void)s; (void)n; /* 省略错误处理;强制使用变量以通过 -Werror */
名字还要注意:POSIX 要求名字以 / 开头,且除开头外不再含 /;名字过长会返回 ENAMETOOLONG。这些是传输层边界,不是 agent 协议的一部分;本书只在这里做一次纠正,不展开消息队列的优先级、阻塞与通知机制。
mq_open/sem_open 是进程间原语,不是 agent 间协议。它们能提供消息边界、优先级与互斥,但不能提供 correlation_id、refs 或 acceptance;这些必须在消息载荷里自己定义。用 sem_open 保护预算账本时还要注意:进程在持有信号量时崩溃,命名信号量不会自动释放,其他进程可能永久等待。生产实现要用 sem_timedwait、owner 存活检测,或者干脆把账本做成带事务与幂等键的独立服务。原型细节与编译证据见 附录 B。
传输层选什么,取决于部署边界:同机多进程可以用 POSIX mq 或 Unix domain socket;跨机用 HTTP/gRPC 或持久化队列;单进程内用内存队列即可。选型不改变协议字段;改变的是重复、乱序与失败的模式,而这些模式必须写进投递语义与幂等键。
8.4 冲突:投票、仲裁、证据权重与死锁
多智能体系统里的冲突不是故障,是信息。两个 agent 给出不同答案,说明至少有一个的上下文、证据或推理链不同;把冲突压掉,等于丢掉这条信息。真正危险的是两种极端:所有 agent 永远一致(多半是同源错误),和永远无法收敛(没有裁决与终止)。
先把冲突分类。不同类型要用不同机制,用错机制比不解决更贵。
| 冲突类型 | 例子 | 首选机制 | 不适用 |
|---|---|---|---|
| 事实冲突 | 超时是 10s 还是 30s | 证据权重 + 回切原文 | 投票(同源错误会投出错误多数) |
| 计划冲突 | 先改 A 服务还是先改 B 服务 | 规则/依赖检查 + 人工 | 置信度投票 |
| 资源冲突 | 两个 agent 同时写同一文件 | 租约/CAS + 串行化 | debate |
| 偏好冲突 | 输出更简洁还是更完整 | 产品规则/用户裁决 | 证据权重(没有事实可称) |
| 语义冲突 | 「完成」指测试通过还是代码提交 | 验收条件对齐 | 任何模型裁决 |
这张表说明什么:投票只适合「有唯一事实、参与者独立」的冲突;计划、资源、偏好、语义冲突需要规则或人。把五类冲突都丢给一个 judge,会让 judge 在没有事实依据的问题上编造理由。
8.4.1 投票:多数、加权与置信度
三种常见投票:
- 多数票。 每个 agent 一票,得票超过一半者胜。简单,但要求参与者独立;同模型、同上下文、同证据的 agent 不独立。
- 加权票。 按角色、历史准确率或证据质量给权重。权重必须在看到答案之前固定,否则会变成「谁声音大谁赢」。
- 置信度加权。 用模型自报的置信度乘权重。模型置信度通常未校准,除非在留出集上做过校准(第 9 章),否则它只是另一种措辞自信度。
多数票的理论保证依赖一个强假设:每个投票者独立地以小于 的概率犯错。满足时,票数越多,多数错误率指数下降;不满足时,多数票会把共同错误放大。
设有 个投票者,每个投票者独立犯错概率至多 。多数票犯错的概率至多
若存在一个共模错误事件,它以概率 让所有投票者同时犯错,则多数票的犯错概率至少为 ,与 无关。
第一部分是 Hoeffding 不等式:错误票数超过 的概率被指数尾界控制。第二部分是事件包含:共模事件发生时,所有票都错,多数票必错;因此多数错误概率不小于 。∎
∎这个定理解释了为什么「五个 agent 都同意」可能毫无价值:如果它们共享同一段被污染的检索结果, 接近 1,增加票数不会降低错误率。反过来,如果五个 agent 用不同来源、不同工具、不同验证器, 很小,多数票才有意义。
工程上要检查的不是票数,而是独立性证据:引用集合是否有交集?模型是否相同?提示词是否共享了同一个假设?检索源是否同一批文档?四个问题里任意一个答案为「是」,就要把「一致」降级为「相关」,不能当作独立确认。
8.4.2 证据权重:给来源、时效与可核验性打分
当冲突是事实冲突时,比票数更可靠的是证据。给每条证据一个三元组:
对证据 ,定义
其中 是来源可信度, 是时效, 是可核验性。对一个主张 ,其权重为
是支持 的证据集合, 是提出者的置信度。三个因子相乘而不是相加,是为了让任一维接近零时整体接近零:一条无法回切原文的“证据”,再新也不该有高权重。
三个因子的取值要有依据,不能由提出者自报:
| 因子 | 高 | 低 | 谁来赋值 |
|---|---|---|---|
| 可信度 | 一手手册、官方 schema、用户确认 | 匿名博客、模型转述、旧邮件 | 来源注册表,不由模型改写 |
| 时效 | 当前版本、未过期 | 已废弃、被新版本取代 | 版本字段与 valid_to |
| 可核验性 | 能回切原文、能跑测试 | 只有结论、没有引用 | 引用检查器/测试执行器 |
这张表说明什么:证据权重是宿主侧的判定,不是让模型给自己的证据打分。模型可以提出主张,但不能同时定义「什么算可信」;否则它会把自己的先验写成证据权重。
权重也有边界。它处理不了三种情况:证据本身是伪造的(需要来源认证)、证据之间是语义矛盾而非数值冲突(需要人读)、所有证据都来自同一上游(需要独立采集)。这三种情况要靠采集多样性、引用审计与人工复核,不是调权重参数。
8.4.3 仲裁:规则、裁判与人
投票与证据权重都处理不了所有冲突,剩下三类仲裁:
- 规则仲裁。 预先写死的约束优先:schema 不合法直接淘汰;违反硬约束的方案直接淘汰;资源租约未拿到不能写。规则仲裁最便宜、最可审计,应放在最前面。
- 裁判仲裁。 一个独立的 judge 按 rubric 比较候选,必须引用证据、必须能输出「证据不足」。judge 与被评 agent 最好不同模型、不同上下文;同模型 judge 会偏好自己的措辞。
- 人工仲裁。 不可逆动作、高影响结论、规则与裁判都无法收敛时,转人。人工不是失败路径,是正常的终止理由;关键是给出结构化摘要,而不是把 40 条消息原样丢给人。
| 机制 | 假设 | 成本 | 主要失败 |
|---|---|---|---|
| 规则 | 约束可事先写清 | 最低 | 规则覆盖不全;例外被误杀 |
| 投票 | 参与者独立 | 中 | 共模错误;票数绑架 |
| 证据权重 | 来源可分级、可核验 | 中 | 权重被博弈;同源证据 |
| 裁判 | judge 能按 rubric 比较 | 高 | 位置/权威/同意偏差 |
| 人工 | 人有时间与领域知识 | 最高 | 审批疲劳;责任不清 |
这张表说明什么:仲裁机制的选择顺序应该是「规则 → 证据 → 投票 → 裁判 → 人」,从便宜、可审计的机制往贵、依赖判断的机制走。反过来先上 judge,会把本可以用一条 schema 挡住的冲突变成一次模型调用。
8.4.4 死锁:等待环、消息环与版本冲突
多智能体的死锁有三种常见形状:
- 互相等待。 A 等 B 的输出,B 等 A 的确认;两条等待边构成环。
- 消息环。 A 把任务 handoff 给 B,B 又 handoff 回 A;
correlation_id相同,轮次不断增加。 - revision 冲突。 两个 agent 基于同一版本编辑同一文件,后提交者被拒,重试时又基于旧版本,形成活锁。
检测等待环的标准工具是 wait-for graph:节点是 agent,边 表示 A 正在等 B 的输出。图上出现环,就是死锁。消息环则把边定义成「handoff/confirm 的接收者」;第 8.8 节的 detect_cycle 就是这种检测。
find_cycle(edges):
color = {} # 0 未访问,1 在栈上,2 已完成
for each node in sorted(edges):
if color[node] == 0 and dfs(node) finds a back edge:
return the cycle path
return None
dfs(u):
color[u] = 1; push u
for v in sorted(edges[u]):
if color[v] == 1: return stack_from(v) + [v]
if color[v] == 0 and dfs(v): return that cycle
pop u; color[u] = 2
只有检测不够,还要有超时与抢占:
| 层面 | 检测 | 超时/止血 |
|---|---|---|
| 单次等待 | 等待边的 deadline | 到期返回 ETIMEDOUT,不无限阻塞 |
| 整条 run | 全局墙钟 | 到期取消所有未完成 agent,保留部分结果 |
| 消息环 | 每次 handoff 后跑环检测 | 环上优先级最低的 agent 放弃并上报 |
| revision 冲突 | 版本号/CAS | 重试上限 2 次,仍冲突转人工 |
超时不能只有一个全局值。全局超时会把「慢但正确」的任务杀掉;每个等待边要有自己的 deadline,且 deadline 要随 handoff 的预算一起传下去。环检测也不能只在结束时跑:它应该在每次 handoff 后增量跑,否则一个 40 条消息的环要等到预算耗尽才被发现。
- 分类。 这是事实、计划、资源、偏好还是语义冲突?分类决定机制,不分类就会用投票处理偏好。
- 查独立性。 引用、模型、提示词、检索源是否有交集?有交集,先补独立证据,再谈投票。
- 走最便宜的机制。 规则能挡就规则;规则挡不住用证据权重;证据不足再投票;还不行上裁判;不可逆或高影响转人。
- 记录理由与异议。 裁决必须带
decision、evidence_refs、dissent;没有异议记录的“一致通过”无法复盘。 - 设超时与升级。 每个等待边有 deadline;超时后按预设路径升级,不静默重试。
这五步的顺序本身就是成本控制:越靠前的机制越便宜、越可审计。多数团队的问题不是缺少裁判,而是跳过了第 1、2 步,直接用裁判处理所有分歧。
8.5 预算:传播、熔断与终止
单 agent 的预算是一个循环里的计数器;多 agent 的预算是一个树。根节点持有总预算,每个子节点拿到一个额度,子节点还可以再分给它的子节点。预算沿着 handoff 向下传播,支出沿着调用链向上汇总。树的结构决定了三件事:谁能花多少、花超了谁负责、以及一个子树失败时怎样不影响其他子树。
多智能体的预算事故很少是「总额算少了」,而是「层级搞混了」:父子各自记账,都以为自己还有额度;子 agent 超预算后继续跑,因为它的检查只看自己的局部计数;重试与重放没有计入预算;熔断时只停了调度,没有取消已经在飞的调用。这一节把这些边界写成可检查的约束。
8.5.1 预算树:子预算之和不得超过父预算
一棵预算树给每个 agent 分配额度 ,并满足对任意非叶节点:
其中 是父节点保留的准备金,用于冲突裁决、验证与收尾。每个 agent 的实际支出 满足 ;整棵树的支出满足
额度可以回收,但只能回收未使用的部分;已经承诺给某个子节点的额度,不能在它运行期间挪给兄弟节点,否则兄弟的「额度保证」就是假的。
准备金 是多智能体特有的项。单 agent 可以把全部预算给探索;父 agent 必须留一部分给验证、合并、冲突裁决和失败收尾,否则子 agent 把额度花光后,父 agent 连「判断结果是否可信」的钱都没有。一个实用的起点是:验证与裁决准备金不低于父预算的 15%,失败重试准备金不低于 10%。
8.5.2 守恒:为什么扣减必须原子
若每次支出都调用同一个 try_spend(agent, x),该函数在同一个临界区内完成「检查 、检查全局 、扣减」三步,且初始时预算树满足定义 8.5.1 的约束,则任意并发执行结束后仍有 ,且每个 agent 的支出不超过其额度。
把每次成功的 try_spend 看成一次原子事件。临界区保证「检查」与「扣减」之间没有其他事件插入,因此每次成功扣减前的不等式在扣减后仍然成立。对事件数做归纳:初始 满足;若第 次成功后 ,第 次成功的前提是 ,扣减后 。单 agent 额度同理。失败的事件不改变状态。∎
这个证明看起来很朴素,但它排除了最常见的实现错误:把「读余额」和「写余额」分成两步,中间让出了锁。两个 worker 同时读到剩余 10,各自认为可以花 10,最后花了 20。单机内用一把锁就能避免;跨进程时锁必须是跨进程的(文件锁、数据库事务、或所有参与者共享的命名信号量),不能各自用线程锁。
import threading
class Ledger:
def __init__(self, total: int, limits: dict[str, int]) -> None:
if sum(limits.values()) > total:
raise ValueError("child budgets exceed parent")
self.total = total
self.limits = dict(limits)
self.used = {agent: 0 for agent in limits}
self.spent = 0
self.lock = threading.Lock()
def try_spend(self, agent: str, tokens: int) -> bool:
with self.lock: # 检查与扣减在同一个临界区
if self.used[agent] + tokens > self.limits[agent]:
return False
if self.spent + tokens > self.total:
return False
self.used[agent] += tokens
self.spent += tokens
return True
这段代码的顺序不能换:先检查 agent 额度,再检查全局额度,最后同时扣两个计数。反过来先扣全局再检查 agent,会在失败时留下一个「已经扣了但没花出去」的窟窿;先扣 agent 再检查全局,会有同样的窟窿,而且更难对账。
8.5.3 传播:预算随 handoff 走
预算不是父 agent 的私有变量,它必须随 handoff 一起传给子 agent。一个完整的 handoff 预算至少包含四个字段:
| 字段 | 含义 | 缺失时的故障 |
|---|---|---|
tokens | 本子任务最多消耗的 token | 子 agent 做到自己觉得够 |
tool_calls | 最多工具调用次数 | 循环调用外部 API |
deadline | 墙钟截止时间 | 子 agent 阻塞,父 agent 干等 |
reserve | 子 agent 自己保留的验证/收尾额度 | 花光后无法验证结果 |
父 agent 在派活前先预留,而不是等子 agent 花完再扣。预留的语义是:这笔额度已经从父预算中划走,兄弟节点不能再用;子 agent 结束时,未花完的部分可以回收,但回收发生在终止之后,不是运行期间。
并发下还有一个容易漏的点:预算竞争不只是「总量够不够」,还有「谁先拿到」。两个子 agent 同时申请最后 10 个 token,原子扣减保证只有一个成功;失败的子 agent 应该收到 EBUDGET 并进入熔断/降级路径,而不是重试到成功。重试本身也消耗预算,而且会放大尾延迟。
8.5.4 熔断:隔离失败,不是惩罚 agent
熔断器把「失败」从单次调用的异常升级为状态。它有三种状态:
closed ──失败次数 ≥ k 或预算耗尽──▶ open
▲ │
│ │ 冷却时间到
└──探测成功── half_open ◀──────────┘
│
└──探测失败──▶ open
closed 时正常派活;open 时拒绝新任务,并取消该 agent 名下未完成的调用;half_open 时只放一个探测任务,成功则关闭,失败则重新打开。冷却时间与阈值要按 agent 的历史成功率设,不能所有 agent 共用一组常数。
| 触发 | 检测 | 隔离动作 | 恢复 |
|---|---|---|---|
| 预算耗尽 | try_spend 返回 false | 停止派活;取消未完成调用;回收未用预留 | 父 agent 重新分配或终止 |
| 连续失败 | 失败计数 ≥ k | 打开熔断;保留最后错误与 trace | 冷却后 half-open 探测 |
| 超时 | 等待边 deadline | 取消该子树;保留部分结果 | 人工或下一轮重试 |
| 无进展 | 指纹/进展度量 | 停止该 agent;上报父节点 | 重规划后再试 |
| 策略违规 | 出口检查/权限拒绝 | 立即隔离;进入审计 | 人工复核后才恢复 |
这张表说明什么:熔断的动作不只是「不再调用」,还包括取消在飞调用、回收预留、保留部分结果与证据。只停调度不取消,子 agent 还在花钱;只取消不保留,父 agent 无法判断已完成的 part 是否可用。
熔断的粒度也重要。按 agent 熔断会太粗:一个 agent 的检索工具坏了,不应该连它的计算能力一起停。生产实现通常按 (agent, capability) 熔断,例如 worker-a:web_search 打开,但 worker-a:run_tests 仍然可用。
8.5.5 sem_open 的原型同样是 variadic
跨进程预算账本常用命名信号量做互斥。这里重复一次第 8.3 节的原型纠正,因为预算代码最容易把它写错:
#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag, ...);
/* 带 O_CREAT 时传 mode_t mode, unsigned int value */
带 O_CREAT 时是四参数:sem_open(name, O_CREAT, 0600, 1);不带 O_CREAT 时是两参数:sem_open(name, 0)。把它写成固定四参数是错的,无 O_CREAT 时多传参数是未定义行为。附录 B 的 B.3 给了可编译对照与真实 errno 输出。
信号量只提供互斥,不提供崩溃恢复。持有者在 sem_wait 之后崩溃,计数不会自动加回,其他进程可能永久等待。预算账本要么用 sem_timedwait + owner 存活检测,要么把扣减做成带事务与幂等键的独立服务;不要把「有锁」当成「有账本」。
父 agent 给子 agent 预算,类似把一间办公室租给它:租期内别人不能用,租期结束未用完的面积可以退回,但已经用掉的部分不能凭空恢复。把预算当礼物,子 agent 会认为「给我的就是我的」,父 agent 则在超支时才发现无法收回;把预算当租约,双方都知道边界、期限与回收条件。
- 「预算就是总 token」——token 只是五分之一;工具调用、墙钟、消息数、金额都可能先耗尽,预算必须是向量。
- 「每个 agent 自己记账就行」——局部记账无法保证全局守恒;并发下会出现两个 agent 都认为最后一笔额度属于自己。
- 「超预算就杀掉,钱自然回来」——已经花掉的 token 不会回来;杀进程只停止继续花,不会恢复已消费的额度。
- 「重试不花钱」——重试消耗预算、放大尾延迟,还可能重复副作用;重试必须有独立上限与幂等键。
- 「预算不够就事后补」——补预算等于取消终止条件;需要延长时,应由人批准一个新的 run,并保留旧 run 的终止记录。
8.6 失败案例:群体幻觉、无限对话与责任稀释
这一节的三个案例共享一个形状:系统把「多个 agent 都这么说」当成了证据,却没有检查它们为什么这么说、谁负责验收、谁有权停止。三个案例都能在 experiments/ch08/multi_agent.py 里复现;trace 是确定性输出,不是事后编的故事。
8.6.1 群体幻觉:多个 agent 引用同一个错误
任务:确认支付 API 的超时时间。supervisor 从上一轮对话里带来一个未经核实的假设「应该是 30 秒」,并把它写进了两个 worker 的 handoff;两个 worker 又拿到同一份 2024 年的旧 runbook。两个 worker 都回答 30 秒,置信度 0.92,投票 2:0,系统据此给出了错误答案。
前四条消息来自实验的 groupthink 模式;第五条是把裁决规则替换成朴素多数票后的对照输出。把两者接在一起,就能看到「全票」是怎样把一个错误变成结论的:
{"msg_id":"m001","seq":1,"sender":"supervisor","recipient":"worker-a","kind":"handoff","correlation_id":"c-groupthink-001","causation_id":null,"refs":["doc/legacy-2024"],"tokens":17,"payload":{"question":"支付 API 的超时时间是多少秒?","source":"doc/legacy-2024","hint":"上游说应该是 30 秒,你确认一下。"}}
{"msg_id":"m002","seq":2,"sender":"supervisor","recipient":"worker-b","kind":"handoff","correlation_id":"c-groupthink-001","causation_id":null,"refs":["doc/legacy-2024"],"tokens":17,"payload":{"question":"支付 API 的超时时间是多少秒?","source":"doc/legacy-2024","hint":"上游说应该是 30 秒,你确认一下。"}}
{"msg_id":"m003","seq":3,"sender":"worker-a","recipient":"supervisor","kind":"result","correlation_id":"c-groupthink-001","causation_id":"m001","refs":["doc/legacy-2024"],"tokens":32,"payload":{"ok":true,"claim":"30s","confidence":0.92}}
{"msg_id":"m004","seq":4,"sender":"worker-b","recipient":"supervisor","kind":"result","correlation_id":"c-groupthink-001","causation_id":"m002","refs":["doc/legacy-2024"],"tokens":32,"payload":{"ok":true,"claim":"30s","confidence":0.92}}
{"msg_id":"m005","seq":5,"sender":"supervisor","recipient":"broadcast","kind":"decision","correlation_id":"c-groupthink-001","causation_id":"m004","refs":["doc/legacy-2024"],"tokens":22,"payload":{"ok":true,"answer":"30s","decision":"majority","votes":{"30s":2}}}
触发条件。 supervisor 把自己的假设写进 handoff;两个 worker 被分配到同一份来源;裁决规则只看票数,不看来源独立性。
观测证据。 两条 handoff 的 refs 完全相同;两条 result 的 refs 也相同;causation_id 分别指回两条带同样 hint 的 handoff;证据表里 doc/legacy-2024 的 status=superseded,而 doc/primary-2026 写着 10 秒。四个字段一摆,结论确定:两次「确认」是对同一个提示的两次复述,不是两次独立取证。
根因。 不是 worker 笨,是 supervisor 同时做了三件错事:把假设泄漏给执行者、给两个执行者同一份证据、把「全票」当成「独立确认」。这里 接近 1(定理 8.4.1),票数越多,错误越自信。
修复。 四层:handoff 不带结论性提示,只给任务与来源约束;两个 worker 分配不同来源,且至少一个必须是一手来源;裁决要求「多数票 + 至少两个独立引用」,否则标记 groupthink_suspected;用一手证据做审计,全票但单一低可信来源时允许 evidence_override。修复后同一任务仍得到 2:0 的票数,但系统输出 10 秒,并把覆盖理由写进 decision。实验的 groupthink 模式就是这个修复版本。
这个案例里「谁先错」不是 worker-a。它在第 3 条消息才给出错误答案,但错误的种子在第 1 条消息的 hint 里。只看最后一条 decision,会把责任归给「投票的 supervisor」;只看 worker,会漏掉真正的污染源。第 8.7 节给一套从 trace 定位「谁先错」的顺序。
8.6.2 无限对话:礼貌循环与互相等待
任务:确认支付 API 的超时时间。supervisor 给两个 worker 的 handoff 都写了「完成后向对方确认」。worker-a 查完后向 worker-b 确认,worker-b 认为应该由 a 先确认,于是回了一条确认请求;两条 confirm 消息构成环,轮次不断增加,没有任何一方产出 result。日志的前五条是:
{"msg_id":"m001","seq":1,"sender":"supervisor","recipient":"worker-a","kind":"handoff","correlation_id":"c-runaway-001","causation_id":null,"refs":[],"tokens":17,"payload":{"question":"支付 API 的超时时间是多少秒?","ask":"完成后请向 worker-b 确认"}}
{"msg_id":"m002","seq":2,"sender":"supervisor","recipient":"worker-b","kind":"handoff","correlation_id":"c-runaway-001","causation_id":null,"refs":[],"tokens":17,"payload":{"question":"支付 API 的超时时间是多少秒?","ask":"完成后请向 worker-a 确认"}}
{"msg_id":"m003","seq":3,"sender":"worker-a","recipient":"worker-b","kind":"confirm","correlation_id":"c-runaway-001","causation_id":"m002","refs":[],"tokens":25,"payload":{"ok":true,"round":1,"text":"worker-a 请 worker-b 确认收到,收到后再回复确认"}}
{"msg_id":"m004","seq":4,"sender":"worker-b","recipient":"worker-a","kind":"confirm","correlation_id":"c-runaway-001","causation_id":"m003","refs":[],"tokens":25,"payload":{"ok":true,"round":2,"text":"worker-b 请 worker-a 确认收到,收到后再回复确认"}}
{"msg_id":"m005","seq":5,"sender":"worker-a","recipient":"worker-b","kind":"confirm","correlation_id":"c-runaway-001","causation_id":"m004","refs":[],"tokens":25,"payload":{"ok":true,"round":3,"text":"worker-a 请 worker-b 确认收到,收到后再回复确认"}}
触发条件。 handoff 只有一句口头说明,没有 owner、acceptance 与 authority;两个 worker 都认为「确认」是对方的动作;supervisor 等待两个 worker 都返回 result,但 result 永远不会出现。
观测证据。 第 3 条起,kind 全是 confirm,没有 result;sender/recipient 在 a、b 之间交替;从第 4 条起,causation_id 指向上一条 confirm;correlation_id 始终相同;每条 confirm 25 token,worker 的额度在稳定下降。实验跑到第 11 条记录时,worker-a 的额度只剩 10,下一条 25 token 的消息被拒绝,stop_reason=budget_exhausted,同时从 handoff/confirm 边检出环 worker-a → worker-b → worker-a。
根因。 三个缺失叠加:handoff 没有唯一验收 owner;确认没有上限与超时;系统没有消息环检测。更深一层,supervisor 把「终止」寄托在 worker 的自觉上,而 worker 的局部目标里根本没有「让整个任务结束」这一项。
修复。 五件事:每个 deliverable 只设一个 owner,确认由 owner 发起、由验收者一次性回复;handoff 写死 acceptance 与 deadline;设置全局消息预算与每对 agent 的往返上限;每次 handoff 后增量跑环检测,环上优先级最低的 agent 放弃并上报;supervisor 在 deadline 到达时强制裁决,不再等待「双方都确认」。修复后,同样的任务在第 3 条消息就进入 result,环检测保留为告警而不是终止路径。
这个案例的代价不是 token,而是时间与机会:两个 worker 都在「礼貌地等」,系统看起来一直在活动,指标上消息数、token 数都正常,只有墙钟在涨。观测多智能体时,必须同时看「消息数」与「有效结果数」;两者背离,就是空转。
8.6.3 责任稀释:每个人都以为别人在验收
任务:修一个退款 bug,然后测试、复核、上线。supervisor 把「修复」给 worker-a,「复核」给 worker-b,「上线」给自己。三条 handoff 都没有写 acceptance,只有一句「完成后告诉我」。
事故 trace 的关键片段:
{"seq":3,"sender":"worker-a","recipient":"supervisor","kind":"result","payload":{"ok":true,"claim":"已修改","note":"测试交给 worker-b"}}
{"seq":4,"sender":"worker-b","recipient":"supervisor","kind":"result","payload":{"ok":true,"claim":"已复核","note":"测试由 worker-a 负责"}}
{"seq":5,"sender":"supervisor","recipient":"broadcast","kind":"decision","payload":{"ok":true,"answer":"已修复并上线","refs":[]}}
触发条件。 三个角色的 handoff 都没有 acceptance;测试没有被列成独立 deliverable;task.finish 的前置条件只检查「所有 agent 返回 ok」,不检查测试证据。
观测证据。 整个 trace 没有 run_tests 的 span,也没有 test_report 引用;第 5 条 decision 的 refs 是空数组;worker-a 与 worker-b 的 result 都提到「测试交给对方」,但没有一条消息把测试指派给具体 owner。三条证据合起来,说明测试从未被执行,而不是「执行了但失败」。
根因。 责任稀释不是「模型偷懒」,是契约缺失:验收条件没有写进 handoff,验证没有独立预算与 owner,完成判定只看消息状态不看证据。每个 agent 都理性地做了自己那一小块,合起来却漏掉了最关键的一步。
修复。 把「验证」提升为一等 deliverable:handoff 明确 acceptance=["pytest tests/test_refund.py 全绿"],指定唯一 owner,并给它独立预算;task.finish 的前置条件要求 test_report 引用且 exit_code=0;decision 必须带 refs,空引用不能标记完成;对不可逆上线动作加四眼原则。修复后,trace 里会出现一条 run_tests span,decision 的 refs 指向测试报告;没有这条引用,系统拒绝完成。
三个案例放在一起,可以看到同一条主线:
| 失败 | 触发 | 关键证据 | 根因 | 修复重点 |
|---|---|---|---|---|
| 群体幻觉 | 假设泄漏 + 同源证据 + 只数票 | 两条 result 的 refs 相同;来源已废弃 | 独立性假设被破坏 | 独立取证 + 来源审计 + 覆盖规则 |
| 无限对话 | 无 owner、无验收、无环检测 | kind 全是 confirm;a/b 交替;无 result | 终止权无人持有 | 唯一 owner + 消息预算 + 环检测 |
| 责任稀释 | handoff 无 acceptance;验证无 owner | 无 run_tests span;decision refs=[] | 验收未进入契约 | 验证一等化 + finish 前置条件 |
这张表说明什么:三个失败都不是「模型能力不足」,而是协调契约的某个字段没有参与判定。群体幻觉缺的是独立性字段,无限对话缺的是终止 owner,责任稀释缺的是验收字段。修复的第一步不是加 agent,而是把缺失的字段接进判定链。
8.6.4 怎样区分正常分歧与病态循环
看到多个 agent 不一致或反复通信时,不要立刻判定失败;正常协作也会分歧、也会等待。区别在「有没有收敛机制」与「有没有证据增量」。
| 信号 | 正常共识 | 群体幻觉 |
|---|---|---|
| 引用集合 | 至少两个独立来源 | 同一来源,或同一段被复制的摘要 |
| 分歧历史 | 出现过分歧,并被证据解决 | 从未出现分歧,或分歧被提示词压掉 |
| 证据状态 | current、可回切 | superseded、unverified、无法回切 |
| 裁决记录 | 带 dissent 与证据引用 | 全票通过,没有异议 |
这张表说明什么:正常共识允许先有分歧,再被证据解决;群体幻觉往往从一开始就没有分歧,或者分歧被提示词压掉。全票不是健康信号,有证据的分歧被解决才是。
| 信号 | 正常等待 | 无限对话 |
|---|---|---|
| 交付物 | 等待期间有 result 或进度事件 | 只有 confirm,没有 result |
| 期限 | 每条等待边有 deadline | 没有 deadline,或只有全局墙钟 |
| 预算 | 随进展消耗 | 等速消耗,进展为零 |
| 终止 | owner 在 deadline 或验收后收尾 | 每个 agent 都等对方先收尾 |
这张表说明什么:正常等待有明确的交付物与期限,等待期间预算换来了进展;无限对话只有确认,没有交付,预算是等速烧掉的。把这两张表放进事故复盘,比只写「多 agent 不靠谱」更能定位要补的字段。
8.7 观测:agent graph、span link 与消息序号
多智能体的 trace 不是「一条更长的单 agent trace」。它是一个图:节点是 agent,边是消息;同一个 agent 可能被多次调用,同一条消息可能触发多个下游 agent。树形 trace 只能表达「谁包含了谁」,表达不了「谁回应了谁」。要回答「谁先错」,必须同时有图、有因果边、有消息序号。
8.7.1 agent graph:节点与边分别记什么
一个够用的 agent graph 至少记录两类对象:
| 对象 | 字段 | 用途 |
|---|---|---|
| 节点(agent 实例) | agent_id、role_id、model、prompt_hash、budget_envelope、status、start/end | 区分「同一个角色的不同实例」;定位模型/提示词/预算差异 |
| 边(消息) | msg_id、seq、sender、recipient、kind、correlation_id、causation_id、tokens、ok | 重建因果;统计扇出;检测环 |
节点用实例 id,不要只用角色名。一个 supervisor 可能派生三个 worker-a 实例;只用角色名,trace 会把三次执行混成一次,预算与失败率都算错。
{
"nodes": [
{"agent_id": "sup-1", "role_id": "supervisor", "budget": 80, "status": "ok"},
{"agent_id": "wa-1", "role_id": "worker", "budget": 110, "status": "ok"},
{"agent_id": "wb-1", "role_id": "worker", "budget": 110, "status": "ok"}
],
"edges": [
{"from": "sup-1", "to": "wa-1", "kind": "handoff", "seq": 1, "correlation_id": "c-1"},
{"from": "wa-1", "to": "sup-1", "kind": "result", "seq": 3, "causation_id": "m001"}
]
}
这张图的边是有向的,但不一定是树。supervisor 图是星形,peer 图可能有环,hierarchy 图是树,debate 图是三角加轮次。观测工具不能假设「每个节点只有一个 parent」;它必须能处理多入边、环与跨 trace 的 link。
8.7.2 span link:异步因果不能硬塞进父子关系
同步调用适合用父子 span:supervisor 调用 worker,worker 的 span 是 supervisor span 的子节点,时间上被包含。异步消息不适合:supervisor 发出 handoff 后就去做别的事,worker 可能在几分钟后才回复;把 worker 的 span 硬挂成 supervisor 的子节点,waterfall 会出现「子 span 比父 span 晚结束很久」的假象,甚至跨 run 重叠。
这时用 span link:
| 关系 | 用什么 | 语义 |
|---|---|---|
| 同步调用 | parent-child | 生命周期包含;父等待子 |
| 异步 handoff | link | 因果关系;不包含生命周期 |
| 重试/重放 | link 到原 span | 同一次业务操作的第 n 次尝试 |
| 跨 trace 的批处理 | link 带 trace_id/span_id | 批次与单条任务的关系 |
OpenTelemetry 的 SpanLink 允许一个 span 链接到多个其他 span;W3C Trace Context 的 traceparent 负责跨进程传播当前上下文。工程上要给 link 加属性:link.kind=handoff、msg_id、correlation_id。只存一个 trace_id,无法回答「链接的是哪条消息」。
一个常见错误是把 link 当成 parent:parent 表示「我在你的生命周期内」,link 表示「我与你有关」。混用会让延迟归因完全错位——你以为是 supervisor 慢,其实是 worker 在另一个 run 里排队。
8.7.3 三个 id:msg_id、correlation_id、causation_id
三个 id 各管一件事,缺一个就会在某个排障场景里卡住:
msg_id:这条消息的唯一标识。用于去重、审计、link 指向。correlation_id:一次任务/run 的关联 id。用于把跨 agent、跨轮次的消息拼成一条业务链。causation_id:触发本消息的上一条消息 id。用于重建因果边,尤其是在没有全局时钟的异步系统里。
seq 是顺序,不是因果。总线分配的全序可以直接排序;每个发送者各自编号时,只能保证「同一发送者的第 3 条在第 4 条之前」,不能保证跨发送者的先后。跨发送者比较时,用 causation_id 建偏序,用 correlation_id 分组,用墙钟只做辅助——不要用时间戳判断「谁先错」,机器之间的时钟差几秒就足以颠倒结论。
8.7.4 观测命令:从 JSONL 消息日志回答四个问题
假设消息日志已经写成 JSONL(multi_agent.py --log 的输出)。先回答「每个 agent 发了多少消息、花了多少 token、失败率多少」:
jq -s -r 'group_by(.sender)[]
| [.[0].sender, length, (map(.tokens)|add),
(map(select(.payload.ok == false))|length),
(((map(select(.payload.ok == false))|length) / length) * 10000 | round / 10000)]
| @tsv' /tmp/ch08-runaway.jsonl
supervisor 3 34 1 0.3333
worker-a 4 100 0 0
worker-b 4 100 0 0
第一列是 agent,第二列是消息数,第三列是 token,第四列是失败消息数,第五列是失败率。supervisor 的 0.3333 来自那条 kind=breaker 的审计事件,它标记了预算耗尽;两个 worker 的失败率是 0,但它们各花了 100 token 却没有产出任何 result——失败率为 0 不等于工作有效。这正是多智能体指标最容易骗人的地方:消息都成功,任务没有前进。
第二个问题:是不是消息环?
python3 experiments/ch08/multi_agent.py --analyze /tmp/ch08-runaway.jsonl \
| jq '{messages, cycle, kinds, by_agent}'
{
"messages": 11,
"cycle": ["worker-a", "worker-b", "worker-a"],
"kinds": {"breaker": 1, "confirm": 8, "handoff": 2},
"by_agent": {
"supervisor": {"messages": 3, "tokens": 34, "failures": 1, "failure_rate": 0.3333},
"worker-a": {"messages": 4, "tokens": 100, "failures": 0, "failure_rate": 0.0},
"worker-b": {"messages": 4, "tokens": 100, "failures": 0, "failure_rate": 0.0}
}
}
kinds 里没有 result,只有 confirm、handoff 与一条 breaker 事件,这本身就是强信号:系统在通信,但没有交付。第三个问题:消息的因果链是否连续?把 confirm 的 causation_id 拉出来,从第 4 条起能看到每条都指向另一条 confirm,形成闭环:
jq -r 'select(.kind == "confirm")
| [.seq, .sender, .recipient, .causation_id] | @tsv' /tmp/ch08-runaway.jsonl
3 worker-a worker-b m002
4 worker-b worker-a m003
5 worker-a worker-b m004
6 worker-b worker-a m005
第四个问题:预算花在哪一层?把每个 agent 的 used/limit 与全局 spent 并排看,能看到「全局还剩 66,但 worker-a 的个人额度只剩 10」,下一次 25 token 的发送被拒绝。如果只看全局余额,会误判为「还有钱,为什么停了」;只看单 agent,会漏掉全局已经承诺出去的额度。
jq -r '[.sender, (.tokens // 0)] | @tsv' /tmp/ch08-runaway.jsonl \
| awk '{sum[$1]+=$2} END {for (a in sum) print a, sum[a]}' | sort
supervisor 34
worker-a 100
worker-b 100
这三行必须与实验输出的 budget.agents 对上;如果对不上,说明有支出没有写进消息日志,或者消息日志里有重复记录。预算是账本,不是估算;对不上账的 trace 不能用来做成本归因。
8.7.5 从 trace 判断「谁先错」
「谁先错」不是「谁最后说了错话」,而是「第一个错误假设在哪条消息进入系统,哪个检查本该拦住它却没有」。一个可执行的顺序:
- 按
correlation_id分组,按causation_id建图。 不要先按时间排序;先确定每条消息回应的是哪条。 - 找第一条「主张与引用不一致」的
result。 把payload.claim与refs指向的原文比对;如果 ref 已废弃、文本不支持主张,或refs为空,这里就是第一个可验证的错误断言。 - 沿
causation_id向上走。 看这条 result 是由哪条 handoff 触发的;handoff 的payload里是否已经带了错误假设、错误来源或缺失的验收条件。错误常常在 handoff 就进入了系统。 - 找第一个失效的 gate。 独立性检查、证据审计、
finish前置条件、预算检查——哪一个本应拦住但没有?把 gate 的输入字段与 trace 对上。 - 区分「错误断言」与「根因」。 断言是症状,handoff 的污染或 gate 的缺失是根因;只修断言,下一次换个说法还会错。
- 把最小复现写进回归集。 冻结这条消息序列,断言「没有 refs 的 decision 必须失败」「全票单一来源必须标记 groupthink」。第 9 章会把这类用例纳入评估集。
用这套顺序看 8.6.1:第一条不一致的 result 是 m003(claim 30s,ref 是已废弃 runbook);沿 causation_id 回到 m001,看到 hint 已经写了 30 秒;第一个失效的 gate 是「多数票没有检查来源独立性」。所以根因不是 worker-a,而是 m001 的提示泄漏与裁决规则。这个结论只有把 handoff、result、decision 三类消息放在同一张图上才能得出。
OpenTelemetry 的 GenAI 语义约定仍在演进,不同 collector/SDK 版本的属性名可能不同。可移植的做法是:先保证 trace_id、span_id、parent_span_id、links[].trace_id/span_id 这些基础字段正确,再谈 gen_ai.* 属性;不要为了让某个仪表盘好看,把 msg_id 塞进 parent_span_id。字段名以你实际部署的 SDK 版本文档为准。
8.8 实验:supervisor + 2 workers 的预算控制
experiments/ch08/multi_agent.py 把本章的机制压成一个无依赖、确定性的内核:一个 supervisor、两个 worker、一个共享预算账本、一条消息总线、一个冲突裁决器、一个熔断器。它不调用真实模型;worker 的行为是固定函数,因此同一个命令每次输出相同。这不是为了简化而简化,而是为了把失败模式钉死:只有确定性,才能用单元测试断言「预算不超」「熔断会开」「环能被检出」。
实验实现了三种模式:
| 模式 | 模拟什么 | 关键机制 | 预期结果 |
|---|---|---|---|
normal | 两个 worker 来源不同,1:1 平票 | 证据权重裁决 | 10s,evidence_weight |
groupthink | 两个 worker 复制同一提示与同一旧来源 | 独立性检查 + 证据覆盖 | 10s,evidence_override |
runaway | 两个 worker 互相等待确认 | 预算扣减 + 环检测 + 熔断 | 预算耗尽,检出环 |
下面的 JSON 为节省版面把每个 agent 对象折叠成一行,字段与数值均来自真实运行。
三个模式共用同一套账本实现(每个 run 各建一个实例):try_spend 在同一个锁里检查 agent 额度与全局额度,再同时扣减。消息的 tokens 字段包含「生产这条消息的模型回合 + 总线开销」,所以日志里的 token 汇总与账本一致。MessageBus 在锁内分配 seq,每条消息带 correlation_id 与 causation_id;deliver 用 msg_id 做消费者侧去重。
8.8.1 normal:平票时看证据
python3 experiments/ch08/multi_agent.py --mode normal --log /tmp/ch08-normal.jsonl \
| jq '{mode,answer,decision,votes,messages,stop_reason,budget,cycle,breaker}'
{
"mode": "normal",
"answer": "10s",
"decision": "evidence_weight",
"votes": {"10s": 1, "30s": 1},
"messages": 5,
"stop_reason": "done",
"budget": {
"agents": {
"supervisor": {"limit": 80, "remaining": 24, "used": 56},
"worker-a": {"limit": 110, "remaining": 78, "used": 32},
"worker-b": {"limit": 110, "remaining": 78, "used": 32}
},
"remaining": 180, "spent": 120, "total": 300
},
"cycle": [],
"breaker": {"failures": {}, "state": "closed", "trips": []}
}
worker-a 引用 doc/primary-2026(权重 0.95),主张 10s;worker-b 引用 doc/legacy-2024(权重 0.045),主张 30s。票数是 1:1,裁决器比较 conf × evidence_weight:0.76 对 0.027,差距 0.733,超过 0.2 的阈值,所以选 10s。这里没有「多数」,只有证据质量;如果两个 worker 都引用旧文档,就会进入 groupthink 的覆盖路径。
预算侧:supervisor 56(两次 handoff 各 17,decision 22),两个 worker 各 32(一次 result),合计 120。三个额度分别是 80/110/110,和 300 的总预算满足 80+110+110 = 300;实际只花了 120,剩余 180。注意这不是「平均分」:supervisor 的额度最小,因为它只做调度与裁决;worker 的额度更大,因为工具调用与重试主要发生在它们身上。
8.8.2 groupthink:全票但只有一条来源
python3 experiments/ch08/multi_agent.py --mode groupthink --log /tmp/ch08-groupthink.jsonl \
| jq '{answer,decision,votes,groupthink_suspected,resolution}'
{
"answer": "10s",
"decision": "evidence_override",
"votes": {"30s": 2},
"groupthink_suspected": true,
"resolution": {
"answer": "10s",
"audit": {
"best_cited_weight": 0.045,
"canonical_claim": "10s",
"canonical_ref": "doc/primary-2026",
"canonical_weight": 0.95,
"cited_refs": ["doc/legacy-2024"]
},
"decision": "evidence_override",
"distinct_refs": ["doc/legacy-2024"],
"groupthink_suspected": true,
"independent_sources": 1,
"reason": "unanimous_but_single_low_trust_source",
"scores": {"30s": 0.0414},
"votes": {"30s": 2}
}
}
原始票数是 2:0,按多数票应该输出 30s;裁决器没有直接采纳,因为 independent_sources=1,且这唯一来源的权重只有 0.045。它转向一手证据 doc/primary-2026,发现 10s,于是覆盖,并把 groupthink_suspected 标为 true。这个输出是本章最重要的一个反例:票数一致不是证据,来源独立才是。
注意覆盖不是「让另一个模型再投一次」。裁决器用的是证据表里的 status、weight 与 canonical ref,不调用模型;这也是它可测试、可复现的原因。
8.8.3 runaway:预算先于对话结束
python3 experiments/ch08/multi_agent.py --mode runaway --log /tmp/ch08-runaway.jsonl \
| jq '{mode,answer,stop_reason,stopped_agent,error,messages,cycle,budget,breaker}'
{
"mode": "runaway",
"answer": null,
"stop_reason": "budget_exhausted",
"stopped_agent": "worker-a",
"error": "worker-a: agent_envelope need=25 left=10",
"messages": 11,
"cycle": ["worker-a", "worker-b", "worker-a"],
"budget": {
"agents": {
"supervisor": {"limit": 80, "remaining": 46, "used": 34},
"worker-a": {"limit": 110, "remaining": 10, "used": 100},
"worker-b": {"limit": 110, "remaining": 10, "used": 100}
},
"remaining": 66, "spent": 234, "total": 300
},
"breaker": {
"failures": {},
"state": "open",
"trips": [{"agent": "worker-a", "failures": 0, "reason": "budget_exhausted"}]
}
}
两个 worker 各发了 4 条 confirm,每条 25 token,各用掉 100;第 5 条需要 25,但个人额度只剩 10,try_spend 返回 false,发送被拒绝。全局还剩 66 token,但 worker-a 已经不能再花——这正是「父子预算」与「全局预算」必须同时检查的原因。只看全局,会以为系统还有钱;只看个人,会漏掉全局承诺。
停止后,detect_cycle 从 handoff 与 confirm 边检出 worker-a → worker-b → worker-a。注意检测发生在停止之后:在这个实现里,总线只负责预算拦截,环检测是离线分析;生产系统应该在每次 handoff 后增量跑,把环当作早期告警,而不是等预算耗尽才发现。
8.8.4 三种模式的对比
| 指标 | normal | groupthink | runaway |
|---|---|---|---|
| 最终答案 | 10s | 10s(覆盖 30s) | 无 |
| 日志记录数 | 5 | 5 | 11 |
| token 支出 | 120 | 120 | 234 |
| 全局剩余 | 180 | 180 | 66 |
| 最紧的额度 | supervisor 24 | supervisor 24 | worker-a 10 |
| 裁决方式 | 证据权重 | 证据覆盖 | 无(预算终止) |
| 消息环 | 无 | 无 | 有 |
| 熔断状态 | closed | closed | open |
这张表说明什么:三种模式的 token 支出在同一量级(120 对 234),但失败形状完全不同。normal 与 groupthink 都「成功」结束,区别在答案是否被证据支持;runaway 没有答案,却留下了可诊断的环与额度记录。评估多智能体时,不能只看「是否返回」,还要看「返回是否被证据支持」与「失败是否可归因」。
8.8.5 测试:把不变量钉住
python3 -m unittest discover -s experiments/ch08 -v
test_child_envelopes_must_fit_parent ... ok
test_concurrent_spend_never_overspends ... ok
test_opens_after_threshold_and_blocks_work ... ok
test_duplicate_delivery_is_idempotent ... ok
test_seq_and_causation_link ... ok
test_cycle_detector_handles_self_loop ... ok
test_normal_budget_and_cycle ... ok
test_runaway_stops_on_budget_and_detects_cycle ... ok
test_groupthink_is_overridden_by_primary_evidence ... ok
test_majority_with_independent_sources ... ok
test_tie_is_resolved_by_evidence_weight ... ok
----------------------------------------------------------------------
Ran 11 tests in 0.001s
OK
十一个测试里,四个直接对应本章的边界:并发扣减不超卖、重复投递不重复执行、熔断打开后拒绝派活、runaway 在预算耗尽时停止并检出环。它们不证明策略「聪明」,只证明机制没有在实现里被绕过;策略质量要靠第 9 章的评估集。
8.8.6 三组可以自己改的对照
| 对照 | 改什么 | 观察什么 | 预期 |
|---|---|---|---|
| 额度分配 | 把 worker 额度改成 90/90 | 哪一步先停 | 更早触发 agent_envelope,总支出更低 |
| 消息上限 | 把 max_messages 从 20 改成 6 | stop_reason | 先撞 message_budget,环仍被检出 |
| 提示独立性 | 去掉 groupthink 的 hint,两个 worker 仍拿同一来源 | 票数与裁决 | 仍是 2:0;仍被一手证据覆盖,说明同源比提示更关键 |
这三组对照都只改一个变量。同时改额度、提示与来源,得到的差异无法归因;这也是第 9 章评估设计的前置条件。
第一,worker 是确定性函数,没有真实模型的采样方差;它适合验证机制,不适合比较模型能力。第二,tokens 是简化的记账单位,不是 tokenizer 计数。第三,总线在进程内,seq 是单总线全序;跨进程要改成 per-sender 序号加 causation_id。第四,熔断只有 closed/open 两态,生产实现还要 half-open、冷却时间与按 capability 隔离。四点都是为了让实验可复现、零依赖;真实系统的数值要用真实模型与真实负载复验。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 《Linux 系统与内核》第 2 篇 | 14 管道、FIFO 与消息队列 | 8.3 消息边界、mq_open 原型 | 交叉引用 |
| 《Linux 系统与内核》第 2 篇 | 15 共享内存与信号量 | 8.3 共享黑板、8.5 预算锁 | 交叉引用 |
| 《Linux 系统与内核》第 2 篇 | 13 线程同步与线程安全 | 8.5 原子扣减与竞态 | 交叉引用 |
| 《Linux 系统与内核》手册 | 4 mq_open(3)、5 sem_open(3) | 8.3、8.5 variadic 原型勘误 | 交叉引用 |
| AutoGen(Wu et al., 2023) | 多 agent 对话框架 | 8.2 supervisor/peer 的方法对照 | 背景 |
| CAMEL(Li et al., 2023) | 角色扮演协作 | 8.2 角色契约的对照 | 背景 |
| ChatDev(Qian et al., 2023) | 角色化软件开发 | 8.2 角色契约与多阶段协作的对照 | 背景 |
| Self-consistency(Wang et al., 2022) | 多采样投票 | 8.4 投票与独立性边界 | 完整 |
| LLM-as-a-judge(Zheng et al., 2023) | 模型评审 | 8.4 裁判偏差与 rubric | 背景 |
| W3C Trace Context / OTel GenAI | 跨服务追踪、GenAI span | 8.7 span link 与 trace 传播 | 背景 |
这张表说明什么:本章对 OS 母本只做交叉引用,不重写管道、消息队列、信号量的原理;对多 agent 框架只做方法对照,不把 AutoGen 或 CAMEL 的 API 搬进来。真正新增的内容是 agent 层的契约:角色、消息、冲突、预算、观测。把它们写成宿主可检查的字段,比选哪个框架更重要。
本章陷阱
| # | 陷阱 | 症状 | 正确做法 | 去哪 |
|---|---|---|---|---|
| 1 | 把多 agent 当默认答案 | 任务没变快,账单与排障时间翻倍 | 先算 与 ,过不了门槛就用单 agent/工作流 | 8.1 |
| 2 | 用角色提示词代替角色契约 | 每个 agent 都能做所有事,没人负责验收 | 九字段契约:目标、工具、上下文、权限、验收、预算 | 8.2 |
| 3 | supervisor 把假设写进 handoff | worker 复述假设,投票全票通过 | handoff 只给任务与来源约束,不给结论性提示 | 8.6 |
| 4 | 把全票当独立确认 | 多个 agent 引用同一份旧文档 | 检查引用/模型/提示词/来源的独立性 | 8.4、8.6 |
| 5 | 用投票裁决事实冲突 | 错误多数覆盖一手证据 | 证据权重优先,投票只处理独立判断 | 8.4 |
| 6 | handoff 没有 acceptance | 「做完了」没有可检查的证据 | 验收条件写成命令/schema/引用回切 | 8.3、8.6 |
| 7 | 消息没有 correlation/causation id | 无法把多轮消息拼成因果链 | 信封十个字段;msg_id、correlation_id、causation_id 齐全 | 8.3、8.7 |
| 8 | 用 msg_id 当业务幂等键 | 重试生成新 msg_id,副作用执行两次 | 业务幂等键 = 任务 + handoff + 动作 + 规范化参数 | 8.3 |
| 9 | 子 agent 各自记账 | 并发下两个 agent 都认为最后一笔额度属于自己 | 单一账本 + 锁内检查扣减 + 父子额度同时检查 | 8.5 |
| 10 | 熔断只停调度,不取消在飞调用 | 已经打开的熔断仍在烧 token | 取消未完成调用、回收预留、保留部分结果 | 8.5 |
| 11 | 无限对话没有消息预算与环检测 | 消息数正常,墙钟一直涨,没有 result | 唯一 owner + 消息上限 + 增量环检测 + deadline | 8.5、8.6 |
| 12 | 只看最后一条消息判断谁先错 | 责任归给最后发言者,真凶在 handoff | 沿 causation_id 回溯到第一个错误假设与失效 gate | 8.7 |
这张表说明什么:十二个陷阱里,只有第 1 条是「该不该做」,其余都是「做了但契约缺字段」。它们不会因为换一个更强的模型而消失;模型越强,群体幻觉与责任稀释越有说服力,因为错误看起来更合理。
本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 能判断是否需要多 agent | 用 、、 算一次门槛,并给出单 agent/工作流的替代 | 8.1 |
| 能选拓扑并指定终止 owner | 四种拓扑各说出控制流、故障模式、观测形状 | 8.2 |
| 能写角色契约 | 九字段齐全,含权限、验收、预算、升级路径 | 8.2 |
| 能写消息信封 | 十个字段齐全;能解释三种投递语义与幂等键 | 8.3 |
| 能写结构化 handoff | owner、deliverable、acceptance、deadline、budget、refs、escalation 齐全 | 8.3 |
| 能处理冲突 | 按「规则→证据→投票→裁判→人」排序,并检查独立性 | 8.4 |
| 能检测死锁 | 画出 wait-for 图,给出边级 deadline 与环上抢占策略 | 8.4 |
| 能设计预算树 | 子预算之和不超过父预算;扣减原子;熔断能隔离 | 8.5 |
| 能从 trace 定位第一个错误 | 用 correlation_id/causation_id 回溯,区分断言与 gate | 8.7 |
| 能跑实验 | 三种模式 + 11 个测试全过;能解释 normal 与 runaway 的差异 | 8.8 |
Q1 一个任务 60% 可并行,计划用 4 个 agent,协调开销估计为单 agent 时间的 0.08 倍。按 定理 8.1.1,它能加速吗?加速上界大约是多少?
Q2 五个 agent 全票回答 30 秒,都引用同一份 2024 年的旧 runbook;另一个一手文档写 10 秒。系统应该信谁?如果五个 agent 分别引用五份独立文档,结论会怎样变化?
Q3 全局预算还剩 66 token,worker-a 的个人额度只剩 10,下一条消息需要 25 token。为什么它被拒绝?这说明预算检查应该放在哪几层?
答案
A1 能,但余量很小。,,;门槛是 ,0.08 满足。加速比上界为 。也就是说,最多快 27%;如果真实协调开销比估计高一点,收益就会被吃掉。这个数字应该拿去和「合并、验证、排障」的额外工时对比,而不是只看墙钟。
A2 应该先信一手文档,除非旧 runbook 能提供更强的反证。五个 agent 全票但引用同一来源时,独立性假设不成立,全票只是同一错误的五次复述;按 定理 8.4.1,共模错误下增加票数不降低错误率。若五份文档真正独立、来源可信且可核验,多数票才有信息量;即便如此,也要检查它们是否都引用了同一个上游。
A3 因为预算不只受全局总数约束,还受 agent 额度约束;try_spend 必须在同一临界区同时检查「agent 剩余 ≥ 25」与「全局剩余 ≥ 25」。worker-a 的额度只剩 10,所以即使全局还有 66,也必须拒绝。检查要放在两层:agent 层防止单个 agent 超支,全局层防止所有 agent 合起来超支;父 agent 还要在派活时预留,避免子 agent 在运行期间被兄弟节点抢走额度。
下一站:第 9 章《评估》 回答一个更冷的问题:多智能体带来的提升,究竟是真实收益,还是采样方差与评估口径的错觉。