这个教程把一个带 RAG 的客服 Agent 真正部署到 Cloudflare:Worker 接入、Durable Object 保存会话、Vectorize 检索、AI Gateway 代理模型、Workflows 跑长任务。
最终形态
https://support-agent.<account>.workers.dev/agents/support-agent/user_123- 同一
user_123命中同一个 Durable Object; - 对话状态持久化;
/ingest导入文档到 Vectorize;- 长任务交给 Workflow;
- 模型调用经 AI Gateway,能看成本、缓存与错误。
前置
- Cloudflare 账号
- Node.js 20+
- Wrangler 4+
- OpenAI(或其他)API Key
npm create cloudflare@latest -- support-agent
cd support-agent
npm install agents
npx wrangler loginStep 1:配置
wrangler.jsonc:
{
"name": "support-agent",
"main": "src/index.ts",
"compatibility_date": "2026-01-01",
"observability": { "enabled": true },
"durable_objects": {
"bindings": [{ "name": "SupportAgent", "class_name": "SupportAgent" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SupportAgent"] }],
"ai": { "binding": "AI" },
"vectorize": [
{ "binding": "VECTORIZE", "index_name": "support-docs" }
],
"r2_buckets": [
{ "binding": "BUCKET", "bucket_name": "support-files" }
],
"workflows": [
{ "name": "ESCALATION_WORKFLOW", "class_name": "EscalationWorkflow" }
]
}创建资源:
npx wrangler vectorize create support-docs --dimensions=1024 --metric=cosine
npx wrangler r2 bucket create support-filesbge-m3 的稠密向量是 1024 维,索引维度必须一致。换模型时重建索引。
Step 2:Agent 类
src/index.ts:
import { Agent, routeAgentRequest } from "agents";
type Msg = { role: "user" | "assistant"; content: string };
type State = { messages: Msg[]; userId: string };
const SYSTEM = `你是 ACME 客服助理。
只根据检索到的政策和订单信息回答。
每个事实后标注来源编号,如 [1]。
资料不足时说「我需要转专员确认」,不要编造。
不承诺政策之外的赔付。`;
export class SupportAgent extends Agent<Env, State> {
initialState: State = { messages: [], userId: "" };
async onRequest(request: Request): Promise<Response> {
const { message, userId } = await request.json<{ message: string; userId: string }>();
if (!this.state.userId) this.setState({ ...this.state, userId });
const sources = await this.search(message);
const context = sources
.map((s, i) => `[${i + 1}] ${s.title} · p.${s.page}\n${s.text}`)
.join("\n\n");
const messages = [
{ role: "system", content: SYSTEM },
...this.state.messages.slice(-6),
{
role: "user",
content: `资料:\n${context || "(无)"}\n\n问题:${message}`,
},
];
const reply = await this.chat(messages);
this.setState({
...this.state,
messages: [...this.state.messages, { role: "user", content: message }, { role: "assistant", content: reply }],
});
return Response.json({ reply, sources });
}
async search(query: string) {
const emb = await this.env.AI.run("@cf/baai/bge-m3", { text: [query] });
const result = await this.env.VECTORIZE.query(emb.data[0], {
topK: 6,
returnMetadata: "all",
});
return result.matches.map((m) => ({
id: m.id,
score: m.score,
title: String(m.metadata?.title ?? ""),
page: Number(m.metadata?.page ?? 0),
text: String(m.metadata?.text ?? ""),
}));
}
async chat(messages: unknown[]): Promise<string> {
const url = `https://gateway.ai.cloudflare.com/v1/${this.env.CF_ACCOUNT_ID}/${this.env.AI_GATEWAY}/openai/chat/completions`;
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: this.env.MODEL ?? "gpt-4.1-mini",
messages,
temperature: 0,
max_tokens: 800,
}),
});
if (!res.ok) throw new Error(`model error: ${res.status} ${await res.text()}`);
const data = await res.json<{ choices: Array<{ message: { content: string } }> }>();
return data.choices[0].message.content;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/ingest" && request.method === "POST") {
return ingest(request, env);
}
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};
async function ingest(request: Request, env: Env): Promise<Response> {
const token = request.headers.get("authorization")?.replace("Bearer ", "");
if (token !== env.INGEST_TOKEN) return new Response("Forbidden", { status: 403 });
const { docs } = await request.json<{ docs: Array<{ title: string; page: number; text: string }> }>();
if (!Array.isArray(docs) || docs.length === 0) return Response.json({ ok: false, error: "empty" }, { status: 400 });
const vectors = await env.AI.run("@cf/baai/bge-m3", { text: docs.map((d) => d.text) });
await env.VECTORIZE.upsert(
docs.map((d, i) => ({
id: `${d.title}-${d.page}-${i}`,
values: vectors.data[i],
metadata: { title: d.title, page: d.page, text: d.text.slice(0, 1200) },
}))
);
return Response.json({ ok: true, count: docs.length });
}三个关键点:
- 状态在 Durable Object:同一用户串行、持久化;
- 检索结果带标题与页码:能引用;
- 模型经 AI Gateway:统一看成本、缓存、错误。
Step 3:交互示例
curl -X POST "https://support-agent.<account>.workers.dev/agents/support-agent/user_123" \
-H "content-type: application/json" \
-d '{"message":"A1024 签收 9 天了还能退吗?","userId":"user_123"}'返回:
{
"reply": "超过 7 天的退款需要人工审核。[1]",
"sources": [{ "title": "退款政策", "page": 2, "score": 0.83 }]
}Step 4:导入文档
分块后调用 /ingest:
curl -X POST "https://support-agent.<account>.workers.dev/ingest" \
-H "authorization: Bearer $INGEST_TOKEN" \
-H "content-type: application/json" \
-d '{"docs":[{"title":"退款政策","page":2,"text":"退款需在签收后 7 天内申请……"}]}'大批量导入时用 Queue 分批,避免单次请求超时。每批 50–100 个片段,失败重试,记录进度。
Step 5:Workflows 跑长任务
客服升级、批量分析、报告生成可能超过请求时限:
src/workflow.ts:
import { WorkflowEntrypoint, WorkflowStep } from "cloudflare:workers";
type Params = { ticketId: string; reason: string };
export class EscalationWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const brief = await step.do("build-brief", async () => {
return await buildTicketBrief(event.payload.ticketId, this.env);
});
const decision = await step.do(
"manager-review",
{ retries: { limit: 3, delay: "30 seconds", backoff: "exponential" } },
async () => await requestManagerReview(brief, this.env)
);
await step.do("notify", async () => {
await notifyCustomer(event.payload.ticketId, decision, this.env);
});
return { decision };
}
}配置里已经绑定 ESCALATION_WORKFLOW。触发:
await this.env.ESCALATION_WORKFLOW.create({ params: { ticketId, reason } });Workflows 提供每步持久化、重试、可等待人工,适合跨分钟到跨天的流程。
Step 6:AI Gateway
在 Cloudflare Dashboard 创建 Gateway,记录 account id 与 gateway 名,配置:
- 缓存:相同请求命中缓存;
- 限流:按用户/模型;
- 回退:主模型失败切备用;
- 日志:token、成本、错误;
- 密钥:供应商 key 存在 Worker secret,或 Gateway 托管。
Worker 里:
npx wrangler secret put OPENAI_API_KEY
npx wrangler secret put INGEST_TOKEN
npx wrangler secret put CF_ACCOUNT_ID
npx wrangler secret put AI_GATEWAY不要把 key 写进 wrangler.jsonc 或代码。
Step 7:部署
npm run dev # 本地
npx wrangler deploy # 线上本地测试 Durable Object:
curl -X POST "http://localhost:8787/agents/support-agent/user_1" \
-H "content-type: application/json" \
-d '{"message":"退款多久到账?","userId":"user_1"}'Step 8:CI/CD
.github/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test --if-present
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: deploy把 CLOUDFLARE_API_TOKEN 配成最小权限(Workers Scripts、Vectorize、R2、Workflows)。不要用全局 key。
限制与坑
| 限制 | 应对 |
|---|---|
| 单请求时间/CPU 有限 | 长任务用 Workflows/Queues |
| Durable Object 单线程 | 按用户/会话分片 |
| 向量索引维度固定 | 换模型必须重建 |
| 边缘位置 | 用 locationHint,接受一致性权衡 |
| 冷启动 | 减少依赖、预热 |
| 成本按请求/存储计 | 预算与用量告警 |
成本估算
请求:Worker 请求数 + Durable Object 请求数
存储:DO SQLite + R2 + Vectorize 向量数
模型:AI Gateway 记录的 token先按「每千次对话」估算,再乘峰值。不要只算模型 token,DO 与向量查询也会计费。
验收清单
- 同一用户命中同一 DO,状态可恢复
-
/ingest有鉴权,能分批导入 - 检索结果带标题、页码、分数
- 回答带引用,资料不足会说不知道
- 长任务走 Workflow
- 模型经 AI Gateway,有成本与错误日志
- secrets 不入库、不进代码
- CI 使用最小权限 token
- 有预算与告警
常见错误
实例随时回收。状态必须放 Durable Object 或外部存储。
Worker 会超时。超过 30 秒的流程改异步/Workflow。
写入成功但查询报错,或结果全错。先确认维度。
任何人都能写你的知识库。必须鉴权 + 限流。
前端只拿短期会话 token,模型 key 永远留在服务端。
小结
- Cloudflare 的组合:Worker 接入、DO 状态、Vectorize 检索、AI Gateway 模型、Workflows 长任务。
- 状态与会话按用户分片,长任务异步化。
- 检索要带来源,写入要鉴权。
- 成本要按请求、存储、模型三部分估算。
下一篇做安全:8 个可复现的攻击与防御实验。