У меня есть 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 рисует графики по этой истории. То есть не просто «эндпоинт есть», а реально работающий мониторинг.
Проверка — три шага, каждый ловит свою ошибку:
curl :8000/metrics/— счётчики реально растут (verify_requests_totalс каждым вызовом,verify_errors_total= 0 на успехе,verify_latency_secondsзаполняется). Это проверяет только приложение — что метрики вообще считаются.Prometheus UI,
/targets— jobfact-checkerв статусеUP. Это проверяет, что Prometheus вообще достучался до эндпоинта.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

