学 AI 产品 · 专业 AI 产品经理播客第 3 章 · T3 解剖 OpenAI Codex:把安全观写进类型系统 · EP 15
第 3 章 · EP 15

多 Agent 是一张要持久化的图

时长 16:14音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场:明天还能不能接着说话2:00
2:00先玩一遍:看一个探索者怎么长到图上1:44
3:44思路一:父子关系是一张有状态的边1:51
5:36寻址:路径是键,外号是给人认的1:31
7:07思路二:投递和叫醒必须分开1:58
9:05信箱:会话级队列和两条槽1:28
10:34思路三:关边和关机是两笔账1:42
12:16横向对比:DSH 和 Claude Code 怎么做1:34
13:50可带走的设计原则2:23
解读全文

codex16 · 多 Agent 是一张要持久化的图

模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页为《学 AI 产品,从入门到精通》配套解读,音频为二次演绎配音版。

一句话速览

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

本集要回答的工程问题是:多 Agent 系统里,主会话昨天派出去的那个孩子,今天还在不在。

本集解决什么工程问题

一个典型失败场景:

  1. 主会话调用派生工具,派一个探索者去查 auth 模块;
  2. 工具返回任务名 /root/explore_auth,外号 Hypatia;
  3. 几分钟后调用 wait_agent,信箱里躺着一份 FINAL_ANSWER;
  4. 第二天打开同一条 thread,子会话的运行时早就卸掉了;
  5. 再次 send_message,控制面报 live agent path not found。

根因不是 bug,是模型选错了:系统只记录了"昨天发生过一次派生调用"这件事,记录的是事件,不是关系。调用结束事件就归档,路径在系统眼里等于从未存在。

对玩具 demo 无关紧要;对需要跨天、跨进程、跨重启活下去的编排系统,这是核心命题。

能力地图

读完本集,你应当能说清四件事:

能力落点一句话判断标准
把父子关系建成持久化图thread_spawn_edges 表能画出谁能生谁、这条边现在是 Open 还是 Closed
区分投递与唤醒trigger_turn 布尔能判断一封信该不该打断对方当前轮次
区分关机与关边shutdown_live_agent / close_agent能解释为什么 LRU 淘汰不能顺手标 Closed
按地址模型避坑绝对路径寻址能说清相对名为什么会发给自己孩子

三条主线 · 源码落点

主线一 · 父子关系是有状态的边

  • 边只有两个值:Open(可作为打开的 spawned agent 恢复)与 Closed(从图视角已关掉),序列化为 open / closed。
  • 出处:codex-rs/agent-graph-store/src/types.rs 第 4–12 行。
  • 边存在 SQLite thread_spawn_edges 表,child_thread_id 是主键 → 一个孩子不能挂两个父,同一孩子再 spawn 一次父和状态会被新值盖住,图保持树形。
  • 出处:codex-rs/state/migrations/0021_thread_spawn_edges.sql 第 1–8 行。
  • 会话正文不进这张表,子 agent 的模型上下文仍走自己的 rollout。边表只回答"谁生了谁、这条边开还是关"。
  • 非临时会话在线程创建后立刻 upsert 一条 Open 边;写入失败只 warn,不让派生失败(子 thread 已在跑,图可后补)。补写用 ON CONFLICT DO NOTHING,不会把已 Closed 的边改回 Open。
  • 出处:codex-rs/core/src/agent/control.rs 第 767–780 行。
  • 列后代的过滤作用在走过的每一条边上,不在起点。父边已 Closed 的子树,即使孙边仍是 Open,也不会出现在 Some(Open) 结果里。
  • 出处:codex-rs/agent-graph-store/src/store.rs 第 49–54 行。

主线二 · 通信和叫醒分开

  • 通信种类四个标签: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 行。

主线三 · 关边和关机是两笔账

  • 反例:驻留名额满 → 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 行。

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

方案抽象方式有无边表恢复路径取舍
Codex图:节点 + Open/Closed 边有,thread_spawn_edges沿 Open 边装回注册表,再按需挂运行时多一张表一套状态机,换来实现成本高
DSH接缝:SubagentProvider 接口无session store 只读枚举 + 可选 persistence换实现便宜,按边恢复要另做
Claude Code工具调用 + transcript 侧链无读 agentId 对应 transcript轻量,父列活孩子要扫侧链,无按边过滤
  • DSH 的 SubagentProvider 字段:name、capabilities、inheritsParentContext、start。进程内 fork、Claude Code、Codex、ACP 同为一张接口上的实现。拓扑由 session header 的 origin: subagent 事后折出。
  • 已核对源码 · 2026-08-22 · packages/subagent/subagent/src/types.ts 第 285–295 行
  • Claude Code 模型侧工具现名 Agent,旧线名 Task 保留以兼容权限规则、hook、恢复中的会话。Explore / Plan 是一次性的,父不再续跑。
  • 已核对源码 · 2026-08-22 · restored-src/src/tools/AgentTool/constants.ts 第 1–4 行

结论:Codex 多一张表 + 一套状态机,换来"重启后仍能按图说话"。这是明确的交易,不是免费的优越。

约束说明

使用这套机制时,以下边界不可越过:

  1. 一个孩子只能有一个父亲。child_thread_id 主键保证图永远是树,不是 DAG。若需求是"一个孩子挂多个父亲",需重新设计,不要在树上打补丁。
  2. 相对路径禁止向上爬。.. 与 . 被明确拒绝,不能靠相对路径摸到兄弟节点。横向移动必须是显式的、可审计的绝对路径。
  3. 角色只能收能力,不能替换父会话的权威。内置活角色为 default、explorer、worker;explorer.toml 是空文件——能力差异不在提示词里。一旦子 agent 能反向决定父权限,权限模型即失效。
  4. 叫醒权稀缺。默认不醒。完成通知、状态回流、日志一律不触发新一轮;只有显式 followup 与用户输入才唤醒。
  5. 关机不等于除名。只有编排者明确 close,才写 Closed。LRU 淘汰、进程崩溃、机器重启都会卸运行时,但不改边。
  6. 会话正文不入边表。边表只管拓扑与状态,模型上下文走 rollout。二者混在一起会让迁移与回放变复杂。

审查清单

在 review 一个多 Agent 编排实现时,逐条对照:

  • 派生产生的关系是落库的边,还是仅存在于内存/日志里的事件?
  • 边的状态枚举是否只有 Open / Closed 两个值,序列化方式是否稳定?
  • 子节点主键选的是谁?能否保证"一个孩子一个父亲"?重复 spawn 是覆盖还是报错?
  • 遍历后代时,过滤条件是否作用在走过的每一条边上,而不是只查起点?
  • 每一条跨 agent 消息是否显式声明了要不要唤醒?默认值是不是"不醒"?
  • 用户插槽与子邮件插槽是否分离?用户是否天然优先?
  • 地址解析是否拒绝 .. 与 .?兄弟互发是否强制绝对路径?
  • 淘汰/卸载运行时是否会误改边状态?
  • 父 turn 正常收尾是否会误关子节点?
  • 恢复是否分两步(先装身份,再按需挂运行时)?重启后是否不必拉起全部运行时?
  • 角色配置是否会覆盖父会话权限?

提示一 · 先定图,再写派生的那个函数

最常见的错误顺序是一上来就写"怎么起子进程、怎么灌上下文",写完才发现重启后找不到孩子。正确顺序反过来:先定张贴持久化的边表(最小形态三列:parent、child、status),再定运行时。运行时可以重写,边表一旦没设计好,后面所有持久化都要返工。

提示二 · 给每条消息加一根叫醒布尔

不必照搬 Codex 的四标签,但一定要在协议里留一个"要不要唤醒"的字段,并让它的默认值是 false。这样完成通知、心跳、日志回流天然不打断;需要打断时由调用方显式置 true。将来换消息总线,这根布尔仍然成立。

提示三 · 把"关机"和"关边"拆成两个接口

一个接口只卸运行时(刷状态、发通知、从管理器摘除),另一个接口才改图上的状态。命名上也要区分清楚,避免后来者把两个语义混用一个动词。评审时可以把这一条当作硬门槛:凡是淘汰路径里出现写 Closed 的代码,一律打回。

提示四 · 遍历过滤写成循环而不是只查一层

列后代时最容易偷懒的地方是"只检查第一条边"。正确写法是在遍历过程中对走过的每条边套同一个过滤谓词。可以在单元测试里构造一棵三层树:根到 A 为 Closed,A 到 B 为 Open,分别用带过滤和不带过滤两种方式列后代,断言 B 在前者里不出现。

提示五 · 兄弟互发的地址一律写全

在代码规范里直接规定:跨 agent 寻址只允许绝对路径,相对名保留给"派生自己的孩子"这一种语义。这样即使有人写错,结果也是可预期的(发给了自己孩子),而不是不可预期的越权访问。

自测卡

  1. 画一棵三层树:根到 A 为 Closed,A 到 B 为 Open。分别用 Some(Open) 与 None 各列一次后代,B 会不会出现?对照 store.rs 第 49–54 行的注释写下两种结果。
  2. 父正在写用户可见的最终答案时,子 agent 的 Result 到达。它会不会打断当前轮?为什么?
  3. LRU 淘汰掉一个孩子的运行时之后,这条边是什么状态?下一次 restore 会不会带上它?
  4. 同一个孩子在两个不同父会话下各 spawn 一次,边表里最终剩几条记录?父是谁?

要点回顾(Takeaway)

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

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本页正文为二次演绎配音版的配套解读,与音频逐段对应。