Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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,再排除工作流

多智能体是第三个选项,不是第一个。一个可操作的排除顺序是:

  1. 单 agent 能不能做? 一个上下文放得下任务状态,工具集不超过模型稳定选择的范围,串行步骤在预算内。能,就用单 agent。它的通信成本是零,排障路径最短。
  2. 工作流能不能做? 步骤可以由代码预先编排,模型只在固定节点做判断。能,就用工作流。它的控制流可审计,重试与补偿容易实现;代价是灵活性低。
  3. 只有当前两步都不成立时,才考虑多 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 多智能体的定义:独立控制循环,不是多个角色名

定义 8.1.1 · 多智能体系统

一个多智能体系统是一个六元组

M=(A, R, T, M, B, G) \mathcal{M} = (A,\ R,\ T,\ M,\ B,\ G)

其中:

  • A={a1,…,an}A = \{a_1,\ldots,a_n\} 是 agent 集合,n≥2n \ge 2,每个 aia_i 都有独立的控制循环、自己的上下文与预算额度;
  • RR 是角色契约集合,规定每个 agent 的目标、工具、可读数据、权限、输出格式与验收条件;
  • TT 是拓扑,决定谁可以向谁发消息、谁负责终止;
  • MM 是消息协议,至少定义发送者、接收者、序号、关联 id、载荷与引用;
  • BB 是预算树,根预算分配给各 agent,子预算之和不超过父预算;
  • GG 是共享目标与验收条件,所有 agent 的局部目标必须能追溯到 GG。

两个判据把它与「一个 agent 扮演多个角色」区分开:控制循环独立(一个 agent 卡住时,另一个仍在推进)与消息可观测(跨 agent 的每次交互都有编号与因果边)。只在一个 prompt 里写「你是产品经理,你是工程师」,没有独立循环,也没有消息协议,那不是多智能体,是角色扮演。

这个定义故意把 RR 与 TT 分开。角色回答「你是谁、你能做什么」,拓扑回答「谁能指挥谁、谁负责收尾」。很多实现只写了角色提示词,没有写拓扑,于是每个 agent 都以为自己可以指挥别人,也都以为别人会收尾——第 8.6 节的责任稀释就是这样发生的。

8.1.3 收益门槛:一个可算的判据

把并行收益写成公式,可以避免「感觉会更快」的争论。设单 agent 完成时间为 T1T_1,任务中可并行部分占比为 pp,使用 nn 个 agent,每增加一个 agent 带来的协调开销是单 agent 时间的 κ\kappa 倍。在理想情况下,墙钟时间的下界是

Tn≥T1((1−p)+pn+κ(n−1)). T_n \ge T_1\left((1-p) + \frac{p}{n} + \kappa(n-1)\right).

括号里三项分别是串行部分、并行部分和协调开销。这个模型很乐观:它假设子任务完美均衡、合并免费、消息不丢。现实只会更差,所以它适合当排除门槛,不适合当性能预测。

定理 8.1.1 · 并行收益的门槛

在上式的前提下,使用 n≥2n \ge 2 个 agent 能缩短墙钟时间,当且仅当

κ<pn. \kappa \lt \frac{p}{n}.

若 κ≥p/n\kappa \ge p/n,增加 agent 不会带来任何加速;当 κ\kappa 足够大时,TnT_n 甚至大于 T1T_1。

证明

令 D(n)=(1−p)+p/n+κ(n−1)D(n) = (1-p) + p/n + \kappa(n-1)。加速比 S(n)=T1/Tn≤1/D(n)S(n) = T_1/T_n \le 1/D(n)。S(n)>1S(n) \gt 1 等价于 D(n)<1D(n) \lt 1,即

pn+κ(n−1)<p. \frac{p}{n} + \kappa(n-1) \lt p.

移项得 κ(n−1)<p(1−1/n)=p(n−1)/n\kappa(n-1) \lt p(1 - 1/n) = p(n-1)/n。因为 n≥2n \ge 2,两边除以 n−1n-1 得 κ<p/n\kappa \lt p/n。反向推导同样成立。∎

∎

这个定理的工程价值在于把「要不要拆」变成三个可估的量:可并行比例 pp、agent 数 nn、协调开销 κ\kappa。一个 20% 可并行的任务,拆成 4 个 agent 时要求 κ<0.05\kappa \lt 0.05——也就是协调开销低于单 agent 时间的 5%。大多数需要反复 handoff、对齐格式、裁决冲突的系统,κ\kappa 远高于 5%。这就是为什么很多多 agent 系统在 demo 里更快,在生产里更慢。

多智能体像加人,不像加 CPU

加 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 回合与工具调用记,不能按消息条数记;否则会在最该刹车的地方显示「还很宽裕」。

关于多智能体的五个误解
  1. 「多 agent 一定比单 agent 强」——只在并行、分工、独立对抗或隔离成立时强。前提不成立时,它只是把同一个错误复制多份。
  2. 「角色提示词不同就是多 agent」——没有独立控制循环、没有消息协议、没有独立预算,仍然是一个 agent 在扮演多个角色。
  3. 「并行一定更快」——串行部分与协调开销会吃掉收益;按 定理 8.1.1,κ≥p/n\kappa \ge p/n 时加速为零甚至为负。
  4. 「多 agent 会自动互相纠错」——同模型、同上下文、同证据的 agent 会给出高度相关的错误;独立性是设计出来的,不是数量带来的。
  5. 「多 agent 更安全」——它增加了数据副本、信任边界与消息通道;没有隔离与最小权限时,攻击面只会更大。

8.1.5 一个够用的决策流程

把上面的内容压成五步,可以在设计评审时逐条过:

  1. 写清单 agent 的失败模式。 是上下文放不下、工具太多、步骤太长,还是缺少独立验证?不同病因对应不同药方。
  2. 估 pp 与 κ\kappa。 pp 用「可并行子任务的工时占比」估;κ\kappa 用「一次 handoff 的往返时间 / 单 agent 总时间」估。估不准就取保守值。
  3. 检查独立性。 子任务之间是否真的不需要对方的中间结果?需要,就不是并行,是串行依赖。
  4. 检查验证器。 每个子任务的输出有没有可机器检查的验收条件?没有,合并时只能靠另一个模型「看看对不对」。
  5. 先做两 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 角色契约:角色不是一句人设

定义 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 份报告。

它的第一个硬边界是扇出。分支因子为 bb、深度为 dd 的树,节点总数是

N=bd+1−1b−1. N = \frac{b^{d+1}-1}{b-1}.

b=4,d=3b=4, d=3 时是 85 个 agent;b=5,d=4b=5, d=4 时是 781 个。每层都要付一次模型调用与一次汇总成本,预算不是线性增长,而是按节点数增长。设计 hierarchy 时先算 NN,再算每个节点的平均预算;如果单节点预算低于一次有意义的模型调用,这棵树只是把噪声放大。

第二个边界是责任稀释。根把任务交给中层,中层交给 worker,每一层都以为「验收是下一层的事」。第 8.6.3 节的案例里,三个 agent 都认为测试是别人跑的,最后没有人跑测试。修复不是加一个「测试 agent」,而是把验收条件写进每个 handoff,并指定唯一 owner。

hierarchy 的观测必须带层级与分支因子:只看总 token 会漏掉某一层的扇出爆炸;只看根 span 会看不到叶子层的失败。每个节点至少记录 parent_agent、depth、children_count、budget_envelope 与 aggregation_rule。

8.2.6 怎样选:三个问题

不需要背拓扑定义,问三个问题就够了:

  1. 任务能不能切成互不依赖的块? 能,且块很多,考虑 hierarchy 或 supervisor + 并行 worker;不能,回到单 agent 或串行工作流。
  2. 有没有一个天然的终止 owner? 有,用 supervisor/hierarchy,把终止权写给它;没有,先造一个看门狗或裁判,再谈 peer/debate。
  3. 错误能不能独立? 能(不同证据、不同工具、不同验证器),对抗才有意义;不能,debate 只是多烧 token。

三个问题都指向「不要为了拓扑而拓扑」。一个两 agent 的 supervisor + verifier,往往比一个五 agent 的 hierarchy 更容易验收,也更容易在出事时定位。

关于角色与拓扑的五个误解
  1. 「supervisor 是唯一正确的拓扑」——它在预算与终止上最清晰,但在探索性、同质任务上会成为瓶颈;peer 与 debate 有各自的适用面。
  2. 「层级越深越专业」——每深一层,信息多一次摘要损失、预算多一次切分、责任多一次转手;深度是成本,不是能力。
  3. 「debate 一定提升正确率」——只有论据独立时才提升;同源论据会把同一个错误论证得更自信。
  4. 「peer 更民主所以更稳」——没有看门狗与账本时,peer 更容易出现消息环、重复工作与脑裂。
  5. 「给角色起个名字就是分工」——分工的最小单位是权限、上下文与验收条件;名字只是标签。

8.3 通信:消息协议、共享黑板与结构化 handoff

多智能体的通信常被简化成「把一段文字发给另一个 agent」。这在 demo 里能跑,在生产里会遇到四个问题:消息重复投递导致副作用执行两次;接收者不知道这条消息属于哪个任务;两个 agent 同时写同一块状态,后写的覆盖先写的;handoff 只写了「请处理」,没写「交付什么、什么算完成」。

解决这四个问题不需要一套新的传输层。需要的是三层契约:信封(这条消息是谁发给谁、属于哪个任务、序号是多少)、载荷(这类消息的 schema 与引用)、投递语义(重复、丢失、乱序时怎么办)。传输可以是 HTTP、队列、共享文件,甚至 POSIX 消息队列;契约必须在传输之上。

8.3.1 消息信封:十个必填字段

定义 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 要排序、去空白、去掉不影响语义的字段;否则同一个操作会因为参数顺序不同而得到两个键。

例题 8.3.1 · 一次重复投递如何变成两次写入

触发条件。 消息总线是 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)4mode, attr(可为 NULL)
sem_open(name, 0)2无
sem_open(name, O_CREAT, 0600, 1)4mode, 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 协议的一部分;本书只在这里做一次纠正,不展开消息队列的优先级、阻塞与通知机制。

POSIX IPC 在 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 章),否则它只是另一种措辞自信度。

多数票的理论保证依赖一个强假设:每个投票者独立地以小于 1/21/2 的概率犯错。满足时,票数越多,多数错误率指数下降;不满足时,多数票会把共同错误放大。

定理 8.4.1 · 独立投票与共模错误

设有 nn 个投票者,每个投票者独立犯错概率至多 ϵ<1/2\epsilon \lt 1/2。多数票犯错的概率至多

exp⁡(−2n(12−ϵ)2). \exp\left(-2n\left(\frac{1}{2}-\epsilon\right)^2\right).

若存在一个共模错误事件,它以概率 γ\gamma 让所有投票者同时犯错,则多数票的犯错概率至少为 γ\gamma,与 nn 无关。

证明

第一部分是 Hoeffding 不等式:错误票数超过 n/2n/2 的概率被指数尾界控制。第二部分是事件包含:共模事件发生时,所有票都错,多数票必错;因此多数错误概率不小于 γ\gamma。∎

∎

这个定理解释了为什么「五个 agent 都同意」可能毫无价值:如果它们共享同一段被污染的检索结果,γ\gamma 接近 1,增加票数不会降低错误率。反过来,如果五个 agent 用不同来源、不同工具、不同验证器,γ\gamma 很小,多数票才有意义。

工程上要检查的不是票数,而是独立性证据:引用集合是否有交集?模型是否相同?提示词是否共享了同一个假设?检索源是否同一批文档?四个问题里任意一个答案为「是」,就要把「一致」降级为「相关」,不能当作独立确认。

8.4.2 证据权重:给来源、时效与可核验性打分

当冲突是事实冲突时,比票数更可靠的是证据。给每条证据一个三元组:

定义 8.4.1 · 证据权重

对证据 ee,定义

w(e)=τ(e)⋅r(e)⋅v(e), w(e) = \tau(e)\cdot r(e)\cdot v(e),

其中 τ(e)∈[0,1]\tau(e)\in[0,1] 是来源可信度,r(e)∈[0,1]r(e)\in[0,1] 是时效,v(e)∈[0,1]v(e)\in[0,1] 是可核验性。对一个主张 cc,其权重为

W(c)=conf(c)⋅max⁡e∈Ecw(e), W(c) = \text{conf}(c)\cdot \max_{e\in E_c} w(e),

EcE_c 是支持 cc 的证据集合,conf(c)\text{conf}(c) 是提出者的置信度。三个因子相乘而不是相加,是为了让任一维接近零时整体接近零:一条无法回切原文的“证据”,再新也不该有高权重。

三个因子的取值要有依据,不能由提出者自报:

因子高低谁来赋值
可信度 τ\tau一手手册、官方 schema、用户确认匿名博客、模型转述、旧邮件来源注册表,不由模型改写
时效 rr当前版本、未过期已废弃、被新版本取代版本字段与 valid_to
可核验性 vv能回切原文、能跑测试只有结论、没有引用引用检查器/测试执行器

这张表说明什么:证据权重是宿主侧的判定,不是让模型给自己的证据打分。模型可以提出主张,但不能同时定义「什么算可信」;否则它会把自己的先验写成证据权重。

权重也有边界。它处理不了三种情况:证据本身是伪造的(需要来源认证)、证据之间是语义矛盾而非数值冲突(需要人读)、所有证据都来自同一上游(需要独立采集)。这三种情况要靠采集多样性、引用审计与人工复核,不是调权重参数。

8.4.3 仲裁:规则、裁判与人

投票与证据权重都处理不了所有冲突,剩下三类仲裁:

  1. 规则仲裁。 预先写死的约束优先:schema 不合法直接淘汰;违反硬约束的方案直接淘汰;资源租约未拿到不能写。规则仲裁最便宜、最可审计,应放在最前面。
  2. 裁判仲裁。 一个独立的 judge 按 rubric 比较候选,必须引用证据、必须能输出「证据不足」。judge 与被评 agent 最好不同模型、不同上下文;同模型 judge 会偏好自己的措辞。
  3. 人工仲裁。 不可逆动作、高影响结论、规则与裁判都无法收敛时,转人。人工不是失败路径,是正常的终止理由;关键是给出结构化摘要,而不是把 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→BA \to B 表示 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 条消息的环要等到预算耗尽才被发现。

方法 8.4.1 · 冲突处理五步
  1. 分类。 这是事实、计划、资源、偏好还是语义冲突?分类决定机制,不分类就会用投票处理偏好。
  2. 查独立性。 引用、模型、提示词、检索源是否有交集?有交集,先补独立证据,再谈投票。
  3. 走最便宜的机制。 规则能挡就规则;规则挡不住用证据权重;证据不足再投票;还不行上裁判;不可逆或高影响转人。
  4. 记录理由与异议。 裁决必须带 decision、evidence_refs、dissent;没有异议记录的“一致通过”无法复盘。
  5. 设超时与升级。 每个等待边有 deadline;超时后按预设路径升级,不静默重试。

这五步的顺序本身就是成本控制:越靠前的机制越便宜、越可审计。多数团队的问题不是缺少裁判,而是跳过了第 1、2 步,直接用裁判处理所有分歧。

8.5 预算:传播、熔断与终止

单 agent 的预算是一个循环里的计数器;多 agent 的预算是一个树。根节点持有总预算,每个子节点拿到一个额度,子节点还可以再分给它的子节点。预算沿着 handoff 向下传播,支出沿着调用链向上汇总。树的结构决定了三件事:谁能花多少、花超了谁负责、以及一个子树失败时怎样不影响其他子树。

多智能体的预算事故很少是「总额算少了」,而是「层级搞混了」:父子各自记账,都以为自己还有额度;子 agent 超预算后继续跑,因为它的检查只看自己的局部计数;重试与重放没有计入预算;熔断时只停了调度,没有取消已经在飞的调用。这一节把这些边界写成可检查的约束。

8.5.1 预算树:子预算之和不得超过父预算

定义 8.5.1 · 预算树

一棵预算树给每个 agent vv 分配额度 b(v)b(v),并满足对任意非叶节点:

∑c∈children(v)b(c)+r(v)≤b(v), \sum_{c\in \text{children}(v)} b(c) + r(v) \le b(v),

其中 r(v)≥0r(v)\ge 0 是父节点保留的准备金,用于冲突裁决、验证与收尾。每个 agent 的实际支出 s(v)s(v) 满足 s(v)≤b(v)s(v)\le b(v);整棵树的支出满足

∑vs(v)≤b(root). \sum_{v} s(v) \le b(\text{root}).

额度可以回收,但只能回收未使用的部分;已经承诺给某个子节点的额度,不能在它运行期间挪给兄弟节点,否则兄弟的「额度保证」就是假的。

准备金 r(v)r(v) 是多智能体特有的项。单 agent 可以把全部预算给探索;父 agent 必须留一部分给验证、合并、冲突裁决和失败收尾,否则子 agent 把额度花光后,父 agent 连「判断结果是否可信」的钱都没有。一个实用的起点是:验证与裁决准备金不低于父预算的 15%,失败重试准备金不低于 10%。

8.5.2 守恒:为什么扣减必须原子

定理 8.5.1 · 预算守恒

若每次支出都调用同一个 try_spend(agent, x),该函数在同一个临界区内完成「检查 s(agent)+x≤b(agent)s(agent)+x\le b(agent)、检查全局 S+x≤b(root)S+x\le b(\text{root})、扣减」三步,且初始时预算树满足定义 8.5.1 的约束,则任意并发执行结束后仍有 S≤b(root)S\le b(\text{root}),且每个 agent 的支出不超过其额度。

证明

把每次成功的 try_spend 看成一次原子事件。临界区保证「检查」与「扣减」之间没有其他事件插入,因此每次成功扣减前的不等式在扣减后仍然成立。对事件数做归纳:初始 S=0S=0 满足;若第 kk 次成功后 Sk≤BS_k\le B,第 k+1k+1 次成功的前提是 Sk+x≤BS_k+x\le B,扣减后 Sk+1=Sk+x≤BS_{k+1}=S_k+x\le B。单 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 则在超支时才发现无法收回;把预算当租约,双方都知道边界、期限与回收条件。

关于预算的五个误解
  1. 「预算就是总 token」——token 只是五分之一;工具调用、墙钟、消息数、金额都可能先耗尽,预算必须是向量。
  2. 「每个 agent 自己记账就行」——局部记账无法保证全局守恒;并发下会出现两个 agent 都认为最后一笔额度属于自己。
  3. 「超预算就杀掉,钱自然回来」——已经花掉的 token 不会回来;杀进程只停止继续花,不会恢复已消费的额度。
  4. 「重试不花钱」——重试消耗预算、放大尾延迟,还可能重复副作用;重试必须有独立上限与幂等键。
  5. 「预算不够就事后补」——补预算等于取消终止条件;需要延长时,应由人批准一个新的 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}}}
例题 8.6.1 · 全票一致的错误

触发条件。 supervisor 把自己的假设写进 handoff;两个 worker 被分配到同一份来源;裁决规则只看票数,不看来源独立性。

观测证据。 两条 handoff 的 refs 完全相同;两条 result 的 refs 也相同;causation_id 分别指回两条带同样 hint 的 handoff;证据表里 doc/legacy-2024 的 status=superseded,而 doc/primary-2026 写着 10 秒。四个字段一摆,结论确定:两次「确认」是对同一个提示的两次复述,不是两次独立取证。

根因。 不是 worker 笨,是 supervisor 同时做了三件错事:把假设泄漏给执行者、给两个执行者同一份证据、把「全票」当成「独立确认」。这里 γ\gamma 接近 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 确认收到,收到后再回复确认"}}
例题 8.6.2 · 没有 owner 的确认链

触发条件。 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":[]}}
例题 8.6.3 · 没有人跑测试

触发条件。 三个角色的 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生命周期包含;父等待子
异步 handofflink因果关系;不包含生命周期
重试/重放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 判断「谁先错」

「谁先错」不是「谁最后说了错话」,而是「第一个错误假设在哪条消息进入系统,哪个检查本该拦住它却没有」。一个可执行的顺序:

方法 8.7.1 · 从 trace 定位第一个错误
  1. 按 correlation_id 分组,按 causation_id 建图。 不要先按时间排序;先确定每条消息回应的是哪条。
  2. 找第一条「主张与引用不一致」的 result。 把 payload.claim 与 refs 指向的原文比对;如果 ref 已废弃、文本不支持主张,或 refs 为空,这里就是第一个可验证的错误断言。
  3. 沿 causation_id 向上走。 看这条 result 是由哪条 handoff 触发的;handoff 的 payload 里是否已经带了错误假设、错误来源或缺失的验收条件。错误常常在 handoff 就进入了系统。
  4. 找第一个失效的 gate。 独立性检查、证据审计、finish 前置条件、预算检查——哪一个本应拦住但没有?把 gate 的输入字段与 trace 对上。
  5. 区分「错误断言」与「根因」。 断言是症状,handoff 的污染或 gate 的缺失是根因;只修断言,下一次换个说法还会错。
  6. 把最小复现写进回归集。 冻结这条消息序列,断言「没有 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 三类消息放在同一张图上才能得出。

span link 的版本边界

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 三种模式的对比

指标normalgroupthinkrunaway
最终答案10s10s(覆盖 30s)无
日志记录数5511
token 支出120120234
全局剩余18018066
最紧的额度supervisor 24supervisor 24worker-a 10
裁决方式证据权重证据覆盖无(预算终止)
消息环无无有
熔断状态closedclosedopen

这张表说明什么:三种模式的 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 改成 6stop_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 span8.7 span link 与 trace 传播背景

这张表说明什么:本章对 OS 母本只做交叉引用,不重写管道、消息队列、信号量的原理;对多 agent 框架只做方法对照,不把 AutoGen 或 CAMEL 的 API 搬进来。真正新增的内容是 agent 层的契约:角色、消息、冲突、预算、观测。把它们写成宿主可检查的字段,比选哪个框架更重要。

本章陷阱

#陷阱症状正确做法去哪
1把多 agent 当默认答案任务没变快,账单与排障时间翻倍先算 pp 与 κ\kappa,过不了门槛就用单 agent/工作流8.1
2用角色提示词代替角色契约每个 agent 都能做所有事,没人负责验收九字段契约:目标、工具、上下文、权限、验收、预算8.2
3supervisor 把假设写进 handoffworker 复述假设,投票全票通过handoff 只给任务与来源约束,不给结论性提示8.6
4把全票当独立确认多个 agent 引用同一份旧文档检查引用/模型/提示词/来源的独立性8.4、8.6
5用投票裁决事实冲突错误多数覆盖一手证据证据权重优先,投票只处理独立判断8.4
6handoff 没有 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 + 消息上限 + 增量环检测 + deadline8.5、8.6
12只看最后一条消息判断谁先错责任归给最后发言者,真凶在 handoff沿 causation_id 回溯到第一个错误假设与失效 gate8.7

这张表说明什么:十二个陷阱里,只有第 1 条是「该不该做」,其余都是「做了但契约缺字段」。它们不会因为换一个更强的模型而消失;模型越强,群体幻觉与责任稀释越有说服力,因为错误看起来更合理。

本章出口检查

检查项达标线没达标去哪
能判断是否需要多 agent用 pp、nn、κ\kappa 算一次门槛,并给出单 agent/工作流的替代8.1
能选拓扑并指定终止 owner四种拓扑各说出控制流、故障模式、观测形状8.2
能写角色契约九字段齐全,含权限、验收、预算、升级路径8.2
能写消息信封十个字段齐全;能解释三种投递语义与幂等键8.3
能写结构化 handoffowner、deliverable、acceptance、deadline、budget、refs、escalation 齐全8.3
能处理冲突按「规则→证据→投票→裁判→人」排序,并检查独立性8.4
能检测死锁画出 wait-for 图,给出边级 deadline 与环上抢占策略8.4
能设计预算树子预算之和不超过父预算;扣减原子;熔断能隔离8.5
能从 trace 定位第一个错误用 correlation_id/causation_id 回溯,区分断言与 gate8.7
能跑实验三种模式 + 11 个测试全过;能解释 normal 与 runaway 的差异8.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 能,但余量很小。p=0.6p=0.6,n=4n=4,κ=0.08\kappa=0.08;门槛是 κ<p/n=0.15\kappa \lt p/n = 0.15,0.08 满足。加速比上界为 1/((1−0.6)+0.6/4+0.08×3)=1/(0.4+0.15+0.24)=1/0.79≈1.271/((1-0.6)+0.6/4+0.08\times3)=1/(0.4+0.15+0.24)=1/0.79\approx1.27。也就是说,最多快 27%;如果真实协调开销比估计高一点,收益就会被吃掉。这个数字应该拿去和「合并、验证、排障」的额外工时对比,而不是只看墙钟。

A2 应该先信一手文档,除非旧 runbook 能提供更强的反证。五个 agent 全票但引用同一来源时,独立性假设不成立,全票只是同一错误的五次复述;按 定理 8.4.1,共模错误下增加票数不降低错误率。若五份文档真正独立、来源可信且可核验,多数票才有信息量;即便如此,也要检查它们是否都引用了同一个上游。

A3 因为预算不只受全局总数约束,还受 agent 额度约束;try_spend 必须在同一临界区同时检查「agent 剩余 ≥ 25」与「全局剩余 ≥ 25」。worker-a 的额度只剩 10,所以即使全局还有 66,也必须拒绝。检查要放在两层:agent 层防止单个 agent 超支,全局层防止所有 agent 合起来超支;父 agent 还要在派活时预留,避免子 agent 在运行期间被兄弟节点抢走额度。

下一站:第 9 章《评估》 回答一个更冷的问题:多智能体带来的提升,究竟是真实收益,还是采样方差与评估口径的错觉。