Модуль 10

Свои модели: инференс и адаптация

Два повода уйти с чужого API на свою модель — собственный инференс и адаптация под задачу. В первой половине модуля разбираем, как устроен serving: prefill и decode, continuous batching, PagedAttention, KV-cache как бюджет конкурентности, speculative decoding. Дальше квантизация и её цена в качестве, экономика self-host против API, Go-шлюз перед vLLM и лицензииJava-шлюз перед vLLM и лицензии open-weight моделей. Во второй половине — когда fine-tuning оправдан, LoRA и QLoRA, датасет и синтетика, DPO, дистилляция, честная оценка «до и после» и multi-LoRA serving.

≈ 15 часовGoJava + Python6 визуализацийна момент написания: октябрь 2026

Как устроен serving

В модуле 1 («Инференс: KV-cache и квантизация») генерация разложена на две фазы. Prefill прогоняет весь промпт одним параллельным проходом и упирается в вычисления (compute-bound). Decode выдаёт по одному токену за шаг и упирается в пропускную способность памяти (memory-bound): на каждом шаге читаются все веса и весь KV-cache. Serving-движок (vLLM, SGLang) — это планировщик поверх этих двух фаз. Он решает, какие запросы считать на текущем шаге, где хранить их KV-cache и кого вытеснить, когда память кончилась. От этих решений зависят и латентность каждого пользователя, и цена токена.

Метрики: что вы обещаете в SLO

МетрикаОпределениеОт чего зависит
TTFT (time to first token)От прихода запроса до первого токена ответаОчередь плюс prefill. Растёт с длиной промпта и с загрузкой: новый запрос ждёт места в батче
TPOT (time per output token)Среднее время на токен после первого: (E2E − TTFT) / (N − 1) для каждого запросаВремя шага decode: размер батча, длина контекстов в батче, квантизация
ITL (inter-token latency)Каждый промежуток между соседними токенами. Смотрят распределение и p99Выбросы: чужой длинный prefill в том же шаге, вытеснение запроса (preemption)
E2E latencyTTFT + TPOT × (N − 1) для ответа из N токеновУ коротких ответов доминирует TTFT, у длинных — TPOT
ThroughputВыходных токенов (или запросов) в секунду по всем запросамРазмер батча: растёт, пока хватает KV-cache и вычислений
GoodputЗапросов в секунду, уложившихся в SLO по TTFT и TPOT одновременноЭто и есть полезная ёмкость. Throughput можно поднять огромным батчем, нарушив SLO для всех

Типичный SLO для чата: p95 TTFT меньше секунды и p95 TPOT меньше 50 мс (20 токенов в секунду, быстрее, чем человек читает). Для фоновой суммаризации TTFT почти не важен, а важны throughput и цена, поэтому та же реплика держит заметно больше фоновой нагрузки, чем интерактивной. Ёмкость реплики без указания SLO — бессмысленное число. Термин goodput в этом смысле ввели авторы DistServe.

Continuous batching и PagedAttention

Конкурентность из бюджета KV-cache

KV-бюджет = VRAM × gpu_memory_utilization − веса − служебная память (активации, CUDA graphs)
KV на токен = 2 × layers × kv_heads × head_dim × байт
одновременных запросов ≈ KV-бюджет / (KV на токен × (вход + выход))

Пример: Qwen3-8B (36 слоёв, 8 KV-голов, head_dim 128) на одном H100 80 ГБ. Веса в BF16 занимают 16.4 ГБ, KV на токен — 2 × 36 × 8 × 128 × 2 байта = 144 КБ. При --gpu-memory-utilization 0.9 и паре гигабайт служебной памяти на KV-cache остаётся около 53 ГБ, то есть примерно 360 тысяч токенов. Запросов по 4K токенов (промпт плюс ответ) поместится около 90, по 32K — всего 11. FP8 для KV-cache (--kv-cache-dtype fp8) удваивает эти числа. При старте vLLM пишет в лог размер KV-cache в токенах и максимальную конкурентность для --max-model-len: сверяйте свою оценку с ним.

Отсюда смысл двух флагов. --max-model-len ограничивает длину одного запроса, --max-num-seqs — число последовательностей в батче. Ставить --max-model-len с запасом до 128K «на всякий случай» вредно: один длинный запрос занимает место десятков коротких. Если длинные запросы редки, выделите под них отдельную реплику или пул.

Аналогия из Java

KV-бюджет работает как java.util.concurrent.Semaphore, где запрос берёт не одно разрешение, а acquire(n) по числу токенов в контексте: запрос «стоит» столько, сколько у него токенов, а не одну единицу. Поэтому --max-num-seqs — только верхняя граница, а реальную конкурентность определяет длина запросов. Шлюз ниже держит обычный семафор по числу запросов: этого хватает, когда длины предсказуемы.

Аналогия из Go

KV-бюджет работает как semaphore.Weighted из golang.org/x/sync с весом в байтах: запрос «стоит» столько, сколько токенов у него в контексте, а не одну единицу. Поэтому --max-num-seqs — только верхняя граница, а реальную конкурентность определяет длина запросов. Шлюз ниже держит обычный семафор по числу запросов: этого хватает, когда длины предсказуемы.

Chunked prefill

Prefill промпта на 30K токенов занимает от сотен миллисекунд до секунд. Если считать его одним куском, остальные запросы в батче на это время перестают получать токены, и у них скачет ITL. Chunked prefill режет prefill на части, которые укладываются в бюджет токенов на шаг (--max-num-batched-tokens), и смешивает их с decode-шагами других запросов (идея Sarathi-Serve). В vLLM V1 он включён по умолчанию. Компромисс настраивается одним числом: по документации vLLM меньший бюджет (порядка 2048) даёт более ровный ITL, а больший (от 8192) — лучше TTFT и throughput.

Prefix caching

KV-блоки хэшируются по содержимому префикса, и запрос с тем же началом (system prompt, few-shot, история чата, тот же документ в RAG) переиспользует уже посчитанный KV-cache: prefill сокращается до «хвоста». Это аналог prompt caching у провайдера (модуль 2), только бесплатный. В свежих версиях vLLM он включён по умолчанию. Для архитектуры из этого следуют три вещи:

Speculative decoding

При малом батче decode простаивает: GPU читает 16 ГБ весов ради одного токена. Speculative decoding использует эту простаивающую мощность. Дешёвый черновик (маленькая модель, дополнительные головы или n-gram-поиск по промпту) предлагает k токенов, большая модель проверяет их все за один проход, как при prefill, и принимает самый длинный подходящий префикс плюс один свой токен. Правило принятия (rejection sampling) подобрано так, что распределение ответа совпадает с распределением большой модели. Это ускорение без потери качества, а не аппроксимация.

ожидаемо токенов за проход большой модели = (1 − αk+1) / (1 − α), α — доля принятых токенов черновика

При α = 0.7 и k = 4 выходит ≈ 2.8 токена за проход вместо одного, но каждый проход теперь дороже: работает черновик и проверяются k позиций. При большом батче GPU и так загружен вычислениями, и выигрыш тает. Это видно по цифрам EAGLE-3: до 6.5× ускорения на одиночных запросах и 1.38× по throughput при батче 64 (в SGLang). Документация vLLM называет целевой сценарий прямо: низкая и средняя нагрузка, memory-bound. Варианты в vLLM задаются через --speculative-config:

Acceptance rate зависит от домена и температуры: черновик, обученный на английском чате, плохо угадывает русские юридические тексты. Включайте speculative decoding только после замера TPOT и goodput на своём трафике.

Serving-калькулятор: VRAM, конкурентность, TTFT и TPOT

Выберите модель, квантизацию весов и GPU, задайте длину запросов и число одновременных пользователей. Сверху — раскладка видеопамяти. Снизу — как throughput и TPOT меняются с ростом конкурентности (ось логарифмическая; вертикальный пунктир — момент, когда кончается KV-cache, горизонтальный — SLO 50 мс на токен). Это расчёт «на салфетке», чтобы понять, что во что упирается, а не замена нагрузочному тесту.

весаKV-cache текущих запросовсвободный KV-бюджетслужебная память и запас
Что упрощено в схеме

Достижимая полоса памяти принята за 70% паспортной, вычисления — за 50% пика BF16 (для FP8 вдвое больше). Tensor parallel масштабируется идеально, без затрат на связь между GPU. Prefill считается как 2 × параметры × токены, без квадратичной части attention и без очереди. Все запросы одной длины, chunked prefill, prefix caching и speculative decoding не учтены, служебная память — 2 ГБ на GPU. Реальные числа дают лог vLLM при старте и vllm bench serve. Схема нужна, чтобы увидеть форму зависимостей: throughput растёт с батчем почти линейно, TPOT сначала почти стоит на месте, а потолок задаёт KV-cache.

Квантизация для инференса

Форматы и байты на параметр разобраны в модуле 1. Здесь речь о методах, которые встречаются при self-hosting: что они ускоряют и как проверить, что качество не уехало.

МетодЧто сжатоКак работаетГде и зачем
FP8 (W8A8)Веса и активации, 8-битное число с плавающей точкой (E4M3)Масштаб на тензор или канал. Квантуется на лету при загрузке (--quantization fp8) или берётся готовый FP8-чекпойнтGPU с аппаратным FP8: Ada (L4, L40S), Hopper (H100, H200), Blackwell. Памяти вдвое меньше, быстрее и prefill, и decode. Качество обычно близко к BF16, поэтому на H100 это разумный дефолт
AWQ (W4A16)Только веса, 4 бита; вычисления в 16 битахОколо 1% «важных» каналов весов (тех, на которые приходятся большие активации на калибровочных данных) масштабируются так, чтобы ошибка квантизации на них была меньшеНужно уместить модель в меньшую или более дешёвую GPU. Ускоряет memory-bound decode при малом батче
GPTQ (W4A16, W8A16)Только весаКвантует веса слой за слоем и компенсирует ошибку остальными весами слоя с помощью приближённой информации второго порядка (гессиана) по калибровочному наборуТа же ниша, что у AWQ, много готовых чекпойнтов. Чувствителен к выбору калибровочных данных
GGUF (Q4_K_M, Q5_K_M, Q8_0…)Формат файла llama.cpp и набор схем квантизации с разными битами для разных тензоровБлочная квантизация с масштабом на блок; K-quants смешивают точность по слоямOllama и llama.cpp: ноутбук, Apple Silicon, CPU, edge. Для GPU-сервера с высокой конкурентностью не основной путь
KV-cache FP8Только KV-cache--kv-cache-dtype fp8Вдвое больше одновременных запросов или длиннее контекст. Проверяйте качество на длинных контекстах

Как измерить цену квантизации в качестве

  1. Тот же eval-набор и те же промпты (модуль 8), greedy-декодирование или фиксированный seed. BF16 и квантованная версия отвечают на одни и те же примеры.
  2. Парное сравнение. Смотрите не только на «92.1% против 91.4%», а на пары: сколько примеров сломалось и сколько починилось. Считайте разницу с доверительным интервалом (парный bootstrap или McNemar, модуль 8). Вопрос ставится как non-inferiority: «нижняя граница ДИ разницы выше −1 п.п.», а допуск задаёт продукт.
  3. Срезы. Средняя метрика прячет провалы. Смотрите отдельно длинный контекст, редкий язык, строгий JSON (долю валидного), арифметику и код: квантизация бьёт по хвостам раньше, чем по среднему.
  4. Поведение: доля отказов, распределение длины ответа, зацикливания. Квантованная модель иногда становится многословнее или чаще повторяется.
  5. Окупаемость. Тем же нагрузочным тестом измерьте TTFT и TPOT p95 и goodput на одном и том же железе. Квантизация, которая на вашем профиле не дала выигрыша в деньгах или латентности, — чистый риск.
Не путайте «модель хуже» и «шаблон другой»

Частая причина «деградации» после смены чекпойнта — другой chat template, другой generation_config.json (vLLM по умолчанию берёт из него параметры сэмплинга, отключается флагом --generation-config vllm) или обрезанный --max-model-len. Сравнивайте при одинаковых параметрах запроса и проверьте, что промпт после применения шаблона совпадает байт в байт.

Self-hosted: движки, экономика, запуск

Свой инференс поднимают по четырём причинам: приватность (данные не должны покидать периметр), стоимость на большом стабильном объёме, контроль (своя fine-tuned модель, фиксированная версия, предсказуемая латентность) и офлайн или edge. Платите вы за это GPU, людьми и дежурствами.

Движки

ДвижокДля чегоКлючевое (по состоянию на октябрь 2026)
vLLMПродакшн-сервинг, высокая конкурентностьPagedAttention, continuous batching, prefix caching, chunked prefill, speculative decoding, tensor parallelism, LoRA-адаптеры. OpenAI-совместимый API и Anthropic-совместимый /v1/messages. Версия 0.31, движок V1
SGLangТо же; силён в структурированной генерации, агентных сценариях и RL-rollout'ахRadixAttention (дерево префиксов), быстрый constrained decoding, разнесение prefill и decode по разным GPU для больших MoE-моделей. Версия 0.5.x
TGI (Hugging Face)Исторически — сервер Hugging Face Inference EndpointsПереведён в режим поддержки (maintenance mode), репозиторий в архиве. Сам Hugging Face рекомендует vLLM и SGLang. Для новых проектов не выбирать
llama.cpp (llama-server)CPU, Apple Silicon, потребительские GPU, edgeGGUF, частичная выгрузка слоёв на GPU, OpenAI-совместимый сервер
OllamaЛокальная разработка, ноутбукНа базе ggml и llama.cpp, одна команда, OpenAI-совместимый API на :11434/v1. Контекст по умолчанию зависит от видеопамяти (4K при меньше 24 ГБ, 32K при 24–48 ГБ, 256K от 48 ГБ), один запрос на модель за раз. Для высоконагруженного прода не предназначен

Почему эти движки быстрые, разобрано в разделе «Как устроен serving». Память под веса — параметры × байты: 8B в BF16 занимает 16 ГБ, 70B в BF16 — 140 ГБ (минимум 2×H100 80 ГБ, на практике 4 ради KV-cache), 70B в FP8 — около 70 ГБ. Конкурентность считается по KV-бюджету, формула выше.

GPU-экономика

Сравнивать надо не «цену GPU-часа», а стоимость миллиона токенов при вашей реальной утилизации: $/1M = GPU $/ч ÷ (tok/s × утилизация × 3600) × 10⁶. GPU, который ночью простаивает, стоит столько же, сколько днём, а API ночью не стоит ничего. Отсюда правило: self-hosted выгоден при большом и ровном объёме, когда модели поменьше хватает по качеству.

C_api = T_in × p_in + T_out × p_out (T — млн токенов в месяц, p — $ за 1M)
N_gpu = max(N_min, ⌈ пиковый поток, ток/с ÷ goodput одной GPU при SLO ⌉)
C_self = N_gpu × h × 730 + C_ops (h — $ за GPU-час, 730 часов в месяце, C_ops — люди и инфраструктура)
безубыточность при N_gpu = N_min: T* = (N_min × h × 730 + C_ops) ÷ p_смеш

Здесь p_смеш — смешанная цена API для вашего соотношения входа и выхода, goodput — пропускная способность одной GPU при вашем SLO по TTFT и TPOT (из нагрузочного теста ниже, а не из маркетинговой таблицы), N_min = 2 ради отказоустойчивости. Пример: смешанная цена Haiku 4.5 при соотношении вход:выход 3:1 составляет (3 × 1 + 1 × 5) / 4 = $2 за 1M. Две GPU по h долларов в час без учёта людей окупаются при T* = 2 × h × 730 / 2 = 730·h млн токенов в месяц. При h = 3 (подставьте свою цену) это ≈ 2.2 млрд токенов в месяц, или ≈ 73 млн в день, причём равномерно распределённых по суткам. Каждые $1000 в месяц на людей и инфраструктуру сдвигают точку ещё на 500 млн токенов в месяц. И сравнение честно, только если open-weight модель не хуже Haiku на вашем eval-наборе.

Калькулятор: self-hosted против API

Оси логарифмические. Линия API растёт линейно с объёмом. Self-hosted — ступеньки: GPU покупается целиком, плюс фиксированные расходы на людей. Пропускная способность — goodput на одну GPU (с батчингом, при вашем SLO); её надо измерять нагрузочным тестом, дефолты даны для порядка величин. Цены по умолчанию условные: подставьте свой прайс.

APIself-hostedточка безубыточности
Что забывают в расчёте
# 1) Запуск OpenAI-совместимого сервера (GPU с 24+ ГБ для 8B в bf16):
#    pip install vllm
#    vllm serve Qwen/Qwen3-8B \
#      --max-model-len 32768 \          # верхняя граница контекста: определяет бюджет KV-cache
#      --gpu-memory-utilization 0.90 \  # доля VRAM под веса + KV-cache
#      --max-num-seqs 128 \             # максимум одновременных последовательностей в батче
#      --enable-prefix-caching \        # в свежих версиях включено по умолчанию
#      --api-key "$VLLM_API_KEY"
#
# 2) Клиент — обычный OpenAI SDK. В vLLM temperature/top_p работают (в отличие от новых Claude).
import asyncio, os, time
from openai import AsyncOpenAI

client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key=os.environ["VLLM_API_KEY"])

async def one(i: int) -> tuple[float, float, int]:
    t0 = time.perf_counter(); ttft = None; n = 0
    stream = await client.chat.completions.create(
        model="Qwen/Qwen3-8B",
        messages=[{"role": "user", "content": f"Кратко: что такое continuous batching? #{i}"}],
        max_tokens=300, temperature=0.2, stream=True,
    )
    async for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            ttft = ttft or time.perf_counter() - t0
            n += 1  # чанк ≈ токен; для точности берите usage
    return ttft or 0.0, time.perf_counter() - t0, n

async def main(concurrency: int = 64) -> None:
    t0 = time.perf_counter()
    res = await asyncio.gather(*(one(i) for i in range(concurrency)))
    wall = time.perf_counter() - t0
    ttfts = sorted(r[0] for r in res)
    print(f"p50 TTFT {ttfts[len(ttfts)//2]:.2f}s  p99 {ttfts[int(len(ttfts)*.99)]:.2f}s")
    print(f"суммарно {sum(r[2] for r in res)/wall:.0f} tok/s при {concurrency} параллельных")

asyncio.run(main())
# Локально без GPU: `ollama pull qwen3:8b` и base_url="http://localhost:11434/v1" — тот же код.
# Встроенный бенчмарк: случайные промпты 2048 → 512 токенов, 64 запроса одновременно.
vllm bench serve --model Qwen/Qwen3-8B \
  --dataset-name random --random-input-len 2048 --random-output-len 512 \
  --num-prompts 640 --max-concurrency 64 \
  --percentile-metrics ttft,tpot,itl --metric-percentiles 50,95,99 \
  --goodput ttft:1000 tpot:50
# Повторите для --max-concurrency 8, 16, 32, 64, 128. Ёмкость реплики — максимум,
# при котором p95 TTFT и TPOT ещё в SLO. Это и есть goodput для калькулятора выше.

Нагрузочный тест нужен, чтобы получить реальный goodput для калькулятора. Поднимайте конкурентность, пока суммарная пропускная способность растёт, а p95 TTFT и TPOT укладываются в SLO: эта точка и есть ёмкость одной реплики. Длины промптов и ответов берите из логов своего продукта, а не случайные: от них зависит всё.

Ollama для локальной разработки

Ollama даёт тот же OpenAI-совместимый API на ноутбуке без GPU-сервера. Поэтому клиентский код, Go-шлюз из следующего разделаJava-шлюз из следующего раздела и eval-прогон можно отладить локально, а потом переключить base_url на vLLM.

ollama pull qwen3:8b                       # ≈ 5 ГБ, 4-битная GGUF-квантизация
OLLAMA_CONTEXT_LENGTH=16384 ollama serve   # по умолчанию 4K при VRAM < 24 ГБ: длинный RAG-промпт обрежется
curl -s http://localhost:11434/v1/chat/completions \
  -d '{"model": "qwen3:8b", "messages": [{"role": "user", "content": "Привет"}], "stream": true}'
Как это звучит на собесе

«Мы посчитали по формуле с goodput из нагрузочного теста: при нашем пике, который втрое выше среднего, две реплики окупались бы только при объёме в несколько раз больше текущего. Поэтому основной чат остался на API. Self-hosted подняли для классификатора с LoRA: там ровный поток, данные не должны уходить наружу, а 4B-модель с адаптером догнала Sonnet на нашем eval-наборе».

Go-шлюз перед vLLM

Java-шлюз перед vLLM

Клиентам не стоит ходить в vLLM напрямую. Флаг --api-key защищает только пути с префиксами /v1, /v2, /inference и /cohere, остальные эндпоинты открыты, и документация vLLM сама советует ставить перед сервером reverse proxy. Общий LLM-шлюз с роутингом между провайдерами разобран в модуле 9; здесь — его часть, специфичная для своего сервера. Кроме авторизации, шлюз берёт на себя то, чего vLLM не делает:

Формат запроса и стрима — Chat Completions от OpenAI: POST /v1/chat/completions, при "stream": true ответ идёт как SSE, строки data: {chat.completion.chunk} и в конце data: [DONE]. С "stream_options": {"include_usage": true} последний чанк несёт usage с точным числом токенов. Шлюз добавляет этот параметр сам: считать токены по чанкам ненадёжно, потому что vLLM может класть несколько токенов в один чанк (параметр stream_interval).

// Шлюз перед OpenAI-совместимым сервером vLLM (подойдёт и Ollama на :11434).
// Делает пять вещей: публичное имя модели → база или LoRA-адаптер в vLLM, лимит
// одновременных запросов под --max-num-seqs, ретраи на 429/5xx, пока клиенту
// ещё ничего не отправлено, таймаут на первый байт стрима и метрики TTFT/TPOT.
package main

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"math/rand/v2"
	"net/http"
	"strconv"
	"time"
)

type Config struct {
	Upstream    string            // http://vllm:8000
	APIKey      string            // --api-key vLLM; пусто — без авторизации
	Models      map[string]string // публичное имя → имя в vLLM: база или адаптер из --lora-modules
	MaxInFlight int               // ≈ max-num-seqs × число реплик
	QueueWait   time.Duration     // сколько запрос ждёт свободный слот, потом 429
	FirstByte   time.Duration     // стрим: нет первого байта за это время — отменяем попытку и повторяем
	Timeout     time.Duration     // общий потолок на запрос
	MaxRetries  int
	Backoff     time.Duration // база экспоненциального backoff с full jitter
	Observe     func(Stats)   // куда отдать метрики: лог, Prometheus, OTel (модуль 9)
}

type Stats struct {
	Model     string
	Status    int
	Attempts  int
	TTFT      time.Duration // от прихода запроса до первого непустого токена
	TPOT      time.Duration // (последний токен − первый) / (токенов − 1)
	OutTokens int
}

type Gateway struct {
	cfg    Config
	client *http.Client
	slots  chan struct{}
}

func New(cfg Config) *Gateway {
	t := http.DefaultTransport.(*http.Transport).Clone()
	t.MaxIdleConnsPerHost = cfg.MaxInFlight // по умолчанию 2: при 128 параллельных стримах соединения пересоздавались бы
	return &Gateway{cfg: cfg, client: &http.Client{Transport: t}, slots: make(chan struct{}, cfg.MaxInFlight)}
}

func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	if r.Method != http.MethodPost || r.URL.Path != "/v1/chat/completions" {
		apiError(w, http.StatusNotFound, "only POST /v1/chat/completions")
		return
	}
	var req map[string]json.RawMessage // остальные поля запроса пробрасываем как есть
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&req); err != nil {
		apiError(w, http.StatusBadRequest, "bad JSON: "+err.Error())
		return
	}
	var public string
	var stream bool
	_ = json.Unmarshal(req["model"], &public)
	_ = json.Unmarshal(req["stream"], &stream)
	target, ok := g.cfg.Models[public]
	if !ok {
		apiError(w, http.StatusNotFound, fmt.Sprintf("model %q not found", public))
		return
	}
	req["model"], _ = json.Marshal(target)
	if _, set := req["stream_options"]; stream && !set {
		req["stream_options"] = json.RawMessage(`{"include_usage":true}`) // точное число токенов в последнем чанке
	}
	body, _ := json.Marshal(req)

	st := Stats{Model: public}
	defer func() {
		if g.cfg.Observe != nil {
			g.cfg.Observe(st)
		}
	}()
	ctx, cancel := context.WithTimeout(r.Context(), g.cfg.Timeout)
	defer cancel()
	wait := time.NewTimer(g.cfg.QueueWait)
	defer wait.Stop()
	select { // слот: в vLLM не больше MaxInFlight запросов, остальные ждут здесь, а не в его очереди
	case g.slots <- struct{}{}:
		defer func() { <-g.slots }()
	case <-wait.C:
		st.Status = http.StatusTooManyRequests
		w.Header().Set("Retry-After", "1")
		apiError(w, st.Status, "all slots busy")
		return
	case <-ctx.Done():
		return
	}

	up, attempts, err := g.send(ctx, body, stream)
	st.Attempts = attempts
	if err != nil {
		st.Status = http.StatusBadGateway
		if ctx.Err() == context.DeadlineExceeded {
			st.Status = http.StatusGatewayTimeout
		}
		apiError(w, st.Status, "upstream: "+err.Error())
		return
	}
	defer up.Close()
	st.Status = up.resp.StatusCode
	if stream && st.Status == http.StatusOK {
		pipeSSE(w, up.body, start, &st)
		return
	}
	raw, err := io.ReadAll(up.body)
	if err != nil {
		st.Status = http.StatusBadGateway
		apiError(w, st.Status, "upstream: "+err.Error())
		return
	}
	var resp struct {
		Usage struct {
			CompletionTokens int `json:"completion_tokens"`
		} `json:"usage"`
	}
	if json.Unmarshal(raw, &resp) == nil {
		st.OutTokens = resp.Usage.CompletionTokens
	}
	st.TTFT = time.Since(start) // без стрима пользователь видит первый токен вместе с последним
	w.Header().Set("Content-Type", up.resp.Header.Get("Content-Type"))
	w.WriteHeader(st.Status)
	_, _ = w.Write(raw)
}

type upstream struct {
	resp   *http.Response
	body   *bufio.Reader
	cancel context.CancelFunc
}

func (u *upstream) Close() { u.resp.Body.Close(); u.cancel() }

// send повторяет запрос, пока vLLM отвечает 429/502/503/504 или стрим молчит дольше FirstByte.
// Повторять можно только до первого байта клиенту: после него ответ уже частично отдан.
func (g *Gateway) send(ctx context.Context, body []byte, stream bool) (*upstream, int, error) {
	for attempt := 1; ; attempt++ {
		up, err := g.attempt(ctx, body, stream)
		if err == nil && !retryable(up.resp.StatusCode) {
			return up, attempt, nil
		}
		if attempt > g.cfg.MaxRetries || ctx.Err() != nil {
			return up, attempt, err // последний 429/503 отдаём клиенту как есть
		}
		delay := time.Duration(0)
		if d := g.cfg.Backoff << (attempt - 1); d > 0 {
			delay = rand.N(d)
		}
		if err == nil {
			if s, perr := strconv.Atoi(up.resp.Header.Get("Retry-After")); perr == nil {
				delay = max(delay, time.Duration(s)*time.Second)
			}
			up.Close()
		}
		select {
		case <-time.After(delay):
		case <-ctx.Done():
			return nil, attempt, ctx.Err()
		}
	}
}

func (g *Gateway) attempt(ctx context.Context, body []byte, stream bool) (*upstream, error) {
	actx, cancel := context.WithCancel(ctx) // отмена закрывает соединение, и vLLM освобождает слот и KV-cache
	req, err := http.NewRequestWithContext(actx, http.MethodPost, g.cfg.Upstream+"/v1/chat/completions", bytes.NewReader(body))
	if err != nil {
		cancel()
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	if g.cfg.APIKey != "" {
		req.Header.Set("Authorization", "Bearer "+g.cfg.APIKey)
	}
	if stream && g.cfg.FirstByte > 0 {
		t := time.AfterFunc(g.cfg.FirstByte, cancel)
		defer t.Stop() // остановится, как только Peek дождётся первого байта
	}
	resp, err := g.client.Do(req)
	if err != nil {
		cancel()
		return nil, err
	}
	up := &upstream{resp: resp, body: bufio.NewReader(resp.Body), cancel: cancel}
	if _, err := up.body.Peek(1); err != nil && err != io.EOF {
		up.Close()
		return nil, err
	}
	return up, nil
}

func retryable(code int) bool {
	return code == http.StatusTooManyRequests || code == http.StatusBadGateway ||
		code == http.StatusServiceUnavailable || code == http.StatusGatewayTimeout
}

// chunk — то, что нужно шлюзу из SSE-чанка chat.completion.chunk.
type chunk struct {
	Choices []struct {
		Delta struct {
			Content   string `json:"content"`
			Reasoning string `json:"reasoning"` // reasoning-модели; в старых vLLM поле звалось reasoning_content
		} `json:"delta"`
	} `json:"choices"`
	Usage *struct {
		CompletionTokens int `json:"completion_tokens"`
	} `json:"usage"`
}

// pipeSSE пробрасывает стрим построчно и сбрасывает буфер после каждой строки.
func pipeSSE(w http.ResponseWriter, src *bufio.Reader, start time.Time, st *Stats) {
	h := w.Header()
	h.Set("Content-Type", "text/event-stream")
	h.Set("Cache-Control", "no-cache")
	h.Set("X-Accel-Buffering", "no") // nginx перед шлюзом не должен копить стрим
	w.WriteHeader(http.StatusOK)
	rc := http.NewResponseController(w)
	var first, last time.Time
	chunks := 0
	for {
		line, err := src.ReadBytes('\n')
		if len(line) > 0 {
			if _, werr := w.Write(line); werr != nil {
				return // клиент ушёл: defer отменит запрос к vLLM, генерация остановится
			}
			_ = rc.Flush()
			if data, ok := bytes.CutPrefix(line, []byte("data:")); ok {
				var c chunk
				if json.Unmarshal(data, &c) == nil { // "[DONE]" не JSON — пропускаем
					if len(c.Choices) > 0 && (c.Choices[0].Delta.Content != "" || c.Choices[0].Delta.Reasoning != "") {
						if first.IsZero() {
							first = time.Now()
						}
						last = time.Now()
						chunks++
					}
					if c.Usage != nil {
						st.OutTokens = c.Usage.CompletionTokens
					}
				}
			}
		}
		if err != nil {
			break
		}
	}
	if st.OutTokens == 0 {
		st.OutTokens = chunks // без usage: чанк ≈ токен, если stream_interval = 1
	}
	if !first.IsZero() {
		st.TTFT = first.Sub(start)
		if st.OutTokens > 1 {
			st.TPOT = last.Sub(first) / time.Duration(st.OutTokens-1)
		}
	}
}

func apiError(w http.ResponseWriter, code int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": msg, "code": code}})
}
// Шлюз перед OpenAI-совместимым сервером vLLM (подойдёт и Ollama на :11434).
// Делает пять вещей: публичное имя модели → база или LoRA-адаптер в vLLM, лимит
// одновременных запросов под --max-num-seqs, ретраи на 429/5xx, пока клиенту
// ещё ничего не отправлено, таймаут на первый байт стрима и метрики TTFT/TPOT.
package course.m10models;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

public final class Gateway implements HttpHandler {

    /**
     * upstream — http://vllm:8000; apiKey — --api-key vLLM, пусто — без авторизации;
     * models — публичное имя → имя в vLLM: база или адаптер из --lora-modules;
     * maxInFlight ≈ max-num-seqs × число реплик; queueWait — сколько запрос ждёт свободный слот, потом 429;
     * firstByte — стрим: нет первого байта за это время — отменяем попытку и повторяем;
     * timeout — общий потолок на запрос; backoff — база экспоненциального backoff с full jitter;
     * observe — куда отдать метрики: лог, Micrometer, OTel (модуль 9).
     */
    public record Config(String upstream, String apiKey, Map<String, String> models, int maxInFlight,
                         Duration queueWait, Duration firstByte, Duration timeout, int maxRetries,
                         Duration backoff, Consumer<Stats> observe) {}

    /** ttft — от прихода запроса до первого непустого токена; tpot — (последний токен − первый) / (токенов − 1). */
    public static final class Stats {
        public final String model;
        public int status, attempts, outTokens;
        public Duration ttft = Duration.ZERO, tpot = Duration.ZERO;

        Stats(String model) {
            this.model = model;
        }

        @Override
        public String toString() {
            return "Stats[model=%s, status=%d, attempts=%d, ttft=%s, tpot=%s, outTokens=%d]"
                    .formatted(model, status, attempts, ttft, tpot, outTokens);
        }
    }

    private static final ObjectMapper JSON = new ObjectMapper();
    private static final int MAX_BODY = 4 << 20;
    private static final ExecutorService VT = Executors.newVirtualThreadPerTaskExecutor();

    private final Config cfg;
    // Пул соединений HttpClient для HTTP/1.1 по умолчанию не ограничен, так что 128 параллельных
    // стримов не пересоздают соединения. HTTP/1.1 явно: vLLM не говорит h2c.
    private final HttpClient client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_1_1)
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    final Semaphore slots;

    public Gateway(Config cfg) {
        this.cfg = cfg;
        this.slots = new Semaphore(cfg.maxInFlight());
    }

    @Override
    public void handle(HttpExchange ex) throws IOException {
        long start = System.nanoTime();
        try (ex) {
            serve(ex, start);
        }
    }

    private void serve(HttpExchange ex, long start) throws IOException {
        if (!ex.getRequestMethod().equals("POST") || !ex.getRequestURI().getPath().equals("/v1/chat/completions")) {
            apiError(ex, 404, "only POST /v1/chat/completions");
            return;
        }
        ObjectNode req; // остальные поля запроса пробрасываем как есть
        try {
            byte[] raw = ex.getRequestBody().readNBytes(MAX_BODY + 1);
            if (raw.length > MAX_BODY) {
                throw new IOException("request body too large");
            }
            req = (ObjectNode) JSON.readTree(raw);
        } catch (IOException | ClassCastException e) {
            apiError(ex, 400, "bad JSON: " + e.getMessage());
            return;
        }
        String pub = req.path("model").asText();
        boolean stream = req.path("stream").asBoolean(false);
        String target = cfg.models().get(pub);
        if (target == null) {
            apiError(ex, 404, "model \"%s\" not found".formatted(pub));
            return;
        }
        req.put("model", target);
        if (stream && !req.has("stream_options")) {
            req.putObject("stream_options").put("include_usage", true); // точное число токенов в последнем чанке
        }
        byte[] body = JSON.writeValueAsBytes(req);

        var st = new Stats(pub);
        try {
            proxy(ex, body, stream, start, st);
        } finally {
            if (cfg.observe() != null) {
                cfg.observe().accept(st);
            }
        }
    }

    private void proxy(HttpExchange ex, byte[] body, boolean stream, long start, Stats st) throws IOException {
        long deadline = start + cfg.timeout().toNanos();
        // Слот: в vLLM не больше maxInFlight запросов, остальные ждут здесь, а не в его очереди.
        try {
            if (!slots.tryAcquire(cfg.queueWait().toNanos(), TimeUnit.NANOSECONDS)) {
                st.status = 429;
                ex.getResponseHeaders().set("Retry-After", "1");
                apiError(ex, st.status, "all slots busy");
                return;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
        try {
            Upstream up;
            try {
                up = send(body, stream, deadline, st);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            } catch (Exception e) {
                st.status = System.nanoTime() >= deadline ? 504 : 502;
                apiError(ex, st.status, "upstream: " + e);
                return;
            }
            try (up) {
                st.status = up.resp().statusCode();
                if (stream && st.status == 200) {
                    pipeSSE(ex, up.body(), start, st);
                    return;
                }
                byte[] raw;
                try {
                    raw = up.body().readAllBytes();
                } catch (IOException e) {
                    st.status = 502;
                    apiError(ex, st.status, "upstream: " + e);
                    return;
                }
                try {
                    st.outTokens = JSON.readTree(raw).path("usage").path("completion_tokens").asInt(0);
                } catch (IOException ignored) {
                    // тело ошибки бывает не JSON — отдаём как есть
                }
                st.ttft = Duration.ofNanos(System.nanoTime() - start); // без стрима пользователь видит первый токен вместе с последним
                ex.getResponseHeaders().set("Content-Type",
                        up.resp().headers().firstValue("Content-Type").orElse("application/json"));
                ex.sendResponseHeaders(st.status, raw.length == 0 ? -1 : raw.length);
                ex.getResponseBody().write(raw);
            }
        } finally {
            slots.release();
        }
    }

    /** close() закрывает тело: соединение рвётся, и vLLM освобождает слот и KV-cache. */
    private record Upstream(HttpResponse<InputStream> resp, BufferedInputStream body) implements AutoCloseable {
        @Override
        public void close() {
            try {
                body.close();
            } catch (IOException ignored) {
                // соединение и так закрывается
            }
        }
    }

    /**
     * Повторяет запрос, пока vLLM отвечает 429/502/503/504 или стрим молчит дольше firstByte.
     * Повторять можно только до первого байта клиенту: после него ответ уже частично отдан.
     */
    private Upstream send(byte[] body, boolean stream, long deadline, Stats st) throws Exception {
        for (int attempt = 1; ; attempt++) {
            st.attempts = attempt;
            Upstream up = null;
            Exception err = null;
            try {
                up = attempt(body, stream, deadline);
            } catch (InterruptedException e) {
                throw e;
            } catch (Exception e) {
                err = e;
            }
            if (up != null && !retryable(up.resp().statusCode())) {
                return up;
            }
            if (attempt > cfg.maxRetries() || System.nanoTime() >= deadline) {
                if (up != null) {
                    return up; // последний 429/503 отдаём клиенту как есть
                }
                throw err;
            }
            long ceil = cfg.backoff().toNanos() << (attempt - 1);
            long delay = ceil > 0 ? ThreadLocalRandom.current().nextLong(ceil) : 0;
            if (up != null) {
                delay = Math.max(delay, retryAfterNanos(up.resp()));
                up.close();
            }
            Thread.sleep(Duration.ofNanos(Math.max(0, Math.min(delay, deadline - System.nanoTime()))));
        }
    }

    private Upstream attempt(byte[] body, boolean stream, long deadline) throws Exception {
        long left = deadline - System.nanoTime();
        if (left <= 0) {
            throw new HttpTimeoutException("gateway deadline exceeded");
        }
        var b = HttpRequest.newBuilder(URI.create(cfg.upstream() + "/v1/chat/completions"))
                .timeout(Duration.ofNanos(left))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofByteArray(body));
        if (!cfg.apiKey().isEmpty()) {
            b.header("Authorization", "Bearer " + cfg.apiKey());
        }
        long wait = stream && cfg.firstByte().isPositive() ? Math.min(cfg.firstByte().toNanos(), left) : left;
        var sent = client.sendAsync(b.build(), HttpResponse.BodyHandlers.ofInputStream());
        var ready = sent.thenApplyAsync(Gateway::peekFirstByte, VT);
        try {
            return ready.get(wait, TimeUnit.NANOSECONDS);
        } catch (java.util.concurrent.TimeoutException e) {
            sent.cancel(true);                                    // заголовков ещё нет — обрываем запрос,
            sent.thenAccept(r -> new Upstream(r, new BufferedInputStream(r.body())).close()); // а если есть — закрываем тело
            throw new HttpTimeoutException("no first byte in " + Duration.ofNanos(wait));
        } catch (ExecutionException e) {
            throw e.getCause() instanceof Exception c ? c : e;
        }
    }

    /** Ждёт первый байт тела: заголовки могли уйти, а токенов нет — реплика зависла на prefill. */
    private static Upstream peekFirstByte(HttpResponse<InputStream> resp) {
        var up = new Upstream(resp, new BufferedInputStream(resp.body()));
        try {
            up.body().mark(1);
            up.body().read(); // -1 (пустое тело) тоже годится
            up.body().reset();
            return up;
        } catch (IOException e) {
            up.close();
            throw new UncheckedIOException(e);
        }
    }

    private static long retryAfterNanos(HttpResponse<?> resp) {
        try {
            return resp.headers().firstValue("Retry-After")
                    .map(s -> TimeUnit.SECONDS.toNanos(Long.parseLong(s.trim()))).orElse(0L);
        } catch (NumberFormatException e) {
            return 0;
        }
    }

    static boolean retryable(int code) {
        return code == 429 || code == 502 || code == 503 || code == 504;
    }

    /** Пробрасывает стрим построчно и сбрасывает буфер после каждой строки. */
    private static void pipeSSE(HttpExchange ex, InputStream src, long start, Stats st) throws IOException {
        var h = ex.getResponseHeaders();
        h.set("Content-Type", "text/event-stream");
        h.set("Cache-Control", "no-cache");
        h.set("X-Accel-Buffering", "no"); // nginx перед шлюзом не должен копить стрим
        ex.sendResponseHeaders(200, 0);   // 0 — chunked: длина заранее неизвестна
        OutputStream out = ex.getResponseBody();
        var in = new BufferedReader(new InputStreamReader(src, StandardCharsets.UTF_8));
        long first = 0, last = 0;
        int chunks = 0;
        while (true) {
            String line;
            try {
                line = in.readLine();
            } catch (IOException e) {
                break; // upstream оборвал стрим
            }
            if (line == null) {
                break;
            }
            try {
                out.write((line + "\n").getBytes(StandardCharsets.UTF_8));
                out.flush();
            } catch (IOException e) {
                return; // клиент ушёл: Upstream.close() оборвёт запрос к vLLM, генерация остановится
            }
            if (!line.startsWith("data:")) {
                continue;
            }
            JsonNode c;
            try {
                c = JSON.readTree(line.substring(5));
            } catch (JsonProcessingException e) {
                continue; // "[DONE]" не JSON — пропускаем
            }
            // reasoning — у reasoning-моделей; в старых vLLM поле звалось reasoning_content.
            JsonNode delta = c.path("choices").path(0).path("delta");
            if (!delta.path("content").asText("").isEmpty() || !delta.path("reasoning").asText("").isEmpty()) {
                long now = System.nanoTime();
                if (first == 0) {
                    first = now;
                }
                last = now;
                chunks++;
            }
            if (c.hasNonNull("usage")) {
                st.outTokens = c.path("usage").path("completion_tokens").asInt();
            }
        }
        if (st.outTokens == 0) {
            st.outTokens = chunks; // без usage: чанк ≈ токен, если stream_interval = 1
        }
        if (first != 0) {
            st.ttft = Duration.ofNanos(first - start);
            if (st.outTokens > 1) {
                st.tpot = Duration.ofNanos((last - first) / (st.outTokens - 1));
            }
        }
    }

    static void apiError(HttpExchange ex, int code, String msg) throws IOException {
        byte[] b = JSON.writeValueAsBytes(Map.of("error", Map.of("message", msg, "code", code)));
        ex.getResponseHeaders().set("Content-Type", "application/json");
        ex.sendResponseHeaders(code, b.length);
        ex.getResponseBody().write(b);
    }
}
// go run . -upstream http://localhost:8000 -models "clf=ticket-clf,chat=Qwen/Qwen3-8B" -max-inflight 128
// Локально без GPU: -upstream http://localhost:11434 -models "chat=qwen3:8b" (Ollama).
package main

import (
	"flag"
	"log/slog"
	"net/http"
	"os"
	"strings"
	"time"
)

func main() {
	listen := flag.String("listen", ":8080", "адрес шлюза")
	upstream := flag.String("upstream", "http://localhost:8000", "vLLM или Ollama")
	models := flag.String("models", "chat=Qwen/Qwen3-8B", "публичное=имя_в_vLLM через запятую")
	inflight := flag.Int("max-inflight", 128, "≈ --max-num-seqs × реплики")
	flag.Parse()

	m := map[string]string{}
	for _, kv := range strings.Split(*models, ",") {
		if pub, target, ok := strings.Cut(kv, "="); ok {
			m[pub] = target
		}
	}
	gw := New(Config{
		Upstream: *upstream, APIKey: os.Getenv("VLLM_API_KEY"), Models: m,
		MaxInFlight: *inflight, QueueWait: 2 * time.Second,
		FirstByte: 10 * time.Second, Timeout: 5 * time.Minute,
		MaxRetries: 2, Backoff: 200 * time.Millisecond,
		Observe: func(s Stats) {
			slog.Info("llm", "model", s.Model, "status", s.Status, "attempts", s.Attempts,
				"ttft_ms", s.TTFT.Milliseconds(), "tpot_ms", s.TPOT.Milliseconds(), "out_tokens", s.OutTokens)
		},
	})
	srv := &http.Server{Addr: *listen, Handler: gw, ReadHeaderTimeout: 5 * time.Second}
	slog.Info("gateway", "listen", *listen, "upstream", *upstream, "models", m)
	if err := srv.ListenAndServe(); err != nil {
		slog.Error("serve", "err", err)
		os.Exit(1)
	}
}
// mvn -q -pl m10-models compile exec:java -Dexec.mainClass=course.m10models.Main \
//   -Dexec.args='--upstream http://localhost:8000 --models clf=ticket-clf,chat=Qwen/Qwen3-8B --max-inflight 128'
// Локально без GPU: --upstream http://localhost:11434 --models chat=qwen3:8b (Ollama).
package course.m10models;

import com.sun.net.httpserver.HttpServer;
import java.lang.System.Logger.Level;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executors;

public final class Main {
    public static void main(String[] args) throws Exception {
        Map<String, String> flags = new HashMap<>(Map.of(
                "--listen", ":8080",                       // адрес шлюза
                "--upstream", "http://localhost:8000",     // vLLM или Ollama
                "--models", "chat=Qwen/Qwen3-8B",          // публичное=имя_в_vLLM через запятую
                "--max-inflight", "128"));                 // ≈ --max-num-seqs × реплики
        for (int i = 0; i + 1 < args.length; i += 2) {
            if (!flags.containsKey(args[i])) {
                throw new IllegalArgumentException("unknown flag " + args[i]);
            }
            flags.put(args[i], args[i + 1]);
        }

        Map<String, String> models = new HashMap<>();
        for (String kv : flags.get("--models").split(",")) {
            String[] p = kv.split("=", 2);
            if (p.length == 2) {
                models.put(p[0], p[1]);
            }
        }
        var log = System.getLogger("gateway");
        var gw = new Gateway(new Gateway.Config(
                flags.get("--upstream"), Objects.requireNonNullElse(System.getenv("VLLM_API_KEY"), ""), models,
                Integer.parseInt(flags.get("--max-inflight")), Duration.ofSeconds(2),
                Duration.ofSeconds(10), Duration.ofMinutes(5), 2, Duration.ofMillis(200),
                s -> log.log(Level.INFO, "llm model={0} status={1} attempts={2} ttft_ms={3} tpot_ms={4} out_tokens={5}",
                        s.model, s.status, s.attempts, s.ttft.toMillis(), s.tpot.toMillis(), s.outTokens)));

        String listen = flags.get("--listen");
        int colon = listen.lastIndexOf(':');
        String host = listen.substring(0, colon);
        int port = Integer.parseInt(listen.substring(colon + 1));
        var srv = HttpServer.create(host.isEmpty() ? new InetSocketAddress(port) : new InetSocketAddress(host, port), 0);
        srv.createContext("/", gw);
        srv.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // поток на запрос: стримы держат соединение минутами
        srv.start();
        log.log(Level.INFO, "gateway listen={0} upstream={1} models={2}", listen, flags.get("--upstream"), models);
    }
}
// fakeVLLM — httptest-сервер, который отвечает как vLLM; handle решает, что вернуть на n-й вызов.
func fakeVLLM(t *testing.T, handle func(n int, w http.ResponseWriter, body map[string]any)) (*httptest.Server, *atomic.Int32) {
	t.Helper()
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/chat/completions" || r.Header.Get("Authorization") != "Bearer k" {
			http.Error(w, "bad request to upstream", http.StatusBadRequest)
			return
		}
		var body map[string]any
		_ = json.NewDecoder(r.Body).Decode(&body)
		handle(int(calls.Add(1)), w, body)
	}))
	t.Cleanup(srv.Close)
	return srv, &calls
}

func newGW(upstream string, stats *[]Stats) *Gateway {
	return New(Config{
		Upstream: upstream, APIKey: "k",
		Models:      map[string]string{"clf": "ticket-clf", "chat": "Qwen/Qwen3-8B"},
		MaxInFlight: 4, QueueWait: 50 * time.Millisecond, FirstByte: 200 * time.Millisecond,
		Timeout: 5 * time.Second, MaxRetries: 2, Backoff: time.Millisecond,
		Observe: func(s Stats) { *stats = append(*stats, s) },
	})
}

func post(t *testing.T, gw http.Handler, body string) *httptest.ResponseRecorder {
	t.Helper()
	rec := httptest.NewRecorder()
	gw.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)))
	return rec
}

func TestStreamPassthroughAndMetrics(t *testing.T) {
	up, _ := fakeVLLM(t, func(_ int, w http.ResponseWriter, body map[string]any) {
		if so, _ := body["stream_options"].(map[string]any); so["include_usage"] != true {
			t.Errorf("gateway must request usage in stream, got %v", body["stream_options"])
		}
		w.Header().Set("Content-Type", "text/event-stream")
		fl := w.(http.Flusher)
		send := func(s string) { fmt.Fprintf(w, "data: %s\n\n", s); fl.Flush() }
		send(`{"choices":[{"delta":{"role":"assistant","content":""}}]}`)
		time.Sleep(30 * time.Millisecond) // prefill
		for _, tok := range []string{"При", "вет", "!"} {
			send(fmt.Sprintf(`{"choices":[{"delta":{"content":%q}}]}`, tok))
			time.Sleep(10 * time.Millisecond) // decode
		}
		send(`{"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3}}`)
		send("[DONE]")
	})
	var stats []Stats
	rec := post(t, newGW(up.URL, &stats), `{"model":"chat","stream":true,"messages":[{"role":"user","content":"привет"}]}`)
	out := rec.Body.String()
	if rec.Code != 200 || rec.Header().Get("Content-Type") != "text/event-stream" {
		t.Fatalf("got %d %q", rec.Code, rec.Header().Get("Content-Type"))
	}
	if strings.Index(out, `"При"`) > strings.Index(out, `"вет"`) || !strings.HasSuffix(out, "data: [DONE]\n\n") {
		t.Fatalf("stream is not passed through in order:\n%s", out)
	}
	s := stats[0]
	if s.OutTokens != 3 || s.TTFT < 30*time.Millisecond || s.TPOT < 5*time.Millisecond || s.TPOT > s.TTFT {
		t.Fatalf("stats = %+v", s)
	}
}

func TestNoFirstByteRetries(t *testing.T) {
	up, calls := fakeVLLM(t, func(n int, w http.ResponseWriter, _ map[string]any) {
		w.Header().Set("Content-Type", "text/event-stream")
		w.(http.Flusher).Flush() // заголовки ушли, а токенов нет: реплика зависла на prefill
		if n == 1 {
			time.Sleep(time.Second)
			return
		}
		fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
	})
	var stats []Stats
	rec := post(t, newGW(up.URL, &stats), `{"model":"chat","stream":true,"messages":[]}`)
	if rec.Code != 200 || calls.Load() != 2 || !strings.Contains(rec.Body.String(), `"ok"`) {
		t.Fatalf("code %d, calls %d, body %s", rec.Code, calls.Load(), rec.Body)
	}
}
// Фрагмент GatewayTest (JUnit 5): фейковый vLLM на com.sun.net.httpserver, шлюз на случайном порту.
class GatewayTest {
    static final ObjectMapper JSON = new ObjectMapper();
    static final HttpClient HTTP = HttpClient.newHttpClient();
    final List<HttpServer> servers = new ArrayList<>();
    final List<Gateway.Stats> stats = new CopyOnWriteArrayList<>();

    @FunctionalInterface
    interface Handle {
        void handle(int n, HttpExchange ex, JsonNode body) throws Exception;
    }

    HttpServer serve(com.sun.net.httpserver.HttpHandler h) throws IOException {
        var srv = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        srv.createContext("/", h);
        srv.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        srv.start();
        servers.add(srv);
        return srv;
    }

    static String url(HttpServer s) {
        return "http://127.0.0.1:" + s.getAddress().getPort();
    }

    /** Фейковый vLLM: handle решает, что вернуть на n-й вызов. */
    String fakeVLLM(AtomicInteger calls, Handle handle) throws IOException {
        return url(serve(ex -> {
            try (ex) {
                if (!ex.getRequestURI().getPath().equals("/v1/chat/completions")
                        || !"Bearer k".equals(ex.getRequestHeaders().getFirst("Authorization"))) {
                    reply(ex, 400, "text/plain", "bad request to upstream");
                    return;
                }
                handle.handle(calls.incrementAndGet(), ex, JSON.readTree(ex.getRequestBody()));
            } catch (Exception e) {
                throw new IOException(e);
            }
        }));
    }

    static void reply(HttpExchange ex, int code, String contentType, String body) throws IOException {
        byte[] b = body.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", contentType);
        ex.sendResponseHeaders(code, b.length == 0 ? -1 : b.length);
        ex.getResponseBody().write(b);
    }

    static Gateway.Config config(String upstream, int maxInFlight, Consumer<Gateway.Stats> observe) {
        return new Gateway.Config(upstream, "k", Map.of("clf", "ticket-clf", "chat", "Qwen/Qwen3-8B"),
                maxInFlight, Duration.ofMillis(50), Duration.ofMillis(200), Duration.ofSeconds(5), 2,
                Duration.ofMillis(1), observe);
    }

    String newGW(String upstream) throws IOException {
        return url(serve(new Gateway(config(upstream, 4, stats::add))));
    }

    static HttpResponse<String> post(String gw, String body) throws Exception {
        return HTTP.send(HttpRequest.newBuilder(URI.create(gw + "/v1/chat/completions"))
                .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
    }

    /** observe вызывается после записи ответа: ждём его, а не полагаемся на порядок. */
    Gateway.Stats awaitStats() throws InterruptedException {
        for (int i = 0; i < 200 && stats.isEmpty(); i++) {
            Thread.sleep(5);
        }
        assertEquals(1, stats.size(), "stats = " + stats);
        return stats.getFirst();
    }

    @Test
    void streamPassthroughAndMetrics() throws Exception {
        String up = fakeVLLM(new AtomicInteger(), (n, ex, body) -> {
            assertTrue(body.path("stream_options").path("include_usage").asBoolean(),
                    "gateway must request usage in stream");
            ex.getResponseHeaders().set("Content-Type", "text/event-stream");
            ex.sendResponseHeaders(200, 0);
            var out = ex.getResponseBody();
            Consumer<String> send = s -> {
                try {
                    out.write(("data: " + s + "\n\n").getBytes(StandardCharsets.UTF_8));
                    out.flush();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            };
            send.accept("{\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}");
            Thread.sleep(30); // prefill
            for (String tok : List.of("При", "вет", "!")) {
                send.accept("{\"choices\":[{\"delta\":{\"content\":\"" + tok + "\"}}]}");
                Thread.sleep(10); // decode
            }
            send.accept("{\"choices\":[],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":3}}");
            send.accept("[DONE]");
        });
        var rec = post(newGW(up), "{\"model\":\"chat\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"привет\"}]}");
        String out = rec.body();
        assertEquals(200, rec.statusCode());
        assertEquals("text/event-stream", rec.headers().firstValue("Content-Type").orElse(""));
        assertTrue(out.indexOf("\"При\"") < out.indexOf("\"вет\"") && out.endsWith("data: [DONE]\n\n"),
                "stream is not passed through in order:\n" + out);
        var s = awaitStats();
        assertEquals(3, s.outTokens, s.toString());
        assertTrue(s.ttft.compareTo(Duration.ofMillis(30)) >= 0, s.toString());
        assertTrue(s.tpot.compareTo(Duration.ofMillis(5)) >= 0 && s.tpot.compareTo(s.ttft) <= 0, s.toString());
    }

    @Test
    void noFirstByteRetries() throws Exception {
        var calls = new AtomicInteger();
        String up = fakeVLLM(calls, (n, ex, body) -> {
            ex.getResponseHeaders().set("Content-Type", "text/event-stream");
            ex.sendResponseHeaders(200, 0); // заголовки ушли, а токенов нет: реплика зависла на prefill
            if (n == 1) {
                Thread.sleep(1000);
                return;
            }
            ex.getResponseBody().write("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n"
                    .getBytes(StandardCharsets.UTF_8));
        });
        var rec = post(newGW(up), "{\"model\":\"chat\",\"stream\":true,\"messages\":[]}");
        assertEquals(200, rec.statusCode());
        assertEquals(2, calls.get());
        assertTrue(rec.body().contains("\"ok\""), rec.body());
    }
}

Полный код с тестами (маппинг модели, стрим и метрики, ретрай на 503, исчерпание ретраев, таймаут первого байта, лимит конкурентности) — в examples/m10-models, запуск go test -race ./....— в examples/java/m10-models (только JDK и Jackson: com.sun.net.httpserver, java.net.http, virtual threads), запуск mvn -pl m10-models -am verify из examples/java. На что обратить внимание:

Что упрощено

Поле model в ответе не переписывается обратно, и клиент видит имя адаптера. Один upstream: несколько реплик ставятся за балансировщик с политикой least-outstanding-requests или с учётом префикса. Одна общая очередь без справедливости между тенантами: когда тенантов много, нужна очередь на тенанта с весами. Метрики отдаются колбэком Observeколбэком observe (Consumer<Stats>), гистограммы Prometheus или OTel из модуля 9 подключаются в нём.

Лицензии open-weight моделей

Open-weight значит, что веса можно скачать, а не что с ними можно всё. Лицензия определяет, можно ли использовать модель коммерчески, распространять дообученные версии, учить на её выходах другие модели и где обязательно указать авторство. Проверяют её до выбора модели, а не перед релизом: менять базовую модель после месяца тюнинга и evals дорого.

Семейство (октябрь 2026)ЛицензияЧто ограничивает
Qwen3, Qwen3.5Apache 2.0Почти ничего: при распространении сохранить текст лицензии и уведомления. Исключение из прошлого поколения — Qwen2.5-3B (только некоммерческое использование) и Qwen2.5-72B (своя лицензия: порог 100 млн MAU и «Built with Qwen» для моделей, обученных на её выходах)
Mistral: Small 3.x, Large 3, 7B, MixtralApache 2.0Как выше. Но часть моделей прошлых лет (Mistral Large 2, Codestral 22B) вышла под Mistral Research License и Non-Production License: коммерческое использование только по отдельному договору
Gemma 4Apache 2.0Как выше
Gemma 3 и раньшеGemma Terms of UseProhibited Use Policy, которую при распространении нужно включить в договор с вашими пользователями. Модели, обученные на синтетике от Gemma (дистилляция), считаются производными. Google оставляет за собой право ограничить использование при нарушении
Llama 3.x, Llama 4Llama Community LicenseЕсли на дату релиза версии у ваших продуктов было больше 700 млн MAU (за предыдущий месяц), нужна отдельная лицензия от Meta. При распространении модели или предоставлении доступа к ней или к продукту и сервису с ней — копия лицензии и надпись «Built with Llama» на видном месте. Модель, созданная или улучшенная с помощью Llama или её выходов, которую вы распространяете или делаете доступной другим, должна называться «Llama…». Обязательна Acceptable Use Policy. Для мультимодальных моделей (Llama 3.2 Vision, Llama 4) права не предоставляются компаниям с основным местом деятельности в ЕС и жителям ЕС; на конечных пользователей продукта это не распространяется
DeepSeek-R1, V3-0324 и новееMITПочти ничего: сохранить уведомление
gpt-oss (OpenAI)Apache 2.0 и usage policyКак Apache, плюс короткая политика использования в репозитории модели

Когда fine-tuning, а когда промпт или RAG

Вторая половина модуля — адаптация модели под задачу. Свой serving из первой половины понадобится и здесь: дообученную модель придётся где-то запускать. Но сначала вопрос, нужна ли она вообще.

Главное правило: fine-tuning меняет поведение, а не знания. Дообучение на внутренней вики не научит модель надёжно отвечать фактами из неё. Модель выучит стиль вики и начнёт уверенно галлюцинировать в этом стиле. Знания подключают через RAG (модуль 5): его можно обновлять без переобучения, давать ссылки на источники и учитывать права доступа.

Fine-tuning оправдан, когда нужно:

Порядок действий всегда один: сначала evals (модуль 8), потом промпт (инструкции, few-shot, structured outputs), потом более сильная модель, RAG, если не хватает знаний, и только затем fine-tuning, если остался измеримый разрыв. Без evals вы не узнаете, помог ли fine-tuning, и не заметите, что он сломал.

Дерево решений: промпт, RAG или fine-tuning

Отвечайте на вопросы кнопками или кликайте по узлам дерева, чтобы вернуться к любому шагу. Внизу появится рекомендация с обоснованием.

Как это звучит на собесе

«Мы начали с промпта и eval-набора на 300 примеров: 87% accuracy. Few-shot дал 91%. Ошибки были в формате и редких классах, а не в знаниях, поэтому RAG не помог бы. Дообучили 8B-модель с LoRA на 3000 размеченных примерах: 94% при латентности 80 мс вместо 1.5 с и в 20 раз дешевле. Регрессию отслеживаем тем же eval-набором».

Виды дообучения

МетодДанныеЧто делаетКогда
SFT (supervised fine-tuning)Пары «вход → эталонный выход», обычно в формате чатаМинимизирует cross-entropy на токенах ответа: модель учится воспроизводить эталонФормат, стиль, узкая задача. Самый частый вариант
Preference tuning: DPOТройки (prompt, chosen, rejected)Напрямую повышает вероятность предпочтительного ответа относительно отвергнутого, без отдельной reward-модели. Параметр β определяет, насколько можно отойти от исходной моделиКогда «правильного» ответа нет, но есть «лучше/хуже»: тон, краткость, уместность отказов
RLHFСравнения от людей → reward modelSFT → обучение reward-модели на сравнениях → оптимизация политики через RL (PPO) с KL-штрафом. Так делали InstructGPT и ранние ChatGPT/ClaudeУ лабораторий. В прикладной работе редкость: сложно, дорого, нестабильно. DPO и его варианты почти вытеснили его в прикладных задачах
RL с верифицируемой наградой (RFT)Задачи и грейдер: тесты, проверка ответа, скриптМодель генерирует, грейдер ставит оценку, веса сдвигаются к высоко оценённым ответам. Так учат reasoning-моделиКогда правильность проверяется автоматически (код, математика, извлечение). У некоторых провайдеров доступно как услуга
DistillationОтветы сильной модели-учителя на ваших входахSFT маленькой модели на выходах большой. В классическом варианте учатся на распределении логитов учителя, через API — только на текстахКачество большой модели устраивает, цена и латентность — нет
Юридическая деталь дистилляции

Условия использования многих провайдеров запрещают обучать на их выходах модели, которые с ними конкурируют. Для внутреннего классификатора это обычно не проблема, для «своей универсальной LLM» — проблема. Перед дистилляцией из API прочитайте ToS или спросите юристов.

Full fine-tuning, PEFT и LoRA

Full fine-tuning обновляет все веса. С AdamW в смешанной точности на каждый параметр приходится ≈16 байт: bf16-веса (2) + градиенты (2) + fp32 мастер-копия (4) + два момента Adam (4 + 4). Для 7B это ~112 ГБ ещё до активаций, то есть несколько GPU по 80 ГБ и шардирование (FSDP или DeepSpeed ZeRO). Результат — полная копия модели на каждую задачу.

PEFT (parameter-efficient fine-tuning) замораживает базовую модель и обучает небольшое число новых параметров. Самый популярный метод — LoRA (Low-Rank Adaptation).

W' = W + ΔW = W + (α / r) · B · A, W ∈ ℝd×k, B ∈ ℝd×r, A ∈ ℝr×k, r ≪ min(d, k)

QLoRA хранит замороженную базовую модель в 4 битах (тип NF4 плюс double quantization констант), а адаптеры обучает в bf16. Память под базу падает в ~4 раза относительно bf16. Авторы дообучили 65B-модель на одном GPU с 48 ГБ. Платите вы скоростью (деквантизация на лету) и немного качеством, которое обычно близко к 16-битной LoRA.

LoRA: сколько параметров обучается и сколько нужно памяти

Слева матрица W размера d×d и её низкоранговое обновление B·A в масштабе. Справа память на веса и состояние оптимизатора для full FT, LoRA и QLoRA (шкала логарифмическая, активации не учтены: они зависят от batch и длины последовательности).

Аналогия из Java

LoRA-адаптер похож на Filter или HandlerInterceptor поверх неизменяемого контроллера: базовая модель общая и read-only, а поведение меняет тонкая обёртка, которую можно менять на каждый запрос. Отсюда и multi-LoRA-сервинг, где на одном GPU одна база и десятки адаптеров, выбираемых по полю model в запросе.

Аналогия из Go

LoRA-адаптер похож на middleware поверх неизменяемого http.Handler: базовая модель общая и read-only, а поведение меняет тонкая обёртка, которую можно менять на каждый запрос. Отсюда и multi-LoRA-сервинг, где на одном GPU одна база и десятки адаптеров, выбираемых по полю model в запросе.

Подготовка датасета

Качество fine-tuning определяется датасетом. Гиперпараметры дают проценты, данные — десятки процентов.

Формат

Стандарт — JSONL, одна строка на один пример в формате чата: такие же роли, как в инференсе. Тренер применяет chat template модели и считает loss только на ответе ассистента (assistant_only_loss в TRL), иначе модель учится воспроизводить ещё и вопросы пользователей.

{"messages": [{"role": "system", "content": "Классифицируй тикет. Ответ: одна метка."},
              {"role": "user", "content": "Списали деньги дважды за один заказ"},
              {"role": "assistant", "content": "billing"}]}

System prompt в обучении должен совпадать с тем, что будет в проде. Если в инференсе промпт другой, вы получите сдвиг распределения на ровном месте.

Качество важнее количества

Дедупликация и утечка теста в трейн

Самый частый способ обмануть себя — дубликаты между train и test. Тикеты из шаблонов, переотправленные письма, один и тот же текст с другим id. Модель запоминает их, test показывает 98%, в проде получается 80%.

Синтетические данные от сильной модели

Быстрый способ получить датасет: Opus или Fable генерирует входы и/или разметку, маленькая модель на этом учится. Работает хорошо, но с рисками:

Код генерации и фильтрации — в разделе «Синтетика и дистилляция в коде».

Синтетика и дистилляция в коде

В прикладной работе дистилляция почти всегда выглядит так: сильная модель (учитель) размечает или генерирует данные, маленькая (ученик) учится на них через SFT. Классический вариант Хинтона учит ученика повторять распределение вероятностей учителя (логиты), а не только итоговый текст. Он доступен, только если учитель — ваша open-weight модель с тем же токенизатором; в TRL для этого есть DistillationTrainer (on-policy дистилляция: ученик генерирует ответы сам, а учитель оценивает его токены). Через API у вас есть только тексты, и это обычный SFT на выходах учителя.

Масштаб метода хорошо виден на DeepSeek-R1-Distill: модели Qwen и Llama от 1.5B до 70B дообучили SFT на 800 тысячах примеров, отобранных с помощью DeepSeek-R1. По отчёту авторов, такая дистилляция дала маленьким моделям больше, чем дорогой RL, применённый к ним напрямую.

Пайплайн

  1. Входы. Лучше всего реальные входы без разметки (логи, тикеты): учитель только размечает, и распределение входов честное. Генерировать сами входы приходится, когда реальных мало или в них нет редких классов.
  2. Разнообразие задаётся явно. У новых моделей Claude нет параметра temperature (модуль 2), а высокая температура и не даёт разнообразия, только шум. Разнообразие задают в промпте: персоны, длина, стиль, опечатки, подтемы, сетка «тема × персона × длина».
  3. Фильтры от дешёвых к дорогим: схема и длина → точные дубли → близость к eval-набору (утечка) → почти-дубли → независимая разметка второй моделью, которая не знает, под какую метку генерировался текст. Расхождение разметчиков означает либо плохой текст, либо пограничный класс, и такие примеры полезно отдать человеку.
  4. Отчёт о разнообразии: распределение меток, длины, доля уникальных начал фраз. Если 40% примеров начинаются с «Здравствуйте, у меня проблема», ученик выучит шаблон.
  5. Смесь с реальными данными и ручная проверка выборки (50–100 примеров) перед обучением. Тест — только реальный.
# Полный файл с офлайн-учителем (StubTeacher) и main — examples/m10-models/synth.py
SYSTEM = "Классифицируй тикет поддержки. Ответ: одна метка из списка: billing, delivery, account, bug, other."

LABELS = {"billing": "оплата, двойные списания, возвраты, счета", "delivery": "сроки и место доставки, курьер",
          "account": "вход, пароль, профиль, коды подтверждения", "bug": "ошибки и падения сайта или приложения",
          "other": "всё остальное: вопросы о магазине, отзывы, сотрудничество"}

def norm(t: str) -> str:
    return re.sub(r"\s+", " ", t.lower()).strip()

def shingles(t: str, k: int = 5) -> set[str]:
    t = norm(t)
    return {t[i:i + k] for i in range(max(1, len(t) - k + 1))}

def jaccard(a: set[str], b: set[str]) -> float:
    return len(a & b) / len(a | b) if a | b else 1.0

class ClaudeTeacher:
    def __init__(self) -> None:
        import anthropic
        self.client = anthropic.Anthropic()  # ANTHROPIC_API_KEY из окружения

    def _text(self, **kw) -> str:
        resp = self.client.messages.create(**kw)
        return "".join(b.text for b in resp.content if b.type == "text")

    def generate(self, label: str, persona: str, n: int) -> list[str]:
        text = self._text(model="claude-opus-5", max_tokens=8000, messages=[{"role": "user", "content":
            f"Сгенерируй {n} разных обращений в поддержку интернет-магазина на тему «{label}» ({LABELS[label]}). "
            f"Автор: {persona}. Варьируй длину (5–60 слов), детали и первые слова. "
            "Не упоминай название темы. Верни только JSON-массив строк."}])
        return json.loads(text[text.find("["):text.rfind("]") + 1])

    def label(self, text: str) -> str:  # разметчик не знает, под какую метку генерировали текст
        words = self._text(model="claude-haiku-4-5", max_tokens=10, system=SYSTEM,
                           messages=[{"role": "user", "content": text}]).split()
        return words[0].strip(".,").lower() if words else ""

def filter_examples(cands: list[tuple[str, str]], eval_texts: list[str], labeler, sim: float = 0.8):
    """Дешёвые проверки первыми, вызов LLM-разметчика — последним. Порог sim подбирают по выборке глазами."""
    kept, dropped, hashes, kept_sh = [], Counter(), set(), []
    eval_sh = [shingles(t) for t in eval_texts]
    for text, label in cands:
        if label not in LABELS or not 15 <= len(text) <= 2000:
            dropped["схема или длина"] += 1
            continue
        h = hashlib.sha256(norm(text).encode()).hexdigest()
        if h in hashes:
            dropped["точный дубль"] += 1
            continue
        sh = shingles(text)
        if any(jaccard(sh, e) >= sim for e in eval_sh):
            dropped["похож на eval"] += 1
            continue
        if any(jaccard(sh, k) >= sim for k in kept_sh):  # ponytail: O(n²); от ~10⁴ примеров — MinHash/LSH
            dropped["почти дубль"] += 1
            continue
        if labeler(text) != label:
            dropped["разметчик не согласен"] += 1
            continue
        hashes.add(h)
        kept_sh.append(sh)
        kept.append((text, label))
    return kept, dropped

Без ключа скрипт работает в режиме заглушки: офлайн-учитель выдаёт по одному дефекту каждого вида, и assert в конце проверяет, что каждый фильтр поймал свой. С ключом учитель — Opus, независимый разметчик — Haiku. Порог похожести 0.8 по символьным 5-граммам подбирается по выборке глазами: слишком низкий выкинет законные вариации, слишком высокий пропустит перефразы.

Потолок учителя и ToS

Ученик не станет лучше учителя на тех примерах, где учитель ошибается: независимый разметчик и ручная проверка снижают долю ошибок, но не обнуляют её. Отдельно проверьте условия провайдера учителя: обучать на выходах закрытой модели узкий внутренний классификатор обычно можно, универсальную модель-конкурента — как правило, нет. С open-weight учителем действует его лицензия (см. раздел о лицензиях: у Llama и Gemma 3 есть условия для моделей, обученных на их выходах).

Train/val/test и кривые обучения

Train — на нём учимся. Validation — по нему выбираем гиперпараметры и момент остановки. Test — трогаем один раз в конце, чтобы честно оценить результат. Если вы смотрели на test и что-то подкручивали, он стал вторым validation, и оценка уже оптимистична. Типичное разбиение 80/10/10, со стратификацией по классу, чтобы редкие классы были во всех частях.

Кривые обучения: где начинается overfitting

Модель игрушечная, но форма кривых реалистична. Маленький датасет с нулевой регуляризацией быстро переобучается, большой датасет отодвигает разворот val loss, а слишком сильная регуляризация даёт underfitting. Маркер показывает, где early stopping (patience = 2 эпохи) остановит обучение и какой чекпойнт вернёт.

train lossval lossлучший чекпойнт / early stop
Loss не равен качеству

Val loss — это cross-entropy на токенах эталона. Она полезна, чтобы поймать переобучение, но не говорит, правильно ли модель решает задачу. Модель может чуть хуже предсказывать слово «пожалуйста» и при этом лучше классифицировать. Финальное решение принимайте по task-метрикам на test (accuracy, F1, eval-набор из модуля 8), а loss используйте для диагностики.

Метрики классификации

Большая часть задач, где дообучают, — классификация или извлечение. Их метрики нужно знать наизусть, про них спросят.

Предсказано «да»Предсказано «нет»
На самом деле «да»TP — верно нашлиFN — пропустили
На самом деле «нет»FP — ложная тревогаTN — верно отсеяли

Confusion matrix, порог и PR-кривая

2000 синтетических примеров со скорами классификатора. Двигайте порог и долю позитивов. При 2% позитивов accuracy почти не меняется, а precision обваливается: вот почему accuracy при дисбалансе бесполезна.

Откуда брать скор у LLM-классификатора

У дообученной open-source модели это вероятность токена метки (logprobs) или отдельная классификационная голова. У API-моделей без logprobs можно голосовать несколькими сэмплами или просить модель вывести уверенность, но такие «уверенности» плохо откалиброваны. Проверяйте калибровку на validation: среди ответов с уверенностью 0.8 доля правильных должна быть около 80%.

Catastrophic forgetting

После дообучения на узкой задаче модель может разучиться тому, что умела: хуже следует общим инструкциям, забывает второй язык, отвечает меткой на любой вопрос. Это catastrophic forgetting: градиенты от вашей задачи переписывают веса, отвечавшие за другое.

Где тюнить, стоимость и код

ВариантПлюсыМинусы
Fine-tuning API провайдера (OpenAI: SFT, DPO, RFT для части моделей; Google Vertex AI; Amazon Bedrock для отдельных семейств)Загрузил JSONL и получил эндпоинт. Не нужны GPU и MLOpsОплата за обучающие токены плюс, как правило, повышенная цена инференса дообученной модели. Веса не ваши, набор моделей ограничен. У Anthropic на момент написания нет общедоступного self-serve fine-tuning в Claude API (исторически был fine-tuning Claude 3 Haiku через Bedrock), проверьте актуальное состояние
Платформы для open-source (Together, Fireworks и др.)LoRA на Llama, Qwen, Mistral в пару кликов, serverless-инференс адаптеровЗависимость от платформы, данные уходят третьей стороне
Свои GPU или арендаПолный контроль, приватность, веса ваши, можно сервить через vLLM (разделы self-hosted и multi-LoRA)Нужны MLOps, мониторинг и обслуживание инференса

Инструменты для своих GPU:

Цифры для порядка величин

LoRA на 8B-модели: 5000 примеров × ~800 токенов × 3 эпохи ≈ 12M обучающих токенов. На одном H100 это десятки минут, то есть единицы-десятки долларов аренды. Дорого обходятся не GPU, а разметка данных (5000 примеров × 30 с работы эксперта ≈ 40 часов) и итерации: обычно нужно 5–15 прогонов, пока датасет и гиперпараметры не устаканятся. Цены провайдеров на обучение — за 1M обучающих токенов, смотрите актуальный прайс.

Код: от JSONL до метрик

Пайплайн: сырые размеченные данные → дедупликация → стратифицированное разбиение → проверка утечки → LoRA-обучение с early stopping → метрики на test с выбором порога на validation. Валидатор JSONL на Go удобноВалидатор JSONL на Java удобно ставить в CI перед каждым запуском обучения.

# pip install scikit-learn pandas
import hashlib, json, re
from collections import Counter

import pandas as pd
from sklearn.model_selection import train_test_split

SYSTEM = "Классифицируй тикет поддержки. Ответ: одна метка из списка: billing, delivery, account, bug, other."

def norm(text: str) -> str:
    text = re.sub(r"\b(order|заказ)\s*#?\d+", "<ID>", text.lower())   # id не делают тексты разными
    return re.sub(r"\s+", " ", text).strip()

def to_chat(row) -> dict:
    return {"messages": [
        {"role": "system", "content": SYSTEM},          # тот же промпт, что будет в проде
        {"role": "user", "content": row.text},
        {"role": "assistant", "content": row.label},
    ]}

df = pd.read_csv("tickets_labeled.csv")                  # колонки: text, label
df = df.dropna(subset=["text", "label"])
df["h"] = df.text.map(lambda t: hashlib.sha256(norm(t).encode()).hexdigest())

# 1) Дедупликация ДО разбиения. Конфликтующие метки у одного текста — сигнал о шуме разметки.
conflicts = df.groupby("h").label.nunique()
print("текстов с конфликтующими метками:", int((conflicts > 1).sum()))
df = df[df.h.isin(conflicts[conflicts == 1].index)].drop_duplicates("h")

# 2) Стратифицированное разбиение 80/10/10: редкие классы есть в каждой части.
train, rest = train_test_split(df, test_size=0.2, stratify=df.label, random_state=42)
val, test = train_test_split(rest, test_size=0.5, stratify=rest.label, random_state=42)

# 3) Проверка утечки: ни один нормализованный текст не встречается в двух сплитах.
assert not (set(train.h) & set(test.h)) and not (set(train.h) & set(val.h)), "утечка!"

for name, part in [("train", train), ("val", val), ("test", test)]:
    with open(f"{name}.jsonl", "w", encoding="utf-8") as f:
        for row in part.itertuples():
            f.write(json.dumps(to_chat(row), ensure_ascii=False) + "\n")
    print(name, len(part), dict(Counter(part.label)))

# Почти-дубликаты (MinHash/embeddings) и групповое разбиение по клиенту — следующий шаг,
# если в данных есть шаблонные тексты или несколько сообщений одного треда.
"""LoRA-дообучение малой модели под классификацию тикетов (TRL + PEFT) с оценкой до и после.

GPU с bf16 (Colab L4/A100, RTX 30xx+, H100), данные из скрипта подготовки датасета модуля 10:
  uv run --python 3.12 --with trl==1.14.2 --with peft==0.21.2 --with transformers==5.19.0 \
      --with datasets==5.1.0 --with accelerate==1.15.0 --with torch \
      python lora_sft.py --train train.jsonl --val val.jsonl --test test.jsonl --model Qwen/Qwen3-4B
CPU dry-run (проверка, что пайплайн собирается; метрики на демо-данных ничего не значат):
  ... python lora_sft.py --demo --model trl-internal-testing/tiny-Qwen3ForCausalLM --cpu --max-steps 2
"""
import argparse
import random

import torch
from datasets import Dataset, load_dataset
from peft import LoraConfig
from transformers import EarlyStoppingCallback
from trl import SFTConfig, SFTTrainer

SYSTEM = "Классифицируй тикет поддержки. Ответ: одна метка из списка: billing, delivery, account, bug, other."
LABELS = ["billing", "delivery", "account", "bug", "other"]


def demo_split(n: int, seed: int) -> Dataset:
    texts = {
        "billing": ["Списали деньги дважды за заказ", "Не пришёл возврат", "Счёт больше, чем сумма в корзине"],
        "delivery": ["Курьер не приехал", "Где мой заказ, прошла неделя", "Привезли не в тот пункт выдачи"],
        "account": ["Не могу войти в аккаунт", "Как сменить email в профиле?", "Не приходит код подтверждения"],
        "bug": ["Приложение падает при оплате", "Кнопка «Купить» не нажимается", "Ошибка 500 на странице заказа"],
        "other": ["Есть ли подарочные карты?", "Хочу похвалить курьера", "Работаете ли вы в праздники?"],
    }
    rnd = random.Random(seed)
    rows = []
    for _ in range(n):
        label = rnd.choice(LABELS)
        rows.append({"messages": [{"role": "system", "content": SYSTEM},
                                  {"role": "user", "content": rnd.choice(texts[label])},
                                  {"role": "assistant", "content": label}]})
    return Dataset.from_list(rows)


@torch.no_grad()
def correct(model, tok, rows: Dataset) -> list[int]:
    """1/0 на каждый пример: совпала ли первая сгенерированная метка с эталоном (greedy)."""
    model.eval()
    out = []
    for ex in rows:
        prompt = tok.apply_chat_template(ex["messages"][:-1], add_generation_prompt=True, tokenize=False,
                                         enable_thinking=False)  # Qwen3: без <think>; другие шаблоны игнорируют
        ids = tok(prompt, return_tensors="pt", add_special_tokens=False).to(model.device)
        gen = model.generate(**ids, max_new_tokens=6, do_sample=False)
        words = tok.decode(gen[0, ids["input_ids"].shape[1]:], skip_special_tokens=True).split()
        out.append(int(bool(words) and words[0].strip(".,") == ex["messages"][-1]["content"]))
    return out


def paired_bootstrap(before: list[int], after: list[int], n: int = 10_000, seed: int = 0) -> tuple[float, float]:
    """95% ДИ разницы accuracy (after − before) на ОДНИХ И ТЕХ ЖЕ примерах: ресэмплим пары, а не системы по отдельности."""
    rnd, k = random.Random(seed), len(before)
    diffs = sorted(sum(after[i] - before[i] for i in (rnd.randrange(k) for _ in range(k))) / k for _ in range(n))
    return diffs[int(0.025 * n)], diffs[int(0.975 * n)]


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--model", default="Qwen/Qwen3-4B")
    for f in ("--train", "--val", "--test"):
        p.add_argument(f)
    p.add_argument("--demo", action="store_true", help="встроенные игрушечные данные вместо JSONL")
    p.add_argument("--cpu", action="store_true")
    p.add_argument("--max-steps", type=int, default=-1, help="-1 — обучать по эпохам")
    p.add_argument("--out", default="out/ticket-clf-lora")
    p.add_argument("--merge", action="store_true", help="дополнительно сохранить модель со слитым адаптером")
    a = p.parse_args()

    if a.demo:
        train, val, test = demo_split(48, 1), demo_split(8, 2), demo_split(8, 3)
    else:
        files = {"train": a.train, "validation": a.val, "test": a.test or a.val}
        data = load_dataset("json", data_files=files)
        train, val, test = data["train"], data["validation"], data["test"]

    # bf16 есть на Ampere и новее (L4, A100, RTX 30xx+, H100). На T4 и CPU — fp32: берите модель ≤ 1.7B или QLoRA.
    bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported() and not a.cpu
    peft_config = LoraConfig(
        r=16,                         # ёмкость адаптера
        lora_alpha=32,                # масштаб ΔW = (alpha / r) · B·A
        lora_dropout=0.05,
        target_modules="all-linear",  # q,k,v,o + MLP: ближе к full FT, чем только attention
        task_type="CAUSAL_LM",
    )
    args = SFTConfig(
        output_dir=a.out,
        model_init_kwargs={"dtype": torch.bfloat16 if bf16 else torch.float32},
        bf16=bf16,                    # в SFTConfig по умолчанию True: без bf16 в железе обучение упадёт
        use_cpu=a.cpu,
        assistant_only_loss=True,     # loss только на ответе; TRL сам подставит шаблон с {% generation %} для Qwen, Llama, Gemma
        max_length=1024,              # длинные примеры обрезаются: проверьте, что метки не теряются
        num_train_epochs=3,
        max_steps=a.max_steps,
        learning_rate=1e-4,           # для LoRA выше, чем для full FT (дефолт SFTConfig 2e-5)
        per_device_train_batch_size=8,
        gradient_accumulation_steps=2,
        eval_strategy="steps", eval_steps=2 if a.demo else 50,
        save_strategy="steps", save_steps=2 if a.demo else 50, save_total_limit=2,
        load_best_model_at_end=True, metric_for_best_model="eval_loss", greater_is_better=False,
        logging_steps=1 if a.demo else 10, report_to="none", seed=42,
    )
    trainer = SFTTrainer(
        model=a.model,                # строка: тренер сам загрузит модель и токенизатор
        args=args,
        train_dataset=train,
        eval_dataset=val,
        peft_config=peft_config,
        callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
        # QLoRA: quantization_config=BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
        #          bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
    )
    tok = trainer.processing_class
    trainer.model.print_trainable_parameters()

    before = correct(trainer.model, tok, test)  # B = 0 → адаптер пока ничего не меняет, это базовая модель
    trainer.train()
    after = correct(trainer.model, tok, test)
    lo, hi = paired_bootstrap(before, after)
    print(f"test accuracy: до {sum(before) / len(before):.3f} → после {sum(after) / len(after):.3f}, "
          f"разница 95% ДИ [{lo:+.3f}; {hi:+.3f}] на {len(test)} примерах")

    trainer.save_model(f"{a.out}/best")  # только адаптер: десятки МБ
    if a.merge:  # одна модель без адаптера: проще сервить, но теряется multi-LoRA
        trainer.model.merge_and_unload().save_pretrained(f"{a.out}/merged")
        tok.save_pretrained(f"{a.out}/merged")
    print(f"vllm serve {a.model} --enable-lora --lora-modules ticket-clf={a.out}/best --max-lora-rank 16")


if __name__ == "__main__":
    main()
# pip install scikit-learn numpy
import numpy as np
from sklearn.metrics import (average_precision_score, classification_report,
                             confusion_matrix, precision_recall_curve,
                             precision_recall_fscore_support, roc_auc_score)

def pick_threshold(y_val, s_val, min_precision=0.95):
    """Порог на VALIDATION: максимальный recall при precision ≥ min_precision."""
    p, r, t = precision_recall_curve(y_val, s_val)       # len(t) == len(p) - 1
    ok = np.where(p[:-1] >= min_precision)[0]
    if len(ok) == 0:
        raise ValueError(f"precision {min_precision} недостижима: модель или данные слабые")
    best = ok[np.argmax(r[:-1][ok])]
    return float(t[best])

def report(y_true, scores, threshold):
    y_pred = (scores >= threshold).astype(int)
    p, r, f1, _ = precision_recall_fscore_support(y_true, y_pred, average="binary", zero_division=0)
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    print(f"threshold={threshold:.3f}  P={p:.3f} R={r:.3f} F1={f1:.3f}")
    print(f"TP={tp} FP={fp} FN={fn} TN={tn}  accuracy={(tp + tn) / len(y_true):.3f}"
          f"  (у «всегда нет»: {1 - y_true.mean():.3f})")
    print(f"ROC-AUC={roc_auc_score(y_true, scores):.3f}  PR-AUC={average_precision_score(y_true, scores):.3f}")

# y_* — 0/1 (например, «нужна эскалация»), s_* — скор модели (logprob метки, вероятность головы).
y_val, s_val = np.load("val_y.npy"), np.load("val_scores.npy")
y_test, s_test = np.load("test_y.npy"), np.load("test_scores.npy")

th = pick_threshold(y_val, s_val, min_precision=0.95)   # выбираем на val…
report(y_test, s_test, th)                               # …а честную оценку даёт test

# Мультикласс: macro-F1 показывает редкие классы, weighted — «средний» опыт пользователя.
labels_true = np.load("test_labels.npy", allow_pickle=True)
labels_pred = np.load("test_pred_labels.npy", allow_pickle=True)
print(classification_report(labels_true, labels_pred, digits=3, zero_division=0))
// Валидатор и дедупликатор чат-датасета: go run . -in train.jsonl -out clean.jsonl
// Падает (exit 1) при ошибках схемы — удобно в CI перед запуском обучения.
package main

import (
	"bufio"
	"crypto/sha256"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"strings"
)

type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}
type Example struct {
	Messages []Message `json:"messages"`
}

func validate(ex Example, maxChars int) error {
	if len(ex.Messages) < 2 {
		return fmt.Errorf("меньше двух сообщений")
	}
	prev, total := "", 0
	for i, m := range ex.Messages {
		switch {
		case m.Role != "system" && m.Role != "user" && m.Role != "assistant":
			return fmt.Errorf("msg %d: неизвестная роль %q", i, m.Role)
		case m.Role == "system" && i != 0:
			return fmt.Errorf("msg %d: system не первым", i)
		case strings.TrimSpace(m.Content) == "":
			return fmt.Errorf("msg %d: пустой content", i)
		case m.Role == prev && m.Role != "system":
			return fmt.Errorf("msg %d: роли не чередуются", i)
		}
		prev, total = m.Role, total+len([]rune(m.Content))
	}
	if last := ex.Messages[len(ex.Messages)-1]; last.Role != "assistant" {
		return fmt.Errorf("последнее сообщение не assistant: учить нечему")
	}
	if total > maxChars { // грубо ~3–4 символа на токен; точнее — токенизатором модели
		return fmt.Errorf("слишком длинный пример: %d символов", total)
	}
	return nil
}

// key: нормализованный текст всех не-assistant сообщений. Одинаковый вход с разными
// ответами — это конфликт разметки, его стоит показать человеку, а не молча выбросить.
func key(ex Example) [32]byte {
	var b strings.Builder
	for _, m := range ex.Messages {
		if m.Role != "assistant" {
			b.WriteString(strings.Join(strings.Fields(strings.ToLower(m.Content)), " "))
			b.WriteByte(0)
		}
	}
	return sha256.Sum256([]byte(b.String()))
}

func main() {
	in := flag.String("in", "train.jsonl", "входной JSONL")
	out := flag.String("out", "clean.jsonl", "выход без дубликатов")
	maxChars := flag.Int("max-chars", 16000, "лимит длины примера")
	flag.Parse()

	f, err := os.Open(*in)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	defer f.Close()
	w, err := os.Create(*out)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	defer w.Close()
	bw := bufio.NewWriter(w)
	defer bw.Flush()

	seen := map[[32]byte]string{} // ключ → ответ ассистента
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 1<<20), 16<<20) // строки JSONL бывают длиннее 64 КБ
	var line, bad, dups, conflicts int
	for sc.Scan() {
		line++
		var ex Example
		if err := json.Unmarshal(sc.Bytes(), &ex); err != nil {
			bad++
			fmt.Fprintf(os.Stderr, "line %d: невалидный JSON: %v\n", line, err)
			continue
		}
		if err := validate(ex, *maxChars); err != nil {
			bad++
			fmt.Fprintf(os.Stderr, "line %d: %v\n", line, err)
			continue
		}
		k, ans := key(ex), ex.Messages[len(ex.Messages)-1].Content
		if prev, ok := seen[k]; ok {
			if prev != ans {
				conflicts++
				fmt.Fprintf(os.Stderr, "line %d: тот же вход, другой ответ: %q vs %q\n", line, prev, ans)
			}
			dups++
			continue
		}
		seen[k] = ans
		bw.Write(sc.Bytes())
		bw.WriteByte('\n')
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "read:", err)
		os.Exit(1)
	}
	fmt.Printf("строк %d, ок %d, ошибок %d, дубликатов %d (конфликтов %d)\n",
		line, len(seen), bad, dups, conflicts)
	if bad > 0 {
		os.Exit(1)
	}
}
// Валидатор и дедупликатор чат-датасета: java -cp ... dataset.JsonlValidator --in train.jsonl --out clean.jsonl
// Падает (exit 1) при ошибках схемы — удобно в CI перед запуском обучения.
package dataset;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

public final class JsonlValidator {
    record Message(String role, String content) {}

    record Example(List<Message> messages) {}

    static final ObjectMapper JSON = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    static final Set<String> ROLES = Set.of("system", "user", "assistant");

    /** Бросает IllegalArgumentException с причиной, если пример не годится для обучения. */
    static void validate(Example ex, int maxChars) {
        List<Message> ms = Objects.requireNonNullElse(ex.messages(), List.of());
        if (ms.size() < 2) {
            throw new IllegalArgumentException("меньше двух сообщений");
        }
        String prev = "";
        int total = 0;
        for (int i = 0; i < ms.size(); i++) {
            String role = Objects.requireNonNullElse(ms.get(i).role(), "");
            String content = Objects.requireNonNullElse(ms.get(i).content(), "");
            if (!ROLES.contains(role)) {
                throw new IllegalArgumentException("msg %d: неизвестная роль \"%s\"".formatted(i, role));
            } else if (role.equals("system") && i != 0) {
                throw new IllegalArgumentException("msg %d: system не первым".formatted(i));
            } else if (content.isBlank()) {
                throw new IllegalArgumentException("msg %d: пустой content".formatted(i));
            } else if (role.equals(prev) && !role.equals("system")) {
                throw new IllegalArgumentException("msg %d: роли не чередуются".formatted(i));
            }
            prev = role;
            total += content.codePointCount(0, content.length());
        }
        if (!ms.getLast().role().equals("assistant")) {
            throw new IllegalArgumentException("последнее сообщение не assistant: учить нечему");
        }
        if (total > maxChars) { // грубо ~3–4 символа на токен; точнее — токенизатором модели
            throw new IllegalArgumentException("слишком длинный пример: %d символов".formatted(total));
        }
    }

    // key: нормализованный текст всех не-assistant сообщений. Одинаковый вход с разными
    // ответами — это конфликт разметки, его стоит показать человеку, а не молча выбросить.
    static String key(Example ex) throws NoSuchAlgorithmException {
        var b = new StringBuilder();
        for (Message m : ex.messages()) {
            if (!m.role().equals("assistant")) {
                b.append(String.join(" ", m.content().toLowerCase(Locale.ROOT).strip().split("\\s+"))).append('\0');
            }
        }
        byte[] h = MessageDigest.getInstance("SHA-256").digest(b.toString().getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(h);
    }

    public static void main(String[] args) throws Exception {
        Map<String, String> flags = new HashMap<>(Map.of(
                "--in", "train.jsonl",     // входной JSONL
                "--out", "clean.jsonl",    // выход без дубликатов
                "--max-chars", "16000"));  // лимит длины примера
        for (int i = 0; i + 1 < args.length; i += 2) {
            flags.put(args[i], args[i + 1]);
        }
        int maxChars = Integer.parseInt(flags.get("--max-chars"));

        Map<String, String> seen = new HashMap<>(); // ключ → ответ ассистента
        int line = 0, bad = 0, dups = 0, conflicts = 0;
        // BufferedReader не ограничивает длину строки: длинные примеры JSONL читаются целиком.
        try (BufferedReader in = Files.newBufferedReader(Path.of(flags.get("--in")));
             BufferedWriter out = Files.newBufferedWriter(Path.of(flags.get("--out")))) {
            for (String raw; (raw = in.readLine()) != null; ) {
                line++;
                Example ex;
                try {
                    ex = JSON.readValue(raw, Example.class);
                    validate(ex, maxChars);
                } catch (JsonProcessingException e) {
                    bad++;
                    System.err.printf("line %d: невалидный JSON: %s%n", line, e.getOriginalMessage());
                    continue;
                } catch (IllegalArgumentException e) {
                    bad++;
                    System.err.printf("line %d: %s%n", line, e.getMessage());
                    continue;
                }
                String k = key(ex), ans = ex.messages().getLast().content();
                String prev = seen.putIfAbsent(k, ans);
                if (prev != null) {
                    if (!prev.equals(ans)) {
                        conflicts++;
                        System.err.printf("line %d: тот же вход, другой ответ: \"%s\" vs \"%s\"%n", line, prev, ans);
                    }
                    dups++;
                    continue;
                }
                out.write(raw);
                out.newLine();
            }
        } catch (IOException e) {
            System.err.println("read: " + e);
            System.exit(1);
        }
        System.out.printf("строк %d, ок %d, ошибок %d, дубликатов %d (конфликтов %d)%n",
                line, seen.size(), bad, dups, conflicts);
        if (bad > 0) {
            System.exit(1);
        }
    }
}
Что упрощено

Валидатор ищет только точные дубликаты после нормализации. Почти-дубликаты требуют MinHash или embeddings, добавляйте их, когда в данных есть шаблонные тексты. Длина считается в символах. Если лимит жёсткий (max_length в тренере), считайте токенизатором целевой модели в Python-части пайплайна.

LoRA- и DPO-скрипты сверены с TRL 1.14, PEFT 0.21 и Transformers 5.19 (октябрь 2026) и прогнаны на CPU в режиме --demo на крошечной тестовой модели trl-internal-testing/tiny-Qwen3ForCausalLM (2 шага, секунды): это проверка, что API и пайплайн собираются, а не обучение; метрики на ней ничего не значат. Полноценный запуск — на GPU. Две ловушки версий. assistant_only_loss требует chat template с разметкой {% generation %}: для Qwen, Llama 3 и Gemma 3 TRL подставит обучающий шаблон сам, для остальных (например, SmolLM2) упадёт с ошибкой, и тогда нужен свой шаблон (chat_template_path) или assistant_only_loss=False. И в SFTConfig по умолчанию bf16=True, поэтому на T4 и CPU его надо выключать явно, как в скрипте.

DPO на практике

SFT учит «вот правильный ответ». Но для тона, краткости и уместности отказов правильного ответа часто нет, а сравнить два ответа легко. DPO (Direct Preference Optimization) учится именно на сравнениях. На тройках (prompt, chosen, rejected) модель повышает вероятность chosen относительно rejected, а замороженная референсная модель (обычно та же модель после SFT) не даёт уйти далеко. Неявная «награда» ответа в DPO равна β · log(π(y|x) / πref(y|x)): насколько обучаемая модель предпочитает этот ответ сильнее, чем референс. Отдельной reward-модели и RL-цикла нет, поэтому DPO и его варианты стали стандартом preference tuning в прикладных задачах.

Откуда брать пары

{"prompt":   [{"role": "user", "content": "Курьер не приехал"}],
 "chosen":   [{"role": "assistant", "content": "Извините за ожидание. Курьер опаздывает на 40 минут; перенести на завтра или отменить?"}],
 "rejected": [{"role": "assistant", "content": "Курьеры иногда опаздывают, это нормально. Подождите ещё."}]}
# Полный файл с демо-парами и запуском — examples/m10-models/dpo.py
def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--model", default="Qwen/Qwen3-4B", help="лучше — уже прошедшая SFT модель")
    for f in ("--train", "--val"):
        p.add_argument(f)
    p.add_argument("--demo", action="store_true")
    p.add_argument("--cpu", action="store_true")
    p.add_argument("--max-steps", type=int, default=-1)
    p.add_argument("--out", default="out/support-dpo")
    a = p.parse_args()

    if a.demo:
        train, val = demo_rows(), demo_rows().select(range(4))
    else:
        data = load_dataset("json", data_files={"train": a.train, "validation": a.val})
        train, val = data["train"], data["validation"]

    bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported() and not a.cpu
    args = DPOConfig(
        output_dir=a.out,
        model_init_kwargs={"dtype": torch.bfloat16 if bf16 else torch.float32},
        bf16=bf16,
        use_cpu=a.cpu,
        beta=0.1,               # сила «поводка» к референсной модели: больше β — меньше уход от неё
        loss_type=["sigmoid"],  # классический DPO; "ipo" устойчивее к шуму в парах
        learning_rate=5e-6,     # DPO чувствителен к LR: на порядок ниже, чем для SFT
        max_length=1024,        # prompt + ответ
        num_train_epochs=1,
        max_steps=a.max_steps,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        eval_strategy="steps", eval_steps=2 if a.demo else 50,
        logging_steps=1 if a.demo else 10, report_to="none", seed=42,
    )
    trainer = DPOTrainer(
        model=a.model,
        ref_model=None,  # с peft_config референсом служит та же модель с выключенным адаптером: вторая копия не нужна
        args=args,
        train_dataset=train,
        eval_dataset=val,
        peft_config=LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, target_modules="all-linear", task_type="CAUSAL_LM"),
    )
    trainer.train()
    m = trainer.evaluate()
    # rewards/accuracies — доля пар, где chosen получил больший неявный reward, чем rejected;
    # margins — средний разрыв. Растут — модель учится различать; ушли в потолок за пару шагов — проверьте утечку или β.
    print({k: round(v, 4) for k, v in m.items() if "rewards" in k or k == "eval_loss"})
    trainer.save_model(f"{a.out}/best")

На что смотреть и где ломается

До и после: оценка, выкатка, multi-LoRA

Дообученная модель остаётся гипотезой, пока её не сравнили с альтернативами на одном и том же замороженном тесте.

ШагЧто делаемЗачем
Замороженный testРеальные примеры, размеченные людьми, без пересечений с train и синтетикой (проверка как в synth.py)Иначе измеряется запоминание
Базовые линииПромпт на API-модели (Haiku, Sonnet), few-shot, та же open-weight модель без адаптераВыигрыш считается против лучшей дешёвой альтернативы, а не против «голой» базы
Парное сравнениеВерно ли решён каждый пример «до» и «после», разница с 95% ДИ: парный bootstrap (функция paired_bootstrap в lora_sft.py) или McNemar (модуль 8)«+2 п.п.» на 200 примерах часто укладывается в шум
Регрессия30–50 общих задач: формат, другой язык, отказы на вредные запросыCatastrophic forgetting не виден по целевой метрике
Латентность и ценаTTFT и TPOT p95, $ за 1000 запросов на целевом стеке (vLLM и шлюз)Обычно ради них и дообучали
ВыкаткаShadow-трафик → canary → A/B через шлюз (модуль 9): смена адаптера — строка в карте моделейОфлайн-тест не видит дрейф реального потока

Multi-LoRA serving

Адаптер весит десятки мегабайт, база — гигабайты. vLLM держит в памяти одну базу и подгружает адаптеры, причём в одном батче могут идти запросы к разным адаптерам (так устроены S-LoRA и Punica). Получается «модель на задачу или тенанта» без отдельной GPU на каждую.

vllm serve Qwen/Qwen3-4B --enable-lora \
  --lora-modules ticket-clf=out/ticket-clf-lora/best support-tone=out/support-dpo/best \
  --max-loras 4 --max-lora-rank 16 --max-cpu-loras 16
# адаптеры видны в /v1/models (у каждого parent = базовая модель) и выбираются полем "model"
curl -s localhost:8000/v1/models | jq '.data[] | {id, parent}'

Практика

Задание 1: open-weight против API на eval-наборе проекта

Возьмите eval-набор проекта B (вопросы к RAG проекта A с эталонами) и поднимите open-weight модель: Qwen3-8B в vLLM на арендованной GPU или qwen3:8b в Ollama, если GPU нет. Подключите её к /ask проекта A через Go-шлюз из этого модулячерез Java-шлюз из этого модуля, не меняя остальной пайплайн. Прогоните набор на трёх вариантах: ваша текущая модель Claude (например, claude-haiku-4-5 или claude-sonnet-5), open-weight модель в BF16 и она же в FP8 или AWQ.

Задание 2: LoRA под узкую задачу с доказанным выигрышем

Выберите в проекте A узкую задачу с объёмом: классификацию вопроса перед поиском (раздел документации, «нужен ли поиск вообще», язык) или извлечение полей из вопроса (версия продукта, компонент). Соберите 1000–3000 примеров: реальные вопросы из логов, размеченные вами или сильной моделью, плюс синтетика через synth.py с фильтрами. Test — 200+ реальных примеров, размеченных людьми.

Задание 3: порог по бизнес-требованию

Для бинарной задачи «нужна эскалация человеку» подберите порог на validation под требование «recall ≥ 0.9» и под «precision ≥ 0.95». Посчитайте на test, сколько тикетов в день уйдёт людям при 10 000 тикетов в сутки для обоих порогов.

Самопроверка

Qwen3-8B в BF16 на одном H100 80 ГБ: сколько одновременных запросов по 8K токенов поместится и что изменит FP8 для KV-cache?

KV на токен: 2 × 36 слоёв × 8 KV-голов × 128 × 2 байта = 144 КБ. Бюджет: 80 × 0.9 − 16.4 ГБ весов − ~2.5 ГБ служебной памяти ≈ 53 ГБ ≈ 360 тысяч токенов, то есть около 44 запросов по 8K. FP8 для KV вдвое уменьшает байты на токен, и помещается около 88. FP8 для весов освободит ещё ~8 ГБ под KV. Точное число vLLM пишет в лог при старте.

Что такое PagedAttention и continuous batching простыми словами? Почему большой батч почти бесплатен для TPOT?

PagedAttention хранит KV-cache страницами фиксированного размера, выделяемыми по требованию, как виртуальная память. Не нужно резервировать память под максимальную длину, фрагментация почти нулевая, а общие префиксы можно разделять, поэтому в GPU помещается больше одновременных запросов. Continuous batching собирает батч заново на каждом шаге декодирования: завершившиеся запросы уходят, новые встают на их место, и GPU не простаивает в ожидании самой длинной последовательности.

Decode ограничен памятью: веса читаются один раз на весь батч, поэтому, пока доминирует их чтение, лишние запросы почти бесплатны, а throughput растёт линейно. С ростом батча растут чтение KV-cache и вычисления, TPOT начинает расти, а когда KV-cache кончается, новые запросы встают в очередь. Полезная ёмкость — goodput при SLO, а не максимальный throughput.

Когда self-hosted экономически проигрывает, даже при большом объёме?

При неровной нагрузке (пики днём, простой ночью), когда утилизация ниже 20–30%; когда нужна модель уровня frontier, которую сами не поднять; когда учтены инженерное время и HA-реплики; когда API-цены падают быстрее, чем окупается резерв GPU. И если self-hosted модель хуже по evals, экономия фиктивная.

Когда speculative decoding не ускорит сервис или даже замедлит его?

При большом батче, когда GPU уже упирается в вычисления: проверка k лишних позиций и работа черновика съедают выигрыш. При низкой доле принятых токенов: черновик не знает вашего домена или языка, высокая температура. И когда черновик сам по себе дорог. Качество при этом не страдает (правило принятия сохраняет распределение), меняется только скорость, поэтому решение принимают по замеру TPOT и goodput на своём трафике.

Европейская компания хочет взять Llama 4 Scout для продукта и выложить дообученную версию как SupportBot. Что не так?

Llama 4 мультимодальна, а Acceptable Use Policy не предоставляет права по лицензии компаниям с основным местом деятельности в ЕС (конечных пользователей чужого продукта это не касается). Кроме того, модель, улучшенная с помощью Llama, которую вы распространяете или делаете доступной другим, должна называться «Llama…», и нужна надпись «Built with Llama». Практичнее взять модель под Apache 2.0 (Qwen3, Mistral Small, Gemma 4). Окончательное решение — за юристом.

Почему fine-tuning — плохой способ «загрузить в модель» базу знаний компании?

Дообучение меняет вероятности токенов, а не создаёт надёжно извлекаемую память. Модель выучит стиль и частые формулировки, но точные факты воспроизводит ненадёжно и галлюцинирует правдоподобно. Кроме того, знания устаревают, и каждое обновление требует переобучения. Нет ссылок на источники и нет контроля прав доступа. Для знаний нужен RAG, для поведения — fine-tuning.

Сколько параметров обучает LoRA с r = 8 на матрице 4096×4096, какая это доля и зачем B инициализируют нулями?

r·(d + k) = 8·(4096 + 4096) = 65 536 параметров против 16 777 216, то есть 0.39%. B нулевая, чтобы в начале обучения ΔW = B·A = 0 и модель совпадала с базовой: обучение стартует из известной хорошей точки, а не с добавленного случайного шума. Поэтому lora_sft.py может измерить «до» на модели с только что созданным адаптером.

Чем QLoRA отличается от LoRA и чем за это платите?

Замороженная базовая модель хранится в 4 битах (NF4 плюс double quantization), а адаптеры обучаются в bf16. Память под базу меньше в ~4 раза, так что 65–70B помещается в один GPU на 48–80 ГБ. Платите скоростью (деквантизация на каждом шаге) и небольшой потерей качества относительно 16-битной LoRA.

Train loss падает, val loss с 3-й эпохи растёт. Что происходит и что делать?

Overfitting: модель запоминает train. Можно взять чекпойнт с минимумом val (early stopping), уменьшить число эпох, r и learning rate, добавить dropout или weight decay, увеличить и почистить датасет, проверить дубликаты внутри train. Отдельно стоит проверить task-метрику: иногда val loss немного растёт, а accuracy ещё улучшается. Признаки переобучения видны и в ответах: шаблонные формулировки из train, провалы на перефразах, поэтому полезно просматривать ответы на отложенных примерах глазами и параллельно проверять catastrophic forgetting.

Почему дедупликацию делают до разбиения на train/test?

Если сначала разбить, копии одного примера окажутся по разные стороны, и тест будет проверять запоминание, а не обобщение. Метрика завышена, в проде провал. По той же причине сообщения одного клиента или треда разбивают группой.

Модель фрода: accuracy 99.1% при доле фрода 1%. Хорошо это или плохо? И когда для мультикласса смотреть macro-F1, а когда weighted-F1?

Из этой цифры ничего не следует: тривиальная модель «всегда не фрод» даёт 99%. Нужны precision и recall по классу «фрод», PR-AUC и confusion matrix при выбранном пороге.

Macro усредняет F1 по классам с равным весом и показывает, как модель работает на редких классах; он нужен, когда редкие классы важны (жалобы, безопасность). Weighted взвешивает по частоте и отражает средний опыт по всему потоку. Хорошо смотреть на оба вместе с per-class метриками.

Что такое catastrophic forgetting и как его заметить?

Это потеря общих навыков после дообучения на узкой задаче. Заметить можно регрессионным набором общих задач (формат, инструкции, другой язык, safety-отказы), который прогоняется до и после. Лечится меньшим числом эпох, LoRA с небольшим r, подмешиванием общих данных и тем, что дообученную модель используют только по назначению.

Вопросы с собеседований

Нужно поднять свою 8B-модель для чата поддержки: SLO p95 TTFT < 1 с и TPOT < 50 мс, в пике 200 одновременных диалогов. Как посчитаете число GPU?

Сначала профиль нагрузки из логов: распределение длины промпта (system prompt, история, RAG-контекст) и ответа, пик против среднего. Затем грубая оценка памяти: веса (8B в BF16 — 16 ГБ, в FP8 — 8 ГБ), KV на токен по архитектуре, бюджет KV при gpu-memory-utilization 0.9 и отсюда максимальная конкурентность на реплику для типичной длины. Если 200 диалогов по 6K токенов на одну H100 не помещаются, нужен FP8 для KV или больше реплик.

Но оценка — только старт. Ёмкость реплики я меряю нагрузочным тестом (vllm bench serve с длинами из логов и --goodput ttft:1000 tpot:50): поднимаю конкурентность, пока p95 TTFT и TPOT в SLO. Число реплик = пик ÷ goodput реплики, плюс одна сверх того для отказоустойчивости. Дальше рычаги: prefix caching для общего system prompt (резко снижает TTFT), chunked prefill с бюджетом под ITL, FP8-веса после проверки качества на eval-наборе, speculative decoding — только если загрузка средняя. Перед репликами — шлюз с лимитом под max-num-seqs, очередью и fallback на API при перегрузке, потому что GPU поднимаются минутами. И сравнение по формуле с API: при неровной нагрузке API может оказаться дешевле.

На что смотрит интервьюер: разделяет TTFT и TPOT и знает, от чего каждый зависит; считает конкурентность через KV-cache, а не «по числу GPU»; говорит о goodput при SLO, а не о пиковом throughput; меряет, а не угадывает; помнит про пики, отказоустойчивость и fallback.

Когда вы бы предложили self-hosted модель вместо API? Посчитайте на примере.

Критерии: требования к данным (данные не могут уходить третьей стороне, резидентность), большой и ровный объём, достаточность модели 8–70B по evals (извлечение, классификация, суммаризация, а не сложный reasoning), потребность в своей fine-tuned модели, контроль латентности.

Расчёт: 2 млрд токенов в день; API по смешанной цене $2 за 1M обходится в ~$120K в месяц. GPU за $3 в час с 2500 tok/s при утилизации 40% даёт ~86M токенов в день на GPU, то есть нужно 24 GPU, или ~$52K в месяц, плюс люди ~$10–20K. Выигрыш есть, но он чувствителен к утилизации: при 20% GPU нужно вдвое больше, и преимущество почти исчезает. Реальные tok/s надо измерить нагрузочным тестом на своей длине запросов, причём как goodput при SLO, а не пиковый throughput.

Упомяну скрытые затраты (пики, HA, дежурства, обновления) и гибрид: self-hosted для массовой простой нагрузки, API для сложного и как fallback.

На что смотрит интервьюер: начинает с требований и качества на evals, а не с цены GPU-часа; считает через утилизацию и goodput; видит чувствительность вывода к утилизации и пикам; помнит о людях и HA; предлагает гибрид, а не «всё или ничего».

Хотим квантовать модель в INT4, чтобы сэкономить. Ваши действия?

Сначала уточню цель. Если модель не помещается в нужную GPU, то INT4 (AWQ или GPTQ) решает задачу памяти. Если цель — throughput под нагрузкой, на H100 разумнее FP8: weight-only INT4 ускоряет decode при малом батче, но на больших батчах выигрыш тает, а FP8 сокращает и память, и вычисления.

Дальше проверка качества как non-inferiority: тот же eval-набор, те же промпты и параметры, парное сравнение с BF16 и доверительный интервал разницы, допуск согласован с продуктом (например, не хуже −1 п.п.). Отдельно смотрю срезы, где квантизация бьёт первой: длинный контекст, русский, строгий JSON, код, а также поведение (отказы, длина, повторы). Калибровку AWQ или GPTQ делаю на своих данных, если домен далёк от английского веба. Затем нагрузочный тест на целевом железе: если goodput и $ за 1M не улучшились, квантизация не нужна. Выкатка через шлюз на долю трафика с онлайн-метриками и быстрым откатом на BF16.

На что смотрит интервьюер: различает weight-only и W8A8 и понимает, когда что ускоряет; доказывает отсутствие деградации статистически и по срезам, а не по одному среднему; помнит про калибровочные данные и шаблон; связывает решение с деньгами и латентностью.

Команда выбрала open-weight модель для продукта. Что проверите до начала работ?

Лицензию конкретной версии, а не семейства: у Qwen2.5 размеры 3B и 72B лицензированы иначе, чем остальные, у Mistral часть моделей под Research License. Дальше по пунктам: коммерческое использование и пороги MAU (у Llama 700 млн, у Qwen2.5-72B 100 млн); требования при распространении (копия лицензии, «Built with Llama», NOTICE у Apache); правила для производных и моделей, обученных на выходах (имя «Llama…», у Gemma 3 дистилляция считается производной); acceptable use policy; территориальные ограничения (мультимодальные Llama и компании из ЕС).

Затем данные для дообучения: условия API-провайдера, если учим на выходах закрытой модели, и права на клиентские данные. И supply chain: официальный источник весов, safetensors, без trust_remote_code без ревью, зафиксированная ревизия. Всё это идёт в ADR с выводом юриста. Если лицензия создаёт риски, выбираю альтернативу под Apache 2.0 или MIT: сейчас она есть почти в каждом классе размеров.

На что смотрит интервьюер: знает, что open-weight не равно open source и не «можно всё»; называет конкретные ограничения популярных лицензий; помнит про данные и ToS провайдеров; не даёт юридических заключений сам, а фиксирует решение и привлекает юриста.

Продакт просит «дообучить GPT/Claude на нашей документации, чтобы бот отвечал правильно». Что ответите?

Сначала переформулирую задачу. Здесь проблема в знаниях, и её решает RAG: документация меняется, нужны ссылки на источники и учёт прав, а fine-tuning плохо запоминает факты и склонен к уверенным галлюцинациям.

План: eval-набор из 100–200 реальных вопросов с эталонами, RAG с гибридным поиском и reranking, метрики faithfulness и answer relevance. Fine-tuning появляется позже и только если останется проблема поведения: тон бренда, формат ответа, дорогой длинный промпт на большом объёме (дистилляция). И тогда я бы дообучал модель, которая работает поверх RAG, на примерах «контекст + вопрос → хороший ответ».

Также упомяну, что у части провайдеров fine-tuning недоступен или ограничен набором моделей, а дообученная модель привязывает вас к версии.

На что смотрит интервьюер: отделяет знания от поведения; начинает с evals и RAG; не отвергает fine-tuning догматически, а называет условия, при которых он нужен.

Объясните LoRA. Почему она работает и какие гиперпараметры вы крутите?

Базовые веса W заморожены, а обновление ΔW параметризуется как B·A с рангом r ≪ d и масштабом α/r. Работает это потому, что изменения весов при адаптации к задаче имеют низкий внутренний ранг, и богатая базовая модель требует небольшой «поправки». Обучается 0.1–1% параметров, состояние оптимизатора крошечное, и памяти нужно в разы меньше. Адаптер весит десятки мегабайт, его можно слить в веса (без потерь в латентности) или сервить много адаптеров поверх одной базы.

Гиперпараметры: r (8–64), α (r–2r), target modules (all-linear обычно лучше, чем только attention), learning rate (~1e-4–2e-4), число эпох (1–3), dropout. Настраиваю по val loss и task-метрике с early stopping. Ограничение: LoRA хуже учит много нового, но меньше забывает. Для памяти есть QLoRA с 4-битной базой.

На что смотрит интервьюер: объясняет, почему низкий ранг работает, а не только пишет формулу; связывает r, α и learning rate; знает ограничения LoRA и следствия для сервинга (merge или multi-LoRA).

Как вы соберёте датасет для fine-tuning классификатора обращений и как убедитесь, что метрики честные?

Источник — реальные обращения с разметкой. Инструкция для разметчиков с примерами пограничных случаев, двойная разметка 10% и Cohen's κ. Если согласие низкое, сначала чиню таксономию классов.

Чистка: нормализация, удаление PII, дедупликация (точная и MinHash), разбор конфликтующих меток. Балансировка: oversampling редких классов только в train, test с реальным распределением. Синтетика от сильной модели допустима для train, с проверкой выборки глазами, но не для test.

Честность метрик: дедупликация до разбиения, стратификация, групповое разбиение по клиенту или треду, а для данных со временем — разбиение по времени. Порог и гиперпараметры выбираются на validation, test используется один раз. Автоматическая проверка пересечения сплитов в CI. Ключевая метрика — macro-F1 и per-class recall, а не accuracy.

На что смотрит интервьюер: утечка между сплитами как главный риск; групповое и временное разбиение; согласие разметчиков; test, который трогают один раз.

Модель модерации: как выберете порог и как объясните бизнесу цену ошибок?

Сначала переведу ошибки в деньги и риски: FN (пропущенный вредный контент) — репутационный и юридический риск, FP (заблокирован нормальный пользователь) — отток и нагрузка на поддержку. Дальше либо ограничение («recall ≥ 0.95 на категории X»), либо минимизация ожидаемой стоимости c_FN·FN + c_FP·FP.

Порог подбираю на validation по PR-кривой и проверяю на test. Показываю бизнесу простыми словами: «при этом пороге из 10 000 постов в день 120 уйдёт на ручную проверку, из них 90 действительно нарушения, и мы пропустим около 5». Часто лучший ответ — три зоны: автоблок при высоком скоре, ручная проверка в серой зоне, пропуск при низком. После запуска слежу за дрейфом: распределение скоров меняется, и порог надо пересматривать.

На что смотрит интервьюер: переводит ошибки в деньги и нагрузку на людей; выбирает порог на validation; предлагает серую зону и мониторинг дрейфа.

SFT, DPO и RLHF: в чём разница, и что бы вы использовали в продукте?

SFT учит воспроизводить эталонные ответы: нужны пары «вход → правильный выход». DPO учит предпочтениям по парам «лучше/хуже» без reward-модели, напрямую сдвигая вероятности относительно референсной модели. RLHF обучает reward-модель на сравнениях людей, а затем оптимизирует политику через PPO с KL-штрафом. Это мощно, но сложно и нестабильно, и в основном используется лабораториями.

В продукте: SFT для формата и задачи, затем, если есть пары «хороший и плохой ответ» (например, из оценок пользователей или правок операторов), DPO для тона и уместности. Если правильность проверяется автоматически, можно RL с верифицируемой наградой через провайдера или TRL. Полноценный RLHF — почти никогда.

На что смотрит интервьюер: прагматизм; знает, откуда в продукте берутся пары; выделяет RL с проверяемой наградой в отдельный класс; понимает, что DPO оценивают по win rate и регрессии, а не по loss.

Дистилляция: как вы бы заменили вызов Opus на маленькую модель для задачи извлечения полей из писем?

Шаги: (1) eval-набор из 300–500 писем с эталонами, проверенными людьми, и измеренное качество Opus как потолок; (2) прогон 5–20 тысяч реальных писем через Opus со structured outputs, фильтрация по валидности схемы и согласию двух прогонов или LLM-судье; (3) LoRA на 3–8B модели на этих данных плюс исправленные людьми примеры; (4) сравнение на eval: per-field F1, доля валидного JSON, латентность, стоимость; (5) раскатка через shadow-трафик и A/B, с роутингом сложных писем (низкая уверенность, длинные) обратно на Opus.

Риски: ошибки учителя становятся потолком ученика, синтетика уже реального распределения, ToS провайдера про обучение на выходах, дрейф входных данных (нужен мониторинг и периодическое переобучение).

На что смотрит интервьюер: начинает с eval и потолка учителя; фильтрует данные учителя; помнит про ToS; оставляет роутинг сложных случаев на большую модель и выкатывает через shadow и A/B.