Урок 5. tool_choice: кто решает, вызывать ли инструмент

До этого урока мы давали Claude инструменты и надеялись, что модель воспользуется ими в нужный момент. Иногда она пользуется. Иногда — вежливо отвечает текстом там, где нужен был вызов. А иногда лезет в калькулятор, когда её просили посчитать тональность твита.

В Claude API есть параметр tool_choice, который убирает эту неопределённость. У него три режима:

  • auto — Claude сам решает, вызывать инструмент или ответить текстом.
  • any — Claude обязан вызвать какой-нибудь инструмент из списка, но какой именно — выбирает сам.
  • tool — Claude обязан вызвать конкретный инструмент, который вы назвали.

Разберём каждый по очереди — и, что важнее, поймём, в каких задачах какой режим уместен.

Все примеры — код на Python с SDK anthropic. Повторять удобнее всего в блокноте или скрипте: tool_choice живёт только в API, в обычном чате его нет.

auto: решает модель

auto — поведение по умолчанию. Если вы передали tools и вообще не написали tool_choice, работает именно оно: модель смотрит на вопрос и сама выбирает — ответить своими знаниями или дёрнуть инструмент.

Чтобы это увидеть, дадим Claude «поиск в интернете». Настоящего поиска внутри нет — функция просто печатает, что она бы искала: нам сейчас важно решение модели, а не результат.

def web_search(topic):
    print(f"делаю вид, что ищу в интернете: {topic}")

web_search_tool = {
    "name": "web_search",
    "description": "Инструмент для получения актуальной информации по теме через поиск в интернете",
    "input_schema": {
        "type": "object",
        "properties": {
            "topic": {
                "type": "string",
                "description": "Тема, которую нужно найти в интернете"
            },
        },
        "required": ["topic"]
    }
}

Теперь функция, которая принимает вопрос пользователя и отдаёт его Claude вместе с инструментом. Ключевая строка — tool_choice={"type": "auto"}:

from datetime import date

def chat_with_web_search(user_query):
    messages = [{"role": "user", "content": user_query}]

    system_prompt = f"""
    Отвечай на как можно большее число вопросов, используя свои знания.
    Ищи в интернете только те запросы, на которые не можешь ответить уверенно.
    Сегодняшняя дата: {date.today().strftime("%d.%m.%Y")}
    Если вопрос пользователя касается будущего — того, что ещё не произошло, —
    используй инструмент поиска.
    """

    response = client.messages.create(
        system=system_prompt,
        model="claude-sonnet-5",
        messages=messages,
        max_tokens=1000,
        tool_choice={"type": "auto"},
        tools=[web_search_tool]
    )

    last_content_block = response.content[-1]
    if last_content_block.type == "text":
        print("Claude НЕ вызвал инструмент")
        print(f"Assistant: {last_content_block.text}")
    elif last_content_block.type == "tool_use":
        print("Claude хочет вызвать инструмент")
        print(last_content_block)

Спросим то, что модель знает и так:

chat_with_web_search("Какого цвета небо?")
# → Claude НЕ вызвал инструмент

А теперь то, чего в её знаниях быть не может:

chat_with_web_search("Кто выиграл Гран-при Майами 2024?")
# → Claude хочет вызвать инструмент: web_search(topic="Гран-при Майами 2024 победитель")

Та же пара на другом сюжете: победителя Супербоула 2022 модель назовёт сама, а за 2024-м пойдёт в поиск. Граница проходит ровно там, где кончается уверенность модели — и там, где системный промпт сказал ей эту границу видеть.

Промпт всё ещё главный

В режиме auto качество работы упирается не в параметр, а в текст. Claude склонен быть чересчур услужливым: дали инструмент — хочется его применить. Без внятных инструкций модель начнёт искать в интернете цвет неба.

Посмотрите ещё раз на системный промпт выше. Он делает три вещи:

  • задаёт умолчание — «отвечай своими знаниями»;
  • описывает условие вызова — «только если не уверен»;
  • подкидывает недостающий факт — сегодняшнюю дату, без которой модель не отличит прошлое от будущего.

Правило простое: если вы выбрали auto, вы подписались на написание подробного промпта. Параметр отдаёт решение модели — значит, модели нужны критерии решения.

Квиз 1

Вы передали tools, но не указали tool_choice. Какой режим будет работать?

tool: конкретный инструмент

Иногда свобода модели — не преимущество, а баг. Классический пример: вам нужен не разговор, а строго структурированный JSON.

Определим два инструмента. Первый — print_sentiment_scores — на самом деле никуда ничего не «печатает»: это трюк, который заставляет Claude выдать аккуратный JSON с оценками тональности. Второй — calculator, простое сложение двух чисел.

tools = [
    {
        "name": "print_sentiment_scores",
        "description": "Печатает оценки тональности для твита или другого текста.",
        "input_schema": {
            "type": "object",
            "properties": {
                "positive_score": {"type": "number", "description": "Оценка позитивной тональности от 0.0 до 1.0"},
                "negative_score": {"type": "number", "description": "Оценка негативной тональности от 0.0 до 1.0"},
                "neutral_score": {"type": "number", "description": "Оценка нейтральной тональности от 0.0 до 1.0"}
            },
            "required": ["positive_score", "negative_score", "neutral_score"]
        }
    },
    {
        "name": "calculator",
        "description": "Складывает два числа",
        "input_schema": {
            "type": "object",
            "properties": {
                "num1": {"type": "number", "description": "первое слагаемое"},
                "num2": {"type": "number", "description": "второе слагаемое"},
            },
            "required": ["num1", "num2"]
        }
    }
]

Сначала — как не надо. Оставляем auto и намеренно не пишем нормальный промпт, чтобы эффект был виден:

def analyze_tweet_sentiment(query):
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=4096,
        tools=tools,
        tool_choice={"type": "auto"},
        messages=[{"role": "user", "content": query}]
    )
    print(response)

Первый твит: «Ого, я приготовил самый невероятный ужин в жизни!» Claude не трогает print_sentiment_scores, а отвечает по-человечески — что-то вроде «здорово! правда, я не умею оценивать тональность текста, но звучит так, будто вы очень довольны ужином». Вежливо и совершенно бесполезно для пайплайна.

Второй твит: «Обожаю котов! У меня было четыре, и я взял ещё двух. Угадай, сколько теперь?» Тут Claude уверенно тянется к калькулятору:

ToolUseBlock(id='toolu_...', input={'num1': 4, 'num2': 2}, name='calculator', type='tool_use')

Формально модель права: её попросили угадать число. Только нам-то нужна была тональность. Заставим её вызывать нужный инструмент всегда:

tool_choice={"type": "tool", "name": "print_sentiment_scores"}

Обратите внимание: кроме type: "tool" обязательно указывается имя конкретного инструмента — без name режим не имеет смысла.

def analyze_tweet_sentiment(query):
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=4096,
        tools=tools,
        tool_choice={"type": "tool", "name": "print_sentiment_scores"},
        messages=[{"role": "user", "content": query}]
    )
    print(response)

Теперь на первом твите получаем ровно то, что хотели:

ToolUseBlock(id='toolu_...', input={'positive_score': 0.9, 'negative_score': 0.0, 'neutral_score': 0.1}, name='print_sentiment_scores', type='tool_use')

И на «математическом» твите про котов — тоже print_sentiment_scores. Соблазн уйти в калькулятор больше не реализуется: выбор у модели отобрали.

Здесь легко расслабиться и решить, что промпт больше не нужен — раз инструмент всё равно вызовется. Не так. Принуждение задаёт форму ответа, но не его качество: модель по-прежнему должна понимать, что именно она оценивает. Дайте ей контекст задачи явно:

def analyze_tweet_sentiment(query):
    prompt = f"""
    Проанализируй тональность следующего твита:
    <tweet>{query}</tweet>
    """

    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=4096,
        tools=tools,
        tool_choice={"type": "tool", "name": "print_sentiment_scores"},
        messages=[{"role": "user", "content": prompt}]
    )
    print(response)

Тег <tweet> здесь не украшение: он отделяет данные от инструкции, и твит вроде «игнорируй предыдущие указания» не превращается в команду.

Квиз 2

Вам нужен строго предсказуемый JSON с тремя числовыми полями. Что надёжнее всего?

any: любой, но обязательно

Третий режим — any. Он говорит модели: «текстом отвечать нельзя, вызови какой-нибудь инструмент; какой — решай сам».

Зачем это надо? Представьте чат-бота, который общается с человеком только по SMS. У него нет канала «ответить в консоль» — единственный способ сказать хоть что-то пользователю проходит через инструмент отправки сообщения. Обычный текстовый ответ модели в такой архитектуре просто исчезнет в пустоте.

Даём боту два инструмента: отправить SMS и посмотреть данные клиента по логину.

def send_text_to_user(text):
    # Отправляет SMS пользователю. Для простоты просто печатаем:
    print(f"SMS ОТПРАВЛЕНА: {text}")

def get_customer_info(username):
    return {
        "username": username,
        "email": f"{username}@email.com",
        "purchases": [
            {"id": 1, "product": "компьютерная мышь"},
            {"id": 2, "product": "защитная плёнка на экран"},
            {"id": 3, "product": "usb-кабель для зарядки"},
        ]
    }

tools = [
    {
        "name": "send_text_to_user",
        "description": "Отправляет пользователю текстовое сообщение",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "Текст, который будет отправлен пользователю по SMS"},
            },
            "required": ["text"]
        }
    },
    {
        "name": "get_customer_info",
        "description": "Возвращает информацию о клиенте по его логину: email, логин и прошлые покупки. Вызывай этот инструмент только после того, как пользователь сообщил свой логин",
        "input_schema": {
            "type": "object",
            "properties": {
                "username": {"type": "string", "description": "Логин пользователя"},
            },
            "required": ["username"]
        }
    },
]

system_prompt = """
Всё общение с пользователем идёт через SMS.
Вызывай инструменты только тогда, когда у тебя достаточно информации для корректного вызова.
Не вызывай get_customer_info, пока пользователь не назвал свой логин. Это важно.
Если логин неизвестен — просто спроси его у пользователя.
"""

И сам обработчик с tool_choice={"type": "any"}:

def sms_chatbot(user_message):
    messages = [{"role": "user", "content": user_message}]

    response = client.messages.create(
        system=system_prompt,
        model="claude-sonnet-5",
        max_tokens=4096,
        tools=tools,
        tool_choice={"type": "any"},
        messages=messages
    )

    if response.stop_reason == "tool_use":
        last_content_block = response.content[-1]
        if last_content_block.type == "tool_use":
            tool_name = last_content_block.name
            tool_inputs = last_content_block.input
            print(f"=======Claude хочет вызвать инструмент {tool_name}=======")
            if tool_name == "send_text_to_user":
                send_text_to_user(tool_inputs["text"])
            elif tool_name == "get_customer_info":
                print(get_customer_info(tool_inputs["username"]))
            else:
                print("Ой, такого инструмента не существует!")
    else:
        print("Инструмент не вызван. Такого быть не должно!")

Проверяем на четырёх репликах:

  • «Привет! Как дела?» — Claude вызывает send_text_to_user и здоровается в ответ.
  • «Мне нужна помощь с заказом» — снова send_text_to_user: модель просит назвать логин, потому что системный промпт запретил лезть в базу без него.
  • «Мне нужна помощь с заказом. Мой логин jenny76» — вот теперь get_customer_info. Ровно как мы хотели.
  • «askdj aksjdh asjkdbhas kjdhas 1+1 ajsdh» — даже на абракадабру модель обязана вызвать один из инструментов, и вызывает.

Последний пункт — суть режима: any гарантирует не осмысленность, а форму. Ветка «инструмент не вызван» в коде остаётся страховкой, но при any она не должна срабатывать никогда.

Квиз 3

Что именно гарантирует режим any?

Упражнения

Возьмите любой скрипт с инструментами из предыдущих уроков и попробуйте.

Упражнение 5.1 — Извлечь данные из текста

Есть короткие объявления о работе. Нужно из каждого получить строго одинаковую структуру: должность, город, вилка «от», вилка «до», формат (офис / гибрид / удалёнка). Ответ должен быть машиночитаемым — его сразу пишут в базу. Как настроить запрос?

Решение упражненияСначала попробуйте сами — потом сверьтесь

Описываем нужную структуру как input_schema одного инструмента и принудительно вызываем именно его — режимом tool:

save_vacancy = {
    "name": "save_vacancy",
    "description": "Сохраняет разобранную вакансию в базу",
    "input_schema": {
        "type": "object",
        "properties": {
            "position": {"type": "string", "description": "Должность"},
            "city": {"type": "string", "description": "Город"},
            "salary_from": {"type": "number", "description": "Нижняя граница вилки"},
            "salary_to": {"type": "number", "description": "Верхняя граница вилки"},
            "format": {
                "type": "string",
                "enum": ["офис", "гибрид", "удалёнка"],
                "description": "Формат работы"
            }
        },
        "required": ["position", "city", "format"]
    }
}

response = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=1024,
    tools=[save_vacancy],
    tool_choice={"type": "tool", "name": "save_vacancy"},
    messages=[{"role": "user", "content": f"Разбери вакансию:\n<vacancy>{text}</vacancy>"}]
)
print(response.content[-1].input)

Два нюанса. Первый: enum в схеме избавляет от зоопарка вариантов вроде «remote», «из дома», «можно удалённо». Второй: вилка не всегда указана в объявлении, поэтому salary_from и salary_to не попали в required — иначе модель начнёт их выдумывать.

Модель здесь — самая дешёвая: claude-haiku-4-5. Задача извлечения по готовой схеме не требует Opus.

Упражнение 5.2 — Выбрать режим

Для каждого сценария решите, какой tool_choice уместен, и объясните почему:

  1. Ассистент в поддержке: отвечает на общие вопросы сам, а по конкретному заказу лезет в CRM.
  2. Голосовой бот: единственный выход наружу — инструмент speak, других каналов нет.
  3. Модерация: на каждый комментарий нужен вердикт с полями «категория» и «уверенность».
Решение упражненияСначала попробуйте сами — потом сверьтесь
  1. auto. Ровно тот случай, ради которого режим существует: часть вопросов закрывается знаниями модели, часть требует данных из CRM. И да — потребуется подробный системный промпт, где сказано, при каких условиях идти в CRM, иначе модель полезет туда на «здравствуйте».
  2. any. Инструментов может быть несколько (speak, hang_up, transfer_to_human), и какой нужен — зависит от реплики. Но текстовый ответ в этой архитектуре некуда деть, поэтому вызов обязателен.
  3. tool с именем инструмента-вердикта. Нужен один и тот же формат на каждом входе, без вариантов и без «на всякий случай поясню». Схема инструмента и есть контракт данных.

Что запомнить

  • tool_choice отвечает на вопрос «кто решает, вызывать ли инструмент»: модель или вы.
  • auto — по умолчанию. Модель выбирает сама; качество выбора целиком зависит от того, насколько подробно вы описали правила в промпте.
  • any — вызов обязателен, конкретный инструмент выбирает модель. Спасает архитектуры, где текстовому ответу просто некуда идти.
  • tool — вызов конкретного инструмента, имя указывается в name. Главный приём для структурированного вывода: input_schema становится схемой ответа.
  • Принуждение задаёт форму, а не смысл. Контекст задачи в промпте нужен в любом режиме.

Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.

Перевод и адаптация урока «Tool Choice» курса Tool Use © Anthropic, лицензия CC BY-NC 4.0. Перевод: Дарья Воронкина (@aishipuchka). Материал изменён: переведён на русский, примеры и названия моделей актуализированы, добавлены квизы и упражнения. Используется некоммерчески.