工具并行判定
Codex 中的“支持并行工具调用”有两层含义。第一层是模型请求是否允许一次 response 产生多个 tool calls;第二层是 这些调用进入本地执行器后,某个 runtime 是否可以与其他工具同时执行。前者来自 ModelInfo,后者来自 ToolExecutor::supports_parallel_tool_calls()、Registry exposure 和精确的 ToolName lookup。只满足其中一层, 都不能推出工具一定并发运行。
本文面向已经读过ToolOrchestrator执行流程和 ToolRegistry数据结构的读者。本文只分析并行判定和执行 gate,不展开多个 future 如何 join、结果如何排序,也不把“parallel-safe”理解成业务副作用天然无冲突。读完后,读者应能从一个具体 handler 追到最终读锁/写锁,解释 Hidden、namespace、MCP server opt-in 和 Responses Lite 对并行的不同影响。
1. 两个开关
1.1 请求开关
Prompt.parallel_tool_calls 表示模型请求层是否允许并行 tool calls。build_prompt 直接读取 turn_context.model_info.supports_parallel_tool_calls;请求构造时还会在 Responses Lite 下强制关闭。
相关源码:
codex-rs/core/src/session/turn.rs :: build_promptcodex-rs/core/src/client.rs :: build_responses_request
Prompt {
input,
tools: router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
output_schema: turn_context.final_output_json_schema.clone(),
output_schema_strict: !crate::guardian::is_basic_session_source(
&turn_context.session_source,
),
}请求序列化时使用:
源码位置:codex-rs/core/src/client.rs :: build_responses_request
parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite,因此模型能力为 true 但 use_responses_lite 为 true 时,最终 wire 仍是 false。这个开关只影响模型能否在一个响应中 提出多个调用,不决定本地 handler 之间的锁。
右侧 gate 仍可能被 Code Mode 等非当前模型 response 来源使用,因此 wire flag 为 false 也不等于运行时永远不会 同时收到多个 future;两层必须分别诊断。
1.2 Runtime开关
每个 ToolExecutor 默认不支持并行;具体 runtime 必须显式覆盖。这个声明描述 handler 是否愿意进入共享读锁, 而不是描述 provider 请求能力。
源码位置:codex-rs/tools/src/tool_executor.rs :: ToolExecutor::supports_parallel_tool_calls
pub trait ToolExecutor<Invocation>: Send + Sync {
fn tool_name(&self) -> ToolName;
fn spec(&self) -> ToolSpec;
fn supports_parallel_tool_calls(&self) -> bool {
false
}
fn handle(&self, invocation: Invocation) -> ToolExecutorFuture<'_>;
}2. 判定链
2.1 Handler声明
Unified Exec 的 exec_command 和 write_stdin 都显式返回 true;MCP resource list/read 也允许并行。未覆盖方法的 Plan、ApplyPatch 等 runtime 使用默认 false。
相关源码:
codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: ExecCommandHandlercodex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs :: WriteStdinHandlercodex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs :: ListMcpResourcesHandler
impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
fn supports_parallel_tool_calls(&self) -> bool {
true
}
}
impl ToolExecutor<ToolInvocation> for WriteStdinHandler {
fn supports_parallel_tool_calls(&self) -> bool {
true
}
}显式 true 只表示可以进入共享读锁。exec_command 仍会在自身内部按 session/process manager、sandbox、审批和文件 系统策略处理资源;公共 gate 不替代这些细粒度同步。
2.2 Adapter传播
Extension adapter 不自行猜测并行性,而是把 extension executor 的声明原样向 Core 传播。MCP handler 则读取 ToolInfo.supports_parallel_tool_calls,该值来自 MCP server/tool 配置与发现结果。
相关源码:
codex-rs/core/src/tools/handlers/extension_tools.rs :: ExtensionToolAdaptercodex-rs/core/src/tools/handlers/mcp.rs :: McpHandler
impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
fn supports_parallel_tool_calls(&self) -> bool {
self.0.supports_parallel_tool_calls()
}
}
impl ToolExecutor<ToolInvocation> for McpHandler {
fn supports_parallel_tool_calls(&self) -> bool {
self.tool_info.supports_parallel_tool_calls
}
}这使并行声明跟随具体 runtime,而不是按“所有 MCP 都并行”或“所有 extension 都串行”的粗粒度分类。
2.3 Registry修正
Registry 用 canonical name 查找 RegisteredTool,然后同时检查 exposure 与 runtime 声明。Hidden exposure 强制 false;查找不到工具返回 None,Router 再用 unwrap_or(false) 收敛为 false。
源码位置:codex-rs/core/src/tools/registry.rs :: supports_parallel_tool_calls
pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option<bool> {
let tool = self.tools.get(&name.clone().with_default_namespace())?;
Some(
tool.exposure != ToolExposure::Hidden
&& tool.runtime.supports_parallel_tool_calls(),
)
}源码位置:codex-rs/core/src/tools/router.rs :: tool_supports_parallel
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
self.registry
.supports_parallel_tool_calls(&call.tool_name)
.unwrap_or(false)
}3. 名称隔离
并行查询使用完整 canonical ToolName。默认 exec_command 声明 parallel-safe,不会让 mcp__server__.exec_command 自动获得同样能力;后者必须在自己的 MCP runtime metadata 中 opt in。
源码位置:codex-rs/core/src/tools/router_tests.rs :: parallel_support_does_not_match_namespaced_local_tool_names
assert!(router.tool_supports_parallel(&ToolCall {
tool_name: ToolName::plain(parallel_tool_name),
call_id: "call-local-tool".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
encrypted_function_args: None,
}));
assert!(!router.tool_supports_parallel(&ToolCall {
tool_name: ToolName::namespaced("mcp__server__", parallel_tool_name),
call_id: "call-namespaced-tool".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
encrypted_function_args: None,
}));这种隔离阻止“同名即同能力”的错误传播。工具名称的 namespace 不只是 wire 展示,也参与 runtime 和并行 metadata 查找。
4. Exposure影响
4.1 Hidden
Hidden runtime 仍能通过 tool_runtime 查到,供兼容或内部路径使用,但 supports_parallel_tool_calls 返回 false。 这避免一个不在普通模型暴露面上的 legacy runtime 被当作当前 direct 调用的 parallel-safe 工具。
4.2 CodeModeOnly
CodeModeOnly 不等于 Hidden。只要 runtime 自己 opt in,Registry 查询仍可返回 true,因为它会被 Code Mode 嵌套调用。 测试同时覆盖 Hidden=false 和 CodeModeOnly=true 的差异。
源码位置:codex-rs/core/src/tools/router_tests.rs :: mcp_parallel_support_uses_handler_data
let hidden_call = ToolCall {
tool_name: ToolName::namespaced("mcp__hidden_echo__", "query_with_delay"),
call_id: "call-hidden".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
encrypted_function_args: None,
};
assert!(!router.tool_supports_parallel(&hidden_call));
assert!(router.tool_runtime(&hidden_call).is_some());
let nested_only_call = ToolCall {
tool_name: ToolName::namespaced("mcp__nested_echo__", "query_with_delay"),
call_id: "call-nested".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
encrypted_function_args: None,
};
assert!(router.tool_supports_parallel(&nested_only_call));4.3 Hosted工具
Hosted web_search 没有 Registry runtime,因此 local parallel query 返回 false。它是否由 provider 并行执行不属于 本地 ToolCallRuntime 的 RwLock 模型。
源码位置:codex-rs/core/src/tools/router_tests.rs :: tools_without_handlers_do_not_support_parallel
assert!(!router.tool_supports_parallel(&ToolCall {
tool_name: ToolName::plain("web_search"),
call_id: "call-web-search".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
encrypted_function_args: None,
}));5. 执行Gate
5.1 共享RwLock
ToolCallRuntime 为一个 Step 的工具调用共享同一个 Arc<RwLock<()>>。parallel-safe 调用拿 read lock,多个 read lock 可以共存;非 parallel-safe 调用拿 write lock,与所有 read/write holder 互斥。
源码位置:codex-rs/core/src/tools/parallel.rs :: ToolCallRuntime::handle_tool_call_with_source
let supports_parallel = router.tool_supports_parallel(&call);
let lock = Arc::clone(&self.parallel_execution);
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {
Either::Right(lock.write().await)
};
router
.dispatch_tool_call_with_terminal_outcome(/* ... */)
.await锁覆盖 Registry dispatch、Hook 和 handler 执行,guard 在整个 async block 结束后释放。因此一个 write-lock 工具会 等待已运行的 parallel calls 完成,也会阻止新的 read-lock 调用进入。
5.2 不做批次预分组
当前 gate 不先扫描一组调用并构造“并行批次”;每个 future 独立查询 metadata 并竞争同一 RwLock。由锁语义自然 形成并发区间:连续 read holders 可重叠,write holder 成为屏障。future 的 join 和输出顺序属于下一篇专题。
5.3 取消等待
取消 token 与 dispatch task 通过 tokio::select! 竞争。调用如果还在 readiness 或 lock wait 中,取消可以终止 dispatch task并返回 aborted output;它不会因为标记 parallel-safe 就绕过取消终态保护。
6. 副作用边界
supports_parallel_tool_calls = true 是 runtime 作者的声明,不是静态纯函数证明。例如:
- 两个
exec_command可以并发,但如果同时写同一文件,业务副作用仍可能冲突; - 两个
write_stdin可以并发访问不同 session,具体 process manager 仍需按 session id 保证安全; - MCP server opt in 表示服务器/工具声明可并发,客户端不会额外推导远端事务冲突;
- read-only MCP resource handler 适合并发,但网络连接池、服务端限流仍可能导致等待或失败。
公共 RwLock 只提供“parallel-safe 与 serial 工具之间的粗粒度隔离”。资源级锁、文件系统事务、session id 和远端 服务能力仍由 handler/runtime 自己负责。
7. 测试路径
7.1 Router判定
mcp_parallel_support_uses_handler_data 同时构造 parallel true、false、Hidden 和 CodeModeOnly MCP runtime,断言 并行结果来自精确 handler metadata 与 exposure。parallel_support_does_not_match_namespaced_local_tool_names 证明 默认工具的能力不会泄漏给同名 namespace 工具。
相关测试:
codex-rs/core/src/tools/router_tests.rs :: mcp_parallel_support_uses_handler_datacodex-rs/core/src/tools/router_tests.rs :: parallel_support_does_not_match_namespaced_local_tool_namescodex-rs/core/src/tools/router_tests.rs :: tools_without_handlers_do_not_support_parallel
7.2 MCP集成
stdio_mcp_parallel_tool_calls_default_false_runs_serially 使用默认 false 的 MCP server,观察两个延迟调用串行完成; stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently 将 capability 设为 true,断言两个调用并发完成。它们证明 server/tool opt-in 会穿过 ToolInfo、McpHandler、Registry 和 RwLock,而不是只停留在配置对象。
相关测试:
codex-rs/core/tests/suite/rmcp_client.rs :: stdio_mcp_parallel_tool_calls_default_false_runs_seriallycodex-rs/core/tests/suite/rmcp_client.rs :: stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently
7.3 Code Mode集成
code_mode_nested_tool_calls_can_run_in_parallel 同时包含两类断言:先用 barrier 和总耗时检查两个 nested calls 是否重叠,再检查 custom tool output 的 content-item 形状和 text(JSON.stringify(results)) 结果。因此它不是一个 纯粹的并发基准:耗时断言与输出 adapter 断言必须分别定位,后置输出形状失败不能直接推翻前面的并发时序,反之 只满足耗时也不能宣称完整 Code Mode 集成通过。
源码位置:codex-rs/core/tests/suite/code_mode.rs :: code_mode_nested_tool_calls_can_run_in_parallel
8. 阅读练习
在 Codex 源码 workspace 中运行:
cargo test -p codex-core mcp_parallel_support_uses_handler_data
cargo test -p codex-core parallel_support_does_not_match_namespaced_local_tool_names
cargo test -p codex-core stdio_mcp_parallel_tool_calls_default_false_runs_serially
cargo test -p codex-core stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently
cargo test -p codex-core code_mode_nested_tool_calls_can_run_in_parallel --test all -- --nocapture然后尝试回答:
- 模型请求
parallel_tool_calls=true,但两个 handler 都使用默认 false 时,本地执行会怎样? - 一个 Hidden runtime 声明 true,为什么 Router 仍返回 false?CodeModeOnly 为什么不同?
- 三个 future 按 A(parallel)、B(serial)、C(parallel) 到达锁时,哪些可以重叠,哪个形成屏障?
- MCP server opt-in 从 ToolInfo 到 read lock 经过哪些对象?
- 为什么 parallel-safe 不能证明两个工具同时修改同一文件是安全的?
9. 边界
并行判定只决定模型请求许可和本地 execution gate,不负责:
- 多个 future 的 join、错误隔离和输出排序;
- handler 内部资源锁、数据库事务、文件冲突和远端限流;
- approval、sandbox、取消 teardown 和 lifecycle 终态;
- provider 是否真的产生多个 tool calls。
排查“模型没有并行”“调用仍然串行”或“并发后副作用冲突”时,应分别检查 Prompt.parallel_tool_calls → canonical ToolName → RegisteredTool.exposure → runtime flag → RwLock admission → handler内部资源, 不要用单一的 supports_parallel_tool_calls 字段解释整条链路。
