ИИ-агент и 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 ловим сигнал и делаем три вещи:
- Помечаем воркер как
draining— новые задачи не берём. - Активные задачи получают через
asyncio.Eventсигнал «мягко остановись после текущего шага LLM». - После остановки шага (стрим 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, каждый деплой был лотереей, где клиент вытаскивал пустой билет. Сейчас доступные нам ИИ-агенты — те, что в проде и разбирают реальные обращения, — переживают выкатку молча. И это, пожалуй, лучший показатель того, что схема работает.
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




