雨天小六

读懂 Codex(七):从一个 Agent 到一支小队

· 更新于 2026-08-01 · 专栏:读懂 Codex

#Codex#Agent Runtime#Multi-Agent#并发#软件架构

第六章追踪的工具,最终都落在文件、进程或网络上。多 Agent 工具也从同一条 Tool Router 进入 Runtime,但它产生的副作用很特别:spawn_agent 创建的不是一个普通后台函数,而是一条新的 Codex Thread。

这条 Thread 有自己的 Session、历史、Turn、模型采样和工具调用。父 Agent 可以继续当前工作,子 Agent 同时处理另一项任务;子 Agent 还可以继续派生下一层。它们共用工作目录,因此一个 Agent 写入的文件会立刻被其他 Agent 看见,但它们不会共用同一段模型上下文。

这组边界决定了 Codex 中“协作”的真实含义:共享外部世界,分离思考过程,通过显式协议交换任务和结果。

Agent 不是套在模型外面的一个新类

在当前实现中,找不到一个包办一切的 Agent 对象。一个可运行的 Agent 是几部分状态的组合:

Agent
├── ThreadId / AgentPath        身份和寻址
├── CodexThread + Session       操作入口与长期运行状态
├── SessionSource               父 Thread、深度、角色、昵称
├── Config + Role               模型、指令、权限和运行配置
├── History + Turn + Task       独立的思考与执行过程
└── shared AgentControl         派生、通信、容量和拓扑控制

因此,创建子 Agent 不能简化为“再调用一次模型”。新的模型请求必须附着在一条可持续运行的 Thread 上,否则它无法接收后续消息、被中断、恢复历史或再次执行任务。

父子 Agent 之间也不是对象字段直接调用。每条 Thread 的 Session 都持有一个 AgentControl,而同一根节点派生出的所有子 Agent 共享同一个控制实例。这个实例内部再连接 Agent Registry、V2 驻留管理器、执行容量限制器和整棵树共享的 Rollout 预算。

Codex 多 Agent 树中独立 Thread、共享 AgentControl、AgentRegistry、执行容量、驻留容量、ThreadManager 和 AgentGraphStore 的关系
图 7-1:每个 Agent 独占 Thread、Session 和模型历史;同一 root 树共享 AgentControl。AgentRegistry 负责运行时寻址,ThreadManager 持有当前加载的 Thread,AgentGraphStore 另存可恢复的父子边。

AgentControl 只保存指向 ThreadManagerState 的弱引用。原因不是语法偏好,而是所有权边界:如果控制器反过来强持有全局 Thread Manager,就会形成 ThreadManager → CodexThread → Session → AgentControl → ThreadManager 的引用环,让已经关闭的会话继续存活,甚至造成影子持久化。

AgentPath 是任务树中的地址

V2 不再要求模型记住一串 UUID。根 Agent 的规范地址是 /root。如果它用任务名 research 派生子 Agent,新地址就是:

/root/research

research 再派生 tests,地址变成:

/root/research/tests

/root/research 内使用相对目标 tests,会解析到自己的子节点;若要联系兄弟节点 /root/implement,就要给出完整地址。路径段只允许小写字母、数字和下划线,root... 以及含 / 的任务名都会被拒绝。

这不是展示层的别名。AgentRegistry 同时维护 AgentPath → ThreadIdThreadId → AgentPath,消息工具先解析路径,再把操作提交给目标 Thread。Spawn 还会提前预留路径,两个并发调用不能侥幸创建出同名子节点。

SessionSource::SubAgent(ThreadSpawn) 则把关系写入子 Session:父 Thread ID、派生深度、AgentPath、昵称和角色都随 Thread 保存。AgentPath 负责可读寻址,ThreadId 负责稳定身份,二者不能互相替代。

Spawn 是一项带回滚的创建事务

spawn_agent 的 Handler 先把模型参数转成子 Agent 配置和派生来源,再交给 AgentControl。控制层按下面的顺序工作:

  1. 判断当前会话实际使用 V1 还是 V2;
  2. 检查这次派生是否还有执行容量;
  3. V2 预留一个驻留槽,V1 预留总 Thread 名额;
  4. 在 Registry 中预留任务路径和昵称;
  5. 从父 Session 继承环境快照,以及允许继承时的 Exec Policy;
  6. 创建全新 Thread,或从父 Rollout 派生历史;
  7. Thread 成功后才提交 Registry 和驻留预留;
  8. 把父子边以 Open 状态写入 AgentGraphStore;
  9. 向子 Session 发送第一条 InterAgentCommunication,并触发它的第一个 Turn。

路径、昵称和容量预留都由带 Drop 清理语义的 Reservation 管理。只要 Thread 创建、历史加载或配置应用中途失败,未提交的预留就会自动归还。这一点解决的是并发下的泄漏,而不只是错误提示:若先增加计数,失败时靠每个分支手工减回,很容易留下“没有 Agent 却占着最后一个槽”的状态。

用接近 Python 的伪代码表达,这项事务大致是:

async def spawn_agent(request: SpawnRequest) -> AgentHandle:
    check_execution_capacity(request.parent)

    async with residency.reserve() as resident_slot:
        async with registry.reserve(request.task_name) as agent_slot:
            child_config = inherit_runtime_config(request.parent)
            history = await build_child_history(request.fork_turns)
            child = await thread_manager.create_thread(
                config=child_config,
                history=history,
                source=request.thread_spawn_source,
                agent_control=shared_agent_control,
            )

            agent_slot.commit(child.thread_id)
            resident_slot.commit(child.thread_id)
            await graph.upsert(request.parent.thread_id, child.thread_id, "open")
            await child.mailbox.send(request.initial_task, trigger_turn=True)
            return AgentHandle(child.thread_id, agent_slot.agent_path)

伪代码把 V1/V2 分支合并了,但保留了源码中的关键顺序:先预留,创建成功后提交,拓扑落盘后再投递首个任务。

Fork 复制的是可用上下文,不是父 Agent 的全部内部轨迹

V2 的 fork_turns 有三种取值:

参数子 Agent 获得的父历史适用情况
none不复制对话历史任务自包含,只需要初始消息和共享工作区
all复制经过清洗的完整有效历史;也是默认值子任务依赖当前讨论的来龙去脉
正整数,例如 3只保留最近三个有效 Turn,再清洗需要近期上下文,但不想携带整段会话

“完整历史”仍不是字节级克隆。派生前,父 Thread 会先把异步 Rollout 写入刷新到存储;截取所需 Turn 后,Runtime 再按模型上下文规则过滤条目:

  • 保留 System、Developer、User 消息;
  • Assistant 消息只保留最终回答阶段;
  • 丢弃 Reasoning、函数调用、工具输出、Tool Search 和 Shell 调用等中间轨迹;
  • 丢弃旧的 Agent 间通信,避免把发给父 Agent 的消息误当成子 Agent 的新任务;
  • 保留 Compaction 和必要的 Session 元数据;
  • 只有安全的全量 Fork 才沿用父 Thread 的 TurnContext、WorldState 参考基线,截断 Fork 在第一次子 Turn 中重新构造动态上下文。

这样处理同时服务两个目标。第一,子 Agent 能理解用户目标和父 Agent 已经确认的结论;第二,它不会继承一批已经完成的工具调用,再次面对无法配对的 Call/Result,或把父 Agent 的隐藏推理当成自己的待执行计划。

角色指令也不能简单复制。V2 会移除父 Agent 的协作提示,并在可识别时把父 Developer Instructions 片段替换成子 Agent 指令。压缩历史中的 replacement history 也使用同一清洗规则。若 fork_turns=all,源码禁止额外覆盖 agent_type,因为全量参考上下文与另一套角色指令可能产生冲突;none 或最近 N 个 Turn 的 Fork 才允许显式选择另一角色。

由此可以得到一个实用判断:子任务越独立,越适合 none;越依赖父 Agent 刚刚建立的概念和约束,越需要最近 N 个 Turn 或 all。Fork 越多并不天然越好,它会增加上下文成本,也会扩大子 Agent 需要重新辨认的历史范围。

消息进入 mailbox,是否启动 Turn 由一个位决定

V2 的通信协议不是“向目标 Prompt 追加字符串”。InterAgentCommunication 至少携带作者路径、接收者路径、内容和 trigger_turnsend_messagefollowup_task 走同一个提交函数,关键差异只有投递模式:

工具或事件trigger_turn目标空闲时典型用途
Spawn 的初始任务true立即启动第一个 Turn创建并开始工作
send_messagefalse只进入 mailbox,不开始采样给正在工作的 Agent 补充信息
followup_tasktrue启动新 Turn让已完成或空闲的 Agent 继续任务
子 Agent Resultfalse只进入父 mailbox把完成结果交还父 Agent

目标 Agent 正在运行时,消息先进入 Session 级 InputQueue。Runtime 会在可接收输入的消息边界,或当前工具调用完成后,把待处理内容交给活动 Turn;若当前阶段不允许同 Turn 投递,就留到下一 Turn。followup_task 还能在目标空闲时启动新 Turn,send_message 不能。

这个区别避免了两种常见浪费。补一句“测试文件在 tests/runtime.rs”不值得额外发起一次模型请求;而“现在根据审查结果修复问题”显然需要目标 Agent 再运行。是否唤醒由发送方明确表达,不让接收方靠猜测决定。

V2 还会在投递前调用 ensure_v2_agent_loaded。如果目标此前因驻留容量不足被卸载,Runtime 会从已存储 Rollout 恢复原 ThreadId、SessionSource、角色和历史,再把通信放进 mailbox。对发送方来说,目标仍由同一个 AgentPath 寻址,不需要显式执行 resume_agent

子 Agent 的最终回答怎样回到父 Agent

子 Agent 每次发出事件时,Session 都会更新 AgentStatus

TurnStarted                         → Running
TurnComplete(last_agent_message)   → Completed(message)
TurnAborted(Interrupted/Budget)     → Interrupted
其他 TurnAborted / Error            → Errored(reason)
ShutdownComplete                    → Shutdown

Interrupted 在这里不是最终状态。它表示当前 Turn 停下了,但这条 Agent Thread 还能收到后续任务。Completed 也只是“这一轮已经给出可交付结果”,不等于 Thread 被销毁;一次 followup_task 可以让它重新进入 Running

V2 子 Session 遇到 TurnCompleteTurnAborted 时,会检查状态是否真正终结。如果是 CompletedErroredShutdown,它把最后回答或错误包装成标准 Result 通信,然后投递到直接父 Session。这个 Result 的 trigger_turn=false,所以它不会为了宣布完成而强行启动父 Agent 的新 Turn。

Codex V2 从 spawn_agent 创建子 Thread、Fork 历史、写入 AgentGraphStore、触发子 Turn,再把终态 Result 投递到父 InputQueue 的时序
图 7-2:Spawn 的初始通信会唤醒子 Agent,完成 Result 只写入父 mailbox。wait_agent 订阅的是 mailbox 或新输入活动,不直接轮询指定子 Agent 的状态。

这里最容易被工具名称误导。V1 的 wait_agent(targets) 会订阅指定 Thread 的状态,直到其中至少一个进入最终状态;V2 的 wait_agent 没有目标参数,它等待当前父 Session 出现两类活动之一:

  • mailbox 收到 Agent 消息或结果;
  • 用户输入对当前 Turn 进行了 Steer。

超时只表示这段时间没有活动。wait_agent 返回的也是“完成、被新输入打断或超时”,真正的子 Agent Result 仍是 mailbox 中的模型输入。这样 V2 不需要让父 Agent同时维护一份“我正在等哪些 UUID”的列表;任务地址和消息信封已经给出了来源。

因此不能把 wait_agent 当成必须调用的 Join。父 Agent 完全可以 Spawn 后继续检查其他文件,子 Agent 完成时 Result 自然进入 mailbox。只有下一步确实依赖结果、父 Agent 已没有别的可推进工作时,等待才有意义。

Interrupt、Close 和卸载是三种不同操作

“让 Agent 停下”至少有三种语义:

操作当前 TurnThread 是否保留父子边能否继续
interrupt_agent发送 Op::Interrupt,状态可变为 Interrupted保留保持 Open可以接收新任务
V1 close_agentFlush 后 Shutdown,并清理活动后代从内存 Registry/Manager 移除标记为 Closed需按已存 Rollout 显式 Resume
V2 Residency 卸载只选择无活动 Turn、无 mailbox 的终态/中断 Thread从 ThreadManager 卸载,身份元数据保留保持 Open下次消息前自动恢复

V2 暴露的协作工具里有 interrupt_agent,没有 V1 的 close_agentresume_agent。这不是少做了生命周期管理,而是把“暂时不占内存”和“从协作拓扑明确关闭”分开:普通空闲 Agent 由 Residency 自动换出和恢复;中断只停止当前工作;持久边的关闭仍是更强的内部生命周期操作。

关闭一个父 Agent 时,控制层还会遍历当前内存中的派生后代并逐一 Shutdown,防止留下失去控制入口的活动 Thread。中断不会级联,也不会释放路径或容量计数。

并发槽和驻留槽限制的不是同一件事

多 Agent 最容易出现的错误模型是“配置最多四个 Agent,所以第五个一定不能存在”。V2 实际把资源分成两个维度。

第一个维度是执行容量。只有正在运行 Turn 的 V2 子 Agent 占用 AgentExecutionGuard;根 Agent不计入这个 Guard。Guard 与 RunningTask 生命周期绑定,Task 完成或取消后通过 Drop 归还。UserInputtrigger_turn=true 的通信在启动空闲 Turn 前都要检查容量;只入队的 send_message 不消耗新的执行槽。

第二个维度是驻留容量。它限制当前加载在 ThreadManager 中的 V2 子 Thread 数量。容量用尽时,Residency 按近似 LRU 顺序寻找可卸载对象,但候选必须同时满足:

  • 状态为 CompletedErroredInterrupted
  • 没有活动 Turn;
  • mailbox 中没有待处理消息。

卸载前必须物化并刷新 Rollout,再 Shutdown Session、移出 ThreadManager。Registry 中的 AgentPath 和持久化 Open 边不因此消失,所以后续通信仍能用原路径恢复它。

两种容量解决不同问题:

Execution capacity = 同时有多少个子 Agent 正在花模型和工具计算
Residency capacity = 同时有多少条子 Thread 占用运行时内存
Persistent graph   = 总共有多少条仍可恢复的协作关系

如果所有执行槽都在 Running,新 Spawn 或 Followup 会被拒绝,即使某个已加载 Thread 理论上可以卸载;如果只是驻留满了,但存在干净的已完成 Agent,Runtime 可以先卸载它,再创建或恢复另一条 Thread。

V1 的限制更直接:Registry 用总 Thread 计数和 agents.max_depth 限制派生。默认深度为 1,达到深度后连 Spawn 工具都不会继续暴露。V2 的工具规划明确忽略这项 V1 深度上限,允许子 Agent继续派生;它依靠执行容量、驻留容量、共享预算和调用规范约束资源。因此在解释配置时,必须先确认会话运行的是 V1 还是 V2。

Registry 管现在,Graph Store 管重启以后

AgentRegistry 与 AgentGraphStore 都保存“Agent 关系”,但权威范围不同。

Registry 是一棵 root 会话树共享的内存索引。它负责路径解析、ThreadId 反查、角色、昵称、并发 Spawn 预留和运行时计数。V2 Residency 卸载 Thread 时不会释放这份身份,因此消息仍能找到待恢复的目标;真正 Shutdown 或关闭才会释放运行时注册。

AgentGraphStore 是存储中立的持久化边界,只记录定向父子关系与 Open/Closed 状态:

parent_thread_id ──Open──> child_thread_id
parent_thread_id ─Closed─> child_thread_id

Open 表示子 Thread 仍处于活动或可恢复的协作图中,Closed 表示这条入边已经被明确关闭。它不保存当前是否 Running,也不替代 AgentStatus。查询后代时,状态过滤会应用到遍历的每条边;关闭父边后,不能绕过它继续把下层节点当成开放后代。

V1 从 Rollout 恢复一棵 Agent 树时,会沿 Graph Store 的开放边递归恢复后代。V2 则更多采用按需恢复:Registry 提供地址,存储提供历史,ensure_v2_agent_loaded 在通信前重新加载目标。下一章讨论持久化时,会继续拆开 AgentGraphStore、Rollout、ThreadStore 与 State 各自负责的事实。

共享工作区不等于共享记忆

同一棵 Agent 树通常使用相同 cwd、文件系统和工具能力。父 Agent 派出一个实现者和一个测试者时,两者可以同时读同一仓库,也可能同时修改同一文件。Codex 没有因为它们属于一支小队,就自动提供数据库事务或文件锁。

这带来两条工程约束:

  1. 子任务的写入范围应尽量不重叠;否则最后写入者可能覆盖另一方的改动,父 Agent 还要额外合并冲突。
  2. 共享文件变化不等于共享语义。一个 Agent 新建了 src/cache.rs,另一个 Agent 能在文件系统中看到它,但除非重新读取文件或收到消息,它的模型上下文并不知道为什么要这样设计。

反过来,Agent 的 History、InputQueue 和活动 Turn 都是独立的。发送消息会形成有作者和接收者的通信记录,不会把发送方整段对话偷偷合并进接收方 History。Fork 只发生在创建时,而且使用前述清洗策略。

这种“外部状态共享、内部上下文隔离”比共享一条长对话更容易控制 Token 和故障范围,代价是任务拆分与结果整合必须显式完成。

Role、Skill 和 MCP 不是三种 Agent

多 Agent 机制经常与扩展能力混在一起讨论,但它们改变 Runtime 的位置不同:

机制改变什么是否创建独立 Thread是否有独立状态与 mailbox
Agent RoleSpawn 时叠加子 Agent 配置、模型或指令角色本身不创建;由 Spawn 创建取决于对应子 Agent
Skill向当前 Agent 注入一套任务说明和资源
MCP / Plugin / App给当前 Runtime 增加工具、资源或能力入口
Multi-agent Spawn创建新的 Thread、Session、历史和任务循环

Role 回答“这条新 Thread 应按什么配置工作”,Skill 回答“当前 Agent 完成某类任务应遵守什么流程”,MCP 和 Plugin 回答“当前 Runtime 还能调用什么能力”。只有 Spawn 改变 Agent 树的拓扑。

这也是为什么“安装更多 Skill”不能替代并行 Agent,而“多开几个 Agent”也不会自动获得新的外部系统能力。前者扩充同一个执行者的方法,后者增加彼此隔离的执行者。

调试协作链要同时看地址、状态和队列

多 Agent 故障通常不是一句“子任务没回来”能够定位的。可以按现象拆开检查:

现象优先检查
Spawn 提示路径已存在当前 AgentPath 下是否重复使用 task_name,失败预留是否已释放
子 Agent 缺少背景fork_turns 是否为 none,初始 message 是否自包含
Fork 后出现旧工具噪声是否错误绕过了 Rollout 清洗,Call/Result 是否仍成对
send_message 后目标没有开始工作该工具本来就不触发 Turn;需要任务时用 Followup
wait_agent 很快返回但没有指定状态表当前是否为 V2;它等待 mailbox/Steer,不是 V1 目标状态订阅
完成 Result 没有唤醒空闲父 AgentResult 的设计就是 queue-only;检查 mailbox,或让父 Turn主动等待
Agent 显示 Interrupted 后仍可用Interrupted 不是最终状态,也没有关闭父子边
路径能解析但 Thread 不在 Manager可能被 Residency 卸载,应检查自动恢复和 Rollout 读取
达到上限但 Agent 总数看起来不多区分正在 Running 的执行槽与已加载 Thread 的驻留槽
两个 Agent 的代码互相覆盖文件系统共享但无自动写冲突协调,重新划分写入范围

日志至少要关联 root Session ID、ThreadId、AgentPath、parent ThreadId、Turn ID、通信 ID、trigger_turn、AgentStatus,以及 Registry/Residency 的变化。只记 Agent 昵称不够:昵称可以重复轮换,AgentPath 才是 V2 工具使用的规范地址。

一支小队仍然由同一个 Runtime 约束

Codex 的多 Agent 没有绕开前六章介绍的机制。Spawn、Message 和 Interrupt 首先仍是 Tool Call;每条子 Thread 仍按 Turn 和 Step 运行;工具仍受原有权限与沙箱约束;结果仍要写进 History 才能影响后续采样。

新增的只是另一层控制协议:

Tool Call
→ 预留路径、容量与驻留槽
→ 创建或 Fork 子 Thread
→ 持久化 Open 父子边
→ trigger_turn 任务通信
→ 子 Agent 独立运行
→ 终态 Result 写入父 mailbox
→ 父 Agent 整合结果

这条链中最重要的分界有四条:Agent 共享工作区但不共享模型历史;Full Fork 也要清洗上下文;消息投递与启动 Turn 是两个动作;运行时 Registry、加载中的 Thread 和持久拓扑分别回答不同问题。

下一章将沿最后一条分界继续:当进程退出、Thread 被卸载或历史经过压缩以后,Codex 到底把哪些事实写进 Rollout、ThreadStore、State 和 AgentGraphStore,又怎样从这些材料恢复一项尚未结束的工作。

延伸阅读

第七章细节导航(7.1—7.40)

评论


← 返回文章列表