Технологии

Паттерн ReAct: мысль действие наблюдение

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

Почти любой агент, который вы сегодня запустите, внутри устроен одинаково: модель думает, выбирает действие, получает результат, думает снова. Схема называется ReAct — от reasoning and acting — и её популярность объясняется тем, что она реализуется примерно за десять строк вокруг обычного вызова модели.

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

#Короткий ответ

ReAct — это цикл из трёх повторяющихся элементов:

  • Мысль — модель вслух рассуждает, что делать дальше;
  • Действие — модель печатает вызов инструмента;
  • Наблюдение — ваш код выполняет вызов и дописывает результат в контекст.

Цикл повторяется, пока модель не решит, что готова отвечать, или пока не сработает лимит. Каждое наблюдение остаётся в контексте до конца задачи — это и есть источник силы схемы и её главного ограничения одновременно.

#Схема цикла

   ┌──────────────────────────────────┐
   │  контекст: цель + всё, что было  │
   └──────────────┬───────────────────┘
                  ▼
            [ вызов модели ]
                  │
     ┌────────────┴───────────┐
     │                        │
 есть вызов              вызова нет
 инструмента                  │
     │                        ▼
     ▼                  ответ готов
 выполнить вызов
     │
     ▼
 дописать наблюдение
 в контекст ────────────▶ назад наверх

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

#Как выглядит трассировка

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

шаг 1  МЫСЛЬ      надо посмотреть, какие вообще есть файлы
       ДЕЙСТВИЕ   list_dir({"path": "."})
       НАБЛЮДЕНИЕ [ok] содержимое: (корень)
                    FILE notes.md 18420b
                    FILE setup.md 4110b
                    DIR  archive

шаг 2  МЫСЛЬ      «setup» звучит подходяще, читаю
       ДЕЙСТВИЕ   read_file({"path": "setup.md"})
       НАБЛЮДЕНИЕ [ok] setup.md
                    …4110 байт содержимого…

шаг 3  МЫСЛЬ      пароля тут нет, но есть отсылка к архиву
       ДЕЙСТВИЕ   list_dir({"path": "archive"})
       НАБЛЮДЕНИЕ [ok] содержимое: archive
                    FILE router-2024.md 890b

шаг 4  МЫСЛЬ      читаю
       ДЕЙСТВИЕ   read_file({"path": "archive/router-2024.md"})
       НАБЛЮДЕНИЕ [ok] router-2024.md
                    …890 байт…

шаг 5  МЫСЛЬ      нашёл, отвечаю
       ОТВЕТ      строка 12 файла archive/router-2024.md

Два наблюдения по этому логу, которые не видны в финальном ответе.

Первое. На шаге 2 модель прочитала файл целиком — все 4110 байт легли в контекст, хотя нужна была одна строка. Это типичный расход: инструмент отдаёт всё, что у него есть, а фильтровать некому.

Второе. Правильный путь был найден со второй попытки. Шаг 2 — это тупик, но он остался в контексте навсегда и участвует во всех последующих вызовах модели, увеличивая счёт и уменьшая свободное окно.

Чтение трассировки — основной навык отладки агентов: ошибка почти всегда видна на первом неверном наблюдении, а не в финальном ответе. Разбор конкретно зацикливаний — в статье про зацикливание и предохранители.

#Рост контекста по шагам

Посчитаем на том же прогоне. Возьмём условные объёмы: постановка задачи и описания инструментов — назовём это базой, каждое наблюдение — свой размер.

Шаг Что добавилось Что видит модель на входе
1 — база
2 список файлов база + список
3 4110 байт файла база + список + файл
4 список архива база + список + файл + список
5 890 байт файла всё вышеперечисленное

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

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

Точность падает в середине. Модели хуже находят информацию в середине длинного входа, чем в начале и в конце. Наблюдение с третьего шага, оказавшись в середине, может быть фактически не использовано; механика этого эффекта разобрана в материале про контекстное окно и потерю в середине.

Окно кончается раньше, чем задача. На локальной модели с небольшим окном это происходит на пятом-седьмом шаге при работе с файлами обычного размера.

Лечится это не «увеличить окно», а тремя приёмами: обрезать результат инструмента до полезного размера, не давать инструментам возвращать всё содержимое целиком и заканчивать цепочку раньше, разбивая задачу. В коде чата Persona стоят прямые ограничения: не более 4000 символов на один результат инструмента, не более 24000 суммарно за ход, не более 16 вызовов и не более шести раундов цикла — восьми в режиме углублённого ответа.

#Что из этого следует на практике

Лимит шагов — не защита от бесконечности, а бюджет. Он должен быть меньше, чем «сколько влезет», и рассчитываться от размера типичного наблюдения.

Обрезка результата важнее выбора модели. Инструмент, возвращающий выдержку вместо целого файла, продлевает жизнь цепочки сильнее, чем переход на модель с вдвое большим окном.

Тупиковые ветки надо уметь выбрасывать. Простой приём — не хранить в контексте наблюдения, которые модель сама пометила как бесполезные. Реализуется сложнее, чем звучит, но эффект заметный.

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

ReAct — не единственная схема. Для задач, где шаги известны заранее, план с фиксированными этапами дешевле и надёжнее; сравнение подходов — в разборе планирования и декомпозиции задач агентом.

#Где интуиция подводит

«Мысль в ReAct — это рассуждение модели, как в chain of thought». Похоже, но роль другая. В цепочке рассуждений промежуточные шаги нужны, чтобы получить лучший финальный текст, и их можно скрыть. В ReAct мысль — это выбор следующего действия, и она напрямую влияет на то, какие данные попадут в контекст. Разница разобрана в справке про chain of thought.

«Чем больше шагов разрешить, тем лучше результат». До определённого предела — да. После него растёт вероятность блуждания, а качество падает из-за переполненного контекста. На практике цепочки длиннее восьми-десяти шагов почти всегда означают, что задачу стоило разбить.

«Наблюдение — это истина». Наблюдение — это то, что вернул инструмент. Инструмент мог вернуть пустоту, ошибку в человекочитаемом виде или устаревшие данные, а модель воспримет всё это как факт и будет рассуждать дальше уверенно и неправильно.

«Если агент ошибся, надо улучшить финальный промпт». Почти всегда ошибка возникла на первом неверном наблюдении, а дальше модель рассуждала логично на неверных данных. Улучшать надо инструмент или его описание.

#Когда ReAct не нужен

Если последовательность шагов известна заранее и не зависит от результатов — напишите её кодом. Цикл нужен ровно тогда, когда следующее действие невозможно назвать, не увидев результата предыдущего. Во всех остальных случаях он добавляет стоимость, задержку и способ ошибиться.

Общая рамка того, где агентность оправдана, — в базовой статье о том, что такое ИИ-агент.

#Частые вопросы

#Чем ReAct отличается от простого вызова инструмента?

Числом итераций и тем, кто их инициирует. Одиночный вызов — это один ход: модель попросила, код выполнил, человек прочитал. В ReAct результат возвращается модели автоматически, и она делает следующий шаг сама, пока не остановится по собственному решению или по лимиту.

#Сколько шагов разумно разрешать агенту?

Начинать стоит с пяти и увеличивать, только если логи показывают, что цепочки регулярно упираются в предел по делу, а не из-за блуждания. Цепочки длиннее восьми-десяти шагов на практике почти всегда сигнализируют, что задачу нужно было разбить на несколько.

#Почему агент повторяет один и тот же вызов?

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

#Что делать, если контекст кончается на середине задачи?

Уменьшать не число шагов, а объём наблюдений: обрезать результаты инструментов, отдавать выдержки вместо целых файлов, выбрасывать наблюдения из тупиковых веток. Увеличение окна модели откладывает проблему, но не устраняет её.

#Подходит ли ReAct для локальной модели?

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

#Вывод

ReAct стоит десять строк кода и приносит с собой одно свойство, о котором в этих десяти строках не написано: контекст растёт с каждым наблюдением, и вместе с ним растут стоимость и вероятность потерять важное в середине.

Если вы строите цикл — сразу закладывайте обрезку результатов, дедупликацию вызовов и внятную обработку упирания в лимит. Если последовательность шагов известна заранее — не стройте цикл вовсе, напишите её кодом.

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт