流還在走,工具已經開工
模型還在打字,讀檔案的聲音已經響了。取樣迴圈的時序是:流內建 future,流後統一 drain。先 persist,再等結果。
- SSE 幀先解成通用事件,還沒有業務含義responses.rs L164
- kind 是 output_item.done,就產出 OutputItemDoneresponses.rs L352
- 取樣迴圈一到就交給 handle_output_item_doneturn.rs L2384
- 先把 function_call 寫入歷史和 rolloutstream_events_utils.rs L316
- 再 pin 工具,推進有序佇列stream_events_utils.rs L320
- 流結束、斷流或取消,都只是離開收流迴圈turn.rs L2282
- drain 按插入順序把結果寫入歷史turn.rs L2135
- 然後才看取消令牌;Stream 可重試turn.rs L2760
你讓模型讀三個檔案再寫摘要。螢幕上還在打字,讀檔案的聲音已經響了。然後你按 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 也保持同步。
歷史只能往上加,不能改寫。工具已經讀了磁碟,這條事實已經發生。取消樹可以打斷執行,打斷不了已經寫下的請求。先寫請求、後寫結果,transcript 始終閉合。這條合同不依賴 Rust,換 TypeScript 也是先 append 再 push 一個 promise。
出處:AGENTS.md 第 91 至 100 行,Model visible context 第一條:No history rewrite。
模型常常先發出讀檔案,再繼續寫一段說明。等收工哨再開工,等於把讀檔案的延遲和打字的延遲串起來。流內開工能讓這兩段時間重疊。代價是取消和斷流必須認領已經開工的 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 行。
中途開工就必須在每個出口等齊。所有權留在取樣函式的區域性變數裡,沒有另一條後臺回收佇列。成功、錯誤、取消共用這一段收尾。換語言也一樣:離開非同步迴圈之後先 allSettled,再決定重試還是中止。
三個工具可以同時跑。誰先跑完,歷史仍按模型發出的順序寫結果。按完成順序寫,同一段會話重放兩次可能對不上,prompt cache 也會更脆。有序佇列把觀測順序和執行順序拆開。併發閘門在別的一層,這裡只記:掛起順序就是日後 drain 的順序。
出處:codex-rs/core/src/session/turn.rs 第 2130 至 2154 行;第 2391 至 2397 行。
Claude Code:預設等流結束,另有一扇流內閘門
預設路徑裡,流式迴圈只收集 tool_use,for await 結束後才進入 runTools。流斷了只需丟掉已經收集的 block,省掉每個出口都 drain 的區域性所有權。代價是工具延遲和打字延遲序列。
streamingToolExecution 開啟時,行為靠近 Codex:流內 addTool,立刻開工。失敗回退要 discard 已經開工的工具,避免舊 id 漏進重試。Codex 沒有對等的 discard,因為它選擇先 persist,重試讀歷史。
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把寫入和掛起對調
在 handle_output_item_done 的工具分支裡,把 record_completed_response_item 和 Box::pin(handle_tool_call) 對調。取消發生在 pin 之前、persist 之前。下一輪取樣和會話恢復會看見什麼?
把答案落到歷史只能增量追加,以及 drain_in_flight 的寫入時機上。
OutputItemDone 一到就先寫入再開工。流的每個出口先 drain,結果按發出順序寫。取消打斷執行,已經寫下的 transcript 留在原處。