本集对应课程章节:OpenAI Codex · 代码模式(Code Mode 运行时与宿主拓扑)
内容来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》),本页为二次演绎的解读与音频稿件版本。
与其让模型发二十次工具调用,不如让它写一段 JavaScript。 但随之而来三个问题:
一句话骨架:往返贵、批处理便宜;给不受信任的代码一个尽量小的环境,是安全设计的通用形状。
| 维度 | 读完本集你能做到 | 对应证据 |
|---|---|---|
| 运行时减法 | 复述工具说明书里「没有什么」的四条 | description.rs L20–25 |
| 让出协议 | 说明 cell 与 wait 如何把「未结束」做成一等状态 | service.rs L77、cell_actor/mod.rs L242 |
| 宽限与不对称 | 解释 1 秒宽限与说明书 / 协议两层参数差异 | service.rs L198–210 |
| 宿主拓扑 | 说明默认已是本地子进程而非主进程内 isolate | features/src/lib.rs L104–111 / L921–925 |
| 回调回家 | 说明远端只搬求值,审批仍回本机 | delegate.rs L26–50 |
| 掉线语义 | 说明世代前缀与 store 归属 | generation.rs L49–67、tasks/mod.rs L888–899 |
动机:读五个文件再汇总 → 五次完整往返,每次把整个文件内容推入上下文,下一轮还要重读滚大的历史。贵的是往返节奏,不是工具本身。
做法:两个工具 exec / wait。exec 收一段 JavaScript 源码,扔进全新 V8 isolate 当 async module 求值;工具挂在全局 tools 对象上,名字规范化为合法 JS 标识符。
工具说明书原文要点(code-mode-protocol/src/description.rs L20–25):
| 条 | 内容 |
|---|---|
| 1 | Runs raw JavaScript —— no Node, no file system, no network access, no console |
| 2 | 接受纯源码文本,不是 JSON、加引号字符串或 markdown 代码围栏 |
| 3 | 首行可写 pragma:// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000} |
| 4 | yield_time_ms 默认 10000 ms;max_output_tokens 默认 10000 tokens |
| 5 | 脚本求值完毕 isolate 生命周期即结束,未 await 的 promise 被静默丢弃 |
关键推论:这些能力不是忘了加,是特意不给。程序要产生副作用只剩一条路 —— 走 tools,而那条路上审批与沙箱一样不少。
附带好处:要审查的面积变小。isolate 若能直接读文件,这层就得再做一套文件权限;现在它什么都做不了,权限判断留在下一层即可,代码不用写两遍。
给不受信任的代码做减法,而不是加监控。换语言、换沙箱,该问的还是同一句:这段代码到底需要哪几样能力,其余的能不能一样都不给。
两难:超时设三分钟 → 用户三分钟看不到动静;设十秒 → 长任务永远做不完,且前九秒的成果跟着一起丢。
问题根源:把「还没跑完」当成了失败。
做法:把正在跑的脚本做成有身份的东西 —— cell。
| 时刻 | 行为 |
|---|---|
yield_time_ms 到点 | cell 不死,把攒下的输出整包交出,清空缓冲继续跑 |
exec 返回 | 一句话:脚本还在跑,编号是多少 |
| 模型拿到编号 | 三个选择:wait 续买时间 / terminate: true 停掉 / 先去干别的 |
wait 返回 | 只返回上次让出之后的新输出(缓冲已清空,不重复占上下文) |
出处:service.rs L77、cell_actor/mod.rs L242、code_mode/mod.rs L283、wait_handler.rs L24、runtime.rs L24
为什么长期成立:把「还没结束」做成一等状态,是长任务接口的通用形状 —— HTTP 202 + 轮询、任务队列 job id + poll、导出大文件先给编号。共同点是不让调用方在「一直等」和「当作失败」之间二选一。
放到 agent 上的额外价值:模型拿到中间输出后可以改主意,发现前几步结果不对直接 terminate,控制权回到会思考的那一方。
service.rs L198–210)wait 有四个参数(cell 编号 / 让出时间 / 返回长度上限 / 是否终止),协议层请求结构体只带前两个;终止走另一条路径,长度上限是拿到结果后才截断。读源码时这两层极易混为一层。为什么拆出去:模型写一段 while (true) {},若与 Codex 事件循环抢同一进程,卡死会从单个 cell 扩大到整条会话。V8 堆、JIT、无条目上限的 store 表,任一在主进程爆炸都会带走 TUI 或 app-server。
出处:features/src/lib.rs L104–111(早期资料常写成「主进程里直接跑 isolate」,当前特性注释已改掉)
当前默认拓扑
| 条件 | 提供方 |
|---|---|
CodeModeHost 打开,或 disable_in_process_fallback 为真 | ProcessOwnedCodeModeSessionProvider |
| 两条都不成立 | DisabledCodeModeSessionProvider |
| —— | 生产路径已无直接 new InProcessCodeModeSession |
CodeModeHost 已 Stable 且默认打开;进程内求值仍存在,但沉到宿主进程内部,房东换成独立二进制 codex-code-mode-host。
出处:thread_manager.rs L455–462、features/src/lib.rs L921–925、code-mode-host/src/lib.rs L599–608
拉起细节:stdin / stdout / stderr 全管道化;Unix 上单独进程组;环境变量先 scrub。找不到可执行文件 → availability() 直接失败,不会改去主进程里 new isolate。真正的回退发生在工具模式层:宿主不可用且未关回退时,有效工具模式变 Direct,模型重新看见普通工具。
常见误解:把 app-server 指到远端 --code-mode-host,以为整段 agent 都搬家了,连审批弹窗都该出现在远端。当前源码对不上。
机制:宿主把 CodeModeSessionDelegate 做成 RemoteDelegate,经 IPC 打回 Codex 主进程;主进程的 dispatch broker 才走嵌套工具。
isolate 搬家了,策略没有搬家。JS 在别处跑,副作用要绕回来问你。
出处:code-mode-host/src/delegate.rs L26–50
安全细节:WebSocket 监听器拒绝带 Origin 头的请求,挡住浏览器页面跨源连到本机宿主。app-server 的 --code-mode-host 认 http / https 为 gRPC,认 ws / wss 为 WebSocket。多个 thread 共享同一条远端连接,store 仍按会话切开。
为什么长期成立:不可信的是 JS 世界,可信的是审批和策略。把前者搬走、后者留在有 UI 的那边 —— 没有这条回路,远端宿主就必须复制整套权限系统。
两种常见预期,源码都不认
| 预期 | 实际 |
|---|---|
| 按中断 → V8 一起掐掉,下一轮 wait 立刻看到终止 | turn 标 Interrupted 时取消令牌一定取消;是否再 terminate cell 取决于 CodeModeInterrupt(在开发中,默认关)。关掉时中断只取消本轮工具调用与审批,宿主上的 isolate 可继续跑。Ctrl-C ≠ terminate。出处:tasks/mod.rs L888–899 |
| 重连 → 刚才的脚本还在原 cell 里接着跑 | gRPC 丢 lease 即关会话;可再开租约,generation 从 1 往上加。第一代沿用原始 cell ID,第二代变 g{generation}:{cell_id};拿旧编号 wait 会收到 stale generation。本地子进程路径无前缀,只是状态机打回 New 并分配新 session-N。出处:grpc_session/generation.rs L49–67 |
两种重连都丢运行中的 cell 与那份 store。 store 表跟着宿主侧 SessionRuntime:无落盘、无跨进程共享、无 TTL;远端重启表就没了;会话 ID 复用会被拒绝。
重连恢复的是「还能再 exec」,不是「刚才那段脚本」。cell 寿命按会话算,不按 turn 算。
| 项目 | 拓扑 | 超时 | 状态 |
|---|---|---|---|
| Codex | 默认本地子进程;宿主进程内才是 isolate。可再换远端 | 让出续跑(cell + wait),「未结束」是一等状态 | store / load 同会话共享,跨会话隔离;无单条大小上限、无 TTL |
| DeepSeek Harness | 进程内 worker_threads.Worker。crate 头注释第一句写死立场:这是 containment,不是 security boundary;模型代码按 bash 等价对待 | 到点终止 worker。一次 run_code 必须在预算内结束,超时即失败,没有续跑状态。默认六万 / 六十万毫秒 | 程序世界随 worker 一起死,不做池化、不做跨运行状态;要传递就写进工具结果或落到工作区文件 |
| Claude Code / Grok | 无对位实现。隔离出现在 git worktree、远端 CCR 会话、子 agent worktree —— 是工作区隔离,不是 JS 宿主拆分 | 留给普通 shell 工具 | —— |
DSH 的代价与收益:实现简单,宿主不用维护活着的 cell;代价是一次运行必须预算内结束。Codex 反过来:模型要多学一个 wait 协议,cell 会占着会话资源。
没有对位物本身就是结论:Claude Code 与 Grok 把「跑模型写的代码」留给了普通 shell 工具。
tools,且那条路上的审批与沙箱未被绕过wait 是否只返回新增输出(缓冲已清空)Origin 的请求同一段脚本在 gRPC 宿主上跑到一半,连接断了又连上:
cell_id 去 wait,会看到什么?CodeModeInterrupt 拨到关,按中断再 wait 一次 —— 答案会不会变?为什么用户按停止并不自动等于 terminate?一段程序要跑四十秒,让出预算默认十秒:
wait?openai/codex,commit 4f39251a01,核对日期 2026-08-22;代码块保留源码原文。code-runtime-worker-thread 与 README(2026-08-22);Claude Code 与 Grok 为检索未找到对位实现。exec-server 搬走的是 shell、PTY 与文件系统 RPC,不跑 JavaScript;嵌套 tools.exec_command 可再走进去,属于下一层执行拆分,两条路不要收成同一个远端。让模型写程序,把 N 次往返压成一次。给这段程序的运行时做减法,没有 Node、文件系统、网络和 console,副作用只能走工具那条已经有审批的路。预算到点先交作业再续跑,把「还没跑完」做成一等状态。默认拓扑里 V8 已经不在主进程;远端只搬走求值,审批仍回本机;宿主崩了或掉线,cell 和 store 一起没了;Ctrl-C 默认不 terminate 还在跑的 cell。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页为二次演绎的解读与音频稿件版本,音频与本页配套发布。