专题篇章 · 拆开 OpenAI Codex

多 Agent 是一张要持久化的图

派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。

本页解决的问题

先给结论

「多 Agent 是一张要持久化的图」要解决的关键问题是什么?

派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。

判断标准

跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。

下一步

为一个自动化步骤写清输入、负责人、审批和恢复动作。

常见误区

一次运行成功了,却说不清发生了什么,也无法安全重放。

课程目标读完能说清三件事:子 agent 是图上的节点,边只有 Open 和 Closed;send_message 只入队,followup_task 才叫醒;关机卸运行时,关边才从图里除名。第二天还能不能接着说话,先查边。
先玩一遍 · 派生一个探索者
从 /root 派出 Hypatia,看信怎么走、结果怎么回流
收尾
关机只卸运行时,边仍是 Open。关边才写成 Closed。切一下再播,重启后的名单不一样。
根会话 /root 空闲,可以派孩子
还没派出 等待 spawn 图上还没有这个节点
SQLite 边卡
还没有 thread_spawn_edges 记录。
信箱与注册表
MESSAGE FOLLOWUP RESULT
注册表空着。恢复只沿着 Open 边把身份贴回来。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 计算下一层深度,写入 ThreadSpawnregistry.rs L87
  2. 用 task_name 拼出绝对路径 /root/explore_authmulti_agents_common.rs L117
  3. 从科学家名单抽出外号 Hypatiacontrol/spawn.rs L32
  4. 非临时会话立刻 upsert 一条 Open 边control.rs L776
  5. send_message 按 QueueOnly 组包,只入队message_tool.rs L103
  6. 信箱是会话级队列,入队后发 Mailbox 活动input_queue.rs L127
  7. followup_task 把 trigger_turn 打开,这才叫醒handlers.rs L98
  8. 子 turn 结束,给父发 Result,不叫醒session/mod.rs L1977
  9. 关机不改边;关边只标目标自己的入边 Closedlegacy.rs L6
  10. 重启只把 Open 后代的身份装回注册表control/spawn.rs L158
点播放,看一个探索者怎么长到图上,信怎么走,结果怎么回流。
图先于运行时节点一出生就有路径、外号和一条 Open 边。运行时可以卸掉,边还在,历史还在 rollout 里。
信和叫醒是两件事MESSAGE 只入队。FOLLOWUP 才开工。RESULT 飞回父信箱,默认不抢当前轮。
收尾决定明天还在不在切换上面的收尾再播一遍,看重启后注册表还认不认这个孩子。
教学示意:外号固定为 Hypatia,路径固定为 /root/explore_auth,用于展示图、信箱和边状态。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 父子关系做成有状态的边
它解决什么问题

你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent,工具回了一句任务名 /root/explore_auth,外号 Hypatia。过几分钟点 wait_agent,信箱里躺着一份 FINAL_ANSWER

第二天打开同一条 thread。子会话的运行时已经卸掉了。系统如果只记得昨天发生过一次调用,这条路径就找不到。你再发 send_message,控制面会报 live agent path not found

思路是什么

Codex 把父子做成有向边。边只有两个值。Open 表示还能当打开的 spawned agent 恢复。Closed 表示从图的视角已经关掉。序列化是 openclosed

出处:codex-rs/agent-graph-store/src/types.rs 第 4 至 12 行

图存在 SQLite 的 thread_spawn_edges 表。child_thread_id 是主键,一个孩子不能挂两个父。同一孩子再 spawn 一次,父和状态都会被新值盖住。会话正文不进这张表。子 agent 的模型上下文仍走自己的 rollout。图只回答谁生了谁,这条边现在开还是关。

出处:codex-rs/state/migrations/0021_thread_spawn_edges.sql 第 1 至 8 行

非临时会话在线程建出来之后立刻 upsert 一条 Open 边。写入失败只打 warn。子 thread 已经在跑,图可以稍后补。补写用 ON CONFLICT DO NOTHING,不会把已经 Closed 的边改回去。

出处:codex-rs/core/src/agent/control.rs 第 767 至 780 行

列后代时,过滤条件作用在走过的每一条边上。Some(Open) 只沿着 Open 走。父边已经 Closed 的子树,就算孙边仍是 Open,也不会被列出来。

出处:codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行

路径才是寻址键,外号是给人认的。根是 /root。相对名接到当前路径后面。... 被拒绝,所以不能靠相对路径爬到兄弟。角色可以收能力,不能替换父会话的权威。内置活角色是 defaultexplorerworkerexplorer.toml 是空文件。

出处:codex-rs/core/src/agent/role.rs 第 1 至 4 行

spawn_agent 新 child thread 路径 + 外号 upsert Open 边 SQLite child rollout JSONL 谁生了谁,开还是关 会话正文不进边表
一次 spawn 之后:身份进注册表,边进 SQLite,正文进 rollout。
为什么长期成立

重启之后必须能问:这个孩子还算活着吗。只记一次 spawn 事件回答不了。Open 才能进恢复列表。Closed 从子树遍历里消失。child_id 做主键,图保持树,遍历可以按深度 BFS。换个语言重写,最小形态仍是一张三列表:parent、child、status。

思路二 · 通信和叫醒分开
它解决什么问题

子 agent 转完了,要回一封 FINAL_ANSWER。如果这封信自带叫醒,父正在写用户看得见的最终答案时,会被子结果强行开一轮。用户看到半截话,再加一份突然插进来的完成通知。

思路是什么

通信种类有四个标签:Spawn、Message、Followup、Result。它们是 OTEL 用的标签。协议侧只有一份 InterAgentCommunication,靠 trigger_turn 区分要不要叫醒。

send_message 是 QueueOnly,只入队。followup_task 是 TriggerTurn,才叫醒。空消息直接拒。followup_task 不能打根节点。

出处:codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs 第 11 至 24 行

处理函数先入队,再决定要不要开工。trigger_turn 为假时,信继续躺着。只有它为真,或会话还有未完成的 durable sleep,才去开工。V2 的完成通知是 Result,trigger_turn 为假。父如果正在说话,这封信按信箱相位排队。

出处:codex-rs/core/src/session/handlers.rs 第 89 至 99 行

信箱是会话级队列。用户插话进 pending_input,子邮件进 mailbox_pending_mails,两条槽。兄弟之间只要用绝对路径,例如 /root/worker_b,就可以互发。相对名 worker_b 会接到自己后面,变成自己的孩子。

出处:codex-rs/core/src/session/input_queue.rs 第 76 至 80 行

send_message QueueOnly followup_task TriggerTurn 子 turn 结束 Result 父或子的信箱 先入队,再看 trigger_turn 躺着,等下一轮 MESSAGE / RESULT 开工,maybe_start_turn 只有 FOLLOWUP 走这里
三种信都进信箱。只有 followup 打开 trigger_turn,完成通知不抢当前轮。
为什么长期成立

叫醒权是稀缺的。谁能开一轮,谁就不能随便开。把投递和开工拆开,完成通知默认不叫醒,叫醒权留给 followup_task 和用户。换一套消息总线也用得上这根布尔:wake 还是只入队。

思路三 · 关边和关机是两件事
它解决什么问题

V2 驻留名额满了,会按 LRU 卸掉一个孩子。如果卸运行时顺便把边标成 Closed,这个孩子从 Open 子树消失。下次恢复找不到它。用户没关过它,系统自己把它除名了。

思路是什么

shutdown_live_agent 关掉活着的 agent,刷 rollout,发 Shutdown,从管理器摘掉 thread。边还是 Open。下次恢复仍会把它当活子树成员。

出处:codex-rs/core/src/agent/control/legacy.rs 第 6 至 8 行

close_agent 先把目标自己的入边标 Closed,再关机。后代的边不会在这里被标 Closed。父 turn 正常结束走 TurnComplete,不调用 close_agent。孩子继续跑,边保持 Open。

出处:codex-rs/core/src/agent/control/legacy.rs 第 48 至 58 行

V2 恢复分两步。先把 Open 后代的身份装回注册表,不重开运行时。真正有人 send_messagefollowup_task 时,才按 rollout 把 thread 挂回来。

出处:codex-rs/core/src/agent/control/spawn.rs 第 144 至 162 行

边是 Open 关机 关边 边仍 Open,运行时卸掉 边变成 Closed 恢复时身份装回注册表 恢复时这条边被滤掉
卸运行时不等于从图里除名。只有 close 才改 status。
关掉的是边,历史还在。
为什么长期成立

运行时活着和从图上除名是两件独立的事。驻留 LRU、进程重启、用户关窗口,都可能卸运行时。只有编排者明确 close,才写成 Closed。恢复时沿着 Open 边走。这条分账不依赖 Rust。

横向对比 · 子 agent 该抽象成什么

DSH:接缝优先,图是列举结果

DSH 把子 agent 做成可替换的 provider 接缝。SubagentProvidernamecapabilitiesinheritsParentContextstart。进程内 fork、Claude Code、Codex、ACP,都是同一张接口上的不同实现。

它也能列出孩子和后代,从活着的 session store 和可选的 persistence 只读枚举。没有一张 thread_spawn_edges 那样的 Open / Closed 边表。拓扑是 session header 的 origin: subagent 加上事后折出来的。换实现便宜,按边恢复要另做。

已核对源码 · 2026-08-22 · packages/subagent/subagent/src/types.ts 第 285 至 295 行 · DSH · Subagent 是一个 seam

Claude Code:工具调用加 transcript 侧链

模型面对的工具现名是 Agent。旧线名仍叫 Task,给权限规则、hook、恢复中的会话做兼容。Explore / Plan 是一次性的,父不会再续跑。

运行时给每个孩子发一个 agentId。没有单独的 spawn-edge 表。恢复靠读这个 id 对应的 transcript。父要列活孩子得扫侧链,没有按边过滤。Codex 多一张表、两套状态,换来重启后仍能按图说话。

已核对源码 · 2026-08-22 · restored-src/src/tools/AgentTool/constants.ts 第 1 至 4 行
课堂练习
01

Closed 父边下面的 Open 孙边还在吗

画一棵三层树:根到 A 为 Closed,A 到 B 为 Open。用 Some(Open)None 各列一次后代。B 会不会出现?

过滤条件作用在走过的每一条边上。然后对照 codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行的注释,把两种结果写下来。

Takeaway:子 agent 是图上的节点。边只有 Open 和 Closed,会话正文走 rollout。send 只入队,followup 才叫醒,完成通知不抢当前轮。关机卸运行时,关边才从图里除名。第二天先查边,再决定要不要挂回运行时。

「先玩一遍 · 派生一个探索者」的能力藏在每次交接里

「你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent ,工具回了一句任务名 /root/explore_auth ,外号 Hypatia。过几分钟点 wait_agent ,信箱里躺着一份 FINAL_ANSWER」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。

先写清状态,再增加能力

从「第二天打开同一条 thread。子会话的运行时已经卸掉了。系统如果只记得昨天发生过一次调用,这条路径就找不到。你再发 send_message ,控制面会报 live agent path not found」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。

  • 计算下一层深度,写入 ThreadSpawn registry.rs L87
  • 用 task_name 拼出绝对路径 /root/explore_auth multi_agents_common.rs L117
  • 从科学家名单抽出外号 Hypatia control/spawn.rs L32

成功路径不能代表系统可靠

用「过滤条件作用在走过的每一条边上。然后对照 codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行的注释,把两种结果写下来」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。

从「先玩一遍 · 派生一个探索者」走到「思路一 · 父子关系做成有状态的边」

「先玩一遍 · 派生一个探索者」先把问题落在「从 /root 派出 Hypatia,看信怎么走、结果怎么回流 播放 单步 重置 收尾 关机 关边 发给孩子 关机只卸运行时,边仍是 Open。关边才写成 Closed。切一下再播,重启后的名单不一样。 根会话 /root 空闲,可以派孩子 尚无边 还没派出 等待 spawn 图上还没有这个节点 SQLite 边卡 还没有 thread_spawn_edges 记录。 信箱与注册表 MESSAGE FOLLOWUP…」上;到了「思路一 · 父子关系做成有状态的边」,讨论继续推进到「你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent ,工具回了一句任务名 /root/explore_auth ,外号 Hypatia。过几分钟点 wait_agent ,信箱里躺着一份 FINAL_ANSWER」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。

把这条判断带到下一个场景

分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。

  • 「先玩一遍 · 派生一个探索者」:从 /root 派出 Hypatia,看信怎么走、结果怎么回流 播放 单步 重置 收尾 关机 关边 发给孩子 关机只卸运行时,边仍是 Open。关边才写成 Closed。切一下再播,重启后的名单不一样。 根会话 /root 空闲,可以派孩子 尚无边 还没派出 等待 spawn 图上还没有这个节点 SQLite 边卡 还没有 thread_spawn_edges 记录。 信箱与注册表 MESSAGE FOLLOWUP…
  • 「思路一 · 父子关系做成有状态的边」:你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent ,工具回了一句任务名 /root/explore_auth ,外号 Hypatia。过几分钟点 wait_agent ,信箱里躺着一份 FINAL_ANSWER
  • 「最后的要点」:send_message 按 QueueOnly 组包,只入队 message_tool.rs L103

最后的「最后的要点」把讨论落到「send_message 按 QueueOnly 组包,只入队 message_tool.rs L103」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。

标记为已学完 阅读进度会自动记录
← 上一篇下一篇 →

继续阅读

同一条线上的下一篇。

文章讨论

读到这里,留下一个判断。

把刚想明白的地方、还没想通的问题,留给下一位一起学习的人。

正在讨论 多 Agent 是一张要持久化的图 拆开 OpenAI Codex
3条讨论文章讨论 · 与共学社区同步
在共学社区查看
AM
Asha Morgan内容编辑
观点实践记录

我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。

文章讨论7 有帮助
LH
Lin Harper独立开发者
观点观点

读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。

文章讨论5 有帮助
KM
Kiki Moore产品运营
问题问题

如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。

文章讨论4 有帮助