Tool use в Claude API: разбор на боевых примерах
Когда у меня впервые появилась задача "научи 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 zodimport 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, но кириллицу в
descriptionSDK глотает спокойно.
Цикл вызова — тот же, что в первом примере. Разница только в том, что результат у тебя — массив объектов, и его удобно сериализовать как 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 запихнуть пять разных запросов.