Зачем управлять выбором: когда auto не подходит

Представьте: вы строите пайплайн сбора данных. Агент должен обязательно вызвать fetch_data, прежде чем анализировать. Но в режиме auto модель иногда считает, что знает ответ без вызова инструмента, и отвечает напрямую. Пайплайн ломается.

Или другая задача: нужно извлечь структурированные данные из неструктурированного текста — имя, email, телефон. Можно попросить Claude ответить JSON, но тогда придётся парсить и валидировать текст. Есть способ лучше: использовать tool_choice в режиме tool, чтобы модель заполнила JSON Schema напрямую. Функцию при этом вызывать не нужно — аргументы блока tool_use и есть структурированный вывод.

Параметр tool_choice даёт три режима управления этим поведением:

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
auto модель решает сама Claude API tool_use если нужен инструмент text если знает ответ оба варианта допустимы tool_choice = {"type": "auto"} универсальные агенты, чат-боты any инструмент обязателен Claude API tool_use любой из переданных tools ✕ ответ без инструмента — запрещён tool_choice = {"type": "any"} пайплайны, сбор данных tool конкретный инструмент Claude API tool_use: search_web аргументы — выбор модели ✕ любой другой инструмент tool_choice = {"type": "tool", "name": "search_web"} экстракция, контроль пайплайна

Важно: tool_choice влияет на то, будет ли вызван инструмент, но не на то, какие аргументы модель ему передаст. Аргументы всегда определяет модель на основе контекста разговора.

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

Режим по умолчанию. Модель взвешивает, нужен ли инструмент для ответа на текущий запрос. Если вопрос требует актуальных данных — вызовет инструмент. Если может ответить по своим знаниям — ответит напрямую без вызова.

Это правильное поведение для универсальных агентов, где одни вопросы требуют данных (погода, цены, статусы), а другие — нет (объяснения, советы, код).

Как это выглядит в протоколе

Вопрос требует инструмента → tool_use
user → assistant (tool needed)
user: "Какая погода в Москве?"
assistant: stop_reason = "tool_use"
  [tool_use: get_weather({ city: "Москва" })]
Вопрос не требует инструмента → text
user → assistant (no tool needed)
user: "Что такое asyncio?"
assistant: stop_reason = "end_turn"
  [text: "asyncio — это библиотека..."]

Признак того, что модель вызвала инструмент: response.stop_reason == "tool_use". Признак прямого ответа: stop_reason == "end_turn". Проверяйте это значение в цикле агента, чтобы знать, нужно ли обрабатывать tool_use блоки.

python
import anthropic

client = anthropic.Anthropic()

tools = [{
    "name": "get_weather",
    "description": "Получает текущую погоду в городе",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "Название города"}
        },
        "required": ["city"],
    },
}]

# auto — значение по умолчанию, можно не указывать tool_choice вообще
response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "auto"},   # ← можно не писать: auto — дефолт
    messages=[{"role": "user", "content": "Какая погода в Москве?"}],
)

# stop_reason показывает решение модели:
# "tool_use"  → вызвала инструмент, нужно обработать tool_use блоки
# "end_turn"  → ответила напрямую, в content будет TextBlock
if response.stop_reason == "tool_use":
    for block in response.content:
        if block.type == "tool_use":
            print(f"Вызван: {block.name}")    # get_weather
            print(f"Аргументы: {block.input}") # {"city": "Москва"}
else:
    for block in response.content:
        if hasattr(block, "text"):
            print(block.text)  # прямой текстовый ответ
ℹ️ Когда выбирать auto

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

any: инструмент обязателен

Режим any заставляет модель вызвать хотя бы один из переданных инструментов. Прямой текстовый ответ без вызова инструмента — невозможен. stop_reason всегда будет "tool_use".

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

Протокол: модель вызывает, даже если «знает» ответ

any: tool_use гарантирован
user: "Расскажи что такое Python"
← в режиме auto модель ответила бы напрямую (знает ответ)

assistant: stop_reason = "tool_use" ← всегда, без исключений
  [tool_use: search_web({ query: "Python programming language" })]
python
tools = [
    {
        "name": "fetch_product_data",
        "description": "Получает актуальные данные о товаре из базы",
        "input_schema": {
            "type": "object",
            "properties": {
                "product_id": {"type": "string"},
                "fields": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Список полей для получения"
                },
            },
            "required": ["product_id"],
        },
    },
    {
        "name": "log_request",
        "description": "Логирует запрос для аудита",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "intent": {"type": "string"},
            },
            "required": ["query"],
        },
    },
]

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "any"},   # ← модель ОБЯЗАНА вызвать инструмент
    messages=[{
        "role": "user",
        "content": "Покажи товар SKU-42"
    }],
)

# stop_reason здесь всегда "tool_use" — гарантировано параметром
assert response.stop_reason == "tool_use"

for block in response.content:
    if block.type == "tool_use":
        print(f"Модель выбрала: {block.name}")
        print(f"Аргументы: {block.input}")
⚠️ any не контролирует, какой инструмент выбрать

Если передать несколько инструментов в режиме any, модель сама решит, какой из них вызвать. Если нужен конкретный — используйте режим tool. Если нужно несколько конкретных шагов — запускайте несколько запросов, переключая режим между шагами.

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

Самый строгий режим: модель обязана вызвать конкретный инструмент, указанный в name. Она всё ещё самостоятельно определяет аргументы на основе контекста, но выбора инструмента у неё нет.

Это полезно для двух совершенно разных задач: принудительного первого шага в пайплайне и — самое нестандартное применение — структурированной экстракции данных без реального вызова функции.

Базовое использование: принудительный вызов

python
tools = [
    {
        "name": "search_web",
        "description": "Ищет актуальную информацию в интернете",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Поисковый запрос"},
                "max_results": {"type": "integer", "default": 5},
            },
            "required": ["query"],
        },
    },
    {
        "name": "read_file",
        "description": "Читает файл с диска",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"}
            },
            "required": ["path"],
        },
    },
]

# Модель ОБЯЗАНА вызвать search_web — даже если read_file может быть полезнее
response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "search_web"},  # ← конкретный
    messages=[{"role": "user", "content": "Найди последние новости о Python 3.14"}],
)

for block in response.content:
    if block.type == "tool_use":
        # Гарантированно будет search_web, никогда read_file
        assert block.name == "search_web"
        print(block.input)  # {"query": "Python 3.14 новости", "max_results": 5}

Паттерн: структурированная экстракция без вызова функции

Это самый мощный и неочевидный способ использования режима tool. Идея: описать нужную структуру данных как «инструмент», принудить модель «вызвать» его, а потом прочитать аргументы этого вызова как структурированный вывод. Функцию при этом вызывать не нужно — block.input и есть результат.

Обычный подход: просить JSON в тексте
  user: "Верни JSON: {name, email, phone}"
  assistant: "```json\n{...}\n```"     ← нужно парсить, может прийти неверный JSON

Tool choice подход: модель заполняет схему
  tool_choice: {"type": "tool", "name": "extract_contact"}
  assistant: tool_use { name: "extract_contact", input: {name:..., email:..., phone:...} }
             ↑ block.input — уже валидный dict, соответствующий JSON Schema
          
python
"""
Структурированная экстракция через tool_choice.
Функция save_contact НЕ вызывается — мы только читаем её аргументы.
"""

# Описываем нужную структуру данных как "инструмент"
extract_tool = {
    "name": "save_contact",
    "description": "Сохраняет контактные данные, извлечённые из текста",
    "input_schema": {
        "type": "object",
        "properties": {
            "full_name":  {"type": "string",  "description": "Полное имя"},
            "email":      {"type": "string",  "description": "Email адрес"},
            "phone":      {"type": "string",  "description": "Номер телефона"},
            "company":    {"type": "string",  "description": "Компания или организация"},
            "position":   {"type": "string",  "description": "Должность"},
        },
        "required": ["full_name"],
    },
}

def extract_contact(text: str) -> dict:
    """Извлекает контактные данные из произвольного текста."""
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=512,
        tools=[extract_tool],
        tool_choice={"type": "tool", "name": "save_contact"},  # ← обязан заполнить схему
        messages=[{
            "role": "user",
            "content": f"Извлеки контактные данные из текста:\n\n{text}",
        }],
    )

    for block in response.content:
        if block.type == "tool_use" and block.name == "save_contact":
            # block.input — уже структурированный dict, прошедший через JSON Schema
            # Функцию НЕ вызываем, аргументы и есть результат экстракции
            return block.input

    return {}


# Использование
text = """
Привет, меня зовут Анна Михайлова, я директор по развитию в TechStart.
Напишите на anna.mikhailova@techstart.ru или звоните: +7 (495) 123-45-67.
"""

contact = extract_contact(text)
print(contact)
# {
#   "full_name": "Анна Михайлова",
#   "email": "anna.mikhailova@techstart.ru",
#   "phone": "+7 (495) 123-45-67",
#   "company": "TechStart",
#   "position": "директор по развитию"
# }
Почему это лучше, чем просить JSON в тексте

При экстракции через tool_choice модель не может вернуть невалидный JSON или добавить лишний текст вокруг структуры. Аргументы tool_use всегда являются валидным JSON, соответствующим указанной схеме. Вы получаете гарантированно типизированный словарь без парсинга.

Пакетная экстракция с Pydantic-валидацией

python
from pydantic import BaseModel
from typing import Optional

class Contact(BaseModel):
    full_name: str
    email: Optional[str] = None
    phone: Optional[str] = None
    company: Optional[str] = None
    position: Optional[str] = None

def extract_contacts_batch(texts: list[str]) -> list[Contact]:
    """Извлекает контакты из списка текстов."""
    results = []
    for text in texts:
        raw = extract_contact(text)  # возвращает dict из предыдущего примера
        try:
            results.append(Contact(**raw))
        except Exception as e:
            # Pydantic проверит типы и заполнит дефолты
            results.append(Contact(full_name="Unknown"))
    return results

Контроль параллельности: disable_parallel_tool_use

Когда Claude в режиме any или auto видит задачу, которую можно разбить на несколько независимых вызовов, он может вернуть несколько tool_use блоков в одном ответе (parallel tool calling). Иногда это нежелательно.

Флаг disable_parallel_tool_use: true ограничивает ответ одним инструментом за раз. Используйте его когда:

  • инструменты имеют побочные эффекты и не могут работать одновременно (запись в БД, отправка сообщений)
  • порядок вызовов важен — второй инструмент использует результат первого
  • вы хотите давать промежуточный фидбек пользователю после каждого шага
python
tools = [create_order_tool, send_email_tool, update_inventory_tool]

# Без флага — модель может попытаться вызвать несколько одновременно
# Это опасно: create_order и send_email могут выполниться до update_inventory
response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=2048,
    tools=tools,
    tool_choice={
        "type": "any",
        "disable_parallel_tool_use": True,   # ← строго один за раз
    },
    messages=messages,
)

# Теперь в каждом ответе будет ровно один tool_use блок.
# Агент выполнит шаги последовательно:
# 1. create_order → получили order_id
# 2. update_inventory(order_id=...)
# 3. send_email(order_id=...)

tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
assert len(tool_use_blocks) == 1   # гарантировано

Флаг работает со всеми тремя режимами: auto, any и tool. Для режима tool он избыточен (конкретный инструмент всегда один), но не сломает код.

ℹ️ Производительность vs надёжность

disable_parallel_tool_use увеличивает количество round-trip к API: вместо одного запроса с тремя параллельными вызовами вы делаете три отдельных запроса. Это медленнее, но безопаснее для операций с побочными эффектами.

Паттерны применения

Паттерн: принудительный первый шаг пайплайна

Агент-исследователь должен сначала обязательно найти актуальные данные, а потом анализировать их. В режиме auto модель иногда отвечает по своим знаниям, пропуская поиск. Решение: первый запрос с tool_choice: tool, последующие — с auto.

python
async def research_agent(query: str) -> str:
    """
    Агент-исследователь с гарантированным первым поиском.
    Шаг 1: принудительный search_web (tool_choice: tool)
    Шаг 2+: свободный режим auto
    """
    messages = [{"role": "user", "content": query}]

    # ── Шаг 1: гарантированный поиск ──────────────────
    response = await client.messages.create(
        model="claude-opus-4-6",
        max_tokens=512,
        tools=tools,
        tool_choice={"type": "tool", "name": "search_web"},  # ← обязательно
        messages=messages,
    )

    messages.append({"role": "assistant", "content": response.content})

    # Обрабатываем результаты поиска
    search_results = await handle_all_tool_calls(response.content)
    messages.append({"role": "user", "content": search_results})

    # ── Шаги 2+: свободный анализ ─────────────────────
    for _ in range(10):  # max_iterations
        response = await client.messages.create(
            model="claude-opus-4-6",
            max_tokens=4096,
            tools=tools,
            tool_choice={"type": "auto"},   # ← теперь модель решает сама
            messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text

        tool_results = await handle_all_tool_calls(response.content)
        messages.append({"role": "user", "content": tool_results})

    return "Достигнут лимит итераций"

Паттерн: маршрутизация задач через any

Часто первым шагом агент должен классифицировать запрос и направить его к нужному инструменту. Вместо отдельного вызова классификатора можно использовать any с набором «роутер-инструментов»: модель выберет подходящий и заполнит его аргументы.

python
# «Маршрутизирующие» инструменты — описания говорят когда их использовать
router_tools = [
    {
        "name": "handle_billing_query",
        "description": "Используй, если вопрос о счёте, оплате, подписке или возврате",
        "input_schema": {
            "type": "object",
            "properties": {
                "query_type": {
                    "type": "string",
                    "enum": ["invoice", "payment", "subscription", "refund"],
                },
                "user_message": {"type": "string"},
            },
            "required": ["query_type", "user_message"],
        },
    },
    {
        "name": "handle_technical_query",
        "description": "Используй, если вопрос технический: баги, интеграции, API",
        "input_schema": {
            "type": "object",
            "properties": {
                "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"]},
                "user_message": {"type": "string"},
            },
            "required": ["severity", "user_message"],
        },
    },
    {
        "name": "handle_general_query",
        "description": "Используй для всех остальных вопросов",
        "input_schema": {
            "type": "object",
            "properties": {"user_message": {"type": "string"}},
            "required": ["user_message"],
        },
    },
]

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=256,
    tools=router_tools,
    tool_choice={"type": "any"},     # ← обязан выбрать маршрут
    messages=[{"role": "user", "content": user_message}],
)

for block in response.content:
    if block.type == "tool_use":
        route = block.name           # "handle_billing_query" и т.д.
        args  = block.input          # уже заполненные поля
        await dispatch(route, args)  # направляем в нужный обработчик

Типичные ошибки

1. Вызывать функцию при экстракции через tool_choice
При структурированной экстракции через {"type": "tool", "name": "extract_contact"} смысл в том, что функция не вызывается. Разработчики по инерции пишут result = extract_contact(**block.input) — и функция выполняется там, где не должна.
✓ При экстракции читайте только block.input. Никаких вызовов функции.
2. Использовать any когда задача не всегда требует инструмента
Если передать {"type": "any"} агенту с вопросом «объясни что такое Python», модель вызовет какой-нибудь инструмент — потому что обязана. Это лишние токены, время и деньги.
any — для задач, которые по определению требуют вызова (сбор данных, формы, маршрутизация). Для общения — auto.
3. Ожидать, что tool управляет аргументами
{"type": "tool", "name": "search_web"} гарантирует что будет вызван search_web, но не гарантирует конкретный query. Аргументы определяет модель. Если модель передала невалидные аргументы — используйте Pydantic-валидацию из предыдущего урока.
✓ Всегда валидируйте аргументы через Pydantic даже в режиме tool.
4. Не включать нужный инструмент в список tools
При {"type": "tool", "name": "search_web"} инструмент search_web должен быть в массиве tools. Если его нет — API вернёт ошибку. Частая опечатка: несоответствие имени в tool_choice.name и в описании инструмента.
✓ Проверяйте что tool_choice.name точно совпадает с tools[i].name.
5. Не проверять stop_reason после any
В режиме any stop_reason гарантированно "tool_use". Но если перейти обратно на auto в следующем запросе — stop_reason может стать "end_turn". Код, который не проверяет stop_reason, сломается.
✓ Всегда проверяйте response.stop_reason перед тем как искать tool_use блоки.

Шпаргалка

auto

Модель решает: вызывать ли инструмент

  • Универсальные агенты
  • Чат-боты с инструментами
  • Смешанные задачи (часть требует инструментов, часть нет)
  • По умолчанию — если не знаешь что выбрать
any

Гарантированный вызов хотя бы одного

  • Пайплайны сбора данных
  • Обогащение записей (каждая строка через инструмент)
  • Маршрутизация/классификация запроса
  • Тестирование — проверить что инструмент вызывается
tool

Гарантированный конкретный инструмент

  • Структурированная экстракция данных
  • Принудительный первый шаг пайплайна
  • Контроль конкретного этапа workflow
  • Замена парсинга JSON из текста

Параметр disable_parallel_tool_use: добавьте "disable_parallel_tool_use": true в любой режим, если инструменты имеют побочные эффекты или порядок выполнения важен.

Паттерн структурированной экстракции:

  1. Опишите нужную структуру данных как инструмент с JSON Schema
  2. Передайте tool_choice={"type": "tool", "name": "..."}
  3. Прочитайте block.input — это и есть результат, функцию не вызывайте
  4. Опционально — провалидируйте через Pydantic

Переключение режимов в пайплайне: первый запрос — tool (гарантированный сбор данных), следующие — auto (анализ и ответ).

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

Задача 1. Напишите функцию extract_invoice(text: str) -> dict, которая принимает произвольный текст счёта-фактуры и извлекает: номер счёта, дату, сумму, название поставщика, название покупателя. Используйте паттерн структурированной экстракции через tool_choice. Добавьте Pydantic-модель для валидации результата. Проверьте на трёх текстах с разным форматом.

Задача 2. Реализуйте маршрутизатор запросов поддержки: три инструмента (handle_refund, handle_technical, handle_general), режим any. Напишите тест, который подаёт 10 разных сообщений и проверяет, что каждое направлено в ожидаемый маршрут. Добавьте логирование какой инструмент был вызван и почему (попросите модель добавить поле reason в схему).

Задача 3 (продвинутая). Реализуйте агента с принудительным первым шагом: (а) первый запрос с tool_choice: tool, name: search_web всегда ищет актуальные данные; (б) следующие запросы в режиме auto, но если агент снова хочет искать — это разрешено; (в) добавьте счётчик вызовов инструментов и выводите его в конце. Проверьте что агент не отвечает из своих данных на вопросы требующие актуальной информации.