Проверка вызовов инструментов, выбранных моделью

ShellBeginner
Практиковаться сейчас

Введение

Модель ИИ может отвечать обычным текстом, но приложению иногда нужны структурированные данные, прежде чем оно сможет выполнить полезную работу. Вызов инструментов позволяет приложению описать операцию, например поиск одного товара в каталоге, а модели — предложить имя инструмента и аргументы. Модель не получает разрешение на выполнение произвольного кода. Она формирует данные, которые Worker должен рассматривать как непроверенный ввод.

В этой лабораторной работе вы создадите POST /catalog-help. Размещённая в Cloudflare модель Llama получит короткий вопрос, например «Есть ли SKU KB-101 в наличии?», и сможет предложить доступный только для чтения инструмент lookup_catalog_item. Worker принимает ровно один известный инструмент, проверяет точный объект аргументов { sku } и только после этого читает небольшой синтетический каталог. Неизвестные инструменты, отсутствующие или лишние поля, некорректные SKU и несколько вызовов никогда не доходят до исполнителя.

Вы будете использовать традиционный вызов функций, чтобы граница безопасности оставалась очевидной: инференс предлагает, проверка принимает решение, а код приложения выполняет операцию. Возвращаемый результат ограничен несколькими общедоступными полями тестовых данных. Это упражнение не предоставляет доступ на запись, не вызывает внешний сервис и не позволяет модели выбирать исполняемый код.

Это пятая лабораторная работа курса. Если вы открыли её напрямую, сначала выполните Подключение LabEx к вашей учётной записи Cloudflare, чтобы научиться пользоваться терминалом виртуальной машины, авторизовать Wrangler, подтвердить учебную учётную запись и настроить её идентификатор.

Выбранная модель @cf/meta/llama-3.3-70b-instruct-fp8-fast поддерживает вызов функций и доступна в рамках стандартного распределения Workers AI. В бесплатный тариф Workers сейчас входит 10 000 Neurons в день. Эта лабораторная работа отправляет только один короткий реальный запрос локально и ещё один после развёртывания, поэтому Workers Paid не требуется, пока доступна бесплатная квота. Локальный инференс всё равно обращается к Cloudflare и расходует ресурсы учётной записи; если модель или квота недоступны, остановитесь и не повторяйте запрос многократно.

Настройка устанавливает Node.js 22.22.0 и локальный для проекта Wrangler 4.132.0 в /home/labex/project/tool-call-guard. Она также предоставляет детерминированные тестовые данные модели и независимые проверки. Настройка не авторизует Wrangler, не создаёт исходный код Worker, не вызывает модель, не выполняет развёртывание и не создаёт облачные ресурсы.

Авторизуйте виртуальную машину и настройте Worker для вызова инструмента

На этом шаге вы авторизуете новую виртуальную машину и настроите один временный Worker. Возможно, в браузере уже открыта учётная запись Cloudflare Dashboard, но Wrangler внутри новой виртуальной машины всё равно требует отдельной ограниченной авторизации.

Перейдите в подготовленный проект и проверьте закреплённую версию CLI:

cd /home/labex/project/tool-call-guard
npx wrangler --version

Ожидается 4.132.0. Запросите только разрешения, необходимые 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 фактическим идентификатором нужной учебной учётной записи:

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 этой лабораторной работы и передаёт проверенное значение только фиксированной функции приложения, предназначенной для чтения.

Просмотрите предоставленные тестовые данные для отклонения:

grep -nE 'unknown tools|missing, extra|zero or multiple' test/worker.test.mjs

Это специально подготовленные фиктивные ответы модели. Они демонстрируют границу безопасности, не расходуя 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

Тесты должны показать пять успешных результатов. В режиме пробного запуска среди привязок должна быть указана env.AI как привязка AI. Вместе эти проверки подтверждают, что код валидации и конфигурация Worker согласованы, прежде чем реальный вызов модели начнёт расходовать ресурсы.

Выполните первый реальный выбор инструмента

На этом шаге вы запустите Worker локально, а его привязка AI выполнит один настоящий удалённый инференс. Локально выполняется только поиск в каталоге; сама модель по-прежнему работает в Cloudflare.

Запустите Wrangler в фоновом режиме и дождитесь ответа маршрута проверки, не использующего AI. & создаёт фоновое задание, $! содержит его идентификатор процесса, а ограниченный цикл прекращает ожидание сразу после успешного ответа /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. Сгенерированный текст не проверяется, поскольку приложение использует структурированное предложение инструмента, а не произвольный текстовый ответ.

Проверьте, что пустой вопрос отклоняется до запуска инференса:

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. Это подтверждает, что обычная проверка запроса выполняется до использования модели.

Разверните Worker и изучите свидетельства вызова инструмента

На этом шаге вы развернёте тот же endpoint и сопоставите его работу с видимыми данными Cloudflare.

Остановите только сохранённый процесс разработки, дождитесь его завершения и выполните развёртывание:

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

Сохраните точный URL, напечатанный Wrangler, и отправьте один общедоступный вопрос:

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 и содержит те же ограниченные поля тестовых данных для чтения.

Откройте Workers & Pages → ваш Worker labex-c07-a05-... → Bindings. Привязка — это именованное соединение, благодаря которому сервис Cloudflare становится доступен коду Worker. Убедитесь, что указано одно соединение Workers AI с именем AI; именно поэтому программа может вызвать env.AI.run(...).

Привязка Workers AI с именем AI

Затем откройте Observability. На этой странице отображаются записи о вызовах и журналы приложения. В приведённом ниже примере после общедоступного запроса и независимых проверок показаны четыре успешных события и ноль ошибок. Ваши значения могут отличаться: каждый запрос может создавать запись о вызове и событие приложения, а доставка данных в Dashboard может задерживаться.

Успешные события Worker в Observability

Синее уведомление о бесплатном тарифе на этой странице относится к квоте событий Workers Logs, а не к инференсу AI. В поле поиска введите tool_call_executed, затем разверните одну подходящую строку. В целевом примере показаны два успешных совпадения и намеренно ограниченные поля в начале события: lookup_catalog_item, точная модель Llama и идентификатор запроса. Полное событие также содержит event: "tool_call_executed" и found: true, но в нём не записываются вопрос пользователя, необработанный ответ модели или возвращённая запись каталога.

Журнал выполнения инструмента с ограниченными данными

Наконец, откройте AI → Workers AI и оставьте выбранной вкладку Neurons. Neuron — это единица вычислений Workers AI в Cloudflare. В примере использованной учётной записи за этот день было израсходовано 342.34/10k Neurons; строка Llama показывает 341.57, а предыдущее упражнение с embedding отображается отдельно. Это общие примеры учётной записи, а не обещанная стоимость одного запроса. Найдите в своей учётной записи точную строку Llama и убедитесь, что сегодняшнее общее потребление остаётся в пределах бесплатной квоты Workers 10k.

Ежедневное потребление Neuron в Workers AI

Страницы Dashboard помогают сопоставить конфигурацию, трафик и использование ресурсов с результатом командной строки. Не повторяйте инференс только для принудительного обновления графика. Авторитетными остаются JSON-ответ и независимая проверка, поскольку графики и журналы могут появиться с задержкой.

Удалите Worker и выйдите из учётной записи

На этом шаге вы удалите временный общедоступный endpoint, а затем отмените авторизацию этой виртуальной машины. Использование Workers AI останется в истории учётной записи; удаление Worker не стирает запись об использовании.

Удалите Worker с точным именем из wrangler.jsonc:

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 проверил точное имя инструмента и объект аргументов, и только после этого выполнился фиксированный код чтения. Детерминированные тестовые данные доказали, что неизвестные инструменты, некорректные аргументы и несколько вызовов не могут выполнить операцию, а реальный инференс продемонстрировал настоящий обмен с моделью. Вы также изучили свидетельства с ограниченными данными и удалили временный Worker и авторизацию виртуальной машины.