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,副作用只能走工具那条已经有审批的路。预算到点先交作业再续跑,把「还没跑完」做成一等状态,成果不丢,控制权还给模型。