第 16 章 从单兵作战到自组织团队 · Agent架构实操十六 开始测验

从「单兵作战」到「自组织团队」

普通 task/message/result 只表达内容,不表达「这条回复正在解决哪个请求」。关机、计划审批这类场景靠自由文本猜上下文不够。本章:协议请求先登记、消息携带稳定 ID、响应严格匹配、状态只迁移一次。
⏱ 约 14 分钟 📋 ProtocolRequest 🚪 plan_gate 门控 +protocol+plan_gate

🎯 学习目标

学完能答
  • 消息送达等于协议完成吗?普通 Mailbox 不知道哪些事实?
  • 第一条硬规则:先登记还是先发送?为什么顺序不能反?
  • ProtocolRequest 有哪些不变量?响应窗口默认多久?
  • plan_gate 如何门控?pending/rejected 时写入工具会怎样?
  • shutdown 的正确顺序是什么?能直接取消正在写文件的队友吗?
  • 为什么不把协议塞进普通 content 字符串?

🧠 核心概念

点击展开。
1 · 消息送达 ≠ 协议完成

普通 Mailbox 已解决持久投递(每条消息有稳定 UUID,经 ready→processing→done/quarantine)。但它不知道:

  • shutdown_response 是否对应真实存在的 shutdown 请求;
  • 响应双方是否正好是原请求的 target 和 sender;
  • 请求是否仍 pending、是否已过期;
  • 同一响应因 Mailbox ack 故障重试时是否已消费过;
  • 不同 transport message 携带相同 request ID 时是否重复响应。

因此 P16 没把协议塞进普通 content 字符串,也没让模型解析自由文本 JSON,而是增加确定性运行时。

2 · 第一条硬规则:先登记,再发送
const request = await protocol.requestShutdown("alice");
// request 已先写入 JsonProtocolStore,再由 TeammateRuntime 投递 typed Mailbox
顺序不能反 先发送,接收方可能在请求登记前就回复,状态机找不到 request ID。发送失败也不假装请求没发生:抛 ProtocolDeliveryError,保留已持久化的 pending 请求供诊断恢复,不自动创建第二个请求掩盖失败,不在后台无限重试。
3 · ProtocolRequest 不变量

使用规范 UUID、明确参与方、aware UTC、封闭状态(pending/approved/rejected)。

  • sender 和 target 不能相同,名字必须是安全小写 Agent slug;
  • ID 必须是规范 UUID,不接受六位随机短 ID/大小写变体/别名;
  • pending 请求不能带 resolution;approved/rejected 必须带匹配 resolution;
  • resolution 时间不能早于创建,不能落在或超过过期时刻;
  • 默认响应窗口 5 分钟,区间 [created_at_utc, expires_at_utc)。

JsonProtocolStore 把全部请求保存到 .agent_tutorial/protocol/state.json(带 version 单一快照)。进程内 mutex 串行化 + 跨进程 proper-lockfile 锁,临时文件+fsync+原子 rename,持久化失败旧字节旧状态不变。

4 · plan_gate 门控

Bob 要重构认证模块,先 submit_plan 提交计划,Lead 用 review_plan 批准或驳回。只要计划仍是 pending 或 rejected,写入和执行类工具就必须在 handler 之前被拒绝,不能只靠 Prompt 提醒模型「先等批准」。

硬门控 这是权限层的硬拒绝,不是 Prompt 软提醒。pending/rejected 状态下 write_file/edit_file/shell 等工具的 effect 在 handler 执行前就被 plan_gate 拦截。三条豁免:read 类工具可用(收集信息)、send_message 可用(沟通)、submit_plan 可用(重提交)。另外:从没提交过计划放行,提交过但被拒则拦住——理由是前者无从谈起,后者是明确说不行。只有 approved 才放行 effectful 工具。
5 · shutdown 正确顺序

Lead 想让 Alice 停下来,但不能在她写文件时直接取消。正确顺序:

  1. 先让当前消息完成(不打断正在处理的 Mailbox 消息);
  2. 再处理结构化 shutdown 请求(协议消息先走确定性 router);
  3. 发回响应;
  4. 然后结束 worker;
  5. Runner 资源仍由组合根统一关闭。

协议消息先走确定性 router,普通消息才进入模型。这保证 shutdown/plan 这类控制信号不被普通消息队列阻塞。shutdown 不进模型:先干完当前消息,然后在模型调用前确定性路由回结构化响应——关闭全程模型调用次数 1→1,不多花一次;plan 响应则状态先变、再用原 Runner 恢复(会多花一次模型调用)。Lead 侧顺序:先写 history,再消费协议状态,最后 ack transport;协议事件不享受 mailbox 的暂存和半状态。

6 · typed Mailbox:不降级成 content JSON

P15 的 MailboxMessageKind 只有 task/message/result。P16 新增独立 ProtocolMessageKind:shutdown_request、shutdown_response、plan_approval_request、plan_approval_response。ProtocolMailboxMessage 显式包含 request_id 和 approved。

协议没创建第二套消息目录、第二个 EventInbox 或第二个 Agent Loop。它复用 P15 的 FileMailboxStore,只是消息 kind 扩展。请求 approved 为 null,响应必须是 boolean。

7 · 失败分三类
失败类型处置
内容错(校验不过/未知类型)quarantine 隔离,队友活着继续
投递/存储故障release 退回 ready + 队友进 failed
取消/关闭release,不伪装成失败

本章承诺的是至少一次:响应只迁移一次是状态机层面的,不等于全局 exactly-once;重试仍可能重复,靠稳定 ID 与工具幂等键兑底。idle 不等于轮询:直接往 store 写消息不会唤醒队友,唤醒必须走 wakeup 回调跑 runEvents。

▶️ 交互演示:协议生命周期

选一个场景(shutdown 或 plan approval),看请求如何「先登记再发送」,响应如何严格匹配 request_id,状态只迁移一次。

🔑 一句话总结

请求先登记→消息携带稳定ID→响应严格匹配→状态只迁移一次:确定性协议,不靠自由文本猜上下文

QA 测验