Codex 的 agent loop 比我们的多了什么?
骨架和第 2 周一样:调模型 → 有工具调用就执行 → 结果送回去 → 再调模型。多出来的都是生产环境逼出来的:流还没结束工具就开跑、读写锁控制哪些工具能并行、结果按调用顺序回填、错误分类加退避加传输降级、用户可以中途插话。
本章对照 openai/codex commit 7993248 的源码逐段读,常量和行号都以这个 commit 为准。不会 Rust 也没关系,每段代码下面都有逐行解释。
1三层循环:和第 2 周对一下
第 2 周的 loop 只有一层:for _ in range(max_turns),每轮调一次模型、执行一批工具。Codex 的一个 turn(用户发一条消息,到 agent 交还控制权)里面套了三层循环:
run_turn core/src/session/turn.rs:163
└─ loop { // ① 外层:一个 turn 里的多次采样(step) :426
取出用户中途插话 pending_input :430
run_sampling_request
└─ loop { // ② 采样重试 :1653
try_run_sampling_request
└─ loop { stream.next() } // ③ 流处理 :2618
output_item.done 是工具调用 → 立刻开跑,future 放进 in_flight
response.completed → 记 token、看 end_turn,退出流循环
drain_in_flight:按调用顺序把工具结果写进历史 :3162
出错 → handle_response_stream_error:分类、退避、降级
}
needs_follow_up = 模型要继续 || 有待处理的插话 :566
!needs_follow_up → Stop hook → break :653
}
| 第 2 周的 loop | Codex | 差别 |
|---|---|---|
stop_reason == "tool_use" 就继续 | needs_follow_up:本次采样出现过工具调用、end_turn == false、或者有用户插话 | 多了"用户插话"这个来源 |
| 等整个响应回来,再执行工具 | 流式解析,一个工具调用完整就开跑 | 工具和模型生成重叠 |
| 工具串行执行 | 可并行的拿读锁同时跑,其余拿写锁独占 | 按工具声明决定能否并行 |
| 请求失败就抛异常 | 错误分成"可重试 / 只听服务端 / 终止",指数退避、Retry-After、WebSocket→HTTP 降级 | 失败是常态,按类型处理 |
| max_turns + token 预算 + 超时 + 重复检测 | 没有步数上限;靠上下文压缩、用户中断、终止类错误兜底 | 交互式和无人值守的取舍不同(第 7 节) |
文中路径省略了 codex-rs/ 前缀。完整固定链接见博客正文。
2停止条件:needs_follow_up 由什么决定
Codex 调的是 OpenAI Responses API,没有 Claude 那样的 stop_reason: "tool_use"。它看输出里有没有工具调用 item。三个来源,任何一个成立就再采样一次:
// core/src/stream_events_utils.rs:350-357(流里收到一个完整的工具调用)
let tool_future: InFlightFuture<'static> = Box::pin(
ctx.tool_runtime
.clone()
.handle_tool_call(call, cancellation_token),
);
output.needs_follow_up = true;
output.tool_future = Some(tool_future);
// core/src/session/turn.rs:2994-2996(response.completed 里的 end_turn)
if let Some(false) = end_turn {
needs_follow_up = true;
}
// core/src/session/turn.rs:566(采样结束后,外层循环再看一眼插话队列)
let needs_follow_up = model_needs_follow_up || has_pending_input;
- 出现过工具调用:模型要看到工具结果才能继续,所以必须再采样。工具名不认识也走这条路:
ToolRouter::build_tool_call不检查工具名(tools/router.rs:248-301),调用照常派发,注册表里找不到才返回unsupported call: <工具名>(tools/registry.rs:551-570),作为失败的工具输出按序回填,让模型自己改。build_tool_call本身返回RespondToModel时(目前只有tool_search参数解析失败一处,router.rs:274),不派发,直接写一条工具输出回历史并置needs_follow_up = true(stream_events_utils.rs:400-424)。 end_turn == Some(false):服务端明说"还没完"。response.incomplete且原因是interrupted时,SSE 解析层也会把它转成end_turn: Some(false)(codex-api/src/sse/responses.rs:450-452)。- 有待处理的用户插话:模型这一步说完了,但用户在它说的过程中又发了消息,那就再采样一次,让模型看到新消息(第 6 节)。
三者都不成立,进入 Stop hook。hook 返回 should_block 并附带一段续写提示时,提示会作为新消息写进历史,continue 回到外层循环(turn.rs:672-689);should_stop 时直接 break。否则检查是否需要 turn 结束时的压缩,然后 break,turn 结束。
end_turn 才算完成":Codex 也没有把截断当成功。response.incomplete 的原因不是 interrupted 或 content_filter 时,会被当成流错误 Incomplete response returned, reason: …(sse/responses.rs:431-434),走重试分支。3流处理:工具一完整就开跑,结果按顺序回填
模型在一次响应里调 4 个工具,第 1 个调用的参数在第 2 秒就生成完了,整个响应第 5 秒才结束。第 2 周的写法要等到第 5 秒才开始执行第 1 个工具。Codex 在收到 response.output_item.done、发现它是工具调用的那一刻就开跑:
// core/src/session/turn.rs:2589
let mut in_flight: FuturesOrdered<InFlightFuture<'static>> = FuturesOrdered::new();
// ... 流循环里,每收到一个 OutputItemDone:
// core/src/session/turn.rs:2787-2793
if let Some(tool_future) = output_result.tool_future {
in_flight.push_back(tool_future);
}
// ...
needs_follow_up |= output_result.needs_follow_up;
// ... 流循环结束后(不管成功还是出错):
// core/src/session/turn.rs:3162-3171
if !in_flight.is_empty() {
// ...
drain_in_flight(&mut in_flight, sess.clone(), &step_context).await?;
}
FuturesOrdered是一个"按放入顺序吐结果"的 future 队列:里面的 future 可以乱序完成,但取结果时一定先拿第 1 个、再拿第 2 个。所以工具结果写进历史的顺序 = 模型发起调用的顺序,和谁先跑完无关。drain_in_flight(turn.rs:2472-2500)就是while let Some(res) = in_flight.next().await,逐个把结果记进历史。它在流循环之后执行,流中途出错也会执行,所以已经开跑的工具结果不会丢。
FuturesOrdered 本身不会在 push_back 时运行 future,要等有人调 poll_next(futures 库文档原话:"the future will not be polled at this point")。而 drain 要等流结束才调用。那工具怎么提前开跑?答案在 core/src/tools/parallel.rs:196:handle_tool_call 里直接 tokio::spawn 了真正干活的任务,tokio 文档说 spawn 出去的任务"will start running in the background immediately"。放进 in_flight 的只是一个"等这个任务结束"的句柄。源码注释也写明了:"The sampling loop collects results in order only after its stream ends."(parallel.rs:229)断流时怎么办:流断了,已经开跑的工具照样跑完、结果进历史;采样重试时不复用原来的输入,而是从历史重新组装(turn.rs:1656-1662),新请求里已经带着这些工具调用和输出,所以不会把它们再执行一遍。
测开视角:怎么证明"真的提前开跑了"
Codex 有一个专门的集成测试 shell_tools_start_before_response_completed_when_stream_delayed(core/tests/suite/tool_parallelism.rs:304-435)。做法很值得照搬:
- mock 服务器先只发 4 个
exec_command调用,response.completed被一个 oneshot 闸门卡住不发; - 每个命令往临时文件里写一个毫秒时间戳;
- 等文件里攒够 4 个时间戳,才打开闸门发 completed;断言 4 个时间戳都 ≤ completed 的发送时刻。
另一个测试 tool_results_grouped(同文件 :226-302)断言第二次请求里:所有 function_call 都排在所有 function_call_output 之前,并且输出的 call_id 顺序和调用一一对应。断言的是 agent 发给模型的请求体,不是模型说了什么。
4并行工具:一把读写锁
组装 Prompt 时 parallel_tool_calls 固定写成 true(turn.rs:1598),真正发出去的值是 prompt.parallel_tool_calls && !model_info.use_responses_lite(client.rs:1001):除了走 Responses Lite 的模型,请求里都是 true,模型可以一次发多个调用。但不是所有工具都能同时跑。每次 run_sampling_request 会创建一个 ToolCallRuntime(turn.rs:1638),里面有一把 tokio::sync::RwLock<()>(parallel.rs:50, :63)。锁本身不保护任何数据,只用来排队:
// core/src/tools/parallel.rs:205-209(在 tokio::spawn 出去的任务里)
let guard = if supports_parallel {
Either::Left(lock.read().await)
} else {
Either::Right(lock.write().await)
};
- 工具声明
supports_parallel_tool_calls() == true→ 拿读锁,多个读锁可以同时持有,这些工具一起跑。 - 否则拿写锁:要等前面的所有工具结束才开始,而且它跑的时候别人都得等。
- 这个方法的默认实现返回
false(tools/src/tool_executor.rs:122-124),不声明就独占,默认是保守的。
| 声明可并行(读锁) | 没声明(写锁,独占) |
|---|---|
exec_command、write_stdin、view_image、tool_search、三个 MCP resource 工具;MCP 工具在 server 声明支持并行、或带 read_only_hint 时(handlers/mcp.rs:148-159) | apply_patch、update_plan 等没有覆盖默认实现的工具 |
两点要注意:
- "读锁"不等于"只读"。
exec_command跑 shell,完全可以写文件,但它拿的是读锁。读/写只是锁的模式,意思是"能不能和其他调用同时跑"。shell 的安全靠的是审批和沙箱(本周后面的章节),不是这把锁。 - tokio 的 RwLock 是公平的:等锁的任务按先来后到排队,队头是写者时,后面的读者也拿不到读锁(tokio 文档:read locks will not be given out until the write lock has been released)。所以"读、读、写、读"这个顺序里,第 4 个读者要等第 3 个写者跑完。下面的演示按这个规则计算。
5重试:分类、退避、降级
第 2 周的 loop 里,请求失败就是一个异常。Codex 把失败当常态,分两层重试:
| 层 | 默认次数 | 重试什么 | 等多久 |
|---|---|---|---|
HTTP 请求层codex-client/src/retry.rs | request_max_retries = 4(上限 100) | 5xx、超时、网络错误;不重试 429(retry_429: false,model-provider-info/src/lib.rs:447-453) | 有 Retry-After 用它;否则 200ms × 2n−1,乘 [0.9, 1.1) 的随机抖动 |
采样层core/src/responses_retry.rs | stream_max_retries = 5(上限 100) | 按 CodexErr::retry_delay 的分类(下表) | 有服务端建议用它;否则 200ms × 2n−1 × [0.9, 1.1)(async-utils/src/backoff.rs) |
| 连接失败 (同一文件 :93-118) | 不限次数,不占上面的 5 次 | ConnectionFailed,且是普通会话、非 Bedrock。它只来自 HTTP 传输:连不上时 HTTP 请求层先按第一行重试 4 次(retry_transport: true),仍失败才映射成 ConnectionFailed(codex-api/src/api_bridge.rs:263-264) | 5 秒起,每次翻倍,封顶 60 秒;每轮之前还有 HTTP 层约 3 秒的重试 |
常量出处:DEFAULT_STREAM_MAX_RETRIES = 5、DEFAULT_REQUEST_MAX_RETRIES = 4、DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000(model-provider-info/src/lib.rs:63-65);INITIAL_DELAY_MS = 200、BACKOFF_FACTOR = 2.0(async-utils/src/backoff.rs:7-8);INITIAL_CONNECTION_RETRY_DELAY = 5s、MAX_CONNECTION_RETRY_DELAY = 60s(responses_retry.rs:23-24)。连接无限重试由 feature flag unbounded_connection_retries 控制,Stable、默认开(features/src/lib.rs:1330-1334)。
WebSocket 握手失败不会变成 ConnectionFailed:map_ws_error 把 IO 错误映射成 TransportError::Network(codex-api/src/endpoint/responses_websocket.rs:566-593),再变成普通的流错误,按采样层计次、用完后降级到 HTTP;握手超过 15 秒(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS,model-provider-info/src/lib.rs:68)算超时,同样计次。降级到 HTTP 之后还连不上,才进入上表第三行的无限重试。
采样层 5 次重试的名义等待是 0.2 + 0.4 + 0.8 + 1.6 + 3.2 = 6.2 秒;加上抖动在 [5.58, 6.82) 秒之间。退避没有封顶,把 stream_max_retries 调大时,第 10 次要等 200ms × 29 ≈ 102 秒。
错误分类:CodexErr::retry_delay(protocol/src/error.rs:389-437)
| 返回 | 错误 | 含义 |
|---|---|---|
None(终止) | ContextWindowExceeded、UsageLimitReached、QuotaExceeded、InvalidRequest、Sandbox、Interrupted、TurnAborted、Fatal 等 | 重试也不会好,直接报错 |
| 只听服务端 | ServerOverloaded、RetryLimit | 服务端给了 Retry-After 才重试,没给就终止 |
| 服务端建议或退避 | Stream(断流、空闲超时)、RateLimitExceeded、Timeout、InternalServerError、ConnectionFailed、UnexpectedStatus、ContentFilter 等 | 可重试 |
codex-api/src/api_bridge.rs:181-240)。HTTP 429 的响应体类型是 usage_limit_reached → UsageLimitReached,终止;insufficient_quota 等 → QuotaExceeded,终止;其他 429 → RetryLimit,只有带 Retry-After 时才重试。而流里的 response.failed 事件、错误码 rate_limit_exceeded → RateLimitExceeded,可重试,等待时间从消息里的 "try again in 11.054s" 解析(sse/responses_error.rs:82-88)。降级:WebSocket → HTTP
内置的 OpenAI provider 默认走 WebSocket(supports_websockets: true,model-provider-info/src/lib.rs:555)。采样层 5 次重试用完,下一次失败时如果还能降级,就切到 HTTP SSE,重试计数清零(responses_retry.rs:120-139)。降级是粘性的,整个会话只发生一次(client.rs:654-673 用 swap(true) 置位)。切换时如果服务端给过 Retry-After,仍然要等到那个时刻,注释写着 "Changing transport must not bypass the server's retry deadline."
对应的测试
core/tests/suite/websocket_fallback.rs:85-128:stream_max_retries = 2时,WebSocket 尝试 1 + 2 次后降级,HTTP 请求 1 次成功。core/tests/suite/retry_after.rs:344-400:429 带Retry-After: 1,断言等了 ≥ 1 秒、总共 2 次请求、turn 正常完成。retry_after.rs:502-506:503server_is_overloaded,request_max_retries = 2。没有 Retry-After 时 3 次请求就结束;带Retry-After: 0且stream_max_retries = 2时是 3 × 3 = 9 次。两层重试是相乘的。
6用户中途插话(steer)
模型正在跑,用户又发了一句"别改测试文件"。Codex 不开新 turn,而是把这句话塞进当前 turn(core/src/session/turn_input.rs 顶部注释:决定输入是 "starts a turn, steers an active turn, or is rejected"):
steer_input把消息放进当前 turn 的 pending input 队列(turn_input.rs:734-740)。Review 和 Compact 任务不接受插话(:683-695)。- 外层循环每一圈开头取出 pending input,作为用户消息写进历史(
turn.rs:430-447)。 - 采样结束后
needs_follow_up = model_needs_follow_up || has_pending_input:哪怕模型已经说完了,只要有插话,就再采样一次。
所以默认行为是:不取消当前流,模型要到下一次请求才看到插话;但 wait_agent、sleep 这类等待型工具会被新输入提前结束(测试 steer_interrupts_wait_agent_and_is_sent_in_follow_up_request、any_new_input_interrupts_sleep,core/tests/suite/pending_input.rs:593-769),普通工具照常跑完。想更快打断,有一个还在开发中的 instant_interrupt(Under Development、默认关,features/src/lib.rs:1120-1124):开启后每次采样会监听插话队列,一有用户输入就取消当前流(session/input_queue.rs:253-273)。用户按 Esc 中断则是另一条路:CancellationToken 一路传下去,turn 以 TurnAborted 结束。
core/tests/suite/pending_input.rs 里有这类用例,例如 :593 的 steer_interrupts_wait_agent_and_is_sent_in_follow_up_request:插话打断正在等待的工具,并断言它出现在下一次请求里。7没有 max_turns:取舍和无人值守
在 core/src/session 和 core/src/tasks 里 grep max_turns|max_steps|max_iterations,没有结果。turn 内的步数没有上限。token 预算类的开关有两个,token_budget 和 rollout_budget,都是 Under Development、默认关(features/src/lib.rs:1760-1764, :1772-1776)。本机 codex features list(codex-cli 0.159.2)的输出也是这样:
instant_interrupt under development false
rollout_budget under development false
token_budget under development false
unbounded_connection_retries stable true
它靠什么防失控?
- 上下文压缩:token 快满时 turn 中途压缩然后继续。源码注释直接写了这个判断:"as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop."(
turn.rs:612) - 人在旁边:交互式编码 agent,用户随时可以 Esc 或者插话纠偏。
- 终止类错误:上下文超窗、额度用完等直接结束 turn;重试有次数上限(连接失败除外)。
| 第 2 周的停止条件 | Codex 默认配置下 | 无人值守(CI、批量评测)还要不要 |
|---|---|---|
| 模型 end_turn | 有:needs_follow_up == false | 要 |
| max_turns | 没有 | 要:没人按 Esc,模型绕圈会一直跑 |
| token / 成本预算 | 没有(开关默认关) | 要:批量跑 N 个任务,成本上限要可控 |
| 超时 | turn 级没有墙钟超时,只有流空闲超时 300 秒、WebSocket 握手 15 秒和工具自己的超时等局部超时;连接失败无限重试 | 要:加墙钟超时,否则断网时会一直等 |
| 重复调用检测 | 没有 | 建议要,或至少在 trace 里统计 |
codex exec 跑批量任务时,也要在外面套一层超时。源码里也有类似的意识:Stop hook 拒绝时,记忆整理这种内部无人值守任务直接报错退出,注释是 "Do not feed managed rejections back into an unattended memory loop."(turn.rs:667)▶互动演示 1:工具什么时候开跑
模拟一次采样:模型先等首 token,然后按"每个调用的参数长度 ÷ 输出速度"逐个吐完工具调用,最后一个吐完时 response.completed。上面一栏是"等整个响应结束再执行",下面一栏是 Codex 的"一完整就执行"。两栏都按 FuturesOrdered 的规则回填(圆点):流结束后才回填,而且第 i 个要等第 i−1 个回填完。读写锁按 tokio 的公平策略计算。不调真实 API。
▶互动演示 2:注入故障,看重试时间序列
按 Codex 的规则逐次模拟请求:先由 retry_delay 判断能不能重试,再看要不要降级、等多久。默认参数取自源码(采样层 5 次、HTTP 层 4 次、退避 200ms 起翻倍、抖动 ±10%、连接失败 5 秒起翻倍封顶 60 秒)。"连接失败"在 WebSocket 上是握手失败,按断流计次;降级到 HTTP 后,HTTP 层先重试,仍连不上才变成 ConnectionFailed,进入不占次数的连接重试。表中每行是一次网络请求(HTTP 层的重试也各算一次)。"连续失败次数"是服务端前 k 个请求都失败,第 k+1 个开始恢复。
✎练习
response.completed 里 end_turn 字段没给。但模型说话期间,用户在 TUI 里又发了一句"顺便把 README 也改一下"。Codex 接下来会怎样?turn.rs:566:needs_follow_up = model_needs_follow_up || has_pending_input。B 接近 instant_interrupt 开启后的行为(插话到达时取消当前流),这个 feature 还在开发中、默认关。默认配置下不取消当前流,这次采样照常结束,插话在下一次请求里出现(wait_agent、sleep 这类等待型工具例外,会被新输入提前结束)。exec_command(都可并行),耗时分别是 4 秒、1 秒、1 秒。按 Codex 的"一完整就执行",这一步(到最后一个结果回填)一共多少秒?in_flight 是一个 FuturesOrdered,而 FuturesOrdered 在 push_back 时并不运行 future,drain_in_flight 又要等流结束才调用。工具为什么还能在流结束前开跑?stream.next(),没有 poll in_flight,所以 C 错。见 core/src/tools/parallel.rs:196 的 tokio::spawn 和 :229 的注释。这也是用 Python 改写时要用 asyncio.create_task 而不是只把协程对象存进列表的原因。stream_max_retries = 5,断流一直不恢复。不考虑抖动,采样层 5 次重试一共要等多少秒?{"error": {"code": "rate_limit_exceeded"}},没有 Retry-After 头。Codex 会怎么处理?retry_429: false,不重试 429,B 错。api_bridge.rs 里这个 429 不是 usage_limit_reached 也不是额度类,落到 RetryLimit;error.rs 里 RetryLimit 只用 server_retry_delay(),没有 Retry-After 就是 None。C 描述的是流里 response.failed 事件带 rate_limit_exceeded 的情况,那是另一条路径。可以在演示 2 里对比"HTTP 429"和"流里 rate_limit_exceeded"。8面试要点与动手实验
一句话讲清楚
Codex 的 loop 和我们手写的同构:一个 turn 里反复采样,本次输出有工具调用、end_turn 为 false 或者用户插了话,就再采样一次。生产级多出来的是:流式解析时工具调用一完整就 tokio::spawn 开跑,用一把公平读写锁让声明可并行的工具同时跑、其余独占,流结束后按 FuturesOrdered 的顺序回填;失败按类型分成终止、只听服务端、可重试三类,指数退避加抖动、遵守 Retry-After、重试用完从 WebSocket 降级到 HTTP。它没有 max_turns,因为人在旁边、压缩能让任务继续;无人值守的批量评测里我会保留 max_turns、墙钟超时和 token 预算。
追问准备
- 提前执行能省多少?省的是"模型还在生成后面调用的参数"这段时间和工具执行的重叠。理论下限是 max(completed 时刻, 每个工具"完整时刻 + 耗时"的最大值)。模型输出越慢、调用越多、前面的工具越慢,省得越多。只有一个调用,或者模型生成很快、工具很慢(重叠窗口很短)时,提前执行几乎不省;工具独占只会让工具之间不能并行,仍能和模型生成重叠(演示 1 默认参数下 4 个都换成 apply_patch:12.0 → 9.0 秒,省 3.0 秒,比全部可并行时的 8.0 → 6.0 还多)。不是"总能省一半"。
- 为什么结果要按调用顺序回填?让历史可复现:同样的模型输出,不管工具谁先跑完,写进历史的顺序都一样,下一次请求的前缀稳定,也便于测试断言和缓存命中。代价是队头阻塞,但 drain 本来就要等所有工具结束,这里的总耗时不受影响。
- 断流时正在跑的工具怎么办?照样跑完、结果进历史;重试时从历史重新组装请求,不会重复执行。这一点要测:断流注入后断言工具只执行了一次。
- 退避为什么要抖动?同一时刻断线的大量客户端如果都按 200、400、800ms 整齐重试,会在同一时刻再次打爆服务端。乘一个 [0.9, 1.1) 的随机因子把重试时间打散。
- 怎么测这些逻辑?照 Codex 的做法:mock 一个 Responses 服务器,编排 SSE 事件序列,用闸门控制 completed 的发送时机,用 Retry-After 头和状态码注入故障,断言请求次数、等待时长和第二次请求体。
动手实验:把第 2 周的 loop 改成"流式 + 提前执行 + 按序回填"
完整代码在 week03_Codex源码/code/stream_loop_sim.py,纯 Python 标准库、不调 API,python3 stream_loop_sim.py 就能跑。核心是这两段:
async def codex_style(calls):
"""Codex 写法:output_item.done 就 create_task 开跑;流结束后按顺序 await,结果按调用顺序回填。"""
clock, lock, history = Clock(), FairRWLock(), []
log = collections.defaultdict(dict)
in_flight = [] # 相当于 FuturesOrdered:先进先出
needs_follow_up = False
async for ev in fake_stream(calls):
if ev["type"] == "response.output_item.done":
item = ev["item"]
log[item["call_id"]]["done"] = clock.now()
history.append(item) # 调用本身先进历史
in_flight.append(asyncio.create_task(run_tool(item, lock, clock, log)))
needs_follow_up = True # 出现过工具调用,就还要再采样一次
elif ev["type"] == "response.completed":
if ev["end_turn"] is False:
needs_follow_up = True
completed = clock.now()
for task in in_flight: # drain_in_flight:按 push 顺序取结果
out = await task
history.append(out)
log[out["call_id"]]["recorded"] = clock.now()
return completed, clock.now(), log, history, needs_follow_up
def retry_delay(kind, retry_after, attempt, rng):
"""对应 CodexErr::retry_delay:None 表示终止,不再重试。"""
if kind in TERMINAL:
return None
if kind in SERVER_DELAY_ONLY:
return retry_after
return retry_after if retry_after is not None else backoff_secs(attempt, rng)
本机运行输出(节选,单位是模拟秒):
A. 第 2 周写法:等响应结束,串行执行
调用 工具 收到 开始 结束 回填
c1 exec_command 2.0 5.0 8.0 8.0
c2 exec_command 3.0 8.0 9.0 9.0
c3 exec_command 4.0 9.0 11.0 11.0
c4 exec_command 5.0 11.0 12.0 12.0
response.completed 在 5.0 秒,这一步总耗时 12.0 秒
B. Codex 写法:工具一完整就开跑,全部可并行
调用 工具 收到 开始 结束 回填
c1 exec_command 2.0 2.0 5.0 5.0
c2 exec_command 3.0 3.0 4.0 5.0
c3 exec_command 4.0 4.0 6.0 6.0
c4 exec_command 5.0 5.0 6.0 6.0
response.completed 在 5.0 秒,这一步总耗时 6.0 秒
回填顺序 ['c1', 'c2', 'c3', 'c4'],needs_follow_up = True
C. Codex 写法:c3 换成 apply_patch(写锁,独占)
调用 工具 收到 开始 结束 回填
c1 exec_command 2.0 2.0 5.0 5.0
c2 exec_command 3.0 3.0 4.0 5.0
c3 apply_patch 4.0 5.0 7.0 7.0
c4 exec_command 5.0 7.0 8.0 8.0
response.completed 在 5.0 秒,这一步总耗时 8.0 秒
注意 B 里的 c2:第 4 秒就跑完了,但要等 c1 在第 5 秒回填之后才轮到它。C 里的 c4 是读者,却要等排在它前面的写者 c3 跑完,这就是公平读写锁。这三组数字和演示 1 的读数一致:A 是默认参数下「第 2 周写法」那一格,B、C 分别是「默认」和「c3 换成 apply_patch」两个预设下的 Codex 写法。
下一章:工具系统与 MCP client。RespondToModel 和 Fatal 的边界、apply_patch 为什么用语法约束而不是 JSON 参数、MCP 工具名冲突怎么处理。