OpenAI Codex · 迴圈骨架

三層 Turn Loop:誰有資格決定繼續

你看到的是一輪對話。內部疊了任務殼、輪次、取樣三層迴圈。各層只回答自己那一個問題,控制權每次只在一層。

課程目標讀完能說清三件事。第一,一條使用者輸入在任務殼、輪次、取樣裡各轉幾圈,每層何時進入、何時退出。第二,插話、工具續跑、stop hook 分別由哪一層接住。第三,為什麼 pending 要問兩次。
先玩一遍 · 一句話在三層裡各轉幾圈
同一條輸入:看控制權落在哪一層,各層圈數怎麼加
劇本
切法
換劇本看哪一層加圈。合成一層之後,插話和 hook 搶同一扇門。
0任務殼
0輪次
0取樣
0工具

任務殼 RegularTask

值班班長。問這一趟還活著嗎。

輪次 run_turn

當班司機。問這一輪還要再採嗎。

取樣 sampling

檢票口。問這一條流結束了嗎。

候車凳 pending

空。插話先坐這裡,當前取樣看不見。

在飛的工具
邏輯軌跡 · 動畫每一步對應原始碼裡的哪一段
  1. 空閒則 spawn RegularTaskturn_input.rs L242
  2. 任務殼發 TurnStarted 後進 loopregular.rs L76
  3. 輪次開頭按開關排 pendingturn.rs L305
  4. 取樣 run_sampling_requestturn.rs L381
  5. 工具當時掛上 futurestream_events_utils.rs L326
  6. Completed 之後再 drainturn.rs L2539
  7. 重算 needs_follow_upturn.rs L423
  8. stop hook 帶 prompt 則 continueturn.rs L525
  9. 任務殼再問 has_pending_inputregular.rs L86
  10. 忙碌則 Steered 寫入 pendingturn_input.rs L207
點播放,看一句話在三層裡各轉幾圈。
教學示意:圈數按這一條預設劇本推演,行號對應 openai/codex 倉庫 commit 4f39251a01。
思路一 · 按誰有資格決定繼續切開
它解決什麼問題

你讓 coding agent 改一個函式。螢幕上這是一輪對話:你說了一句,它忙了一陣,最後回「改完了」。

忙的時候其實疊了幾件事。模型調了工具。你中途補了一句「測試用 pytest」。它寫完助手訊息後,Stop hook 說還沒跑 linter,於是又採了一次樣。

這幾件事如果塞進同一個 while,就只能靠幾個布林搶出口。誰先檢查、誰能打斷誰,會變成口頭約定。少一層,就少一個乾淨插口:插話、續跑和流重試會攪在一起。

思路是什麼

Codex 拆成三層。任務殼 RegularTask::run 決定這一趟還要不要再開一輪。輪次 run_turn 決定工具續跑、插話和 hook 要不要繼續。取樣層只把一次模型流收到 Completed

對外入口 start_or_steer_turn 自己不看會話空不空閒。返回值只表示 Core 接沒接住這條輸入,不等 hooks,也不等取樣。空閒就 spawn RegularTask,忙碌就 Steered 寫入 pending。三層迴圈從任務殼才開始轉。

出處:codex-rs/core/src/codex_thread.rs 第 333 至 344 行 · codex-rs/core/src/session/turn_input.rs 第 1 至 9 行

任務殼 RegularTask 問:這一趟還活著嗎。進入:spawn 之後。退出:run_turn 返回且佇列為空。 輪次 run_turn 問:這一輪還要再採嗎。進入:任務殼呼叫。退出:無續跑且 stop hook 放行。 取樣 sampling 問:這一條流結束了嗎。進入:run_sampling_request。退出:Completed 且 drain 完工具。 重試留在本層。取消和流中斷走 Err。控制權還沒回到輪次。 TurnStarted 只發一次
教學化結構圖:外層保住這一趟還活著,中層決定要不要再採,內層只收完這一條流。

任務殼發一次 TurnStarted,然後只要佇列裡還有待處理輸入,就再調一次 run_turn。第二次進去時 next_input 為空,新訊息從 input_queue 取。turn_id 釘死,介面不會再閃一次「新的一輪開始了」。

出處:codex-rs/core/src/tasks/regular.rs 第 76 至 90 行

輪次層把取樣回來的兩件事合成一個布林:model_needs_follow_up || has_pending_input。為真就自己 continue。為假才跑 stop hook。hook 帶 prompt 攔收工,這一層自己再轉,任務殼和取樣層都還沒退。

出處:codex-rs/core/src/session/turn.rs 第 423 行 · 第 500 至 525 行

取樣層自己還有兩圈。外圈處理可重試錯誤。內圈消費一條 SSE 流。工具呼叫不等 CompletedOutputItemDone 當時就會掛上 future。流收到 Completed,先 drain_in_flight,再把結果交回 run_turn

出處:codex-rs/core/src/stream_events_utils.rs 第 326 至 327 行 · codex-rs/core/src/session/turn.rs 第 2539 至 2584 行 · 第 2749 行

為什麼長期成立

三層按「誰有資格決定繼續」切開。取樣層只看見這一次流,能重試,不能收整輪。輪次層看見工具、pending、預算和 hook,能續取樣,不能重發 TurnStarted。任務殼看見任務還在、佇列裡是否還有活。

這不隨檔案怎麼拆而變。換個語言重寫,該問的還是三個問題:這一條流結束了嗎,這一輪還要再採嗎,這一趟任務還活著嗎。

思路二 · pending 問兩次,夾住 stop hook
它解決什麼問題

RegularTaskrun_turn 都看 pending。同一句使用者話如果兩處都取,會轉兩圈。如果只留一處,就會把 hook 和後到訊息擠進同一個出口。

思路是什麼

兩處問的是兩件不同的事。

輪次層在取樣剛剛結束時問,問的是「這一輪還要不要再採一次」。任務殼在 run_turn 已經 break 之後問,問的是「這一趟任務還要不要再進一次 run_turn」。前者把插話和工具結果留在同一個 turn_id 裡。後者是介面已經可以收工、佇列裡又來了必須處理的輸入。

取樣返回 工具已 drain 輪次問 pending 這一輪還要再採嗎 有貨:自己 continue 任務殼還在等這次 run_turn 無貨:跑 stop hook block 續跑,stop 才 break 任務殼再問 pending 這一趟還要再進 run_turn 嗎
教學化分流圖:兩次判斷夾住 stop hook,問的是兩個時刻的兩個問題。

去掉輪次層那一問:模型寫出最終答案後,run_turn 會去跑 stop hook 並 break,中途那句「測試用 pytest」只能等任務殼再進一次 run_turn。功能上還能補上,只是多一次函式返回,stop hook 會在插話進模型之前先跑一輪。

去掉任務殼那一問:run_turnshould_stop 返回後,任務直接結束。佇列裡後到的使用者訊息要麼消失,要麼等會話變空閒,由 maybe_start_turn_for_pending_work 換一個 turn_id 新開任務。TurnStarted 會再閃一次,回放裡變成兩個 turn 桶。

stop hook 返回 block 且帶 prompt,控制權留在輪次層。取樣層早已返回。任務殼還在等這次 run_turn。block 是輪次層內部續跑。stop 是輪次層把控制權交回任務殼。

出處:codex-rs/core/src/session/turn.rs 第 509 至 537 行 · codex-rs/hooks/src/events/stop.rs 第 67 至 74 行

兩次判斷夾住 stop hook,所以要問兩次。
為什麼長期成立

原始碼沒有單獨寫「為什麼要問兩次」,下面從實現反推。hook 續跑時控制權留在輪次層,任務殼看不見。hook 放行後,任務殼才有機會接手「收工瞬間又來的那一句」。兩個問題發生在不同時刻,所以要問兩次。這跟具體語言、具體 hook 協議無關。

橫向對比 · 同一道題的另一種切法

DSH:按語義切成 Turn、Step、Inbox

DSH 的外圈是 kickwhile (await this.turn()) {}turn() 自己再套一層 while (true),每一圈先 preStep,再 step()。第三層不是第三條 while。Inbox 是兩條陣列:next-turnnext-step。呼叫方在入隊時選 followup、steer 還是 inject。

兩邊都叫三層,切分維度不同。DSH 按語義:Turn 是一輪完整工作,Step 是一次模型請求加工具,Inbox 是說話時機。Codex 按生命週期:任務殼問這一趟還活著嗎,輪次問這一輪還要再採嗎,取樣問這一條流結束了嗎。DSH 因此能從 session 事件重放兩條佇列。Codex 因此能把流重試、取消、end_turn 各自關在取樣層,TurnStarted 只閃一次。

兩側均已核對原始碼 · 2026-08-22 · DSH · Inbox

Claude Code:單層 while 加狀態袋

主迴圈在 query.ts。可變狀態放進一個 state 物件,迴圈體頂部解構,continue 處寫回整袋。needsFollowUp 只由助手訊息裡的 tool_use 塊點亮。沒有 follow-up 時,同一層接著做壓縮、stop hook、進入下一 turn。turnCount 加一,transition 寫成 next_turn,回到 while (true) 頂部。

續跑、壓縮、stop hook 收成同一袋狀態。改一處 continue,要同時核對 stopHookActiveturnCounttransition。Codex 把這三件事分給三層,DSH 把插話分給 Inbox,兩邊都不必在同一個布林上搶門。

兩側均已核對原始碼 · 2026-08-22
課堂練習
01

把任務殼那一問改成恆為假

模型開始調工具時,你補了一句「順便列出當前目錄」。先按三層推演:任務殼、輪次、取樣各轉幾圈,這句後續由哪一層取走。

然後把 regular.rs 第 86 行的 has_pending_input 改成恆為假,讓任務殼在第一次 run_turn 返回後立刻結束。這句後續是消失、等到下一輪,還是由 maybe_start_turn_for_pending_work 換一個 turn_id

Takeaway:三層各自的終止條件寫成三個函式。插話只進 pending。stop hook 只進輪次迴圈。任務殼只在輪次返回後再看佇列。不要收成一個 while 加三個布林。