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,这个对象什么时候被清掉,谁来管它的大小。