OpenAI Codex · 工具闭环

流还在走,工具已经开工

模型还在打字,读文件的声音已经响了。采样循环的时序是:流内建 future,流后统一 drain。先 persist,再等结果。

课程目标读完能说清三件事。第一,解析器在哪一帧决定工具起跑。第二,为什么请求要先写入历史,再挂到队列上。第三,流正常结束、提前关掉或用户按 Esc,已经开工的工具归谁收尾,历史里留下什么。
先玩一遍 · 拖进度,看哪一帧起跑
同一条 SSE 流:拖进度,看工具何时开工
出口
拖滑块或点事件格。左边流内执行,右边等流结束再执行。出口开关改最后一帧怎么收场。
流内执行0 条已落盘
等待开始。
等流结束0 条已落盘
等待开始。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. SSE 帧先解成通用事件,还没有业务含义responses.rs L164
  2. kind 是 output_item.done,就产出 OutputItemDoneresponses.rs L352
  3. 采样循环一到就交给 handle_output_item_doneturn.rs L2384
  4. 先把 function_call 写入历史和 rolloutstream_events_utils.rs L316
  5. 再 pin 工具,推进有序队列stream_events_utils.rs L320
  6. 流结束、断流或取消,都只是离开收流循环turn.rs L2282
  7. drain 按插入顺序把结果写入历史turn.rs L2135
  8. 然后才看取消令牌;Stream 可重试turn.rs L2760
拖进度或点播放。看解析器在哪一帧决定工具起跑。
起跑点左边在第一条 OutputItemDone 就盖章并开工。右边要等最后一帧。模型还在吐字的时候,两边已经分道。
断流左边已经落盘的请求和 drain 出来的结果都在,重试读这份历史。右边请求还没写下,重试从空历史开始。
Esc左边请求保留,输出写成中止文案,标记 TurnAborted,不会再打一轮。右边什么都没写下。
教学示意:事件带与耗时为课程化设定,用来对照两种时序。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 请求先盖章,工具再开工
它解决什么问题

你让模型读三个文件再写摘要。屏幕上还在打字,读文件的声音已经响了。然后你按 Esc。界面停了,历史里却留下那次请求,有时还留下结果。你以为取消等于什么都没发生。运行时并不这么记账。

另一头更常见:模型已经发出两个 function_call,第三个还在路上,SSE 在 response.completed 到来之前关掉。下一次重试该看见空历史,还是已经落盘的调用和结果?

若等 Completed 再写入,提前关流会把已经完整的调用一起扔掉。重试让模型再发一遍同样的调用。若取消时跳过写入,历史只剩半截请求,模型和界面都看见一个没闭合的调用。

思路是什么

OutputItemDone 是解析层把一帧 response.output_item.done 收成的业务事件。它一到,采样循环先把这一条写入会话历史和 rollout,再把工具执行包成 future 挂到有序队列上。取消令牌用子令牌,父令牌一亮,这个工具跟着停。取消来得再快,这一条 function_call 已经进历史。最多再多写一条 aborted by user

出处:codex-rs/core/src/stream_events_utils.rs 第 190 至 192 行;第 316 至 327 行。类型别名上方的注释把合同写死:完成的模型输出要立刻记下来,后面 turn 被取消,历史和 rollout 也保持同步。

SSE 字节流 还在继续吐帧 OutputItemDone 一条完整的工具请求 写入历史 function_call 先落盘 挂起 future 工具已经在跑 后续 delta 仍在路上 输入是一条已经完成的工具请求。输出是回执:历史多了一条,队列多了一个还在跑的 future。 模型后面的字还没说完。请求已经盖章。
教学化结构图:同一帧里先 persist,再 pin。后面的打字和工具时间重叠。
为什么长期成立

历史只能往上加,不能改写。工具已经读了磁盘,这条事实已经发生。取消树可以打断执行,打断不了已经写下的请求。先写请求、后写结果,transcript 始终闭合。这条合同不依赖 Rust,换 TypeScript 也是先 appendpush 一个 promise。

出处:AGENTS.md 第 91 至 100 行,Model visible context 第一条:No history rewrite。

思路二 · 流内挂起,每个出口都 drain
它解决什么问题

模型常常先发出读文件,再继续写一段说明。等收工哨再开工,等于把读文件的延迟和打字的延迟串起来。流内开工能让这两段时间重叠。代价是取消和断流必须认领已经开工的 future。没有认领人,就会出现孤儿任务:工具还在跑,历史对不上。

思路是什么

收流循环无论正常 Completed、提前关流还是 or_cancel,都只是离开 loop。函数还没返回。随后固定调用 drain_in_flight,按插入顺序等到每条 future 给出结果,再写入历史。然后才检查取消令牌。

断流走 Stream,可重试。重试从 clone_history 重建 prompt,已经写下的调用和结果都在。Esc 走 TurnAborted,不可重试。正在跑的工具写出中止文案,drain 把它当普通输出写入。

出处:codex-rs/core/src/session/turn.rs 第 2282 至 2284 行;第 2744 至 2762 行。codex-rs/protocol/src/error.rs 第 88 至 93 行、第 364 至 390 行。

收流循环 Completed 提前关流 Esc drain_in_flight 按插入顺序写结果,然后才看取消令牌
教学化出口图:三条路先汇到 drain,再分出跟进、重试或中止。
流内建 future,流后统一 drain。
为什么长期成立

中途开工就必须在每个出口等齐。所有权留在采样函数的局部变量里,没有另一条后台回收队列。成功、错误、取消共用这一段收尾。换语言也一样:离开异步循环之后先 allSettled,再决定重试还是中止。

思路三 · 执行可以并行,历史按发出顺序写

三个工具可以同时跑。谁先跑完,历史仍按模型发出的顺序写结果。按完成顺序写,同一段会话重放两次可能对不上,prompt cache 也会更脆。有序队列把观测顺序和执行顺序拆开。并发闸门在别的一层,这里只记:挂起顺序就是日后 drain 的顺序。

出处:codex-rs/core/src/session/turn.rs 第 2130 至 2154 行;第 2391 至 2397 行。

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

Claude Code:默认等流结束,另有一扇流内闸门

默认路径里,流式循环只收集 tool_usefor await 结束后才进入 runTools。流断了只需丢掉已经收集的 block,省掉每个出口都 drain 的局部所有权。代价是工具延迟和打字延迟串行。

streamingToolExecution 打开时,行为靠近 Codex:流内 addTool,立刻开工。失败回退要 discard 已经开工的工具,避免旧 id 漏进重试。Codex 没有对等的 discard,因为它选择先 persist,重试读历史。

两侧均已核对源码 · query.ts 第 551 至 568 行、第 1380 至 1382 行

DSH:三段瀑布加单调 Guard,管的是谁能拒绝

DSH 的入口是一条已经成型的工具调用。pre / guard / around / post 回答谁能拒绝,拒绝之后结果还在不在。Guard 只有拒绝理由或弃权,没有放行这个选项。它的 drained 是单次 execute 内部的收尾。SSE 还在飞的时候,这套瀑布还没开始。

两边词面相近,出口不同。一边护权限单调,一边护流式 transcript 闭合。把 Guard 搬进 Codex,挡不住断流丢 transcript。把 persist-then-drain 搬进 DSH,也回答不了插件能不能把拒绝改成放行。

两侧均已核对源码 · tools/src/index.ts 第 1 至 4 行、第 703 至 711 行、第 1328 至 1337 行 · DSH · 三段瀑布与单调 Guard
课堂练习
01

把写入和挂起对调

handle_output_item_done 的工具分支里,把 record_completed_response_itemBox::pin(handle_tool_call) 对调。取消发生在 pin 之前、persist 之前。下一轮采样和会话恢复会看见什么?

把答案落到历史只能增量追加,以及 drain_in_flight 的写入时机上。

Takeaway:OutputItemDone 一到就先写入再开工。流的每个出口先 drain,结果按发出顺序写。取消打断执行,已经写下的 transcript 留在原处。