OpenAI Codex · 程式碼模式

exec 與 wait:跑不完的程式怎麼收場

與其讓模型發二十次工具調用,不如讓它寫一段 JavaScript。這段程式在哪跑、能幹什麼、十秒跑不完又怎麼辦,本課講這三個問題背後的兩個思路。

課程目標讀完能説清兩件事:為什麼給模型的運行時要做減法,減到沒有 Node、沒有檔案系統、沒有網絡、連 console 都沒有;以及一段程式在預算內跑不完的時候,為什麼 Codex 把它記成「還在跑」,不記成一次失敗。
先玩一遍 · 預算到點,殺掉還是讓出
同一段程式:六個子任務,每個約兩秒,一共十二秒
預算
左邊把它當牆鐘上限,右邊把它當讓出間隔。調大到 20 秒,兩邊的差別會消失。
到點終止0/6 有效0段交付
等待開始。
到點讓出0/6 完成0次往返
等待開始。
邏輯軌跡 · 動畫每一步對應源碼裏的哪一段
  1. 模型可以在首行寫一句 pragma,聲明這次給多少讓出時間description.rs L22
  2. 執行入口把這個毫秒數換成「跑到點就觀察一次」的模式service.rs L77
  3. 超過十秒的預算再送一秒寬限,另受服務端上限封頂service.rs L198
  4. 定時器到點,把攢下的輸出整包交出去,緩衝同時清空cell_actor/mod.rs L242
  5. 讓出這件事被翻譯成一句模型讀得懂的話,帶上 cell 編號code_mode/mod.rs L283
  6. 模型拿編號回來續跑,還能順手改預算、限長度或直接叫停wait_handler.rs L24
  7. 腳本跑完,返回結果並關閉這個 cellruntime.rs L24
點播放,看同一段程式在兩種超時策略下怎麼收場。
成果去向到點終止那邊,已經跑完的子任務成果跟着進程一起消失,模型只收到一條超時消息,沒有任何可用的中間結果。
續跑的代價到點讓出那邊,模型多花了幾次往返,換來的是六個子任務全部完成,而且每次讓出都能看見新進展。
預算夠用的時候把預算調到 20 秒,兩邊都是一次跑完。這兩種策略的差別只在預算不夠的時候才顯現。
教學示意:子任務數量與耗時為課程化設定,用於展示兩種超時策略的結構差異。邏輯軌跡右側行號對應 openai/codex 倉庫 commit 4f39251a01。
思路一 · 給模型一個運行時,然後把它削到最小
它解決什麼問題

讀五個檔案再彙總,用普通工具調用要走五次完整往返。每次往返把整個檔案內容推進上下文,模型下一輪還得把滾大的歷史重讀一遍。真正貴的地方在往返的節奏上,工具本身不貴。

那讓模型寫程式不就行了。麻煩在於,這段程式沒有任何人審過一行,模型現寫現交。給它一個能讀檔案、能發網絡請求的運行時,等於把宿主的全部能力直接交出去。

思路是什麼

Codex 給模型兩個工具,execwaitexec 收一段 JavaScript 源碼,扔進一個全新的 V8 isolate 當 async module 求值。所有工具掛在全局 tools 對象上,名字被規範化成合法的 JS 標識符,寫起來就是 await tools.exec_command(...)。程式裏想循環就循環、想分支就分支,中間值留在變數裏,只有主動交出去的那部分回到模型。

關鍵在於這個運行時被削得很薄。工具説明書裏對模型直説了它沒有什麼:

codex-rs/code-mode-protocol/src/description.rs第 20 至 25 行
- Runs raw JavaScript -- no Node, no file system, no network access, no console.
- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
源碼快照説明:依據本地倉庫 openai/codex,核對檔案 codex-rs/code-mode-protocol/src/description.rs,commit 4f39251a01,核對日期 2026-08-22。程式碼塊保留源碼原文,這段文本本身就是發給模型的工具説明。

沒有 Node,沒有檔案系統,沒有網絡,連 console 都沒有。這些能力不是忘了加,是特意不給。程式想產生任何副作用,只剩一條路,走 tools。那條路上審批和沙箱一樣不少,該彈的窗照彈,該攔的照攔。

附帶好處是要審查的面積小了。isolate 裏如果能直接讀檔案,這一層就得自己再做一套檔案權限;現在它什麼都做不了,權限判斷留在下一層就夠,程式碼不用寫兩遍。

逐個調用 模型 工具 每一次往返都要一輪採樣,中間結果整段進上下文 寫一段程式 模型 採樣一輪 V8 isolate 沒有 Node、檔案系統、網絡與 console,副作用只能走 tools 一段 JavaScript 只有主動交出去的部分
教學化結構圖:同一件活,上面走多次往返,下面走一次。
為什麼長期成立

往返貴、批處理便宜,這是幾十年的老賬。數據庫有批量寫入,RPC 框架都在攢 batch。模型採樣一輪比一次網絡往返貴得多,把 N 次合成一次,收益只會更誇張。

減法這一半更通用。給不受信任的程式碼一個儘量小的環境,讓它想幹壞事都沒有接口可用,這是安全設計的通用形狀,和 V8 這個具體技術沒關係。換成別的語言、別的沙箱,該問的還是同一個問題:這段程式碼到底需要哪幾樣能力,其餘的能不能一樣都不給。

思路二 · 跑不完不叫失敗,叫還在跑
它解決什麼問題

程式要跑三分鐘,超時該設多少。

設成三分鐘,用戶三分鐘看不到動靜,也沒地方喊停。設成十秒,長任務永遠做不完,更難受的是前面九秒的成果跟着一起丟,模型只收到一條超時消息,只能從頭再來。兩個方向都不對,問題出在把「還沒跑完」當成了失敗。

思路是什麼

Codex 把正在跑的腳本做成一個有身份的東西,叫 cell。yield_time_ms 到點,cell 不死,它把這段時間攢下的輸出整包交出去,然後清空緩衝繼續跑。exec 這時返回一句話,告訴模型腳本還在跑,編號是多少。

模型拿到編號,手上就有三個選擇:調 wait 再買一段時間;帶 terminate: true 把它停掉;或者乾脆先去幹別的。wait 只返回上次讓出之後的新輸出,因為交出去的時候緩衝就被清空了,同一段內容不會重複佔兩次上下文。

到點終止 腳本運行中,輸出攢在緩衝裏 牆鐘到點,進程終止 攢下的輸出一起消失 預算到點 到點讓出 腳本運行中,輸出攢在緩衝裏 整包交出,緩衝清空 腳本繼續跑 wait 續跑 只收新增的那一段 預算到點
教學化時序圖:同一個時刻,一邊終止進程,一邊交出成果繼續跑。

有個小細節很能説明設計者在想什麼。讓出時間超過十秒時,Codex 會額外再送一秒寬限,然後才真的觀察。出處:codex-rs/code-mode-runtime/src/service.rs 第 198 至 210 行剛好卡在邊界上完成的腳本,不會因為差幾毫秒白白多走一次往返。

還有一處不對稱值得記一筆。發給模型的説明書裏,wait 有四個參數:cell 編號、讓出時間、返回長度上限、要不要終止。可協議層的請求結構體只帶前兩個,後兩個停在處理器那一層,終止走的是另一條路徑,長度上限是拿到結果之後才截斷的。讀源碼的時候這兩層很容易混成一層。

預算到點,不殺掉,先交作業。
為什麼長期成立

把「還沒結束」做成一等狀態,是長任務接口的通用形狀。HTTP 有 202 加輪詢,任務隊列有 job id 加 poll,導出大檔案的後台任務也是先給你一個編號。共同點是不讓調用方在「一直等」和「當作失敗」之間二選一,而是給一個可以再問一次的把手。

放到 agent 上,這個把手還多一層價值。模型拿到中間輸出之後可以改主意,發現前四步的結果不對,直接 terminate,不用陪着跑完剩下八分鐘。控制權回到了會思考的那一方手裏。

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

超時:DeepSeek Harness 選擇殺掉

DSH 的 run_code 用兩本賬。一本記忙碌時間,靠輪詢 worker 的事件循環利用率,熱循環藏不住,幹等慢工具也不冤枉計費。另一本記牆鐘,到點直接終止 worker。預設是六萬毫秒和六十萬毫秒。

代價很清楚:一次 run_code 必須在預算內結束,超時就是失敗,沒有「同一段程式接着跑」這種一等狀態。換來的是實現簡單,宿主不用維護一堆還活着的 cell。Codex 反過來,模型要多學一個 wait 協議,cell 會在會話裏佔着資源,直到跑完、被停掉或者會話結束。

兩側均已核對源碼 · 2026-08-22 · DSH · Code Mode

狀態:一次性的世界,還是留着的抽屜

DSH 的設計筆記寫得很直白,程式所在的世界會隨 worker 一同終止,不做池化,也不做跨運行狀態。需要傳給下一次的東西,要麼寫進工具結果,要麼落到工作區檔案裏。好處是每次運行都是乾淨的新世界,出了問題容易重放。

Codex 給了 storeload,同一個會話裏的多次 exec 可以共享數據,跨會話則互相看不見。編排起來方便,代價是清理責任落回自己身上:這個抽屜沒有單條大小上限,只拒絕存不進 JSON 的值,也沒有過期時間,要等整個會話結束才隨運行時一起釋放。

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

讓出預算怎麼算才不虧

一段程式要跑四十秒,讓出預算預設十秒。推演一下:模型一共要發幾次 wait,每次拿到的是全部輸出還是新增的那一段,為什麼把預設值改成三十秒並不總是更划算。

進階一問:如果程式在第三十五秒把一個很大的對象放進了 store,隨後模型決定 terminate,這個對象什麼時候被清掉,誰來管它的大小。

Takeaway:讓模型寫程式,把 N 次往返壓成一次。給這段程式的運行時做減法,沒有 Node、檔案系統、網絡和 console,副作用只能走工具那條已經有審批的路。預算到點先交作業再續跑,把「還沒跑完」做成一等狀態,成果不丟,控制權還給模型。