AI SDK Code Mode 使用指南:让模型在 QuickJS 沙箱中编写代码调用工具
@ai-sdk/code-mode 是 AI SDK(Vercel AI SDK)生态中的一个实验性工具包:它允许模型直接编写 JavaScript/TypeScript 代码来编排你提供的 AI SDK 工具,代码在隔离的 QuickJS 沙箱中运行,并返回一个 JSON 可序列化的结果。本文将以 packages/code-mode/README.md 为核心,结合包内源码(src/run-code-mode.ts、src/code-mode-tool.ts、src/types.ts 等)与测试用例(src/core.test.ts),从安装、基础用法、执行策略、审批、中断续接到安全模型,完整讲解 code mode 的实战姿势与底层原理。
Code Mode 是什么:何时该用它
在常规的 AI SDK 工具调用流程里,模型每轮只输出一次"调用哪个工具、传什么参数"的结构化请求,工具调用的编排逻辑(先调哪个、结果如何变换、是否并行)完全由宿主代码负责。当模型需要连续调用多个工具、对工具结果做中间变换、或者希望并发执行相互独立的调用时,这种"一问一答"的模式就会变得低效且啰嗦。
Code mode 把编排能力直接交给模型:模型产出一段 JavaScript/TypeScript 源码,由沙箱执行。与"模型自由执行任意代码"的 Agent 方案不同,code mode 有两条硬性约束:
- 只暴露你提供的工具:沙箱内的全局
tools对象只包含你传给 code mode 的那几个工具,模型写不出工具列表之外的能力; - 结果必须是 JSON 可序列化值:沙箱执行完后,返回值会经过 JSON 序列化与反序列化再交还给宿主。
因此 code mode 适合"需要调用多个工具、变换它们的返回结果、或并发运行它们"的场景。例如先并行拉取库存与需求数据,再在沙箱内比较、计算并返回结论——这正是 README 中库存/需求对比示例的用途。
安装与环境要求
pnpm add ai @ai-sdk/code-mode
@ai-sdk/code-mode 是服务端包,要求 Node.js 22.13 或更高版本(package.json 中 engines 字段明确声明 "node": ">=22.13.0")。它的运行时依赖只有一个 run 包(版本 ^2.0.0),peer 依赖为同工作区的 ai。包内关键词(keywords)包括 ai-sdk、agents、code-mode、quickjs、sandbox、tools,其自述为 "QuickJS-backed code mode tool for AI SDK",即底层沙箱正是 QuickJS。
注意:所有 code mode API 目前均为实验性命名(带有
experimental_前缀),API 形态可能在后续版本中调整。
快速开始:在 generateText 中使用 code mode
下面是从 README 完整继承的官方示例,稍作拆解以便看清每个部件的作用。
import {
DIRECT_TOOL_CALL,
experimental_codeModeTool as codeModeTool,
} from '@ai-sdk/code-mode';
import { generateText, isStepCount, tool } from 'ai';
import { z } from 'zod';
// 宿主工具 1:查询库存
const getInventory = tool({
description: 'Get available inventory for a product.',
inputSchema: z.object({ productId: z.string() }),
outputSchema: z.object({
productId: z.string(),
availableUnits: z.number(),
}),
execute: async ({ productId }) => ({
productId,
availableUnits: 42,
}),
});
// 宿主工具 2:查询需求
const getDemand = tool({
description: 'Get requested units for a product.',
inputSchema: z.object({ productId: z.string() }),
outputSchema: z.object({
productId: z.string(),
requestedUnits: z.number(),
}),
execute: async ({ productId }) => ({
productId,
requestedUnits: 31,
}),
});
const tools = {
code_mode: codeModeTool({
executionPolicy: {
timeoutMs: 30_000,
},
}),
getInventory,
getDemand,
} as const;
const result = await generateText({
model,
tools,
experimental_toolCallers: {
getInventory: ['code_mode', DIRECT_TOOL_CALL],
getDemand: ['code_mode'],
},
stopWhen: isStepCount(10),
prompt: 'Compare inventory and demand for product sku_123.',
});
示例中的几个关键机制:
codeModeTool(即experimental_codeModeTool):创建的是一个"工具调用者"(tool caller),它本身不直接持有宿主工具列表,而是由外层generateText调用中的experimental_toolCallers配置动态绑定。从源码看,src/code-mode-tool.ts 中它基于experimental_toolCaller实现,bind回调在运行时把tools绑定进createCodeModeTool(tools, options)。experimental_toolCallers映射:它声明了每个宿主工具可以被谁调用。getInventory: <a href="https://link.gitcode.com/i/01dd0f2e615494385ff3984c3ece9813" target="_blank">'code_mode', DIRECT_TOOL_CALL]表示模型既可以通过 code mode 写代码调用getInventory,也可以直接调用getInventory;getDemand: ['code_mode']则表示getDemand只能经由 code mode 间接调用。DIRECT_TOOL_CALL是常量字符串'AI_SDK_DIRECT_TOOL_CALL'(见 [src/direct-tool-call.ts),之所以用字符串而非 Symbol,是为了保证可序列化。stopWhen: isStepCount(10):限制最多 10 步循环,防止模型反复生成代码导致无限循环。
模型随后可能生成如下代码并交给沙箱执行(README 原例):
const [inventory, demand] = await Promise.all([
tools.getInventory({ productId: 'sku_123' }),
tools.getDemand({ productId: 'sku_123' }),
]);
return {
sufficient: inventory.availableUnits >= demand.requestedUnits,
remaining: inventory.availableUnits - demand.requestedUnits,
};
注意沙箱内工具的调用方式是异步的 tools.name(input),必须 await;对相互独立的调用建议用 Promise.all 并发执行。这段代码最终会返回 { sufficient, remaining } 这样的 JSON 可序列化对象。
直接执行:experimental_runCodeMode
如果不想经过模型生成环节,只想在沙箱里直接跑一段代码并调用宿主工具,可以使用 experimental_runCodeMode(README 原例):
import { experimental_runCodeMode as runCodeMode } from '@ai-sdk/code-mode';
const result = await runCodeMode({
js: 'return await tools.getInventory({ productId: "sku_123" });',
tools: { getInventory },
});
从源码看,src/run-code-mode.ts 是 code mode 的核心执行函数,codeModeTool 的 execute 最终也会走到这里。它接收四类输入(定义见 src/types.ts 的 RunCodeModeInput):
| 字段 | 说明 |
|---|---|
js |
要执行的 JavaScript 或 TypeScript 源码 |
tools |
暴露给沙箱的宿主工具集合(CodeModeToolSet) |
toolExecutionOptions |
透传给嵌套工具调用的 AI SDK 执行元数据(toolCallId、messages、abortSignal、context 等) |
options |
code mode 选项(executionPolicy、continuationSecurity、approval) |
runCodeMode 的执行路径大致是:解析执行策略 → 校验源码大小 → 准备续接状态(continuation)→ 把用户源码包装进沙箱运行环境 → 创建宿主桥接函数 → 交给 run 包的 runner 执行 → 处理结果或中断。
沙箱内的源码包装
用户源码并不是原样执行的。createCodeModeSource(src/run-code-mode.ts)会把源码包装成如下形态:
const __codeModeBindings={...}; // 工具名 → 宿主桥接函数引用
const tools=new Proxy(Object.create(null),{...}); // 沙箱内全局 tools 代理
const __codeModeResult=await(async()=>{
<用户源码>
})();
if(__codeModeResult===undefined)return undefined;
return JSON.parse(JSON.stringify(__codeModeResult));
由此可以得到几个可验证的沙箱行为:
- 支持顶层
await和顶层return:源码被包进异步 IIFE; - 结果强制 JSON 序列化:返回值会经
JSON.parse(JSON.stringify(...))往返,非 JSON 可序列化内容(如函数、循环引用)会在边界被规整或报错; tools是一个 Proxy:对不存在的工具名会走__codeMode.missing路径,从而在宿主侧抛出"Unknown tool"错误,而不是静默返回 undefined;- 每次调用都是全新的全局作用域:测试 src/core.test.ts 验证了在一个调用里写入
globalThis.sharedValue,下一次调用读不到它。
TypeScript 支持:类型擦除
沙箱支持"type-stripped TypeScript"——即剥掉类型标注后执行。测试 src/core.test.ts 覆盖了三种情况:简单的 const value: number = 7 注解、interface Item {...} 声明、以及 satisfies 语法。因此模型可以放心写出带类型标注的代码,不必担心语法错误。
沙箱能力边界
自动生成的工具描述(详见下文"自动生成的工具描述"一节)会明确告诉模型:JSON.parse / JSON.stringify 可用,fetch 不可用。也就是说沙箱内没有网络能力,一切外部副作用都必须通过宿主工具完成——这正是隔离设计的关键:模型写的代码没有直接执行副作用的能力,任何对外操作都要经过你提供的、可控的宿主工具。
执行策略(executionPolicy):精细控制沙箱资源
codeModeTool / createCodeModeTool / runCodeMode 都接受 options.executionPolicy。CodeModeExecutionPolicy 的完整字段、默认值与含义如下(默认值定义见 src/types.ts 的 JSDoc,实际解析在 src/run-code-mode.ts 的 resolveExecutionPolicy):
| 字段 | 默认值 | 含义 |
|---|---|---|
timeoutMs |
30_000 |
单次沙箱调用的超时(毫秒),超时抛 CodeModeTimeoutError |
memoryLimitBytes |
64 * 1024 * 1024(64 MB) |
沙箱内存上限 |
maxStackSizeBytes |
2 * 1024 * 1024(2 MB) |
栈大小上限 |
maxResultBytes |
1024 * 1024(1 MB) |
返回值序列化后的最大字节数 |
maxConsoleOutputBytes |
64 * 1024(64 KB) |
控制台输出(console)累计字节上限 |
maxSourceBytes |
256 * 1024(256 KB) |
用户源码字节上限,超限抛 CodeModeSourceTooLargeError |
maxToolInputBytes |
1024 * 1024(1 MB) |
单个工具输入参数序列化字节上限 |
maxToolOutputBytes |
4 * 1024 * 1024(4 MB) |
单个工具返回结果序列化字节上限 |
maxBridgeRequests |
256 |
单次沙箱调用可发起的宿主桥接请求总数上限 |
maxInFlightBridgeRequests |
32 |
同时在途(in-flight)的宿主桥接请求数上限,防止并发失控 |
例如 README 示例里只配置了超时:
codeModeTool({
executionPolicy: {
timeoutMs: 30_000,
},
})
值得注意的是这些限制在传给底层 run runner 时会被"放大":toRunLimits(src/run-code-mode.ts)会通过 expandedSerializationLimit 把结果/参数/输出字节限制乘以 2 再加 1024(至少 4096),并考虑包装代码带来的源码体积开销——因为序列化本身也有开销。当底层 runner 因超限抛错时,错误信息中的 limits.* 路径会被翻译回 executionPolicy.* 的命名(见 translateLimitPath),方便你定位是哪个配置项触发的。
自动生成的工具描述:模型写代码的"说明书"
code mode 之所以能让模型"照着写代码",是因为工具描述是根据宿主工具的 schema 自动生成的。createCodeModeTool 在创建时调用 buildCodeModeToolDescription(tools)(src/tool-prompt.ts)生成一段包含以下内容的提示文本:
- 一个
declare const tools: { ... }形式的 TypeScript 类型声明块,把每个宿主工具的输入/输出 schema 翻译成 TS 类型(支持$ref、enum、oneOf/anyOf/allOf、嵌套对象、数组、Record<string, unknown>等,最大递归深度 8 层,见schemaToTypeInner); - 一段工具调用示例:单个工具时给出
const result = await tools.xxx({...}); return ...;;多个工具时给出Promise.all([...])并发调用并聚合成返回对象的模板; - 一系列约束声明:把完整程序放在
js里、顶层await/return可用、只允许tools.name(input)异步调用、JSON.parse/JSON.stringify可用、fetch不可用; - 若没有宿主工具,则明确告知模型"No host tools. Do not call
tools.*."。
从源码推断,示例输入并非随机:它会优先取工具声明的 inputExamples,否则基于 schema 生成样例值(sampleFromSchemaInner,支持 default、examples、format 等)。这意味着给宿主工具写清晰的 description、inputSchema、outputSchema,直接影响模型生成代码的质量——schema 越精确,生成的类型声明和调用示例就越准确。
宿主工具的调用链与工具级防护
沙箱代码调用 tools.xxx(input) 时,会通过宿主桥接函数 invokeHostTool(src/tool-invocation.ts)走一条完整链路:
- 中止检查:外层
abortSignal已中止则立即抛出; - 存在性检查:工具不存在抛
CodeModeToolError('Unknown tool: ...'),无execute抛"does not have execute()"; - 输入校验:按工具
inputSchema校验(validateToolInput),校验失败抛CodeModeToolError,错误详情里带toolName与cause; - 审批检查:见下一节;
- 执行:调用工具
execute,支持同步、Promise 以及 AsyncIterable(流式输出取最后一个值,见executeHostTool);输出序列化后回传沙箱。
调用期间的 toolCallId 形如 ${outerToolCallId}:tool-${requestIndex},与外层 AI SDK 调用形成可追踪的嵌套关系;同时工具的 context / experimental_context、messages 等执行上下文会被透传(CodeModeToolExecutionOptions,见 src/types.ts)。
工具审批:callback 与 interrupt 两种模式
对于敏感工具,AI SDK 工具本身支持 needsApproval。code mode 把这一机制带进了沙箱调用链(requiresApproval 见 src/tool-invocation.ts)。通过 options.approval(src/types.ts)配置审批行为:
codeModeTool({
approval: {
mode: 'callback', // 默认值
onApprovalRequired: async (request) => {
// request: { toolName, input, toolCallId }
return 'approved'; // 或 { approved: false, reason: '...' }
},
},
})
approval.mode 取值与行为:
| 模式 | 行为 |
|---|---|
'callback'(默认) |
调用 onApprovalRequired({ toolName, input, toolCallId }),回调返回 'approved' / 'denied' 或 { approved: boolean; reason?: string }(ApprovalDecision)。未配置回调时抛 CodeModeToolApprovalRequiredError;被拒绝时抛 CodeModeToolApprovalDeniedError(均见 src/errors.ts) |
'interrupt' |
不调用回调,改为返回一个类型为 code-mode-interrupt 的审批中断对象,把决定权交还给宿主流程 |
在 interrupt 模式下,配套的辅助函数可以帮助你把审批嵌入 AI SDK 的消息循环(见 src/approval-continuation.ts):
experimental_isCodeModeApprovalInterrupt:判断一个值是否是审批中断(payloadkind为'ai-sdk-code-mode/tool-approval',见 src/approval.ts);experimental_toCodeModeApprovalMessages:把审批中断转换为ModelMessage[](含tool-call与tool-approval-request两个 part),可追加到对话历史中交给上层模型/UI;experimental_getCodeModeApprovalResponse:从消息中反向提取用户的tool-approval-response(含approvalId、approved、reason);experimental_continueCodeModeApproval:携带审批响应继续执行被中断的沙箱调用;approvalId不匹配会抛CodeModeProtocolError。
中断与续接:沙箱外的人工确认
除了审批中断,宿主工具还可以主动发起任意自定义中断(context.interrupt(payload),payload 需是含 kind 字段的对象,见 src/types.ts 的 CodeModeInterruptPayload)。这时沙箱执行会挂起,返回一个 CodeModeInterrupt(type: 'code-mode-interrupt'),并携带经过签名的续接凭证(continuation)。
配套 API(见 src/interrupt-continuation.ts):
import {
experimental_getCodeModeInterrupt,
experimental_continueCodeModeInterrupt,
experimental_unwrapCodeModeResult,
} from '@ai-sdk/code-mode';
// 从 generateText / runCodeMode 的结果中提取中断
const interrupt = experimental_getCodeModeInterrupt(result);
// 分类结果:已完成 or 被中断
const { status, output, interrupt } = experimental_unwrapCodeModeResult(result);
// 带着用户决议继续执行
const finalResult = await experimental_continueCodeModeInterrupt({
interrupt,
resolution: { approved: true }, // 由你的业务逻辑给出
tools,
});
experimental_continueCodeModeInterrupt 内部会把源码、工具集合、continuation 凭证与本次决议重新交给 runCodeMode 恢复执行;getCodeModeInterrupt 还能递归地从 toolResults / content 数组以及 {type:'json'|'text'} 包裹中提取中断。每次续接只能按序消费一个 pending 中断(prepareContinuation 会校验 interruptId 与签名账本是否匹配)。
续接凭证的签名安全
continuation 不是裸的状态对象,而是经过 HMAC-SHA256 签名的凭证(CodeModeContinuationAuth,见 src/types.ts 与 src/continuation-capability.ts)。默认签名密钥是进程启动时 randomBytes(32) 生成的随机密钥,续接有效期默认 1 小时(maxAgeMs 默认 60 * 60 * 1000)。校验逻辑包括:信封结构、过期时间、签发时间不早于当前时间 60 秒以上、常量时间比较(timingSafeEqual)签名。这意味着:
- 续接状态不能被客户端篡改:源码、工具名、中断清单都在签名覆盖范围内;
- 多实例部署时需要显式配置共享密钥,否则不同实例间无法互相续接(使用
experimental_setCodeModeContinuationSigningKey设置全局默认,或在options.continuationSecurity里传入signingKey与maxAgeMs); - 无状态续接:沙箱暂停时不占用 worker,凭证可持久化到数据库后跨请求恢复。
错误模型:稳定可机器读取的错误码
code mode 的所有错误都继承自 CodeModeError(src/errors.ts),每个错误带稳定的 code 字符串和可选的 details 结构化诊断信息。下表汇总了所有错误类型:
| 错误类 | code | 触发条件 |
|---|---|---|
CodeModeTimeoutError |
CODE_MODE_TIMEOUT |
沙箱执行超过 executionPolicy.timeoutMs |
CodeModeAbortedError |
CODE_MODE_ABORTED |
外层 AI SDK abort signal 中止 |
CodeModeConcurrencyError |
CODE_MODE_CONCURRENCY_LIMIT |
达到进程级 worker 上限(setMaxWorkers) |
CodeModeSourceTooLargeError |
CODE_MODE_SOURCE_TOO_LARGE |
源码超过 maxSourceBytes |
CodeModeBridgeLimitError |
CODE_MODE_BRIDGE_LIMIT |
超过桥接请求数限制 |
CodeModeDetachedBridgeRequestError |
CODE_MODE_DETACHED_BRIDGE_REQUEST |
沙箱代码发起宿主桥接工作后未 await 就返回(游离请求) |
CodeModeProtocolError |
CODE_MODE_PROTOCOL_ERROR |
主线程与 worker 间协议消息非法/不匹配,或续接凭证校验失败 |
CodeModeToolError |
CODE_MODE_TOOL_ERROR |
嵌套宿主工具调用失败(如未知工具、输入校验失败) |
CodeModeToolApprovalRequiredError |
CODE_MODE_TOOL_APPROVAL_REQUIRED |
需要审批但未配置回调 |
CodeModeToolApprovalDeniedError |
CODE_MODE_TOOL_APPROVAL_DENIED |
审批被拒绝(含 reason) |
另外,底层 runner 抛出的错误在传播时会被翻译为上述类型(toCodeModeRuntimeError),并且错误堆栈会被改写:把 run.js:行:列 映射回 code-mode.js:行:列(源码行偏移 1 行,见 translateSourceStack),让堆栈指向你的用户源码位置而非包装代码,便于调试模型生成的代码。
并发控制:experimental_setMaxWorkers
QuickJS 沙箱运行在 worker 上,进程内并发执行是有限额的。@ai-sdk/code-mode 从 run 包重新导出了 experimental_setMaxWorkers(见 src/index.ts),用于设置进程全局的 worker 上限;达到上限时新的调用会抛 CodeModeConcurrencyError。测试代码里也出现了 setMaxWorkers(32) 的用法(src/core.test.ts)。在并发压力大的服务里,建议按可用 CPU/内存调大该值,同时留意 maxInFlightBridgeRequests 对单次调用内并发的约束。
测试验证:core.test.ts 揭示的沙箱行为
包的单元测试(src/core.test.ts)可以当作行为契约来读,除前面提到的点外还包括:
- 运行 JS 并返回 JSON 值:
return { answer: 40 + 2 }→{ answer: 42 }; - 无返回值返回
undefined:脚本没有return时结果为undefined; - 类型擦除:类型注解、
interface、satisfies均被剥离; - JSON 工具可用:沙箱内
JSON.parse/JSON.stringify正常工作; - 全局作用域隔离:每次调用全新全局环境;
- 高频并发:
Promise.all同时发起 20 次独立调用也能正常完成。
包内还有 e2e 测试(src/e2e/code-mode-haiku.e2e.test.ts,基于 Claude Haiku 走真实模型链路)以及针对选项解析、序列化、审批、中断续接、工具调用的专项测试(src/options.test.ts、src/approval-continuation.test.ts、src/run-compatibility.test.ts 等),感兴趣的读者可以直接阅读这些测试文件深入了解边界行为。
安全模型与实践建议
综合以上源码事实,code mode 的安全模型可以概括为"最小暴露 + 边界序列化 + 凭证签名"三层:
- 最小暴露:沙箱代码只能调用你显式提供的宿主工具;没有
fetch、没有文件系统、没有进程访问,一切副作用都收敛在宿主工具里。toolsProxy 对未知工具名直接报错,杜绝动态逃逸; - 边界序列化:所有进出沙箱的数据(工具输入、工具输出、最终结果、console 输出)都有字节上限,并强制 JSON 往返,避免对象引用/原型链等跨边界污染;
- 凭证签名:中断/续接状态经 HMAC-SHA256 签名并带过期时间,多实例环境必须配置共享签名密钥,续接凭证应视为不透明数据原样持久化,不要手工读写其字段(src/types.ts 的
CodeModeContinuation注释明确如此要求)。
实战建议:
- 给宿主工具写精确的
inputSchema/outputSchema/description,它们会被翻译成沙箱内的类型声明与调用示例,直接决定模型生成代码的准确率; - 对执行敏感操作的工具配置
needsApproval,并用approval.mode: 'interrupt'把审批交给 UI/人工流程; - 根据业务容忍度收紧
executionPolicy(尤其timeoutMs、maxBridgeRequests),并在stopWhen上限制循环步数; - 服务端多实例部署时务必调用
experimental_setCodeModeContinuationSigningKey设置稳定的共享密钥,并让密钥与业务密钥同等级别管理。
从"模型生成代码执行"到"审批、中断、续接、并发、错误码",@ai-sdk/code-mode 为 TypeScript 生态的 AI 应用提供了一个兼顾灵活性与可控性的工具编排范式:灵活的部分(编排逻辑)交给模型在隔离沙箱内自由书写,可控的部分(副作用、资源、审批、安全)牢牢留在宿主手中。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.22 K638- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python400
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python48367
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go20843
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java34351