对模型说随便并行,底下用锁管住
模型看见的、实际执行的、历史记录的,是三套顺序。一面旗允许一次发多个调用,一把读写锁决定谁能叠着进。
本页解决的问题
先给结论「对模型说随便并行,底下用锁管住」要解决的关键问题是什么?
模型看见的、实际执行的、历史记录的,是三套顺序。一面旗允许一次发多个调用,一把读写锁决定谁能叠着进。
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
- build_prompt 把并行旗写成 trueturn.rs L1321
- 编请求时和 Lite 标记做与client.rs L952
- 路由查注册表,没有就当 falserouter.rs L137
- Hidden 即使自称并行也当串行registry.rs L472
- 先 spawn,再等就绪,最后拿锁parallel.rs L144
- 能并行就 read,否则 writeparallel.rs L152
- 结果按 FuturesOrdered 插入顺序入账turn.rs L2135
你让模型同时读 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_command、view_image、tool_search 覆盖成 true。apply_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_calls 为 true 或 Lite 下的 false。它看不见谁能并行。工具清单也不会因为某个工具其实要拿写锁而少掉一项。
对模型的许可以和宿主调度拆开,换语言重写也用得上。请求侧只回答愿不愿意一次发多个调用。执行侧只回答这一次能不能和别人重叠。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 可以一起进。容量交给进程、沙箱和操作系统。
问的是这一刻有没有独占者。餐厅可以多人同时看菜单,只准一人进后厨。公平策略写在锁的实现里。业务代码只问并行还是独占。换一把会让新读者插队的锁,写者就有机会被饿死。把就绪等待放在锁外面,也是同一类判断:还没准备好的人,不要占着门口。
两个 exec_command 叠着跑,后发出的可能先跑完。若按完成顺序写历史,模型下一轮看到的结果顺序会和它发出的调用对不上。流内每到一条 OutputItemDone 就建 future,那一套管的是何时开工。这里管的是开工之后谁能重叠,以及结果按什么顺序入账。
采样循环把 tool_future 按到达顺序推进 FuturesOrdered。drain 按插入顺序出队,再写入会话。读锁让两个 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 也可以叠着跑。
Claude Code:按参数分批,只读 bash 才并行
Claude 的 isConcurrencySafe 默认返回 false。BashTool 把并行交给 isReadOnly:命令通过只读约束才返回 true。ls 可以进并行批,带写副作用的命令进串行批。连续的安全调用收成一批走并发,不安全的每个自成一批,一批里也是一个接一个。上限来自环境变量,解析失败时是 10。
Codex 的 exec_command 省掉命令解析,写命令也进读锁。三边都把调度元数据藏在宿主,闭合的位置不同。Codex 闭合在默认值和 Hidden,对 shell 最放开。DSH 闭合在分类器,bash 全串行。Claude 夹在中间。
后到的读能不能插队
view_image 还在跑,apply_patch 已经在门外排队。这时模型又发出一个 exec_command。演示里切到后到的读,单步走完,对照下面三问。
exec_command 能不能和还在跑的 view_image 叠着跑。apply_patch 的窗口改成读牌,闸门还会不会把它单独拦住。「先玩一遍 · 几个工具同时起跑」的能力藏在每次交接里
「你让模型同时读 src/main.rs 、读 src/lib.rs ,再打一处补丁。屏幕上两份读文件几乎同时出进度,打补丁那一下却停了一拍」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「如果运行时把可以并行理解成这些调用叠着跑,两个 apply_patch 会一起改共享的 diff 记账,账会乱。如果这些调用首尾相接,两个读文件也要排队,多耗一轮墙钟。请求侧那面旗只回答模型愿不愿意一次发多个盒子,回答不了盒子落地时谁能重叠」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- build_prompt 把并行旗写成 true turn.rs L1321
- 编请求时和 Lite 标记做与 client.rs L952
- 路由查注册表,没有就当 false router.rs L137
成功路径不能代表系统可靠
用「view_image 还在跑, apply_patch 已经在门外排队。这时模型又发出一个 exec_command 。演示里切到后到的读,单步走完,对照下面三问」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 几个工具同时起跑」走到「思路一 · 对模型说可以并行,底下再自己分流」
「先玩一遍 · 几个工具同时起跑」先把问题落在「同一把读写锁:谁进看菜单,谁在门外等后厨 播放 单步 重置 场景 默认四件套 后到的读 两个独占 点窗口可改读牌或写牌。后到的读用来看 fair 锁:写者已经排队,后来的读者不能插队。 看菜单 · 读锁 能并行的可以多人同时在 后厨 · 写锁 独占,只准一人 门外排队 模型发出的顺序 实际执行的顺序 历史入账的顺序 等待开始。点播放,看锁怎么分流。 逻辑轨迹 · 动画每一步对应源码里的哪一段 build_prompt…」上;到了「思路一 · 对模型说可以并行,底下再自己分流」,讨论继续推进到「你让模型同时读 src/main.rs 、读 src/lib.rs ,再打一处补丁。屏幕上两份读文件几乎同时出进度,打补丁那一下却停了一拍」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 几个工具同时起跑」:同一把读写锁:谁进看菜单,谁在门外等后厨 播放 单步 重置 场景 默认四件套 后到的读 两个独占 点窗口可改读牌或写牌。后到的读用来看 fair 锁:写者已经排队,后来的读者不能插队。 看菜单 · 读锁 能并行的可以多人同时在 后厨 · 写锁 独占,只准一人 门外排队 模型发出的顺序 实际执行的顺序 历史入账的顺序 等待开始。点播放,看锁怎么分流。 逻辑轨迹 · 动画每一步对应源码里的哪一段 build_prompt…
- 「思路一 · 对模型说可以并行,底下再自己分流」:你让模型同时读 src/main.rs 、读 src/lib.rs ,再打一处补丁。屏幕上两份读文件几乎同时出进度,打补丁那一下却停了一拍
- 「最后的要点」:先 spawn,再等就绪,最后拿锁 parallel.rs L144
最后的「最后的要点」把讨论落到「先 spawn,再等就绪,最后拿锁 parallel.rs L144」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。