本教程将构建一个可搜索知识库并向其中添加内容的 agent,且每次写入都需经人工批准。让 agent 修改数据存在风险,因此每次保存会在执行前暂停等待批准,你也可以回滚错误的保存。
一个 Cloudflare Agent,可搜索 AI Search 实例、提议要索引的新文档、等待你批准每一项,并能够撤销已批准的保存。
- 注册 Cloudflare 账户 ↗。
- 安装
Node.js↗。
Node.js 版本管理器
使用 Volta ↗ 或 nvm ↗ 等 Node 版本管理器,以避免权限问题并切换 Node.js 版本。本指南后续将介绍的 Wrangler 需要 Node 版本 16.17.0 或更高。
你不需要其他内容。Agent 首次运行时会自行预配 AI Search 实例。
该 agent 使用 Code Mode,这是一种工具使用模式:模型编写小型程序来调用你的工具,而不是分别请求每次调用。**durable runtime(持久运行时)**会记录程序的每一次调用,在敏感调用前暂停以便人工批准,并可通过运行 revert 补偿已应用的调用。该持久状态保存在 Agent 的 Durable Object 中,因此批准可跨请求与休眠等待。
你通过 **connector(连接器)**向运行时暴露 AI Search:这是一个普通类,将 AI Search 操作转为模型可调用的方法。本教程为模型提供只读的 search 方法,以及需要批准的 saveDocument 方法。
使用 create-cloudflare CLI(C3)创建新的 Worker 项目。C3 ↗ 是帮助你设置并将新应用部署到 Cloudflare 的命令行工具。
运行以下命令创建名为 kb-agent 的新项目:
npm create cloudflare@latest -- kb-agentyarn create cloudflare kb-agentpnpm create cloudflare@latest kb-agent进行设置时,请选择以下选项:
- 对于 What would you like to start with?,选择
Hello World example。 - 对于 Which template would you like to use?,选择
Worker only。 - 对于 Which language do you want to use?,选择
TypeScript。 - 对于 Do you want to use git for version control?,选择
Yes。 - 对于 Do you want to deploy your application?,选择
No(部署前我们还会做一些修改)。
进入应用目录:
cd kb-agent安装依赖。ai 与 zod 版本固定在 Agents SDK 作为 peer dependencies 所期望的范围内:
npm i @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4yarn add @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4pnpm add @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4bun add @cloudflare/codemode @cloudflare/ai-chat agents ai@6 workers-ai-provider zod@4本教程使用 AI Search 与 Worker Loader 绑定,需要 Wrangler v4。若 create-cloudflare 为项目设置了更早版本,请升级:
npm i -D wrangler@4yarn add -D wrangler@4pnpm add -D wrangler@4bun add -d wrangler@4将你的 Wrangler 配置文件 替换为以下内容。这会添加 AI Search 绑定、用于模型的 Workers AI 绑定、在隔离 Worker 中运行模型代码的 Worker Loader 绑定,以及存储 agent 聊天历史与 durable runtime 状态的 Durable Object。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "kb-agent",
"main": "src/server.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"compatibility_flags": [
"nodejs_compat"
],
"ai": {
"binding": "AI"
},
"ai_search_namespaces": [
{
"binding": "AI_SEARCH",
"namespace": "default",
"remote": true
}
],
"worker_loaders": [
{
"binding": "LOADER"
}
],
"durable_objects": {
"bindings": [
{
"name": "Chat",
"class_name": "Chat"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"Chat"
]
}
]
}name = "kb-agent"
main = "src/server.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
compatibility_flags = ["nodejs_compat"]
[ai]
binding = "AI"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = true
[[worker_loaders]]
binding = "LOADER"
[[durable_objects.bindings]]
name = "Chat"
class_name = "Chat"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Chat"]AI Search 没有本地模拟器,因此绑定始终与远程服务通信(remote = true)。因此,你需要通过部署来演练 agent,而不是使用 wrangler dev。AIChatAgent 会将消息持久化到 SQLite,因此其类必须列在 new_sqlite_classes 中。
创建 src/ai-search-connector.ts。该连接器直接调用 AI Search 绑定,因此请求保持在进程内,无需公开端点。
为模型提供只读的 search 方法与 saveDocument 方法。由于 saveDocument 会写入内容,请将其标记为 requiresApproval,并添加 revert 以便运行时可以回滚。
import { CodemodeConnector } from "@cloudflare/codemode";
// The instance this connector reads from and writes to.
const INSTANCE_ID = "knowledge-base";
// A connector turns AI Search operations into methods the model can call from
// its generated code. Each connector becomes one named object in the sandbox.
export class AISearchConnector extends CodemodeConnector {
// The sandbox global. The model calls `aiSearch.search()` and
// `aiSearch.saveDocument()`.
name() {
return "aiSearch";
}
// Shown to the model so it knows what this connector is for.
instructions() {
return "Use this connector to search indexed content and save new documents.";
}
// Every method the model can call. `inputSchema` is JSON Schema; the runtime
// validates the model's arguments against it before calling `execute`.
tools() {
return {
// Read-only, so it runs without approval.
search: {
description: "Search indexed content and return the matching chunks.",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
execute: async (input) => {
const { query } = input;
return this.env.AI_SEARCH.get(INSTANCE_ID).search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
},
},
// Writes content, so it is gated behind approval and made reversible.
saveDocument: {
description: "Save a new document to the knowledge base.",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
content: { type: "string" },
},
required: ["name", "content"],
},
// Pauses the run before this method executes so a human can approve it.
requiresApproval: true,
execute: async (input) => {
const { name, content } = input;
// upload() queues the document for indexing and returns right away.
// The item becomes searchable once indexing finishes, a few seconds later.
const item = await this.env.AI_SEARCH.get(INSTANCE_ID).items.upload(
name,
content,
);
// This return value is passed to `revert` if the call is rolled back.
return { id: item.id, key: item.key, status: item.status };
},
// Compensating action for rollback: delete the document this call added.
revert: async (_input, result) => {
const { id } = result;
await this.env.AI_SEARCH.get(INSTANCE_ID).items.delete(id);
},
},
};
}
}import { CodemodeConnector, type ConnectorTools } from "@cloudflare/codemode";
// The instance this connector reads from and writes to.
const INSTANCE_ID = "knowledge-base";
// A connector turns AI Search operations into methods the model can call from
// its generated code. Each connector becomes one named object in the sandbox.
export class AISearchConnector extends CodemodeConnector<Env> {
// The sandbox global. The model calls `aiSearch.search()` and
// `aiSearch.saveDocument()`.
override name() {
return "aiSearch";
}
// Shown to the model so it knows what this connector is for.
protected override instructions() {
return "Use this connector to search indexed content and save new documents.";
}
// Every method the model can call. `inputSchema` is JSON Schema; the runtime
// validates the model's arguments against it before calling `execute`.
protected override tools(): ConnectorTools {
return {
// Read-only, so it runs without approval.
search: {
description: "Search indexed content and return the matching chunks.",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
execute: async (input) => {
const { query } = input as { query: string };
return this.env.AI_SEARCH.get(INSTANCE_ID).search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
},
},
// Writes content, so it is gated behind approval and made reversible.
saveDocument: {
description: "Save a new document to the knowledge base.",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
content: { type: "string" },
},
required: ["name", "content"],
},
// Pauses the run before this method executes so a human can approve it.
requiresApproval: true,
execute: async (input) => {
const { name, content } = input as { name: string; content: string };
// upload() queues the document for indexing and returns right away.
// The item becomes searchable once indexing finishes, a few seconds later.
const item = await this.env.AI_SEARCH.get(INSTANCE_ID).items.upload(
name,
content,
);
// This return value is passed to `revert` if the call is rolled back.
return { id: item.id, key: item.key, status: item.status };
},
// Compensating action for rollback: delete the document this call added.
revert: async (_input, result) => {
const { id } = result as { id: string };
await this.env.AI_SEARCH.get(INSTANCE_ID).items.delete(id);
},
},
};
}
}name() 的结果(aiSearch)会成为模型代码调用的全局对象,因此方法可通过 aiSearch.search() 与 aiSearch.saveDocument() 使用。
创建 src/server.ts。Agent 首次运行时会预配启用了混合搜索 的 AI Search 实例,然后使用连接器创建 Code Mode 运行时,并将其作为单个 codemode 工具暴露给模型。@callable() 方法允许你的客户端列出待批准项,以及批准、拒绝或回滚写入。
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
createCodemodeRuntime,
DynamicWorkerExecutor,
} from "@cloudflare/codemode";
import { callable, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { AISearchConnector } from "./ai-search-connector";
// Code Mode stores its durable state (execution log, pending approvals) in a
// facet exported from the Worker entry. The runtime requires this export.
export { CodemodeRuntime } from "@cloudflare/codemode";
const INSTANCE_ID = "knowledge-base";
// Seed content, so the agent has something to find on the first query.
const SEED_DOC = `# 快速入门
AI Search indexes your content so an agent can search it and add to it.`;
export class Chat extends AIChatAgent {
// In-memory guard, so the one-time setup runs once per instance lifetime.
ready = false;
// Create the AI Search instance with hybrid search enabled, then seed it.
// create() throws if the instance already exists, so the try/catch makes
// this safe to call on every message.
async ensureInstance() {
if (this.ready) return;
try {
// index_method with both vector and keyword enables hybrid search.
await this.env.AI_SEARCH.create({
id: INSTANCE_ID,
index_method: { vector: true, keyword: true },
});
// Queue the seed document for indexing so the first search has content.
await this.env.AI_SEARCH.get(INSTANCE_ID).items.upload(
"getting-started.md",
SEED_DOC,
);
} catch (err) {
// create() throws if the instance already exists, which is expected on
// every run after the first. Log anything else so real failures surface.
console.error("ensureInstance:", err);
}
this.ready = true;
}
// Build the Code Mode runtime for this request. The handle is cheap to
// create; the durable state lives in the Durable Object, not the handle.
#runtime() {
return createCodemodeRuntime({
ctx: this.ctx,
// Runs the model's generated code in an isolated Worker.
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [new AISearchConnector(this.ctx, this.env)],
});
}
// Runs on every chat message from the client.
async onChatMessage() {
await this.ensureInstance();
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You help maintain a knowledge base. Use codemode to search existing " +
"content and to save new documents. Search before you answer.",
messages: await convertToModelMessages(this.messages),
// The model sees one `codemode` tool and writes code that calls the connector.
tools: { codemode: this.#runtime().tool() },
// Cap the agent's tool-use loop.
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse();
}
// The methods below are called from your client to drive the approval flow.
// List the writes that are paused waiting for approval.
@callable()
async pendingApprovals() {
return this.#runtime().pending();
}
// Approve a paused write. The runtime resumes the program and runs it.
@callable()
async approveExecution(executionId) {
return this.#runtime().approve({ executionId });
}
// Decline a paused write. The execution ends without saving.
@callable()
async rejectExecution(executionId, seq) {
return this.#runtime().reject({ executionId, seq });
}
// Undo an applied write by running the connector's revert.
@callable()
async rollbackExecution(executionId) {
await this.#runtime().rollback({ executionId });
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
};import { AIChatAgent } from "@cloudflare/ai-chat";
import {
createCodemodeRuntime,
DynamicWorkerExecutor,
type CodemodeRuntimeHandle,
type PendingAction,
} from "@cloudflare/codemode";
import { callable, routeAgentRequest } from "agents";
import { createWorkersAI } from "workers-ai-provider";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { AISearchConnector } from "./ai-search-connector";
// Code Mode stores its durable state (execution log, pending approvals) in a
// facet exported from the Worker entry. The runtime requires this export.
export { CodemodeRuntime } from "@cloudflare/codemode";
const INSTANCE_ID = "knowledge-base";
// Seed content, so the agent has something to find on the first query.
const SEED_DOC = `# 快速入门
AI Search indexes your content so an agent can search it and add to it.`;
export class Chat extends AIChatAgent<Env> {
// In-memory guard, so the one-time setup runs once per instance lifetime.
private ready = false;
// Create the AI Search instance with hybrid search enabled, then seed it.
// create() throws if the instance already exists, so the try/catch makes
// this safe to call on every message.
private async ensureInstance() {
if (this.ready) return;
try {
// index_method with both vector and keyword enables hybrid search.
await this.env.AI_SEARCH.create({
id: INSTANCE_ID,
index_method: { vector: true, keyword: true },
});
// Queue the seed document for indexing so the first search has content.
await this.env.AI_SEARCH.get(INSTANCE_ID).items.upload(
"getting-started.md",
SEED_DOC,
);
} catch (err) {
// create() throws if the instance already exists, which is expected on
// every run after the first. Log anything else so real failures surface.
console.error("ensureInstance:", err);
}
this.ready = true;
}
// Build the Code Mode runtime for this request. The handle is cheap to
// create; the durable state lives in the Durable Object, not the handle.
#runtime(): CodemodeRuntimeHandle {
return createCodemodeRuntime({
ctx: this.ctx,
// Runs the model's generated code in an isolated Worker.
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [new AISearchConnector(this.ctx, this.env)],
});
}
// Runs on every chat message from the client.
async onChatMessage() {
await this.ensureInstance();
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You help maintain a knowledge base. Use codemode to search existing " +
"content and to save new documents. Search before you answer.",
messages: await convertToModelMessages(this.messages),
// The model sees one `codemode` tool and writes code that calls the connector.
tools: { codemode: this.#runtime().tool() },
// Cap the agent's tool-use loop.
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse();
}
// The methods below are called from your client to drive the approval flow.
// List the writes that are paused waiting for approval.
@callable()
async pendingApprovals(): Promise<PendingAction[]> {
return this.#runtime().pending();
}
// Approve a paused write. The runtime resumes the program and runs it.
@callable()
async approveExecution(executionId: string) {
return this.#runtime().approve({ executionId });
}
// Decline a paused write. The execution ends without saving.
@callable()
async rejectExecution(executionId: string, seq: number): Promise<boolean> {
return this.#runtime().reject({ executionId, seq });
}
// Undo an applied write by running the connector's revert.
@callable()
async rollbackExecution(executionId: string): Promise<void> {
await this.#runtime().rollback({ executionId });
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;生成类型:
npx wrangler types由于 AI Search 在远程运行,你需要部署 Worker 才能运行 agent。
使用你的 Cloudflare 账户登录:
npx wrangler login部署你的 Worker,使其可在 Internet 上访问:
npx wrangler deployWrangler 会打印你的 Worker URL,例如 https://kb-agent.<your-subdomain>.workers.dev。你将在下一步中使用它。
模型会收到一个 codemode 工具。当你要求它查找并保存内容时,它会编写调用连接器方法的短程序:
// The model writes this program. It runs inside the Code Mode sandbox.
async () => {
// The read-only search runs immediately.
const existing = await aiSearch.search({ query: "onboarding steps" });
// Only save when the knowledge base has no matching content yet.
if (existing.chunks.length === 0) {
// saveDocument requires approval, so this call pauses the program here.
await aiSearch.saveDocument({
name: "onboarding.md",
content: "# Onboarding\nStep 1: create an account.",
});
}
return existing.chunks.length;
};aiSearch.search() 会立即运行。当程序到达 aiSearch.saveDocument() 时,运行时会将该调用记录为待处理,并在上传执行前暂停执行。
你的客户端发送启动运行的聊天消息,然后通过 @callable() 方法驱动批准流程。以下脚本使用 Agents SDK 客户端 完成这两步。将其保存为 client.mjs,将 HOST 设为你已部署的 Worker,并用 node client.mjs 运行:
import { AgentClient } from "agents/client";
// Your deployed Worker, without the protocol.
const HOST = "kb-agent.<your-subdomain>.workers.dev";
const client = new AgentClient({ agent: "Chat", name: "default", host: HOST });
await client.ready;
// 1. Ask the agent to find and save content. It writes a Code Mode program;
// the saveDocument call pauses for approval instead of running.
client.send(
JSON.stringify({
type: "cf_agent_use_chat_request",
id: crypto.randomUUID(),
init: {
method: "POST",
body: JSON.stringify({
messages: [
{
id: crypto.randomUUID(),
role: "user",
parts: [
{
type: "text",
text: "Search for onboarding steps. If there is none, save a document named onboarding.md with a short onboarding guide.",
},
],
},
],
}),
},
}),
);
// Give the turn time to run and pause at saveDocument.
await new Promise((r) => setTimeout(r, 20_000));
// 2. List the writes waiting for approval.
const pending = await client.call("pendingApprovals");
console.log("Pending approvals:", pending);
// 3. Approve the first one. The runtime replays the program: completed calls
// return their recorded results, and the approved saveDocument runs.
if (pending.length > 0) {
await client.call("approveExecution", [pending[0].executionId]);
console.log("Approved", pending[0].executionId);
// To roll back later, delete the uploaded document:
// await client.call("rollbackExecution", [pending[0].executionId]);
}
client.close();import { AgentClient } from "agents/client";
// Your deployed Worker, without the protocol.
const HOST = "kb-agent.<your-subdomain>.workers.dev";
const client = new AgentClient({ agent: "Chat", name: "default", host: HOST });
await client.ready;
// 1. Ask the agent to find and save content. It writes a Code Mode program;
// the saveDocument call pauses for approval instead of running.
client.send(
JSON.stringify({
type: "cf_agent_use_chat_request",
id: crypto.randomUUID(),
init: {
method: "POST",
body: JSON.stringify({
messages: [
{
id: crypto.randomUUID(),
role: "user",
parts: [
{
type: "text",
text: "Search for onboarding steps. If there is none, save a document named onboarding.md with a short onboarding guide.",
},
],
},
],
}),
},
}),
);
// Give the turn time to run and pause at saveDocument.
await new Promise((r) => setTimeout(r, 20_000));
// 2. List the writes waiting for approval.
const pending = await client.call("pendingApprovals");
console.log("Pending approvals:", pending);
// 3. Approve the first one. The runtime replays the program: completed calls
// return their recorded results, and the approved saveDocument runs.
if (pending.length > 0) {
await client.call("approveExecution", [pending[0].executionId]);
console.log("Approved", pending[0].executionId);
// To roll back later, delete the uploaded document:
// await client.call("rollbackExecution", [pending[0].executionId]);
}
client.close();来自 pendingApprovals() 的每个 PendingAction 都包含 executionId、seq 编号以及方法与参数,因此你可以在决定前向用户展示待处理文档。批准相关方法的行为如下:
approveExecution(executionId)会重放程序并运行已批准的saveDocument。文档会排队等待索引,几秒后即可搜索。rejectExecution(executionId, seq)会在不保存的情况下结束执行。rollbackExecution(executionId)通过运行连接器的revert撤销已应用的写入,从而删除已上传的文档。
你的 agent 现在可以:
- 使用只读工具搜索知识库。
- 通过会暂停等待人工批准的写入工具提议新文档。
- 在批准后恢复同一程序,而无需重新运行已完成的工作。
- 通过删除已索引文档回滚已批准的保存。