1. Главная
  2. Блог
  3. ИИ-агент и SIGTERM: как мы перестали терять задачи на каждом релизе

ИИ-агент и SIGTERM: как мы перестали терять задачи на каждом релизе

ИИ-агент и SIGTERM: как мы перестали терять задачи на каждом релизе

Знакомая картина: инженер под вечер жмёт «деплой», docker compose up -d перезапускает контейнер с FastAPI, и через пару минут прилетает сообщение из поддержки — «мы отправили обращение, а ответа нет». Открываю базу — задача висит в processing, время последнего касания подозрительно совпадает с моментом рестарта. И рядом ещё десятки таких же. Клиент прислал письмо, наш ИИ-агент начал разбирать вложение, воркер получил SIGTERM, контейнер ушёл в перезапуск — а задача так и осталась висеть в лимбе. GigaChat уже успел посчитать половину токенов, а на другом конце — тишина.

Мы жили с этим полгода. Каждый релиз — минус десятки незавершённых обращений и один звонок от менеджера с вечным вопросом «а можно деплоить не в рабочее время?». Ниже расскажу, как чинили: что пробовали сначала, почему первая попытка сделала хуже и почему в итоге пришли к трём линиям обороны. Если у вас похожая история с ИИ-агентом на проде — надеюсь, сэкономлю вам пару недель.

Почему SIGTERM в LLM-задаче — это не то же самое, что в веб-хендлере

Обычный HTTP-хендлер отрабатывает за 100–300 мс. Даже uvicorn с настройками по умолчанию (--timeout-graceful-shutdown 30) обычно успевает дождаться завершения активных запросов. Но задача ИИ-агента — это не хендлер. Это фоновый воркер, который:

  • вычитывает свежий контекст клиента из pgvector (порядка секунды);
  • собирает промпт из истории и вложений;
  • шлёт стрим в GigaChat Pro (в среднем — десяток секунд, для длинных цепочек — под минуту);
  • парсит ответ, вызывает функции CRM (ещё несколько секунд);
  • пишет результат в PostgreSQL и триггерит уведомление в Telegram.

Средняя задача — около двадцати секунд. Хвост распределения уползает почти к полутора минутам. При деплое Docker шлёт SIGTERM, ждёт десять секунд по умолчанию и добивает SIGKILL. За эти десять секунд редко успевает завершиться даже одна LLM-генерация.

Хуже другое. Когда воркер умирает, задача остаётся с status='processing', worker_id='old-container'. Новый контейнер не знает, что она осиротела — он берёт свежие задачи со status='pending'. Осиротевшие болтаются до тех пор, пока менеджер не пришлёт скриншот с недовольным клиентом.

Первый подход: увеличить stop_grace_period до 60 секунд

Логика была очевидной и наивной: раз задачи по полминуты, дадим воркеру минуту дожить. В docker-compose.yml прописали:

services:
  agent-worker:
    stop_grace_period: 60s

Результат — стало хуже. Теперь релиз занимал не пятнадцать секунд, а полторы минуты. При этом хвост в полторы минуты всё равно ловил SIGKILL, а инженер, который релизит, начинал нервничать и жать Ctrl+C руками. И главное — эта схема ломалась при любом внештатном рестарте: OOM, кубернетовский evict, сбой в узле. Ни в одном из этих сценариев grace period тебя не спасёт.

Пришлось признать, что мы боремся не с той проблемой. Настоящий вопрос — не «как дать воркеру больше времени», а «как гарантировать, что задача либо завершится, либо возьмётся заново другим воркером». Как только сформулировал это вслух на ретро, стало понятно, что делать.

Правильная схема: heartbeat, orphan reaper и идемпотентный резюм

Мы построили три линии обороны. Каждая работает независимо, все три вместе покрывают все сценарии смерти воркера — от штатного деплоя до внезапного kill -9. Это и есть то самое решение с ИИ-агентами, которое я теперь тащу в каждый новый проект по умолчанию: без него любой сервис с длинными LLM-задачами рано или поздно начнёт терять данные.

Линия 1: heartbeat каждые 5 секунд

Пока воркер обрабатывает задачу, он раз в пять секунд апдейтит last_heartbeat_at. Простой UPDATE tasks SET last_heartbeat_at = now() WHERE id = $1 в отдельной asyncio-таске, которую запускаем параллельно с основной обработкой:

async def process_with_heartbeat(task_id: int):
    hb = asyncio.create_task(heartbeat_loop(task_id))
    try:
        await run_agent(task_id)
    finally:
        hb.cancel()

async def heartbeat_loop(task_id: int):
    while True:
        await asyncio.sleep(5)
        await db.execute(
            "UPDATE tasks SET last_heartbeat_at = now() "
            "WHERE id = $1", task_id
        )

Один воркер — одна колонка в таблице, одно короткое обновление раз в пять секунд. Даже на пиковых нагрузках это капля в море UPDATE'ов, PostgreSQL не замечает. Первый вопрос, который мне задали на код-ревью: «а не станет ли WAL пухнуть?» — не станет, апдейт короткий, HOT-update, страница не переезжает.

Линия 2: orphan reaper на APScheduler

Отдельный планировщик раз в полминуты ищет задачи, у которых status='processing' и last_heartbeat_at протух больше чем на двадцать секунд. Переводит их обратно в pending, инкрементирует счётчик retry_count, пишет в лог причину orphaned.

UPDATE tasks
SET status = 'pending',
    retry_count = retry_count + 1,
    worker_id = NULL
WHERE status = 'processing'
  AND last_heartbeat_at < now() - interval '20 seconds'
  AND retry_count < 3
RETURNING id;

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

Линия 3: graceful cancel через FastAPI lifespan

При штатном SIGTERM ждать двадцать секунд orphan reaper — это долго для клиента. Через lifespan-хук FastAPI ловим сигнал и делаем три вещи:

  1. Помечаем воркер как draining — новые задачи не берём.
  2. Активные задачи получают через asyncio.Event сигнал «мягко остановись после текущего шага LLM».
  3. После остановки шага (стрим GigaChat завершается или прерывается) откатываем задачу в pending с пометкой graceful_shutdown, чтобы её тут же подхватил соседний воркер.

Ключевой момент, ради которого пришлось перекраивать код: наш ИИ-агент разбит на шаги — retrieve → plan → tool_call → generate → verify. Каждый шаг — транзакция. Между шагами мы проверяем shutdown_event.is_set(). Если да — фиксируем прогресс, откатываем статус, выходим. Новый воркер берёт задачу и стартует с шага, который не был завершён. Идемпотентность внутри шагов гарантируется через уникальные ключи в таблице agent_steps — повторный запуск того же шага не создаёт дубли записей в CRM. Без этого ни один «умный» ретрай не спасёт: клиент вместо ответа получит две одинаковые задачи в CRM.

Что получили по ощущениям

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

После — примерно ни одной потерянной задачи за релиз. Единичные случаи, когда что-то уезжает в dead_letter, — это уже реальные ошибки в промпте или в CRM API, не связанные с деплоем. Их ловим в течение минуты после релиза, потому что reaper пишет в тот же дашборд Grafana, что и остальные метрики агента.

Время релиза вернулось к прежним пятнадцати секундам. stop_grace_period мы поставили в восемь секунд — этого хватает, чтобы завершить текущий шаг pipeline. Если не хватает — сработает reaper. Клиент видит задержку максимум в полминуты вместо потерянного обращения, и это уже совсем другая история.

Отдельный бонус — теперь у нас есть честная метрика надёжности воркера. retry_count > 0 в разрезе причин показывает, где реально ломается прод: то ли GigaChat отдал 502, то ли pgvector отвалился по таймауту, то ли релиз перезапустил под. Раньше эти случаи сливались в общий «непонятно почему задача зависла» и разбирались по логам вручную.

Что бы я сделал иначе, если бы начинал заново

Первое — не полагался бы на stop_grace_period вообще. Это костыль, который создаёт иллюзию решения, но не спасает ни от kill -9, ни от evict, ни от OOM. Heartbeat + reaper покрывают всё.

Второе — сразу разбил бы агента на шаги с явными точками возобновления. Мы начали с монолитной корутины run_agent(task_id), где всё было единым куском. Разбор на шаги занял день работы и превратил идемпотентный резюм из мечты в двадцать строк кода. Сейчас, когда рисую архитектуру нового ИИ-агента с API-интеграциями в CRM или биллинг, «шаги + идемпотентные ключи» — первое, что появляется на доске.

Третье — писал бы worker_id = hostname + pid + start_time с самого начала. Когда впервые начали ловить orphaned-задачи, выяснилось, что отличить «мой воркер ещё жив» от «мой воркер перезапустился, и это уже новый процесс» без стартового таймстампа невозможно.

Мораль простая. Если ваш ИИ-агент работает дольше десяти секунд на задачу, docker compose restart — это не безопасная операция. Пока не появились heartbeat и reaper, каждый деплой был лотереей, где клиент вытаскивал пустой билет. Сейчас доступные нам ИИ-агенты — те, что в проде и разбирают реальные обращения, — переживают выкатку молча. И это, пожалуй, лучший показатель того, что схема работает.

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.