验证模型选择的工具调用

ShellBeginner
立即练习

介绍

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 服务。确认存在一个名称为 AIWorkers AI 连接;程序正是通过这个名称调用 env.AI.run(...)

名为 AI 的 Workers AI 绑定

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

Observability 中的成功 Worker 事件

此页面上的蓝色 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 配额以内。

Workers AI 每日 Neuron 用量

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 授权。