什么是 Codemode
Codemode 将工具使用转化为模型编写并运行的代码,而非一系列结构化的函数调用。Armin Ronacher 解释了这给工具设计带来的变化、规划与执行之间的分工,以及各部分如何被编排。
中文
复制
写于 2026 年 10 月 6 日
一年多前,我在这里写过几篇文章,劝大家不要把自定义工具(或 MCP 服务器)加载进上下文,直接用更多脚本就好。其中最关键的一篇是 Code Is All You Need,另外我还写过 MCP needs code。到了 Pi 1.0,我们通过 Codemode 加上了 MCP 支持——某种程度上这是迟早的事,但对一些人来说可能又有点意外。所以我想在这篇博客里聊聊这件事意味着什么,顺便更新一下我的想法。
什么是工具
当 Pi 这样的 harness 为 LLM 提供可调用的工具时,它给出的是一组工具定义,这些定义在服务端会转换成某种 token 结构。模型是否被鼓励去调用某个工具,是强化学习过程的结果。想深入了解的话,我之前写过这个话题。
我们之所以强烈偏向 CLI 和 bash,原因之一是它便于组合调用,另一个原因是模型在训练时也学会了文件系统是怎么运作的。所以当它调用 echo foo > /tmp/test.txt 这样的工具时,模型同时也知道,这次调用之后 /tmp 里就多了一个叫 test.txt 的文件。
但 bash 有一个根本性的限制:它只能组合能运行的程序。而有些东西并不是程序,却是 LLM 的原生工具,而且它们只能如此。
最明显的例子是 read 或 view_image。多模态模型要读一张图片,就不能用 cat,因为 harness 必须把真正的图像数据注入到 LLM 的协议里。
另一个很直观的例子是子 agent。要创建并编排子 agent,就很难绕开由 harness 提供的工具。理论上 agent 可以提供一个 CLI 工具,通过环境变量和 Unix socket 与外层 harness 通信,但这个过程相当粗糙。而且它还有另一个问题:代码在哪里运行。
大脑与手
要理解这一点,得先想清楚各个部件分别跑在哪里。通常涉及两个不同的系统。第一个是大脑,也就是 harness:它跑在一台机器上,是可信的。第二个往往是同一台机器,但它才是工具真正执行的地方:手。在 Pi 里我们把它叫做执行环境,但你可以把它理解为所有操作的目标。
关键的是,harness 大脑与运行 bash、执行工具的目标环境之间存在一条分界线。
把它一分为二会带来一些相当重要的后果。首先,这意味着它们运行在不同的文件系统上,信任级别也不同。比如,如果你使用像 Gondolin 这样的沙箱方案,你的 bash 部分会被妥善沙箱化,但 harness 本身不会。
编排 Harness
这就引出了 Codemode 真正做的事:它让 LLM 能够在 harness 一侧表达和编排复杂操作,而不是在执行环境一侧。Codemode 运行在 harness 中,有自己的沙箱。在 Pi 的情况下,它跑在 WASM 运行时里的 QuickJS 中,并且带有刻意设置的种种限制:没有网络,没有文件系统,没有定时器,内存有限。唯一的办法就是调用更多工具。你也可以设想 Codemode 运行 Scheme 或其他语言。
如果你不熟悉 Codemode,它基本上就是一种在某种语言内部发起工具调用的方式,在我们的场景里是 JavaScript。这样你就能组合这些调用,而不必事事都经过 LLM 的上下文。这个名字要归功于 Cloudflare 的朋友们,是他们造出来的。
比如,如果你在 LLM 里把 bash 调用当作普通工具调用来发起,那我们只会把末尾 2000 行丢进上下文,agent 想要更多就得自己去看溢出文件。但如果 agent 通过 Codemode 发起这次调用,Codemode 一侧就会以结构化方式收到更大的输出。
最重要的是,因为 Codemode 是 JavaScript,agent 可以表达并发操作和基本工作流。现在常见的一种用法是:先从某个工具的响应里探测 5 到 10 个条目,看看它长什么样,然后写一个 Codemode 脚本来处理接下来的 n 个条目。
Codemode 还允许你把状态丢进 transcript!也就是说,一次 Codemode 调用可以存下数据,会话中的下一次调用再把它加载回来。记住:这是在 harness 主机上,不是沙箱里。
在 Pi 里,Codemode 还允许你发起一些在 Pi 传统界面里天然没有意义的调用。比如你想用图像模型生成图片,或者用一次性分类模型给文本分类,这些 Pi API 通过 Codemode 暴露出来,但不会做成常规工具——做成工具只会白白占用上下文。
它长什么样
说了这么多,还是具体一点比较好。下面用我最近几次 Pi 会话中调用 Codemode 的实际例子走一遍。注意这些代码没有一行是人写的,全部来自真实的 Pi 会话,只是为了方便阅读重新缩进过。Agent 会自动开始使用 Codemode,要么是因为这类任务模型本来就会自然选中这个工具,要么是因为用户要求它这么做。
注意 Codemode 在 Pi 中默认只在启用 MCP 时才开启,但你可以在设置里用 "defaultTools": ["+codemode"] 打开。直接让 Pi 帮你启用就行。
生成图片
先从简单的图片生成开始。图片生成是 Pi 在 AI SDK 核心层支持的功能,但它不是 agent 能调用的工具。过去想用图像模型,只能写一个专门的扩展,或者让 agent 自己跑 node 去调用内部图像 API。而由于我们在 Codemode 里暴露了不少内部模型 API,agent 现在可以直接用:
const [painter] = await models.getAvailableOfType("image");
const result = await models.generateImages(painter, {
input: [{ type: "text", text: "A cute little puppy sitting on a grassy " +
"lawn, soft natural light, photorealistic" }],
});
if (result.stopReason !== "stop") return result.errorMessage;
for (const block of result.output) {
if (block.type === "image") image(block);
else text(block.text);
}
注意对 image() 的调用会把图片作为 image content 发回给 LLM。在 harness 这一侧,它既直接喂给 agent,也会作为临时产物写到磁盘上,以便 agent 之后能把这张图片再传给 bash。
分类
分类模型也是类似的情况,比如 Jev。它们同样不适合通过常规工具接入 agent 的工作流。但 Codemode 的做法不是做一个专门的工具,而是让 agent 直接伸手进 AI SDK 调用它们。下面这段是 Jev 被用来批量处理 GitHub issue、做一次快速情感分析的例子:
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const r = await tools.bash({
command: "gh issue list --state open --limit 100 " +
"--json number,title,body,comments",
});
const issues = JSON.parse(r.output);
const results = await Promise.all(issues.map(async (issue) => {
const res = await models.classify(jev, {
state: {
title: issue.title,
body: (issue.body || "").slice(0, 4000),
comments: issue.comments.slice(-5).map(c => c.body.slice(0, 800)),
},
questions: {
sentiment: {
type: "choice",
instructions: "What is the overall sentiment of the author towards pi?",
criteria: {
positive: "Appreciative, happy, constructive praise",
neutral: "Matter-of-fact report or request without emotion",
negative: "Frustrated, annoyed, upset, or angry",
},
},
frustration: {
type: "score",
instructions: "How frustrated is the reporter?",
criteria: ["not at all", "mildly", "clearly frustrated", "very angry"],
},
kind: {
type: "choice",
instructions: "What kind of issue is this?",
criteria: {
bug: "Bug report or regression",
feature: "Feature request or enhancement",
question: "Question or support request",
other: "Docs, discussion, meta, spam",
},
},
},
});
if (res.stopReason !== "stop") {
return { n: issue.number, title: issue.title, error: res.errorMessage };
}
return { n: issue.number, title: issue.title, ...res.answers };
}));
store("sentiment_results", results);
return results
.filter(r => !r.error)
.sort((a, b) => b.frustration.score - a.frustration.score)
.slice(0, 12)
.map(r => `#${r.n} ${r.frustration.score.toFixed(2)} [${r.kind.choice}] ${r.title}`);
注意,在上面的示例中我们还调用了 store(),它会把这次执行的结果写入会话记录。之后再次调用 Codemode 时,如果需要,就能把这个结果读回来。
这里的 Promise.all 没问题,因为 Pi 自己把并发工具执行的总数限制在四个,其余的排队等待。
一个更大胆的例子是用 Jev 驱动游戏引擎来调试:
用 Jev 做游戏调试的 Codemode
它知道我的 tankctl 命令,于是很快围绕它搭了一个最小化的 harness,驱动游戏循环,帮用户排查问题。注意它构建了一个 30 步的循环,每一步都会回到游戏引擎取一份当前状态的文本 dump,然后再回到 Jev 决定下一步做什么:
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const tank = async (cmd) =>
(await tools.bash({ command: `tools/tankctl "${cmd}"` })).output;
await tank("start --map assets/maps/night_arena.map");
const questions = {
action: {
type: "choice",
instructions: "You control the tank '@' in a top-down tank game. " +
"Choose the best next action.",
criteria: {
attack: "an enemy has line of sight to you and you can fire at it",
approach: "no enemy has line of sight; drive toward the nearest enemy",
dodge: "an enemy shot is heading at you and will hit soon",
powerup: "a powerup is close and no enemy threatens you",
},
},
};
function commandFor(choice, st) {
const p = st.player;
const enemy = st.enemies.filter(e => !e.dead)
.sort((a, b) => (b.los - a.los) || (a.dist - b.dist))[0];
if (choice === "attack" && enemy) {
return `fire_at tank ${enemy.id}; frames 30 until clear,damage,kill`;
}
if (choice === "dodge") {
// move perpendicular to the closest incoming shot
const s = st.projectiles.filter(s => !s.yours)
.sort((a, b) => a.eta - b.eta)[0];
const dir = s && Math.abs(s.vel[0]) > Math.abs(s.vel[1])
? (p.pos[1] > s.pos[1] ? "+down" : "+up")
: (p.pos[0] > (s ? s.pos[0] : 0) ? "+right" : "+left");
return `input ${dir}; frames 20 until damage; input stop`;
}
const powerup = st.powerups.filter(u => u.available)
.sort((a, b) => a.dist - b.dist)[0];
if (choice === "powerup" && powerup) {
return `goto ${powerup.pos[0]} ${powerup.pos[1]} 180`;
}
return enemy ? `goto ${enemy.pos[0]} ${enemy.pos[1]} 90` : null;
}
const log = [];
for (let step = 0; step < 30; step++) {
const st = JSON.parse(await tank("state"));
if (st.state !== "playing") break;
const threats = st.projectiles
.filter(s => !s.yours && s.miss_dist < 1.5 && s.eta < 1.5)
.map(s => `incoming shot dist ${s.dist} eta ${s.eta}s`)
.join("\n") || "no incoming shots";
const r = await models.classify(jev, {
state: { map: await tank("view 8"), threats, hp: st.player.hp },
questions,
});
if (r.stopReason !== "stop") {
log.push(`#${step} classifier error: ${r.errorMessage}`);
break;
}
const choice = r.answers.action.choice;
const cmd = commandFor(choice, st);
if (!cmd) break;
log.push(`#${step} hp=${st.player.hp} ${choice} -> ${await tank(cmd)}`);
}
return log.join("\n");
调用 MCP Server
最后,Codemode 显然非常适合调用 MCP server。由于我们实际上并没有把任何 MCP 工具暴露给 LLM,agent 会先用提供的 API 在 Codemode 里发起一次工具搜索,看看用已连接的 server 能做些什么。这种渐进式发现让整套 MCP 机制在今天的很多场景下都足够好用。
比如这里,agent 直接就去找 Sentry MCP,甚至没有先发现工具,大概是因为它在 RL 过程中已经知道 Sentry MCP 长什么样了。但它知道 Sentry server 可用,是从我们注入系统提示词的内容里学到的。它并不是完全在猜。
const orgs = await tools.mcp__sentry__find_organizations({});
const { organizations } = orgs.structuredContent;
const results = await Promise.allSettled(organizations.map(org =>
tools.mcp__sentry__find_projects({
organizationSlug: org.slug,
regionUrl: org.regionUrl,
})
));
return organizations.map((org, i) => {
const r = results[i];
if (r.status !== "fulfilled") return { org: org.slug, error: String(r.reason) };
if (r.value.isError) return { org: org.slug, error: r.value.content };
return {
org: org.slug,
projects: r.value.structuredContent.projects.map(p => p.slug),
};
});
现代 MCP 是一场斗争
我其实不太想在这里多谈 MCP,但 MCP 确实是一个能从 Codemode 中大大受益的协议。问题部分在于,实践中的 MCP 往往面向那些(还?)没有用上 Codemode 的 harness。但风向正在变。在此期间,一个临时的拐杖就是 Cloudflare 的做法:在 MCP server 内部实现 Codemode。可这样一来就变成了 Codemode 套 Codemode,相当糟糕。这意味着双重 JSON 转义,小模型很容易被绕晕,而且内层代码没法调用外层的工具。所以如果你在 Pi 里用 Cloudflare 的 MCP server,agent 就得写 JavaScript,再把这 JavaScript 塞进更多 JavaScript 里。这实在不理想,但也能理解为什么会变成这样:
const accRes = await tools.mcp__cloudflare__execute({
code: `async () => {
const r = await cloudflare.request({ method: "GET", path: "/accounts" });
return r.result.map(a => ({ id: a.id, name: a.name }));
}`,
});
const accounts = JSON.parse(accRes.content.map(c => c.text).join(""));
const out = [];
for (const account of accounts) {
const r = await tools.mcp__cloudflare__execute({
account_id: account.id,
code: `async () => {
const r = await cloudflare.request({
method: "GET",
path: \`/accounts/\${accountId}/workers/scripts\`,
});
return r.result.map(s => ({ id: s.id, modified: s.modified_on }));
}`,
});
out.push({ account: account.name, workers: r.content.map(c => c.text).join("") });
}
return out;
MCP 的诉求
最后收个尾:Codemode 现在跟 MCP 配合得怎么样?嗯……不算特别好。因为 MCP 服务器还没有真正面向使用 Codemode 的 harness(不过我觉得现在大多数 harness 都已经支持了)。
要想配合得好,有几点建议:
- 结构化内容: Codemode 希望调用返回格式规整的 JSON。这需要服务器端返回,而很多服务器目前还做不到。MCP 里的
outputSchema机制很适合干这个。 - 结果一致性: 一个挺有意思的失败案例是 MCP 服务器返回的数据不一致。比如它会根据结果集里有多少条数据来做 token 优化。这会导致一开始探测 5 条能成功,但服务器返回最大批量时反而失败。
- 大型二进制数据: MCP 目前还不支持大型二进制数据,所以不少本来很有意思的用例根本跑不起来。你只能搞出各种奇怪的绕路方案,比如用预签名 URL 让文件上传走非 MCP 通道。
- 可组合的工具搜索: 对于某个任务该用哪个工具,MCP 服务器可能比 MCP 客户端更清楚。但目前没有好的机制让 harness 把工具搜索分发到多个 MCP 服务器上。全靠自发行为,服务器一多就不好使了。
Codemode 的未来
那这把我们带到哪儿了?这是否推翻了我一年前鼓励用 CLI 的那篇文章?我不这么认为。事实上,在我看来,MCP 生态恰好印证了我们一年前指出的可行方向:代码。但 Codemode 不止于 MCP,它能在 harness 内部充当一个有力的机制,给 agent 表达更多自由。
不过还有些问题需要解决。其一,Codemode 的持久化更麻烦。我们可能得借鉴一些持久化工作流引擎的思路来给调用做快照。又或者,考虑到 Starlark 的确定性,它可能比 JavaScript 更适合做组合语言。
图片、二进制数据,以及这种模式无法在更小的模型上跑通——这些也都还需要进一步梳理。所以它肯定还不是一个完美的方案,但确实是个相当有用的模式,我预计我们会用得更多。
© Copyright 2026 by Armin Ronacher.
内容基于 Creative Commons Attribution-NonCommercial 4.0 International License 授权。
通过 mail、bluesky、x 或 github 联系我。
你可以在 github 上赞助我。
更多信息:imprint 和 AI transparency。通过 atom / RSS 订阅。
配色方案:auto、light、dark。