Code Mode
前面几集里,模型要一次工具,就发一次工具调用。读一个文件是一轮,改一个文件是一轮,跑一条命令又是一轮。工具少的时候没问题;工具一多,模型会在“说要调什么”上花掉很多轮。
这一集看另一种做法:模型不直接逐个调工具,而是写一段 JavaScript。脚本里想调多少次就调多少次,循环、并发、解析输出都在脚本里完成;最后只把脚本的结果交回模型。
这就是 Code Mode。
图 11-1 · 模型只发一次 codemode,脚本在里面调多次工具,只有输出回到模型
它解决什么问题
Section titled “它解决什么问题”假设模型要比较两个文件,再跑一次测试:
读 a.txt读 b.txt跑 diff跑测试普通工具调用是四次模型往返。每次往返,模型都要重新看一遍上下文,再说下一次要调什么。Code Mode 把这一步压成一次:
const [a, b] = await Promise.all([ tools.read({ path: "a.txt" }), tools.read({ path: "b.txt" }),]);const diff = await tools.bash({ command: "diff -u a.txt b.txt" });const test = await tools.bash({ command: "npm test" });text(diff.output);console.log(test.output);return { same: a === b, exitCode: test.exitCode };模型只发一次 codemode 调用,脚本在里面调四次工具。对模型来说,最后只看到脚本的输出;中间那四次工具调用,是 Durable 在下面替它跑的。
Code Mode 是一个扩展,入口在 @earendil-works/pi-durable/codemode:
源码对照 · packages/durable/src/codemode/codemode.ts:255
export function createCodemodeExtension(options: CodemodeToolOptions): Extension { return defineExtension({ name: "codemode", tools: [createCodemodeTool(options), ...(options.models === false ? [] : createModelTools())], sections: [ section("codemode", (input) => codemodeSection(/* … */)), ], });}工具的输入只有一个 code 字符串(codemode.ts:213);它的 callers 只有 ["model"],所以脚本不能再启动另一个脚本;它的 replay 是 "safe",所以崩溃后会重放(codemode.ts:246)。
它需要一个沙盒。Node 上用 CodemodeSandbox,Cloudflare 上用 RemoteCodemodeSandbox:
import { CodemodeSandbox } from "@earendil-works/pi-codemode";import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";
const codemode = createCodemodeExtension({ sandbox: new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 }),});registry.install(codemode);如果只想把 codemode 这个工具给模型,其他工具留给脚本调,可以只把 codemode 放进 modelTools:
await root.configure({ modelTools: [codemode] }, context);这样模型看到的是“一个能跑脚本的工具”,脚本里再通过 tools.<name> 去用读、写、bash 和 MCP 工具。
沙盒里有什么
Section titled “沙盒里有什么”Code Mode 的脚本跑在 QuickJS 沙盒里,不是 Node。它没有 require、没有文件系统、没有网络、没有定时器,也不能直接读环境变量。
脚本能用的东西不多:
tools.<name>(args):调这个对话里的工具;text()、image()、console.log()、return:产出结果;store()/load():按对话保存 JSON 值;searchTools()、describeTool()、describeNamespace():找没被列出来的工具;models.*:模型目录、分类和图片生成(默认开启)。
源码对照 · packages/durable/src/codemode/describe.ts:29
const DESCRIPTION_INTRO = `Run JavaScript that calls other tools. The input is raw JavaScript (not JSON, no code fence), run as an async function body in a QuickJS sandbox: top-level \`await\` and \`return\` work. No Node, file system, network, or timers.- \`await tools.<name>({ ...args })\` resolves to a string, or an object if the tool's declaration says so, and rejects with an Error on failure. A tool whose declaration returns an object resolves on failure too when it returned that object; check its fields. Calls still running when the script ends are cancelled.- The script survives a restart: it resumes where it was, without running finished calls again.- Optional first line: \`// @options: {"max_output_tokens": 10000, "timeout_ms": 60000}\``;这就是沙盒的边界:脚本能编排工具,但不能绕过工具去碰机器。它要读文件,仍然走 tools.read;要跑命令,仍然走 tools.bash。工具背后可以是本地 ExecutionEnv,也可以是第十集讲的远程环境。
图 11-2 · 脚本没有 Node、文件系统、网络和定时器;工具是唯一的出口
每次 tools.* 都是一次嵌套调用
Section titled “每次 tools.* 都是一次嵌套调用”脚本里的 tools.read(...) 不是普通函数调用。它会在 Durable 里创建一个嵌套工具任务,挂在 codemode 这个父任务下面。
每个嵌套调用都有自己的任务记录、checkpoint、replay 策略和结果条目。脚本只拿到结果,不直接碰记录本。这样做的结果就是:
- 嵌套调用失败,脚本里会收到一个错误;
- 工具声明了
structuredOutputSchema,就返回结构化值;没声明,就返回文本; bash这种声明了 schema 的工具,非零退出也会 resolve 成{ output, exitCode, ... },脚本要自己检查exitCode;- 脚本结束时还没完成的调用,会被取消;
- 只有脚本最后的输出,才会作为
codemode工具的结果交给模型。
源码对照 · packages/durable/docs/spec.md:4376
The script calls the agent's `callable` tools as `tools.<name>(args)`; each call is a nested call with key`c<id>`, the VM's own number for the call. As in the coding agent, a tool thatdeclares `structuredOutputSchema` resolves with its `structuredOutput`, also on anerror result that carries one (`bash` with a nonzero exit resolves with`{ output, exitCode, ... }`, which the script checks), and a schema-less toolresolves with its text output, images left out, as its declaration(`Promise<string>`) says. Every other error rejects...嵌套调用在 UI 上也能看到。脚本运行期间,pi.live.nestedTools 里会出现它的调用行;结束后,codemode 结果的 details.calls 里会列出这次脚本调了哪些工具、状态是 ok、error 还是 cancelled。
图 11-3 · codemode 父任务下面挂着每个 tools.* 的嵌套工具任务
提示词里列哪些工具
Section titled “提示词里列哪些工具”模型写脚本之前,得知道有哪些工具可用。Code Mode 会加一个 codemode 系统提示词段,把可调用工具按 TypeScript 声明列出来,默认预算 3000 个估算 token:
源码对照 · packages/durable/src/codemode/describe.ts:23
export const DEFAULT_CODEMODE_INLINE_BUDGET = 3000;工具太多时,列不下的部分不会硬塞。脚本可以用 searchTools(query, { limit?, namespace? }) 搜,用 describeTool(name) 看单个工具,用 describeNamespace(name) 看一组工具的说明。
工具还可以按名字前缀分组。比如一个 MCP server 的工具都叫 mcp__github__...,可以这样声明:
createCodemodeExtension({ sandbox, namespaces: [ { name: "github", prefix: "mcp__github__", description: "GitHub tools" }, ],});源码对照 · packages/durable/src/codemode/describe.ts:14
export type CodemodeNamespace = { readonly name: string; readonly description?: string; readonly instructions?: string; readonly prefix: string;};命名空间的作用是让脚本和模型先看到“有哪些组”,而不是一上来就面对几百个工具名。
图 11-4 · codemode 段在预算内列工具,列不下的用搜索和 describe 找
结果长什么样
Section titled “结果长什么样”脚本不是只返回一个字符串。它可以有多个输出项、console 输出、图片和返回值。Code Mode 会把它们排成模型能读的格式:
- 开头是
Script completed或Script failed,带执行时间; - 多个文本项用
==> text N/M <==分开; console行集中在一个<console_output>块里;- 失败时带上错误,以及已经调用过的工具;
- 超过
max_output_tokens(默认 10000)的文本保留头尾,完整文本写到临时文件; - 图片保存成文件,结果里给出路径。
源码对照 · packages/durable/src/codemode/output.ts:44
function formatItems(output: readonly CodemodeOutputItem[]): Item[] { const total = output.filter((item) => item.type === "text" && !item.console).length; const items: Item[] = []; const consoleLines: string[] = []; let index = 0; for (const item of output) { if (item.type === "image") items.push(item); else if (item.console) consoleLines.push(item.text); else { index++; items.push({ type: "text", text: total > 1 ? `==> text ${index}/${total} <==\n${item.text}` : item.text }); } } // …}这也是 Code Mode 和普通工具调用的区别:普通工具的结果通常就是一段文本;Code Mode 的结果是一个“脚本执行报告”,模型能看出脚本做了什么、哪一步失败了。
图 11-5 · 结果带 header、文本项、console、截断文件和每个嵌套调用的状态
脚本为什么能扛住重启
Section titled “脚本为什么能扛住重启”这是 Code Mode 最关键的一部分。
脚本是内存里的 VM 状态。如果进程在 await Promise.all(...) 等 200 个读文件时挂了,普通做法是脚本丢了,前面读过的也白读。Code Mode 的办法是:把脚本变成可重放的。
沙盒运行脚本时是确定性的:
- 虚拟时钟;
- 带种子的随机数;
- UTC;
- 同样的启动数据、同样的结果顺序,就会做出同样的调用。
每次脚本拿到一个工具结果之前,Code Mode 先把这条结果写进 pi.codemode.journal。重启后,它重新启动脚本,把 journal 里的结果按原顺序喂回去。脚本会再次发出同样的 tools.* 调用,这些调用按 key c<VM call id> 重新挂到原来的嵌套任务上,不会重新执行;然后脚本从断点继续。
源码对照 · packages/durable/src/codemode/codemode.ts:125
export const CodemodeRunDoc = defineDoc<RunState>({ kind: "pi.codemode.run", version: 1, scope: "task", initial: () => ({}), checkpointWhen: () => false,});源码对照 · packages/durable/src/codemode/codemode.ts:151
export const CodemodeJournalDoc = defineDoc<JournalState>({ kind: "pi.codemode.journal", version: 1, scope: "task", initial: () => ({ entries: [] }), checkpointWhen: () => false,});pi.codemode.run 存启动数据:模块版本、开始时间、种子、VM 指纹、deadline、脚本看到的工具和全局、store 快照。pi.codemode.journal 存每次交付给 VM 的结果,以及脚本在处理这个结果后已经发出了哪些调用。pi.codemode.store 存 store() / load() 的值,是对话级的,fork 时按 fork 点复制。
重放不是无条件的。遇到下面这些情况,Code Mode 不会假装成功,而是让这次调用以错误结束:
- 脚本这次走了另一条路径,调用顺序和 journal 对不上;
- 沙盒指纹变了;
- Code Mode 的模块版本变了;
timeout_ms已经过了。
沙盒在脚本运行时被关掉(比如进程退出)不算结果;下一次进程会用新的沙盒重新跑脚本。
图 11-6 · 结果先写 journal,再交给 VM;重启后按原顺序重放,嵌套调用按 key 重新挂上
store、load 和模型
Section titled “store、load 和模型”脚本还可以在对话里留一点小状态:
const count = load("count") ?? 0;store("count", count + 1);return count;这些值按对话保存,fork 出来的对话看到的是 fork 点的快照。它们适合放“脚本上次跑到哪”“用户偏好”这类小 JSON,不适合放大量数据。
models.* 默认也会给脚本。models.classify() 和 models.generateImages() 不是普通本地函数,而是扩展内部的嵌套工具 pi.codemode.models.classify 和 pi.codemode.models.generateImages。它们各自有任务、用量和 replay 策略,一个脚本最多同时跑四个。models: false 可以关掉这组能力。
什么时候值得用
Section titled “什么时候值得用”Code Mode 适合:
- 工具很多,模型需要先搜索、再组合;
- 一次任务要循环、并发或解析大量输出;
- 想让模型写一段可复用的编排逻辑,而不是一轮一轮地“说下一步”;
- 需要脚本在崩溃后接着跑,而不是从头再来。
不适合:
- 只有一两个简单工具调用;
- 需要 Node、文件系统、网络或定时器;
- 脚本很短、模型直接调工具更清楚;
- 需要严格隔离不可信代码,但又不准备沙盒资源。
Code Mode 的沙盒是 QuickJS,不是完整的 OS 沙盒。它把脚本限制在“只能通过工具接触外界”的范围内;工具侧的隔离仍然来自你给工具配置的 ExecutionEnv。如果工具跑在本地,QuickJS 只是脚本沙盒,不是 OS 沙盒;需要更强隔离,就把工具接到第十集的远程机器或容器。
这一集记住三句话:
- Code Mode 让模型写脚本,不逐个调工具。 脚本里可以循环、并发、解析,最后只把结果交给模型。
- 脚本里的每次
tools.*都是嵌套工具任务。 它们有自己的 checkpoint、replay 和结果,只有脚本输出回到模型。 - 脚本能扛重启,是因为 journal 和确定性重放。 结果先写 journal,再交给 VM;重启后按原顺序喂回去,嵌套调用按 key 重新挂上。
前几集讲的是 Durable 怎么把一次运行记下来,这一集讲的是:模型可以把“怎么调工具”写成程序,而 Durable 负责让这段程序在崩溃后还能接着跑。