Проблема: LLM замкнута в своих обучающих данных
Пользователь спрашивает: «Какой курс доллара прямо сейчас?». Модель, обученная на данных прошлого года, знает только то, что было тогда. Без возможности обратиться к внешнему миру она беспомощна в любом вопросе, требующем актуальной информации, вычислений или записи данных.
Tool calling решает именно эту проблему — он даёт модели возможность запрашивать выполнение кода. Вот как меняется взаимодействие:
«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. Посмотри на последовательность целиком, прежде чем разбирать каждый шаг:
Главное, что нужно понять
end_turn.Шаг 1: описываем инструменты для модели
LLM не знает, какие функции есть в твоём коде. Ты описываешь каждый инструмент в виде JSON-структуры и передаёшь в параметре tools при каждом запросе. Именно по этому описанию модель решает — нужен ли инструмент, какой именно, и с какими аргументами его вызвать.
Описание инструмента состоит из трёх обязательных полей:
tool_use блока.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 как часть своего контекста. Плохое описание («Получить курс») ведёт к ошибкам выбора или неправильным аргументам. Хорошее описание явно указывает сценарии использования, типы входных данных и что возвращается.
Шаг 2: читаем ответ модели
Если модель решила вызвать инструмент, она не вернёт обычный текстовый ответ. Вместо этого response.stop_reason будет равен "tool_use", а response.content будет содержать список блоков — среди них блок типа tool_use.
Вот как выглядит структура ответа API в этом случае:
content: [
{
type: "text",
text: "Сейчас уточню актуальный курс..." ← необязателен, может отсутствовать
},
{
type: "tool_use", ← сигнал: вызови функцию
id: "toolu_01XqZ9K...", ← уникальный ID, сохрани его
name: "get_exchange_rate", ← имя функции для вызова
input: { currency: "EUR" } ← аргументы, готовы к распаковке
}
]
# Всегда проверяй 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-функции.
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: модель не помнит предыдущий запрос, ты должен передать весь контекст заново.
История для второго запроса должна содержать три элемента:
{ role: "user", content: "Какой сейчас курс евро?" }
// 2. Ответ модели с блоком tool_use (передаём response.content как есть)
{ role: "assistant", content: [ <text_block>, <tool_use_block> ] }
{ role: "user", content: [
{
type: "tool_result",
tool_use_id: "toolu_01XqZ9K...", ← тот же ID из блока tool_use
content: '{"currency":"EUR","rate_to_rub":100.1}' ← строка, не dict
}
] }
# Составляем историю: исходный запрос + ответ модели + результаты инструментов
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 основа для агента с инструментами.
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.
content[0].text — это финальный текст для пользователя.type="tool_use", выполни функции, верни tool_result.max_tokens или обрабатывай частичный ответ отдельно.stop_sequences. Нормально, если ты сам их задавал.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}")
Типичные ошибки и как их избежать
user_msg,
# забыли assistant-ответ!
{"role":"user","content": tool_results}
]
tool_use блоком. API вернёт ошибку — нарушен порядок ролей.user_msg,
{"role":"assistant", "content": response.content},
{"role":"user", "content": tool_results}
]
content в tool_result должно быть строкой. Dict вызовет ошибку валидации.json.dumps() перед передачей.response = client.messages.create(...)
# нет ограничения итераций
response = client.messages.create(...)
if stop: break
Шпаргалка
tools=[...] в messages.create(). В каждом инструменте: name, description, input_schema."tool_use" — найди все блоки с type=="tool_use", сохрани id, name, input.{"error": str(e)} вместо исключения.tool_result блоки с правильным tool_use_id. content — строка через json.dumps.stop_reason=="end_turn" — читай content[0].text. Иначе продолжай цикл.for _ in range(max_iterations). Без ограничения агент может зациклиться в продакшне.Практическое задание
-
Калькулятор-агент. Реализуй инструмент
calculate(expression: str) -> float, который вычисляет математическое выражение. Попроси агента: «Сколько будет 15% от 83 490 рублей?». Убедись, что агент вызывает инструмент, а не считает в уме. -
Два инструмента. Добавь второй инструмент
get_weather(city: str), возвращающий заглушку с температурой. Задай вопрос: «Если поеду в Лондон и конвертирую 1000 евро в фунты, сколько получу и какая там погода?». Посмотри вызовет ли модель оба инструмента. -
Обработка ошибки. Намеренно сломай инструмент — при валюте «XYZ» выбрасывай
ValueError("Неизвестная валюта"). Убедись, что агент перехватывает ошибку, передаёт её черезtool_resultи отвечает пользователю понятным сообщением — а не падает с исключением.
Теперь ты знаешь механику одного tool call. Следующая статья — JSON Schema для инструментов: как описывать сложные параметры — enum, массивы, вложенные объекты — чтобы модель всегда передавала аргументы в нужном формате.