OpenAI Codex · 多 Agent 图 多 Agent 是一张要持久化的图 派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。 一句话速览 派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。
课程目标 读完能说清三件事:子 agent 是图上的节点,边只有 Open 和 Closed; send_message 只入队, followup_task 才叫醒;关机卸运行时,关边才从图里除名。第二天还能不能接着说话,先查边。
先玩一遍 · 派生一个探索者
从 /root 派出 Hypatia,看信怎么走、结果怎么回流
播放 单步 重置
收尾
关机 关边
发给孩子
关机只卸运行时,边仍是 Open。关边才写成 Closed。切一下再播,重启后的名单不一样。
根会话 /root 空闲,可以派孩子
尚无边
还没派出 等待 spawn 图上还没有这个节点
SQLite 边卡
还没有 thread_spawn_edges 记录。
信箱与注册表
MESSAGE FOLLOWUP RESULT
注册表空着。恢复只沿着 Open 边把身份贴回来。
逻辑轨迹 · 动画每一步对应源码里的哪一段
计算下一层深度,写入 ThreadSpawn registry.rs L87 用 task_name 拼出绝对路径 /root/explore_auth multi_agents_common.rs L117 从科学家名单抽出外号 Hypatia control/spawn.rs L32 非临时会话立刻 upsert 一条 Open 边 control.rs L776 send_message 按 QueueOnly 组包,只入队 message_tool.rs L103 信箱是会话级队列,入队后发 Mailbox 活动 input_queue.rs L127 followup_task 把 trigger_turn 打开,这才叫醒 handlers.rs L98 子 turn 结束,给父发 Result,不叫醒 session/mod.rs L1977 关机不改边;关边只标目标自己的入边 Closed legacy.rs L6 重启只把 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 表示从图的视角已经关掉。序列化是 open 和 closed 。 出处: 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 。相对名接到当前路径后面。 .. 和 . 被拒绝,所以不能靠相对路径爬到兄弟。角色可以收能力,不能替换父会话的权威。内置活角色是 default 、 explorer 、 worker 。 explorer.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_message 或 followup_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 接缝。 SubagentProvider 带 name 、 capabilities 、 inheritsParentContext 、 start 。进程内 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 才叫醒,完成通知不抢当前轮。关机卸运行时,关边才从图里除名。第二天先查边,再决定要不要挂回运行时。