提取经过验证的工单字段

JavaScriptBeginner
立即练习

介绍

面向人的 AI 回答可以在措辞上有所不同,通常不会造成问题。但应用程序代码需要更严格的结果。例如,工单路由服务需要名为 categorypriority 的字段,并且字段值必须来自已知集合。结构化输出要求模型返回机器可读的数据,而不是自由格式的文本。

本实验将结合 JSON ModeJSON Schema。JSON 是数据格式,Schema 是一份契约,用于说明哪些字段必需、允许使用哪些值类型,以及是否禁止额外字段。要求模型遵循 Schema,可以改善响应结构,但它不是信任边界:模型输出仍属于外部数据,可能缺少字段、格式错误,或与应用不兼容。

你将构建 POST /extract。Worker 会向 Cloudflare 托管的 Llama 模型发送一条简短的模拟支持工单,并请求四个字段:类别、优先级、简短摘要和是否需要后续跟进。随后,Worker 会使用 Ajv 独立检查同一个 Schema,然后返回通过验证的记录。确定性的测试夹具会注入格式错误的模型输出,让你确认无效数据会进入错误路径,而不会进入已接受的响应。

这是本课程的第三个实验。实验假设你已经知道 Cloudflare Worker 如何处理 HTTP 请求,也知道 AI 绑定会以 env.AI 的形式提供 Workers AI。如果你是直接进入本课程的,请先完成将 LabEx 连接到你的 Cloudflare 账户,了解如何使用 VM 终端、授权 Wrangler、确认学习账户并保存账户 ID。

本实验使用支持 JSON Mode 的 @cf/meta/llama-3.3-70b-instruct-fp8-fast,并将每个提示和结果控制在较小范围内。Workers Free 账户当前每天共享 10,000 Neurons 的额度,因此只要账户仍有免费额度,就不需要 Workers Paid。即使在本地运行推理,请求仍会发送到 Cloudflare,并消耗该额度。如果模型或额度不可用,请停止操作,不要重复发送请求。

初始化过程会在 /home/labex/project/ticket-fields 中安装 Node.js 22.22.0、项目本地的 Wrangler 4.132.0 和 Ajv 8.17.1,并提供确定性的测试和独立检查。初始化不会登录、调用模型、部署 Worker 或创建云资源。在删除临时 Worker 并确认退出登录之前,请保持此 VM 打开。

授权 VM 并配置提取 Worker

在本步骤中,你将授权这台全新的 VM,并配置一个临时 Worker。Dashboard 登录属于浏览器;新 VM 中的 Wrangler 需要单独完成有限授权,之后才能管理学习账户。

进入准备好的项目,并确认固定的 Wrangler 版本:

cd /home/labex/project/ticket-fields
npx wrangler --version

预期输出为 4.132.0。请求与之前 Workers AI 实验相同的最小权限。删除 Worker 时,Wrangler 4.132.0 会检查 KV 依赖,因此即使本实验不会创建 KV 数据,也需要 workers_kv:write,以避免无关的清理错误。

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,并读取目标账户的 nameid。生成一个唯一的临时 Worker 名称:

RUN="labex-c07-a03-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

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.AIremote: true 表示本地 Worker 进程仍会调用由真实账户提供的模型。可观测性功能会保存少量生命周期事件,供你在部署后检查。此时还没有执行推理或部署。

了解结构化输出契约

在本步骤中,你将检查保护应用的两层机制。JSON Mode 会在模型请求中发送 Schema。Worker 内部的 Ajv 会根据该 Schema 检查返回值。第一层用于引导生成,第二层决定是否可以安全接受该值。

生成 Worker 的环境类型:

npx wrangler types
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts

查找 AI: Ai。这是平台提供的绑定,不是存储在源代码中的模型 API 密钥。

记录包含四个字段:

category: billing | account | upload | other
priority: low | medium | high
summary: nonempty text, at most 160 characters
needs_follow_up: true or false

在 JSON Schema 中,type 控制值的类型,enum 将值限制为已知列表,required 指定必须存在的字段,additionalProperties: false 则拒绝额外字段。最后一条规则很重要,因为否则新增字段可能会在未被注意的情况下直接通过。Schema 描述的是结构,不代表模型的判断一定客观正确;人工或后续业务规则仍可能需要审核已接受的字段。

检查为确定性测试提供的格式错误夹具:

grep -nE 'security|priority: 1|internal_note|not-an-object' test/worker.test.mjs

这些夹具不会消耗 Neurons。它们可以稳定地测试那些不应通过反复发送在线提示而故意生成的情况。

构建经过验证的提取端点

在本步骤中,你将实现 Schema、模型请求和应用端验证。只有通过 Ajv 检查的分支才会返回 record

创建 Worker 入口文件:

cat > src/index.js <<'JS'
import Ajv from "ajv";

const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const MAX_TICKET = 1200;

export const TICKET_SCHEMA = {
  type: "object",
  properties: {
    category: { type: "string", enum: ["billing", "account", "upload", "other"] },
    priority: { type: "string", enum: ["low", "medium", "high"] },
    summary: { type: "string", minLength: 1, maxLength: 160 },
    needs_follow_up: { type: "boolean" }
  },
  required: ["category", "priority", "summary", "needs_follow_up"],
  additionalProperties: false
};

const ajv = new Ajv({ allErrors: true });
const isTicketRecord = ajv.compile(TICKET_SCHEMA);

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 > 2048) {
    return { error: json({ error: "ticket_too_large" }, 413) };
  }

  let body;
  try {
    body = JSON.parse(raw);
  } catch {
    return { error: json({ error: "invalid_json" }, 400) };
  }

  const ticket = typeof body?.ticket === "string" ? body.ticket.trim() : "";
  if (!ticket) return { error: json({ error: "invalid_ticket" }, 400) };
  if (ticket.length > MAX_TICKET) {
    return { error: json({ error: "ticket_too_large" }, 413) };
  }
  return { ticket };
}

async function extractTicket(request, env) {
  const parsed = await readTicket(request);
  if (parsed.error) return parsed.error;

  const requestId = crypto.randomUUID();
  const details = { requestId, model: MODEL };

  let result;
  try {
    result = await env.AI.run(MODEL, {
      messages: [
        {
          role: "system",
          content: "Extract support-ticket fields. Use only evidence in the ticket. Keep the summary short and do not add fields."
        },
        { role: "user", content: parsed.ticket }
      ],
      response_format: {
        type: "json_schema",
        json_schema: TICKET_SCHEMA
      },
      max_tokens: 160,
      temperature: 0
    });
  } catch {
    console.error(JSON.stringify({ event: "ticket_extraction_failed", ...details }));
    return json({ error: "model_unavailable", requestId }, 502);
  }

  const candidate = result?.response;
  if (!isTicketRecord(candidate)) {
    console.error(JSON.stringify({ event: "ticket_output_rejected", ...details }));
    return json({ error: "invalid_model_output", requestId }, 502);
  }

  console.log(JSON.stringify({ event: "ticket_output_accepted", ...details }));
  return json({ record: candidate, 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 === "/extract") {
      return extractTicket(request, env);
    }
    return json({ error: "not_found" }, 404);
  }
};
JS

Worker 不会记录工单内容或返回的字段。请求 ID 可以将客户端响应与已接受、已拒绝或失败的生命周期事件关联起来,同时不会把支持内容复制到可观测性数据中。Ajv 的错误详情也不会出现在客户端响应中,因为这些详情可能泄露内部验证设计;客户端只会收到稳定的 invalid_model_output 契约。

运行确定性测试:

node --test test/worker.test.mjs

预期通过五个测试。其中一个测试会通过虚假的 AI 绑定注入七个格式错误的候选值,并要求每个响应都不包含 record。然后在不部署的情况下打包真实 Worker:

npx wrangler deploy --dry-run

这些夹具可以在不依赖模型输出变化的情况下证明拒绝行为。试运行可以证明源代码、Ajv 依赖和 Worker 配置能够一起完成打包。下一步将执行一次真实的结构化推理。

执行一次真实的结构化结果请求

在本步骤中,你将从 VM 运行 Worker,并发送一次真实的 JSON Mode 请求。「本地」描述的是请求处理程序的位置;AI 绑定仍会使用所选的 Cloudflare 账户,并消耗每日额度的一部分。

在后台启动 Wrangler,并保存其进程 ID:

npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid

等待不调用 AI 的健康检查路由就绪:

for attempt in $(seq 1 30); do
  if curl --silent --fail http://127.0.0.1:8787/health; then
    break
  fi
  sleep 1
done

发送一条清晰的模拟工单:

curl --silent --show-error http://127.0.0.1:8787/extract \
  --header 'Content-Type: application/json' \
  --data '{"ticket":"Customer cannot upload a PDF and needs help before today’s deadline."}'

预期得到包含 recordrequestId 的 JSON 响应。具体的类别、优先级、摘要措辞和后续跟进决定可能有所不同。重要的是,record 必须恰好包含四个字段,并且每个值都符合 Schema。

现在证明无效的应用请求会在调用模型之前被拒绝:

curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
  http://127.0.0.1:8787/extract \
  --header 'Content-Type: application/json' \
  --data '{"ticket":""}'

预期得到 {"error":"invalid_ticket"} 和 HTTP 400。输入验证可以保护模型调用;输出验证可以保护应用记录。这是两个独立的边界。

部署并检查已接受的输出

在本步骤中,你将部署同一个经过验证的端点,并将 Dashboard 中可见的状态与运行时结果关联起来。先只停止保存的开发进程:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true

部署 Worker:

npx wrangler deploy

保存 Wrangler 输出的完整 workers.dev URL:

WORKER_URL="https://YOUR_WORKER_URL"

发送一次受限的公共请求:

curl --silent --show-error "$WORKER_URL/extract" \
  --header 'Content-Type: application/json' \
  --data '{"ticket":"Customer cannot upload a PDF and needs help before today’s deadline."}'

确认公共响应中的 record 再次只包含 Schema 规定的字段。仅凭 HTTP 成功状态还不够;独立检查还会验证每个返回字段以及已部署的 AI 绑定。

打开 Cloudflare Dashboard,进入 Workers & Pages → Overview → 你的 labex-c07-a03-... Worker。检查其绑定,然后打开 Observability → Logs。搜索 ticket_output_accepted,展开该事件,并确认其中的 modelrequestId 和事件名称。日志会有意排除工单内容和提取出的记录。

下面的绑定视图来自一次临时调试运行。图示和表格都将名称 AI 与 Workers AI 关联起来;在 Worker 中,这对应 env.AI,也是 Dashboard 中可见的对应项。你的 Worker 唯一名称会不同。

提取 Worker 中名为 AI 的 Workers AI 绑定

同一次运行在公共请求和独立检查后记录了 3 Success0 Errors。这些总数只是示例,不是必须达到的数量。重要的是,选中的 Worker 成功处理了可见的 /extract 请求。

提取 Worker 事件成功且没有调用错误

筛选 ticket_output_accepted 后,展开的应用事件会显示确切的 Llama 模型、请求 ID 和已接受事件名称。它不包含模拟工单或提取出的记录。这确认了隐私边界,但不能将日志行视为 Schema 验证通过的证明;运行时响应和独立检查才提供该证明。

受隐私边界保护的结构化输出已接受生命周期事件

然后打开 Workers AI,检查今天的模型使用情况。找到 Llama 3.3 模型,并确认这些受限的练习仍处于 10,000 Neurons 的 Workers Free 额度内。Dashboard 数据可能会延迟,因此请稍等片刻,不要仅为了强制刷新图表或日志而重复执行推理。

示例账户中的 Llama 模型显示为 261.63/10k Neurons。该总数包含同一学习账户之前进行的课程制作练习,因此不是本实验单独产生的费用,你看到的数值也会不同。检查点是保持在 Free 额度内,而不是匹配示例数字。

Workers AI 每日 Free 额度和 Llama 模型使用量

Dashboard 中的数值属于这次临时运行。学习目标是确认准确的 Worker 身份、其 AI 绑定、受隐私边界保护的已接受事件,以及 Free 额度使用情况。如果 Dashboard 视图延迟,CLI、API 和运行时检查仍然是权威依据。

删除 Worker 并退出登录

在本步骤中,你将删除临时 Worker,然后移除这台 VM 的授权。Workers AI 使用量属于账户级历史记录,因此删除 Worker 只会移除其端点,不会删除使用记录,也不会改变账户套餐。

删除 wrangler.jsonc 中指定的 Worker:

npx wrangler delete

只有当 Wrangler 显示本实验唯一的 labex-c07-a03-... 名称时,才确认删除。命令最后应显示 Successfully deleted。刷新 Workers & Pages → Overview,确认该准确名称已经不存在。

在 VM 仍处于授权状态时,运行独立的管理检查:

python3 .labex/verify.py deleted

只有当它报告 PASS: deleted 后,才移除 VM 保存的授权:

npx wrangler logout
npx wrangler whoami --json

确认 loggedIn: false。本地文件缺失、浏览器标签页关闭或网络错误,都不能证明云端删除或退出登录已经成功。

总结

你构建了一个 Workers AI 端点,使用 JSON Mode 和 JSON Schema 请求结构化工单字段。你了解了请求的结构不等于可信数据,使用 Ajv 建立了独立的应用边界,并通过格式错误的测试夹具证明无效的模型输出永远不会成为已接受的记录。你在 Workers Free 上执行了一次真实的本地和已部署结果,将已接受事件与 Dashboard 可观测性关联起来,删除了临时 Worker,并让全新的 VM 退出登录。