Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 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 次执行

传统 tool calling 是 LLM↔服务端 N 次往返;Code Mode 是 LLM 写一段 JS,一次执行多步 传统 vs Code Mode:同样的多工具任务 传统 tool calling LLM 服务端 glob src/** [47 files] readFile a.ts "..." readFile b.ts ... readFile z.ts ... 48+ 次 round-trip 每轮都重发完整 prompt Code Mode LLM Worker 沙盒 execute(`...`) { count: 18, files: [...] } glob + 47×readFile + 正则 在沙盒里一口气跑完 2 次 round-trip 工具描述只发 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() 返回的 ToolSetexecutegetCurrentTime平级: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

useAgentChatonToolCall 回调可以拦截待审批的 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 替代。

延伸阅读

下一章预告

LLM 学会了“调工具“和“写代码组合工具“,但它还不知道用什么风格做事 —— 比如 code review 应该怎么找问题、按什么 checklist。下一章我们引入 Skills 与 Memory:用 Session context block 注入“方法论“,用 Agent Memory(私测)让 agent 跨会话记住偏好。