У меня есть RAG‑сервис: проверяет фактологические утверждения в бизнес‑отчётах против реальных источников — SEC EDGAR, World Bank, FRED, Wikipedia. Это капстоун LLM Zoomcamp (DataTalksClub). Курс закончился, оценку поставили (peer review, 42/42), а я решил дотянуть проект до состояния «не стыдно показать на собеседовании как рабочий код», а не просто «закрыл критерии оценки курса».

Критерии оценки курса не требуют production‑паттернов — они про RAG‑пайплайн: retrieval, evaluation, orchestration. На собеседовании про RAG спрашивают другое — то, что курс не проверяет:

  • что будет, если сервису подсунут неправильные переменные окружения при деплое;

  • можно ли по логам восстановить путь одного запроса среди параллельных;

  • видно ли снаружи, что сервис вообще жив и отвечает быстро.

Это и есть Phase 5 — post‑submission работа поверх готового капстоуна, 4 паттерна, каждый отдельным коммитом на ветке feat/production-patterns.

1. Fail‑fast lifespan — не молчать о сломанной конфигурации

src/api.py использует FastAPI lifespan: раньше он просто поднимал приложение, теперь прогревает три внешние зависимости до приёма трафика — модель эмбеддингов (get_model()), реранкер (get_reranker()), LLM‑провайдер (chat_llm().invoke("ping")).

Почему это вообще срабатывает на старте: lifespan — ASGI‑хук. Код до yield — это обработчик события lifespan.startup, которое uvicorn посылает перед тем, как начать принимать HTTP‑трафик. Если до yield вылетает исключение, для ASGI‑сервера это failed startup: uvicorn не переходит в режим приёма запросов, процесс завершается ненулевым кодом. Это стандартное поведение протокола — не кастомный try/except.

Смысл один: раньше сломанный OPENROUTER_API_KEY, неправильный LLM_PROVIDER или битые файлы моделей всплывали на первом пользовательском /verify — то есть уже в проде. Теперь любая из трёх зависимостей падает синхронно, до yield: контейнер не доходит до Application startup complete, ошибка видна сразу на docker compose up.

Механизм одинаковый для всех трёх, только зависимость разная:

  • LLM‑ключ — chat_llm().invoke("ping") бьёт по сети реальным запросом.

  • Модели — get_model()/get_reranker() вызывают Embedder.__init__/CrossEncoder.__init__, те делают Tokenizer.from_file(...) и ort.InferenceSession(...) по пути MODELS_DIR / repo. Не отработал download_onnx_models.py на сборке — файлов там нет, оба вызова падают FileNotFoundError до yield.

src/api.py, весь прогрев целиком:

@asynccontextmanager
async def lifespan(app: FastAPI):
    get_model()
    get_reranker()
    chat_llm().invoke("ping")  # real call to LLM provider — fails fast on bad/missing API key
    yield


app = FastAPI(title="Fact-Checker RAG", lifespan=lifespan)

Три строки, но каждая бьётся о реальную внешнюю зависимость (модель на диске, модель на диске, сетевой запрос к LLM‑провайдеру), а не просто импортирует модуль.

Важно не путать со сборкой образа: lifespan выполняется только при старте контейнера (docker compose up), не при docker build. Модели на диск кладёт отдельный RUN‑шаг сборки (download_onnx_models.py, раздел 4 ниже) — к моменту старта они уже должны там лежать.

Проверка: закомментировал OPENROUTER_API_KEY в .env, поднял docker compose up -d --build app — контейнер завершился с Application startup failed. Exiting. и openai.OpenAIError: Missing credentials, статус Exited (1), строка Application startup complete вообще не появилась в логах. Вернул ключ обратно — чистый старт.

Проверен только LLM‑ключ — реальным прогоном с логом выше. Кейс с битыми моделями (FileNotFoundError из bullet выше) не гонял — это описание механизма по коду, не отдельно снятый лог.

2. structlog — JSON‑логи с request_id

/verify теперь пишет verify_start/verify_done как структурированные JSON‑записи (structlog, JSONRenderer + ISO timestamp), и у каждой записи есть request_id (uuid4).

Зачем это, если и так есть логи: в проде запросы идут параллельно, и строки от разных пользователей перемешиваются в общем потоке. Восстановить путь конкретного запроса от начала до конца обычным grep по тексту не получится — совпадения по содержимому у разных запросов случаются постоянно. С request_id это один grep <uuid>.

Проверка: curl -X POST :8000/verify с телом запроса — в логах контейнера появляются две JSON‑строки, verify_start и verify_done, обе с одинаковым request_id.

Настройка structlog и сам обработчик (src/api.py):

structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.add_log_level,
        structlog.processors.JSONRenderer(),
    ]
)
log = structlog.get_logger()

...

@app.post("/verify")
def verify(request: VerifyRequest) -> VerifyResponse:
    request_id = str(uuid.uuid4())
    log.info("verify_start", request_id=request_id, text_len=len(request.text))
    REQUESTS.inc()
    start = time.perf_counter()
    try:
        results = []
        for claim in extract_claims(request.text):
            v = verify_claim(claim)
            results.append(ClaimVerdict(claim=claim.text, verdict=v.verdict, source=v.source, quote=v.quote))

        log.info("verify_done", request_id=request_id, num_claims=len(results))
        return VerifyResponse(claims=results)
    except Exception:
        ERRORS.inc()
        raise
    finally:
        LATENCY.observe(time.perf_counter() - start)

request_id генерируется один раз на входе в обработчик /verify и прокидывается в оба лог‑вызова — весь контракт держится на том, что это одна и та же переменная, а не на каком‑то скрытом контексте.

3. Prometheus + Grafana — pull‑based метрики, не просто эндпоинт

src/api.py добавляет prometheus_client — счётчики и гистограмму латентности, смонтированные как отдельное ASGI‑приложение:

REQUESTS = Counter("verify_requests_total", "Total /verify calls")
ERRORS = Counter("verify_errors_total", "Total /verify errors")
LATENCY = Histogram("verify_latency_seconds", "/verify latency")

app.mount("/metrics", make_asgi_app())

make_asgi_app() — готовое ASGI‑приложение из prometheus_client, монтируется как саб‑приложение FastAPI, не переписывается вручную поверх Response.

Нюанс с конечным слэшем: make_asgi_app() делает 307-редирект /metrics/metrics/. Скрейпер, настроенный без слэша, получит редирект вместо метрик и, в зависимости от настроек, либо зафейлится, либо будет скрейпить не туда.

В docker-compose.yml — два новых сервиса: prometheus (скрейпит app:8000/metrics/ раз в 5 секунд по prometheus.yml) и grafana. Prometheus как источник данных в Grafana добавлен руками через UI (Settings → Data Sources), не YAML‑конфигом (provisioning) — то есть настройка не переживёт пересоздание контейнера, придётся кликать заново. Честно, не автоматизировано.

Зачем именно так, а не просто эндпоинт: /metrics сам по себе ничего не даёт, если его никто не читает — цифры появятся, только если вручную дёрнуть curl. Prometheus сам, по расписанию, забирает их и сохраняет как историю, Grafana рисует графики по этой истории. То есть не просто «эндпоинт есть», а реально работающий мониторинг.

Проверка — три шага, каждый ловит свою ошибку:

  1. curl :8000/metrics/ — счётчики реально растут (verify_requests_total с каждым вызовом, verify_errors_total = 0 на успехе, verify_latency_seconds заполняется). Это проверяет только приложение — что метрики вообще считаются.

  2. Prometheus UI, /targets — job fact-checker в статусе UP. Это проверяет, что Prometheus вообще достучался до эндпоинта.

  3. curl :9090/api/v1/query?query=verify_requests_total — то же число видно уже в самом Prometheus (TSDB), не только в приложении.

Третий шаг обязателен: шаги 1 и 2 могут быть зелёными, а скрейп при этом не настроен (Prometheus просто ничего не забирает) — это ловит именно шаг 3.

4. ONNX вместо sentence‑transformers — убрать torch целиком

Первое, что бросилось в глаза при ревизии проекта: docker‑образ весил 8.15GB. Для сервиса, который считает эмбеддинги и реранкинг двух небольших моделей, это перебор.

Первый подозреваемый — torch

sentence-transformers тянет torch без вариантов, даже когда инференс идёт не на нём. В pyproject.toml не было пина под CPU‑сборку, поэтому ставился полный GPU‑вариант с CUDA‑библиотеками внутри — в контейнере без GPU они просто лежат мёртвым грузом.

Пин torch на CPU‑only индекс убрал больше 6GB одним параметром: 8.15GB → 1.72GB.

Первая попытка не сработала

Самое простое решение — передать backend="onnx" в конструктор SentenceTransformer. Один параметр, ничего не переписывать.

Образ не похудел ни на мегабайт. Причина: sentence-transformers тянет torch в зависимостях безусловно — независимо от того, какой backend реально гоняет инференс. В uv.lock torch числился обязательной зависимостью, без extra‑маркера, который можно было бы отключить. Смена backend меняла только то, что использует библиотека внутри, а не то, что попадает в docker‑образ.

Пришлось переписывать руками

Убрать torch реально можно было только одним способом — не использовать sentence-transformers вообще. Взял паттерн из ONNX‑урока LLM Zoomcamp (09-onnx‑embedder.md) и написал свои классы в src/embeddings.py и src/rerank.py на голом onnxruntime + tokenizers, без единого импорта sentence-transformers.

Пайплайн внутри простой: токенизация → прогон через onnx‑граф → для эмбеддингов mean pooling и L2-нормализация, для реранкера — сырой логит с одного выхода сети (пары запрос‑документ, без пулинга).

Класс эмбеддера целиком (src/embeddings.py):

class Embedder:
    def __init__(self, repo: str = EMBEDDING_MODEL):
        path = MODELS_DIR / repo
        self.tokenizer = Tokenizer.from_file(str(path / "tokenizer.json"))
        self.tokenizer.enable_padding()
        self.session = ort.InferenceSession(str(path / "model.onnx"), providers=["CPUExecutionProvider"])
        self.input_names = {inp.name for inp in self.session.get_inputs()}

    def encode(self, text: str) -> np.ndarray:
        return self.encode_batch([text])[0]

    def encode_batch(self, texts: list[str]) -> np.ndarray:
        encoded = self.tokenizer.encode_batch(texts)
        feed = {}
        if "input_ids" in self.input_names:
            feed["input_ids"] = np.array([e.ids for e in encoded], dtype=np.int64)
        if "attention_mask" in self.input_names:
            feed["attention_mask"] = np.array([e.attention_mask for e in encoded], dtype=np.int64)
        if "token_type_ids" in self.input_names:
            feed["token_type_ids"] = np.array([e.type_ids for e in encoded], dtype=np.int64)

        hidden = self.session.run(None, feed)[0]
        mask = feed["attention_mask"][..., None]
        pooled = (hidden * mask).sum(axis=1) / mask.sum(axis=1)
        return pooled / np.linalg.norm(pooled, axis=1, keepdims=True)

Реранкер (src/rerank.py) устроен так же, разница только в последнем шаге — вместо mean pooling берётся сырой логит с одного выхода сети, без нормализации:

class CrossEncoder:
    def __init__(self, repo: str = RERANK_MODEL):
        path = MODELS_DIR / repo
        self.tokenizer = Tokenizer.from_file(str(path / "tokenizer.json"))
        self.tokenizer.enable_padding()
        self.session = ort.InferenceSession(str(path / "model.onnx"), providers=["CPUExecutionProvider"])
        self.input_names = {inp.name for inp in self.session.get_inputs()}

    def predict(self, pairs: list[tuple[str, str]]) -> np.ndarray:
        encoded = self.tokenizer.encode_batch(pairs)
        feed = {}
        if "input_ids" in self.input_names:
            feed["input_ids"] = np.array([e.ids for e in encoded], dtype=np.int64)
        if "attention_mask" in self.input_names:
            feed["attention_mask"] = np.array([e.attention_mask for e in encoded], dtype=np.int64)
        if "token_type_ids" in self.input_names:
            feed["token_type_ids"] = np.array([e.type_ids for e in encoded], dtype=np.int64)

        logits = self.session.run(None, feed)[0]
        return logits.reshape(-1)

Три if "..." in self.input_names перед сборкой feed — не перестраховка «на всякий случай», а реальная защита. Разные ONNX‑модели ожидают разный набор входов: у одних есть token_type_ids, у других (например, у некоторых DistilBERT‑подобных моделей) его нет вообще. Если бы код всегда клал все три ключа безусловно, на модели без token_type_ids ONNX Runtime упал бы с ошибкой — сессия просто не ждёт входа, которого нет в её графе. Проверка через input_names делает код рабочим на любой модели с любым набором входов, а не только на той, что использовалась при написании.

Публичный интерфейс поверх обоих классов не поменялся — embed_texts() и rerank() вызываются в остальном коде ровно как раньше, поменялась только начинка внутри:

@lru_cache(maxsize=1)
def get_model() -> Embedder:
    return Embedder()


def embed_texts(texts: list[str]) -> list[list[float]]:
    if not texts:
        return []
    vectors = get_model().encode_batch(texts)
    return vectors.tolist()

Модели для ONNX тяну отдельным скриптом download_onnx_models.py, не в рантайме:

  • Когда: на этапе сборки образа (RUN python download_onnx_models.py в Dockerfile и Dockerfile.airflow, после COPY) — тот же fail‑fast принцип, что и с прогревом: не хочется, чтобы контейнер зависел от сети ради того, что между запусками не меняется.

  • Откуда: tokenizer.json + onnx/model.onnx прямо с HF Hub — обе используемые модели уже публикуют ONNX‑экспорт сами, сторонний мирор не понадобился.

  • Зависимости: из pyproject.toml/requirements-airflow.txt убраны sentence-transformers и torch (и CPU‑пин, который существовал только ради torch), добавлены onnxruntime, tokenizers, huggingface-hub.

Итог: 1.72GB → 1.35GB, ещё −21%.

Почему не в разы

Я ждал более резкого падения. Истории про то, как удаление torch ужимает образ в разы, встречаются часто — урок курса упоминает 33x на проекте с нуля. Здесь так не вышло.

Причина простая: langchain, ragas, pandas, streamlit, pyarrow остались в образе как были, а весят они куда больше, чем onnxruntime (53MB). Torch был одной строкой в общем счёте — не всем счётом.

Проверка, что цифры вообще сопоставимы:

  • эмбеддинги остались 384-мерными, L2-норма ≈ 1.0;

  • скор реранкера у ONNX‑версии совпал с sentence-transformers до 5 значащих цифр (-4.2231293 против -4.2231326, -11.352023 против -11.352027) — это FP32-округление, не смена поведения модели;

  • uv sync подтвердил, что torch/sentence-transformers/transformers полностью пропали из resolved‑окружения;

  • docker compose build app собирается со скачиванием моделей внутри, контейнер доходит до LLM‑ping строки в lifespan без ошибок эмбеддингов/реранкинга — ONNX‑путь реально работает внутри образа, не только на хостовом venv.

Почему это два разных слоя, а не дубль

Phase 4 (Streamlit UI + мониторинг) считает бизнес‑метрики: распределение вердиктов, доля фидбека, токены на запрос — это сигнал, который пишется в Postgres и читается со Streamlit‑дашборда. Phase 5 — это инфраструктурный слой: жив ли процесс, с какой скоростью отвечает, как часто падает — сигнал для дежурного инженера или healthcheck в Docker/k8s. Они не конкурируют между собой, отвечают на разные вопросы.

Что осталось честно не доделанным

UI на Streamlit вызывает extract_claims()/verify_claim() напрямую в своём процессе, минуя HTTP /verify. Из‑за этого весь остальной production‑слой из Phase 5 — fail‑fast прогрев, structured‑логи, метрики Prometheus — видит только прямые HTTP‑запросы к сервису app на :8000, а трафик через UI на :8501 проходит мимо. Чинится это переносом токен‑аккаунтинга на сторону API — примерно 35–40 строк в app.py и src/api.py. Сейчас он живёт в get_usage_log(): thread‑local, наполняется in‑process прямо внутри invoke_structured. В рамках этой сессии я оставил как есть и задокументировал, а не сделал вид, что проблемы нет.

Запустить

docker compose up -d --build app ui        # API → :8000, UI → :8501
docker compose up -d prometheus grafana    # метрики → :9090, дашборды → :3000

Репозиторий: github.com/vadimvvlasov/llm‑fact‑checker