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

构建一个聊天 Agent

构建一个能够流式返回 AI 响应、调用服务端工具、在浏览器中执行客户端工具,并在敏感操作前请求用户批准的聊天 Agent。

你将构建的内容:一个由 Workers AI 驱动的聊天 Agent,包含三种类型的工具 —— 自动执行、客户端执行,以及需要批准的工具。

预计时间:~15 分钟

前提条件:

  • Node.js 18+
  • 一个 Cloudflare 账户(免费版即可)

1. 创建项目

Terminal window


npm create cloudflare@latest chat-agent


在提示时选择 “Hello World” Worker。然后安装依赖:

Terminal window


cd chat-agent

npm install agents @cloudflare/ai-chat ai workers-ai-provider zod


2. 配置 Wrangler

wrangler.jsonc 替换为:

JSONC


{

  "name": "chat-agent",

  "main": "src/server.ts",

  // Set this to today's date

  "compatibility_date": "2026-04-29",

  "compatibility_flags": ["nodejs_compat"],

  "ai": { "binding": "AI" },

  "durable_objects": {

    "bindings": [{ "name": "ChatAgent", "class_name": "ChatAgent" }],

  },

  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] }],

}


Explain Code

TOML


name = "chat-agent"

main = "src/server.ts"

# Set this to today's date

compatibility_date = "2026-04-29"

compatibility_flags = [ "nodejs_compat" ]


[ai]

binding = "AI"


[[durable_objects.bindings]]

name = "ChatAgent"

class_name = "ChatAgent"


[[migrations]]

tag = "v1"

new_sqlite_classes = [ "ChatAgent" ]


Explain Code

关键设置:

  • ai 绑定 Workers AI —— 不需要 API key
  • durable_objects 注册你的聊天 Agent 类
  • new_sqlite_classes 启用 SQLite 存储用于消息持久化

3. 编写服务端

创建 src/server.ts,这是 Agent 所在之处:

JavaScript


import { AIChatAgent } from "@cloudflare/ai-chat";

import { routeAgentRequest } from "agents";

import { createWorkersAI } from "workers-ai-provider";

import {

  streamText,

  convertToModelMessages,

  pruneMessages,

  tool,

  stepCountIs,

} from "ai";

import { z } from "zod";


export class ChatAgent extends AIChatAgent {

  async onChatMessage() {

    const workersai = createWorkersAI({ binding: this.env.AI });


    const result = streamText({

      model: workersai("@cf/meta/llama-4-scout-17b-16e-instruct"),

      system:

        "You are a helpful assistant. You can check the weather, " +

        "get the user's timezone, and run calculations.",

      messages: pruneMessages({

        messages: await convertToModelMessages(this.messages),

        toolCalls: "before-last-2-messages",

      }),

      tools: {

        // Server-side tool: runs automatically on the server

        getWeather: tool({

          description: "Get the current weather for a city",

          inputSchema: z.object({

            city: z.string().describe("City name"),

          }),

          execute: async ({ city }) => {

            // Replace with a real weather API in production

            const conditions = ["sunny", "cloudy", "rainy"];

            const temp = Math.floor(Math.random() * 30) + 5;

            return {

              city,

              temperature: temp,

              condition:

                conditions[Math.floor(Math.random() * conditions.length)],

            };

          },

        }),


        // Client-side tool: no execute function — the browser handles it

        getUserTimezone: tool({

          description: "Get the user's timezone from their browser",

          inputSchema: z.object({}),

        }),


        // Approval tool: requires user confirmation before executing

        calculate: tool({

          description:

            "Perform a math calculation with two numbers. " +

            "Requires user approval for large numbers.",

          inputSchema: z.object({

            a: z.number().describe("First number"),

            b: z.number().describe("Second number"),

            operator: z

              .enum(["+", "-", "*", "/", "%"])

              .describe("Arithmetic operator"),

          }),

          needsApproval: async ({ a, b }) =>

            Math.abs(a) > 1000 || Math.abs(b) > 1000,

          execute: async ({ a, b, operator }) => {

            const ops = {

              "+": (x, y) => x + y,

              "-": (x, y) => x - y,

              "*": (x, y) => x * y,

              "/": (x, y) => x / y,

              "%": (x, y) => x % y,

            };

            if (operator === "/" && b === 0) {

              return { error: "Division by zero" };

            }

            return {

              expression: `${a} ${operator} ${b}`,

              result: ops[operator](a, b),

            };

          },

        }),

      },

      stopWhen: stepCountIs(5),

    });


    return result.toUIMessageStreamResponse();

  }

}


export default {

  async fetch(request, env) {

    return (

      (await routeAgentRequest(request, env)) ||

      new Response("Not found", { status: 404 })

    );

  },

};


Explain Code

TypeScript


import { AIChatAgent } from "@cloudflare/ai-chat";

import { routeAgentRequest } from "agents";

import { createWorkersAI } from "workers-ai-provider";

import {

  streamText,

  convertToModelMessages,

  pruneMessages,

  tool,

  stepCountIs,

} from "ai";

import { z } from "zod";


export class ChatAgent extends AIChatAgent {

  async onChatMessage() {

    const workersai = createWorkersAI({ binding: this.env.AI });


    const result = streamText({

      model: workersai("@cf/meta/llama-4-scout-17b-16e-instruct"),

      system:

        "You are a helpful assistant. You can check the weather, " +

        "get the user's timezone, and run calculations.",

      messages: pruneMessages({

        messages: await convertToModelMessages(this.messages),

        toolCalls: "before-last-2-messages",

      }),

      tools: {

        // Server-side tool: runs automatically on the server

        getWeather: tool({

          description: "Get the current weather for a city",

          inputSchema: z.object({

            city: z.string().describe("City name"),

          }),

          execute: async ({ city }) => {

            // Replace with a real weather API in production

            const conditions = ["sunny", "cloudy", "rainy"];

            const temp = Math.floor(Math.random() * 30) + 5;

            return {

              city,

              temperature: temp,

              condition:

                conditions[Math.floor(Math.random() * conditions.length)],

            };

          },

        }),


        // Client-side tool: no execute function — the browser handles it

        getUserTimezone: tool({

          description: "Get the user's timezone from their browser",

          inputSchema: z.object({}),

        }),


        // Approval tool: requires user confirmation before executing

        calculate: tool({

          description:

            "Perform a math calculation with two numbers. " +

            "Requires user approval for large numbers.",

          inputSchema: z.object({

            a: z.number().describe("First number"),

            b: z.number().describe("Second number"),

            operator: z

              .enum(["+", "-", "*", "/", "%"])

              .describe("Arithmetic operator"),

          }),

          needsApproval: async ({ a, b }) =>

            Math.abs(a) > 1000 || Math.abs(b) > 1000,

          execute: async ({ a, b, operator }) => {

            const ops: Record<string, (x: number, y: number) => number> = {

              "+": (x, y) => x + y,

              "-": (x, y) => x - y,

              "*": (x, y) => x * y,

              "/": (x, y) => x / y,

              "%": (x, y) => x % y,

            };

            if (operator === "/" && b === 0) {

              return { error: "Division by zero" };

            }

            return {

              expression: `${a} ${operator} ${b}`,

              result: ops[operator](a, b),

            };

          },

        }),

      },

      stopWhen: stepCountIs(5),

    });


    return result.toUIMessageStreamResponse();

  }

}


export default {

  async fetch(request: Request, env: Env) {

    return (

      (await routeAgentRequest(request, env)) ||

      new Response("Not found", { status: 404 })

    );

  },

} satisfies ExportedHandler<Env>;


Explain Code

各类工具的作用

工具execute?needsApproval?行为
getWeather在服务端自动运行
getUserTimezone发送给客户端;由浏览器提供结果
calculate是(大数字)暂停等待用户批准,然后在服务端运行

4. 编写客户端

创建 src/client.tsx:

JavaScript


import { useAgent } from "agents/react";

import { useAgentChat } from "@cloudflare/ai-chat/react";


function Chat() {

  const agent = useAgent({ agent: "ChatAgent" });


  const {

    messages,

    sendMessage,

    clearHistory,

    addToolApprovalResponse,

    status,

  } = useAgentChat({

    agent,

    // Handle client-side tools (tools with no server execute function)

    onToolCall: async ({ toolCall, addToolOutput }) => {

      if (toolCall.toolName === "getUserTimezone") {

        addToolOutput({

          toolCallId: toolCall.toolCallId,

          output: {

            timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,

            localTime: new Date().toLocaleTimeString(),

          },

        });

      }

    },

  });


  return (

    <div>

      <div>

        {messages.map((msg) => (

          <div key={msg.id}>

            <strong>{msg.role}:</strong>

            {msg.parts.map((part, i) => {

              if (part.type === "text") {

                return <span key={i}>{part.text}</span>;

              }


              // Render approval UI for tools that need confirmation

              if (part.type === "tool" && part.state === "approval-required") {

                return (

                  <div key={part.toolCallId}>

                    <p>

                      Approve <strong>{part.toolName}</strong>?

                    </p>

                    <pre>{JSON.stringify(part.input, null, 2)}</pre>

                    <button

                      onClick={() =>

                        addToolApprovalResponse({

                          id: part.toolCallId,

                          approved: true,

                        })

                      }

                    >

                      Approve

                    </button>

                    <button

                      onClick={() =>

                        addToolApprovalResponse({

                          id: part.toolCallId,

                          approved: false,

                        })

                      }

                    >

                      Reject

                    </button>

                  </div>

                );

              }


              // Show completed tool results

              if (part.type === "tool" && part.state === "output-available") {

                return (

                  <details key={part.toolCallId}>

                    <summary>{part.toolName} result</summary>

                    <pre>{JSON.stringify(part.output, null, 2)}</pre>

                  </details>

                );

              }


              return null;

            })}

          </div>

        ))}

      </div>


      <form

        onSubmit={(e) => {

          e.preventDefault();

          const input = e.currentTarget.elements.namedItem("message");

          sendMessage({ text: input.value });

          input.value = "";

        }}

      >

        <input name="message" placeholder="Try: What's the weather in Paris?" />

        <button type="submit" disabled={status === "streaming"}>

          Send

        </button>

      </form>


      <button onClick={clearHistory}>Clear history</button>

    </div>

  );

}


export default function App() {

  return <Chat />;

}


Explain Code

TypeScript


import { useAgent } from "agents/react";

import { useAgentChat } from "@cloudflare/ai-chat/react";


function Chat() {

  const agent = useAgent({ agent: "ChatAgent" });


  const { messages, sendMessage, clearHistory, addToolApprovalResponse, status } =

    useAgentChat({

      agent,

      // Handle client-side tools (tools with no server execute function)

      onToolCall: async ({ toolCall, addToolOutput }) => {

        if (toolCall.toolName === "getUserTimezone") {

          addToolOutput({

            toolCallId: toolCall.toolCallId,

            output: {

              timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,

              localTime: new Date().toLocaleTimeString(),

            },

          });

        }

      },

    });


  return (

    <div>

      <div>

        {messages.map((msg) => (

          <div key={msg.id}>

            <strong>{msg.role}:</strong>

            {msg.parts.map((part, i) => {

              if (part.type === "text") {

                return <span key={i}>{part.text}</span>;

              }


              // Render approval UI for tools that need confirmation

              if (

                part.type === "tool" &&

                part.state === "approval-required"

              ) {

                return (

                  <div key={part.toolCallId}>

                    <p>

                      Approve <strong>{part.toolName}</strong>?

                    </p>

                    <pre>{JSON.stringify(part.input, null, 2)}</pre>

                    <button

                      onClick={() =>

                        addToolApprovalResponse({

                          id: part.toolCallId,

                          approved: true,

                        })

                      }

                    >

                      Approve

                    </button>

                    <button

                      onClick={() =>

                        addToolApprovalResponse({

                          id: part.toolCallId,

                          approved: false,

                        })

                      }

                    >

                      Reject

                    </button>

                  </div>

                );

              }


              // Show completed tool results

              if (

                part.type === "tool" &&

                part.state === "output-available"

              ) {

                return (

                  <details key={part.toolCallId}>

                    <summary>{part.toolName} result</summary>

                    <pre>{JSON.stringify(part.output, null, 2)}</pre>

                  </details>

                );

              }


              return null;

            })}

          </div>

        ))}

      </div>


      <form

        onSubmit={(e) => {

          e.preventDefault();

          const input = e.currentTarget.elements.namedItem(

            "message",

          ) as HTMLInputElement;

          sendMessage({ text: input.value });

          input.value = "";

        }}

      >

        <input name="message" placeholder="Try: What's the weather in Paris?" />

        <button type="submit" disabled={status === "streaming"}>

          Send

        </button>

      </form>


      <button onClick={clearHistory}>Clear history</button>

    </div>

  );

}


export default function App() {

  return <Chat />;

}


Explain Code

客户端关键概念

  • useAgent 通过 WebSocket 连接到你的 ChatAgent
  • useAgentChat 管理聊天的整个生命周期(消息、流式输出、工具)
  • onToolCall 处理客户端工具 —— 当 LLM 调用 getUserTimezone 时,浏览器提供结果,对话自动继续
  • addToolApprovalResponse 批准或拒绝带 needsApproval 的工具
  • 消息、流式输出和续传都会被自动处理

5. 本地运行

生成类型并启动开发服务器:

Terminal window


npx wrangler types

npm run dev


试试这些提示语:

  • “What is the weather in Tokyo?” —— 调用服务端的 getWeather 工具
  • “What timezone am I in?” —— 调用客户端的 getUserTimezone 工具(由浏览器提供答案)
  • “What is 5000 times 3?” —— 在执行前触发批准 UI(数字超过 1000)

6. 部署

Terminal window


npx wrangler deploy


你的 Agent 现在已经在 Cloudflare 全球网络上线了。消息会持久化到 SQLite,断开后流会自动续传,空闲时 Agent 会休眠以节省资源。

你构建了什么

你的聊天 Agent 拥有:

  • 流式 AI 响应,通过 Workers AI 提供(无需 API key)
  • 消息持久化,存储在 SQLite 中 —— 对话不会因重启而丢失
  • 服务端工具,自动执行
  • 客户端工具,在浏览器中运行,并把结果反馈给 LLM
  • Human-in-the-loop 批准,用于敏感操作
  • 可恢复的流式输出 —— 客户端在流式过程中断开后,会从中断处继续

下一步

Chat agents API reference AIChatAgent 和 useAgentChat 的完整参考 —— 提供商、存储和高级用法。

Store and sync state 在聊天消息之外添加实时状态。

Callable methods 把 Agent 方法暴露为带类型的 RPC 给客户端使用。

Human-in-the-loop 关于批准流程和人工干预的深入模式。