AI / LLM
Цикл агента: ReAct, неуправляемые шаги и контекст, растущий каждый ход
Алерт у дежурного — это всплеск по счёту, а не падение. Один запрос агента, который должен стоить $0.08, крутится уже девять минут и сжёг около $12. Открываешь трейс: агент вызвал search_orders, получил пустой результат, вызвал search_orders снова с теми же аргументами, получил тот же пустой результат — и сделал так ещё 140 раз. Ни исключения, ни падения: каждый шаг был валидным вызовом модели с валидным вызовом инструмента. У цикла просто не было причины остановиться, и никто не сказал ему, когда это сделать.
Цикл — это пять строк
Сними обёртку фреймворков, и LLM-агент окажется почти неловко простым. Это паттерн ReAct — Reason, Act, Observe (рассуждай, действуй, наблюдай) — обёрнутый в while:
messages = [system, user_task]
while True:
response = model(messages, tools) # THINK: модель рассуждает + решает
if not response.tool_calls: break # инструмент не нужен → это ответ
result = run_tool(response.tool_calls) # ACT: выполнить выбранный инструмент
messages.append(response) # запомнить, что решила
messages.append(result) # OBSERVE: вернуть результат обратноЭто весь движок. Модель смотрит на диалог, решает — вызвать инструмент или ответить, и если она вызвала инструмент, ты его выполняешь и отдаёшь вывод обратно как следующее наблюдение. Модель сама ничего не выполняет — это делает твой код. «Агентность» только в том, что следующее действие на каждом ходу выбирает модель, а не твой control flow. Всё трудное в агентах в проде — следствие этих двух фактов: цикл может выполниться сколько угодно раз, и messages становится длиннее на каждом проходе.
Контекст накапливается, и цена не линейна
Вот что упускают джуны. Каждая итерация добавляет в messages рассуждение модели, её вызов инструмента и результат инструмента, а затем ты отправляешь модели всю историю снова на следующем ходу. Ты платишь не за один шаг — ты платишь за перечитывание всего транскрипта на каждом шаге.
Если шаг n добавляет примерно k токенов, то к шагу N каждый вызов читает около N·k токенов, а суммарная стоимость прогона — это сумма 1+2+3+…+N, квадратичная по числу шагов. Задача в 5 шагов дешёвая. Задача в 15 шагов — это не тройная стоимость, а ближе к восьмикратной, плюс latency, ведь каждый вызов ещё и ждёт промпт побольше. Reflexion-цикл на десять итераций измеряли примерно в 50× токенов одного линейного прохода. Этот квадратичный рост — самая опасная экономическая ловушка в дизайне агентов, и она невидима, пока трейс не станет длинным.
| Шаг | Что добавляется | Токенов в ЭТОМ вызове | Накопленная стоимость |
|---|---|---|---|
| 1 | задача + 1-й think/act/observe | ~1k | ~1k |
| 5 | ещё 4 триплета | ~5k | ~15k |
| 15 | ещё 10 триплетов | ~15k | ~120k (квадратично) |
| 40+ | история превышает окно | переполнение / усечение | ошибки посреди задачи |
В конце этой таблицы ждёт второй провал: переполнение контекстного окна посреди задачи. История растёт монотонно, поэтому задача с длинным горизонтом рано или поздно выходит за лимит контекста модели. Теперь фреймворк либо падает с ошибкой, либо молча усекает самые старые сообщения — а это часто включает исходную инструкцию. Агент забывает, о чём его просили, и уверенно завершает не ту задачу. Модели к тому же непропорционально внимательны к началу и концу длинного контекста, так что важный факт, найденный на шаге 5 из 15-шаговой цепочки, может фактически исчезнуть ещё до всякого усечения.
Завершение: циклу нужен не один выход
У наивного цикла ровно один выход — модель отказалась вызывать инструмент. В проде этого мало, потому что застрявшая, запутавшаяся или мечущаяся модель с радостью будет вызывать инструменты вечно. Сеньор даёт циклу несколько независимых выходов:
- Естественное завершение — модель возвращает финальный ответ без вызова инструмента. Хороший случай.
- Ограничитель шагов — жёсткий потолок итераций. LangGraph поставляет его как
recursion_limit(по умолчанию 25 супершагов в 1.0.x), и ты почти всегда ставишь его сильно ниже — 10–25 для большинства задач, ведь задача, которой нужно 1000 шагов, — это задача, потерявшая нить. - Бюджет по времени / токенам — остановка после, скажем, 60 секунд или N токенов, независимо от числа шагов. Именно это реально ограничивает сумму в долларах.
- Проверка прогресса / дедуп — если последние несколько шагов повторяют тот же инструмент с теми же аргументами, агент мечется; разорви цикл или впрысни сообщение «ты повторяешься, остановись».
История со всплеском по счёту — классический недостающий выход: у цикла была только дверь «модель не вызвала инструмент», а модель продолжала вызывать инструмент, так что до двери она не дошла.
Почему это работает
Относись к ограничителю шагов как к ремню безопасности, а не к рулю. Жёсткий max_steps — это страховка, ограничивающая худший случай, но если агент регулярно в него упирается, ограничитель прячет настоящий баг — плохой инструмент, расплывчатый промпт, отсутствующий сигнал завершения. Основной контроль — модель достигает настоящего stop, потому что задача выполнена; ограничитель спасает тебя в ту ночь, когда этого не происходит.
Восстановление после ошибок и ретрай, который не сдаётся
Когда инструмент падает, ты обычно возвращаешь текст ошибки обратно как наблюдение, чтобы модель адаптировалась — поправила аргументы, попробовала другой инструмент. Это одна из суперсил цикла. И она же ловушка. Допустим, search_orders возвращает пустой список (даже не ошибку — просто ничего полезного). Плохо запромпченный агент читает это, решает, что наверняка ошибся, и вызывает search_orders снова. Тот же ввод, тот же пустой результат, тот же вывод. Без дедупа или проверки прогресса это бесконечный цикл из совершенно валидных вызовов — ровно тот трейс на $12 из хука. Фикс структурный: ограничь ретраи на инструмент, детектируй идентичные подряд идущие вызовы и сделай «я не могу это найти, вот что я пробовал» допустимым финальным ответом, а не провалом, с которым модель обязана продолжать бороться.
Скриптовый воркфлоу против открытого агента
Вопрос сеньора редко звучит как «как мне построить агента» — это «а это вообще должно быть агентом?». Скриптовый воркфлоу жёстко прописывает шаги: вызвать инструмент A, потом B, потом C, с if/else для ветвлений. Он дешевле (один-два вызова модели, а не пятнадцать), быстрее, полностью предсказуем и тривиально тестируется. Открытый агент позволяет модели выбирать шаги в рантайме; он берёт задачи, которые ты не смог бы перечислить заранее, ценой недетерминизма, большего расхода токенов и куда большей поверхности для сбоя цикла.
| Измерение | Скриптовый воркфлоу | Открытый агент |
|---|---|---|
| Кто выбирает следующий шаг | Ты, при написании кода | Модель, в рантайме |
| Стоимость / latency | Низкая, ограниченная | Высокая, квадратичная по шагам |
| Предсказуемость | Детерминированный, тестируемый | Недетерминированный |
| Берёт новые задачи | Нет — только то, что закодил | Да — адаптируется в рантайме |
Трейдофф — это автономия против контроля, стоимости и предсказуемости. Давай модели свободу выбирать свой путь только там, где эта свобода окупается; везде, где путь известен, скриптуй его. Динамические лимиты ходов, подстраивающиеся под вероятность успеха задачи, по измерениям срезают стоимость на ~24%, удерживая долю решённых, — доказательство, что нужная доля автономии обычно меньше дефолтной.
Саппорт-флоу всегда делает одни и те же три шага: найти заказ, проверить право на возврат, оформить или отказать. Выбери дизайн.
Почему прогон агента на 15 шагов стоит сильно больше, чем 3× прогона на 5 шагов?
Агент вызывает один и тот же инструмент с идентичными аргументами пять раз подряд, получая тот же пустой результат. Какой правильный guard?
Расставь одну итерацию цикла ReAct-агента:
- 1 Отправить всю историю сообщений + определения инструментов модели (THINK)
- 2 Модель возвращает либо финальный ответ, либо вызов инструмента
- 3 Если это финальный ответ без вызова инструмента → разорвать цикл
- 4 Иначе выполнить выбранный инструмент (ACT)
- 5 Добавить результат инструмента в историю (OBSERVE) и снова в цикл
- 01Проведи коллегу через то, почему стоимость агента растёт квадратично с числом шагов и что переполняется в самом конце.
- 02Почему жёсткий потолок шагов необходим, но недостаточен для безопасного завершения, и что ещё добавляет сеньор?
LLM-агент — это while-цикл вокруг модели: он думает (модель рассуждает и выбирает действие), действует (твой код выполняет выбранный инструмент), наблюдает (результат добавляется) и повторяет — паттерн ReAct, и «агентным» его делает только свобода модели выбирать следующее действие. Всё трудное в нём задают два факта. Цикл может выполниться сколько угодно раз, и история растёт на каждом проходе, поэтому стоимость квадратична по шагам, ведь весь транскрипт переотправляется каждый ход — прогон в 15 шагов это ~9× от прогона в 5 шагов, а задача с длинным горизонтом рано или поздно переполняет контекстное окно и молча роняет собственную инструкцию. У наивного цикла один выход (модель перестаёт вызывать инструменты), до которого застрявшая или мечущаяся модель никогда не доходит; трейс со всплеском по счёту — это агент, ретраящий тот же пустой вызов инструмента 140 раз. Поэтому дай циклу несколько независимых выходов: естественное завершение, жёсткий ограничитель шагов (recursion_limit у LangGraph), бюджет по времени или токенам, который ограничивает реальную стоимость в долларах, и дедуп/проверку прогресса, убивающую метания. Прежде всего взвешивай автономию против контроля, стоимости и предсказуемости — скриптовый воркфлоу дешевле, быстрее и тестируем везде, где путь известен, так что оставь открытого агента для задач, шаги которых ты честно не можешь перечислить заранее.