- Python 94.5%
- Dockerfile 5.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| app | ||
| data | ||
| docs | ||
| scripts | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.gpu.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| litellm_classifier_sample.yaml | ||
| litellm_plugin.py | ||
| pyproject.toml | ||
| README.md | ||
| renovate.json | ||
| requirements-torch-cpu.txt | ||
| requirements-torch-cuda.txt | ||
| requirements.txt | ||
laya-systemone
HTTP-сервис для Laya — быстрого System-1 классификатора на энкодерах (без авторегрессии) с откалиброванными вероятностями.
Laya (Convai Innovations, Apache-2.0) — быстрый System-1 классификатор-«приниматель решений» на BERT-подобных энкодерах (без авторегрессии). Один forward pass даёт откалиброванные вероятности по любым вашим вопросам: тематика, спам/фишинг, токсичность, сложность запроса — что угодно из трёх нативных типов:
| Тип | Что возвращает | Пример |
|---|---|---|
choice |
один из ваших ключей + probabilities по всем + confidence |
«какая тема тикета? billing/technical/account/...» |
noul |
бинарный go/no-go (0..1) + act_probability |
«это спам?», «нужен ли ответ?», «токсично?» |
score |
непрерывная порядковая оценка 0..(N-1) по вашей шкале | «уровень срочности: no pressure → blocking → critical» |
Мультязычность — из коробки. Авторский Router определяет скрипт/язык текста и шлёт его на нужный чекпоинт: EN → english (ModernBERT-large, 421M, 512 токенов), RU/KZ/… (кириллица и прочие не-латиницы) → multilingual (mmBERT-base, 322M, 1024 токенов, 100+ языков). Оба резидентны в памяти — переключение языка стоит ~0 мс.
Скорость (замеры на хосте, выделенная A2, один вопрос, смешанный корпус RU/KZ/EN):
| Режим | CPU (inflight=8, OMP=4) | GPU A2 (inflight=8) |
|---|---|---|
| последовательно, p50 / mean | 127 / 199 мс | 25 / 41 мс (≈5×) |
| 8 паралл., p50 / wall на 32 req | 290 мс / 1.9 с | 367 мс / 1.5 с |
| 32 паралл., p50 / wall на 128 req | 1586 мс / 7.0 с | 1373 мс / 5.7 с |
| насыщение (req/s) | ~18 | ~22 |
Ответы CPU и GPU совпадают по выбору тира и роутингу (12/12 на seed-корпусе). Для inline-классификатора (один вызов на ход пользователя) важен первый ряд: GPU p50 25 мс — зазор до таймаута плагина (900 мс) в 35×. CPU пригоден как fallback/проба качества.
LAYA_MAX_INFLIGHTвыше 8 не даёт throughput (проверено: GPU 8→16 = 24→22 req/s, CPU 4→8 = 16→18 req/s). GPU ограничен одним CUDA-stream'ом (forward'ы сериализуются, потолок ≈ 1/среднее_время ≈ 22 req/s); CPU — BLAS-oversubscription (нужно снижатьOMP_NUM_THREADSпропорционально). Для inline-классификации 22 req/s — избыточный запас (10 одновременных пользователей ≈ 5–10 req/s). Если когда-нибудь понадобится > 22 req/s на GPU — через CUDA streams или request-level batching, не через inflight.
Возможности в цифрах (zero-shot, seed-наборы в репо)
| Сценарий (primary) | Тип | Exact acc | Комментарий |
|---|---|---|---|
| Тема тикета поддержки (5 кл.) | choice | 83% | промахи — класс «general» (закрыл/спасибо) |
| Спам письма | noul | 78% (F1 0.83) | EN 100%; RU/KZ хуже на коротких «нет» |
| Токсичность UGC | noul | 71% (prec 1.0) | точность на дающих 100%: каждый «yes» — верный; recall на KZ низкий |
| Сложность запроса (4 тира) | choice | 45% exact / 83% ±1 | см. docs/litellm-autorouting.md |
Все цифры — на seed-наборах (14–42 примера) только для калибровки методологии; перед запуском обязательно перемерить на своих данных (см. ниже). Детали и повторение: scripts/eval_quality.py --scenario <name>.
Запуск
Docker (рекомендуется)
Готовые образы в корпоративном регистре (CI собирает оба на каждый push в main):
git.maydayoffice.kz/maydayoffice/laya-systemone:cpu и :cuda.
git clone https://git.maydayoffice.kz/maydayoffice/laya-systemone
cd laya-systemone
echo "LAYA_API_KEY=$(openssl rand -hex 16)" >> .env
docker compose up -d --build # CPU, порт 43300
# GPU (RTX A2 и аналог):
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build # порт 43000
Первый старт скачивает ~1.4 GB весов в именованный volume (laya-hf-cache) — дальше старт быстрее. Важно: даже с тёплым кэшем каждый старт по умолчанию делает короткий запрос к HF («есть ли новая ревизия?») — в логах это GET https://huggingface.co/api/models/convaiinnovations/laya/revision/main. Чтобы загрузить веса строго офлайн, добавьте в .env:
HF_HUB_OFFLINE=1
TRANSFORMERS_OFFLINE=1
и перезапустите контейнер. (Ставьте 0 только на чистой машине с пустым кэшем — для первого скачивания.)
Контейнер отвечает /healthz 503 {"status":"loading"} до готовности (задайте start_period у healthcheck с запасом, в compose уже 600s).
Без Docker (разработка)
python3 -m venv .venv
.venv/bin/pip install -r requirements-torch-cpu.txt -r requirements.txt # один проход, см. грабли
LAYA_API_KEY=dev LAYA_DEVICE=cpu .venv/bin/python -m uvicorn app.main:app --port 43300
Переменные окружения (ключевые)
| Var | Default | Описание |
|---|---|---|
LAYA_API_KEY |
— | Bearer-ключ; единственная защита «от посторонних». Мульти-клиенты разделяйте на стороне прокси (LiteLLM) — см. docs/litellm-autorouting.md |
LAYA_DEVICE |
cpu |
cpu / cuda |
LAYA_PRELOAD |
english,multilingual |
Чекпоинты в памяти; без preload переключение языка = 7–10 с reload |
LAYA_REQUIRE_CUDA |
false |
true — тихий fallback CUDA→CPU (laya не роняет!) становится ошибкой запуска |
LAYA_MAX_INFLIGHT |
4 / 8 (cuda) | параллельные forward passes |
LAYA_MAX_QUEUE |
64 | очередь; переполнение → 529 + Retry-After |
LAYA_MAX_LEN / LAYA_HEAD_MAX_LEN |
из чекпоинта | бюджеты контекста (EN 512/192, ML 1024/256) |
LAYA_MODEL |
convaiinnovations/laya |
HF-repo или локальный dir бандла (см. FT ниже) |
HF_TOKEN |
— | для приватных зеркал весов |
API
Все POST под Authorization: Bearer $LAYA_API_KEY. Ошибки: {"detail": {"error_type", "message"}} (401/422/503/529).
POST /v1/predict — основной универсальный эндпоинт
Вопросы задаёт клиент в каждом запросе — сервис ни к одному сценарию не привязан:
// запрос
{"state": {"request": "Платёж списался, но подписка не активировалась"},
"questions": {
"topic": {"type": "choice", "instructions": "What does the customer want in `request`?",
"criteria": {"billing": "...", "technical": "...", "account": "...", "feature": "...", "general": "..."}},
"is_urgent": {"type": "noul", "instructions": "Time pressure or hard deadline?"},
"priority": {"type": "score", "instructions": "Urgency for the support team.",
"criteria": ["p4_low","p3_normal","p2_high","p1_critical"]}}}
// ответ
{"answers": {"topic": {"type":"choice","choice":"billing","confidence":0.85,
"probabilities":{"billing":0.95,"technical":0.04,...},
"action":{"act_probability":0.9}},
"is_urgent":{"type":"noul","noul":0.02,"confidence":0.98,"action":{...}},
"priority":{"type":"score","score":1.74,"legend":{"0":"p4_low",...}}},
"usage": {"input_tokens": 42, "output_tokens": 0},
"routing": {"model": "multilingual", "reason": "non-Latin script (cyrillic, ...)"}}
state — строка или вложенный dict/list (ключи игнорируются детекцией; имя ключа имеет значение: формулировки официальных пресетов ссылаются на request/body/post/prompt — используйте тот же ключ, что и в instructions). routing — какой чекпоинт ответил и почему (для аудита и отладки).
POST /v1/route — только решение роутера (без forward pass, 1–2 мс)
Проверить, на какой чекпоинт уйдёт текст, прежде чем делать предсказание.
POST /v1/systemone — Jev/TypeSafe wire-контракт
Тот же функционал в формате, который постит LiteLLM classifier_type: jev. См. docs/litellm-autorouting.md.
GET /v1/models, GET /healthz
Резидентные чекпоинты; статус загрузки (503 → 200).
Сценарии (seed-определения + датасеты)
app/scenarios.py — единый источник вопросных бандлов; data/<scenario>_eval_set.json — seed-наборы:
| Сценарий | Primary | Тип | Вопросный бандл |
|---|---|---|---|
tier |
tier |
choice | 4-тирная сложность (см. docs/litellm-autorouting.md) |
support |
intent |
choice | тема тикета + is_urgent/refund_requested/churn_risk (noul) |
spam |
is_spam |
noul | спам + is_phishing + needs_reply |
moderation |
toxic |
noul | токсичность + harassment/spam + severity (score) |
Формулировки — близко к официальным пресетам laya.presets (на них модель и обучалась/калибровалась), с RU/KZ-адаптацией. Свой сценарий = свои вопросы в запросе + свой eval-набор; ничего в сервисе менять не нужно.
Методика оценки качества (делайте так)
# 1) seed-прогон (проверка «в принципе работает»):
python3 scripts/eval_quality.py --base http://127.0.0.1:43300 --key "$LAYA_API_KEY" --scenario support
# метрики: exact accuracy, within-1 (для упорядоченных), ECE (калибровка),
# mean_confidence, latency p50/p95, per-class (choice) / P-R-F1 (noul), routing по языкам
# 2) СВОЙ набор: data/my_tickets_eval_set.json (формат: [{"id","lang","gold","text"}])
python3 scripts/eval_quality.py --scenario support --set data/my_tickets_eval_set.json ...
# 3) A/B против базлайна (ваш текущего GigaChat/regex/classifier) на тех же текстах —
# только это решает вопрос «можно ли запускать в прод».
Критерии «можно в прод» (наш ориентир):
- within-1 ≥ 90% для упорядоченных задач (ошибка на один уровень — дёшево: чуть больший thinking budget / один лишний алерт);
- precision на «yes» ≥ 0.95 для binarnых гейтов (спам/токсичность) — лучше пропустить «да», чем ошибочно заблокировать человека; recall догоняется порогом на
noul(по умолчанию 0.5) и/или FT; - ECE ≤ 0.1 — чтобы confidence можно было использовать в гейтах (
LAYA_CONF_GATE-подобных).
Пороги на probabilities/noul — ваш рычаг без дообучения: noul даёт непрерывное 0..1, и сдвиг порога с 0.5 на 0.7–0.9 почти всегда повышает precision с потерей recall. Для KZ-токсичности (seed: recall 0.0!) это первый и правильный шаг, не FT.
Fine-tuning: когда, что и как
Когда: только после шагов 1–3 выше, если на ваших данных (a) within-1/precision не доходят до порогов никакими порогами, или (b) нужен новый язык/домен, которого нет в multilingual.
Что мы знаем из самих чекпоинтов (это не догадка, а rl_agent_config.json из бандла):
english= дообученныйmultilingual("fine_tuned_from_checkpoint": true): ~7 300 обновлений, 1 эпоха, ~2 ч на одной GPU.multilingual— 4 эпохи / 16 000 обновлений / ~5 ч.- Архитектура:
encoder(HuggingFace:answerdotai/ModernBERT-large/jhu-clsp/mmBERT-base) +type_emb+scorer(логиты на варианты) +act_head,head_layers: 2. Валидатор checkpoint'а строго требует этот набор ключей и config — свой FT-артефакт обязан его воспроизвести. - Калибровка:
temperature(по head'ам) и опциональноtemperature_by_options; ECE считается поверх этого. - Тренировочного кода в публичном пакете
layaнет (только inference-модули). Автор обучал RL-агентом (model_name: "rl-agent",act_costs,cost_wrong_act) — это закрытый пайплайн.
Практический план FT (в порядке возрастания усилий):
- Head-only (рекомендуемый старт). Энкодер замораживаем, обучаем
scorer/act_head(2 слоя) на ваших (текст, метка) — десятки–сотни тысяч примеров не нужны, обычно хватает 1–5k размеченных на домен. Потеря: почти ничего (представление BERT-энкодера уже мультязычное); выигрыш: head учится вашим категориям/порогам. GPU: любая 1×A2, часы. - LoRA по энкодеру — если head-only не дотягивает по recall на целевом языке (KZ-токсичность, скорее всего, именно сюда).
- Полное — только если домен экзотический; риск catastrophic forgetting, придётся ре-калибровать (переобучить temperature) и ре-валидировать ECE.
Артефакт FT (что собирать): каталог, изоморфный субфолдеру checkpoint'а:
ft-model/
├── encoder/ # HF-модель (как есть или + LoRA-адаптеры, смёрженные)
├── model.safetensors # веса scorer/type_emb/act_head (формат — как в бандле)
├── rl_agent_config.json# тот же schema; temperature = [1,1,1] до рекалибровки
└── tokenizer/
Подключение: LAYA_MODEL=/path/to/ft-model (локальный dir) — но пока Router ожидает субфолдерную структуру бандла (english в корне, остальные по именам в подпапках): для замены одного чекпоинта достаточно положить FT-каталог как multilingual/ (или english/) в свой бандл-репозиторий/дир и указать его в LAYA_MODEL. Если понадобится произвольное имя чекпоинта (напр. laya-kz) + отдельный HF-repo — это маленький рефакторинг LAYA_MODEL → список (name, repo) (issue, не blocker).
Рекалибровка после FT (обязательно): температура из родительского checkpoint'а не переносится. Прогоните hold-out с известной истинностью, подберите temperature (по head'ам) на минимум ECE (grid 0.3–2.0, шаги 0.1 — минутная работа), впишите в rl_agent_config.json, заново замерьте ECE/precision на пороге 0.5 и выберите рабочий порог.
Разметка: формат уже заложен в seed-наборах (id/lang/gold/text). 500–2000 примеров на домен (стратифицированно по классам, с RU/KZ-разбиением) — реалистичный минимум для head-only; для LoRA — 5–20k.
Известные грабли (найдены при разработке)
laya.load(device="cuda")молча падает на CPU (предупреждение в лог, ответы «правильные», в 10× медленнее). ПоэтомуLAYA_REQUIRE_CUDA=trueв GPU-варианте + hard-assert наagent.device.typeпосле загрузки.- CPU-образ: один pip-pass.
layaдекларируетtorch>=2.0.0; отдельныйpip install layaбез--extra-index-urlтащит ~5 ГБ CUDA-зависимостей. В Dockerfile резолвится один проход с пиномtorch==2.14.0+cpu. - A2 (sm_89, Ada) — поддерживается стандартным PyPI-torch (bf16); Blackwell-пиннинг cu128 из других Laya-образов не нужен.
- Первый forward на GPU ~15 с (CUDA context + autotune) — warmup-предикт при старте, не первым запросом.
- CUDA-образ должен содержать
gcc/g++(Triton JIT-компилирует C-драйвер на первом forward; ModernBERT-RoPE использует triton-кернелbmm_outer_product).cuda.h— из pip-колёсаnvidia-cuda-runtime, не из системы. Без gcc контейнер падает именно на warmup с «Failed to find C compiler». (Dockerfile ставит их только дляDEVICE=cuda.) - 1 uvicorn-worker: модель в процессе, воркеры дублировали бы веса без прироста.
USE_TF=0— transformers при наличии TF deadlock'ится при сборке модели.multilingualне поставляется с откалиброванной температурой (temperature: [1,1,1]) — доверятьconfidenceможно только после замера ECE (seed: RU/KZ ECE ~0.1, EN ~0.5 — EN-температуру стоит перекалибровать первой).- CPU + параллельные запросы: 8 параллельных forward'ов дают деградацию ~10× (BLAS thread-pool thrashing). Это ок для inline-классификатора (один на запрос + короткий timeout клиента + fallback); для batch-обработки используйте GPU.
- Алиас «laya» в Router мапит на
english— никогда не передавайте его как имя чекпоинта (сервис уже защищён: пинятся только резидентные имена; скрипт-правильность выше явного пина).
Структура
app/ FastAPI: main, config, jev_adapter, jev_schemas, laya_loader, batcher, scenarios
scripts/ eval_quality.py (универсальный harness по сценариям)
data/ seed-наборы: tier/support/spam/moderation
docs/ litellm-autorouting.md, scenarios-support-spam.md
tests/ unit: jev-адаптер, routing-pin, plugin compose, rubric sync (14 passed)
litellm_plugin.py standalone ClassifierPlugin для LiteLLM (копируется на хост прокси)
Dockerfile multi-stage, DEVICE=cpu|cuda, non-root, ENV BLAS caps
docker-compose.yml / docker-compose.gpu.yml
.forgejo/workflows/ci-cd.yml два параллельных build'а (:cpu, :cuda) на push в main
CI/CD
.forgejo/workflows/ci-cd.yml на push в main собирает :cpu и :cuda и пушит в git.maydayoffice.kz/maydayoffice/laya-systemone.