← max_tokens

Почему мы выбрали Temporal для управления ИИ-агентами, а не Celery

ИИ-агент в продакшене похож на обычную фоновую задачу только на демо.

В демо всё линейно: пользователь спросил, модель ответила, инструмент вызвался, результат вернулся. В реальном продукте это быстро превращается в долгий процесс: несколько вызовов LLM, инструменты, внешние API, ожидание человека, таймауты, ретраи и ветки, которые заранее не распишешь.

Каждый шаг может упасть. Модель вернула невалидный JSON. Провайдер прислал rate limit. Инструмент завис. Воркер умер во время деплоя. Пользователь нажал approve через восемь часов, когда исходный процесс уже давно не живёт в памяти.

Мы сначала смотрели на это как на набор фоновых задач. Логично: есть Python, есть Celery, есть брокер, есть воркеры. Но чем дальше мы заходили в агентную архитектуру, тем сильнее становилось ощущение, что мы используем не тот примитив.

Агент по форме не задача. Агент по форме процесс.

Именно поэтому для оркестрации таких процессов мы выбрали Temporal.

Где Celery начинает скрипеть

Celery хорошо ложится на модель task queue: задача прилетела в очередь, воркер её выполнил, результат записали. Отправить письмо. Сжать картинку. Пересчитать счётчик. Запустить короткую фоновую работу.

Долгие задачи в Celery тоже возможны. У него есть retries, ETA/countdown, chains, groups, chords, periodic tasks через Celery beat. Это не «простая очередь без возможностей».

Проблема не в том, что Celery слабый. Проблема в форме работы.

У обычной task queue задачи нет середины как first-class сущности. Если процесс состоит из десяти шагов, ждёт внешнего события и должен пережить рестарт воркера, эту середину приходится моделировать самому.

Обычно появляется таблица agent_runs: статус, текущий шаг, попытки, next_retry_at, промежуточные результаты, флаги ожидания. Каждая задача начинается с «прочитай, где мы остановились» и заканчивается «запиши, куда дошли». Через какое-то время рядом появляется cron или watchdog, который ищет зависшие запуски и пытается понять, что с ними делать.

По сути, вы пишете state machine руками.

Оркестрация тоже расползается. Celery Canvas даёт chain, group, chord и callbacks, но агентный цикл редко выглядит как статический граф. Модель может выбрать один инструмент, потом другой, потом попросить уточнение, потом решить, что данных достаточно. В итоге логика «что дальше» начинает жить в хвостах задач и callback’ов, а не в одном читаемом цикле.

Падение посреди многошагового процесса превращается в расследование. Воркер умер между «инструмент отработал» и «результат записан». Что уже случилось во внешнем мире? Можно ли повторить шаг? А если это был webhook, письмо или списание денег?

Ретраи тоже оказываются не там, где хочется. Celery умеет ретраить task. Но агенту часто нужен retry конкретного шага внутри долгого процесса: rate limit подождать и повторить, невалидный ответ модели перезапросить с уточнением, auth error сразу остановить без бессмысленных повторов. Пошаговые ретраи в Celery получить можно: каждый шаг — отдельная задача со своей retry-политикой. Но это ровно та фрагментация, о которой шла речь выше: процесс снова рассыпается на цепочку задач и колбэков.

И отдельно человек в цикле. «Подожди approve» в task queue модели обычно превращается в polling, delayed tasks или отдельный слой состояния. Процесс, который должен просто стоять и ждать сутки, для Celery не является естественным объектом.

Можно ли всё это построить поверх Celery? Да.

Вопрос в том, хотите ли вы писать durable orchestration сами.

Celery против Temporal: задача плюс самодельные статусы и вотчдоги — против workflow, activities, replay и сигналов

Что Temporal делает иначе

Temporal не «очередь получше». Это runtime для долгих процессов.

Он не сериализует память вашей функции. Вместо этого Temporal ведёт Event History: workflow started, activity scheduled, activity completed with result, timer fired, signal received. Когда воркер падает или выкатывается новая версия, другой воркер запускает workflow code с начала и проигрывает эту историю.

Если Activity уже завершилась и её результат записан в history, replay не вызывает её повторно. Workflow получает записанный результат и доходит до того места, где остановился.

Снаружи это похоже на восстановление памяти. На деле Temporal восстанавливает состояние не из memory snapshot, а через replay.

Отсюда требование: workflow должен быть детерминированным. При одинаковой истории он обязан принимать те же решения. Поэтому LLM-вызовы, HTTP, база, системное время и случайность не живут в workflow напрямую. Они уходят в Activities или в специальные API Temporal.

Разделение простое:

  • Workflow решает, что должно произойти дальше.
  • Activity делает работу, которая может быть недетерминированной.

Для агента это почти идеальная граница.

Сам агентный цикл становится workflow: выбрать следующий шаг, проверить условие выхода, дождаться человека, решить, какой инструмент вызвать дальше. Всё недетерминированное становится Activity: вызов модели, tool call, HTTP-запрос, чтение из базы, отправка сообщения.

LLM по природе недетерминирована, но это не конфликтует с Temporal. Нельзя вызывать модель прямо из workflow code. Можно вызвать Activity, которая обращается к модели. Результат Activity запишется в Event History, и при replay workflow увидит тот же ответ модели, что и в первый раз.

Что это даёт агенту

Первое: состояние выглядит как код, а не как таблица статусов.

История диалога, промежуточные результаты, счётчик итераций и текущая ветка могут быть обычными переменными workflow. Temporal восстановит их через replay. Таблица agent_runs, которую вы держали только ради checkpoint’ов и восстановления оркестрации, становится не нужна. Внешнее хранилище всё ещё может пригодиться для другого: продуктовых выборок, больших payload’ов, отчётности, интеграций, — но не как место, из которого вы восстанавливаете запуск.

Второе: агентный цикл читается как цикл.

while с вызовом модели, разбором ответа и dispatch инструментов остаётся while в одном файле. Не графом из callback’ов. Не набором задач, где каждая в конце планирует следующую. Через полгода это сможет прочитать новый человек в команде.

@workflow.defn
class AgentWorkflow:
    @workflow.run
    async def run(self, task: str) -> str:
        history = [task]
        while True:
            step = await workflow.execute_activity(call_model, history,
                start_to_close_timeout=timedelta(minutes=2))
            if step.done:
                return step.answer
            tool_result = await workflow.execute_activity(run_tool, step.call,
                start_to_close_timeout=timedelta(minutes=5))
            history.append(tool_result)

Третье: ретраи становятся точечными.

Для каждого Activity задаётся своя политика: backoff для rate limit, ограниченное число попыток для сбойного вызова, нулевые ретраи для ошибок авторизации. Невалидный JSON можно валидировать внутри Activity и считать retryable failure. Но retry повторяет тот же вызов с тем же входом; переспросить модель, вернув ей её ошибку в промпте, — это уже виток цикла Workflow, а не retry policy. Там, где годится простой повтор, повторение делает retry policy, а не ручная обвязка вокруг каждого LLM-вызова.

Четвёртое: human-in-the-loop перестаёт быть костылём.

Workflow может ждать Signal от пользователя часы или дни, не занимая worker: пока он ждёт, он не держит память воркера, а Signal будит его через replay. Query показывает текущее состояние в любой момент, пока есть живой воркер, чтобы ответить: на каком шаге агент, чего ждёт, что уже сделал. Для запроса, который нужно проверить и на который нужно ответить, рядом с Signal есть Update.

Пятое: появляется нормальная видимость исполнения.

В Temporal Web UI виден event history конкретного запуска: Activity events, retries, signals, timers, pending activities и место, где workflow сейчас ждёт. Для агентной системы это очень важно. Иначе дебаг быстро превращается в «склей логи трёх сервисов по trace id и угадай, что произошло».

Temporal не отменяет аккуратность

Плохая ошибка в разговоре про Temporal: представить его как платформу, которая магически делает внешние side effects exactly-once.

Нет.

Если Activity уже завершилась и результат записан в Event History, replay не вызовет её повторно. Но сама Activity может выполниться больше одного раза из-за retry, timeout, heartbeat timeout или сбоя до фиксации результата.

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

Temporal берёт на себя orchestration retry. Корректность side effects остаётся ответственностью вашего кода.

Есть и вторая граница: workflow history не должна превращаться в лог всего на свете.

Не стоит писать в Event History каждый токен LLM-стрима, мегабайты сырых ответов API или бесконечный debug-log. У Temporal есть лимиты на payload’ы, размер history transaction и количество событий. Для больших данных лучше хранить объект во внешнем storage и передавать в workflow ссылку. Один run держит порядка десятков тысяч событий, а Temporal предупреждает задолго до потолка, поэтому длинные диалоги и циклы перезапускают через Continue-As-New или child workflows.

Temporal хорош для durable state и крупных шагов процесса. Для token streaming лучше SSE, WebSocket или event bus с коротким replay-окном.

Новая версия тоже не бесплатна. Replay работает, только пока изменённый workflow-код совместим со старой историей: переставьте или уберите шаги в уже запущенном workflow, и replay упрётся в non-determinism error. Для несовместимых изменений у Temporal есть Worker Versioning и patching API: старые запуски доигрываются по старой логике, а новые идут по новому пути.

Почему не просто оставить Celery рядом

Temporal и Celery не взаимоисключающие. Можно оркестрировать агентный run в Temporal, а тяжёлую работу отдавать отдельным воркерам, хоть Celery, хоть Kubernetes jobs, хоть собственному worker pool.

Но retry-логику и ответственность за side effects надо держать в одном месте. Иначе легко получить двойную обвязку: Temporal ретраит Activity, Celery ретраит task внутри неё, внешний API получает два запроса, а вы потом разбираете, кто виноват.

Мы для себя выбрали простое правило: Temporal отвечает за процесс. Всё, что является шагом этого процесса, должно быть видно в его Event History. Если внутри шага есть тяжёлая вычислительная работа, её можно вынести отдельно, но orchestration boundary остаётся в Temporal.

Где Temporal не нужен

Temporal не надо тащить везде.

Для простого CRUD, синхронного запроса «туда-обратно» и одиночной фоновой задачи без состояния это оверкилл. Вы платите отдельным сервисом, базой, эксплуатацией, SDK-ограничениями и дисциплиной детерминизма. Если задача просто отправляет письмо или пересчитывает кеш, task queue обычно проще.

Не его территория и сам поток токенов в чате. Temporal может оркестрировать чат-сессию или долгий job, но не стоит писать каждый токен в workflow history. Токены пусть идут через SSE/WebSocket/event bus. Temporal пусть хранит durable state: какой run запущен, какие шаги прошли, какой итог зафиксирован, где нужно дождаться человека.

Temporal нужен там, где есть многошаговость, длительность, состояние и требование переживать сбои.

Платёжный пайплайн. Saga с компенсациями. Долгий импорт. Human approval. Агент, который вызывает инструменты, ждёт внешние события и должен продолжить после деплоя.

Итог

Выбор для нас свёлся к одному вопросу: чем по форме является агент?

Если это короткая задача, берите task queue и не усложняйте. Celery, RQ, Sidekiq нормально подходят для такой формы работы.

Если это процесс с состоянием, который должен пережить рестарт, retry, ожидание человека и недетерминированные ответы модели, нужна durable orchestration модель.

Мы выбрали Temporal не потому, что Celery плохой. Мы выбрали Temporal потому, что агентный run для нас стал процессом, а не задачей.

Цена реальна: отдельный сервис, дисциплина детерминизма, версионируемые деплои. Мы платим её потому, что эту оркестрацию иначе пришлось бы писать руками.

Меньше инфраструктурных костылей. Больше времени на сам продукт.

Источники

<|endoftext|> · 3 287 tok · finish_reason: stop

// top_k · nearest neighbors

  1. [0] 0.626 Herdr: диспетчерская для агентов в терминале
  2. [1] 0.607 Orca vs Herdr: изолировать задачи или держать сессии живыми
  3. [2] 0.595 Graphify и MemPalace: агенту нужна не «память», а карта проекта и история решений

cosine of embeddings · scale 0–1 absolute · computed at build

integrity: sha256 5f1bfd6b…

tokens · o200k_base