跳转到内容
搜索文档

人在环路的知识库更新

最后更新 查看 MarkdownAgent 设置

本教程将构建一个可搜索知识库并向其中添加内容的 agent,且每次写入都需经人工批准。让 agent 修改数据存在风险,因此每次保存会在执行前暂停等待批准,你也可以回滚错误的保存。

你将构建的内容

一个 Cloudflare Agent,可搜索 AI Search 实例、提议要索引的新文档、等待你批准每一项,并能够撤销已批准的保存。

前提条件

  1. 注册 Cloudflare 账户
  2. 安装 Node.js

Node.js 版本管理器

使用 Voltanvm 等 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 方法。

1. 创建 Worker 项目

使用 create-cloudflare CLI(C3)创建新的 Worker 项目。C3 是帮助你设置并将新应用部署到 Cloudflare 的命令行工具。

运行以下命令创建名为 kb-agent 的新项目:

npm 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

安装依赖。aizod 版本固定在 Agents SDK 作为 peer dependencies 所期望的范围内:

npm i @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@4

2. 配置 Wrangler

将你的 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 devAIChatAgent 会将消息持久化到 SQLite,因此其类必须列在 new_sqlite_classes 中。

3. 创建 AI Search 连接器

创建 src/ai-search-connector.ts。该连接器直接调用 AI Search 绑定,因此请求保持在进程内,无需公开端点。

为模型提供只读的 search 方法与 saveDocument 方法。由于 saveDocument 会写入内容,请将其标记为 requiresApproval,并添加 revert 以便运行时可以回滚。

src/ai-search-connector.jsjs
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);
				},
			},
		};
	}
}
src/ai-search-connector.tsts
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() 使用。

4. 构建 agent

创建 src/server.ts。Agent 首次运行时会预配启用了混合搜索 的 AI Search 实例,然后使用连接器创建 Code Mode 运行时,并将其作为单个 codemode 工具暴露给模型。@callable() 方法允许你的客户端列出待批准项,以及批准、拒绝或回滚写入。

src/server.jsjs
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 })
		);
	},
};
src/server.tsts
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

5. 部署

由于 AI Search 在远程运行,你需要部署 Worker 才能运行 agent。

使用你的 Cloudflare 账户登录:

npx wrangler login

部署你的 Worker,使其可在 Internet 上访问:

npx wrangler deploy

Wrangler 会打印你的 Worker URL,例如 https://kb-agent.<your-subdomain>.workers.dev。你将在下一步中使用它。

6. 试用批准与回滚流程

模型会收到一个 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 运行:

client.mjsjs
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();
client.mjsts
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 都包含 executionIdseq 编号以及方法与参数,因此你可以在决定前向用户展示待处理文档。批准相关方法的行为如下:

  • approveExecution(executionId) 会重放程序并运行已批准的 saveDocument。文档会排队等待索引,几秒后即可搜索。
  • rejectExecution(executionId, seq) 会在不保存的情况下结束执行。
  • rollbackExecution(executionId) 通过运行连接器的 revert 撤销已应用的写入,从而删除已上传的文档。

你构建了什么

你的 agent 现在可以:

  • 使用只读工具搜索知识库。
  • 通过会暂停等待人工批准的写入工具提议新文档。
  • 在批准后恢复同一程序,而无需重新运行已完成的工作。
  • 通过删除已索引文档回滚已批准的保存。

后续步骤

混合搜索

以可配置的融合方式组合向量搜索与关键词搜索。

这篇文档对您有帮助吗?