Урок 5. tool_choice: кто решает, вызывать ли инструмент
До этого урока мы давали Claude инструменты и надеялись, что модель воспользуется ими в нужный момент. Иногда она пользуется. Иногда — вежливо отвечает текстом там, где нужен был вызов. А иногда лезет в калькулятор, когда её просили посчитать тональность твита.
В Claude API есть параметр tool_choice, который убирает эту неопределённость. У него три режима:
auto— Claude сам решает, вызывать инструмент или ответить текстом.any— Claude обязан вызвать какой-нибудь инструмент из списка, но какой именно — выбирает сам.tool— Claude обязан вызвать конкретный инструмент, который вы назвали.
Разберём каждый по очереди — и, что важнее, поймём, в каких задачах какой режим уместен.
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, вы подписались на написание подробного промпта. Параметр отдаёт решение модели — значит, модели нужны критерии решения.
Вы передали 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> здесь не украшение: он отделяет данные от инструкции, и твит вроде «игнорируй предыдущие указания» не превращается в команду.
Вам нужен строго предсказуемый 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 она не должна срабатывать никогда.
Что именно гарантирует режим 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 уместен, и объясните почему:
- Ассистент в поддержке: отвечает на общие вопросы сам, а по конкретному заказу лезет в CRM.
- Голосовой бот: единственный выход наружу — инструмент
speak, других каналов нет. - Модерация: на каждый комментарий нужен вердикт с полями «категория» и «уверенность».
Решение упражненияСначала попробуйте сами — потом сверьтесь
auto. Ровно тот случай, ради которого режим существует: часть вопросов закрывается знаниями модели, часть требует данных из CRM. И да — потребуется подробный системный промпт, где сказано, при каких условиях идти в CRM, иначе модель полезет туда на «здравствуйте».any. Инструментов может быть несколько (speak,hang_up,transfer_to_human), и какой нужен — зависит от реплики. Но текстовый ответ в этой архитектуре некуда деть, поэтому вызов обязателен.toolс именем инструмента-вердикта. Нужен один и тот же формат на каждом входе, без вариантов и без «на всякий случай поясню». Схема инструмента и есть контракт данных.
Что запомнить
tool_choiceотвечает на вопрос «кто решает, вызывать ли инструмент»: модель или вы.auto— по умолчанию. Модель выбирает сама; качество выбора целиком зависит от того, насколько подробно вы описали правила в промпте.any— вызов обязателен, конкретный инструмент выбирает модель. Спасает архитектуры, где текстовому ответу просто некуда идти.tool— вызов конкретного инструмента, имя указывается вname. Главный приём для структурированного вывода:input_schemaстановится схемой ответа.- Принуждение задаёт форму, а не смысл. Контекст задачи в промпте нужен в любом режиме.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.