- PVSM.RU - https://www.pvsm.ru -
У меня есть 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.
src/api.py [1] использует 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 [2] на сборке — файлов там нет, оба вызова падают FileNotFoundError до yield.
src/api.py [1], весь прогрев целиком:
@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 [2], раздел 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 выше) не гонял — это описание механизма по коду, не отдельно снятый лог.
/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 [1]):
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 и прокидывается в оба лог‑вызова — весь контракт держится на том, что это одна и та же переменная, а не на каком‑то скрытом контексте.
src/api.py [1] добавляет 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 — job fact-checker в статусе UP. Это проверяет, что Prometheus вообще достучался до эндпоинта.
curl :9090/api/v1/query?query=verify_requests_total — то же число видно уже в самом Prometheus (TSDB), не только в приложении.
Третий шаг обязателен: шаги 1 и 2 могут быть зелёными, а скрейп при этом не настроен (Prometheus просто ничего не забирает) — это ловит именно шаг 3.
Первое, что бросилось в глаза при ревизии проекта: docker‑образ весил 8.15GB. Для сервиса, который считает эмбеддинги и реранкинг двух небольших моделей, это перебор.
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 [3]) и написал свои классы в src/embeddings.py [4] и src/rerank.py [5] на голом onnxruntime + tokenizers, без единого импорта sentence-transformers.
Пайплайн внутри простой: токенизация → прогон через onnx‑граф → для эмбеддингов mean pooling и L2-нормализация, для реранкера — сырой логит с одного выхода сети (пары запрос‑документ, без пулинга).
Класс эмбеддера целиком (src/embeddings.py [4]):
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 [5]) устроен так же, разница только в последнем шаге — вместо 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 [2], не в рантайме:
Когда: на этапе сборки образа (RUN python download_onnx_models.py [2] в 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 + мониторинг) [6] считает бизнес‑метрики: распределение вердиктов, доля фидбека, токены на запрос — это сигнал, который пишется в 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 [7] и src/api.py [1]. Сейчас он живёт в 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
Автор: datascientist_ekb
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/grafana/456734
Ссылки в тексте:
[1] api.py: http://api.py
[2] models.py: http://models.py
[3] 09-onnx‑embedder.md: http://09-onnx-embedder.md
[4] embeddings.py: http://embeddings.py
[5] rerank.py: http://rerank.py
[6] Phase 4 (Streamlit UI + мониторинг): https://github.com/vadimvvlasov/llm-fact-checker
[7] app.py: http://app.py
[8] Источник: https://habr.com/ru/articles/1071230/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1071230
Нажмите здесь для печати.