介绍
AI 模型可以用文字回答,但应用有时需要先获取结构化信息,才能完成有用的工作。工具调用允许应用描述某项操作,例如查询一个目录项,并让模型提出工具名称和参数。模型不会因此获得执行任意代码的权限,而是生成数据,Worker 必须将这些数据视为不可信输入。
本实验将构建 POST /catalog-help。Cloudflare 托管的 Llama 模型会接收类似「SKU KB-101 是否有库存?」的简短问题,并可以提议使用只读工具 lookup_catalog_item。Worker 只接受一个已知工具,验证参数对象是否严格为 { sku },验证通过后才读取一个很小的模拟目录。未知工具、缺少字段或包含额外字段、格式错误的 SKU,以及多个工具调用都不会到达执行器。
你将使用传统函数调用,让安全边界清晰可见:推理负责提出调用,验证负责作出决定,应用代码负责执行。返回结果限制为少量公开 fixture 字段。本实验不会授予写入权限,不会调用外部服务,也不会让模型选择可执行代码。
这是本课程的第五个实验。如果你是直接进入本实验的,请先完成将 LabEx 连接到你的 Cloudflare 账户,了解如何使用 VM 终端、授权 Wrangler、确认学习账户并配置账户 ID。
本实验选择的 @cf/meta/llama-3.3-70b-instruct-fp8-fast 模型支持函数调用,并且可以通过标准 Workers AI 配额使用。Workers Free 当前每天包含 10,000 Neurons。本实验只会在本地发送一次简短的实时请求,并在部署后再发送一次,因此在免费配额仍可用时不需要 Workers Paid。即使在本地运行推理,请求仍会到达 Cloudflare 并消耗账户用量;如果模型或配额不可用,请停止操作,不要反复重试。
安装过程会在 /home/labex/project/tool-call-guard 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0,同时提供确定性的模型 fixture 和独立检查。安装过程不会授权 Wrangler、创建 Worker 源代码、调用模型、部署 Worker 或创建云资源。
授权 VM 并配置工具调用 Worker
在此步骤中,你将授权这台全新的 VM,并配置一个临时 Worker。你的浏览器可能已经登录 Cloudflare Dashboard,但新 VM 中的 Wrangler 仍需要单独完成有限授权。
进入准备好的项目目录,并确认固定的 CLI 版本:
cd /home/labex/project/tool-call-guard
npx wrangler --version
应显示 4.132.0。只请求 AI Worker 所需的权限。Wrangler 4.132.0 在删除资源时还会检查 KV 依赖,因此清理流程需要 KV 权限,即使本实验不会创建 KV 数据。
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write ai:write
打开显示的链接,输入当前代码,检查账户和权限,然后授权你的学习账户。不要将代码、密码或令牌发送给任何其他人。接着检查结构化的身份信息:
npx wrangler whoami --json
确认输出中包含 loggedIn: true,然后生成一个唯一名称,以便清理操作只定位本实验创建的 Worker:
RUN="labex-c07-a05-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
下面的 here-document 会写入普通的 JSON 配置。将 YOUR_ACCOUNT_ID 替换为目标学习账户的实际 ID:
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",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"ai": { "binding": "AI", "remote": true }
}
JSON
AI 绑定为代码提供安全的 env.AI 句柄,无需将模型 API 密钥写入源代码。remote: true 表示本地开发时仍会调用账户关联的模型,而不是离线模拟推理。
了解工具边界
在此步骤中,你将把平台绑定连接到应用必须强制执行的边界。
根据 wrangler.jsonc 生成环境类型,然后查看生成的接口:
npx wrangler types
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts
查找 AI: Ai。工具描述是发送给模型的结构化数据,包括名称、用自然语言描述的用途,以及可能参数的模式。它可以帮助模型提出调用,但它不是授权,也不是可执行代码。
本实验只允许一个只读工具 lookup_catalog_item,该工具接收一个参数,例如 { "sku": "KB-101" }。推理完成后,应用要求模型只提出一个调用,并且工具名称必须完全匹配允许的名称。随后,应用要求 arguments 是一个只包含 sku 的对象,检查该实验使用的简短公开 SKU 格式,最后只将验证后的值传递给应用固定的只读函数。
查看提供的拒绝 fixture:
grep -nE 'unknown tools|missing, extra|zero or multiple' test/worker.test.mjs
这些 fixture 是刻意构造的虚假模型响应。它们可以在不消耗 Neurons、也不依赖实时模型生成格式错误调用的情况下,验证安全边界。
构建经过验证的目录工具
在此步骤中,你将向模型描述工具,验证模型提出的调用,并且只执行应用的只读目录函数。
创建 Worker 入口文件:
cat > src/index.js <<'JS'
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const TOOL_NAME = "lookup_catalog_item";
const MAX_QUESTION = 240;
const SKU_PATTERN = /^[A-Z]{2}-[0-9]{3}$/;
const CATALOG = [
{ sku: "KB-101", name: "Compact Keyboard", priceUsd: 49, inStock: true },
{ sku: "MS-205", name: "Wireless Mouse", priceUsd: 29, inStock: false }
];
const TOOLS = [{
name: TOOL_NAME,
description: "Read one public catalog item by the exact SKU stated in the user's question.",
parameters: {
type: "object",
properties: { sku: { type: "string", description: "An exact catalog SKU such as KB-101" } },
required: ["sku"]
}
}];
function json(data, status = 200) { return Response.json(data, { status }); }
async function readQuestion(request) {
if (!(request.headers.get("content-type") || "").toLowerCase().includes("application/json")) {
return { error: json({ error: "json_required" }, 415) };
}
let body;
try { body = await request.json(); } catch { return { error: json({ error: "invalid_json" }, 400) }; }
const question = typeof body?.question === "string" ? body.question.trim() : "";
if (!question) return { error: json({ error: "invalid_question" }, 400) };
if (question.length > MAX_QUESTION) return { error: json({ error: "question_too_large" }, 413) };
return { question };
}
export function validateToolSelection(toolCalls) {
if (!Array.isArray(toolCalls) || toolCalls.length !== 1) throw new Error("exactly one tool call is required");
const call = toolCalls[0];
if (!call || call.name !== TOOL_NAME) throw new Error("unknown tool");
const args = call.arguments;
if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("arguments must be an object");
if (Object.keys(args).length !== 1 || !Object.hasOwn(args, "sku")) throw new Error("unexpected arguments");
if (typeof args.sku !== "string" || !SKU_PATTERN.test(args.sku)) throw new Error("invalid sku");
return { name: TOOL_NAME, arguments: { sku: args.sku } };
}
export function executeCatalogTool(argumentsValue) {
const item = CATALOG.find((candidate) => candidate.sku === argumentsValue.sku);
return item ? { ...item, found: true } : { sku: argumentsValue.sku, found: false };
}
export async function handleCatalogHelp(request, env, execute = executeCatalogTool) {
const parsed = await readQuestion(request);
if (parsed.error) return parsed.error;
const requestId = crypto.randomUUID();
let inference;
try {
inference = await env.AI.run(MODEL, {
messages: [
{ role: "system", content: "Use exactly one provided read-only tool. Copy only the exact SKU from the user. Do not answer from memory." },
{ role: "user", content: parsed.question }
],
tools: TOOLS,
max_tokens: 128,
temperature: 0
});
} catch {
console.error(JSON.stringify({ event: "tool_inference_failed", requestId, model: MODEL }));
return json({ error: "model_unavailable", requestId }, 502);
}
let selected;
try { selected = validateToolSelection(inference?.tool_calls); }
catch {
console.error(JSON.stringify({ event: "tool_call_rejected", requestId, model: MODEL }));
return json({ error: "invalid_tool_call", requestId }, 502);
}
const result = execute(selected.arguments);
console.log(JSON.stringify({ event: "tool_call_executed", requestId, model: MODEL, tool: selected.name, found: result.found }));
return json({ model: MODEL, tool: selected.name, arguments: selected.arguments, result, requestId });
}
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 === "/catalog-help") return handleCatalogHelp(request, env);
return json({ error: "not_found" }, 404);
} };
JS
注意执行顺序:env.AI.run() 返回数据,validateToolSelection() 将这些数据收窄为一个允许的形状,之后 executeCatalogTool() 才会运行。模型永远不会提供 JavaScript、选择 URL 或获得执行写入操作的权限。日志会记录生命周期元数据,但不会记录用户问题和目录结果。
运行五个确定性测试,然后让 Wrangler 在不部署的情况下进行打包检查:
node --test test/worker.test.mjs
npx wrangler deploy --dry-run
测试应报告五项通过。dry run 应列出 env.AI,并显示其为 AI 绑定。这两项结果共同证明,在实时模型调用消耗用量之前,验证代码和 Worker 配置已经匹配。
完成一次实时工具选择
在此步骤中,你将运行本地 Worker,同时让其 AI 绑定执行一次真实的远程推理。只有目录查询在本地运行;模型仍然运行在 Cloudflare 上。
在后台启动 Wrangler,并等待不涉及 AI 的健康检查路由。& 会创建后台任务,$! 是该任务的进程 ID,有限次数的循环会在 /health 成功后立即停止等待:
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
发送一个包含单个准确模拟 SKU 的简短问题:
curl --silent --show-error http://127.0.0.1:8787/catalog-help \
--header 'Content-Type: application/json' \
--data '{"question":"Is SKU KB-101 in stock and what does it cost?"}'
预期结果应包含准确的 Llama 模型、tool: "lookup_catalog_item"、只包含 KB-101 的参数,以及范围受限的 Compact Keyboard fixture。由于应用使用结构化的工具提议,而不是自由格式文本,因此不会检查模型生成的措辞。
在推理开始前拒绝空问题:
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' http://127.0.0.1:8787/catalog-help \
--header 'Content-Type: application/json' --data '{"question":""}'
预期结果为 {"error":"invalid_question"},HTTP 状态码为 400。这证明普通请求验证发生在模型用量消耗之前。
部署并检查工具调用证据
在此步骤中,你将部署相同的端点,并把它的运行时行为与 Cloudflare 中可见的证据对应起来。
只停止保存的开发进程,等待它退出,然后进行部署:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy
保存 Wrangler 输出的准确 URL,并发送一个公开问题:
WORKER_URL="https://YOUR_WORKER_URL"
curl --silent --show-error "$WORKER_URL/catalog-help" \
--header 'Content-Type: application/json' \
--data '{"question":"Is SKU KB-101 in stock and what does it cost?"}'
确认公开结果使用准确的模型和允许的工具,只返回经过验证的 SKU 参数,并包含相同的范围受限只读 fixture 字段。
打开 Workers & Pages → 你的 labex-c07-a05-... Worker → Bindings。绑定是一个命名连接,用于让 Worker 代码访问 Cloudflare 服务。确认存在一个名称为 AI 的 Workers AI 连接;程序正是通过这个名称调用 env.AI.run(...)。

接着打开 Observability。此页面会收集调用记录和应用日志。下面的示例显示,在发送公开请求并完成独立检查后,有四个成功事件和零个错误。你的数量可能不同,因为每个请求可能贡献一条调用记录和一条应用事件,而且 Dashboard 传递数据可能存在延迟。

此页面上的蓝色 Free 计划提示指的是 Workers Logs 事件配额,不是 AI 推理。在搜索字段中输入 tool_call_executed,然后展开一条匹配记录。示例重点显示了两个成功匹配,以及事件开头刻意限制的字段:lookup_catalog_item、准确的 Llama 模型和请求 ID。完整事件还包含 event: "tool_call_executed" 和 found: true,但不会记录用户问题、模型原始响应或返回的目录记录。

最后打开 AI → Workers AI,并保持选中 Neurons 标签页。Neuron 是 Cloudflare 用于衡量 Workers AI 计算量的单位。示例账户当天使用了 342.34/10k Neurons;其中 Llama 行显示 341.57,更早的嵌入实验则单独显示。这些是共享账户的示例,并不代表单次请求的承诺成本。在你的账户中找到准确的 Llama 行,并确认当天总量仍在 10k Workers Free 配额以内。

Dashboard 页面可以帮助你将配置、流量和用量与命令行结果对应起来。不要为了强行更新图表而重复执行推理。JSON 响应和独立验证仍然是权威依据,因为图表和日志可能稍后才会显示。
删除 Worker 并退出登录
在此步骤中,你将删除临时的公开端点,然后移除这台 VM 的授权。Workers AI 用量仍会保留在账户历史记录中;删除 Worker 不会删除用量记录。
删除 wrangler.jsonc 中指定的准确 Worker:
npx wrangler delete
只有当 Wrangler 显示本实验唯一的 labex-c07-a05-... 名称时才确认删除。确认出现 Successfully deleted,然后在授权仍然有效时运行独立的云端不存在性检查:
python3 .labex/verify.py deleted
只有在该命令报告 PASS: deleted 后,才退出登录并检查结构化状态:
npx wrangler logout
npx wrangler whoami --json
必须确认输出中包含 loggedIn: false。关闭浏览器标签页或删除本地源代码,都不能证明公开 Worker 已经删除。
总结
你将模型选择与应用权限分离开来。Workers AI 提出了一个结构化的目录查询,Worker 验证了准确的工具名称和参数对象,之后才执行固定的只读代码。确定性 fixture 证明未知工具、格式错误的参数和多次调用都无法执行操作;实时推理则展示了真实的模型交互。你还检查了范围受隐私保护的证据,并删除了临时 Worker 和 VM 授权。



