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 留在原處。