介绍
应用通常按照直接写在代码中的规则运行。人工智能(AI)推理增加了另一类操作:应用向经过训练的模型发送输入,模型生成结果。发送给模型的指令和上下文称为提示词。由于模型生成的措辞可能因请求而异,因此可靠的应用会控制输入并检查结果,而不是要求模型每次都返回完全相同的句子。
Cloudflare Workers AI 允许 Worker 通过 Cloudflare 平台运行受支持的 AI 模型。Worker 是部署在 Cloudflare 网络上、负责响应请求的应用代码。AI 绑定是一种经过配置的连接,使代码可以通过 env.AI 使用 Workers AI;这样就不需要在项目中保存单独的供应商 API 密钥。
在本实验中,支持应用需要在客服人员打开工单完整描述前,先生成一段简短摘要。你将配置 AI 绑定,实现 POST /summaries 端点,在推理前拒绝不合适的输入,在本地测试同一个 Worker,部署它,并在 Cloudflare Dashboard 中检查实际的 Worker 和 AI 活动。本实验使用 @cf/meta/llama-3.3-70b-instruct-fp8-fast,这是一个由 Cloudflare 托管、可通过标准 Workers AI 免费配额使用的模型。评分不关注返回文字的具体措辞,而关注应用契约是否正确。
开始本课程前,请完成将 LabEx 连接到你的 Cloudflare 账户。该实验会介绍 LabEx VM 终端、设备授权、账户确认以及保存实际账户 ID。你还应该了解小型 JavaScript Worker 如何处理 HTTP 请求。本实验不要求具备机器学习知识。
目前,Workers AI 为 Workers Free 账户提供共享的每日 10,000 个 Neurons 配额。Neurons 是 Cloudflare 用于表示模型计算量的单位。本实验会限制提示词和输出的大小,不需要付费计划,但同一账户上的其他活动也会使用这份配额。开始前,请查看最新的 Llama 3.3 模型页面和 Workers AI 定价。如果当天配额已经用完,必须等到限制重置后才能继续推理;不要通过重复调用来绕过限制。本地 Workers AI 开发同样使用云端模型并计入配额,不是离线模拟。
初始化过程会在 /home/labex/project/ticket-summary 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0。同时还会提供使用固定 AI 响应的确定性测试,不会实际调用模型。初始化过程中不会登录、修改计划、部署或运行推理。请保持此 VM 开启,直到删除临时 Worker 并确认已退出登录。
授权 VM 并选择账户
在此步骤中,你会将这个全新的 LabEx VM 连接到 Cloudflare 学习账户,并创建唯一的 Worker 配置。在 Dashboard 中打开的浏览器会话不会自动授权 VM 中的终端命令。
进入准备好的项目,并确认固定的 Wrangler 版本:
cd /home/labex/project/ticket-summary
npx wrangler --version
应显示 4.132.0。使用本实验所需的最小权限开始设备授权。workers_scripts:write 用于部署、读取和删除临时 Worker。ai:write 允许 Worker 调用 Workers AI。删除 Worker 时,Wrangler 4.132.0 还会检查 KV 绑定引用,因此需要 workers_kv:write 才能完成清理检查,即使本实验不会创建 KV 命名空间。账户和用户的读取权限用于确认目标账户。
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write ai:write
在浏览器中打开终端显示的链接,输入当前设备代码,检查权限,然后选择你的学习账户。由于 Wrangler 必须在浏览器流程结束后继续工作,界面中可能还会显示 Background Access。确认账户和权限列表与本实验一致后再授权,然后返回终端并等待成功提示。
npx wrangler whoami --json
确认 loggedIn: true,然后读取准备使用的账户的 name 和 id,即使列表中只有一个账户也要执行此操作。名称有助于避免选错账户;ID 是 Wrangler 保存到配置中的稳定值。
生成唯一的 Worker 名称。openssl rand -hex 6 会生成 12 个随机十六进制字符,$(...) 会将这些字符插入 Shell 变量。
RUN="labex-c07-a01-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
将选定的账户 ID 复制到下面的配置中,替换 YOUR_ACCOUNT_ID。Here-document 会把两个 JSON 标记之间的内容写入 wrangler.jsonc。未加引号的标记允许 $RUN 展开,而反斜杠会使 $schema 键保持字面值。
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "YOUR_ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-16",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"ai": {
"binding": "AI",
"remote": true
}
}
JSON
compatibility_date 固定了本实验测试所使用的运行时行为。observability 会保留调用记录和应用日志,供后面的 Dashboard 检查使用。写入该文件不会部署 Worker,也不会调用模型。
检查 Workers AI 绑定
在此步骤中,你会根据配置生成 Worker 环境的类型描述,并将绑定名称连接到下一步要编写的代码。
绑定是由 Workers 运行时提供的、具有名称的能力。wrangler.jsonc 中的 AI 表示 Worker 将通过 env.AI 运行模型。源代码中没有 API 令牌:Cloudflare 会将已部署的 Worker 连接到选定账户。remote: true 在运行 wrangler dev 时很重要,因为即使请求处理程序在此 VM 中运行,模型推理也始终发生在 Cloudflare 上。
根据项目配置生成环境类型描述:
npx wrangler types
Wrangler 会创建 worker-configuration.d.ts。不要阅读整个文件,而是搜索生成的 Env 条目:
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts
输出应包含类似下面的 AI 绑定:
interface __BaseEnv_Env {
AI: Ai;
}
Wrangler 会将生成的绑定放在基础接口中,然后通过继承将其扩展为 Env。AI: Ai 这一行是有用的一致性检查:如果只修改配置中的绑定名称而忘记更新代码,部署可能成功,但运行时会失败。每次绑定发生变化后都要重新生成类型。稍后进行完整的部署试运行时,Wrangler 会同时验证配置和 Worker 构建结果。
构建受限的摘要端点
在此步骤中,你会实现请求边界和模型调用。语言模型擅长生成简洁说明,但不应该由它决定任意请求是否可以安全处理。普通应用代码必须在推理前拒绝错误的内容类型、格式错误的 JSON、缺少详细信息以及过大的输入。
该端点会向模型发送两条消息。系统消息定义模型的角色和响应限制。用户消息包含模拟工单。模型会读取并生成令牌,令牌是较小的文本片段,可能是一个单词、单词的一部分或标点符号。max_tokens 限制生成的输出,而应用则单独限制传入字符数。这是两种不同的控制:前者限制模型可以生成的内容,后者限制你发送给模型的内容。temperature 控制模型生成结果的变化程度;这里使用较低的值,倾向于生成稳定的摘要,但不保证措辞完全一致。
创建 Worker 入口文件:
cat > src/index.js <<'JS'
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const MAX_DETAILS = 2000;
function json(data, status = 200) {
return Response.json(data, { status });
}
async function readTicket(request) {
const contentType = request.headers.get("content-type") || "";
if (!contentType.toLowerCase().includes("application/json")) {
return { error: json({ error: "json_required" }, 415) };
}
const raw = await request.text();
if (raw.length > 4096) {
return { error: json({ error: "ticket_too_large" }, 413) };
}
let body;
try {
body = JSON.parse(raw);
} catch {
return { error: json({ error: "invalid_json" }, 400) };
}
const subject = typeof body?.subject === "string" ? body.subject.trim() : "";
const details = typeof body?.details === "string" ? body.details.trim() : "";
if (!details) {
return { error: json({ error: "invalid_ticket" }, 400) };
}
if (subject.length > 120 || details.length > MAX_DETAILS) {
return { error: json({ error: "ticket_too_large" }, 413) };
}
return { ticket: { subject, details } };
}
async function summarize(request, env) {
const requestId = crypto.randomUUID();
const parsed = await readTicket(request);
if (parsed.error) return parsed.error;
try {
const result = await env.AI.run(MODEL, {
messages: [
{
role: "system",
content: "Summarize this support ticket in one plain sentence. Do not invent facts."
},
{
role: "user",
content: `Subject: ${parsed.ticket.subject || "(none)"}\nDetails: ${parsed.ticket.details}`
}
],
max_tokens: 120,
temperature: 0.2
});
const summary = result.response?.trim();
if (!summary) throw new Error("empty model response");
console.log(JSON.stringify({
event: "ticket_summarized",
requestId,
model: MODEL,
inputCharacters: parsed.ticket.details.length,
totalTokens: result.usage?.total_tokens ?? null
}));
return json({ summary, model: MODEL, requestId });
} catch (error) {
console.error(JSON.stringify({
event: "ticket_summary_failed",
requestId,
model: MODEL,
reason: error instanceof Error ? error.message : "unknown"
}));
return json({ error: "model_unavailable", requestId }, 502);
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return json({ status: "ok" });
}
if (request.method === "POST" && url.pathname === "/summaries") {
return summarize(request, env);
}
return json({ error: "not_found" }, 404);
}
};
JS
每个请求都会获得随机的请求 ID,该 ID 同时出现在响应和日志中,因此无需记录工单内容,也能追踪单个请求。代码会记录该 ID、模型选择和计数,但不会记录工单文本。这样,后续的可观测性——帮助你了解 Worker 执行情况的记录——就能发挥作用,同时不会将客户内容复制到监控数据中。代码还会验证此特定模型返回的 response 字符串,而不是假定所有 Workers AI 模型都返回相同的对象。
运行预先提供的确定性测试。测试会使用一个小型固定数据替换 env.AI,因此不会消耗模型用量:
node --test test/worker.test.mjs
应通过 4 个测试。然后让 Wrangler 构建 Worker,但不要部署:
npx wrangler deploy --dry-run
这些测试使用受控的模型数据验证输入和输出契约。试运行则验证 Wrangler 能否正确打包实际的 Worker。两者都不能证明模型当前可用,也不能证明账户仍有每日免费配额;下一步会通过一次真实请求进行验证。
运行一次本地推理
在此步骤中,你会从 VM 运行请求处理程序,同时让其 AI 绑定调用真实的 Cloudflare 托管模型。这称为本地开发,但只有 Worker 进程在本地运行,推理仍然是远程执行并计量的。
在后台以 8787 端口启动 Wrangler。> 将日志保存到文件,2>&1 将错误发送到同一个文件,& 让终端立即返回命令行提示符,同时服务器继续运行。保存 $! 可以记录进程 ID,方便后续清理。
npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
等待健康检查路由响应:
for attempt in $(seq 1 30); do
if curl --silent --fail http://127.0.0.1:8787/health; then
break
fi
sleep 1
done
健康检查响应应为 {"status":"ok"},并且不会调用模型。现在发送一条较小的模拟工单。--data 会使请求成为 POST 请求,而请求头会告知 Worker 按 JSON 解析内容。
curl --silent --show-error http://127.0.0.1:8787/summaries \
--header 'Content-Type: application/json' \
--data '{"subject":"Invoice upload fails","details":"After signing in, the customer selects a PDF invoice. The upload stops before completion and no confirmation appears."}' | jq
应返回非空的 summary、准确的模型 ID 以及本次运行专属的 requestId。返回句子可能与下面的示例不同:
{
"summary": "The customer cannot complete a PDF invoice upload after signing in.",
"model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"requestId": "..."
}
验证无效输入会由普通应用代码在推理前拒绝:
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
http://127.0.0.1:8787/summaries \
--header 'Content-Type: application/json' \
--data '{"details":""}'
应返回 {"error":"invalid_ticket"} 和 HTTP 400。应用不会将此请求发送给模型。如果有效请求返回 model_unavailable,请检查 .labex/dev.log;免费配额耗尽、模型容量不足或授权错误,都不能证明端点契约已经通过。
部署并检查 AI Worker
在此步骤中,你会停止本地进程,将相同的代码部署到 Cloudflare,并把命令行证据与 Dashboard 中可见的状态对应起来。
只停止已保存的开发进程,并等待它退出:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
部署 Worker:
npx wrangler deploy
Wrangler 会输出公开的 workers.dev URL。保存这个确切的 URL,并替换下面的示例值:
WORKER_URL="https://YOUR_WORKER_URL"
向已部署的端点发送一条新的模拟工单:
curl --silent --show-error "$WORKER_URL/summaries" \
--header 'Content-Type: application/json' \
--data '{"subject":"Password reset loop","details":"The customer opens the reset email, chooses a new password, and returns to the sign-in page, but the old password remains active."}' | jq
生成的句子可以不同,但 model 必须标识 Llama 3.3,并且必须存在 requestId。这证明公开的 Worker 已访问其配置的 AI 绑定。
打开 Cloudflare Dashboard,进入 Workers & Pages → Overview → 你的 labex-c07-a01-... Worker → Settings → Bindings。找到 AI Workers AI 绑定。这里显示的是 wrangler.jsonc 与代码中 env.AI 之间的实际连接。

示例显示绑定名称为 AI,与 env.AI 使用的名称一致。你的临时 Worker 名称会有所不同。
接下来,为同一个 Worker 打开 Observability → Logs。找到最近一次成功调用,并展开结构化的 ticket_summarized 日志。将日志中的请求 ID 与已部署端点的响应进行匹配。日志应显示模型和计数,但不应显示工单文本。如果保存的日志尚未到达,请使用 Real-time logs,再发送一条较小的模拟请求,然后检查这次调用。

概览首先确认请求已无错误地到达 Worker。打开其中一个请求,可以看到结构化的应用事件:

注意,日志包含模型、令牌计数和请求 ID 等运行信息,但不包含支持工单的主题或详细信息。这就是你编写的日志代码所建立的隐私边界。
最后,从 Developer Platform 导航中打开 Workers AI,检查用量视图。查找与本次受限测试关联的近期模型活动或 Neuron 用量。用量数据可能比请求晚到;如果图表立即为空,这并不能说明有问题,也不应通过重复生成推理请求来“修复”。

这里的 20.32/10k 表示本次验收运行只消耗了该账户每日 Free 配额的一小部分。你的总量还包括学习账户上的其他 Workers AI 活动,因此不会与截图一致。
本实验中的 Dashboard 截图展示的是一次临时验收运行的示例值。你的 Worker 名称、请求 ID、时间戳、令牌计数和用量总数都会不同。
删除 Worker 并退出登录
在此步骤中,你会删除临时云应用,然后撤销此 VM 上的 Wrangler 会话。删除 Worker 会停止其公开端点,但不会更改 Workers 计划,也不会删除账户级用量记录。
删除 wrangler.jsonc 中指定名称的 Worker:
npx wrangler delete
当 Wrangler 显示本实验生成的唯一名称时,确认删除。不要删除其他应用。在 Dashboard 中返回 Workers & Pages → Overview,确认准确的 labex-c07-a01-... Worker 已不存在。脚本删除后,历史日志或用量记录仍可能保留。
Wrangler 会在完成前检查是否有其他 Worker 依赖此 Worker。这就是前面登录时需要 KV 清理权限的原因,即使你的应用没有使用 KV;删除成功后,应在没有身份验证错误的情况下返回命令行提示符。
在 VM 仍处于授权状态时运行删除检查:
python3 .labex/verify.py deleted
只有在该命令报告 PASS: deleted 后,才删除保存的授权:
npx wrangler logout
npx wrangler whoami --json
最终输出必须报告 loggedIn: false。网络错误不能证明已经退出登录。
总结
你通过 AI 绑定将 Worker 连接到 Cloudflare 托管的模型,限制了输入和生成的输出,并在消耗模型用量前测试了确定性行为。随后,你在本地和部署后分别执行了真实推理,并将响应与 Dashboard 中的绑定、日志和用量证据对应起来。最后,你安全地删除了临时 Worker,并让全新的 VM 退出登录。



