专题篇章 · 拆开 OpenAI Codex

exec 与 wait:跑不完的程序怎么收场

能力做减法的 V8 isolate,以及跑不完就让出 cell、用 wait 续跑的长任务协议

本页解决的问题

先给结论

「exec 与 wait:跑不完的程序怎么收场」要解决的关键问题是什么?

能力做减法的 V8 isolate,以及跑不完就让出 cell、用 wait 续跑的长任务协议

判断标准

跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。

下一步

为一个自动化步骤写清输入、负责人、审批和恢复动作。

常见误区

一次运行成功了,却说不清发生了什么,也无法安全重放。

课程目标读完能说清两件事:为什么给模型的运行时要做减法,减到没有 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,副作用只能走工具那条已经有审批的路。预算到点先交作业再续跑,把「还没跑完」做成一等状态,成果不丢,控制权还给模型。

「先玩一遍 · 预算到点,杀掉还是让出」的能力藏在每次交接里

「读五个文件再汇总,用普通工具调用要走五次完整往返。每次往返把整个文件内容推进上下文,模型下一轮还得把滚大的历史重读一遍。真正贵的地方在往返的节奏上,工具本身不贵」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。

先写清状态,再增加能力

从「那让模型写程序不就行了。麻烦在于,这段程序没有任何人审过一行,模型现写现交。给它一个能读文件、能发网络请求的运行时,等于把宿主的全部能力直接交出去」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。

  • 模型可以在首行写一句 pragma,声明这次给多少让出时间 description.rs L22
  • 执行入口把这个毫秒数换成「跑到点就观察一次」的模式 service.rs L77
  • 超过十秒的预算再送一秒宽限,另受服务端上限封顶 service.rs L198

成功路径不能代表系统可靠

用「进阶一问:如果程序在第三十五秒把一个很大的对象放进了 store ,随后模型决定 terminate ,这个对象什么时候被清掉,谁来管它的大小」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。

从「先玩一遍 · 预算到点,杀掉还是让出」走到「思路一 · 给模型一个运行时,然后把它削到最小」

「先玩一遍 · 预算到点,杀掉还是让出」先把问题落在「同一段程序:六个子任务,每个约两秒,一共十二秒 播放 单步 重置 预算 4 秒 8 秒 20 秒 左边把它当墙钟上限,右边把它当让出间隔。调大到 20 秒,两边的差别会消失。 到点终止 0 /6 有效 0 段交付 等待开始。 到点让出 0 /6 完成 0 次往返 等待开始。 逻辑轨迹 · 动画每一步对应源码里的哪一段 模型可以在首行写一句 pragma,声明这次给多少让出时间 description.rs L22 执…」上;到了「思路一 · 给模型一个运行时,然后把它削到最小」,讨论继续推进到「读五个文件再汇总,用普通工具调用要走五次完整往返。每次往返把整个文件内容推进上下文,模型下一轮还得把滚大的历史重读一遍。真正贵的地方在往返的节奏上,工具本身不贵」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。

把这条判断带到下一个场景

分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。

  • 「先玩一遍 · 预算到点,杀掉还是让出」:同一段程序:六个子任务,每个约两秒,一共十二秒 播放 单步 重置 预算 4 秒 8 秒 20 秒 左边把它当墙钟上限,右边把它当让出间隔。调大到 20 秒,两边的差别会消失。 到点终止 0 /6 有效 0 段交付 等待开始。 到点让出 0 /6 完成 0 次往返 等待开始。 逻辑轨迹 · 动画每一步对应源码里的哪一段 模型可以在首行写一句 pragma,声明这次给多少让出时间 description.rs L22 执…
  • 「思路一 · 给模型一个运行时,然后把它削到最小」:读五个文件再汇总,用普通工具调用要走五次完整往返。每次往返把整个文件内容推进上下文,模型下一轮还得把滚大的历史重读一遍。真正贵的地方在往返的节奏上,工具本身不贵
  • 「最后的要点」:让出这件事被翻译成一句模型读得懂的话,带上 cell 编号 code_mode/mod.rs L283

最后的「最后的要点」把讨论落到「让出这件事被翻译成一句模型读得懂的话,带上 cell 编号 code_mode/mod.rs L283」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。

标记为已学完 阅读进度会自动记录
← 上一篇下一篇 →

继续阅读

同一条线上的下一篇。

文章讨论

读到这里,留下一个判断。

把刚想明白的地方、还没想通的问题,留给下一位一起学习的人。

正在讨论 exec 与 wait:跑不完的程序怎么收场 拆开 OpenAI Codex
3条讨论文章讨论 · 与共学社区同步
在共学社区查看
AM
Asha Morgan内容编辑
观点实践记录

我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。

文章讨论7 有帮助
LH
Lin Harper独立开发者
观点观点

读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。

文章讨论5 有帮助
KM
Kiki Moore产品运营
问题问题

如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。

文章讨论4 有帮助