lenec ru

← все посты

Tool use в Claude API: разбор на боевых примерах

16K

Когда у меня впервые появилась задача "научи Claude дёргать наш бэкенд", я полез читать доку про tool use и сразу запутался. Документация у Anthropic нормальная, но между одиноким примером и реальной интеграцией — пропасть. Этот текст — то, что я хотел прочитать сам, когда начинал.

Покажу tool use на трёх живых сценариях: получить погоду через внешний API, поискать по нашей базе товаров, собрать многошаговую цепочку с двумя инструментами. Везде Node, TypeScript, SDK @anthropic-ai/sdk. Без фреймворков сверху, чтобы видеть, что именно происходит.

Что такое tool use, если коротко

Модель не умеет ходить в интернет, читать твою БД и запускать код. Но она умеет в JSON. Tool use — это просто соглашение: ты описываешь модели список доступных функций со схемой аргументов, она в ответе вместо обычного текста отдаёт tool_use блок с именем функции и аргументами. Дальше ты сам вызываешь функцию у себя в коде, кладёшь результат обратно в диалог как tool_result и просишь модель продолжить.

То есть Claude не "вызывает API". Claude говорит: "вызови get_weather с city="Москва"". А вызываешь ты.

Установка и базовый клиент

npm i @anthropic-ai/sdk zod
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

const MODEL = "claude-sonnet-4-5";

Дальше всё крутится вокруг одного вызова — client.messages.create. Параметр tools — это массив описаний инструментов с JSON Schema на входные аргументы.

Пример 1. Один инструмент, один шаг

Минимальный сценарий — погода. Пишем функцию getWeather, описываем её для модели, обрабатываем ответ.

const tools = [
  {
    name: "get_weather",
    description: "Возвращает текущую погоду в указанном городе",
    input_schema: {
      type: "object",
      properties: {
        city: { type: "string", description: "Город на русском" },
        units: { type: "string", enum: ["metric", "imperial"] },
      },
      required: ["city"],
    },
  },
];

async function getWeather(city: string, units = "metric") {
  // Здесь твой реальный вызов погодного API
  return { city, units, temp: 12, condition: "облачно" };
}

Первый запрос — спрашиваем у Claude, на какой инструмент он хочет позвать.

const first = await client.messages.create({
  model: MODEL,
  max_tokens: 1024,
  tools,
  messages: [
    { role: "user", content: "Какая погода в Питере? Скажи в градусах." },
  ],
});

console.log(first.stop_reason); // "tool_use"

Когда модель решила позвать функцию, stop_reason равен tool_use, а в content лежит блок типа tool_use с id, name и input. Дальше идём искать этот блок:

const toolUse = first.content.find((b) => b.type === "tool_use");
if (!toolUse || toolUse.type !== "tool_use") throw new Error("no tool_use");

const { city, units } = toolUse.input as { city: string; units?: string };
const weather = await getWeather(city, units);

Теперь возвращаем результат в диалог и просим финальный ответ. Важно: на следующий запрос отправляем всю историю — оригинальное сообщение пользователя, полный assistant ответ с tool_use, и наш tool_result.

const second = await client.messages.create({
  model: MODEL,
  max_tokens: 1024,
  tools,
  messages: [
    { role: "user", content: "Какая погода в Питере? Скажи в градусах." },
    { role: "assistant", content: first.content },
    {
      role: "user",
      content: [
        {
          type: "tool_result",
          tool_use_id: toolUse.id,
          content: JSON.stringify(weather),
        },
      ],
    },
  ],
});

const text = second.content.find((b) => b.type === "text");
console.log(text?.type === "text" ? text.text : "");

Получишь что-то вроде "В Питере сейчас +12, облачно". Всё, базовый цикл закрыт.

Пример 2. Поиск по своей базе

Это более частый случай у меня в проде. Модель — фронт к нашему каталогу. Пользователь спрашивает "найди мне беспроводные наушники до 5 тысяч", модель формирует параметры и зовёт нашу функцию search_products.

const tools = [
  {
    name: "search_products",
    description:
      "Поиск по каталогу товаров. Возвращает максимум 10 совпадений.",
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string" },
        max_price: { type: "number" },
        category: {
          type: "string",
          enum: ["audio", "phones", "laptops", "accessories"],
        },
      },
      required: ["query"],
    },
  },
];

Несколько вещей, которые я понял на практике:

  • Описание инструмента важнее, чем кажется. Claude читает его и принимает решение, звать или нет. "Поиск по каталогу товаров" работает лучше, чем "function for products".
  • Не делай схему слишком свободной. Если у тебя четыре категории — заложи enum. Иначе модель придумает свои.
  • Имена snake_case — традиция Anthropic, но кириллицу в description SDK глотает спокойно.

Цикл вызова — тот же, что в первом примере. Разница только в том, что результат у тебя — массив объектов, и его удобно сериализовать как JSON в content поля tool_result.

Пример 3. Несколько инструментов и многошаговая цепочка

Здесь начинается интересное. Допустим, у нас два инструмента: search_products и get_stock (проверить остаток на складе по SKU). Запрос: "Найди мне Sony WH-1000XM5 и скажи, есть ли на складе".

Модель сама решит сначала позвать search_products, потом get_stock с найденным SKU. Наша задача — крутить цикл, пока stop_reason не станет end_turn.

const tools = [
  {
    name: "search_products",
    description: "Поиск по каталогу.",
    input_schema: {
      type: "object",
      properties: { query: { type: "string" } },
      required: ["query"],
    },
  },
  {
    name: "get_stock",
    description: "Остаток на складе по SKU.",
    input_schema: {
      type: "object",
      properties: { sku: { type: "string" } },
      required: ["sku"],
    },
  },
];

async function callTool(name: string, input: any) {
  if (name === "search_products") {
    return [{ sku: "WH1000XM5-BLK", title: "Sony WH-1000XM5", price: 32990 }];
  }
  if (name === "get_stock") {
    return { sku: input.sku, in_stock: 3 };
  }
  throw new Error(`Unknown tool: ${name}`);
}

async function chat(userMessage: string) {
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ];

  for (let step = 0; step < 8; step++) {
    const res = await client.messages.create({
      model: MODEL,
      max_tokens: 2048,
      tools,
      messages,
    });

    messages.push({ role: "assistant", content: res.content });

    if (res.stop_reason !== "tool_use") {
      const text = res.content.find((b) => b.type === "text");
      return text?.type === "text" ? text.text : "";
    }

    const toolResults = [];
    for (const block of res.content) {
      if (block.type !== "tool_use") continue;
      const result = await callTool(block.name, block.input);
      toolResults.push({
        type: "tool_result" as const,
        tool_use_id: block.id,
        content: JSON.stringify(result),
      });
    }
    messages.push({ role: "user", content: toolResults });
  }

  throw new Error("too many tool steps");
}

Здесь три неочевидные штуки.

В одном ответе может быть несколько tool_use блоков. Если ты дал модели возможность параллельно дёрнуть две функции, она это сделает. Поэтому собираем все tool_result в один user сообщение, а не отправляем по очереди.

Лимит шагов — must have. На моей практике Claude не зацикливается, но один раз в продакшене модель в третий раз подряд позвала тот же инструмент с теми же аргументами после ошибки в нашей функции. Лимит шагов в 6–10 спас от слива бюджета.

Ошибки инструментов передавай как ошибки. В tool_result есть флаг is_error: true. Если функция упала, не делай вид, что всё нормально. Модель адекватно реагирует на "база недоступна" — извинится и расскажет, что попробовать.

try {
  const result = await callTool(block.name, block.input);
  toolResults.push({
    type: "tool_result" as const,
    tool_use_id: block.id,
    content: JSON.stringify(result),
  });
} catch (e) {
  toolResults.push({
    type: "tool_result" as const,
    tool_use_id: block.id,
    content: `Error: ${(e as Error).message}`,
    is_error: true,
  });
}

Что я ещё узнал на проде

Поле tool_choice есть, но я его почти не использую. Дефолтный auto — нормальный вариант: модель сама решает, звать инструмент или ответить текстом. Если поставить { type: "tool", name: "..." }, она обязана вызвать конкретный инструмент. Это полезно для фиксированных пайплайнов, где "модель — это парсер запроса".

Параметр disable_parallel_tool_use: true в tool_choice заставляет модель ходить по инструментам строго по одному. Удобно, когда внутренние API не любят гонок.

Кэширование промптов отлично работает с tool use. Большой блок tools с подробными описаниями — кандидат номер один на cache_control. Если у тебя десяток функций с детальной схемой, на разнице между cache_read и обычными input-токенами экономишь в разы.

Для валидации input от модели я всегда прогоняю через zod-схему перед вызовом функции. JSON Schema в SDK — не валидатор, а подсказка модели. Бывает, что Claude отдаёт строку там, где ты ждал число, особенно для редких аргументов.

Куда дальше

Когда базовый цикл tool use в голове уложился, осваивать MCP-серверы становится сильно проще. MCP — это фактически тот же протокол tool use, только инструменты лежат в отдельном процессе и переиспользуются между приложениями. Anthropic-овский computer-use — тоже tool use под капотом, просто инструменты заранее заданы (скриншот, клик, ввод).

Если делаешь LLM-фичу впервые — начинай с одного инструмента и одного шага. Потом добавляешь второй, третий, лимиты, обработку ошибок. Claude хорошо себя ведёт, когда инструменты простые и описание точное. Сложности почти всегда от того, что мы пытаемся в один get_data запихнуть пять разных запросов.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.