Перейти к содержимому
PD
AI-автоматизация 8 мин чтения

«Процесс жив» не значит «система работает»: health check для AI-пайплайнов

Живой контейнер ничего не говорит о качестве AI-пайплайна. Разбираю трёхуровневую схему health check, которую я ставлю в каждый проект: liveness, readiness и canary-прогоны на golden inputs.

PD

Pavel Duglas

AI Automation & MVP Architect

Прошлой весной клиент написал мне в 9 утра: «У нас всё зелёное, но отдел продаж говорит, что данные по обогащению лидов вчера превратились в мусор». Их мониторинг проверял одну вещь: отвечает ли контейнер HTTP 200 на /health. Хендлер возвращал строку ok. Он ни разу не дёргал ни провайдера модели, ни vector store, ни пул прокси. А в это время OpenAI-совместимый шлюз начал отдавать пустые completions для конкретного алиаса модели, и код одиннадцать часов радостно писал пустые строки в Postgres.

Живой процесс и работающий пайплайн - это разные вещи. В AI-автоматизации это особенно больно, потому что типичная поломка тут не падение, а правдоподобный мусор. Ниже - архитектура проверок, которую я теперь ставлю в каждый проект до того, как агент или парсер уходит в прод.

Почему обычный health check вас обманывает

У классического веб-сервиса короткая цепочка зависимостей: процесс, база, может быть кэш. Процесс жив, база отвечает - вы почти наверняка в порядке.

У AI-автоматизации цепочка длинная, и половина её вам не принадлежит:

  • провайдер модели (или три, если у вас роутинг)
  • квоты и rate limit, которые меняются без предупреждения
  • vector store, который поднят, но пустой после неудачного реиндекса
  • headless-браузер или BAS-профиль с протухшей сессией
  • пул прокси, где 70% IP уже забанены на целевом сайте
  • prompt-шаблоны, которые кто-то поправил через веб-интерфейс
  • output schema, которую модель тихо перестала соблюдать после смены версии

Любой из этих пунктов может сломаться при абсолютно живом процессе. А поскольку выход LLM - это текст, сломанный пайплайн отдаёт уверенную чушь вместо стектрейса. Ваш алертинг при этом видит ноль ошибок и 100% uptime.

Поэтому перестаньте спрашивать «работает ли оно» и начните задавать три отдельных вопроса.

Уровень 1: liveness (процесс вообще жив?)

Эта проверка специально остаётся тупой. Она отвечает только на один вопрос: надо ли меня перезапустить.

@app.get("/livez")
def livez():
    return {"status": "alive"}

Никаких вызовов зависимостей. Никогда. Если вы проверяете Postgres в liveness-пробе, то пятиминутный сбой базы заставит Kubernetes перезапустить все реплики сразу и превратит мелкую аварию в крупную. Я это видел вживую. Liveness должна быть эгоистичной.

Уровень 2: readiness (могу ли я прямо сейчас делать полезную работу?)

Readiness проверяет зависимости: с таймаутами, параллельно и с кэшем. Она отвечает на вопрос, стоит ли гнать в этот инстанс трафик и задачи из очереди.

Правила, которых я держусь:

  1. У каждой проверки жёсткий таймаут (обычно 300-800 мс).
  2. Проверки идут конкурентно, а не в цикле.
  3. Результат кэшируется на 5-15 секунд, чтобы поток мониторинга не заDDoSил вашего же провайдера.
  4. Проверки только читают. Никогда не меняйте продовые данные ради доказательства своего здоровья.
  5. degraded и down - это разные статусы.
import asyncio, time

CACHE = {"at": 0, "payload": None}

async def check(name, coro, timeout=0.8):
    started = time.perf_counter()
    try:
        await asyncio.wait_for(coro, timeout=timeout)
        ok, detail = True, None
    except Exception as e:
        ok = False
        detail = f"{type(e).__name__}: {e}"[:200]
    return name, {
        "ok": ok,
        "ms": round((time.perf_counter() - started) * 1000),
        "detail": detail,
    }

@app.get("/readyz")
async def readyz():
    if time.time() - CACHE["at"] < 10 and CACHE["payload"]:
        return CACHE["payload"]

    results = await asyncio.gather(
        check("db", db.execute("select 1")),
        check("queue", redis.ping()),
        check("vectors", qdrant.count("docs", exact=False)),
        check("model_primary", probe_model(PRIMARY)),
        check("model_fallback", probe_model(FALLBACK)),
        check("proxies", proxy_pool.healthy_ratio()),
    )
    checks = dict(results)

    critical = ["db", "queue"]
    down = any(not checks[c]["ok"] for c in critical)
    model_down = not checks["model_primary"]["ok"] and not checks["model_fallback"]["ok"]

    status = "down" if (down or model_down) else (
        "degraded" if any(not v["ok"] for v in checks.values()) else "ready"
    )
    payload = {"status": status, "checks": checks, "version": BUILD_SHA}
    CACHE.update(at=time.time(), payload=payload)
    return payload

Проверки, специфичные для AI, которые почти все пропускают

Проба модели, а не пинг URL. Не надо проверять, что base URL отвечает 200. Отправьте запрос на один токен и проверьте, что контент не пустой:

async def probe_model(model):
    r = await client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": "ping"}],
        max_tokens=1,
        temperature=0,
    )
    assert (r.choices[0].message.content or "").strip(), "empty completion"

Один этот assert поймал бы одиннадцатичасовую аварию у моего клиента на первой минуте. Стоимость - доли копейки за проверку.

Пустота vector store. Существующая коллекция с нулём векторов - классика жанра после кривого реиндекса. Проверяйте, что количество выше разумного порога (count > 0.8 * expected), а не просто что соединение установилось.

Версии промптов и схем. Отдавайте SHA-256 каждого prompt-шаблона и JSON-схемы, по которой валидируете ответ. Когда кто-то поправит промпт через UI и качество просядет, вы хотите видеть хеш в логах и в выводе /readyz, чтобы сопоставить всё за десять секунд, а не за два часа.

Здоровье прокси и сессий. Для браузерной автоматизации и BAS-скриптов readiness звучит так: у меня больше 50% живых прокси и хотя бы один профиль залогинен. Я делаю один дешёвый авторизованный запрос к стабильной странице целевого сайта и проверяю маркер залогиненности, а не код 200. Страница логина будет отдавать 200 хоть весь день.

Запас по квоте. Если провайдер отдаёт остаток квоты или вы сами считаете расход, ставьте degraded после 85% дневного бюджета. Это ваш шанс переключиться на более дешёвую модель раньше, чем вы переключитесь на нулевой выход.

Уровень 3: canary на golden inputs (результат всё ещё правильный?)

Readiness доказывает, что зависимости отвечают. Она не доказывает, что пайплайн всё ещё считает правильно. Для этого нужны эталонные входы, прогоняемые по расписанию.

Возьмите 5-10 реальных входов с известными ожиданиями. Не полный ground truth, а именно набор утверждений.

GOLDEN = [
  {"id": "inv-01", "file": "fixtures/invoice_de.pdf",
   "assert": {"currency": "EUR", "total": 1240.50, "vat_present": True}},
  {"id": "cls-03", "text": "Верните деньги за последний заказ",
   "assert": {"intent": "refund_request"}},
]

Прогоняйте их каждые 15 минут через настоящий пайплайн (те же промпты, тот же роутинг моделей, тот же парсинг) и проверяйте:

  • ответ валиден по JSON-схеме
  • обязательные поля не пустые
  • детерминированные поля совпадают точно (валюта, ID, значения enum)
  • числовые поля попадают в допуск
  • задержка и расход токенов не выше 2x от 30-дневного baseline

Два подряд провала canary - это звонок дежурному. Один провал - предупреждение, потому что модели стохастичны, и если будить людей на каждом одиночном флаке, через месяц алерты начнут игнорировать.

Это самый полезный мониторинг, который я добавил в AI-проекты за последние два года. Стоит несколько долларов в месяц и ловит депрекейты моделей, тихую смену роутинга у провайдера, правки промптов и регрессии парсера раньше клиентов.

Уровень 4: heartbeat свежести (работа вообще выполнялась?)

Canary проверяет код. Heartbeat проверяет, что задача вообще запускалась. Каждая джоба по расписанию пишет строку при успешном завершении:

create table pipeline_heartbeat (
  pipeline text primary key,
  last_success_at timestamptz not null,
  rows_written int not null,
  run_id text not null
);

Дальше одно правило алерта: для каждого пайплайна now() - last_success_at > expected_interval * 2 означает инцидент. Второе правило - по объёму: rows_written < 0.3 * median_last_7_runs значит, что джоба отработала, но подозрительно мало. Скрапер, который вернул 4 позиции вместо 900, никогда не бросит исключение. Он просто тихо посадит ваш продукт на голодную диету.

Я вешаю это финальной HTTP-нодой в n8n, последним POST перед выходом в BAS-скрипте и в блоке finally для Python-джоб, который срабатывает только на успешной ветке.

Матрица severity: что реально будит человека

Не каждая красная лампочка стоит звонка в три ночи. Мой дефолт:

СигналДействие
Упала livenessАвторестарт, без звонка
Основная модель мертва, fallback живУходим на fallback, лог, без звонка
Все модели мертвыЗвонок
Количество векторов обвалилосьЗаморозить реиндекс, звонок
Провалился 1 canaryПредупреждение в Slack
2+ canary падают дважды подрядЗвонок
Heartbeat просрочен вдвоеЗвонок
Аномалия объёмаПредупреждение, автотикет
Живых прокси меньше 30%Пауза скрапинга, предупреждение

Самая важная половина этой таблицы - строки «без звонка». Каждая автоматическая деградация, которую вы заложили (fallback-модель, более дешёвая модель, пауза вместо долбёжки), превращает инцидент в строчку в логе. Это и есть цель.

План внедрения на один день

Если у вас в проде AI-пайплайн с фейковым /health, я бы правил в таком порядке:

  1. Утро: разделить /livez и /readyz. Добавить базу, очередь и настоящую пробу модели на один токен с assert на непустой ответ. Кэш на 10 секунд.
  2. День: сделать таблицу heartbeat и по одному правилу алерта на каждую задачу по расписанию. Это ловит самый частый реальный сбой: джоба просто перестала запускаться.
  3. После обеда: вытащить 5 golden inputs из реального трафика, написать assert-ы, поставить прогон каждые 15 минут.
  4. Вечер: перенести матрицу severity в конфиг алертинга и удалить все алерты, на которые никто ни разу не реагировал.

Это один день работы, который превращает «нам сказал отдел продаж» в «мы узнали за 90 секунд». В AI-автоматизации именно этот разрыв отличает систему, которой доверяют, от системы, которой тихо перестают пользоваться.

Антипаттерны, которые я регулярно выпиливаю из клиентского кода

  • Health check, который дёргает LLM на каждый пользовательский запрос. Вы удвоили стоимость и задержку. Кэшируйте.
  • Проверки без таймаутов. Одна медленная зависимость подвешивает readiness, и оркестратор объявляет мёртвым весь флот.
  • Проверки, которые пишут тестовые строки в продовые таблицы. Я видел, как canary-счёт доехал до дашборда реального клиента. Используйте отдельный тенант или dry-run флаг.
  • status: ok без деталей. Отдавайте задержку и текст ошибки по каждой проверке. Время отладки падает на порядок.
  • Алерты только на перцентили задержки. Латенси выглядит прекрасно, когда модель мгновенно возвращает пустые строки.

Вопросы и ответы

Сколько стоит такой мониторинг по токенам?

Мало, если считать. Проба модели на 1 токен раз в 10 секунд с кэшем - это единицы центов в месяц. Основной расход дают canary на golden inputs: 5-10 реальных входов каждые 15 минут через полный пайплайн обходятся обычно в 3-15 долларов в месяц. Если ваши golden inputs дорогие (большие PDF, длинный контекст), сократите частоту до раза в час и оставьте 3-4 самых показательных кейса.

Чем canary отличается от обычных eval-тестов в CI?

Eval в CI проверяет ваш код и промпты перед деплоем. Canary проверяет прод после деплоя и, главное, между деплоями. Большинство реальных поломок в AI-системах происходит без релиза: провайдер сменил версию модели, кто-то поправил промпт через UI, протухла сессия, обвалился vector store. CI такое не поймает по определению. Логика и фикстуры могут переиспользоваться, но запускать надо в двух местах.

У меня всё на n8n и BAS, без своего FastAPI. Как это реализовать?

Разбейте на два куска. Heartbeat и canary легко делаются без своего сервиса: отдельный workflow по расписанию прогоняет golden inputs через основной workflow и пишет результат в Postgres или Google Sheets, а любая задача перед выходом делает POST в таблицу heartbeat. Readiness превращается в проверочный workflow, который дёргает модель, базу, прокси и пишет статус. Алертинг вешайте на SQL-запрос по этим таблицам, например через Grafana или простой cron-скрипт с уведомлением в Telegram.

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