Куда девать цикл агента, если движок durable execution в системе уже есть? Развилка выглядит как выбор инструмента — агентный граф против движка, — а на деле оба класса инструментов оркестраторы: у каждого свой цикл, своё состояние, свои ретраи. Вопрос не «какой лучше», а где между ними провести границу и сколько эта граница стоит.
Ниже — что показал месяц работы стенда, где такая граница проведена: Temporal на Python, шлюз к моделям, трассировка вызовов, семь сценариев, включая агента-разработчика и автоматическое SRE-расследование.
Тезис, с которого всё началось
Правило приняли в первый день: один вызов модели — одна activity, граф принадлежит движку durable execution, а не агентному фреймворку.
Обоснование звучало убедительно. Заверни агентный цикл целиком в одну activity — и движок снаружи увидит непрозрачный блок: падение на восьмом шаге из пятнадцати переиграет его с первого. Пошаговая устойчивость, ради которой движок брался, исчезает, а сверху появляются два слоя ретраев, которые друг о друге не знают.
Честная оговорка: это рассуждение, а не измерение. Альтернативу на стенде не строили, поэтому месяц проверил не сам тезис, а цену принятого решения. Вот она и есть данные.
Чего утверждать нельзя
«Агентный фреймворк не даёт durability» — фактически неверно. У современных агентных графов персистентность есть, с выбором режима: только при выходе из графа, асинхронно, либо синхронно перед каждым шагом. Разница проходит по другой линии.
Первое — гранулярность: что именно считается шагом, который не переигрывают. Второе — чей бюджет ограничивает попытки. Третье — что происходит при возобновлении: узлы после точки восстановления исполняются заново, включая вызовы моделей и внешние запросы. Точка восстановления и есть единица, за которую платят повторно.
Официальная интеграция движка с агентным SDK решает ровно это: цикл агента, выбор инструментов и передачи управления живут в workflow, а каждый отдельный вызов модели исполняется как activity — и потому не повторяется при реплее. Агентный фреймворк, запертый в одной activity, — плохо. Агентный цикл под оркестратором с ходом-на-activity — штатная поддерживаемая конструкция. Наивная формулировка тезиса это различие стирает.
Три уровня ретрая, которые нельзя складывать
Практика свелась к жёсткому разделению профилей activity, и разница между ними принципиальная.
| Профиль | Таймаут | Попыток | Почему так |
|---|---|---|---|
| код: валидация, рендер, запись | 20–30 с | 3 | отказ дёшев и обычно транзиентный |
| вызов модели | 180–240 с | 1 | отказ не транзиентный, стоит денег |
| эффект в проде | из политики | 1 | тихий реплей мутации — решение, не умолчание |
Ретрай с обратной связью — когда ошибки валидации возвращаются в промпт — живёт внутри шага. Инфраструктурный ретрай поверх него был бы вторым, невидимым слоем: он не знает контракта, не видит бюджета эпизода и оплачивает каждую попытку. От этого и предостерегал исходный тезис, только слои оказались не «один фреймворк против другого», а «ретрай шага против ретрая activity».
Эффект стоит отдельного слова. Он идемпотентен по ключу, и всё равно не ретраится инфраструктурой: идемпотентность делает повтор безопасным, но не делает его желаемым. Дедлайн у такой activity берётся из файла политики, а не из константы в коде, — чтобы записанный в политике таймаут был настоящим.
Граница агентности
Формула, к которой стенд пришёл: свобода внутри эпизода, детерминизм между эпизодами.
Единственное место, где модель решает, что делать дальше, — дочерний эпизод. Он выбирает, какие файлы открыть и в каком порядке, возвращает типизированное предложение и умирает. За его пределами не меняется ничего: снапшот, бюджет попыток, применение результата и решение о следующем заходе принадлежат родителю. У ребёнка нет ни write-инструмента, ни shell, а read-инструменты резолвят каждый путь от модели и отказывают, если он ведёт наружу.
Бюджеты живут константами в коде, а не пожеланиями в промпте: предел ходов внутри прохода, предел проходов, отдельный лимит на отклонённые ответы — причём на эпизод, а не на раунд. Пораундовый лимит перемножается: три раунда по три попытки дают девять вызовов на худшем пути, каждый с полными файлами в контексте.
Предел ходов нужен ещё по одной причине, которую лучше знать заранее. Модель, перечитывающая один и тот же файл, без ограничения потратит весь бюджет и не сдвинется. Так ведёт себя reasoning-модель, если её собственную историю переписывают у неё под ногами — например, санитайзер, токенизирующий пути в прошлых вызовах инструментов.
Отказ, которого не видит ни один слой
Главная находка стенда и причина, по которой «завернуть и забыть» не работает.
Генерация, завершившаяся ошибкой на стороне провайдера, приходит как HTTP 200 без контента и без вызова инструмента. Она сжигает десятки тысяч токенов на выходе и не несёт заголовка с ценой — счёт за неё не выставляют.
| Слой | На что реагирует | Почему молчит |
|---|---|---|
| fallback-цепочка шлюза | HTTP-ошибка | ответ 200 |
| ретрай activity оркестратора | исключение | исключения нет |
| агентный SDK | некорректный ответ | ответ корректен, просто пуст |
Там, где ход обязан вызвать инструмент, пустой ход съедает одну из десяти попыток, и эпизод может истратить весь бюджет, не ответив ничего. Счёт при этом выглядит дешёвым — отсюда отдельная метрика на вызовы без цены: она делает потерю видимой вместо «бесплатной на вид». Про то, какие приёмы экономии токенов работают, а какие перепроданы, я писал отдельно.
Детектор пришлось строить по распарсенному ответу, а не по HTTP-телу: вычитать тело в хуке транспорта — значит сломать разбор в самом SDK. К моменту, когда SDK отдаёт результат, причина завершения уже потеряна, и остаётся проверять следствие: ход не произвёл ничего. Границы проверки важны. «Нет вызова инструмента» неверно для агентов, отвечающих прозой; «нулевой расход токенов» назвал бы отказом законно пустое завершение.
Замер одного прогона на дешёвом тире: 167 ходов, из них 97 вернулись без цены. Повтор запроса той же моделью спас 0 из 36 — ни разу. Сильный тир ответил все 36, ни одного пустого. Доля отвеченных случаев поднялась с 79 до 91 процента. Цена хода между тирами отличается примерно всемеро.
Что здесь важнее чисел: фикс обходит отказ, а не устраняет его. Дешёвый тир по-прежнему выдаёт пустой ход больше чем на половине вызовов. Третий путь — менять не тир, а сам запрос: меньшая схема инструментов, ослабленное требование обязательно вызвать инструмент. На стенде его никто не измерил, и он остаётся гипотезой.
Цена: шаг нельзя удалить
Прямое следствие того, что каждый шаг — activity. Реплей идёт против записанной истории, поэтому убрать вызов, который уже сделал живущий workflow, — это слом совместимости.
На стенде это встретилось при миграции метрик: две activity потеряли смысл, потому что данные стали писаться в durable-таблицу напрямую, — и остались в коде пустыми заглушками с комментарием «имя и сигнатура сохранены намеренно». Метрика не стоит слома совместимости, а удалить её вызов нельзя.
Обратная сторона той же монеты и есть причина всей конструкции: шаг выполняется внутри activity, поэтому реплей никогда не зовёт модель повторно — результат уже в истории. Рестарт воркера, восстановление после падения, переигровка истории не платят за токены дважды. Покупается здесь не атомарность, а повторяемость без повторной оплаты — ровно та же логика, что в выкате, где запись состояния идёт последней операцией.
Когда что выбирать
| Ситуация | Что брать | Что вы принимаете |
|---|---|---|
| пайплайн уже под движком durable execution | ход модели = activity, цикл в workflow | шаг нельзя удалить; сигнатуры живут вечно |
| автономный агент, шаги дёшевы и обратимы | агентный граф со своим персистом | узлы после точки восстановления переигрываются вместе с вызовами моделей |
| есть эффекты во внешнем мире | движок с явными профилями ретрая | больше кода вокруг каждого шага, зато ретрай под контролем |
| прототип, цена ошибки — только время | ни то ни другое, простой цикл в коде | восстановления нет; переписывать при первом же проде |
Чего этот опыт не доказывает
Агентный граф как владелец цикла на стенде не поднимался: всё, что сказано про его режимы персистентности, взято из документации, а не из собственного прогона. Ошибок недетерминизма при реплее за месяц не случилось ни разу — по имеющимся данным не различить, дисциплина держалась или её просто не нагружали, и вывод «декомпозиция защищает от недетерминизма» отсюда не следует. Числа — один стенд и один-два прогона: разброс между сессиями не мерился.
Про то, что дорогая часть агентной системы — не сам харнесс, а всё, что вокруг него, есть отдельный разбор. Этот стенд добавляет к нему одну поправку: дорогой оказывается граница. Провести её нужно один раз, до первого прода, и потом жить с тем, что каждый шаг остаётся в истории навсегда.