第 12 章 多代理编排:从 orchestrator 到 agent graph¶
上一章讲的是"一个 agent 跨时间接力"——同一个执行体,靠记忆和压缩在时间轴上延续。这一章讲另一种"一个不够"的解法:多个 agent 在同一时刻并行协作。但先把结论钉死:多代理不是杠杆的升级版,它是一把双刃剑。用对了是分身术,用错了是给自己开了一家需要管理的公司。
引子:四个把多代理玩崩的现场¶
抽象的"协调成本"听起来像废话,得先让你看见它崩起来是什么样子。下面四个场景,全都来自真实多代理系统的失败模式,也全都能在 Codex 的源码里找到对应的防御机制——我们这一章就是顺着这四口"坑"往下挖。
现场一:孤儿 agent。 主 agent 派生了三个子 agent 去并行干活,结果自己先"做完了",一收工退出。三个子 agent 还在后台烧着 token 跑,没人收它们的结果,也没人关它们。它们成了无主的进程——孤儿。在按 token 计费的世界里,孤儿 agent 就是开着没人用的水龙头。
现场二:死等(deadlock)。 A 派生了 B,然后调用 wait 等 B 的结果;但 B 在某个分支里也调了 wait 等一条永远不会来的消息。两个都在等,谁都不动。如果 wait 没有超时,这就是一个经典的分布式死锁。
现场三:重复劳动。 主 agent 把"调研竞品"这个任务原封不动地丢给五个子 agent,没说清各自负责哪一块。结果五个 agent 跑去搜了几乎一模一样的关键词,烧了五倍 token,产出几乎一样的东西。Anthropic 在复盘自己的多代理系统时就吃过这个亏:早期的子 agent "误解任务,或者跑去做和别的 agent 一模一样的搜索"。
现场四:失控的派生。 一个本该一句话答完的简单问题,主 agent 兴奋地派生了 50 个子 agent 去"全网搜索不存在的来源"。这也是 Anthropic 原话里的真实事故:"早期的 agent 会犯一些错误,比如为一个简单查询派生 50 个子 agent。"
这四个现场背后是同一句话:多个 agent 一起干活,立刻把单 agent 的世界变成了一个分布式系统——谁负责哪部分、怎么通信、怎么知道别人完工没、一个挂了或多生了怎么办。你以为加了几个帮手,其实是接手了一整套并发与生命周期管理的难题。
所以这一章的正确读法不是"快上多代理",而是:当你确实需要并行时,看 Codex 提供了哪些原语,让上面这四种崩溃不至于发生。
把这件事说穿一点:上面四个坑,没有一个是 LLM 特有的。孤儿进程、死锁、重复计算、fork 炸弹(fork bomb)——这是操作系统课本里讲了几十年的并发老问题。多代理的真正难点在于,过去这些问题的"执行体"是确定性的线程或进程,而现在的执行体是个会自己临场判断、会自己再派生孙子的语言模型——它既是被调度的资源,又是调度的决策者。这就是为什么"对 agent 可见的才存在"这条主线在多代理里格外要命:传统并发系统里,调度器对所有线程的状态一清二楚;而在多代理里,主 agent 对子 agent 的认知,完全取决于 harness 把多少状态喂回了它的上下文。没被写回主 agent 视野的子 agent 状态,对主 agent 就等于不存在——它就会做出"以为大家都完工了"这类错误判断,于是孤儿、死等接踵而至。Codex 这一章的全部机制,本质都在回答一个问题:如何让"一群会思考的执行体"的生命周期,对协调它们的那个执行体保持可见、可控、可回收。
〔配图 1:四个崩溃现场〕——四宫格手绘:①孤儿 agent(主 agent 关灯走了,三个小人还在后台烧 token);②死等(两个小人举着沙漏面对面,箭头互指成环);③重复劳动(五个小人搜同一个关键词,产出叠在一起);④失控派生(一个小人裂变成 50 个)。底部:"多代理 = 把单 agent 变成一个要管理的分布式系统。"
一、先问:到底要不要多代理¶
在写一行编排代码之前,先过一道判断题。Anthropic 给出的标准很冷静:多代理值得上,仅当任务天然适合并行、且价值高到付得起协调成本。 反过来,"要求所有 agent 共享同一份上下文、或 agent 之间依赖很多"的任务,"今天还不适合多代理"。
为什么这么保守?因为账算下来很吓人。Anthropic 给了一组可量化的数字:agent 类应用大约比普通聊天多烧 4 倍 token,而多代理系统大约多烧 15 倍。 也就是说,你每开一个并行 agent,都是在为"协调"这件事预付一笔不便宜的税。当然,收益也可能很大——他们报告里那个"Opus 4 当主控、Sonnet 4 当子 agent"的多代理系统,在一类广度优先的调研任务上比单个 Opus 4 高出 90.2%。
关键在"广度优先"这四个字。多代理擅长的是沿多个独立方向同时探索、以及信息量超出单个上下文窗口的任务——比如"同时调研十家公司"。它不擅长的是高度串行、彼此强依赖的任务。Anthropic 特意点名:"大多数编码任务里,真正可并行的部分比调研任务少得多。" 这正解释了为什么 Codex(一个编码 agent)的多代理设计如此克制——它默认你大部分时候不需要多代理,只在确有可并行子任务时才提供这套工具。
把这道判断题画成一句口诀:不是"人多力量大",而是"任务能切成互不依赖的几块吗"。 切得开,多代理是分身;切不开,多代理是内耗。
这里有一条容易被忽略的成本,得单独点出来:协调本身也是要花上下文的。主 agent 每派生一个子 agent,都要在自己的上下文里记一笔"我派了谁、让它干什么、它回来说了什么";子 agent 越多,主 agent 用来"管人"的上下文就越多,留给"干活/思考"的就越少。这正是第 11 章"上下文是有限预算"那条线在多代理里的延续——15 倍 token 不只烧在子 agent 的推理上,也烧在主 agent 那份越来越臃肿的协调账本上。所以一个反直觉但重要的结论是:多代理省的是"墙上时间"(并行更快),花的是"总 token"和"协调注意力"。 当你的瓶颈是延迟(等不及串行跑完),多代理划算;当你的瓶颈是成本或主 agent 的上下文预算,它可能反而更糟。判断要不要上,先想清楚自己的瓶颈到底是哪一个。
〔配图 2:要不要上多代理〕——决策菱形:"任务能切成互不依赖的并行块吗?且价值付得起 15x token?"。否 → 一个 agent 独自干(标"别为多代理而多代理");是 → 一个 agent 裂成几个并排干(标"准备好为协调付 15x 的税")。底部引 Anthropic:"多代理多烧约 15× token——值不值,先看任务能不能并行。"
二、Codex 的 agent 抽象:身份、角色、状态机¶
要协调一群 agent,先得让每个 agent 是个"有身份的实体"。Codex 的 core/src/agent/ 模块就干这件事,分成四块:
- 注册(
registry.rs):登记当前有哪些 agent 在跑,是协调的"花名册"底座。 - 角色(
role.rs):这个 agent 被赋予什么人格与配置。注意——Codex 内置的角色不是你可能在别处听过的"规划/生成/评估"三件套,而是default、explorer(专门回答代码库的具体问题,快而权威)、worker(执行实现类工作,强调要"明确指定它负责哪些文件"以避免冲突)。还有一个awaiter(专职等待长任务)目前在源码里被注释掉、临时下线。每个角色其实就是一份会叠加到config.toml之上的配置层(role 文件如explorer.toml),换句话说,"角色"在 Codex 里不是硬编码的类,而是可配置的一层 prompt+设置。这正是"harness 随模型演化"的体现:角色清单是数据,不是代码,随时能改。 - 控制(
control.rs):怎么启动、暂停、关闭一个 agent,以及——很关键——维护"谁派生了谁"的关系(详见第五节)。 - 状态(
status.rs):它现在在忙、在等、还是完工了。
加上独立的 agent-identity crate 给每个 agent 签发可验证的身份(基于 ed25519 签名的 JWT),"一群 agent"才从一团乱麻变成可点名、可寻址、可审计的组织。顺带一提,Codex 给 agent 起的"昵称"是从一串科学家名字里随机挑的(Euclid、Gauss、Faraday……见 agent_names.txt),纯粹为了让日志里好认人。
这一节真正值得细看的是状态机——它直接对应了引子里的"孤儿"和"死等"。Codex 用一个 AgentStatus 枚举刻画每个 agent 的生命周期:
pub enum AgentStatus {
PendingInit, // 等待初始化
Running, // 正在跑
Interrupted, // 当前轮被打断,但还可能收到更多输入
Completed(Option<String>), // 完工,带上最终消息
Errored(String), // 出错了
Shutdown, // 已关闭
NotFound, // 找不到这个 agent
}
解读:这七个状态里,真正的"终态"判断由一个独立函数给出——
pub(crate) fn is_final(status: &AgentStatus) -> bool {
!matches!(status,
AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted)
}
注意这个 is_final 的写法很讲究:它不是"列出哪些是终态",而是"列出哪些不是终态"(待初始化、运行中、被打断)。剩下的——完工、出错、关闭、找不到——都算终态。为什么 Interrupted 不是终态?因为被打断的 agent 还能再收消息、再起一轮,它只是暂歇,不是死了。这个区分极其关键:协调逻辑全靠 is_final 来判断"这个子 agent 是不是该等的、可以收尾了"。一个会把 Interrupted 误判成终态的系统,就会过早放弃还能救活的 agent——这正是"对 agent 可见的才存在"在状态层面的体现:只有被正确建模进状态机的生命周期,协调器才"看得见"。
状态是怎么流转的?status.rs 里 agent_status_from_event 把底层事件翻译成状态变化:TurnStarted → Running,TurnComplete → Completed(并捎上最后那条消息),被打断且原因是用户中断或预算用尽 → Interrupted,其他中断原因或 Error 事件 → Errored,ShutdownComplete → Shutdown。状态不是手动设的,而是从 agent 实际发出的事件里推导出来的——这保证了花名册上的状态和 agent 的真实行为不会脱节。
这里有个值得品的设计取舍:TurnAborted 事件携带一个 reason,而 status.rs 对它做了分流——
EventMsg::TurnAborted(ev) => match ev.reason {
TurnAbortReason::Interrupted | TurnAbortReason::BudgetLimited
=> Some(AgentStatus::Interrupted), // 可恢复
_ => Some(AgentStatus::Errored(...)), // 视为出错
},
解读:同样是"一轮被中止",用户主动打断和预算用尽被归为 Interrupted(暂歇、还能续),而其他原因被归为 Errored(出错、终态)。这个分类不是随手写的——它直接决定了协调器对这个 agent 的后续态度:是"等会儿再喂点输入它就能继续",还是"它废了、别再指望"。把"预算用尽"划进可恢复一侧尤其耐品:在按 token 计费、随时可能触顶的世界里,预算触顶是个常态事件而非故障,所以它该被当成"暂停"而不是"崩溃"。状态机的每一条归类,都是 harness 对"这个执行体还值不值得救"的一次判断——这正是把分布式系统的"故障语义"显式建模进 agent 抽象的价值所在。
再回到主线:"对 agent 可见的才存在"在这里有个具体落点:子 agent 的状态,只有先被 agent_status_from_event 翻译、写进 registry,才会在主 agent 调 list_agents 或 wait_agent 醒来时被"看见"。一个没有被正确事件驱动的状态机,会让主 agent 看到一份过期的花名册——而过期的花名册,就是孤儿和死等的温床。
〔配图 3:AgentStatus 状态机〕——手绘状态图:PendingInit→Running(TurnStarted),Running→Completed(TurnComplete,带消息卡)、→Interrupted(被打断,画一条虚线回到 Running 表示"还能复活")、→Errored、→Shutdown。用橙色框圈出"终态"(Completed/Errored/Shutdown/NotFound),用蓝色虚线圈出"非终态"(PendingInit/Running/Interrupted)。底部:"is_final 判的是'不是终态'的补集——Interrupted 不算死,只是暂歇。"
三、协调的"动词":六个真实的工具¶
agent 之间怎么协作?Codex 在 core/src/tools/handlers/multi_agents_v2/ 下,把协调动作做成了一组工具——也就是说,"管理别的 agent"本身就是 agent 的一种工具调用(回到第 6 章:能力即工具)。这套是 v2,与更早的 v1(multi_agents/)并存、仍在演进。v2 的六个真实工具如下(工具名取自源码里的 ToolName::plain(...),没有 assign_task 这种东西):
| 工具 | 干什么 | 关键语义 |
|---|---|---|
spawn_agent |
派生一个新子 agent,并直接带上初始任务 | 不存在"先生再派"两步;spawn 即派活 |
followup_task |
给已有 agent 追加一个任务并唤醒它起一轮 | TriggerTurn:触发新的一轮 |
send_message |
给已有 agent 发消息,只入队、不打断 | QueueOnly:不抢占对方当前的轮 |
wait_agent |
等"邮箱"出现任何更新(含完工通知/排队消息) | 必带超时;超时即返回 |
list_agents |
列出当前 root 线程树下还活着的 agent(包含 root 本身) | 可按任务路径前缀过滤 |
close_agent |
关掉一个 agent | 不能关自己、不能关 root |
这里有三个极易被讲错、但源码写得明明白白的细节,逐个点透:
细节一:spawn 即派活,没有 assign_task。 spawn_agent 的入参是 message(要干的活)+ task_name(给它起的子任务名),派生的那一刻任务就一起塞过去了。源码里 spawn 把初始任务包装成一条 InterAgentCommunication 直接发给新 agent,trigger_turn 置真——生出来就开干。所以"派生→派活"是一个动作,不是两个。旧的设计里那个独立的 assign_task 工具在 v2 里根本不存在。
细节二:send_message 与 followup_task 的差别,就是一个布尔位。 这俩在源码里走的是同一条提交路径(handle_message_string_tool),唯一区别是 MessageDeliveryMode:
pub(crate) enum MessageDeliveryMode {
QueueOnly, // send_message 用:只入队,不打断对方
TriggerTurn, // followup_task 用:立刻唤醒对方起一轮
}
解读:QueueOnly 是"留个言,你忙完再看";TriggerTurn 是"敲门,请你现在处理"。这个区分是协调里防止"互相打断到谁也干不完"的关键设计——你不想每发一条消息都把对方正在跑的轮给搅黄了。工具描述里写得很直白:send_message 是"消息会被尽快投递,但不触发新的一轮";followup_task 则"触发目标起一轮;如果目标正在轮中,消息会排队,用于它下一轮的开头"。换句话说,即便是 TriggerTurn,也不会粗暴打断正在进行的轮——它礼貌地排到下一轮。这就是引子里"互相打断到崩溃"那口坑的防御。
细节三:wait_agent 等的是"邮箱有动静",不是"某个具体 agent 完工"。 这点反直觉,但源码非常清楚:
let mut mailbox_rx = session.input_queue.subscribe_mailbox().await;
let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
let timed_out = !wait_for_mailbox_change(&mut mailbox_rx, deadline).await;
解读:wait_agent 订阅自己的"邮箱",然后带着 deadline 等任意一条更新(可能是某个子 agent 完工的通知,也可能是某个 agent 发来的排队消息)。它返回的不是内容,而只是"有更新了"或"超时了"。两个要点:第一,它必带超时(受配置的 min/max/default 三档约束)——这是对引子"现场二:死等"的根本防御,Codex 里不存在无限期的 wait。第二,它等的是"邮箱级"事件而非"某个 agent 级"完工,这让主 agent 用一次 wait_agent 就能被任意子 agent 的进展唤醒,而不必逐个轮询。醒来之后,主 agent 再用 list_agents / 读消息去看"到底谁有动静"。
把六个动词连起来,就是一套完整的协调语言:派生并派活(spawn_agent)→ 追加任务(followup_task)/ 留言(send_message)→ 等邮箱动静(wait_agent)→ 点名(list_agents)→ 收工(close_agent)。它和你写并发程序时那套 spawn / send / join 惊人地像——因为本质就是同一回事:管理一组并发执行体。差别只在于,这里的"执行体"是会自己思考、会自己再派生孙 agent 的语言模型。
把这套动词跑一遍,一个典型的并行协调循环长这样(这不是某个函数,而是主 agent 在 orchestrator.md 引导下、用工具调用串起来的行为序列):
- 主 agent 把任务切成三块互不依赖的子任务,连调三次
spawn_agent,各带一份message和不同的task_name——三个子 agent 立刻并行开跑。 - 主 agent 调一次
wait_agent(带超时),自己挂起,把 CPU/推理预算让给子 agent。 - 某个子 agent 完工,它的
Completed(最终消息)状态通知进了主 agent 的邮箱,wait_agent因"邮箱有动静"而返回。 - 主 agent 醒来,
list_agents看看谁还活着、读一下完工那个的结果;如果还有在跑的,回到第 2 步再wait_agent。 - 全部完工后,主 agent 把结果汇总,并对每个子 agent
close_agent收工——派生树上的 open 边逐条翻成 closed,资源回收干净。
注意这个循环里 wait_agent 的位置:它是"让出执行权"的那一步。如果主 agent 不 wait 就继续往下跑,就回到了引子"现场一"——它会以为自己该收工了,把还在后台跑的子 agent 扔成孤儿。orchestrator.md 那句"子 agent 在跑就要等它们再交还控制权",对应的就是"别跳过第 2、4 步"。协调的纪律,一半在工具的语义里,一半在 prompt 的提醒里——这又是一次"机制 + 人格"的合奏。
〔配图 4:协调的六个动词〕——中央一个"主 agent",用六个带标签的箭头管理周围三个子 agent:spawn_agent(变出一个新小人,手里已攥着任务卡)、followup_task(敲门,画一个"下一轮"的小标)、send_message(留言贴在邮箱上,不敲门)、wait_agent(带闹钟的沙漏,强调"必超时")、list_agents(点名册)、close_agent(关灯)。底部:"管理别的 agent,本身也是一种工具(第 6 章)。wait 永远带超时——没有无限期的等。"
四、谁来"指挥":orchestrator 是模板,不是那个 orchestrator.rs¶
到这里要拆掉一个最常见的误会。Codex 里确实有个文件叫 core/src/tools/orchestrator.rs,里面有个类型叫 ToolOrchestrator——但它和多代理半毛钱关系都没有。
读一眼它自己的模块注释就清楚了:
Module: orchestrator
Central place for approvals + sandbox selection + retry semantics. Drives a
simple sequence for any ToolRuntime: approval → select sandbox → attempt →
retry with an escalated sandbox strategy on denial.
解读:ToolOrchestrator 编排的是单个工具调用的执行流程——审批(要不要让用户点同意)→ 选沙箱 → 执行尝试 → 被拒就升级沙箱策略重试。它是第 7、8 章"审批与沙箱"那条线的指挥官,管的是"一次 exec 怎么安全地跑起来",不是"一群 agent 怎么分工"。把它当成多代理的项目经理,是张冠李戴。
那么多代理真正的"指挥"语义在哪?在两处协同,而不是某个叫 orchestrator 的类里:
core/templates/agents/orchestrator.md——一份 prompt 模板。 多代理的"主控"是一种人格,由这份发给主 agent 的指令塑造出来,而不是一段调度代码。它的原文非常直白地教模型怎么当协调者:- "优先用多个子 agent 来并行你的工作。时间是约束,并行能更快解决任务。"
- "如果子 agent 在跑,在你交还控制权之前要等它们——除非用户问了明确的问题。"
- "当你让子 agent 替你干活,你的角色就只剩协调它们。它们干活时,你不要自己去做实际工作。"
- "当你的计划有多步时,尽可能为每一步派生一个 agent,并行处理。"
这几条等于把第二、三节的状态机和动词,翻译成了给模型的行为准则。尤其"等它们再交还控制权"这一条,正是对引子"现场一:孤儿 agent"的防御——别自己先溜,把后台的孩子扔下不管。
- 第三节那六个工具。 模板给"该怎么做"的判断,工具给"做"的能力。指挥权 = prompt(人格)+ tools(手脚)。
这是 harness engineering 一个反复出现的母题:很多看起来该是"代码逻辑"的东西,其实是 prompt。 "要不要并行""派几个""等不等"——这些决策没有写死在 Rust 里,而是交给模型在 orchestrator.md 的引导下临场判断。代码只负责把这些判断变得可执行且安全(spawn 真能起进程、wait 真有超时、close 真能回收)。这也呼应主线:"模型是商品、harness 是杠杆"——决策交给商品(模型),杠杆(harness)只保证决策落地不出事。
值得一提的是 OpenAI 自己在另一条产品线上探索过不同的多代理范式。它早期的实验框架 Swarm(已被生产级的 Agents SDK 取代)主打两个原语:routine(一份自然语言指令 + 一组工具,本质就是一个 agent)和 handoff(一个 agent 把"当前对话"整个移交给另一个 agent,"就像电话被转接给别人,只不过新 agent 完整知道之前的对话")。把它和 Codex 对照很有意思:handoff 是"接力棒式"的串行移交(同一时刻只有一个 agent 在台上),而 Codex 的 spawn/wait 是"分身式"的并行派生(主 agent 在台上,子 agent 在后台并行)。同一个"多代理"词,底下是两种拓扑——这正说明"多代理"不是一个范式,而是一族范式,选哪个取决于你的任务是该接力还是该并行。
再多说一句这两种拓扑的工程含义。handoff 式(接力)天然没有"汇合"问题——同一时刻只有一个 agent 在台上,前一个把整段对话交出去就退场,所以不存在孤儿、不存在死等,代价是无法并行。spawn 式(分身)能并行加速,但把引子里那四口坑全请了进来:派生出去的 agent 要管生命周期(孤儿)、要等汇合(死等)、要分配 ownership(重复劳动)、要限制数量(失控派生)。Codex 选了更难的那条路(并行 spawn),所以它必须把这一章前面所有的机制——状态机、必带超时的 wait、能回收后代的 close、open/closed 的派生树——全部备齐。 这不是 Codex 比 Swarm"更高级",而是它选择的拓扑欠下了更多的协调债,只能用更厚的 harness 去还。回到主线:harness 的厚度,是由你选的拓扑欠下的协调债决定的——这也是"harness 是杠杆"的另一面:杠杆越长,支点要越结实。
〔配图 5:两个 orchestrator 别搞混 + 指挥权的来源〕——左半:"ToolOrchestrator(代码)"管一次工具调用:审批→选沙箱→尝试→重试,旁边大叉"≠ 多代理主控"。右半:"多代理主控 = orchestrator.md(人格 prompt)+ 六个工具(手脚)",画一个戴着指挥帽的主 agent,帽子标"prompt",手里六根指挥棒标六个工具。底部:"很多以为是代码逻辑的判断,其实是 prompt。"
五、agent graph:它是一棵派生树,不是消息网¶
最后一个被讲滥的概念:"agent graph"。很多介绍把它描述成一张网状的消息图——节点是 agent,边是"消息""依赖""谁在等谁",仿佛是个能表达任意关系的有向图。Codex 的 agent-graph-store crate 不是这样。打开 types.rs,整个 crate 只定义了一种边:
/// Lifecycle status attached to a directional thread-spawn edge.
pub enum ThreadSpawnEdgeStatus {
Open, // 子线程还活着 / 可恢复
Closed, // 这条派生关系已从父/子图里关闭
}
解读:只有 ThreadSpawnEdge——派生边,方向是"父派生了子",带一个 Open/Closed 的生命周期状态。没有"消息边",没有"依赖边"。所以这张所谓的"图",本质是一棵派生树(spawn tree):root → 它派生的子 → 子派生的孙……每条边记录"谁生了谁,这关系还开着吗"。
注意 store 注释里那句不起眼但重要的约束:"实现必须为 list 方法返回稳定的排序,这样调用方才能把持久化的图状态和内存里的活状态合并,而不引入非确定性输出。"换句话说,这棵树的遍历结果是可重现的——list_thread_spawn_descendants 按深度分层的广度优先返回,同层再按 thread id 排序。为什么连排序都要钉死?因为多代理调试本就难(你没法单步跟一群并行的模型),如果连"现在树长什么样"每次查都不一样,调试就彻底没法做了。可确定性是协调层的底线:拓扑可以复杂,但查询它的结果不能随机。这也解释了为什么 Codex 宁可只支持"一种边、一棵树"的简单结构,也不去做一张表达力更强的任意图——表达力换来的复杂度,会以"不可重现的调试地狱"的形式连本带利还回来。
store 暴露的 API 也完全是树的语言(store.rs):
upsert_thread_spawn_edge(parent, child, status)——记下一条父子派生边;set_thread_spawn_edge_status(child, status)——把某条边翻成 Open 或 Closed;list_thread_spawn_children(parent, status_filter)——列直接子节点;list_thread_spawn_descendants(root, status_filter)——广度优先遍历后代,而且过滤器作用在"每一条被走过的边"上:传Some(Open)就只沿 open 边往下走,一条边一旦 Closed,它底下整棵子树都不再被遍历。
这棵树是怎么活起来的?看 control.rs 里三处真实调用,刚好串成生命周期:
- spawn 时:
upsert_thread_spawn_edge(parent, child, Open)——派生一个子 agent,就在树上加一条 Open 边。 - close 时:
close_agent先set_thread_spawn_edge_status(child, Closed)把边翻成 Closed,然后关掉这个 agent 以及从内存树里能到达的所有活着的后代。注意这两个连环动作——这正是对引子"现场一:孤儿 agent"的根治:关一个 agent,会顺着派生树把它的孩子、孙子一并回收,不留无主进程。源码里close_agent还专门挡掉两种危险操作:"agent 不能关自己('return your result and let the parent close you')"和"不能关 root('root is not a spawned agent')"——前者防自杀式的逻辑混乱,后者防把整棵树连根拔了。 - 恢复时:会话恢复时,
control.rs从持久化的派生树里list_thread_spawn_children_with_status(parent, Open)逐层把 Open 的子树重建出来——也就是说,派生树是持久的,崩溃重启后还能照着它把当时活着的 agent 们重新接回来。
那 wait_agent 怎么靠这棵树判断"大家是不是都完工了、可以汇合了"?逻辑很顺:主 agent 想知道"我派出去的那批还有谁在跑",就遍历自己这棵树的 open 子边——每关掉一个子 agent,对应的边翻成 Closed,从 open 视角看就"消失"了;当 open 后代清空,就说明全员收尾、可以汇合。派生树的 open/closed 状态,就是天然的"汇合判据"。 这比维护一张任意消息网要简单得多,也正因为简单才不易出错——而多代理系统最怕的就是协调层自己出错。
为什么是树而不是网,背后还有一层主线意味。第 4 章说"对 agent 可见的才存在"。这里换个角度:Codex 故意只让"派生关系"成为持久、可查询的一等结构,而把"消息""谁在等谁"这些瞬时关系留在运行时、不进图。 因为派生关系是生命周期的骨架(决定谁该被回收、谁能被恢复),稳定且必须可靠;而消息往来是易逝的流量,没必要也不该固化成图。把该持久的持久化、把该易逝的留在内存——这是一个克制而清醒的设计取舍,而不是"图越大越强"。
还有一个细节值得停一下:派生树是持久化的(存进 state_db),但运行时的内存里也有一份活的树。control.rs 在关闭和恢复时反复在这两者之间对齐——close 时既翻持久化边的状态,又"关掉从内存树里能到达的活着的后代";恢复时则反过来,从持久化的 open 边把内存树重建出来。为什么要两份?因为它们职责不同:持久化的树负责"崩溃后还能找回当时的拓扑",内存里的树负责"此刻谁真的还在跑"。 一个 agent 可能在持久层记着 open(上次没正常关),但进程其实已经没了——所以 close_agent 里能看到 ThreadNotFound 的兜底分支:线程找不到了,但只要它在元数据里登记过(known_agent),照样把持久化边翻成 closed,把账平掉。这种"持久态 vs 运行态可能不一致、需要对账"的处理,正是分布式系统里最不起眼也最容易出 bug 的地方——Codex 把它显式地写进了 close_agent 的每一个 match 分支,而不是假装它不会发生。
寻址也呼应这棵树。第三节提过,每个 agent 有一个规范任务名,长得就像文件路径:/root/task1/task_3。spawn 工具的描述里写得清清楚楚——"如果你当前任务是 /root/task1,spawn 时给 task_name 传 'task_3',新 agent 的规范名就是 /root/task1/task_3。" 这个路径即派生树上的位置:斜杠分隔的层级,就是父子派生的层级。子 agent 之间可以用相对名(task_3)互相喊,跨子树则要用全路径——和文件系统一模一样。寻址方案本身就是派生树的投影,再次印证它是棵树。
〔配图 6:派生树 agent graph〕——一棵真实的树:root 在顶,向下派生 task1、task2;task1 再派生 task_3、task_4。每条边标 open(实线)或 closed(虚线灰)。画一个 close_agent 动作砍掉 task1 这条边 → 整条子树(task1/task_3/task_4)一起被回收变灰,旁注"关父即回收全部后代,不留孤儿"。右侧节点旁标规范名
/root/task1/task_3,注"路径 = 树上的位置"。底部:"agent graph 是一棵派生树,不是消息网。open 边清空即汇合。"
六、回到主线:分工的真正价值¶
绕了一圈机制,收束一句方法论上的话。多代理最被高估的卖点是"人多力量大"——加几个 agent,产出就翻几倍。但从 Anthropic 的数据看,单纯的并行加速只在"任务本就可切分"时才成立,否则只是 15 倍 token 的内耗。
多代理更深的价值,往往不在"多",而在"分"——把不同的视角/职责分到不同 agent 上,让它们相互制衡。我们在第二节看到,Codex 内置的 explorer(只回答代码库问题)和 worker(只负责被明确指定文件范围的实现)就是这种"分"的雏形:explorer 是一双只管看、看完就走的眼睛,worker 是一双只管在自己地盘里干的手。它们之所以被设计成独立角色,正是为了让"探索"不被"实现"的惯性带偏、让并行的 worker 因为 ownership 清晰而不互相踩脚——这恰好对症引子里的"现场三:重复劳动"。
其中最值钱的一种分工,是把"判断"从"执行"里独立出来:一个 agent 干活,另一个专门去检验、挑刺、打回。为什么这条分离线最值钱?因为执行者天然有"为自己的产出辩护"的倾向——它写的代码、它做的调研,它倾向于相信是对的;而一个没有沉没成本、只被赋予"挑毛病"职责的独立 agent,更容易看见执行者的盲区。这在多代理里几乎是免费的:你本来就有了 spawn 一个独立上下文的能力,让其中一个的人格变成"评审者"而非"作者",成本只是多一份角色 prompt。为什么"自己判自己"几乎必然失靠、为什么开箱即用的模型是个糟糕的 QA、又该怎么把"评估者"调教得真能挑出毛病——这正是下一章"评估"的主题。这里只埋一个伏笔:多代理把"角色分离"变成了可能,而最有价值的那条分离线,是判断与执行之间的那条。
一个澄清,免得误导:你可能在别处听过"planner / generator / evaluator 三角色"是多代理的经典模板——那是 Anthropic 等团队总结的方法论,不是 Codex 内置的角色。Codex 真正内置的角色是
default/explorer/worker(外加临时下线的awaiter)。方法论与具体实现要分开看:方法论告诉你"该怎么分工",Codex 的源码告诉你"分工落地需要哪些原语兜底"。本书的立场始终是后者——拆开杠杆本身。
本章小结¶
- 多代理是双刃剑:约 15 倍 token 成本,只在任务可并行、价值够高时才划算;高依赖、需共享同一上下文的任务(如大多数编码)不适合(Anthropic)。
- agent 抽象 = 身份 + 角色 + 状态机:
AgentStatus七态、is_final用"非终态的补集"判终态、Interrupted不算死;状态从事件推导,不手设。内置角色是default/explorer/worker,不是 planner/generator/evaluator。 - 六个真实协调动词:
spawn_agent(派生即派活)、followup_task(TriggerTurn,下一轮处理)、send_message(QueueOnly,不打断)、wait_agent(必带超时,等邮箱动静)、list_agents、close_agent(不能关自己/root)。没有assign_task。 - 多代理主控是 prompt + 工具,不是
ToolOrchestrator(那是单次工具调用的审批/沙箱/重试编排);主控人格在orchestrator.md。 - agent graph 是一棵派生树(
ThreadSpawnEdge,Open/Closed),不是消息网;spawn 加 open 边、close 翻 closed 并回收整棵后代子树(防孤儿)、恢复时按 open 边重建、wait 靠 open 边清空判汇合;规范任务名/root/...就是树上的路径。 - 分工的价值在"分"不在"多":最值钱的是把判断从执行里独立出来——下一章详解评估。
下一章,我们钻进"评估":谁来判断 agent"做对了",以及为什么"让它自己判自己"几乎必然失败。
参考来源¶
解剖标本(codex-rs 源码,HEAD=ad2012d6)
core/src/agent/registry.rs— agent 注册表core/src/agent/role.rs— 内置角色default/explorer/workercore/src/agent/control.rs— 派生树维护、close_agent回收后代core/src/agent/status.rs—agent_status_from_event、is_finalprotocol/src/protocol.rs—AgentStatus枚举agent-identitycrate — 基于 ed25519 的 agent 身份 JWTcore/src/tools/handlers/multi_agents_v2/spawn.rs—spawn_agent,spawn 即派活core/src/tools/handlers/multi_agents_v2/wait.rs—wait_agent,订阅 mailbox + 必带超时core/src/tools/handlers/multi_agents_v2/send_message.rs—MessageDeliveryModecore/src/tools/handlers/multi_agents_v2/close_agent.rs— 禁止关自己/rootcore/src/tools/handlers/multi_agents_v2/multi_agents_spec.rs— 工具描述与规范任务名core/src/tools/handlers/multi_agents/— 多代理工具 v1,与 v2 并存core/src/tools/orchestrator.rs—ToolOrchestrator,单次工具调用编排,与多代理无关core/templates/agents/orchestrator.md— 多代理主控人格agent-graph-storecrate — 派生树,仅ThreadSpawnEdge一种边core/src/codex_delegate.rs— 委派external-agent-sessionscrate — 外部 agent 会话collaboration-mode-templatescrate — 协作模式模板
方法论
- Anthropic《How we built our multi-agent research system》
- OpenAI Cookbook《Orchestrating Agents: Routines and Handoffs》