ReAct (Reason + Act): пошаговое взаимодействие LLM с агентами

ReAct — один из самых популярных сегодня паттернов для организации взаимодействия Large Language Model (LLM) с внешними инструментами (агентами).

ReAct (Reason + Act): пошаговое взаимодействие LLM с агентами
Краткое содержание

Он позволяет модели пошагово:

  1. Размышлять (Reason) над входной задачей, разбивая её на несколько логических подзадач или шагов.
  2. Действовать (Act), вызывая «агентов» (инструменты, функции, API), передавая им аргументы и получая результаты.

Причём каждый очередной вызов агента может опираться на результат предыдущего шага. Давайте рассмотрим, для чего это нужно, и как это выглядит на практике.

1. Проблема: Одного вызова LLM недостаточно

Иногда задача пользователя требует нескольких последовательных действий. Например:

«Напомни мне через 2 минуты пойти гулять, но только если сейчас рабочее время с 9 до 18.»

Здесь нужно:

  1. Узнать текущее время.
  2. Проверить, попадает ли текущее время в диапазон (9–18).
  3. Если да — прибавить 2 минуты и вызвать агента напоминаний.
  4. Если нет — сообщить пользователю, что «напоминание не актуально».

Один вызов агента «reminder» не спасёт — нужно сначала вызвать агент «datetime» (и, возможно, «schedule»), а потом решить, делать ли «reminder».

Ручной код мог бы легко это реализовать, но если мы хотим, чтобы сама LLM (GPT) «рассуждала» и звала агентов, нам нужен некий паттерн, позволяющий модели делать несколько вызовов подряд, опираясь на результаты предыдущих вызовов.

2. Ключевая идея ReAct (Reason + Act)

2.1. Разделение Reasoning и Action

Reasoning (цепочка размышлений) — это то, как LLM внутренне решает задачу. Согласно лучшим практикам, мы не показываем эту цепочку размышлений пользователю в чистом виде (дабы не засорять ответ и не раскрывать внутреннюю логику), но она всё равно существует внутри модели.

Action — это когда модель решает: «Ага, чтобы выполнить подзадачу, мне нужно вызвать такого-то агента с такими-то аргументами».
Это «действие», которое бот совершает: он обращается к внешнему коду, инструментам, библиотекам, API.

2.2. Итеративный цикл

В ReAct-подходе взаимодействие выглядит так:

  1. (User) → (LLM): Пользователь задаёт вопрос или задачу.
  2. LLM (Reason): Модель размышляет, какой агент нужно вызвать.
  3. LLM (Act): Генерирует описание действия: «Вызвать агент X с аргументами Y».
  4. Система (наш бэкенд) вызывает этот агент, получает результат.
  5. Система добавляет результат вызова агента (Observation) обратно в контекст диалога.
  6. LLM (Reason) читает Observation и думает, что делать дальше. Может решить вызвать следующий агент.
  7. LLM (Act): «Теперь вызовем другой агент...»
  8. ... и так далее, пока модель не вернёт финальный «ответ» пользователю, без новых вызовов агентов.

3. Простейший пример «в вакууме» (диалог)

Пусть у нас есть LLM и два агента:

  1. datetime — возвращает текущее время.
  2. 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. Основные составляющие:

  1. system_prompt — инструкция для LLM, где описаны правила ReAct:
    • «Размышляй шаг за шагом скрыто»
    • «Когда решишь, что нужен агент, верни JSON с agent_calls»
    • «В итоге верни response для пользователя»
  2. Итеративная функция (например, get_ai_response) с циклом:
    • Отправляет модельные сообщения messages в LLM, получает вывод.
    • Если вывод содержит вызовы агентов (agent_calls), выполняет их и добавляет результаты обратно как «Observation».
    • Повторяет, пока модель не вернёт финальный ответ.
  3. Метод вызова агента (например, 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 слишком «умное» и решает задачу за один шаг, вместо того чтобы пошагово вызывать агенты. Чтобы подтолкнуть его к нужному сценарию:

  1. Системный промпт:
    • Явно укажите, что задача может требовать нескольких шагов.
    • Приведите пример, где LLM вызывает агенты несколько раз.
  2. Примеры диалогов (few-shot learning):
    • Дайте пару примеров, когда LLM разбивает задачу на шаги, вызывает «datetime», «calculator» и т.д., передаёт наблюдение, снова вызывает агента.
  3. Ужесточение инструкций:
    • «Запрещено решать всё в одном шаге, если требуется дополнительная информация от другого агента».
    • «Если пользователь просит что-то, где нужно текущее время, обязательно сначала вызывай datetime».

6. Другие варианты: Plan-and-Execute, Self-Ask, Controller+Executors

Помимо ReAct, есть и другие способы организовать многошаговый процесс. Но ReAct (Reason+Act) популярен тем, что модель сама выбирает, сколько шагов ей нужно, и в каком порядке вызывать агентов.

ReAct — удобный паттерн, когда:

  • Нужно динамически вызывать несколько агентов (инструментов) подряд.
  • LLM должна принимать решение пошагово, опираясь на результаты предыдущих вызовов.
  • Вы хотите скрыть внутреннюю логику (Reasoning) от пользователя, показывая лишь итоговый ответ и перечисление вызванных агентов (или вообще скрывать сами вызовы, если не нужно логгировать их наружу).

Основные шаги:

  1. Подготовить системный промпт, описывающий правила ReAct.
  2. Реализовать итеративный цикл:
    • LLM → JSON (response + agent_calls)
    • Если есть agent_calls, выполнить их, добавить Observation → новое сообщение в истории → следующий шаг.
  3. Дать LLM примеры, где оно действительно вызывает нескольких агентов, чтобы она понимала формат.

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

Nerd IT 🌀 ML, DS, ANN, GPT
Привет! Меня зовут Семён, я работаю в сфере ML и аналитики данных и пишу в блог nerdit.ru статьи о своем опыте и том, что может пригодиться начинающим в начале их пути изучения больших данных.

Подписаться на новости Nerd IT

Не пропустите последние выпуски. Зарегистрируйтесь сейчас, чтобы получить полный доступ к статьям.
jamie@example.com
Подписаться