Полный цикл ReAct с заглушкой LLM

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

Цикл ReAct — пока нет финального ответа: спросить модель → распарсить → если действие, выполнить инструмент и добавить наблюдение → повторить.

Что соберём

Задача: «Сколько людей живёт в Токио, округлённо в миллионах?» Агент должен сначала найти население, затем при необходимости посчитать. У нас есть:

  • два инструмента — search (заглушка-база) и calc;
  • заглушка LLM, ведущая себя по правилам ReAct в зависимости от истории;
  • парсер и цикл, который всё связывает.

Заглушка детерминирована, поэтому трасса воспроизводима — вы видите механику настоящего агента без обращения к API.

Рабочий цикл целиком

import re

# --- инструменты ---
def search(q):
    db = {"население токио": "около 14 миллионов"}
    return db.get(q.lower().strip(), "не найдено")

def calc(expr):
    return str(eval(expr))

TOOLS = {"search": search, "calc": calc}

# --- заглушка LLM в формате ReAct ---
def react_llm(history):
    text = "\n".join(history)
    if "Observation:" not in text:
        return ("Thought: мне нужно узнать население Токио.\n"
                "Action: search[население Токио]")
    if "около 14 миллионов" in text:
        return ("Thought: население — около 14 миллионов, это и есть ответ.\n"
                "Final Answer: около 14 миллионов человек")
    return "Final Answer: не удалось"

# --- парсер ---
def parse(text):
    f = re.search(r"Final Answer:\s*(.*)", text)
    if f:
        return ("final", f.group(1).strip())
    a = re.search(r"Action:\s*(\w+)\[(.*?)\]", text)
    if a:
        return ("action", a.group(1), a.group(2))
    return ("unknown", text)

# --- цикл ReAct ---
def run(question, max_steps=5):
    history = [f"Question: {question}"]
    for step in range(1, max_steps + 1):
        block = react_llm(history)
        history.append(block)
        print(f"--- шаг {step} ---")
        print(block)
        parsed = parse(block)
        if parsed[0] == "final":
            return parsed[1]
        if parsed[0] == "action":
            name, arg = parsed[1], parsed[2]
            obs = TOOLS.get(name, lambda x: "нет инструмента")(arg)
            history.append(f"Observation: {obs}")
            print("Observation:", obs)
    return "лимит шагов исчерпан"

answer = run("Сколько людей живёт в Токио?")
print("=== ОТВЕТ ===")
print(answer)

Вывод:

--- шаг 1 ---
Thought: мне нужно узнать население Токио.
Action: search[население Токио]
Observation: около 14 миллионов
--- шаг 2 ---
Thought: население — около 14 миллионов, это и есть ответ.
Final Answer: около 14 миллионов человек
=== ОТВЕТ ===
около 14 миллионов человек

Что здесь произошло

  • Шаг 1: модель рассудила, что нужен поиск, и запросила инструмент. Цикл выполнил search и добавил наблюдение в историю.
  • Шаг 2: увидев наблюдение, модель решила, что ответ найден, и выдала Final Answer — цикл завершился.

Подменив react_llm на реальный вызов LLM с тем же промпт-форматом, вы получите настоящего ReAct-агента. Вся остальная инфраструктура — парсер, диспетчер, цикл, история — остаётся прежней.

Роль истории

Список history — это память агента. На каждом шаге он целиком уходит модели, чтобы та видела весь контекст: вопрос, прошлые мысли, действия и наблюдения. Без накопления истории агент «забывал» бы, что уже сделал, и зацикливался. Памяти посвящён следующий раздел.

Итог

  • Полный ReAct-агент = инструменты + заглушка/модель + парсер + цикл, связанные через историю.
  • Цикл повторяет «спросить → распарсить → выполнить → наблюдать», пока не появится финальный ответ.
  • История — это память: она целиком отдаётся модели, чтобы агент помнил весь ход решения.
Проверьте себя
1. Что делает цикл ReAct, получив от парсера результат типа «action»?
AЗавершает работу
BВыполняет соответствующий инструмент и добавляет его результат в историю как Observation
CИгнорирует и спрашивает модель снова
DВозвращает аргумент пользователю как ответ
2. Зачем на каждом шаге передавать модели всю накопленную историю?
AЧтобы увеличить стоимость
BЧтобы модель видела весь контекст — вопрос, прошлые мысли, действия и наблюдения — и не забывала, что уже сделала
CЭто требование регулярных выражений
DЧтобы ускорить парсер
3. Что нужно изменить в собранном цикле, чтобы получить настоящего ReAct-агента?
AПереписать парсер и диспетчер
BЗаменить детерминированную заглушку react_llm на реальный вызов LLM с тем же форматом
CУдалить историю
DОтказаться от инструментов
Поддержать проект