AGAgent 学习路线
第 15 / 20
CHAPTER 15 · GPT 生成学习页

Teammate + Inbox

让持续队友保留身份和历史,通过持久 Mailbox 异步通信。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

Teammate + Inbox

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 一次性 Subagent 与持续 Teammate 的生命周期不同。
  • 消息投递不等于协议完成。
  • 公共 Loop 负责消费事件,队友拥有自己的历史。
1Lead 创建队友
2Mailbox 持久消息
3typed event 注入
4队友持续工作
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

20 个小节20 组代码22 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

01导读:问题背景与本章目标这句话说起来轻松,但真正踩到这个边界时,体感完全不同。一个后端重构任务同时涉及认证、数据库、API 路由和测试,单个 Agent 处理到后半程,前半程的细节已经离开上下文。它不是突然变笨,而是物理上看不见了。

上下文窗口就那么大。

这句话说起来轻松,但真正踩到这个边界时,体感完全不同。一个后端重构任务同时涉及认证、数据库、API 路由和测试,单个 Agent 处理到后半程,前半程的细节已经离开上下文。它不是突然变笨,而是物理上看不见了。

第 6 章的一次性子 Agent 可以把独立任务放进干净上下文,完成后只把结果带回 Lead。但有些工作不是“一问一答”:队友需要保留自己的历史,接收后续补充,异步汇报进展,并在完成一轮工作后继续存在。

第 15 章实现的就是这组最小闭环:持续 Teammate、持久 Mailbox,以及把 Mailbox 消息重新送入公共 Agent Loop 的 typed event 通道。

图片
图片

02问题的本质:协作不是多开几个模型调用一次性子 Agent 和持续队友的差别,不在于有没有并发,而在于身份、历史、通信和生命周期由谁持有。

一次性子 Agent 和持续队友的差别,不在于有没有并发,而在于身份、历史、通信和生命周期由谁持有。

边界一次性子 Agent第 15 章 Teammate
生命周期完成一次委派后返回完成后进入 idle,可接收后续消息
对话历史每次委派独立每个名字绑定一个独立 AgentRunner,后续继续复用
通信返回最终结果通过持久 Mailbox 双向发送文本消息
公开工具Lead 的 taskLead 的 spawn_teammatesend_message
状态运行或结束runningidlefailedshutdown

持续存在不能只靠一个进程内 dict。如果消息读取后直接删除,Agent 在“读到消息”和“写入历史”之间崩溃,工作就消失了。如果多个 writer 追加同一个 .jsonl,还要额外解决并发写、部分行和读取游标。

因此本章没有实现一个 MessageBusread_text() / unlink(),而是把协作拆成四个明确边界:

Lead 工具:spawn_teammate / send_message
    |
    v
FileMailboxStore
  每消息一个文件,ready -> processing -> done/quarantine
    |
    v
TeammateRuntime
  持有队友 Runner、状态和 worker,并组合 Mailbox/Cron event
    |
    v
AgentRunner.runEvents()
  把 typed message 注入 canonical history,再执行 event-only turn

相关实现集中在以下文件:

  • [Mailbox 领域模型](code/chapters/ch15/src/features/mailbox.ts)
  • [每消息一文件的 store](code/chapters/ch15/src/adapters/mailbox-json.ts)
  • [持续队友运行时](code/chapters/ch15/src/features/teammates.ts)
  • [typed EventInbox](code/chapters/ch15/src/core/events.ts)
  • [公共 Agent Loop](code/chapters/ch15/src/core/loop.ts)
  • [章节组合根](code/chapters/ch15/src/bootstrap.ts)
  • [CLI 组合入口](code/chapters/ch15/src/cli.ts)

第 15 章严格等于第 14 章已有能力,再增加 TEAMMATEMAILBOX。Cron、后台任务、权限、Hook、恢复和前面章节的工具都继续存在,不另起一套 Loop。


03公开工具:Lead 只增加两个const spawnTeammateInputSchema = z.object({

Lead 相对第 14 章只追加两个工具:

const spawnTeammateInputSchema = z.object({
  name: z.string(),
  role: z.string(),
  prompt: z.string(),
}).strict();

const sendMessageInputSchema = z.object({
  to: z.string(),
  content: z.string(),
}).strict();
工具必填字段可观察结果
spawn_teammatenameroleprompt创建独立 Runner,把首个 task 消息持久写入队友 Mailbox,并异步启动 worker
send_messagetocontentmessage 持久写入 Lead 或已存在队友的 Mailbox

工具 schema 拒绝额外字段,也不为关键字段填默认值。Agent 名必须是安全的小写 slug,例如 aliceapi-writer;路径片段、大小写混合、Windows 保留名和重复名字都会在创建工作前失败。lead 是保留名,不能被队友占用。

P15 的 Lead 工具序列完整保留 P14 前缀,只在末尾追加:

shell, read_file, write_file, edit_file, glob,
todo_write, task, load_skill,
create_task, get_task, list_tasks, claim_task, complete_task,
schedule_cron,
spawn_teammate, send_message

这里没有 check_inbox。Lead 不靠模型猜测何时主动拉取消息;Mailbox 是运行时事件源,消息由 TeammateRuntime 发布到 EventInbox


04MailboxMessage:先把消息建模成事件interface MailboxMessage extends RuntimeEvent {

一条消息不是随手拼出的字典,而是严格、不可变的领域对象:

interface MailboxMessage extends RuntimeEvent {
  readonly id: string;
  readonly sender: string;
  readonly recipient: string;
  readonly kind: MailboxMessageKind;
  readonly content: string;
  readonly createdAtUtc: Date;
  readonly idempotencyKey: string;
}

kind 只有三种:

kind用途
taskspawn_teammate 产生的首次任务
messageLead 或队友主动发送的后续文本
result队友一轮 Runner 执行完成后的最终结果,或可观察失败结果

消息 ID 必须是规范 UUID,时间必须是 aware UTC datetime,sender/recipient 必须通过同一套 Agent 名校验,content 不能为空。原始 content 会原样保留,不通过隐式 strip() 改写消息正文。

MailboxMessage 同时满足 RuntimeEvent

eventId = message.id;
idempotencyKey = message.id;

这样 Mailbox 与 Cron 可以共享 typed EventInbox,同时保留各自的领域状态和确认逻辑。消息只在进入 Loop 的边界被序列化为普通 user message;内部不使用含混的文本前缀判断事件类型。

图片
图片

05每消息一个文件,状态由目录表达每条消息对应一个 {uuid}.json。目录名就是消息状态:

Mailbox 数据位于当前 workspace:

.agent_tutorial/
  .mailboxes.lock
  mailboxes/
    lead/
      ready/
      processing/
      done/
      quarantine/
    alice/
      ready/
      processing/
      done/
      quarantine/

每条消息对应一个 {uuid}.json。目录名就是消息状态:

状态含义
ready已持久写入,尚未被消费者认领
processing已被一个消费者原子 claim,尚未确认
done已进入消费者的完成边界
quarantine文件或消息无效,或队友处理该消息失败

发送时先在目标目录创建临时文件,写入 UTF-8 JSON,flushfsync,最后用 os.replace 提交到 ready。如果替换失败,目标消息和临时文件都不会残留。

claim 不是“读完再删”,而是同一把跨进程 FileLock 下的原子 rename:

ready/{id}.json
    -- os.replace -->
processing/{id}.json

ack 同样通过 rename 完成:

processing/{id}.json
    -- os.replace -->
done/{id}.json

还要处理一个跨 runtime 时序:第二个 runtime 可能先把旧 processing 恢复为 ready,重新 claim 并完成 ack,而第一个消费者仍持有同一条消息。此时第一个消费者再次 ack,会在同一把锁内读取 done/{id}.json。完整消息相同就幂等返回成功,内容不同则抛出 MailboxStorageError。因此旧消费者不会把已完成消息重新排队,同 ID 冲突也不会被静默吞掉。

release 把 processing 放回 ready,quarantine 把它移到 quarantine。这些迁移失败时保留原文件,不能出现源和目标都不存在的半状态。

同一个 UUID 在所有 Agent、所有状态目录中必须唯一。store 发现冲突会明确失败,不允许第二条消息覆盖第一条。并发 writer 和并发 claimer 都经过锁保护;同一条 ready 消息最多只会被一个 claimer 移入 processing。

同一进程内,FileMailboxStore 还会先通过 withProcessMutex 串行化操作。它解决的是多个 store 实例在同一 Agent 进程内同时读写同一份状态目录的问题;跨进程竞争才由 .mailboxes.lock 接管。两个锁解决的问题不同,因此不能互相替代。


06FIFO、恢复与坏消息隔离文件名字按 UUID 排序并不等于消息顺序。claim 的权威 FIFO 键是:

文件名字按 UUID 排序并不等于消息顺序。claim 的权威 FIFO 键是:

(created_at_utc, id)

先比较 UTC 创建时间;时间相同时,用规范 UUID 文本稳定打破平局。这让重启后的顺序与目录枚举顺序无关。

processing 不是“已经完成”,因此运行时重建时需要恢复:

processing -> ready -> 再次 claim

Lead runtime 启动时恢复 Lead 的 processing 消息;创建队友时恢复该队友 Mailbox 中遗留的 processing 消息。这是至少一次投递:崩溃可能让同一消息重放,但不能因为进程中断把它静默丢掉。

坏消息也不能堵住整个收件箱。claim 和 recover 会逐个严格校验:

  • 文件必须是普通 .json 文件,文件名必须是规范 UUID。
  • JSON 必须是 UTF-8,字段、消息 ID 和 recipient 必须与路径一致。
  • 未知字段、坏 JSON、坏字节和路径错配会原子移入 quarantine
  • 隔离一个坏文件后继续寻找下一条合法消息。

如果 quarantine 已有同名文件,store 会选择不冲突的新名字,保留两份证据,不覆盖旧故障现场。


07TeammateRuntime:一个名字,一个持续 Runnerspawnteammate 返回时不等待队友完成。运行时为每个合法且未占用的名字创建一个内部 Worker 记录,其中持有:

spawn_teammate 返回时不等待队友完成。运行时为每个合法且未占用的名字创建一个内部 Worker 记录,其中持有:

Teammate 快照
AgentRunner
当前受管 Promise task
当前 processing MailboxMessage

首次 prompt 不直接塞进一个临时 messages[],而是先作为 task 消息落入队友 Mailbox。worker claim 后调用自己的 AgentRunner.run(content, { idempotencyKey: current.id })。该稳定 UUID 会进入这一轮所有工具的 ToolContext.idempotencyKey。消息重放时键不变,外部副作用实现可以据此去重。AgentRunner.run() 会在历史、Hook 和模型调用前拒绝空键,不能让损坏的幂等上下文退化成普通执行。

模型返回最终文本时,运行时把它作为 result 发给 Lead,然后才 ack 当前输入消息。稳定键提供去重依据,但 Mailbox 的权威语义仍是至少一次投递,不把任意外部动作承诺成跨进程恰好一次。

队友完成一轮后不会销毁 Runner:

spawn: running
一轮完成: idle
收到新消息: running
再次完成: idle
执行异常: failed
runtime close 完成: shutdown

Lead 后续调用 send_message(to="alice", ...) 时,如果 alice 处于 idle,运行时重新启动该 worker,但继续使用原来的 Runner。因此第二轮模型请求仍能看到第一轮的 user/assistant history。Alice 和 Bob 则各自持有不同 Runner,历史不会串线。

failedshutdown 的队友不能继续接收消息。重复 spawn 同名队友时,即使两个调用并发到达,注册锁也只允许一个成功。

TeammateRuntime.has_pending_work 不把“持续队友仍存在”算作普通 turn 必须等待的工作。否则只要创建过一个队友,用户 turn 就可能永远等不到系统认为空闲。


08队友工具严格裁剪为四个Lead 继承前 14 章的完整工具面,队友没有。队友 Runner 只注册:

Lead 继承前 14 章的完整工具面,队友没有。队友 Runner 只注册:

shell
read_file
write_file
send_message

它没有 edit_fileglob、TODO、Skill、一次性子 Agent、Task DAG、Cron,也没有 spawn_teammate。团队结构固定为一个 Lead 加多个平级队友,队友不能继续生成队友。

工具少不等于绕过治理。每个队友 Runner 使用自己的名字作为 ToolContext.identity,并复用当前章节的 Hook、PermissionPolicy、Recovery 和 Compaction 边界。队友调用 read_filewrite_fileshell 时,仍按公共顺序执行参数校验、Hook、当前权限和 handler。

队友的 send_message 也不是一个可以伪造 sender 的通用函数。工具输入只有 tocontent,sender 来自当前 Runner 的可信 identity。


09Inbox 注入:先进入 canonical history,再 ack队友写给 Lead 的消息会从 ready claim 到 processing,再以 MailboxMessage 原对象发布到共享 EventInbox。Loop 空闲时执行显式 event-only turn:

队友写给 Lead 的消息会从 ready claim 到 processing,再以 MailboxMessage 原对象发布到共享 EventInbox。Loop 空闲时执行显式 event-only turn:

EventInbox drain 一条 typed RuntimeEvent
-> 校验稳定 event_id
-> 序列化并追加到 Lead canonical history
-> 调用 TeammateRuntime.acknowledge_events()
-> Mailbox processing -> done
-> 模型执行这一条 event turn

顺序不能倒过来。若先 ack 再追加 history,进程在两步之间崩溃会永久丢消息。当前契约要求 ack 时,canonical history 中已经存在且只存在一份对应事件。

ack 失败时,TeammateRuntime 把同一个事件重新发布到 EventInbox。Runner 已经记住该 event_id,重试时不会重复追加 history。确认成功后才会进行模型调用。于是一次可观察的 ack 持久化失败不会造成重复历史或重复模型 turn。

这仍然是“至少一次接收 + Runner 内稳定 ID 去重”,不是对任意外部副作用的全局恰好一次承诺。Mailbox message ID 会作为 idempotency key 传播,真正有外部副作用的工具仍需在自己的边界实现幂等。


10Mailbox、Cron 和 Background 共享运行时P15 不创建第二个事件队列或第二个 task owner。CLI 先创建一个 EventInbox,再让这些组件共享它:

P15 不创建第二个事件队列或第二个 task owner。CLI 先创建一个 EventInbox,再让这些组件共享它:

EventInbox
  <- JobSupervisor 的 background event
  <- CronRuntime 的 CronEvent
  <- TeammateRuntime 的 MailboxMessage

JobSupervisor
  <- background job
  <- cron scheduler
  <- teammate worker

TeammateRuntime 组合已有的 CronRuntime:drain/wait 继续走同一条 typed FIFO,ack 时由 Cron 和 Mailbox 各自确认属于自己的事件。Bootstrap 会验证传入的 TeammateRuntime 与 CronRuntime 持有的正是同一个 supervisor 和同一个 EventInbox;看起来相似但彼此断开的资源会在构建阶段失败。

这样 Lead 不需要轮询 .json 文件,也不需要 check_inbox。P15 把消息持久发布到 inbox 后,由宿主显式调用 AgentRunner.runEvents()。普通 user turn 的 drain 点只会暂存这类带 idempotencyKey 的事件,不会把它混入当前 turn。P15 也不会因为空闲 Lead 收到消息就自动启动新 turn。如果显式 runEvents() 遇到 Lead 正在处理用户 turn,Runner 的异步锁阻止并发进入同一 history,消息仍保留在 inbox/processing,等待后续 event turn。Cron 继续保留 P14 已有的自动 wakeup,这不是 Mailbox 在 P15 新增的行为。


11一次完整协作的状态流假设 Lead 要让 Alice 先读现有说明,再根据反馈补充内容:

假设 Lead 要让 Alice 先读现有说明,再根据反馈补充内容:

Lead
  -> spawn_teammate(name="alice", role="writer", prompt="阅读说明并列出缺口")

FileMailboxStore
  -> alice/ready/{task-id}.json

Teammate worker
  -> claim: alice/ready -> alice/processing
  -> Alice Runner.run("阅读说明并列出缺口", { idempotencyKey: taskId })
  -> read_file(...)
  -> result: lead/ready/{result-id}.json
  -> ack: alice/processing -> alice/done
  -> Alice status = idle

Lead event turn
  -> claim: lead/ready -> lead/processing
  -> typed message 进入 Lead canonical history
  -> ack: lead/processing -> lead/done
  -> Lead 根据结果继续决策

Lead
  -> send_message(to="alice", content="只补充恢复与失败边界")

Teammate worker
  -> 复用同一个 Alice Runner 和既有 history
  -> Alice status: idle -> running -> idle

Alice 的 result 是持久消息,不是后台线程向终端打印的一行日志。打印只能让人看到;进入 Lead history 才能让模型继续协作。


12失败与关闭:P15 做到哪里队友执行模型或工具链时抛出异常,当前 processing 输入消息会进入 quarantine,队友状态变为 failed。运行时还会尽力向 Lead 发送一条包含失败原因的 result,让失败成为可观察事件,而不是只留在后台日志。

队友执行模型或工具链时抛出异常,当前 processing 输入消息会进入 quarantine,队友状态变为 failed。运行时还会尽力向 Lead 发送一条包含失败原因的 result,让失败成为可观察事件,而不是只留在后台日志。

如果 TeammateRuntime.close() 发生在队友执行中,它会:

  1. 标记 runtime 关闭,拒绝新 spawn/send。
  2. cancel 并 await 所有在途队友 task。
  3. 当前 processing 消息通过 release 回到 ready,保留重放机会。
  4. 对每个队友依次尝试 release processing 消息并关闭 Runner;某一项失败不跳过其余队友。
  5. 把队友状态迁移为 shutdown;未完成的清理会保留,以便重试 close。

TeammateRuntime.close() 本身不关闭共享 CronRuntime 或 JobSupervisor。组合根把资源注册为:

resources = [
    supervisor,
    cronRuntime,
    teammateRuntime,
];

AgentRunner.close() 逆序关闭,所以顺序是 Teammate -> Cron -> supervisor。先停止会产生任务的运行时,再关闭唯一 task owner;CLI 构建失败时也保持同样的逆序清理。

某个资源关闭失败时,运行时仍会继续尝试剩余资源。全部尝试结束后,单个异常按原对象抛出,多个异常组成 AggregateError。未完成的队友清理会在下一次 close 重试。只有一轮无异常地完成全部关闭,组合根的 Runner 才进入最终 closed 状态。

这里的 shutdown 是本进程资源关闭状态,不是队友之间的 shutdown 消息协议。请求、批准和关闭握手属于下一章。


13组合根与两个入口P15 的 BuildDependencies 显式要求 teammateRuntime。P14 传入它会失败,P15 缺少它也会失败,避免 profile 声明能力但运行时没有接入。

P15 的 BuildDependencies 显式要求 teammateRuntime。P14 传入它会失败,P15 缺少它也会失败,避免 profile 声明能力但运行时没有接入。

固定章节入口:

Set-Location 'F:\笔记\Agent实操\code'
npm run ch15 -- --prompt "创建一名 writer 队友,让她阅读 README 并汇报缺口"

通用入口:

Set-Location 'F:\笔记\Agent实操\code'
npm run agent-tutorial -- run --chapter 15 --prompt "创建一名 writer 队友,让她阅读 README 并汇报缺口"

消息状态会写入当前 workspace 的 .agent_tutorial/mailboxes/


14与 Claude Code 的差异参照 learn-claude-code/s15agentteams/README.md 中深入 CC 源码的分析,P15 的 Teammate/Mailbox 在教学简化上与 Claude Code 的团队协作机制存在以下差异:

参照 learn-claude-code/s15_agent_teams/README.md 中深入 CC 源码的分析,P15 的 Teammate/Mailbox 在教学简化上与 Claude Code 的团队协作机制存在以下差异:

存储模型。 CC 教学版为每个 Agent 使用一个 .jsonl 文件作为收件箱,read_inbox() 执行读文件 + unlink() 消费式读取,存在 read/unlink 竞态。P15 使用每消息一个 .json 文件,通过目录名(ready/processing/done/quarantine)表达消息状态,用 FileLock 保护原子 os.replace 迁移,并发 writer 和 claimer 都被锁保护。

公开工具。 CC 教学版为 Lead 暴露 spawn_teammatesend_messagecheck_inbox 三个新工具(共 14 个 Lead 工具)。P15 的 Lead 工具序列不包含 check_inbox,mailbox 是运行时事件源;消息由 TeammateRuntime 发布到 EventInbox,Lead 在显式 event turn 中接收,不需要模型猜测何时拉取。

消息读取方式。 CC 教学版由 Lead 显式调用 check_inbox 拉取消息。CC 真实源码的 useInboxPoller 每 1 秒检查一次收件箱,检测到消息后自动提交为新 turn,不需要用户输入。P15 把 mailbox 事件放入共享 EventInbox,由宿主显式调用 AgentRunner.runEvents() 处理。普通 user turn 的 drain 点只暂存带 idempotencyKey 的事件,不会因为 Lead 收到消息就自动启动新 turn。

队友实现。 CC 教学队友运行在 daemon 线程中,持有简化 system prompt、独立 messages 列表和 4 个子工具,教学版限制最多 10 轮对话后自动结束。CC 真实源码的队友由 spawnTeammate() 创建,可运行在 tmux 窗格或进程内,并用 idle loop 等待 inbox 消息。P15 的每名队友拥有独立 AgentRunner,Runner 持有完整历史、Hook 和 Recovery/Compaction 边界。完成一轮后进入 idle 状态而非销毁,后续消息复用同一 Runner 和既有 history。

消息类型。 CC 教学版消息只有 "message""result" 两种 type。CC 真实源码有 15 种结构化消息类型(包括 idle_notificationpermission_requestpermission_responseplan_approval_requestshutdown_request/approved/rejectedtask_assignment 等),文本消息包装在 <teammate-message> XML 标签中交付模型。P15 的 MailboxMessageKind 只有 taskmessageresult 三种,消息同时实现 RuntimeEvent 接口,通过 typed event inbox 而非 XML 标签注入 history。

投递语义。 CC 教学版的 read_inbox 是 read + unlink,进程在读取后、处理前崩溃会永久丢失消息(至多一次投递)。CC 真实源码用 proper-lockfile 文件锁保护 JSON 数组收件箱。P15 采用每消息独立文件 + 目录状态机,processing 在重启时恢复为 ready,坏消息隔离到 quarantine;通过稳定一致 idempotency key 实现至少一次投递,同一 done 消息允许幂等 ack。

权限冒泡。 CC 教学版省略了权限冒泡。CC 真实源码使用双向轮询:队友发 permission_request 到 Lead 收件箱,Lead 的 useInboxPoller 检测并路由到审批队列;用户审批后 Lead 发 permission_response 回队友,队友的 useSwarmPermissionPoller(每 500ms)接收回复后继续或拒绝。P15 的队友和 Lead 复用同一套 Hook、PermissionPolicy、Recovery 和 Compaction,未单独实现跨 Agent 权限冒泡。

生命周期与关闭。 CC 教学版队友是 daemon 线程,10 轮后自动结束,无体面关闭协议。CC 真实源码的队友经历 spawn → work → idle(Stop hook 触发 idle_notification)→ shutdown(Lead 发 shutdown_request,队友回复 shutdown_approved 后清理)。P15 的队友状态机为 running → idle → failed/shutdown,关闭是进程内资源释放(cancel 在途 task、release processing 消息),不是跨 Agent 的 shutdown 握手协议。

队友工具面。 CC 教学版队友工具为 bash、read_file、write_file、send_message,真实 CC 队友还持有 TaskCreateTaskUpdate 等工具,任务系统是团队共享的。P15 的队友工具注册 shellread_filewrite_filesend_message,显式排除 edit_fileglob、TODO、Skill、子 Agent、Task DAG、Cron 和 spawn_teammatesend_message 的 sender 来自可信 identity 而非工具输入。

团队注册表。 CC 真实源码通过 ~/.claude/teams/{teamName}/config.json 存储团队注册表,包含成员 ID、颜色和激活状态;P15 无持久团队注册表,队友信息和状态完全在 TeammateRuntime 进程内管理。

上述 CC 实现细节来自本仓库的参考快照 learn-claude-code/s15_agent_teams/README.md 及该快照引用的 CC 源码行号。


15从 ai-agent-book 学到什么:消息信封与可观察协作本地仓库的 ai-agent-book 第 10 章正文 最贴近本章:它把不共享上下文的多 Agent 协作拆成数据平面与控制平面,并用结构化信封和 Message Bus 解决异步通信。配套实验 10-4 的说明见 README,总线实现见 messagebus.py。

本地仓库的 [ai-agent-book 第 10 章正文](ai-agent-book/book/chapter10.md) 最贴近本章:它把不共享上下文的多 Agent 协作拆成数据平面与控制平面,并用结构化信封和 Message Bus 解决异步通信。配套实验 10-4 的说明见 [README](ai-agent-book/chapter10/parallel-web-research/README.md),总线实现见 [message_bus.py](ai-agent-book/chapter10/parallel-web-research/message_bus.py)。

16ai-agent-book 的实验设计ai-agent-book 第 10 章强调,消息不能只是一段裸字符串,而应携带结构化信封。messagebus.py 的 Envelope 包含 senderid、target、type、payload、seq、ts,消息类型包括 taskassigned、statusupdate、result、terminate、ack。MessageBus 按订阅关系转发,支持点对点投递和 BROADCAST="" 广播。history 保留完整消息流,short() 输出带时间戳和全局序号的紧凑日志,让实验“看得见”每条协作事件。

ai-agent-book 第 10 章强调,消息不能只是一段裸字符串,而应携带结构化信封。message_bus.pyEnvelope 包含 sender_idtargettypepayloadseqts,消息类型包括 task_assignedstatus_updateresultterminateackMessageBus 按订阅关系转发,支持点对点投递和 BROADCAST="*" 广播。history 保留完整消息流,short() 输出带时间戳和全局序号的紧凑日志,让实验“看得见”每条协作事件。

实验 10-4 把这套总线用到了真实并行搜索上:Manager 动态启动多个同构 worker,每个 worker 使用独立 Playwright browser context;单站点超时或解析失败不会阻塞 peers。第一个 target_foundasyncio.Lock 下结算,只允许一次 terminate 广播,后续迟到命中只记录不重复结算。输家收到 terminate 后在安全点取消、发送 ack 并关闭 browser context。README 还要求串行路径访问同一批真实网站、使用同一抽取函数,用真实 wall-clock 和资源创建/关闭计数作为验收证据。

17P15 的对照与取舍P15 没有照搬中心 Message Bus,而是把 ai-agent-book 的结构化消息、可观察历史和资源审计原则落到持久 Mailbox:

P15 没有照搬中心 Message Bus,而是把 ai-agent-book 的结构化消息、可观察历史和资源审计原则落到持久 Mailbox:

  • 结构化信封。 ai-agent-book 用 Envelope 携带 sender/target/type/payload;P15 的 MailboxMessage 携带 idsenderrecipientkindcontentcreatedAtUtcidempotencyKey,并直接实现 RuntimeEvent。消息类型由 MailboxMessageKind 限定为 task/message/result,不需要用字符串前缀猜类型。
  • 点对点 vs 发布订阅。 ai-agent-book 的中心 MessageBus 支持订阅和广播,适合动态 peer 集;P15 当前拓扑固定为 Lead 与多个平级队友,因此用持久收件箱做点对点投递,不实现 BROADCAST、不做新 Agent 自主订阅,也不让队友动态生成队友。
  • 可观测性。 ai-agent-book 的 history 和带时间戳日志让消息流可回放;P15 不把日志当作持久消息,而是用磁盘 ready/processing/done/quarantine 状态目录和 EventInbox 的 typed history 表达同一件事。测试可以断言 send/claim/ack/release/quarantine 后的 store 状态,而不是只看打印文本。
  • 优雅终止与资源审计。 实验 10-4 用 terminate + ack + browser context 创建/关闭计数证明资源不漏;P15 的 TeammateRuntime.close() cancel/await 在途 worker、release processing 消息并尝试关闭每个 Runner,测试断言 shutdown 状态和可重试清理。P15 没有跨 Agent terminate/shutdown 握手,留给 P16。
  • 失败隔离。 ai-agent-book 的单站点失败不阻塞 peers;P15 的队友执行失败会把输入移入 quarantine、状态改为 failed,并向 Lead 发布可观察失败 result。Lead 仍可继续协作,不会因为一个队友的模型或工具错误整条链崩溃。
  • 离线验证 vs 真实实验。 ai-agent-book 用真实浏览器和真实串行基线测 speedup,并用 provenance 保存 114 条总线事件、资源计数和 acceptance gates;P15 聚焦可重复离线测试:注入确定 UUID、UTC clock 和模型替身,断言状态迁移、事件流、幂等 ack、失败事件和资源关闭,不声称有真实 wall-clock 加速证据。

P15 明确不做的事: 没有中心 MessageBus、订阅过滤、BROADCASTstatus_updateterminate/ack 总线协议,也没有跨 Agent 的 shutdown 握手。ai-agent-book 的广播和动态拓扑更适合 Agent 数量不固定、需要自主发现与订阅的场景;第 15 章先守住“固定团队、持久收件箱、typed event 注入唯一 Loop”这条最小闭环。


18如何验证本章测试注入确定 UUID、UTC clock 和模型替身,不依赖真实 OpenAI、真实 sleep 或网络。五组聚焦测试覆盖 store、持续队友、Loop 事件确认、组合根和双入口:

本章测试注入确定 UUID、UTC clock 和模型替身,不依赖真实 OpenAI、真实 sleep 或网络。五组聚焦测试覆盖 store、持续队友、Loop 事件确认、组合根和双入口:

  • [Mailbox store 测试](code/chapters/ch15/tests/ch15-mailbox.test.ts)
  • [持续队友测试](code/chapters/ch15/tests/ch15-teammates.test.ts)
  • [Mailbox event turn 测试](code/chapters/ch15/tests/ch15-loop.test.ts)
  • [P14/P15 增量与组合根测试](code/chapters/ch15/tests/ch15-bootstrap.test.ts)
  • [双入口测试](code/chapters/ch15/tests/ch15-entry.test.ts)

运行聚焦验证:

Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch15
npm run typecheck
npm run lint
npm run format:check
npm run build

关键断言都是可观察结果:

  • P15 capability 严格等于 P14 加 TEAMMATEMAILBOX
  • Lead 工具保持 P14 完整前缀,只追加 spawn_teammatesend_message
  • 队友工具严格为 shellread_filewrite_filesend_message
  • 两名队友并行执行,各自 history 隔离;idle 后的新消息复用原 Runner。
  • 并发同名 spawn 只成功一次,非法名字和未知收件人在创建工作前失败。
  • FIFO 使用 (created_at_utc, id),并发 writer 不丢消息,并发 claimer 不重复认领。
  • send/claim/ack/release/quarantine/recover 的替换失败保留提交前状态。
  • 重启把 processing 恢复为 ready;坏消息进入 quarantine 且不阻塞合法消息。
  • 队友工具获得当前 Mailbox UUID 作为稳定 idempotency key;空键在任何 turn 副作用前失败。
  • Mailbox event 由显式 event turn 接收,进入 canonical history 后才 ack;ack 失败重试不重复 history 或模型调用;跨 runtime 的相同 done 消息可幂等 ack,内容冲突明确失败。
  • Cron 与 Mailbox 事件由各自 owner 确认,但共享一个 typed EventInbox。
  • 队友失败会隔离输入并向 Lead 发布可观察结果;close 会 cancel、await 并留下 shutdown 状态;单个资源关闭失败不跳过其余资源,多异常可观察且关闭可重试。
  • CLI 两个入口构建共享 runtime,并按 Teammate -> Cron -> supervisor -> model 关闭。

19本章明确不做什么第 15 章只完成持续队友和持久文本 Mailbox 的最小闭环,没有提前实现:

第 15 章只完成持续队友和持久文本 Mailbox 的最小闭环,没有提前实现:

  • check_inbox、list、cancel 或额外 Lead 工具
  • 结构化 request/response 协议
  • 计划审批和权限请求冒泡
  • shutdown_request / shutdown_approved 握手
  • 队友从共享 Task DAG 自主找活、抢占工作或空闲自驱

request/response、计划审批和 shutdown 握手属于第 16 章。空闲队友自主发现并领取工作属于第 17 章。P15 的 idle 只表示 Runner 保留且正在等待显式消息,不表示它会自己找任务。


20小结给 Agent 加队友,不是启动几个无人管理的后台线程。真正的工程边界是:

给 Agent 加队友,不是启动几个无人管理的后台线程。真正的工程边界是:

  1. Lead 只通过两个严格工具创建队友和发送消息。
  2. 每个队友持有独立、可复用的 Runner history,并拥有明确状态机。
  3. 每条消息以独立文件经历 ready/processing/done/quarantine 原子迁移。
  4. processing 在重启时恢复,坏消息隔离,投递采用稳定 ID 的至少一次语义;同一 done 消息允许幂等 ack。
  5. MailboxMessage 通过共享 typed EventInbox 进入唯一 Agent Loop,先写 canonical history 再 ack。
  6. Teammate、Cron 和 Background 共用 JobSupervisor,并按资源依赖逆序、全资源尝试关闭。

做到这些以后,队友的进展不再是一行易丢的日志,Lead 也不需要靠 check_inbox 猜测消息何时到达;宿主可以在明确的 event turn 边界处理它。下一篇再在这条持久消息通道上增加 request/response、计划审批和 shutdown 握手。

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始