第七章 多 agent 与编排:一个人如何变成一支队伍

第六章结尾,spawn_agentsend_message 作为“组织协作的工具”露过一面:模型对外部世界的一切影响都是工具调用,派一个“分身”去并行干活也不例外。 本章就顺着这个工具往下走:当模型真的调用了 spawn_agent,harness 里发生了什么?分身住在哪个线程里、父子之间怎么说话、父亲怎么等儿子回信、一支队伍的成本和并发由谁封顶? 这些问题合起来有一个更大的名字——编排(orchestration):谁派活、谁干活、谁汇报、谁待命,以及这一切如何不失控。


7.1 为什么“多雇几个人”比想象中难

一个朴素的想象是:多 agent 不就是多开几个线程,每个线程跑一个模型,大家一起干活吗?

在 agent harness 里,这件事的难度恰恰藏在“一起”两个字里。设想一支人类团队,管理者要回答的问题包括:

  • 派什么活? 哪些子任务值得交给别人并行做,哪些自己顺手做掉更快?
  • 给什么背景? 新同事入职,要不要把项目前因后果全部讲一遍?讲到什么程度?
  • 怎么称呼? 两个人同名怎么办?怎么保证一封信送到正确的人手上?
  • 怎么沟通? 我在忙的时候他送来一份材料,是立刻打断我,还是放在我桌上等我歇口气再看?他干完了怎么通知我?
  • 怎么等待? 我等他回信的时候,是干等着发呆,还是先做别的、有事叫我?
  • 多少人合适? 办公室座位有限、预算有限,人太多了谁先挪窝?
  • 谁有权力? 实习生能不能直接找客户签字?他闯了祸算谁的?

多 agent 系统面对的是完全相同的一组问题。而 Codex 对这组问题的回答,建立在一个贯穿前六章的核心立场上:

多 agent 不是一套新运行时,而是已有机制的重新组合。

对照一下就会发现,多 agent 需要的零件前面全部造好了:

编排需要的能力 复用的已有机制 出处
分身住在哪里 一个独立线程:自己的提交循环、自己的历史、自己的状态机 第 1 章
派活、说话、等待 普通工具调用(spawn / send_message / wait) 第 6 章
分身送来的消息如何进入历史 与 steer 完全相同的“待处理输入队列 + 步骤边界排空” 第 1、5 章
分身干完了如何汇报 一封特殊来信;子线程的轮次生命周期事件 第 2 章
分身需要用户批准怎么办 审批反向请求,用户照批不误 第 2、6 章
队伍的成本如何封顶 rollout 预算:整棵 agent 树共享的加权 token 预算 第 5 章
分身怎么恢复、怎么持久化 每个分身的事件流就是一份独立 rollout 第 10 章

本章剩下的部分,就是看这些零件如何拼成一支队伍。

类比:多 agent 编排像一家公司的项目组管理。员工(agent)各自有独立的工位和档案(线程与历史);项目经理通过工单系统派活(工具调用);员工之间靠内部邮件协作(邮箱),邮件可以“请速办”也可以“请阅知”;工位(内存槽位)不够时,不活跃的员工把档案归档、腾出工位(换出),有活了再调档回来(换入);而整个项目组共用一个预算账户(rollout 预算)。


7.2 一个 agent 就是一个线程:树、身份与地址

第一章画过运行时的五层结构,并提到“一棵 agent 树共享一个 session ID”。现在可以把这棵树完整画出来了:

graph TD
    ROOT["/root(根线程)<br/>直接面对用户,持有完整工具集"]
    ROOT --> A["/root/search_docs<br/>昵称:Ada"]
    ROOT --> B["/root/run_tests<br/>昵称:Lin"]
    ROOT --> C["/root/refactor<br/>昵称:Grace"]
    C --> C1["/root/refactor/helper"]
    A -.->|"同一 session ID<br/>各自独立的 thread ID 与历史"| ROOT

几个关键点:

1. 每个分身都是一个完整的线程。 子 agent 不是一个函数调用,而是一个与根线程结构完全相同的独立线程:它有自己的提交循环、自己的消息历史、自己的 LOOP、自己的工具集。第一章那套“串行外壳、并发内核”的结构在每个分身内部原样成立。分身和根线程的区别只在于身份(它是被派出来的,有父线程)和权限画像(工具集按身份裁剪,7.8 节展开)。

2. 身份是一棵树,地址是一条路径。 每个 agent 有一个规范任务名(canonical task name),形如 /root/search_docs——这就是它在队伍中的地址(AgentPath)。路径按派生关系嵌套:/root/refactor 派出的分身叫 /root/refactor/helper。模型在通信时既可以用相对名(helper),也可以用完整路径(/root/refactor/helper);相对名相对于发信者自己的路径解析,就像文件系统里的相对路径与绝对路径。路径冲突(两个分身抢同一个名字)在注册时直接报错。

3. 昵称给人看,路径给模型用。 路径虽然规整但对人类不友好,所以每个分身还有一个昵称:从一个名字池里随机挑一个不重复的人名(Ada、Lin、Grace……),名字池用完了就用“某某二世”这类序号续上。前端的协作面板、事件展示用人话昵称;模型之间的寻址、事件的坐标用规范路径。两套标识各司其职。

4. 树是持久化的数据。 父子派生关系不只是内存里的指针,而是作为一条“派生边”写入持久化存储(第 10 章):谁是谁的父、派生时的角色、路径是什么。进程重启恢复会话时,整棵树的身份(路径、昵称、角色)可以从磁盘重建——哪怕分身的运行时还没加载。这是 7.7 节“线程换页”的前提。

第一章留下的一个 ID 细节在这里闭环:子 agent 来信自动唤醒的合成轮次使用随机 UUIDv4(而不是用户触发轮次的 UUIDv7),因为它不是人类的一次提交,没有“提交时间”需要编码。


7.3 两代协作协议:从“工具模拟”到“模型原生”

在深入机制之前,必须先交代一个背景:Codex 的多 agent 存在两代设计,它们在当前代码里并存,由模型能力卡片(第 3 章)上的“多 agent 协议版本”决定走哪一代。理解两代的差异,能帮我们看清多 agent 设计的正确方向。

7.3.1 第一代:用工具“模拟”一支队伍

第一代(V1)里,分身能力全部建模为一个命名空间下的普通工具:派活、发消息、等待、关闭、恢复,都是工具调用。它的约束比较紧:

  • 总数封顶:一棵 session 树最多派生 6 个分身线程;
  • 深度封顶:派生深度默认为 1——根可以派分身,但分身不能再派分身。模型撞到深度上限会收到一句直截了当的回灌:“已达到 agent 深度上限,请自己完成任务。”
  • 汇报靠“伪装的用户消息”:分身跑到终态(完成/出错)后,一个后台守望者把它的状态总结成一条用户角色的通知消息注入父线程历史,父亲在下一轮 LOOP 读到“你的分身 X 完成了,结果是……”。

V1 能用,但它有一个根本的别扭:模型并不真正理解“队友”这个概念。在它看来,派分身只是调了一个返回很快的工具,分身的来信只是历史里冒出来的一段用户文本。所有协作语义都靠提示词和文本格式维系,模型容易忘记自己有分身、弄错收信人、或者傻等一个不会主动来的消息。

7.3.2 第二代:模型原生的协作协议

第二代(V2)建立在一个新事实之上:新一代模型的通信协议里,原生就有一种“给队友的消息” item(agent message)。模型可以直接产出一条带发信人、收信人的消息,消息内容还可以像思维链一样加密传输(第 2 章的加密 reasoning 同款机制)——分身之间委托任务的细节属于模型的“内部交流”,不需要以明文落进任何一方的可见历史。

在这个原生协议之上,V2 把协作面重做成六个扁平工具:

工具 作用 是否唤醒对方轮次
spawn_agent 派出一个新分身,附带任务说明 是(分身立刻开工)
send_message 给已存在的 agent 发一条消息 否(只投递,不打断)
followup_task 给已有分身派一个新任务 是(空闲则唤醒,忙碌则在消息边界插入)
wait_agent 等待邮箱里的任何来信(7.6 节)
interrupt_agent 中断另一个 agent 的当前轮次
list_agents 列出当前树里所有存活 agent 及其状态

V2 同时放松了数量约束:取消了深度限制(分身可以生分身),总数也不再硬封顶——取而代之的是 7.7 节那套“驻留槽位 + 换出换入”机制。

两代的差异可以归纳为一句话:V1 是 harness 用工具和文本“扮演”一支队伍给模型看;V2 是模型在协议层就知道自己在一支队伍里,harness 只负责把消息送到、把资源管好。 这与第 3 章“能力是数据,不是代码”的演进路线一致:新能力随模型卡片到来,旧模型自动走 V1 兜底。

本章后续以 V2 为主线讲述(它代表了设计的最终形态),V1 的差异点会在关键位置对照说明。


7.4 spawn:派活时到底给了什么

聚焦一次 spawn_agent 调用。模型需要提供的核心参数有:任务名(task_name,也就是分身的地址末段)、任务说明(message)、以及三类可选的定制:角色(agent_type)、模型与推理档位(model / reasoning_effort)、上下文继承量(fork_turns)。

7.4.1 fork_turns:给新同事多少背景

这是 spawn 最有讲究的一个参数。分身是独立线程,出生之后历史各走各的,但出生那一刻要不要把父亲手里的对话记录复制给它?复制多少?三种选择(V2 默认是 all):

  • all(默认):全量继承。分身拿到父亲的参考上下文基线(reference context)作为起点。这有两个好处:其一,全量 fork 默认继承父亲的模型与推理档位——因为它本质上是“同一个我,分一条时间线出去”;这里要说明的是,“不要覆盖模型/推理档位”只是发给模型的一条 usage hint 约定(提示除非用户/AGENTS.md/skill 明确要求,否则别改),并不是硬性拒绝:spawn 实现仍会无条件应用传入的 model/reasoning_effort 覆盖。其二,它直接继承了一段完整、规整、内容稳定的基线(环境事实 + 各轮最终答复),分身首轮不必从零重建世界状态。
  • none:不给任何历史。分身只收到任务说明本身。最省、最干净,适合完全自足的子任务;但任务隐含依赖前文时(“按我们刚才讨论的方案改”),分身会因为缺乏背景而做偏。而且它首轮必须全量重建世界状态
  • N(最近 N 轮):只带最近几轮,折中方案;同样丢掉了较早的参考上下文,首轮需重建部分基线。

这里要澄清一个容易误解的点:三种模式继承的都不是“父亲的原始流水”,而是一份过滤后的视图。 真正被复制的,是第四/九章说的那份参考上下文——系统/开发者/用户消息、世界状态基线,以及父亲每一轮给出的最终答复;而工具调用、工具结果、推理记录(reasoning)、分身间通信这些工作过程不复制(父亲的协作提示词片段也会被擦除,换成分身自己身份的提示词)。

注意过滤发生的时机:它不是“发请求时临时删掉”,而是 fork 那一刻就决定哪些条目进入子 agent 的历史——子 agent 从出生起,自己的历史里就没有这些工具项。所以它发给模型的历史是一份自洽的“会议纪要结论版”,以 all 为例,大致长这样:

user:       环境基线(目录、权限、AGENTS.md……参考上下文)
user:       用户最初的任务
assistant:  父亲第一轮的最终答复(只有结论,没有它中间跑过的命令)
user:       用户的第二轮输入
assistant:  父亲第二轮的最终答复
……          (每轮只保留 final answer;工具调用、命令输出、reasoning 全程缺席)
developer:  分身自己的身份指令(替换掉父亲那段开发者指令)
user:       分身身份的 usage hint("你是团队中的一个 agent……")
user:       派给它的新任务(NEW_TASK)

为什么工具项缺席也不会“悬空”?因为父亲保留下来的最终答复本身就是自包含的结论文本,是一条独立的 assistant 消息,而不是某个工具调用的附属品;第 4 章“工具调用与结果必须成对”约束的是同一线程连续 LOOP,而 fork 是开一条新时间线,新历史从头就不含工具项,不触发配对问题。分身由此知道“父亲和用户聊到哪、每轮结论是什么、当前环境如何”,但看不到“父亲跑过哪些命令、怎么试的错”。

关于缓存,要特别纠正一个想当然的推断:删掉工具调用和 reasoning 后,子 agent 的请求前缀和父亲的在线请求前缀确实不一样了。注意发散点不在系统指令:分身的 base instructions(系统提示)其实是原样继承父亲的,真正让前缀岔开的是三处——分身的 developer 身份指令替换掉了父亲那段开发者指令、额外注入了一条分身身份的 usage hint(“你是团队中的一个 agent”),以及历史里工具项被删导致内容不同。(工具集通常也随 subagent/root 身份而不同,但对默认的、不带 agent_type 的全量 fork 并不做按角色裁剪,所以这条不是必然的发散来源。)而 prompt cache 命中靠的是前缀逐字节相同,所以子 agent 并不会命中父亲那条含工具调用的在线前缀prompt_cache_key 虽然整棵树共享同一个会话 ID(第 3 章),但那只是“缓存分区”,不等于内容命中。

那全量 fork 在缓存上的优势到底是什么?在于子 agent 继承到的参考上下文是一段完整、稳定、会被它自己后续每一轮原样重发的内容

  • 子 agent 从第 1 轮起,这段基线就在它的请求里;到第 2、3 轮采样时,它命中的是自己建立起来的缓存前缀,且可以直接在父亲传下来的基线上做世界状态差分(第 4 章),不必首轮先重建。
  • 截断 fork(none/N)则连这段基线都没有,子 agent 首轮临时拼装环境片段,内容零散、还可能和后续轮次对不齐,缓存等于从零开始。

换句话说,缓存意义上的“继承”是继承了一段值得缓存的稳定内容,而不是“父子共享同一个缓存条目”。被复制的东西(结论、背景、环境事实)既是分身需要的、也是天然适合缓存的稳定前缀;被丢弃的东西(试错流水、工具日志、思维链)既是分身不需要的噪声和父亲的隐私,本来也只会让前缀臃肿、并随每次工具调用不断变动、对缓存稳定性毫无帮助。

代码注释把这个取舍讲得很直白:全量 fork“保留可缓存的 prompt 前缀、可以从父亲的持久基线继续做差分”;截断 fork“丢掉了部分 prompt,首轮必须自行重建上下文”。注意这里的“保留前缀”指的是保留那段稳定的参考上下文内容,而不是逐字节复用父亲含工具调用的在线缓存。

7.4.2 角色:分身可以是“专才”

agent_type 允许指定一个角色(role)。角色是一份可配置的“职位描述”:可以覆盖这个分身的开发者指令、模型、推理档位、服务等级,甚至开关某些功能特性。内置角色里最典型的是“等待者”(awaiter):推理档位调低、后台命令超时长、指令严格限定为“盯着这个任务直到结束,不解读、不优化、不做无关动作,用长超时轮询”。

角色机制有一条铁律:角色只能收权,不能越权。 角色文件可以关掉分身的功能、给它更窄的指令,但并发上限、权限边界这些由根会话配置决定的纪律,角色文件无权放大——就像公司可以给实习生更少的权限,但不能给他批预算的权力。

7.4.3 共享的是世界,不是记忆

分身继承什么、不继承什么,背后是一条与第 4 章完全一致的原则:

  • 不共享历史:每个 agent 的上下文是自己的工作记忆。出生时可以按 fork_turns 继承一份父亲参考上下文的副本(过滤后的基线,从此各写各的),之后互不可见——父亲看不到分身的推理过程,分身之间也不共享对话记录,协作只能靠消息显式通信。
  • 共享世界:所有分身跑在同一个容器、同一个文件系统、同一个工作目录下。一个分身改了文件,其他分身立刻可见;执行环境(本地/远程)也整体继承。
sequenceDiagram
    participant M as 父 agent 模型
    participant H as harness
    participant C as 子 agent 线程

    M->>H: spawn_agent(task_name="search_docs", message="...", fork_turns="none")
    H->>H: 预留执行槽与驻留槽(满则换出空闲分身)
    H->>C: 创建线程:独立历史 + 继承工作目录/环境/权限
    H-->>M: 工具结果:task_name(立即返回,不等待)
    Note over M: 父 agent 继续自己的 LOOP
    C->>C: 首封来信 NEW_TASK 触发第一个轮次
    C->>C: 自己的 LOOP:采样、工具、回灌……

注意时序:spawn 是“派完就走”的。工具结果在分身创建成功后立刻返回(只回一个任务名),父 agent 的 LOOP 不等待分身完成。分身的成果以后续来信的形式到达(7.5 节)。工具描述里也明确告诫模型:只在任务“具体、有界、能与本地工作并行”时才派分身,否则就地完成——派分身本身有通信和上下文成本,不是银弹

模型与档位的覆盖同样受第 3 章能力卡片约束:spawn 时 harness 会校验分身模型确实在目录里、请求的服务等级和推理档位被该模型支持,校验不过就把错误回灌给模型重新选择。


7.5 通信:邮箱、两种通道与三类信件

分身之间如何说话?答案是一个对读者来说已经很熟悉的结构——邮箱(mailbox),它和第 1 章的 steer 待处理队列是同一套机制的两个入口。

7.5.1 消息长什么样

V2 里,agent 之间的消息以固定格式出现在接收方的历史中(作为 analysis channel 的内容):

Message Type: NEW_TASK
Task name: /root/search_docs
Sender: /root
Payload:
<任务正文……>

三个字段分别回答:这是什么类型的信、给谁的、谁写的。消息类型有三种,对应协作中的三种语义:

类型 何时产生 含义
NEW_TASK spawn_agentfollowup_task 新任务,要求接收方开工(trigger_turn = true)
MESSAGE send_message 普通告知,不要求打断(trigger_turn = false)
FINAL_ANSWER 分身完成轮次、给出最终答复 任务成果自动回传给父 agent

这里有一个比“消息类型枚举”更深的设计——模型输出有两个通道(channel)

  • analysis channel(分析通道):过程性内容,对应线上的 commentary 阶段消息。分身之间的通信(NEW_TASK / MESSAGE)走这里,它也承载模型的工作交流;
  • final channel(最终通道):分身的最终答复,对应 final answer 阶段消息。分身一旦在最终通道给出内容,harness 自动把它封装成一封 FINAL_ANSWER 信送回父 agent——“儿子交作业”是协议行为,不需要儿子显式调用工具。

分身的系统提示词里把这个约定讲得很直白:“当你在最终通道给出回复,内容会立即送回你的父 agent。”

7.5.2 trigger_turn:一封信要不要“叫醒”对方

每封信携带一个布尔标志 trigger_turn,它决定了投递语义:

  • trigger_turn = true(NEW_TASK):如果接收方空闲,harness 自动为它开一个合成轮次处理这封信(第 1 章说的“空闲唤醒”);如果它正忙,信在步骤边界插入,相当于 steer。
  • trigger_turn = false(MESSAGE / FINAL_ANSWER):只投进邮箱,绝不主动打断。接收方在下一个步骤边界排空邮箱时自然读到;如果它已经空闲下班,信就安静躺着,等它下次被唤醒时再看。

这个区分精确对应人类协作中的两种邮件:“请速办”会把人从工位上叫起来,“请阅知”只落在收件箱里。followup_task 工具还有两条护栏:不能以根 agent 为目标(根由用户的输入唤醒,不该被分身随意支使);目标正在采样时,新任务在消息边界插入而不是打断在途请求——与 steer 不在采样中途注入完全同理(第 1、5 章)。

7.5.3 邮箱与 LOOP 的三个衔接点

邮箱机制几乎全部复用前面章节的结论,但有三个值得专门指出的衔接点:

衔接点一:入队与排空,完全复用 steer 纪律。 信件进入每个线程私有的待处理队列;在 LOOP 的步骤边界被排空、写入历史(第 5 章阶段一)。写信因此是“尾部追加”,不破坏在途采样的上下文自洽,也不破坏 prompt cache 前缀。

衔接点二:流式途中的来信可以“提前让位”。 第 5 章埋过一个伏笔:模型正在流式输出时如果收到分身来信,循环不必干等整条响应和全部工具跑完——在 commentary 消息或 reasoning item 完成的边界,harness 检查邮箱,有信就提前结束本次响应处理、标记“还需跟进”,让来信尽快进入下一轮 LOOP。注意抢占点的选择:工具调用成形之后不能抢(工具已经 spawn,必须收口),最终答复之后不能抢(那是收工点);只有 commentary / reasoning 这类“过程性自言自语”的边界是安全的。

衔接点三:相位控制——“回答之后来的信,留到下一轮”。 这是第 1 章提到的相位语义的完整形态。一个轮次有两个相位:

sequenceDiagram
    participant P as 父 agent 轮次
    participant C as 子 agent
    Note over P: 相位 CurrentTurn:来信可在本轮排空
    P->>P: LOOP 进行中,子 agent 来信 → 步骤边界排空
    P->>P: 模型给出最终答复(final answer 成形)
    Note over P: 相位翻转为 NextTurn
    C->>P: 迟到的分身来信
    Note over P: 不再进入本轮历史(用户已经看到答复)<br/>trigger 信 → 自动开新轮次<br/>普通信 → 留在邮箱等下次唤醒

为什么需要这个?因为最终答复是用户已经可见的承诺。如果父亲已经回答完用户、轮次都收尾了,一封迟到的分身来信又把轮次“偷偷复活”并产出新内容,用户看到的行为会非常诡异——“它不是说完了吗,怎么又自己动起来了?“所以一旦最终答复成形,相位翻转:迟到的 trigger 信会开一个新轮次(对用户表现为 agent 收到新进展后主动继续,而不是旧轮次赖着不死);普通信则留在邮箱里,等下一轮自然消费。

7.5.4 通信内容默认是加密的

最后一个容易忽略的安全向细节:分身之间通过工具发起的通信,内容默认以密文形式进入双方历史(加密机制与第 2 章的加密思维链相同),harness 自己只搬运、看不到明文;只有特定的直连明文路径才落明文。模型委托给分身的任务细节、分身回报的中间结论,属于模型群体的“内部对话”,不向 harness、扩展或旁观者公开。这与思维链加密是同一种立场:模型的协作过程受保护,可观测的是行为与结果(第 12 章)。


7.6 wait:等待也是一种工具调用

父亲派完活继续干自己的事,到了“必须等分身结果才能往下走”的时刻怎么办?答案是 wait_agent 工具——等待本身被建模成一次工具调用,而不是线程级的阻塞原语。

它的语义有几个刻意的设计:

  • 等的是“邮箱有动静”,不是某个具体分身。 调用后,这个工具 future 挂起在邮箱的活动通知上:任何分身来信、任何最终汇报都会唤醒它;用户插话(steer)同样唤醒它。它返回的是“有更新了”这个事实和摘要,不返回信件内容——内容要等下一轮 LOOP 排空邮箱时才进入历史。
  • 有超时,且超时被钳制。 模型可以给超时参数,但 harness 设有下限(约 10 秒)、默认值(约 30 秒)和硬上限(1 小时):请求太短会被抬到下限,请求太长会被压到上限。工具描述里直接引导模型“偏好长等待(分钟级),避免忙轮询”。超时也是正常结果(timed_out),模型读到后可以决定继续等还是先干别的。
  • 等待不占着队伍。 等待的父亲挂起在 future 上(第 6 章审批挂起的同款姿态),不占执行槽、不阻塞提交循环处理中断和审批,也不挡其他分身运行。

为什么等待要做成工具而不是让模型反复 list_agents 轮询?三个原因:轮询浪费 token(每次轮询都是一轮采样);轮询制造大量无意义历史;而“挂起-唤醒”是事件驱动的,零成本等待、有信即醒。这与第一章“线程的睡与醒也是事件驱动的”一脉相承。

V1 的 wait_agent 形态略不同:它接收一组分身 ID,等其中任意一个到达终态即返回,是“等具体下属”的语义;V2 改为“等整个邮箱”,因为 V2 里父亲与分身的关系是长期的(分身可以被反复派新任务、可以换入换出),等“活动”比等“某个人死亡”更贴合长期协作的形态。


7.7 并发受控:线程的“虚拟内存”

一支队伍可能同时有很多分身:根派了三个,每个又派了两个孙分身……内存、模型连接、成本都是有限的。V2 用两道闸门 + 一套换页机制管理这件事,设计思想和操作系统的虚拟内存如出一辙。

7.7.1 两道闸门

flowchart TD
    S["spawn 新分身 / 唤醒休眠分身"] --> G1{"① 执行槽有空?<br/>正在运行轮次的分身数 < 上限"}
    G1 -->|"否"| E1["回灌错误:AgentLimitReached<br/>模型可读错后等待或改计划"]
    G1 -->|"是"| G2{"② 驻留槽有空?<br/>内存中存活的分身数 < 上限"}
    G2 -->|"否"| EV["换出:挑最久未活动的空闲分身<br/>flush 持久化 → 关闭线程 → 释放槽位"]
    EV --> G3{"成功换出?"}
    G3 -->|"否(全都在忙)"| E1
    G3 -->|"是"| LOAD["创建/换入分身线程"]
    G2 -->|"是"| LOAD
    LOAD --> RUN["运行;完工后可再次被换出"]

第一道:执行槽(并发运行上限)。 同一时刻,整棵树里正在跑轮次的分身数量有上限(默认配置是“全队 4 个槽位,包含根自己”,即同时最多 3 个分身并行采样)。槽位用一个计数信号量管理:轮次开始领取、轮次结束归还。没有空槽时,唤醒操作得到一个明确的错误,这个错误作为工具结果回灌给模型——模型读到“队里满了”,可以选择先做本地工作、过会儿再派,而不是 harness 悄悄排队或直接崩溃。

第二道:驻留槽(内存存活上限)。 内存里同时活着的线程数也有上限。注意“活着”和“在跑”的区别:一个分身可能空闲了但线程还驻留在内存里,占着位置。驻留槽满时,harness 启动 LRU 换出

  1. 从最久未活动的分身开始挑候选;
  2. 候选必须是可换出的:已到终态(完成/出错/中断)、没有活跃轮次、邮箱里没有未处理信件;
  3. 把它的 rollout 完整落盘(第 10 章,事件流本就是持久化的),然后关闭线程、从注册表移除;
  4. 腾出来的槽位给新分身。如果所有候选都在忙、一个都换不出,才报“上限已达”。

7.7.2 换入:分身可以“休眠后唤醒”

被换出的分身并没有死:它的身份(路径、昵称、角色)在树的持久化边里,它的全部历史在自己的 rollout 文件里。当有新信件发给它(followup_task / send_message),harness 先做一次“确保已加载”:发现线程不在内存,就从磁盘读回它的历史、重建线程、还原角色配置(模型、权限画像、指令),再投递信件。对发信的模型来说,这个过程是透明的——它只知道“信送到了”。

这正是虚拟内存的隐喻:

操作系统 agent 编排
进程的完整地址空间 分身的完整身份与历史(持久化在 rollout)
物理内存页框 驻留槽位
工作集换出(swap out) 空闲分身落盘、关闭线程
缺页中断后换入(swap in) 来信时从 rollout 重建线程
LRU 页置换 LRU 分身换出

这套机制解释了 V2 为什么敢取消 V1 的深度限制和总数硬顶:孙分身、曾孙分身可以无限派生,因为不活跃的分身会被换出到磁盘,内存成本有界;而真正的成本约束交给了更精确的东西——7.8 节的共享预算。数量是代理指标,token 才是真金白银(与第 5 章“不数迭代轮数,只封顶稀缺资源”同一种思路)。

V1 没有换页机制,线程一旦创建就常驻,所以只能用“总数 6、深度 1”的硬上限自保。机制的强弱决定了策略的松紧。


7.8 一支队伍的共享纪律

分身是独立线程,但独立不等于各自为政。几条队伍级的纪律横跨整棵树。

7.8.1 共享预算:全队一个钱包

第 5 章提过 rollout 预算——一个按根线程的整棵 agent 树共享的加权 token 预算。现在可以讲清它的形状:

  • 记账是全队聚合的。 树里任何一个 agent(根、分身、孙分身)每轮采样的用量都汇总到同一个预算账户:输出 token 按采样权重、非缓存输入 token 按预填充权重加权累计(服务端也可以直接上报预算单位)。分身并行不会让成本失控——它们花的是同一个钱包。
  • 提醒按线程投递。 预算越过提醒阈值时,每个正在跑的线程都会在自己的步骤边界收到一次预算提醒(第 4 章的片段注入),且每个压缩窗口只提醒一次,不刷屏。
  • 触顶是全队性事件。 预算耗尽后,采样得到致命错误,轮次以 BudgetLimited 中止(第 1 章),可恢复——用户追加预算后整棵树接着跑。没有任何分身能绕过父亲超额消费。

7.8.2 权限与工具:按身份裁剪,角色只能收权

第 6 章讲过工具集“按身份裁剪”,多 agent 是这套裁剪最重要的应用场景:

  • 问人是根的特权。 分身拿不到“向用户提问”这类工具——分身不直接面对用户,它有疑问应该写信问父亲,由父亲决定要不要请示人类。这保证了“人机协同”只有一个入口,用户不会被三个分身同时弹审批框。
  • 协作工具的护栏。 followup_taskinterrupt_agent 不能以根为目标;一个 agent 不能中断自己(工具描述提示它:“返回你的结果即可,需要的话让父 agent 中断你”);协作工具不允许在 code mode 的嵌套代码里调用——派活必须是模型的直接决策,不能藏在一段自动执行的脚本里。
  • 内部分身工具集最小。 审查类分身只拿到跑命令、喂输入、看图这类必需工具(第 6 章)。
  • 审批策略对分身同样有效。 分身跑危险命令、改文件,走的是第 6 章完全相同的六道关卡:策略判定、审批反向请求(最终还是弹给用户)、沙箱执行。权限画像从父亲继承,角色文件只能收窄不能放宽;用户“本会话始终允许”的批准缓存对分身同样生效,不会重复打扰。

7.8.3 hooks 按身份分层

第 5 章留下的伏笔在这里闭合:停止 hooks 对不同身份的 agent 跑不同的事件——

  • 根线程跑标准的会话开始 / 停止 hooks(Stop);
  • 模型派生的分身(thread-spawn)跑专门的分身事件:SubagentStart / SubagentStop——扩展可以针对“分身被派出”“分身收工”挂逻辑,但不能把分身当成根会话来对待;
  • harness 内部合成的分身(下一节展开)不跑任何用户 hook——它们是系统内部设施,不该被用户扩展拦截或改写。

同理,会话结束(SessionEnd)事件只属于根线程。


7.9 谁在编排:模型派活与 harness 派活

到目前为止,派分身的决策者都是模型:模型在 LOOP 中判断“这个子任务值得并行”,调用 spawn_agent。但这不是唯一的编排来源。

7.9.1 三种编排模式:显式、主动、自定义

模型“该不该主动派分身”由一个世界状态片段(第 4 章)控制,分三种模式:

  • 显式模式(ExplicitRequestOnly,默认):只有用户、AGENTS.md 或技能指令明确要求“派分身/并行处理”时,模型才派活。提示词写得很明确:“除非明确要求,不要派生 sub-agent。”
  • 主动模式(Proactive):当推理档位是内部档位 ultra(第 3 章:它对服务端就是最高推理档,对 harness 还是一个编排信号)时激活。注入的提示词变为:“主动多 agent 委派已开启……当并行工作能显著提升速度或质量时,使用 sub-agent。”
  • 自定义模式(Custom):配置或模型目录直接提供一段编排策略文本。

关键洞察是:即使在主动模式下,harness 也不替模型 spawn 任何分身。 它做的全部事情,是换一段提示词。派不派、派几个、派谁、怎么分工,仍然是模型在 LOOP 中的推理决策。harness 掌握的是护栏(槽位、预算、权限),模型掌握的是编排权本身——这与“tool_choice 永远是 auto”(第 3 章)、“停止与否模型提议、harness 制衡”(第 5 章)是同一种权力分配:判断权交给最懂任务内容的一方,硬约束交给掌握全局状态的一方。 模式片段本身也是可替换的世界状态:一条后续的模式消息可以把主动模式收回为显式模式。

7.9.2 第三类 agent:harness 自己派的“内部员工”

树里还跑着另一类分身,它们不由模型派生、而由 harness 根据自身需要合成

内部分身 触发场景 特征
代码审查(review) 用户请求审查一轮改动 一次性子会话:禁用联网与协作工具、审批策略为“从不询问”(自主跑完)、专用审查提示词、结构化审查结论回灌父线程
自动审查(guardian) 高风险操作需要自动把关(第 8 章) 会话启动时预热的常驻审查线程;最小工具集;审查意见作为审批决策的输入
压缩(compact) 上下文到限(第 4 章) 独立的压缩任务,产出交接摘要
记忆巩固 后台总结长期记忆 内部会话来源,产出写入记忆系统

它们与模型分身的差异是系统性的:

  • 不占协作面:模型看不到、也不能给它们发消息;它们不出现在 list_agents 的队伍列表里;
  • 不跑用户 hook:内部设施不受扩展干预(7.8.3);
  • 工具最小、权限收窄:例如审查分身禁用联网、禁用协作、审批免问(它本就是来做判断的,不该再问人);
  • 结果走专用通道回汇:审查结论以结构化结果回到父线程(第 2 章的协作事件族),而不是走通用邮箱。

于是整棵树里有三类“干活的实体”:根 agent(面对用户、持有全权)、模型派生的分身(模型委派、长期存活、可通信、可换页)、harness 合成的内部分身(一次性、专才、无面孔)。第一章那个“线程管理器持有所有线程”的大管家,管的就是这一大家子。

7.9.3 可观测:一支队伍要能被看见

多 agent 的并发也给了前端和观测系统新的素材(第 12 章详述):

  • 协作事件族:分身启动、被联系、被中断都有专门的活动事件(SubAgentActivity);spawn / wait 这类工具调用在界面上呈现为结构化卡片(带分身昵称、任务摘要、模型、各分身状态),而不是一行干巴巴的工具名;
  • 通信链路追踪:每次 agent 间通信在 tracing 里记一对“发送/接收”事件,带通信 ID、发信/收信线程 ID、类型(spawn/message/followup/result),一封邮件在树里的完整路径可以被重建;
  • 归因坐标:分身轮次的事件都携带父轮次 ID 与根轮次 ID(第 2 章的信封字段),任何一个分身的动作都能追溯回“是根的哪次任务触发的”;多封触发信来自不同父轮次时,归因会被标记为“歧义”而不是瞎认领。

7.10 小结:多 agent 编排的六条设计原则

  1. 多 agent 是组合,不是新机制。 分身是线程(第 1 章),派活/通信/等待是工具调用(第 6 章),来信复用 steer 邮箱与步骤边界排空(第 1、5 章),审批复用反向请求(第 2 章),恢复复用 rollout(第 10 章)。编排层没有发明任何新原语,只把已有机制编织成“队伍”语义。

  2. 身份即地址,地址即树。 每个 agent 有规范任务路径(/root/…)与人类可读昵称;路径按派生关系成树并持久化;相对寻址像文件路径一样解析。模型用路径通信,用户用昵称辨识,事件用坐标归因。

  3. 通信即邮箱,等待即工具。 消息分 NEW_TASK / MESSAGE / FINAL_ANSWER 三类,以 trigger_turn 区分“请速办”与“请阅知”;最终通道内容自动回传父亲;信件在步骤边界排空、流式途中可在安全边界提前让位、回答边界之后留到下一轮。等待是挂起在邮箱活动上的工具调用,事件驱动、有超时、防忙等,绝不让线程傻等。

  4. 共享世界,不共享记忆。 分身继承文件系统、工作目录、权限画像,但各自拥有独立历史;上下文继承量由 fork_turns 显式选择,复制时只留结论与背景、擦除推理流水。角色只能收权不能越权,问人是根的特权。

  5. 编排权归模型,护栏归 harness。 连“主动委派”模式也只是换一段提示词,派不派分身永远是模型的推理决策;harness 掌握执行槽、驻留槽、共享预算、权限裁剪、hook 分层这些硬约束。全队一个预算钱包,超限全队 BudgetLimited。

  6. 线程可换页,身份永不丢。 分身的身份与历史持久化在派生边和 rollout 里,内存槽位只是工作集:LRU 换出空闲分身、来信时从磁盘换入,对模型透明。正因为换页让内存成本有界,V2 才得以取消深度与总数硬顶,把约束让位给更精确的预算。

留给读者思考的几个问题

  • V2 取消了派生深度限制,只靠驻留槽和预算约束。如果模型陷入“递归派分身”的病态行为(每个分身都觉得“这事该再派个人”),现有机制里哪一道会最先拦住它?被拦住时模型收到的信号足够让它自纠吗?(→ 第 5、8 章)
  • FINAL_ANSWER 自动回传父亲、且 trigger_turn = false(不叫醒)。如果父亲恰好空闲、而儿子的结果里包含“父亲必须立刻知道的坏消息”,这个“不主动打断”的设计会不会误事?该靠模型培训约束,还是该给信件增加优先级语义?
  • 分身之间只共享文件系统、不共享历史。这逼着协作全部显式化(写信),但也可能导致两个分身重复劳动或互相踩文件。要支持“分身 A 直接读分身 B 的工作笔记”,应该开放历史读取,还是让它们约定通过文件交换?各自破坏什么不变量?
  • 驻留换出用 LRU 挑选空闲分身。但“最久没活动”不等于“最没用”——一个等待关键审批结果的分身可能长时间沉默却很重要。LRU 在什么场景下会换错人?换错的代价是什么?
  • 内部分身(审查、guardian、压缩)不跑用户 hook、不进协作面。如果用户的扩展需要审计“审查分身做了什么”,这层隔离会不会成为盲区?可观测性(第 12 章)需要为此开什么口子?
  • 协作工具禁止在 code mode 嵌套代码里调用,派活必须是模型的直接工具调用。这个限制防住了什么风险?如果允许一段自动执行的脚本批量 spawn 分身,失控面会扩大成什么样?(→ 第 6、8 章)

下一章我们进入安全策略:分身会跑命令、会改文件、会长出一支队伍,那么“谁被允许做什么”由谁判定——沙箱后端、权限模型、审批策略、网络管控与自动审查(guardian)如何协同,把这支能力越来越强的队伍关在制度的笼子里。

分类:Agent Harness标签:#agent #harness #codex