Проблема: LLM замкнута в своих обучающих данных

Пользователь спрашивает: «Какой курс доллара прямо сейчас?». Модель, обученная на данных прошлого года, знает только то, что было тогда. Без возможности обратиться к внешнему миру она беспомощна в любом вопросе, требующем актуальной информации, вычислений или записи данных.

Tool calling решает именно эту проблему — он даёт модели возможность запрашивать выполнение кода. Вот как меняется взаимодействие:

Без tool calling
Пользователь: Какой курс доллара?
▼ LLM обращается к обучающим данным
LLM: На момент моего обучения курс был около 73 рублей...
Ответ устарел. Данные из обучения — не реальный мир.
С tool calling
Пользователь: Какой курс доллара?
▼ LLM решает вызвать инструмент
▼ get_exchange_rate("USD") → 92.4
▼ LLM формирует ответ на основе реальных данных
LLM: Сейчас курс доллара — 92.4 рубля.
ℹ️ Терминология

«Tool calling», «function calling» и «tool use» — одно и то же. Anthropic в своей документации использует термин tool use, OpenAI — function calling. В этом гайде используем «tool calling» как наиболее распространённый общий термин.

Теория: как это работает на уровне протокола

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

Под капотом tool calling — это особый формат диалога в протоколе Messages API. Модель возвращает JSON-структуру (блок tool_use), которую ты разбираешь в своём коде, вызываешь нужную функцию и возвращаешь результат обратно в следующем сообщении. Никакой магии — только структурированный обмен сообщениями.

Полный цикл: диаграмма взаимодействия

Один цикл tool calling — это минимум два HTTP-запроса к API. Посмотри на последовательность целиком, прежде чем разбирать каждый шаг:

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
👤 Пользователь твоё приложение 🤖 Claude API языковая модель ⚙️ Твой код функции-инструменты ШАГ 1 · ПЕРВЫЙ ЗАПРОС messages + tools[] ШАГ 2 · РЕШЕНИЕ ВЫЗВАТЬ ИНСТРУМЕНТ stop_reason="tool_use" · блок tool_use {name, input, id} ШАГ 3 execute function() → result ШАГ 4 · ВТОРОЙ ЗАПРОС С РЕЗУЛЬТАТОМ history + блок tool_result {tool_use_id, content} ШАГ 5 · ФИНАЛЬНЫЙ ОТВЕТ stop_reason="end_turn" · текстовый ответ пользователю ⟳ Шаги 2–4 могут повторяться: модель может вызвать следующий инструмент вместо финального ответа

Главное, что нужно понять

JSON
LLM не выполняет код. Она возвращает JSON-структуру с именем функции и аргументами. Это просто текст особого формата. Твой код читает его и решает что делать.
2 запроса
Один tool call = минимум 2 HTTP-запроса. Первый — модель решает вызвать инструмент. Второй — ты возвращаешь результат, модель формирует ответ.
История
Контекст передаётся вручную. API stateless. Во втором запросе ты должен передать всю историю: исходный вопрос + ответ модели + результат инструмента.
Петля
Агент — это цикл, не одиночный запрос. После получения результата модель может снова запросить инструмент. Цикл продолжается до end_turn.

Шаг 1: описываем инструменты для модели

LLM не знает, какие функции есть в твоём коде. Ты описываешь каждый инструмент в виде JSON-структуры и передаёшь в параметре tools при каждом запросе. Именно по этому описанию модель решает — нужен ли инструмент, какой именно, и с какими аргументами его вызвать.

Описание инструмента состоит из трёх обязательных полей:

name
Уникальный идентификатор. Используй snake_case, без пробелов. По этому имени твой код находит нужную функцию после получения tool_use блока.
description
Инструкция для модели. LLM читает это, чтобы решить когда и зачем вызывать инструмент. Чем точнее — тем меньше ошибок выбора. Пиши: когда использовать, что принимает, что возвращает.
input_schema
JSON Schema параметров. Описывает каждый аргумент: тип, назначение, какие обязательны. Модель генерирует аргументы строго по этой схеме.
python
import anthropic

client = anthropic.Anthropic()

tools = [
    {
        "name": "get_exchange_rate",
        # description — это инструкция для LLM, не документация для человека
        "description": (
            "Возвращает актуальный курс указанной валюты к российскому рублю (RUB). "
            "Используй этот инструмент когда пользователь спрашивает о курсах валют, "
            "конвертации денег или стоимости чего-либо в рублях."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "currency": {
                    "type": "string",
                    "description": "Трёхбуквенный код валюты по стандарту ISO 4217: USD, EUR, GBP, JPY и т.д."
                }
            },
            "required": ["currency"]
        }
    }
]

# Передаём tools в каждый запрос
response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,                                      # ← список инструментов
    messages=[{"role": "user", "content": "Какой сейчас курс евро?"}]
)
💡 description — это промпт, не документация

Модель выбирает инструмент, читая description как часть своего контекста. Плохое описание («Получить курс») ведёт к ошибкам выбора или неправильным аргументам. Хорошее описание явно указывает сценарии использования, типы входных данных и что возвращается.

Шаг 2: читаем ответ модели

Если модель решила вызвать инструмент, она не вернёт обычный текстовый ответ. Вместо этого response.stop_reason будет равен "tool_use", а response.content будет содержать список блоков — среди них блок типа tool_use.

Вот как выглядит структура ответа API в этом случае:

Response от Claude API
stop_reason: "tool_use"
content: [
  {
    type: "text",
    text: "Сейчас уточню актуальный курс..."  ← необязателен, может отсутствовать
  },
  {
    type: "tool_use",  ← сигнал: вызови функцию
    id:    "toolu_01XqZ9K...",  ← уникальный ID, сохрани его
    name:  "get_exchange_rate",  ← имя функции для вызова
    input: { currency: "EUR" }  ← аргументы, готовы к распаковке
  }
]
python
# Всегда проверяй stop_reason первым — не ищи блоки вслепую
if response.stop_reason == "tool_use":

    # Среди блоков контента находим все tool_use
    tool_use_blocks = [b for b in response.content if b.type == "tool_use"]

    for block in tool_use_blocks:
        print(f"Инструмент: {block.name}")    # "get_exchange_rate"
        print(f"Аргументы: {block.input}")    # {"currency": "EUR"}
        print(f"ID вызова: {block.id}")       # "toolu_01XqZ9K..." — нужен для ответа

Шаг 3: выполняем функцию

Ты получил имя инструмента и аргументы. Теперь твой код должен найти соответствующую функцию и вызвать её. Самый простой способ — словарь-роутер, который маппит имена инструментов на Python-функции.

python
import json

# Реальная функция — делает что угодно: HTTP-запросы, БД, вычисления, файлы
def get_exchange_rate(currency: str) -> dict:
    """Получаем актуальный курс через внешний API"""
    import httpx
    resp = httpx.get(f"https://api.exchangerate-api.com/v4/latest/{currency}")
    data = resp.json()
    return {"currency": currency, "rate_to_rub": data["rates"]["RUB"]}

# Роутер: по имени инструмента вызываем нужную функцию
TOOL_HANDLERS = {
    "get_exchange_rate": get_exchange_rate,
    # "search_web": search_web,
    # "write_to_db": write_to_db,
}

# Выполняем все запрошенные инструменты и собираем результаты
tool_results = []
for block in tool_use_blocks:
    handler = TOOL_HANDLERS.get(block.name)
    if handler is None:
        result = {"error": f"Инструмент не найден: {block.name}"}
    else:
        try:
            result = handler(**block.input)      # распаковываем аргументы
        except Exception as e:
            result = {"error": str(e)}           # ошибку тоже передаём модели

    tool_results.append({
        "type": "tool_result",
        "tool_use_id": block.id,                 # обязателен — связывает запрос и ответ
        "content": json.dumps(result, ensure_ascii=False)   # строка, не dict
    })
💡 Не пробрасывай исключения наружу

Если функция упала — оберни ошибку в tool_result с полем error. Модель прочитает описание ошибки и либо попробует другой подход, либо сообщит пользователю понятным языком. Необработанное исключение просто сломает цикл агента.

Шаг 4: возвращаем результат в API

Это самый неочевидный шаг. Ты не отправляешь результат отдельным запросом — ты добавляешь его в историю сообщений и делаешь новый запрос с полной историей. API stateless: модель не помнит предыдущий запрос, ты должен передать весь контекст заново.

История для второго запроса должна содержать три элемента:

messages[] для второго запроса
// 1. Исходный вопрос пользователя (как был)
{ role: "user",  content: "Какой сейчас курс евро?" }

// 2. Ответ модели с блоком tool_use (передаём response.content как есть)
{ role: "assistant",  content: [ <text_block>, <tool_use_block> ] }

// 3. Результат инструмента — НОВОЕ сообщение с ролью "user"
{ role: "user",  content: [
  {
    type:        "tool_result",
    tool_use_id: "toolu_01XqZ9K...",  ← тот же ID из блока tool_use
    content:     '{"currency":"EUR","rate_to_rub":100.1}'  ← строка, не dict
  }
] }
python
# Составляем историю: исходный запрос + ответ модели + результаты инструментов
messages = [
    {"role": "user",      "content": user_message},       # исходный вопрос
    {"role": "assistant", "content": response.content},   # ответ с tool_use
    {"role": "user",      "content": tool_results},       # наши результаты
]

# Второй запрос — модель получает результаты и формирует финальный ответ
final_response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,          # инструменты передаём снова (API stateless)
    messages=messages
)

# Если stop_reason == "end_turn" — получаем текстовый ответ
print(final_response.content[0].text)
# → "Текущий курс евро составляет 100.1 рубля."

Полный пример: рабочий агент

Соберём все шаги в единую функцию с корректным циклом. Это минимальная, но production-ready основа для агента с инструментами.

python
import json
import anthropic

client = anthropic.Anthropic()

# ── Инструменты ──────────────────────────────────────────────────────────
def get_exchange_rate(currency: str) -> dict:
    rates = {"USD": 92.4, "EUR": 100.1, "GBP": 117.3}
    if currency not in rates:
        return {"error": f"Неизвестная валюта: {currency}"}
    return {"currency": currency, "rate_to_rub": rates[currency]}

TOOLS = [
    {
        "name": "get_exchange_rate",
        "description": (
            "Возвращает актуальный курс валюты к рублю (RUB). "
            "Используй при любых вопросах о курсах валют и конвертации."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "currency": {
                    "type": "string",
                    "description": "Трёхбуквенный код валюты: USD, EUR, GBP и т.д."
                }
            },
            "required": ["currency"]
        }
    }
]

TOOL_HANDLERS = {"get_exchange_rate": get_exchange_rate}

# ── Агент ────────────────────────────────────────────────────────────────
def run_agent(user_message: str, max_iterations: int = 5) -> str:
    messages = [{"role": "user", "content": user_message}]

    for _ in range(max_iterations):
        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=1024,
            tools=TOOLS,
            messages=messages
        )

        # Финальный ответ — выходим из цикла
        if response.stop_reason == "end_turn":
            return response.content[0].text

        # Модель хочет вызвать инструменты
        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type != "tool_use":
                    continue
                handler = TOOL_HANDLERS.get(block.name)
                if handler:
                    try:
                        result = handler(**block.input)
                    except Exception as e:
                        result = {"error": str(e)}
                else:
                    result = {"error": f"Инструмент не найден: {block.name}"}

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": json.dumps(result, ensure_ascii=False)
                })

            # Добавляем ответ модели и результаты в историю
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user",      "content": tool_results})
            continue  # следующая итерация — второй запрос к API

        # Неожиданный stop_reason
        raise RuntimeError(f"Неожиданный stop_reason: {response.stop_reason}")

    raise RuntimeError("Превышен лимит итераций агента")


# Запускаем
print(run_agent("Какой сейчас курс евро?"))
# → "Текущий курс евро составляет 100.1 рубля."

stop_reason: индикатор следующего действия

Каждый ответ модели содержит stop_reason. Это первое, что нужно проверять — именно он определяет что делать дальше. Никогда не ищи блоки tool_use без предварительной проверки stop_reason.

"end_turn"
Модель завершила ответ. Читай content[0].text — это финальный текст для пользователя.
"tool_use"
Модель запрашивает вызов инструмента. Найди все блоки type="tool_use", выполни функции, верни tool_result.
"max_tokens"
Ответ обрезан по лимиту. Увеличь max_tokens или обрабатывай частичный ответ отдельно.
"stop_sequence"
Встретилась стоп-последовательность из stop_sequences. Нормально, если ты сам их задавал.
python
match response.stop_reason:
    case "end_turn":
        return response.content[0].text         # финальный ответ
    case "tool_use":
        handle_tool_calls(response, messages)   # выполнить инструменты
    case "max_tokens":
        raise RuntimeError("Ответ обрезан, увеличь max_tokens")
    case _:
        raise RuntimeError(f"stop_reason={response.stop_reason}")

Типичные ошибки и как их избежать

❌ Неправильно
messages = [
  user_msg,
  # забыли assistant-ответ!
  {"role":"user","content": tool_results}
]
Пропущен ответ модели с tool_use блоком. API вернёт ошибку — нарушен порядок ролей.
✅ Правильно
messages = [
  user_msg,
  {"role":"assistant", "content": response.content},
  {"role":"user", "content": tool_results}
]
Полная история: вопрос → ответ модели → результат инструмента.
❌ Неправильно
"content": {"rate": 92.4} # dict!
Поле content в tool_result должно быть строкой. Dict вызовет ошибку валидации.
✅ Правильно
"content": json.dumps({"rate": 92.4})
Сериализуй результат через json.dumps() перед передачей.
❌ Неправильно
while True:
  response = client.messages.create(...)
  # нет ограничения итераций
Модель теоретически может вызывать инструменты бесконечно. Агент зациклится.
✅ Правильно
for _ in range(max_iterations):
  response = client.messages.create(...)
  if stop: break
Всегда ограничивай число итераций. В продакшне — 5–10 итераций максимум.

Шпаргалка

Шаг 1
Запрос с инструментами: передай tools=[...] в messages.create(). В каждом инструменте: name, description, input_schema.
Шаг 2
Проверь stop_reason: если "tool_use" — найди все блоки с type=="tool_use", сохрани id, name, input.
Шаг 3
Выполни функции: вызови нужный Python-код. Оберни ошибки в try/except, верни {"error": str(e)} вместо исключения.
Шаг 4
Верни результат: добавь в messages ответ модели + tool_result блоки с правильным tool_use_id. content — строка через json.dumps.
Шаг 5
Финальный ответ: если stop_reason=="end_turn" — читай content[0].text. Иначе продолжай цикл.
Лимит
Ограничь цикл: всегда for _ in range(max_iterations). Без ограничения агент может зациклиться в продакшне.

Практическое задание

  1. Калькулятор-агент. Реализуй инструмент calculate(expression: str) -> float, который вычисляет математическое выражение. Попроси агента: «Сколько будет 15% от 83 490 рублей?». Убедись, что агент вызывает инструмент, а не считает в уме.
  2. Два инструмента. Добавь второй инструмент get_weather(city: str), возвращающий заглушку с температурой. Задай вопрос: «Если поеду в Лондон и конвертирую 1000 евро в фунты, сколько получу и какая там погода?». Посмотри вызовет ли модель оба инструмента.
  3. Обработка ошибки. Намеренно сломай инструмент — при валюте «XYZ» выбрасывай ValueError("Неизвестная валюта"). Убедись, что агент перехватывает ошибку, передаёт её через tool_result и отвечает пользователю понятным сообщением — а не падает с исключением.
Следующий шаг

Теперь ты знаешь механику одного tool call. Следующая статья — JSON Schema для инструментов: как описывать сложные параметры — enum, массивы, вложенные объекты — чтобы модель всегда передавала аргументы в нужном формате.