ReAct (Reason + Act): пошаговое взаимодействие LLM с агентами
Он позволяет модели пошагово:
- Размышлять (Reason) над входной задачей, разбивая её на несколько логических подзадач или шагов.
- Действовать (Act), вызывая «агентов» (инструменты, функции, API), передавая им аргументы и получая результаты.
Причём каждый очередной вызов агента может опираться на результат предыдущего шага. Давайте рассмотрим, для чего это нужно, и как это выглядит на практике.
1. Проблема: Одного вызова LLM недостаточно
Иногда задача пользователя требует нескольких последовательных действий. Например:
«Напомни мне через 2 минуты пойти гулять, но только если сейчас рабочее время с 9 до 18.»
Здесь нужно:
- Узнать текущее время.
- Проверить, попадает ли текущее время в диапазон (9–18).
- Если да — прибавить 2 минуты и вызвать агента напоминаний.
- Если нет — сообщить пользователю, что «напоминание не актуально».
Один вызов агента «reminder» не спасёт — нужно сначала вызвать агент «datetime» (и, возможно, «schedule»), а потом решить, делать ли «reminder».
Ручной код мог бы легко это реализовать, но если мы хотим, чтобы сама LLM (GPT) «рассуждала» и звала агентов, нам нужен некий паттерн, позволяющий модели делать несколько вызовов подряд, опираясь на результаты предыдущих вызовов.
2. Ключевая идея ReAct (Reason + Act)
2.1. Разделение Reasoning и Action
Reasoning (цепочка размышлений) — это то, как LLM внутренне решает задачу. Согласно лучшим практикам, мы не показываем эту цепочку размышлений пользователю в чистом виде (дабы не засорять ответ и не раскрывать внутреннюю логику), но она всё равно существует внутри модели.
Action — это когда модель решает: «Ага, чтобы выполнить подзадачу, мне нужно вызвать такого-то агента с такими-то аргументами».
Это «действие», которое бот совершает: он обращается к внешнему коду, инструментам, библиотекам, API.
2.2. Итеративный цикл
В ReAct-подходе взаимодействие выглядит так:
- (User) → (LLM): Пользователь задаёт вопрос или задачу.
- LLM (Reason): Модель размышляет, какой агент нужно вызвать.
- LLM (Act): Генерирует описание действия: «Вызвать агент X с аргументами Y».
- Система (наш бэкенд) вызывает этот агент, получает результат.
- Система добавляет результат вызова агента (Observation) обратно в контекст диалога.
- LLM (Reason) читает Observation и думает, что делать дальше. Может решить вызвать следующий агент.
- LLM (Act): «Теперь вызовем другой агент...»
- ... и так далее, пока модель не вернёт финальный «ответ» пользователю, без новых вызовов агентов.
3. Простейший пример «в вакууме» (диалог)
Пусть у нас есть LLM и два агента:
datetime
— возвращает текущее время.reminder
— устанавливает напоминание.
Диалог в стиле ReAct может выглядеть так (упрощённо):
User: Напомни мне через 2 минуты пойти гулять
LLM (Reasoning, внутреннее):
- Нужно узнать текущее время (datetime).
- Потом прибавить 2 минуты и вызвать reminder.
LLM (Act -> system):
{ "agent": "datetime", "args": "current_time" }
System -> datetime: current_time
System <- datetime: "14:30"
System добавляет в контекст: "Observation: datetime вернул 14:30"
LLM (Reasoning, внутреннее):
- 14:30 + 2 минуты = 14:32
- Теперь вызвать reminder
LLM (Act -> system):
{ "agent": "reminder", "args": "пойти гулять, 14:32" }
System -> reminder: "пойти гулять, 14:32"
System <- reminder: "Напоминание установлено на 14:32"
System добавляет в контекст: "Observation: reminder вернул: Напоминание установлено"
LLM (Reasoning): Окей, задача выполнена.
LLM (response to user): Готово, я установил напоминание!
4. Как реализовать ReAct-протокол в коде
4.1. Основные составляющие:
- system_prompt — инструкция для LLM, где описаны правила ReAct:
- «Размышляй шаг за шагом скрыто»
- «Когда решишь, что нужен агент, верни JSON с
agent_calls
» - «В итоге верни
response
для пользователя»
- Итеративная функция (например,
get_ai_response
) с циклом:- Отправляет модельные сообщения
messages
в LLM, получает вывод. - Если вывод содержит вызовы агентов (
agent_calls
), выполняет их и добавляет результаты обратно как «Observation». - Повторяет, пока модель не вернёт финальный ответ.
- Отправляет модельные сообщения
- Метод вызова агента (например,
handle_agent_call
), который действительно вызывает нужный класс/функцию, передаёт аргументы и возвращает результат строкой.
4.2. Пример system_prompt под ReAct
self.system_prompt = (
"Ты — GPT-бот, способный решать задачи пошагово, используя парадигму ReAct: Reason + Act.\n\n"
"1) Reason (скрытый): Размышляешь над задачей, определяя, какие шаги нужно сделать.\n"
"2) Act: Вызываешь агентов, передавая им аргументы.\n\n"
"Список агентов:\n"
+ "\n".join(f"{agent['name']} — {agent['description']}" for agent in agents_info)
+ "\n\n"
"Возвращай результат в JSON-формате:\n"
"{\n"
' "response": "Конечный ответ пользователю",\n'
' "agent_calls": [\n'
' {"agent": "название_агента", "args": "аргументы"},\n'
' ...\n'
" ]\n"
"}\n\n"
"Если нужно несколько шагов, можешь вызывать агентов по очереди, получая результаты и используя их в следующем Reason.\n"
"Не показывай пользователю свои рассуждения, только итоговый ответ.\n"
)
4.3. Итеративное взаимодействие
Ниже — упрощённый код-скелет метода get_ai_response
, который реализует ReAct-цикл:
async def get_ai_response(self, user_message: str) -> str:
"""
Реализация ReAct:
- Подготавливаем систему + user.
- Циклом обращаемся к LLM, парсим вывод (JSON).
- Если есть agent_calls, исполняем их, добавляем "Observation" в контекст, повторяем.
- Если нет, возвращаем финальный 'response'.
"""
# 1) Читаем ключи и настраиваем клиента
api_key = os.getenv("GPT_API_KEY")
if not api_key:
return "Внутренняя ошибка: нет API ключа GPT."
base_url = os.getenv("GPT_BASE_URL", "https://api.openai.com/v1")
gpt_model = os.getenv("GPT_MODEL", "gpt-3.5-turbo")
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
# 2) Начальные сообщения (system + user)
messages = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": user_message},
]
for step_i in range(10): # ограничение на кол-во итераций
# 3) Запрос к LLM
resp = await client.chat.completions.create(
model=gpt_model,
messages=messages,
max_tokens=1500,
temperature=0.7,
)
answer = resp.choices[0].message.content.strip()
# 4) Пытаемся распарсить JSON
try:
data = json.loads(answer)
except:
# Если формат не JSON, возможно LLM решило закончить
# (или нарушило правила). Возвращаем как есть
return answer
# 5) Извлекаем response и agent_calls
response_text = data.get("response", "")
agent_calls = data.get("agent_calls", [])
if not agent_calls:
# LLM решил завершить без вызова агентов
return response_text
# 6) Если есть вызовы агентов, исполняем их по порядку
observation_text = ""
for call in agent_calls:
agent_name = call.get("agent", "").lstrip("/")
args = call.get("args", "")
# Вызываем метод handle_agent_call
result = await self.handle_agent_call(agent_name, args)
observation_text += f"Результат агента {agent_name}: {result}\n"
# 7) Добавляем observation в контекст, чтобы LLM мог "видеть" результат
messages.append({
"role": "assistant",
"content": observation_text
})
# Переходим к следующей итерации: LLM учтёт новое observation
# Если цикл закончился, а LLM не выдало финального ответа:
return "Превышен лимит итераций."
4.4. Метод handle_agent_call
Предположим, у нас есть менеджер агентов (AgentManager
), где command_map
хранит соотнесение agent_name -> объект агента
.
async def handle_agent_call(self, agent_name: str, args: str) -> str:
agent = self.agent_manager.command_map.get(agent_name)
if not agent:
return f"Ошибка: Агент {agent_name} не найден."
# Формируем «фейковое» сообщение (или используем реальное), если агент требует Message
class FakeMessage:
def __init__(self):
self.from_user = type("User", (), {"id": 999999})
self.chat = type("Chat", (), {"id": 999999})
fake_msg = FakeMessage()
response = await agent.handle(args, fake_msg)
return response
Таким образом, если LLM вернул что-то вроде:
{
"response": "Сейчас обращусь к агенту",
"agent_calls": [
{
"agent": "datetime",
"args": "current_time"
}
]
}
код «выполнит» этот вызов, запустит метод datetime_agent.handle("current_time", fake_msg)
и получит результат.
5. Как заставить LLM делать многошаговые вызовы
Бывают случаи, когда LLM слишком «умное» и решает задачу за один шаг, вместо того чтобы пошагово вызывать агенты. Чтобы подтолкнуть его к нужному сценарию:
- Системный промпт:
- Явно укажите, что задача может требовать нескольких шагов.
- Приведите пример, где LLM вызывает агенты несколько раз.
- Примеры диалогов (few-shot learning):
- Дайте пару примеров, когда LLM разбивает задачу на шаги, вызывает «datetime», «calculator» и т.д., передаёт наблюдение, снова вызывает агента.
- Ужесточение инструкций:
- «Запрещено решать всё в одном шаге, если требуется дополнительная информация от другого агента».
- «Если пользователь просит что-то, где нужно текущее время, обязательно сначала вызывай
datetime
».
6. Другие варианты: Plan-and-Execute, Self-Ask, Controller+Executors
Помимо ReAct, есть и другие способы организовать многошаговый процесс. Но ReAct (Reason+Act) популярен тем, что модель сама выбирает, сколько шагов ей нужно, и в каком порядке вызывать агентов.
ReAct — удобный паттерн, когда:
- Нужно динамически вызывать несколько агентов (инструментов) подряд.
- LLM должна принимать решение пошагово, опираясь на результаты предыдущих вызовов.
- Вы хотите скрыть внутреннюю логику (Reasoning) от пользователя, показывая лишь итоговый ответ и перечисление вызванных агентов (или вообще скрывать сами вызовы, если не нужно логгировать их наружу).
Основные шаги:
- Подготовить системный промпт, описывающий правила ReAct.
- Реализовать итеративный цикл:
- LLM → JSON (response + agent_calls)
- Если есть
agent_calls
, выполнить их, добавить Observation → новое сообщение в истории → следующий шаг.
- Дать LLM примеры, где оно действительно вызывает нескольких агентов, чтобы она понимала формат.
С таким подходом вы получите гибкую систему, в которой LLM может разруливать сложные задачи, разбивая их на несколько шагов и вызывая нужные инструменты по мере необходимости.