第 4 章:让 LLM 写代码 — Code Mode 默认 + 传统工具
一句话定位:你会让 agent 用一段 JavaScript 调一连串工具,而不是一个一个工具循环 —— token 省一大截,任务还做得更对。
想要什么
第 3 章结束时,我们的 CoderAgent 能记得对话了,但它还是只能“说话“。我们要它真正动手干活。
来一个具体任务:
“找出
src/里所有用了console.log的地方,告诉我一共多少处、分布在哪些文件。”
普通工具调用模式下,LLM 大概会这样:先 glob 列文件 → 拿到 47 个 .ts,挨个 readFile → 字符串里 console.log.* 一扫 → 自己累加。每一步都是一次 tool call,LLM 看了 47 次文件全文,context 涨成几十 KB,结果还可能漏算。
更糟糕的是,模型每一轮都要等服务端返回工具结果再继续 —— 47 次 round-trip,光 inference latency 就吃掉好几秒。
我们想要的是:LLM 写一段 JS 脚本,在一次执行里完成“glob + 读 + 正则匹配 + 计数“,最后只把结论返回给自己继续推理。这就是 Project Think 的默认工具调用方式 —— Code Mode。
为什么
传统的 tool calling 是 OpenAI 在 2023 年定的约定:每个 tool 一段 JSON Schema,LLM 一次只能输出一个 tool call,服务端跑完再回给它,如此循环到 finish_reason = "stop"。
这套协议的根毛病:循环里每一轮都把整个工具列表 + 全部历史 + 全部中间结果再发一次给模型。如果你的工具有 30 个、任务要 10 步,那就是 300 个 tool 描述 × 10 轮的重复 prompt。Token 账单按这个量级飙。
Cloudflare 给的解法是 Code Mode:把一组工具打包成 JS 模块,暴露给 LLM 一个单一的工具 execute(code: string)。LLM 写一段 JS,里面 import { glob, readFile } from "codemode"; 然后用编程语言原生的 for 循环、Array.reduce、模板字符串去组合调用。这段 JS 在 Cloudflare 的 Dynamic Worker 沙盒里跑,全程隔离(默认连 fetch 都禁),只把最终 return 的值送回 LLM。
效果:
- 工具描述只在 prompt 里出现一次(以 TS 类型签名的形式塞进 system),不再每轮重复。
- 多步组合在一次 sandbox 执行里完成,LLM 的 round-trip 从 N 次降到 1 次。
- LLM 写代码这件事它本来就在练 —— 它写 JS 比写 JSON tool call 顺得多。
Cloudflare 自己的测试里,同样的多工具任务,Code Mode 的 token 花费比传统循环少 70-90%。
但 Code Mode 不是银弹。简单的、单步的查询(查时间、查天气)用传统 tool 反而更直白 —— 不必为了 getTime() 让 LLM 去写 return await codemode.getTime();。本章两种都演示。
图 4-1:Code Mode 把 N 步循环压成 1 次执行
方案选择
| 模式 | 适合什么 | 我们用吗 |
|---|---|---|
Code Mode(createExecuteTool) | 多工具组合、循环、聚合、过滤 —— 凡是“先 A 再 B 再汇总“ | 默认用 |
传统 tool({ inputSchema, execute }) | 单步、无组合、参数简单(getTime、getWeather) | 与 Code Mode 共存 |
客户端工具(tool() 无 execute) | 浏览器 API:地理位置、剪贴板 | 这章用一次,做 HITL 审批 UI |
| MCP 工具 | 别人写好的工具集 | 第 10 章 |
Project Think 的 getTools() 钩子里两种完全可以混用:你返回的 ToolSet 既可以包含 Code Mode 的 execute,也可以包含若干个传统 tool 给 LLM 直接调用。LLM 自己看任务复杂度选。
落地
装包
# Terminal
npm install @cloudflare/codemode@latest zod@^4
@cloudflare/codemode 提供 Dynamic Worker 沙盒;zod 给传统 tool 写 schema。
加 worker_loaders binding
Code Mode 在 Dynamic Worker 里跑用户代码,需要 worker_loaders 这个新 binding:
// wrangler.jsonc(增量)
{
"compatibility_date": "2026-04-30",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI" },
"durable_objects": {
"bindings": [{ "class_name": "CoderAgent", "name": "CODER_AGENT" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["CoderAgent"] }
],
"worker_loaders": [
{ "binding": "LOADER" }
]
}
LOADER 这个名字不重要(全大写小写都行),但要记得在 Env 类型里加上。
把工具拆出来
按 BLUEPRINT_v2 的目录:
src/
├── agent.ts
├── index.ts
└── tools/
├── index.ts
├── workspace.ts
└── execute.ts
src/tools/workspace.ts — 文件系统工具
// src/tools/workspace.ts
import { createWorkspaceTools } from "@cloudflare/think/tools/workspace";
import type { Workspace } from "@cloudflare/think";
// createWorkspaceTools(ws) 返回一组 { readFile, writeFile, glob, grep, ... }
// 直接读写 Think 内置的 SQLite-backed Workspace。
export const buildWorkspaceTools = (workspace: any) =>
createWorkspaceTools(workspace);
this.workspace 是 Think 基类自带的字段,默认是 SQLite 后端的虚拟文件系统(章节内可以理解成“agent 的私有项目目录“)。第 6 章引入 Sandbox 后,我们会让 workspace 与真实容器内的文件系统同步;现在它纯靠 DO 的 SQLite。
src/tools/execute.ts — Code Mode 包装
// src/tools/execute.ts
import { createExecuteTool } from "@cloudflare/think/tools/execute";
import { buildWorkspaceTools } from "./workspace";
// workspace 用 any 是因为 Think 内部 this.workspace 类型是 WorkspaceLike,
// 而 createWorkspaceTools 形参又叫 Workspace —— SDK preview 阶段两者类型未对齐。
export const buildExecuteTool = (
workspace: any,
loader: WorkerLoader,
) =>
createExecuteTool({
// 传给 LLM 的工具集 —— 它们会变成沙盒里 codemode.* 命名空间下可调的方法
tools: buildWorkspaceTools(workspace),
// 沙盒执行器:用 worker_loaders binding
loader,
// 默认 30s,够用
timeout: 30_000,
// 出站完全禁掉(不让用户写的 JS 偷偷 fetch 外网)
globalOutbound: null,
});
createExecuteTool返回的是单个 tool(name: "execute"),它在内部接管那一组传统工具的转译 —— 把 zod schema 转成 TS 类型签名喂给 LLM,把 LLM 写的 JS 扔进 Dynamic Worker。LLM 看到的只是execute(code: string)。
src/tools/index.ts — 一个简单的传统 tool 做对照
// src/tools/index.ts
import { tool } from "ai";
import { z } from "zod";
// 简单查询:不需要组合,LLM 直接调
export const getCurrentTime = tool({
description: "返回服务器当前时间(ISO 8601)。",
inputSchema: z.object({
timezone: z.string().default("UTC").describe("IANA 时区名"),
}),
execute: async ({ timezone }) => {
return new Date().toLocaleString("sv-SE", { timeZone: timezone });
},
});
export const getWeather = tool({
description: "查询给定城市当前天气。返回简短文字描述。",
inputSchema: z.object({
city: z.string().describe("城市名,如 Beijing、San Francisco"),
}),
execute: async ({ city }) => {
// 真实场景下接个天气 API,这里 mock
return `${city} 当前 22°C,多云。`;
},
});
把工具挂到 agent + 让 streamText 真把工具结果用起来
ch04 的 agent.ts 在 ch03 基础上加了 getTools(),把 ch03 的 onRequest 也改一下,让 streamText 接受 tools 并循环到完整回答:
// src/agent.ts
import { Think } from "@cloudflare/think";
import { createAiGateway } from "ai-gateway-provider";
import { createUnified } from "ai-gateway-provider/providers/unified";
import { callable } from "agents";
import {
generateText, streamText, stepCountIs, convertToModelMessages,
type LanguageModel, type UIMessage,
} from "ai";
import type { Session, SessionMessage } from "agents/experimental/memory/session";
import { createCompactFunction } from "agents/experimental/memory/utils";
import { buildExecuteTool } from "./tools/execute";
import { getCurrentTime, getWeather } from "./tools/index";
export type AIProvider = "workers-ai" | "anthropic" | "openai" | "google";
export type Env = Omit<Cloudflare.Env, "AI_PROVIDER"> & {
AI_PROVIDER?: AIProvider;
AI_GATEWAY_ID?: string;
CF_ACCOUNT_ID?: string;
CF_AIG_TOKEN?: string;
LOADER: WorkerLoader; // ch04 新增,跟 worker_loaders 绑定对应
};
const MODEL_MAP: Record<AIProvider, string> = {
"workers-ai": "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"anthropic": "anthropic/claude-haiku-4-5",
"openai": "openai/gpt-5-mini",
"google": "google-ai-studio/gemini-2.5-pro",
};
export class CoderAgent extends Think<Env> {
getModel(): LanguageModel {
const provider = this.env.AI_PROVIDER ?? "workers-ai";
const aigateway = createAiGateway({
accountId: this.env.CF_ACCOUNT_ID!,
gateway: this.env.AI_GATEWAY_ID ?? "default",
apiKey: this.env.CF_AIG_TOKEN,
});
const unified = createUnified();
return aigateway(unified(MODEL_MAP[provider]));
}
getSystemPrompt() {
return [
"你是 agent-coder,中文编程助手。",
"对于多步、组合性的任务(grep + 统计、批量改写、跨文件分析)优先调 execute,",
"在沙盒里写一段 JS 完成。简单查询(时间、天气)直接调对应 tool。",
].join("\n");
}
getTools() {
return {
execute: buildExecuteTool(this.workspace, this.env.LOADER),
getCurrentTime,
getWeather,
};
}
// configureSession 跟 ch03 一致(略,完整文件见 snapshot)
// /api/chat:在 ch03 基础上加 tools + stopWhen 循环
async onRequest(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/chat" && request.method === "POST") {
const { messages } = await request.json<{ messages: UIMessage[] }>();
const lastUser = messages[messages.length - 1];
await this.session.appendMessage({
id: crypto.randomUUID(), role: "user",
parts: lastUser?.parts ?? [],
} as SessionMessage);
const history = this.session.getHistory();
const result = streamText({
model: this.getModel(),
system: this.getSystemPrompt(),
tools: this.getTools(),
// ⚠️ AI SDK v6 默认单步:tool call 之后不会自动跑下一轮把结果转成 assistant 回答
// stopWhen: stepCountIs(N) 让它最多跑 N 步,工具调用才会被"接续"
stopWhen: stepCountIs(5),
messages: await convertToModelMessages(history as unknown as UIMessage[]),
onFinish: async ({ text }) => {
await this.session.appendMessage({
id: crypto.randomUUID(), role: "assistant",
parts: [{ type: "text", text }],
} as SessionMessage);
},
onError: ({ error }) => console.error("streamText:", error),
});
return result.toTextStreamResponse();
}
return super.onRequest(request);
}
}
三个跟 ch03 不同的实战要点:
stopWhen: stepCountIs(N)必须加:不加,LLM 输出第一次tool-call就停,curl 看到的是空白(text-only stream)。AI SDK v5 默认会循环,v6 改成显式声明 — 这是个高频坑。getTools()返回的ToolSet里execute跟getCurrentTime等平级:LLM 自己根据 system prompt 选用哪个,Code Mode 不是包装层。wrangler.jsonc必须加worker_loaders绑定,且LOADER这个名字要在Env里声明(全大写小写都行)。改完 wrangler.jsonc 记得npx wrangler types。
一次真实任务
启动 dev server,curl 那个 console.log 计数任务:
# Terminal
npx wrangler dev
# 另一个 terminal
curl -N -X POST http://localhost:8787/api/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"找出当前 workspace 里 src/ 下所有用了 console.log 的地方,告诉我一共多少处、分布在哪些文件。"}]}]}'
LLM 会输出一个 tool-call part,name: "execute",args.code 大概是这样:
// LLM 自动生成的(在沙盒里运行)
const files = await codemode.glob("src/**/*.{ts,tsx,js}");
const hits = [];
for (const path of files) {
const text = await codemode.readFile(path);
const matches = text.match(/console\.log/g);
if (matches) hits.push({ path, count: matches.length });
}
return {
total: hits.reduce((s, h) => s + h.count, 0),
files: hits,
};
Think 把它扔进 Dynamic Worker,30 秒内返回 { total: 18, files: [...] }。LLM 拿到结果,生成自然语言总结发回客户端。整个交互两轮,token 用量是传统模式的零头。
HITL:写文件之前问一下
Code Mode 跑在沙盒里,理论上更安全(没有 outbound,没有真实 FS)。但 writeFile 这种工具最终还是要落到真实的 workspace —— 用户改坏了文件没法撤销。
Think 提供 beforeToolCall 钩子(think.d.ts:574),返回 ToolCallDecision 决定 allow / block / substitute。我们用它给 writeFile 加确认门:
// src/agent.ts(增量)
import type { ToolCallContext, ToolCallDecision } from "@cloudflare/think";
export class CoderAgent extends Think<Env> {
// ... 前面 getModel / getTools 不变
async beforeToolCall(ctx: ToolCallContext): Promise<ToolCallDecision | void> {
// ctx.toolName 是 "execute"(顶层),
// 我们关心的是它沙盒里调了哪个内部工具 —— 看 ctx.input.code 里有没有 writeFile
if (ctx.toolName !== "execute") return;
const code: string = ctx.input?.code ?? "";
if (!/\bwriteFile\s*\(/.test(code)) return;
// 触发待审批事件;客户端 onToolCall 决定批准还是拒绝
return {
action: "block",
// 客户端 UI 看到 reason,做按钮文案
reason: "writeFile 需要确认。请在客户端点击"批准"以继续。",
};
}
}
Code Mode + HITL 的精确写法 Think 还在打磨。当前推荐做法是在
beforeToolCall里只拦顶层execute,然后前端显示沙盒代码的预览给用户审核 —— 把“批准“的语义放在“我看过这段代码,可以跑“这一层,而不是单工具粒度。粒度更细的“沙盒内拦某个工具“在 0.4.x 里还没正式 API,以官方文档为准。
客户端审批 UI
useAgentChat 的 onToolCall 回调可以拦截待审批的 tool call,弹个对话框,用户决定后调 addToolResult:
// app/chat.tsx
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
export function Chat({ conversationId }: { conversationId: string }) {
const agent = useAgent({ agent: "CoderAgent", name: conversationId });
const chat = useAgentChat({
agent,
onToolCall: async ({ toolCall, addToolResult }) => {
if (toolCall.toolName !== "execute") return;
const code = toolCall.args.code as string;
// 简陋的 confirm,真实场景换成 modal + 高亮代码
const ok = window.confirm(
`Agent 想跑这段代码,是否允许?\n\n${code.slice(0, 800)}`,
);
addToolResult({
toolCallId: toolCall.toolCallId,
output: ok ? "approved" : "rejected by user",
});
},
});
return (
<div>
{chat.messages.map((m) => (
<pre key={m.id}>{JSON.stringify(m, null, 2)}</pre>
))}
</div>
);
}
这套客户端代码沿用 v1 的 @cloudflare/ai-chat/react,跟 Think 配合无缝。
验证
跑两个对照实验。
实验 1:Code Mode 任务(console.log 计数)。看 wrangler dev 的日志,你应该看到一条 tool-call name=execute 后面跟一条 tool-result,中间没有别的 round-trip。返回的最终消息里应该包含“共 N 处“和文件清单。
实验 2:传统 tool 任务:
curl -N -X POST http://localhost:8787/api/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","parts":[{"type":"text","text":"现在几点?用上海时区。"}]}]}'
日志里应该看到 tool-call name=getCurrentTime args={timezone:"Asia/Shanghai"},而不是 name=execute。LLM 自己判断了“这是单步查询,直接调专用工具更直接“。如果 LLM 把它当 Code Mode 写成 execute("return ..."),说明你的 system prompt 写得不够明确,把那句“对于多步、组合性的任务…简单查询直接调“再强调一下。
实验 3:HITL(浏览器里跑 Chat 组件)。让它“在 notes/today.md 里写’今天部署成功’“:你应该看到沙盒代码预览的 confirm 弹窗,点取消,sandbox 收到 “rejected by user”,LLM 自然语言回:“已取消写入。”
边界与坑
- Code Mode 沙盒默认零出站(
globalOutbound: null)。如果你的工具内部需要fetch,要么把fetch通过tools暴露给沙盒,要么显式传一个Fetcher。不要为图省事开globalOutbound: env,等于把整个 worker 的网络权暴露给 LLM 写的 JS。 worker_loaders是新 binding,本地 wrangler dev 需要wrangler@latest。版本太老会报Unknown binding type: worker_loaders。- 传统 tool 与 Code Mode 同时挂时,LLM 偶尔会“两路都试“—— 先调
execute写 JS 调getCurrentTime,失败再降级用单 tool。这通常是 system prompt 没写清“什么时候用哪个“。把决策规则写死在 prompt 里。 createSandboxTools不是 Code Mode:它是另一个未实现的占位(参见tools/sandbox.d.ts:34)。本章不要碰它,第 6 章我们用真正的@cloudflare/sandbox替代。
延伸阅读
- Code Mode 官方公告 — 设计动机与 token 节省数据
- Project Think tools API —
getTools()与beforeToolCall - AI SDK tool() 文档 — 传统 tool 的 inputSchema / execute 签名
- Tools 中文文档 — Agent 基类 tool 相关 API
下一章预告
LLM 学会了“调工具“和“写代码组合工具“,但它还不知道用什么风格做事 —— 比如 code review 应该怎么找问题、按什么 checklist。下一章我们引入 Skills 与 Memory:用 Session context block 注入“方法论“,用 Agent Memory(私测)让 agent 跨会话记住偏好。