exec 與 wait:跑不完的程式怎麼收場
與其讓模型發二十次工具調用,不如讓它寫一段 JavaScript。這段程式在哪跑、能幹什麼、十秒跑不完又怎麼辦,本課講這三個問題背後的兩個思路。
- 模型可以在首行寫一句 pragma,聲明這次給多少讓出時間description.rs L22
- 執行入口把這個毫秒數換成「跑到點就觀察一次」的模式service.rs L77
- 超過十秒的預算再送一秒寬限,另受服務端上限封頂service.rs L198
- 定時器到點,把攢下的輸出整包交出去,緩衝同時清空cell_actor/mod.rs L242
- 讓出這件事被翻譯成一句模型讀得懂的話,帶上 cell 編號code_mode/mod.rs L283
- 模型拿編號回來續跑,還能順手改預算、限長度或直接叫停wait_handler.rs L24
- 腳本跑完,返回結果並關閉這個 cellruntime.rs L24
讀五個檔案再彙總,用普通工具調用要走五次完整往返。每次往返把整個檔案內容推進上下文,模型下一輪還得把滾大的歷史重讀一遍。真正貴的地方在往返的節奏上,工具本身不貴。
那讓模型寫程式不就行了。麻煩在於,這段程式沒有任何人審過一行,模型現寫現交。給它一個能讀檔案、能發網絡請求的運行時,等於把宿主的全部能力直接交出去。
Codex 給模型兩個工具,exec 和 wait。exec 收一段 JavaScript 源碼,扔進一個全新的 V8 isolate 當 async module 求值。所有工具掛在全局 tools 對象上,名字被規範化成合法的 JS 標識符,寫起來就是 await tools.exec_command(...)。程式裏想循環就循環、想分支就分支,中間值留在變數裏,只有主動交出去的那部分回到模型。
關鍵在於這個運行時被削得很薄。工具説明書裏對模型直説了它沒有什麼:
- 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 裏如果能直接讀檔案,這一層就得自己再做一套檔案權限;現在它什麼都做不了,權限判斷留在下一層就夠,程式碼不用寫兩遍。
往返貴、批處理便宜,這是幾十年的老賬。數據庫有批量寫入,RPC 框架都在攢 batch。模型採樣一輪比一次網絡往返貴得多,把 N 次合成一次,收益只會更誇張。
減法這一半更通用。給不受信任的程式碼一個儘量小的環境,讓它想幹壞事都沒有接口可用,這是安全設計的通用形狀,和 V8 這個具體技術沒關係。換成別的語言、別的沙箱,該問的還是同一個問題:這段程式碼到底需要哪幾樣能力,其餘的能不能一樣都不給。
程式要跑三分鐘,超時該設多少。
設成三分鐘,用戶三分鐘看不到動靜,也沒地方喊停。設成十秒,長任務永遠做不完,更難受的是前面九秒的成果跟着一起丟,模型只收到一條超時消息,只能從頭再來。兩個方向都不對,問題出在把「還沒跑完」當成了失敗。
Codex 把正在跑的腳本做成一個有身份的東西,叫 cell。yield_time_ms 到點,cell 不死,它把這段時間攢下的輸出整包交出去,然後清空緩衝繼續跑。exec 這時返回一句話,告訴模型腳本還在跑,編號是多少。
模型拿到編號,手上就有三個選擇:調 wait 再買一段時間;帶 terminate: true 把它停掉;或者乾脆先去幹別的。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 會在會話裏佔着資源,直到跑完、被停掉或者會話結束。
狀態:一次性的世界,還是留着的抽屜
DSH 的設計筆記寫得很直白,程式所在的世界會隨 worker 一同終止,不做池化,也不做跨運行狀態。需要傳給下一次的東西,要麼寫進工具結果,要麼落到工作區檔案裏。好處是每次運行都是乾淨的新世界,出了問題容易重放。
Codex 給了 store 和 load,同一個會話裏的多次 exec 可以共享數據,跨會話則互相看不見。編排起來方便,代價是清理責任落回自己身上:這個抽屜沒有單條大小上限,只拒絕存不進 JSON 的值,也沒有過期時間,要等整個會話結束才隨運行時一起釋放。
讓出預算怎麼算才不虧
一段程式要跑四十秒,讓出預算預設十秒。推演一下:模型一共要發幾次 wait,每次拿到的是全部輸出還是新增的那一段,為什麼把預設值改成三十秒並不總是更划算。
進階一問:如果程式在第三十五秒把一個很大的對象放進了 store,隨後模型決定 terminate,這個對象什麼時候被清掉,誰來管它的大小。