No description
  • Python 94.5%
  • Dockerfile 5.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Артур Калагов 02fac8a0a7
All checks were successful
Build and Push Docker Images / build-cpu (push) Successful in 24s
Build and Push Docker Images / build-cuda (push) Successful in 13m50s
Merge pull request 'Настройка Renovate для автоматического обновления зависимостей' (#1) from renovate/configure into main
Reviewed-on: #1
2026-09-23 17:37:34 +05:00
.forgejo/workflows laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00
app docs+eval: generalize to any classification scenario (support/spam/moderation) 2026-09-22 13:25:53 +00:00
data docs+eval: generalize to any classification scenario (support/spam/moderation) 2026-09-22 13:25:53 +00:00
docs docs+eval: generalize to any classification scenario (support/spam/moderation) 2026-09-22 13:25:53 +00:00
scripts docs+eval: generalize to any classification scenario (support/spam/moderation) 2026-09-22 13:25:53 +00:00
tests docs+eval: generalize to any classification scenario (support/spam/moderation) 2026-09-22 13:25:53 +00:00
.dockerignore laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00
.env.example docs+compose: add HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE to stop per-boot HF check 2026-09-22 16:18:01 +00:00
.gitignore laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00
docker-compose.gpu.yml perf: finalize MAX_INFLIGHT/OMP defaults from dedicated-A2 benchmarks 2026-09-22 15:42:06 +00:00
docker-compose.yml docs+compose: add HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE to stop per-boot HF check 2026-09-22 16:18:01 +00:00
Dockerfile perf: finalize MAX_INFLIGHT/OMP defaults from dedicated-A2 benchmarks 2026-09-22 15:42:06 +00:00
litellm_classifier_sample.yaml fix: correct RU/KZ routing on the Jev path + LiteLLM custom-plugin integration 2026-09-22 09:57:54 +00:00
litellm_plugin.py fix: correct RU/KZ routing on the Jev path + LiteLLM custom-plugin integration 2026-09-22 09:57:54 +00:00
pyproject.toml laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00
README.md docs+compose: add HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE to stop per-boot HF check 2026-09-22 16:18:01 +00:00
renovate.json Add renovate.json 2026-09-22 17:32:01 +00:00
requirements-torch-cpu.txt laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00
requirements-torch-cuda.txt laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00
requirements.txt laya-systemone: Jev/TypeSafe-compatible /v1/systemone server for the Laya Router 2026-09-22 09:12:05 +00:00

laya-systemone

HTTP-сервис для Laya — быстрого System-1 классификатора на энкодерах (без авторегрессии) с откалиброванными вероятностями.

Python Laya Docker GPU CPU Forgejo Actions Container Registry

Gitea Stars Gitea Forks Gitea Issues Open Gitea Issues Closed Gitea Last Commit Gitea Release

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 (в порядке возрастания усилий):

  1. Head-only (рекомендуемый старт). Энкодер замораживаем, обучаем scorer/act_head (2 слоя) на ваших (текст, метка) — десятки–сотни тысяч примеров не нужны, обычно хватает 1–5k размеченных на домен. Потеря: почти ничего (представление BERT-энкодера уже мультязычное); выигрыш: head учится вашим категориям/порогам. GPU: любая 1×A2, часы.
  2. LoRA по энкодеру — если head-only не дотягивает по recall на целевом языке (KZ-токсичность, скорее всего, именно сюда).
  3. Полное — только если домен экзотический; риск 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.

Известные грабли (найдены при разработке)

  1. laya.load(device="cuda") молча падает на CPU (предупреждение в лог, ответы «правильные», в 10× медленнее). Поэтому LAYA_REQUIRE_CUDA=true в GPU-варианте + hard-assert на agent.device.type после загрузки.
  2. CPU-образ: один pip-pass. laya декларирует torch>=2.0.0; отдельный pip install laya без --extra-index-url тащит ~5 ГБ CUDA-зависимостей. В Dockerfile резолвится один проход с пином torch==2.14.0+cpu.
  3. A2 (sm_89, Ada) — поддерживается стандартным PyPI-torch (bf16); Blackwell-пиннинг cu128 из других Laya-образов не нужен.
  4. Первый forward на GPU ~15 с (CUDA context + autotune) — warmup-предикт при старте, не первым запросом.
  5. 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.)
  6. 1 uvicorn-worker: модель в процессе, воркеры дублировали бы веса без прироста.
  7. USE_TF=0 — transformers при наличии TF deadlock'ится при сборке модели.
  8. multilingual не поставляется с откалиброванной температурой (temperature: [1,1,1]) — доверять confidence можно только после замера ECE (seed: RU/KZ ECE ~0.1, EN ~0.5 — EN-температуру стоит перекалибровать первой).
  9. CPU + параллельные запросы: 8 параллельных forward'ов дают деградацию ~10× (BLAS thread-pool thrashing). Это ок для inline-классификатора (один на запрос + короткий timeout клиента + fallback); для batch-обработки используйте GPU.
  10. Алиас «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.