Проблема: инструментов без рассуждений недостаточно

Представьте такой вопрос агенту: «Если Apple сегодня стоит $220 за акцию, а год назад — $175, то на сколько процентов выросла компания? Сколько лет потребуется при таком темпе, чтобы утроить капитализацию?»

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

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

📄 Откуда паттерн

ReAct описан в статье «ReAct: Synergizing Reasoning and Acting in Language Models» (Yao et al., 2022, arxiv.org/abs/2210.03629). Авторы показали, что явные шаги рассуждения перед каждым действием значительно улучшают качество на задачах, требующих многоэтапного поиска — HotpotQA, Fever, AlfWorld. С тех пор ReAct стал де-факто стандартной архитектурой для агентов с инструментами.

Архитектура ReAct: три шага в цикле

ReAct структурирует работу агента как бесконечно повторяющийся цикл из трёх шагов — до тех пор, пока агент не решит, что готов ответить:

  • Thought (Мышление) — явное рассуждение: что известно, чего не хватает, какой следующий шаг логичен
  • Action (Действие) — вызов инструмента: поиск, вычисление, запрос к API или базе данных
  • Observation (Наблюдение) — результат инструмента: данные, которые возвращает внешняя система

Цикл продолжается до тех пор, пока агент не накопит достаточно информации — тогда вместо нового вызова инструмента он генерирует финальный ответ.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
↺ повтор пока не накоплено достаточно информации Задача user message 💭 Мышление text block в ответе «Нужно сначала найти X, затем рассчитать Y...» stop_reason: "tool_use" или "end_turn" ↓ ⚙️ Действие tool_use block 👁 Наблюдение tool_result block stop_reason = "end_turn" ✅ Финальный ответ text block в ответе инструменты не вызываются

Как цикл отображается на Claude API

Важная деталь: все три шага ReAct — это обычные элементы стандартного цикла сообщений Anthropic API. Никаких специальных методов нет:

Thought      →  text block в response.content
Action       →  tool_use block в response.content  (stop_reason = "tool_use")
Observation  →  tool_result block в следующем user-сообщении

Финал        →  text block в response.content      (stop_reason = "end_turn")

Claude генерирует шаги Thought и Action в одном ответе: сначала текст с рассуждением, затем вызов инструмента. Вы выполняете инструмент и отправляете результат обратно как Observation. Повторяете, пока не придёт финальный ответ.

Это значит, что ReAct-агент — это тот же базовый агентный цикл из урока «Реактивный цикл», но с осознанным использованием текстовых блоков для рассуждений. Паттерн определяет как вы проектируете систему, а не какой API вызываете.

💡 Thought — это текст, не отдельный вызов

Шаг «Мышление» — это текстовый блок в том же ответе, что и tool_use. Он не требует отдельного вызова API. Claude решает сам, писать ли рассуждение перед действием — ваша задача через системный промпт направить его делать это явно.

Анатомия шага Thought: что генерирует Claude

В одном ответе Claude может выдать несколько блоков разных типов. Типичный ответ в середине ReAct-цикла выглядит так:

python
# Пример response.content при stop_reason = "tool_use"
[
    TextBlock(
        type="text",
        text="Нужно вычислить процентный рост: (220 - 175) / 175 × 100. "
             "Подставлю числа в калькулятор."
    ),
    ToolUseBlock(
        type="tool_use",
        id="toolu_01XYZ",
        name="calculate",
        input={"expression": "(220 - 175) / 175 * 100"}
    )
]

Два важных момента:

  • Мышление первым — Claude обычно пишет текст до вызова инструмента. Это не случайность: трансформер генерирует токены слева направо, и рассуждение в начале буквально влияет на выбор аргументов инструмента. Именно поэтому явный Thought улучшает качество Action.
  • Несколько tool_use — если в одном ответе несколько блоков tool_use, это параллельные вызовы. Если один — последовательное рассуждение «одно действие за раз».

Финальный ответ отличается от промежуточного только отсутствием tool_use блоков:

python
# Финальный ответ: stop_reason = "end_turn", нет tool_use
[
    TextBlock(
        type="text",
        text="Акции Apple выросли на 25.7% ((220-175)/175×100). "
             "При таком темпе роста в год, до утроения (~200% роста) "
             "потребуется примерно 5.2 года (решение: 1.257^n = 3 → n = log(3)/log(1.257))."
    )
]

Конкретная трасса: два шага рассуждения

Разберём полный цикл на примере. Задача: «Сколько часов было в 2024 году? Это больше или меньше, чем в 2023?»

① Задача от пользователя
role: "user"
content: "Сколько часов было в 2024 году? Это больше или меньше, чем в 2023?"
② Мышление (text block)
2024 ÷ 4 = 506 — делится на 4. 2024 ÷ 100 = 20.24 — не делится на 100.
Значит, 2024 — високосный год (366 дней). 2023 — обычный (365 дней).
Вычислю количество часов для каждого года через калькулятор.
③ Действие (tool_use)
name: "calculate"
input: {"expression": "366 * 24"}
④ Наблюдение (tool_result)
"8784"
⑤ Мышление (text block)
В 2024 году — 8784 часа. Теперь вычислю для 2023 (365 дней).
⑥ Действие (tool_use)
name: "calculate"
input: {"expression": "365 * 24"}
⑦ Наблюдение (tool_result)
"8760"
⑧ Финальный ответ (stop_reason = "end_turn")
В 2024 году было 8 784 часа (год високосный, 366 дней).
В 2023 году — 8 760 часов (365 дней).
В 2024-м на 24 часа больше — ровно один дополнительный день.

Обратите внимание: именно текст мышления на шаге ② позволил агенту самостоятельно вспомнить правило високосного года и не галлюцинировать. Без явного рассуждения он мог бы попробовать один вызов с обоими числами сразу — и запутаться.

Полная реализация ReAct-агента

Перейдём к коду. ReAct-агент состоит из трёх частей: определение инструментов, системный промпт и основной цикл.

Инструменты и системный промпт

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

  • Явно разрешить (и попросить) думать вслух перед каждым действием
  • Задать структуру: сначала анализ, потом инструмент
  • Сказать, что финальный ответ — только когда накоплено достаточно данных
python
"""react_agent.py — ReAct агент: Мышление → Действие → Наблюдение"""
import anthropic

client = anthropic.Anthropic()

# ── Инструменты ──────────────────────────────────────────────────────────────

TOOLS = [
    {
        "name": "search",
        "description": (
            "Поиск информации в интернете. Используй для получения"
            " фактов, данных, определений, которых нет в обучающих данных."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Поисковый запрос на русском или английском"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "calculate",
        "description": (
            "Вычисляет математическое выражение Python. Используй для"
            " арифметики, когда нужен точный числовой результат."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "Выражение Python, например '365 * 24' или '(100 - 37) / 100'"
                }
            },
            "required": ["expression"]
        }
    }
]

# ── Системный промпт: явно включаем шаг рассуждения ──────────────────────────

SYSTEM = """Ты — аналитический ассистент. Решай задачи поэтапно:

1. Перед каждым действием напиши, что планируешь сделать и почему
2. Используй инструменты для получения точных данных — не угадывай
3. После каждого результата анализируй: достаточно ли данных, или нужен следующий шаг
4. Отвечай финальным текстом только тогда, когда уверен в ответе

Рассуждай вслух — это помогает замечать ошибки логики до вызова инструмента."""

Цикл агента

Основной цикл прост: отправляем запрос, читаем ответ, если есть вызовы инструментов — выполняем и отправляем результаты, повторяем. Выходим, когда stop_reason == "end_turn".

python
# ── Заглушки инструментов (замените на реальные реализации) ──────────────────

def execute_tool(name: str, args: dict) -> str:
    if name == "calculate":
        try:
            # В продакшне: используй ast-based eval или math-библиотеку
            result = eval(args["expression"])  # noqa: S307
            return str(result)
        except Exception as e:
            return f"Ошибка вычисления: {e}"
    if name == "search":
        # Здесь: реальная интеграция с поисковиком (Tavily, SerpAPI, etc.)
        return f"[Заглушка] Результаты по запросу: {args['query']}"
    return f"Неизвестный инструмент: {name}"


# ── Главный цикл ReAct ────────────────────────────────────────────────────────

def react_agent(task: str, max_steps: int = 10) -> str:
    """
    ReAct-агент: цикл Мышление → Действие → Наблюдение до финального ответа.
    Возвращает финальный текстовый ответ агента.
    """
    messages = [{"role": "user", "content": task}]

    for step in range(1, max_steps + 1):
        print(f"\n{'─' * 60}")
        print(f"Шаг {step}")

        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=4096,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )

        # Добавляем ответ ассистента в историю
        messages.append({"role": "assistant", "content": response.content})

        # Выводим мышление и действия для отладки
        for block in response.content:
            if hasattr(block, "text") and block.text:
                print(f"\n💭 Мышление:\n{block.text.strip()}")
            elif block.type == "tool_use":
                print(f"\n⚙️  Действие: {block.name}({block.input})")

        # Условие выхода: модель ответила без вызова инструментов
        if response.stop_reason == "end_turn":
            for block in response.content:
                if hasattr(block, "text") and block.text:
                    return block.text.strip()
            return "(нет текстового ответа)"

        # Выполняем инструменты → формируем Наблюдения
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                observation = execute_tool(block.name, block.input)
                print(f"👁️  Наблюдение [{block.name}]: {observation}")
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": observation,
                })

        # Возвращаем наблюдения в контекст — следующий виток цикла
        messages.append({"role": "user", "content": tool_results})

    return f"Достигнут лимит в {max_steps} шагов без финального ответа."


# ── Запуск ────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    task = "Сколько часов было в 2024 году? Это больше или меньше, чем в 2023?"
    print(f"Задача: {task}")

    answer = react_agent(task)

    print(f"\n{'═' * 60}")
    print(f"✅ Финальный ответ:\n{answer}")

Вывод в терминале при запуске будет примерно таким:

bash
Задача: Сколько часов было в 2024 году? Это больше или меньше, чем в 2023?

────────────────────────────────────────────────────────────
Шаг 1

💭 Мышление:
2024 делится на 4 (2024/4 = 506), значит он потенциально високосный.
Проверю делимость на 100: 2024/100 = 20.24 — не делится. Значит 2024 — високосный год (366 дней).
2023 — обычный год (365 дней). Вычислю часы.

⚙️  Действие: calculate({'expression': '366 * 24'})
👁️  Наблюдение [calculate]: 8784

────────────────────────────────────────────────────────────
Шаг 2

💭 Мышление:
В 2024 году 8784 часа. Теперь 2023 (365 × 24).

⚙️  Действие: calculate({'expression': '365 * 24'})
👁️  Наблюдение [calculate]: 8760

────────────────────────────────────────────────────────────
Шаг 3

💭 Мышление:
Все данные собраны: 2024 — 8784 ч, 2023 — 8760 ч. Разница = 24 часа.

════════════════════════════════════════════════════════════
✅ Финальный ответ:
В 2024 году было 8 784 часа (366 дней, год високосный).
В 2023 году — 8 760 часов (365 дней).
В 2024-м на 24 часа больше.
Трасса — главный инструмент отладки

Сохраняйте текстовые блоки мышления в лог при разработке. Они показывают, почему агент принял то или иное решение — это в разы упрощает отладку, по сравнению с агентами без явного рассуждения.

Управление циклом: остановка и предотвращение зависания

Цикл ReAct должен обязательно завершиться — агент не должен работать вечно. У вас два механизма завершения: естественный (агент сам решает ответить) и принудительный (вы ограничиваете итерации).

Естественный выход: stop_reason = "end_turn"

Когда Claude решает, что у него достаточно данных, он генерирует финальный текстовый ответ без tool_use блоков, и API возвращает stop_reason = "end_turn". Это ваш сигнал для выхода из цикла.

python
def extract_final_answer(response) -> str | None:
    """
    Возвращает текст финального ответа, если цикл завершён.
    Возвращает None, если агент ещё вызывает инструменты.
    """
    if response.stop_reason != "end_turn":
        return None  # продолжаем цикл

    # Собираем все текстовые блоки (обычно один)
    texts = [
        block.text
        for block in response.content
        if hasattr(block, "text") and block.text
    ]
    return "\n".join(texts) if texts else None

Принудительный выход: max_steps

Без ограничения агент может зациклиться — например, если каждый поисковый запрос возвращает «не найдено» и агент пробует снова и снова. Параметр max_steps обязателен.

Сколько шагов давать? Зависит от задачи:

  • Фактические вопросы — 5–8 шагов достаточно для большинства случаев
  • Аналитические задачи — 10–15 шагов для задач с несколькими источниками
  • Исследовательские агенты — 20+ шагов, но нужен счётчик одинаковых действий
python
def react_agent_robust(task: str, max_steps: int = 10) -> str:
    messages = [{"role": "user", "content": task}]
    seen_actions: list[tuple[str, str]] = []  # (tool_name, args_hash)

    for step in range(1, max_steps + 1):
        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=4096,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        # Естественный выход
        if response.stop_reason == "end_turn":
            return extract_final_answer(response) or "(нет ответа)"

        # Защита от зацикливания: те же аргументы дважды подряд
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue

            action_key = (block.name, str(sorted(block.input.items())))
            if action_key in seen_actions[-3:]:
                # Агент повторяет одни и те же вызовы — прерываем
                return (
                    f"Агент застрял после {step} шагов: "
                    f"повторный вызов {block.name} с теми же аргументами."
                )
            seen_actions.append(action_key)

            observation = execute_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": observation,
            })

        messages.append({"role": "user", "content": tool_results})

    # Принудительный выход при превышении лимита
    return f"Превышен лимит {max_steps} шагов — задача слишком сложная или агент застрял."
⚠️ Не полагайтесь только на max_steps

Агент может делать 10 разных, но бесполезных вызовов, и max_steps его не остановит вовремя. Добавляйте детекцию повторяющихся действий и мониторинг токенов — у длинных цепочек рассуждений быстро растёт контекст.

ReAct vs простой агент: когда что применять

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

Характеристика Простой агент ReAct агент
Подходит для задач 1–2 вызова инструментов, чёткая структура Многошаговые, неструктурированные, требуют планирования
Качество рассуждений Среднее — модель рассуждает неявно Высокое — явный текст до действия
Трассируемость Низкая — видны только вызовы Высокая — видно, почему принято решение
Расход токенов Меньше — нет текста мышления Больше — текст мышления в контексте
Отладка и улучшение Сложнее — непонятно, почему ошибся Проще — мышление видно в логах
Галлюцинации при цепочках Выше — нет промежуточной проверки Ниже — каждый шаг верифицируется
Скорость ответа Быстрее — меньше токенов на генерацию Медленнее — рассуждения занимают время

Практический ориентир — когда что брать:

Простой агент
  • Чёткий пайплайн: запрос → 1-2 инструмента → ответ
  • Структурированные данные: поиск в БД, API с предсказуемым форматом
  • Высоконагруженные системы, где важна скорость
  • Задачи с очевидной логикой, где LLM не нужно «думать»
ReAct агент
  • Многошаговый поиск: следующий запрос зависит от предыдущего результата
  • Неструктурированные задачи без заранее известного числа шагов
  • Когда важно видеть ход рассуждений для отладки или объяснения
  • Задачи с высокой ценой ошибки — явное рассуждение снижает галлюцинации

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

1. Системный промпт не просит рассуждать вслух
Без явного указания Claude часто пропускает шаг Thought и сразу вызывает инструмент. Для простых задач это нормально, но для сложных многошаговых — ухудшает качество. «Думать вслух» нужно явно разрешить.
✓ Добавьте в системный промпт: «Перед каждым действием напиши, что планируешь сделать и почему».
2. Нет max_steps или он слишком большой
Агент без ограничения шагов может работать бесконечно — особенно если инструменты возвращают нерелевантные данные и агент пробует снова. Слишком большой max_steps тоже опасен: контекст заполнится, и стоимость запроса вырастет экспоненциально.
✓ Установите реалистичный max_steps (5–15) и добавьте мониторинг длины контекста.
3. Игнорирование текстовых блоков в ответе
Некоторые реализации обрабатывают только tool_use блоки и выбрасывают текст. В результате шаги Thought теряются: они не попадают в messages — хотя API их уже включил в ответ ассистента.
✓ Добавляйте response.content целиком как ответ ассистента — messages.append({"role": "assistant", "content": response.content}).
4. Попытка распарсить Thought вручную
Некоторые пытаются извлечь «Thought:», «Action:» из текста через регулярки — как в оригинальных промптах ReAct под GPT-3. В Claude API это не нужно: мышление — это текстовый блок, действие — tool_use блок. Структура уже есть.
✓ Используйте нативные блоки Claude API: block.type == "tool_use" для действий, hasattr(block, "text") для мышления.
5. Слишком подробный системный промпт ограничивает гибкость
Промпт вида «ШАГ 1: всегда делай X, ШАГ 2: потом Y» превращает ReAct в жёсткий пайплайн. Смысл паттерна — в том, что агент сам решает, что делать дальше на основе наблюдений.
✓ Задавайте принципы (думать перед действием, верифицировать данные), а не жёсткую последовательность шагов.

Шпаргалка

Три шага ReAct:

  • Thought → text block в response.content
  • Action → tool_use block в response.content (stop_reason = "tool_use")
  • Observation → tool_result block в следующем user-сообщении
  • Final Answer → text block, stop_reason = "end_turn", нет tool_use

Каркас цикла:

  • Инициализация: messages = [{"role": "user", "content": task}]
  • Добавить ответ ассистента: messages.append({"role": "assistant", "content": response.content})
  • Выход при stop_reason == "end_turn" → вернуть текстовый блок
  • Иначе — выполнить инструменты, добавить tool_results, повторить

Системный промпт:

  • Попросить думать вслух перед каждым действием
  • Задать принцип: сначала анализ, потом инструмент
  • Указать, что финальный ответ — только когда достаточно данных

Защита от зависания:

  • max_steps=10 — разумное значение по умолчанию
  • Детект повторных вызовов с теми же аргументами
  • Мониторинг длины контекста через response.usage.input_tokens

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

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

Задача 1. Скопируйте код из урока и добавьте реальный инструмент поиска через любой доступный API (Tavily, DuckDuckGo, Wikipedia). Запустите агента с вопросом, требующим минимум двух поисковых запросов — например: «Кто основал компанию, разработавшую Python? В каком году он родился?» Убедитесь, что трасса мышления отображает логику переходов между запросами.

Задача 2. Добавьте в агент подсчёт токенов на каждом шаге через response.usage. Запустите одну и ту же задачу с разными системными промптами — с явным требованием рассуждать вслух и без него. Сравните: качество ответа, количество шагов, суммарный расход токенов.

Задача 3 (продвинутая). Реализуйте детекцию «застревания»: если агент три раза подряд делает семантически похожие вызовы (не обязательно идентичные), добавьте в следующее user-сообщение подсказку: «Предыдущие три поиска не дали нужного результата. Попробуй другую стратегию или сформулируй финальный ответ на основе собранных данных». Проверьте, как это меняет поведение агента на задачах с заведомо труднодоступной информацией.