Проблема: инструментов без рассуждений недостаточно
Представьте такой вопрос агенту: «Если 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 (Наблюдение) — результат инструмента: данные, которые возвращает внешняя система
Цикл продолжается до тех пор, пока агент не накопит достаточно информации — тогда вместо нового вызова инструмента он генерирует финальный ответ.
Как цикл отображается на 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 вызываете.
Шаг «Мышление» — это текстовый блок в том же ответе, что и tool_use. Он не требует отдельного вызова API. Claude решает сам, писать ли рассуждение перед действием — ваша задача через системный промпт направить его делать это явно.
Анатомия шага Thought: что генерирует Claude
В одном ответе Claude может выдать несколько блоков разных типов. Типичный ответ в середине ReAct-цикла выглядит так:
# Пример 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 блоков:
# Финальный ответ: 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?»
content: "Сколько часов было в 2024 году? Это больше или меньше, чем в 2023?"
Значит, 2024 — високосный год (366 дней). 2023 — обычный (365 дней).
Вычислю количество часов для каждого года через калькулятор.
input: {"expression": "366 * 24"}
input: {"expression": "365 * 24"}
В 2023 году — 8 760 часов (365 дней).
В 2024-м на 24 часа больше — ровно один дополнительный день.
Обратите внимание: именно текст мышления на шаге ② позволил агенту самостоятельно вспомнить правило високосного года и не галлюцинировать. Без явного рассуждения он мог бы попробовать один вызов с обоими числами сразу — и запутаться.
Полная реализация ReAct-агента
Перейдём к коду. ReAct-агент состоит из трёх частей: определение инструментов, системный промпт и основной цикл.
Инструменты и системный промпт
Системный промпт — ключевой рычаг управления поведением. Без явного указания Claude может не писать рассуждения вслух, особенно для простых задач. Промпт должен:
- Явно разрешить (и попросить) думать вслух перед каждым действием
- Задать структуру: сначала анализ, потом инструмент
- Сказать, что финальный ответ — только когда накоплено достаточно данных
"""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".
# ── Заглушки инструментов (замените на реальные реализации) ──────────────────
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}")
Вывод в терминале при запуске будет примерно таким:
Задача: Сколько часов было в 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". Это ваш сигнал для выхода из цикла.
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+ шагов, но нужен счётчик одинаковых действий
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} шагов — задача слишком сложная или агент застрял."
Агент может делать 10 разных, но бесполезных вызовов, и max_steps его не остановит вовремя. Добавляйте детекцию повторяющихся действий и мониторинг токенов — у длинных цепочек рассуждений быстро растёт контекст.
ReAct vs простой агент: когда что применять
Оба подхода используют одинаковый базовый цикл Anthropic API. Разница — в наличии явного шага рассуждения и в том, как вы проектируете систему вокруг этого шага.
| Характеристика | Простой агент | ReAct агент |
|---|---|---|
| Подходит для задач | 1–2 вызова инструментов, чёткая структура | Многошаговые, неструктурированные, требуют планирования |
| Качество рассуждений | Среднее — модель рассуждает неявно | Высокое — явный текст до действия |
| Трассируемость | Низкая — видны только вызовы | Высокая — видно, почему принято решение |
| Расход токенов | Меньше — нет текста мышления | Больше — текст мышления в контексте |
| Отладка и улучшение | Сложнее — непонятно, почему ошибся | Проще — мышление видно в логах |
| Галлюцинации при цепочках | Выше — нет промежуточной проверки | Ниже — каждый шаг верифицируется |
| Скорость ответа | Быстрее — меньше токенов на генерацию | Медленнее — рассуждения занимают время |
Практический ориентир — когда что брать:
- Чёткий пайплайн: запрос → 1-2 инструмента → ответ
- Структурированные данные: поиск в БД, API с предсказуемым форматом
- Высоконагруженные системы, где важна скорость
- Задачи с очевидной логикой, где LLM не нужно «думать»
- Многошаговый поиск: следующий запрос зависит от предыдущего результата
- Неструктурированные задачи без заранее известного числа шагов
- Когда важно видеть ход рассуждений для отладки или объяснения
- Задачи с высокой ценой ошибки — явное рассуждение снижает галлюцинации
Типичные ошибки
tool_use блоки и выбрасывают текст. В результате шаги Thought теряются: они не попадают в messages — хотя API их уже включил в ответ ассистента.response.content целиком как ответ ассистента — messages.append({"role": "assistant", "content": response.content}).tool_use блок. Структура уже есть.block.type == "tool_use" для действий, hasattr(block, "text") для мышления.Шпаргалка
Три шага 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-сообщение подсказку: «Предыдущие три поиска не дали нужного результата. Попробуй другую стратегию или сформулируй финальный ответ на основе собранных данных». Проверьте, как это меняет поведение агента на задачах с заведомо труднодоступной информацией.