Codex 怎么和子 agent 协作:从一次任务分派看消息、上下文与状态管理

Jellow 编辑发布,AI 辅助生成并完成技术核对。 计算示例和实测结果会在文中分别说明。

让三个 agent 一起改一个项目,看起来只需要三段提示词。真正接到工程里,很快就会遇到更具体的问题:任务交出去以后,主 agent 怎么知道对方开始了?中途补一句话,会不会触发第二个任务?等到了消息,是否意味着可以收尾?子 agent 已经修改的文件,取消以后会自动恢复吗?

这些问题决定多 agent 协作能不能稳定运行。模型负责理解任务、选择动作,运行时则需要把动作落成可追踪的线程、输入、事件和状态变化。

本文选取 OpenAI 开源 Codex 的 rust-v0.153.2 标签、MultiAgentV2 路径作为阅读对象,核对日期为 2026-09-08。该版本同时保留旧的多 agent 实现;本文不把两套工具混用,也不把内部函数当成所有客户端都稳定开放的 API。示例程序是解释设计的教学模型,不是 Codex 的 Python 移植版。

先把三个“身份”分开

假设主 agent 在排查一次推理服务延迟回退,把工作分成两块:一个子 agent 读调度器,另一个读注意力内核,主 agent 自己整理基准条件。

至少有三种东西需要分别命名:

概念 要解决的问题 示例
agent 的任务路径 人和模型在协作树里如何称呼它 /root/scheduler
thread 标识 运行时把输入路由到哪条会话 一个 ThreadId
turn 标识 这条会话当前进行的是哪一轮工作 某次开始、补充和完成事件关联的轮次

路径并不意味着操作系统真的有一个同名进程。在线程解析入口中,Codex 会先尝试把目标解析成 ThreadId,否则再解析 agent 引用。目标解析源码展示了这个分工。

对外集成时还会遇到 App Server 的 thread/startturn/startturn/steer 等方法。它们属于客户端与 Codex 运行时之间的协议;本文后面讲的 spawn_agent 等,则是供 agent 调用的协作工具,二者不能直接互换。官方 App Server 文档说明,turn/steer 为正在进行的 turn 补充输入,还要求匹配 expectedTurnId协议说明

可以由此提炼一个自己的设计原则:定位参与者与定位某次工作,要使用不同的标识。 一个长期存在的 worker 可以连续处理多次任务;它叫“scheduler”,不代表它的所有历史结果都属于当前这次排查。

一次 spawn,在运行时里经过哪些步骤

主 agent 生成工具调用,只是流程的起点。沿着 V2 的 spawn 处理器往下读,可以看到几类工作:

  1. 解析参数,检查任务信息和上下文继承选项。
  2. 根据父线程与角色配置构造子 agent 的运行配置,建立任务路径及父子关系。
  3. 把初始任务封装为 agent 间通信,并标记为需要触发工作。
  4. 调用 AgentControl 创建或派生子线程,然后返回可以继续引用的任务名。

再进入 AgentControl 的创建路径,还能看到执行容量检查、相关槽位预留、线程创建、父子关系持久化以及初始输入投递。V2 中驻留资源与执行容量也有相应管理,不能把一个“agent 数量”粗略理解成唯一资源限制。

把这条路径压缩成便于阅读的结构,就是:

主 agent 的工具调用
        ↓
spawn 处理器:参数、角色、上下文、任务路径
        ↓
AgentControl:容量与线程生命周期
        ↓
子线程:接收初始任务,运行自己的 agent 循环

这里的创建成功回执不是任务结果。它告诉调用方“现在有了这个参与者,可以继续与它交互”,并不证明对方已经查到原因、改完代码或通过测试。

如果自己实现类似系统,日志最好也分开记录“请求创建”“线程已建立”“初始任务已投递”和“任务已完成”。否则用户只会看到一句“派发成功”,而运行时卡在哪一层完全不可见。这个日志拆分是本文的工程建议,不是对 Codex 所有事件名称的照抄。

send_message 和 followup_task,差别不在消息文字

这个版本里最值得细读的是两个看起来几乎相同的工具:

工具 核心语义 不应据此推断的事情
spawn_agent 创建子 agent 并交付初始任务 子任务已经完成
send_message 给已有 agent 补充消息,不触发新 turn 空闲 agent 一定马上重新工作
followup_task 给已有非 root agent 追加任务;空闲时触发工作 无论对方状态如何都新建一轮
wait_agent 等待当前输入队列的相关活动或超时 已经收齐所有子任务结果
list_agents 查询协作树中的 agent 情况 一次查询之后状态不会再变
interrupt_agent 请求中断目标 agent 当前工作 已执行的修改会自动回滚

这些是 V2 工具集合中的语义概要。具体工具契约集中在 工具定义文件,不同版本或客户端应重新核对。

send_messagefollowup_task 最终共用一个消息处理入口,只是前者选择 QueueOnly,后者选择 TriggerTurn。该入口解析接收者、确认 agent 已知且可加载,再把区别放入通信对象的 trigger_turn 字段;不是靠模型从“请尽快处理”几个字里猜测是否开始工作。共享消息处理器

于是,前面的排查场景可以这样交互:

子 agent 正在检查调度器:
  send_message:补充“回退只发生在长输入,短输入正常”。

子 agent 已经结束这一轮:
  followup_task:要求“根据刚才的结论,再核对一个具体提交”。

对正在运行的接收者,followup 的契约是及时补充输入:采样期间在消息边界处理,若正有工具调用则等到该调用完成。它不是凭空再并发开启一份完全相同的 worker。

再往下一层,控制入口把通信作为 Op::InterAgentCommunication 投递给目标线程;需要启动工作时还会经过容量检查。

对自己的系统而言,这种区分很有价值:可以让“补充证据”与“安排下一次工作”拥有不同的触发规则、配额和审计记录。否则一段只想同步进展的消息,也可能意外唤醒一个模型,增加调用和成本。

wait_agent 等到的不是一张“全部完成”证明

沿着 V2 的 等待实现看,处理器会订阅输入队列活动,同时接收已经存在的待处理活动。等待函数使用截止时间,并区分三类结束原因:邮箱活动、新输入打断、超时。

这与 join(all_workers) 的含义不同。调度器 agent 发来一句“我找到一个可疑分支”,就可能成为值得主 agent 处理的消息;注意力内核 agent 此时仍可能在工作。等待返回,只意味着控制流程需要重新检查收到的内容和任务状态。

超时也一样。它表示这次等待窗口内没有取得预期活动,不应自动解释成“子任务失败”,更不能当成所有 worker 已被取消。

这里还有一个很适合讲并发设计的细节:源码把订阅与已存在活动一起交给等待逻辑。自己实现邮箱时,也需要防范这个竞态:先检查发现邮箱为空,随后消息到达,最后才开始订阅,结果把刚刚那次通知漏掉。可用锁保护的检查与订阅、序列号或条件变量等办法处理,但不能只写一句 sleep 就当作等待机制。

如果业务要求收齐两个检查结果,聚合端应单独维护集合:

expected = {scheduler 的本轮结果, kernel 的本轮结果}
completed = 已收到且验证过身份的结果

只有 expected 全部被 completed 覆盖,才进入最终汇总。

还需要把“成功完成”“失败”“取消”分别表示。结果里一句自然语言“差不多好了”,不足以充当机器可判定的成功状态。

一个不调用模型的小实验

下面的程序只模拟消息与工作状态的区别,以及结果聚合时的一次版本检查。epoch本文示例自己增加的任务代次,不是宣称 Codex 的公开工具就有这个参数;deliver 也不是 Codex SDK 方法。

from collections import deque
from dataclasses import dataclass, field

@dataclass
class Worker:
    name: str
    state: str = "idle"
    epoch: int = 0
    inbox: list = field(default_factory=list)

    def deliver(self, text, trigger=False):
        self.inbox.append(text)
        if trigger and self.state == "idle":
            self.epoch += 1
            self.state = "running"

    def finish(self, result):
        if self.state != "running":
            raise RuntimeError("no running task")
        self.state = "idle"
        return ("result", self.name, self.epoch, result)

worker = Worker("scheduler")
worker.deliver("长输入才发生回退")
print("after_message:", worker.state)
assert worker.state == "idle"

worker.deliver("检查调度逻辑", trigger=True)
worker.deliver("补查长输入分支", trigger=True)
assert worker.epoch == 1  # 已在运行:补充输入,不增加代次
old_result = worker.finish("第一轮结论")

worker.deliver("按更新后的基准重新检查", trigger=True)
new_result = worker.finish("第二轮结论")

expected = {"scheduler": 2, "kernel": 1}
events = deque([
    old_result,
    ("progress", "kernel", 1, "正在检查"),
    new_result,
    ("result", "kernel", 1, "内核检查完成"),
    new_result,  # 重复事件不重复计数
])
accepted = {}
ignored = 0
first_event = True
while events:
    kind, name, epoch, result = events.popleft()
    if (kind == "result" and expected.get(name) == epoch
            and name not in accepted):
        accepted[name] = result
    else:
        ignored += 1
    if first_event:
        print("done_after_first_event:", len(accepted) == len(expected))
        first_event = False

print("accepted:", sorted(accepted))
print("ignored_events:", ignored)
assert accepted["scheduler"] == "第二轮结论"
assert len(accepted) == 2 and ignored == 3

输出是:

after_message: idle
done_after_first_event: False
accepted: ['kernel', 'scheduler']
ignored_events: 3

旧结果、进度消息和重复结果都没有被算成新的完成项。这里没有网络,也没有真实并发,因此它只验证状态转移与聚合条件,不验证消息中间件的可靠性、取消竞态或多进程一致性。

把这个想法接入自己的服务时,任务代次还应与输入版本、代码提交或需求快照关联。主 agent 更新了基准条件,旧 agent 的一份正确报告也可能已经不适用于当前问题。需要拒绝的是过期证据,不是这个 agent 本身。

fork 上下文,并不等于 fork 整个工作环境

V2 的 spawn 参数支持 fork_turns,该标签里可以选择 noneall 或正整数形式的字符串。这个选项影响继承的会话历史范围;none 不代表子 agent 没有系统规则、运行配置或工具环境。参数与分支处理

完整继承与截取最近历史也不是单纯的数组复制。源码的派生线程路径会处理历史项、参考上下文与指令,完整历史分支还考虑保留可复用的提示前缀。因此,不能把“分了一个子 agent”解释成它持续自动看到父线程之后的每条新消息。历史派生实现

在应用层选择时,可以从依赖关系出发:

子任务情况 更合适的输入方式
能靠几个文件、明确问题独立完成 精简任务说明与必要证据
强依赖刚才的讨论 给足相关历史,并明确新的任务边界
历史很长,重要约束散落其中 先整理约束摘要,再决定继承多少历史

输入分开以后,文件系统仍要单独判断。若多个 agent 指向同一个工作目录,一个 agent 的写入会影响另一个;不能用“它们有独立会话”推断代码修改也自动隔离。

OpenAI 的子 agent 文档建议先从读操作较多的任务开始,并提醒并行写代码会产生冲突和协调开销。官方子 agent 指南

就前面的延迟排查而言,让两个子 agent 分别返回文件位置、调用链和可复现证据,由主 agent 汇总后再安排修改,通常更容易检查。若确实需要并行改代码,可以明确文件归属或使用独立 worktree;合并与验证仍然要有负责人。

中断任务,只处理生命周期的一部分

V2 的 中断处理器先获取目标状态,再请求中断,并把先前状态返回。底层控制入口投递的是 Op::Interrupt。这不能作为文件事务已经回滚的证明。

假设子 agent 已经修改两个文件,第三个修改前被中断。接下来主 agent 需要查看实际 diff、确认哪些验证完成,再决定保留、继续还是回退。取消一个调度动作与撤销已经产生的副作用,是两套机制。

此外,不要拿旧版本某个 completion watcher 的一段代码,直接描述新版本所有完成通知。在这个标签的创建路径里,V2 与旧路径对 watcher 的处理就存在分支。本文明确跟踪的是 V2 的创建、投递与等待入口,不对未逐条验证的通知重试、投递次数或崩溃恢复作保证。

对自己设计的系统,可以补上三个独立的约定:任务如何结束、结果如何确认接收、工作区如何恢复。尤其是有外部写入的任务,要在执行前确定可逆边界,而不是取消以后才寻找“撤销所有操作”的按钮。

真正值得借鉴的是一份清楚的协作契约

如果要把这套思路用到自己的 agent 系统,我会先定义每个子任务的交付格式:

任务:检查长输入下的调度变化
输入版本:指定提交、指定基准条件
范围:只读哪些目录;哪些文件允许修改
交付:结论、证据位置、验证方法、尚未排除的解释
完成条件:明确需要覆盖哪些分支或测试

随后才决定要开几个 agent。可以分别测量任务派发到真正开始工作的等待时间、子任务执行时间、父线程等待时间,以及整合和返工时间。并行部分再快,如果主 agent 花很久修复冲突,端到端仍可能更慢。

比较单 agent 与多 agent 时,还要用同一验收标准:检查覆盖率、结果是否可复现、修改是否通过测试。子 agent 的模型调用和工具工作会增加消耗,不能把墙钟时间降低直接写成总成本降低。

一套能维护的协作系统,应当能回答:这条消息发给了谁,属于哪次工作,是否触发执行,等待为什么结束,以及这个结果现在是否仍然有效。提示词帮助模型把任务做好,明确的协议则让其他部分知道它到底做到了哪一步。


本文基于公开文档及 rust-v0.153.2 的指定源码路径,代码仅在 CPU 上验证教学状态模型。未调用真实子 agent 做性能基准,也未把本地源码阅读当作端到端系统测试。后续版本的工具名、返回值和调度行为需重新核对。

内容生成、审核与纠错说明

继续阅读


本站的内容生成、审核与纠错说明

发表评论