OpenAI Codex · 工具调度

对模型说随便并行,底下用锁管住

模型看见的、实际执行的、历史记录的,是三套顺序。一面旗允许一次发多个调用,一把读写锁决定谁能叠着进。

课程目标读完能说清三件事。发给模型的并行旗,只表示一次响应里可以出现多个工具调用。每个工具默认走写锁,要并行必须自己改。结果写进会话时,仍按模型当初发出调用的顺序排列。
先玩一遍 · 几个工具同时起跑
同一把读写锁:谁进看菜单,谁在门外等后厨
场景
点窗口可改读牌或写牌。后到的读用来看 fair 锁:写者已经排队,后来的读者不能插队。
看菜单 · 读锁
能并行的可以多人同时在
后厨 · 写锁
独占,只准一人
门外排队
模型发出的顺序
实际执行的顺序
历史入账的顺序
等待开始。点播放,看锁怎么分流。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. build_prompt 把并行旗写成 trueturn.rs L1321
  2. 编请求时和 Lite 标记做与client.rs L952
  3. 路由查注册表,没有就当 falserouter.rs L137
  4. Hidden 即使自称并行也当串行registry.rs L472
  5. 先 spawn,再等就绪,最后拿锁parallel.rs L144
  6. 能并行就 read,否则 writeparallel.rs L152
  7. 结果按 FuturesOrdered 插入顺序入账turn.rs L2135
点播放,看几个工具同时起跑之后,谁能叠着进,谁必须等。
谁能并行亮读牌的进看菜单,可以叠着跑。亮写牌的要独占后厨,门外有写者之后,后来的读也只能排在它后面。
三套顺序模型发出的顺序是 1、2、3、4。执行顺序可以重叠。历史入账仍按发出顺序,先发出的先入账,哪怕它更晚跑完。
你能改的边界把补丁改成读牌,四个会一起进。把读牌改成写牌,它们会互相挡住。后到的读用来看 fair 锁不让插队。
教学示意:窗口与耗时为课程化设定,用来展示读写锁分流和三条顺序可以不一致。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 对模型说可以并行,底下再自己分流
它解决什么问题

你让模型同时读 src/main.rs、读 src/lib.rs,再打一处补丁。屏幕上两份读文件几乎同时出进度,打补丁那一下却停了一拍。

如果运行时把可以并行理解成这些调用叠着跑,两个 apply_patch 会一起改共享的 diff 记账,账会乱。如果这些调用首尾相接,两个读文件也要排队,多耗一轮墙钟。请求侧那面旗只回答模型愿不愿意一次发多个盒子,回答不了盒子落地时谁能重叠。

思路是什么

发给模型的旗写在 Prompt 上。字段默认是 false,采样主路径走 build_prompt,把旗写成 true。编成 Responses 请求体时,再和模型是不是 Responses Lite 做一次与。Lite 上这面旗关掉。压缩那两条组包路径也写死 true,为的是和主采样请求的形状对齐,websocket 复用会逐字段对比,其中就包括这面旗。

出处:codex-rs/core/src/session/turn.rs 第 1312 至 1328 行 · codex-rs/core/src/client.rs 第 946 至 953 行

执行侧另有一张表。ToolExecutor 默认 supports_parallel_tool_calls 返回 false。漏写覆盖就走写锁。exec_commandview_imagetool_search 覆盖成 trueapply_patch 不覆盖。路由先查注册表,查不到当 false。曝光是 Hidden 的,handler 自己返回 true 也没用。MCP 还要服务器开关或只读提示,缺省仍是串行。

出处:codex-rs/tools/src/tool_executor.rs 第 122 至 124 行 · codex-rs/core/src/tools/registry.rs 第 470 至 473 行

模型只看见 parallel_tool_callstrue 或 Lite 下的 false。它看不见谁能并行。工具清单也不会因为某个工具其实要拿写锁而少掉一项。

请求侧 build_prompt 写成 true Lite 再与成 false 模型可以一次发多个 不保证落地会重叠 分类表 默认 false,要并行自己改 Hidden、未知名字当串行 MCP 要 opt-in 或只读提示 模型看不见这张表 闸门 并行走读锁 串行走写锁 一把 RwLock 管全场 先 spawn,再拿锁
教学化结构图:一面旗、一张表、一把锁,各自管一段。
为什么长期成立

对模型的许可以和宿主调度拆开,换语言重写也用得上。请求侧只回答愿不愿意一次发多个调用。执行侧只回答这一次能不能和别人重叠。Lite 测试把旗关掉,说明作者接受某些模型路径上失去这层提示。闸门始终在。模型如果仍在一条响应里发出两个调用,两个任务照样 spawn,照样按本地表拿锁。

思路二 · 一把全场读写锁当闸门
它解决什么问题

分类对了,还要有人看门。两个读可以共存,一个写必须独占。若先握住锁再等 MCP 服务器连上,一次冷启动会让旁边已经就绪的 exec_command 也卡住。

还有公平性。若后来的读者能从写者头顶上翻进去,补丁可能一直拿不到锁。换成标准库那把 RwLock,优先级依赖操作系统,写者有机会被饿死。

思路是什么

一次采样共用一把 tokio::sync::RwLock<()>。锁保护的值是单元类型,里面没有业务数据,只当闸门。任务先 spawn,可选地等就绪,再拿锁。能并行就 read,否则 write。就绪等在锁外面。一个还没连上的 MCP 服务器,不会占着写锁让旁边的 shell 也卡住。

出处:codex-rs/core/src/tools/parallel.rs 第 144 至 156 行

这把锁的优先级是 fair,也叫 write-preferring。已经排队的写请求没拿到、没释放之前,后面的读锁不会发下去。一个 view_image 还在跑,apply_patch 已经在门外,再来的 exec_command 明明可以和 view_image 重叠,却必须排在补丁后面。fair 换来的是写者不会饿死,代价是后来的读者被写者隔开。

分类粒度是工具实例,看不到这一次的参数。exec_command 无论跑 ls 还是 rm,都走读锁。apply_patch 无论补丁多大,都走写锁。两个无关的串行工具也会互相挡住。检索业务代码,没有容量上限。十个 shell 可以一起进。容量交给进程、沙箱和操作系统。

看菜单 · 已拿读锁 view_image exec_command 已经进门的继续跑 两人可以同时看菜单 门外 · 写锁排队 apply_patch 等读者放锁 写者排进 FIFO 后到的读 exec_command 不能插队 排在写者后面
教学化示意:已经进门的读者继续跑,后来的读者看见写者排队,自己排到后面。
为什么长期成立

问的是这一刻有没有独占者。餐厅可以多人同时看菜单,只准一人进后厨。公平策略写在锁的实现里。业务代码只问并行还是独占。换一把会让新读者插队的锁,写者就有机会被饿死。把就绪等待放在锁外面,也是同一类判断:还没准备好的人,不要占着门口。

思路三 · 跑完的顺序不决定入账的顺序
它解决什么问题

两个 exec_command 叠着跑,后发出的可能先跑完。若按完成顺序写历史,模型下一轮看到的结果顺序会和它发出的调用对不上。流内每到一条 OutputItemDone 就建 future,那一套管的是何时开工。这里管的是开工之后谁能重叠,以及结果按什么顺序入账。

思路是什么

采样循环把 tool_future 按到达顺序推进 FuturesOrdereddrain 按插入顺序出队,再写入会话。读锁让两个 shell 叠着跑,入账仍按发出顺序。先发出的先入账,哪怕它其实更晚跑完。

出处:codex-rs/core/src/session/turn.rs 第 2130 至 2140 行

模型以为的顺序、锁上实际发生的顺序、历史里写下的顺序,可以不一致。
为什么长期成立

观测顺序和执行重叠从结构上分开。并行只改墙钟,不改账本。自己做 Agent 时,至少把这两条队列分开写。对模型说可以并行,跑工具时再看本地表,结果仍按调用列表的原顺序收下。

横向对比 · 同一道题的另一种答法

DeepSeek Harness:按参数分类,独占当屏障

DSH 让每个工具提供 isConcurrencySafe(args)。只有精确的 true 才加入并行。缺声明、参数不合法、分类器抛错,都是独占。bash 没有分类器,整条独占。调度器等完整消息到齐,连续的并行调用编成一组,每个独占调用单独成组当屏障。组内滚动池,上限默认 10。

Codex 可以没有这套分组,因为它把判断压成工具级布尔,再用一把锁模拟屏障。DSH 能让只读的 bash 仍然串行,少掉一部分并发。Codex 能让两个 ls 叠着跑,两个 rm 也可以叠着跑。

两侧均已核对源码 · 2026-08-22

Claude Code:按参数分批,只读 bash 才并行

Claude 的 isConcurrencySafe 默认返回 falseBashTool 把并行交给 isReadOnly:命令通过只读约束才返回 truels 可以进并行批,带写副作用的命令进串行批。连续的安全调用收成一批走并发,不安全的每个自成一批,一批里也是一个接一个。上限来自环境变量,解析失败时是 10。

Codex 的 exec_command 省掉命令解析,写命令也进读锁。三边都把调度元数据藏在宿主,闭合的位置不同。Codex 闭合在默认值和 Hidden,对 shell 最放开。DSH 闭合在分类器,bash 全串行。Claude 夹在中间。

两侧均已核对源码 · 2026-08-22
课堂练习
01

后到的读能不能插队

view_image 还在跑,apply_patch 已经在门外排队。这时模型又发出一个 exec_command。演示里切到后到的读,单步走完,对照下面三问。

这个 exec_command 能不能和还在跑的 view_image 叠着跑。
三条结果在历史上按什么顺序入账。
如果把 apply_patch 的窗口改成读牌,闸门还会不会把它单独拦住。
Takeaway:对模型说可以并行,是请求侧的旗。谁能叠着跑,看工具有没有改默认值,Hidden 和未知名字走独占。跑完之后,历史按发出顺序入账。三套顺序可以不一致。