Свои модели: инференс и адаптация
Два повода уйти с чужого 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.
Как устроен 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 latency | TTFT + 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
- Continuous batching (iteration-level scheduling, впервые в системе Orca). Статический батч ждёт, пока закончится самая длинная последовательность. В continuous batching на каждом шаге декодирования завершённые запросы уходят, а новые встают на их место. GPU загружен постоянно, и короткие запросы не ждут длинные.
- Почему батч почти бесплатен. На шаге decode веса читаются из памяти один раз на весь батч. Пока батч маленький, время шага определяется чтением весов, и 32 запроса идут почти так же быстро, как один: throughput растёт линейно, TPOT почти не меняется. С ростом батча растёт чтение KV-cache и объём вычислений, и шаг начинает замедляться. Где этот перелом для вашей модели и GPU, покажет схема ниже.
- PagedAttention. KV-cache хранится не одним непрерывным куском на максимальную длину последовательности, а блоками по 16 токенов, как страницы виртуальной памяти в ОС. Блоки выделяются по мере роста последовательности, почти без фрагментации, и могут разделяться между запросами с общим префиксом (copy-on-write). В ту же память GPU помещается в разы больше одновременных последовательностей, и, по данным статьи, пропускная способность выросла в 2–4 раза относительно предыдущих систем.
- Preemption. Если KV-cache кончился посреди генерации, vLLM вытесняет часть запросов и позже пересчитывает их заново (режим
RECOMPUTEпо умолчанию). Пользователь видит это как скачок ITL. Растущий счётчикvllm:num_preemptions_totalв/metrics— повод снизить--max-num-seqsили добавить памяти.
Конкурентность из бюджета KV-cache
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 «на всякий случай» вредно: один длинный запрос занимает место десятков коротких. Если длинные запросы редки, выделите под них отдельную реплику или пул.
KV-бюджет работает как java.util.concurrent.Semaphore, где запрос берёт не одно разрешение, а acquire(n) по числу токенов в контексте: запрос «стоит» столько, сколько у него токенов, а не одну единицу. Поэтому --max-num-seqs — только верхняя граница, а реальную конкурентность определяет длина запросов. Шлюз ниже держит обычный семафор по числу запросов: этого хватает, когда длины предсказуемы.
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 он включён по умолчанию. Для архитектуры из этого следуют три вещи:
- Порядок частей промпта важен так же, как для кэша провайдера: стабильное — в начало, изменчивое — в конец.
- При нескольких репликах нужен роутинг с учётом префикса (prefix-aware routing). Иначе ходы одного диалога попадают на разные реплики, и кэш не работает.
- По времени ответа можно понять, был ли префикс в кэше, а значит, видел ли его кто-то ещё. Для мультитенантного сервиса в vLLM есть параметр запроса
cache_salt, который разделяет кэш между тенантами.
Speculative decoding
При малом батче decode простаивает: GPU читает 16 ГБ весов ради одного токена. Speculative decoding использует эту простаивающую мощность. Дешёвый черновик (маленькая модель, дополнительные головы или n-gram-поиск по промпту) предлагает k токенов, большая модель проверяет их все за один проход, как при prefill, и принимает самый длинный подходящий префикс плюс один свой токен. Правило принятия (rejection sampling) подобрано так, что распределение ответа совпадает с распределением большой модели. Это ускорение без потери качества, а не аппроксимация.
При α = 0.7 и k = 4 выходит ≈ 2.8 токена за проход вместо одного, но каждый проход теперь дороже: работает черновик и проверяются k позиций. При большом батче GPU и так загружен вычислениями, и выигрыш тает. Это видно по цифрам EAGLE-3: до 6.5× ускорения на одиночных запросах и 1.38× по throughput при батче 64 (в SGLang). Документация vLLM называет целевой сценарий прямо: низкая и средняя нагрузка, memory-bound. Варианты в vLLM задаются через --speculative-config:
draft_model— маленькая модель того же семейства и с тем же токенизатором;eagle3— лёгкие головы, обученные на скрытых состояниях целевой модели (их нужно найти или обучить под вашу модель);mtp— если модель обучена с multi-token prediction;ngramиsuffix— без модели. Хорошо работают, когда ответ копирует куски промпта: RAG с цитатами, правка кода.
Acceptance rate зависит от домена и температуры: черновик, обученный на английском чате, плохо угадывает русские юридические тексты. Включайте speculative decoding только после замера TPOT и goodput на своём трафике.
Serving-калькулятор: VRAM, конкурентность, TTFT и TPOT
Выберите модель, квантизацию весов и GPU, задайте длину запросов и число одновременных пользователей. Сверху — раскладка видеопамяти. Снизу — как throughput и TPOT меняются с ростом конкурентности (ось логарифмическая; вертикальный пунктир — момент, когда кончается KV-cache, горизонтальный — SLO 50 мс на токен). Это расчёт «на салфетке», чтобы понять, что во что упирается, а не замена нагрузочному тесту.
Достижимая полоса памяти принята за 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 | Вдвое больше одновременных запросов или длиннее контекст. Проверяйте качество на длинных контекстах |
- Weight-only (W4A16) ускоряет не всегда. Такая квантизация уменьшает число байт, читаемых на шаге decode, поэтому выигрывает при малом батче. При большом батче шаг упирается в вычисления, а веса ещё нужно деквантизовать, и преимущество тает. FP8 (W8A8) сокращает и память, и вычисления, поэтому лучше держит нагрузку. Решение принимайте по замеру на своём профиле нагрузки, а не по таблице.
- Калибровка. AWQ и GPTQ подбирают масштабы на калибровочном наборе, обычно это английский веб-текст. Если ваш трафик на русском, в коде или в строгом JSON, квантуйте на своих данных (llm-compressor от проекта vLLM принимает свой датасет) или особенно тщательно проверяйте готовый чекпойнт.
- Готовые чекпойнты. У крупных семейств есть официальные квантованные версии, например
Qwen/Qwen3-8B-FP8иQwen/Qwen3-8B-AWQна Hugging Face. Это быстрее своей квантизации, но проверка качества всё равно на вас. - 4 бита с плавающей точкой. На Blackwell появились форматы NVFP4 и MXFP4. Например, gpt-oss выпущена с MoE-весами в MXFP4, поэтому 120B-версия помещается в одну GPU на 80 ГБ.
Как измерить цену квантизации в качестве
- Тот же eval-набор и те же промпты (модуль 8), greedy-декодирование или фиксированный seed. BF16 и квантованная версия отвечают на одни и те же примеры.
- Парное сравнение. Смотрите не только на «92.1% против 91.4%», а на пары: сколько примеров сломалось и сколько починилось. Считайте разницу с доверительным интервалом (парный bootstrap или McNemar, модуль 8). Вопрос ставится как non-inferiority: «нижняя граница ДИ разницы выше −1 п.п.», а допуск задаёт продукт.
- Срезы. Средняя метрика прячет провалы. Смотрите отдельно длинный контекст, редкий язык, строгий JSON (долю валидного), арифметику и код: квантизация бьёт по хвостам раньше, чем по среднему.
- Поведение: доля отказов, распределение длины ответа, зацикливания. Квантованная модель иногда становится многословнее или чаще повторяется.
- Окупаемость. Тем же нагрузочным тестом измерьте 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, edge | GGUF, частичная выгрузка слоёв на 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 выгоден при большом и ровном объёме, когда модели поменьше хватает по качеству.
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); её надо измерять нагрузочным тестом, дефолты даны для порядка величин. Цены по умолчанию условные: подставьте свой прайс.
- Пиковая нагрузка вдвое-втрое выше средней, а GPU закладываются под пик (или autoscaling, но GPU поднимаются минутами, и их может не оказаться в регионе).
- Сравнение нечестное, если self-hosted модель слабее: вы экономите на токенах и теряете в качестве. Прогоните evals.
- Инженерное время: обновление моделей и драйверов, дежурства, нагрузочное тестирование. Один инженер на полставки легко стоит больше $8000 в месяц.
- API дешевеет каждые полгода, а купленный или зарезервированный на год GPU — нет.
# 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}'
- Параллелизм. По умолчанию модель обрабатывает один запрос за раз (
OLLAMA_NUM_PARALLEL=1), остальные ждут в очереди, а при её переполнении сервер отвечает 503. Каждый параллельный слот увеличивает выделенный контекст. - Цифры не переносятся. GGUF Q4 на ноутбуке и FP8 или BF16 в vLLM — разные модели по качеству и совсем разные по латентности. Evals и нагрузочный тест проводите на целевом стеке.
- Модель выгружается из памяти через 5 минут простоя (
OLLAMA_KEEP_ALIVE), и первый запрос после паузы получает TTFT с загрузкой весов.
«Мы посчитали по формуле с 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 не делает:
- Публичные имена моделей. Клиент просит
clf, шлюз отправляет в vLLM имя LoRA-адаптераticket-clfили базовой модели. Клиент не знает о внутренней раскладке, а откат адаптера — правка одной строки конфига. - Лимит конкурентности под
--max-num-seqs. vLLM при перегрузке по умолчанию не отказывает, а держит лишние запросы в очереди ожидания (метрикаvllm:num_requests_waiting), и TTFT растёт без предела. Шлюз держит очередь сам: запрос ждёт свободный слот ограниченное время, потом получает 429 сRetry-After, и клиент или балансировщик уходит на другую реплику или на API-провайдера (fallback из модуля 9). - Ретраи на 429 и 5xx, но только пока клиенту не отправлено ни байта. Ollama при переполнении очереди отвечает 503, балансировщики перед репликами vLLM — 502, 503 и 504.
- Таймаут на первый байт стрима. Зависшая реплика видна по тому, что заголовки пришли, а токенов нет. Такую попытку можно отменить и повторить, пока клиент ничего не получил.
- TTFT и TPOT глазами клиента. Серверные гистограммы vLLM (
vllm:time_to_first_token_seconds,vllm:inter_token_latency_seconds) не видят очередь шлюза и сеть. Шлюз меряет то, что видит пользователь, и отдаёт в ваш стек метрик (модуль 9).
Формат запроса и стрима — 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. На что обратить внимание:
- Отмена контекста при уходе клиента. Запрос к vLLM привязан к
r.Context(). Пользователь закрыл вкладку, соединение с vLLM закрылось, и он прерывает генерацию и освобождает KV-cache. Шлюз, который дочитывает ответ «в никуда», занимает слот батча впустую. - Отмена при уходе клиента. Шлюз пишет ответ построчно; когда пользователь закрыл вкладку, запись бросает
IOException, иUpstream.close()закрывает тело ответа vLLM. Соединение рвётся, vLLM прерывает генерацию и освобождает KV-cache. Шлюз, который дочитывает ответ «в никуда», занимает слот батча впустую. MaxIdleConnsPerHostпо умолчанию равен 2. При сотне параллельных стримов к одному хосту соединения пересоздавались бы на каждый запрос.- Пул соединений.
java.net.http.HttpClientдля HTTP/1.1 по умолчанию не ограничивает число keep-alive соединений к хосту, так что сотня параллельных стримов не пересоздаёт соединения. ВерсиюHTTP_1_1стоит задать явно: vLLM не говорит h2c, а попытка upgrade до HTTP/2 — лишняя работа на каждом соединении. - Сброс буфера после каждой строки (
http.NewResponseController(w).Flush()) и заголовокX-Accel-Buffering: noдля nginx: иначе стрим приходит пачками, и TTFT у клиента не совпадает с измеренным. - Сброс буфера после каждой строки (
out.flush()в chunked-ответеHttpExchange; в Spring —SseEmitterилиFlux<ServerSentEvent<String>>в WebFlux) и заголовокX-Accel-Buffering: noдля nginx: иначе стрим приходит пачками, и TTFT у клиента не совпадает с измеренным. - Ретрай после таймаута первого байта не бесплатен: отменённая попытка уже потратила prefill. Поэтому таймаут ставится с запасом над p99 TTFT, а не «на всякий случай короткий».
Поле model в ответе не переписывается обратно, и клиент видит имя адаптера. Один upstream: несколько реплик ставятся за балансировщик с политикой least-outstanding-requests или с учётом префикса. Одна общая очередь без справедливости между тенантами: когда тенантов много, нужна очередь на тенанта с весами. Метрики отдаются колбэком Observeколбэком observe (Consumer<Stats>), гистограммы Prometheus или OTel из модуля 9 подключаются в нём.
Лицензии open-weight моделей
Open-weight значит, что веса можно скачать, а не что с ними можно всё. Лицензия определяет, можно ли использовать модель коммерчески, распространять дообученные версии, учить на её выходах другие модели и где обязательно указать авторство. Проверяют её до выбора модели, а не перед релизом: менять базовую модель после месяца тюнинга и evals дорого.
| Семейство (октябрь 2026) | Лицензия | Что ограничивает |
|---|---|---|
| Qwen3, Qwen3.5 | Apache 2.0 | Почти ничего: при распространении сохранить текст лицензии и уведомления. Исключение из прошлого поколения — Qwen2.5-3B (только некоммерческое использование) и Qwen2.5-72B (своя лицензия: порог 100 млн MAU и «Built with Qwen» для моделей, обученных на её выходах) |
| Mistral: Small 3.x, Large 3, 7B, Mixtral | Apache 2.0 | Как выше. Но часть моделей прошлых лет (Mistral Large 2, Codestral 22B) вышла под Mistral Research License и Non-Production License: коммерческое использование только по отдельному договору |
| Gemma 4 | Apache 2.0 | Как выше |
| Gemma 3 и раньше | Gemma Terms of Use | Prohibited Use Policy, которую при распространении нужно включить в договор с вашими пользователями. Модели, обученные на синтетике от Gemma (дистилляция), считаются производными. Google оставляет за собой право ограничить использование при нарушении |
| Llama 3.x, Llama 4 | Llama 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, плюс короткая политика использования в репозитории модели |
- Лицензия модели ≠ лицензия данных. У датасета для дообучения свои ограничения: выходы API-моделей подчиняются условиям провайдера (многие запрещают обучать на них конкурирующие модели), данные клиентов — договору и закону о персональных данных (модуль 11).
- Внутреннее использование и распространение. Многие требования (атрибуция, копия лицензии, правила именования) срабатывают при распространении модели или продукта с ней. SaaS, где модель работает на вашем сервере, — пограничный случай, который юристы трактуют по-разному.
- Производные. Выкладываете адаптер или дообученную модель наружу: Llama требует «Llama» в начале имени, условия Gemma 3 — передать ограничения дальше, Apache 2.0 — сохранить NOTICE.
- Веса — это код. Скачивайте из официальной организации на Hugging Face, предпочитайте
safetensorsвместо pickle-форматов, включайтеtrust_remote_codeтолько после ревью кода и фиксируйте ревизию (commit hash). Это LLM04 Supply Chain из OWASP Top 10 for LLM 2026 (модуль 7): в редакции 2026 сюда же входит доверие к продвигаемому артефакту модели. - Решение фиксируйте в ADR (модуль 13): модель, ревизия, лицензия, дата проверки, вывод юриста. Применимость лицензии к конкретной системе определяет юрист.
Когда fine-tuning, а когда промпт или RAG
Вторая половина модуля — адаптация модели под задачу. Свой serving из первой половины понадобится и здесь: дообученную модель придётся где-то запускать. Но сначала вопрос, нужна ли она вообще.
Главное правило: fine-tuning меняет поведение, а не знания. Дообучение на внутренней вики не научит модель надёжно отвечать фактами из неё. Модель выучит стиль вики и начнёт уверенно галлюцинировать в этом стиле. Знания подключают через RAG (модуль 5): его можно обновлять без переобучения, давать ссылки на источники и учитывать права доступа.
Fine-tuning оправдан, когда нужно:
- Устойчивый формат или стиль, который промптом держится нестабильно: особый тон бренда, строгий DSL, специфическая разметка.
- Узкая задача с большим объёмом: классификация тикетов, извлечение полей, роутинг. Маленькая дообученная модель здесь часто не хуже большой универсальной, а дешевле и быстрее в 10–50 раз.
- Латентность и стоимость: дистилляция поведения большой модели в маленькую, когда промпт с 30 few-shot примерами стоит слишком дорого на миллионах вызовов.
- То, что трудно описать словами, но легко показать примерами: «пиши как наши лучшие операторы поддержки».
Порядок действий всегда один: сначала 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 model | SFT → обучение 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 можно представить произведением двух узких матриц. Вместо d·k параметров обучаются r·(d + k). Для d = k = 4096 и r = 16 это 131 072 вместо 16.7 млн, то есть 0.8% на одну матрицу.
- Инициализация. A случайная, B нулевая, поэтому в начале ΔW = 0 и модель ведёт себя как базовая.
- r (rank) — ёмкость адаптера. Обычно 8–64. Больше r — больше выразительности и риска переобучения. Для формата и стиля хватает 8–16.
- α (alpha) — масштаб: ΔW умножается на α/r. Обычно α = r или 2r. Меняете r — пересматривайте α или learning rate. Вариант rsLoRA масштабирует на α/√r и стабильнее при больших r.
- Какие слои. В исходной статье это только attention (q, v). В QLoRA показано, что адаптеры на все линейные слои (q, k, v, o, gate, up, down) нужны, чтобы догнать full fine-tuning. Сейчас это разумный дефолт:
target_modules="all-linear". - Инференс. Адаптер можно слить в веса (
merge_and_unload), и тогда латентность не меняется. Можно держать отдельно и подгружать на лету: vLLM обслуживает много LoRA-адаптеров поверх одной базовой модели, то есть «модель на тенанта» за копейки (см. multi-LoRA serving). - Learning rate. Для LoRA выше, чем для full FT: порядка 1e-4–2e-4 против 1e-5–2e-5.
QLoRA хранит замороженную базовую модель в 4 битах (тип NF4 плюс double quantization констант), а адаптеры обучает в bf16. Память под базу падает в ~4 раза относительно bf16. Авторы дообучили 65B-модель на одном GPU с 48 ГБ. Платите вы скоростью (деквантизация на лету) и немного качеством, которое обычно близко к 16-битной LoRA.
LoRA: сколько параметров обучается и сколько нужно памяти
Слева матрица W размера d×d и её низкоранговое обновление B·A в масштабе. Справа память на веса и состояние оптимизатора для full FT, LoRA и QLoRA (шкала логарифмическая, активации не учтены: они зависят от batch и длины последовательности).
LoRA-адаптер похож на Filter или HandlerInterceptor поверх неизменяемого контроллера: базовая модель общая и read-only, а поведение меняет тонкая обёртка, которую можно менять на каждый запрос. Отсюда и multi-LoRA-сервинг, где на одном GPU одна база и десятки адаптеров, выбираемых по полю model в запросе.
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 в обучении должен совпадать с тем, что будет в проде. Если в инференсе промпт другой, вы получите сдвиг распределения на ровном месте.
Качество важнее количества
- Сколько нужно. Для формата и стиля 200–500 чистых примеров часто достаточно. Для узкой классификации или извлечения обычный диапазон 500–5000. Статья LIMA показала, что 1000 тщательно отобранных примеров дают сильную инструктивную модель. Удвоение датасета обычно даёт меньше, чем чистка ошибок в имеющемся.
- Консистентность разметки. Если два разметчика ставят разные метки одному тикету, модель выучит шум. Измеряйте согласие разметчиков (Cohen's κ) на 100 общих примерах. Если оно низкое, чините инструкцию для разметки, а не модель.
- Покрытие. Редкие классы, длинные входы, другие языки, пограничные случаи. Модель не обобщит на то, чего не видела.
- Балансировка. При 90% «other» модель научится говорить «other». Помогают oversampling редких классов (осторожно: переобучение на дублях), undersampling частых, веса классов. Но тестовое распределение оставляйте реальным, иначе метрики будут врать.
Дедупликация и утечка теста в трейн
Самый частый способ обмануть себя — дубликаты между train и test. Тикеты из шаблонов, переотправленные письма, один и тот же текст с другим id. Модель запоминает их, test показывает 98%, в проде получается 80%.
- Точные дубликаты ищутся по хэшу нормализованного текста (нижний регистр, схлопнутые пробелы, без id и дат).
- Почти-дубликаты ищутся через MinHash/LSH по шинглам или cosine similarity embedding'ов > 0.95.
- Групповое разбиение. Все сообщения одного клиента, треда или документа попадают в один сплит (
GroupShuffleSplit). Для временных данных делите по времени: train раньше, test позже. Именно так модель будет работать в проде. - Сначала дедупликация, потом разбиение. Обратный порядок оставит копии по разные стороны.
Синтетические данные от сильной модели
Быстрый способ получить датасет: Opus или Fable генерирует входы и/или разметку, маленькая модель на этом учится. Работает хорошо, но с рисками:
- Узкое распределение. Синтетика однообразна по стилю и длине, в ней нет опечаток, мата и сумбура реальных пользователей. Смешивайте с реальными данными и варьируйте персоны, длину и сложность в промпте генерации.
- Ошибки учителя становятся потолком. Если сильная модель ошибается в 5% разметки, ученик выучит эти 5% как истину. Проверяйте выборку глазами и фильтруйте LLM-судьёй или согласием нескольких прогонов.
- Утечка eval. Если учитель генерировал и тестовые примеры, вы проверяете модель на её «родном» распределении. Тест должен быть реальным и размеченным людьми.
- ToS провайдера для дистилляции (см. выше).
Код генерации и фильтрации — в разделе «Синтетика и дистилляция в коде».
Синтетика и дистилляция в коде
В прикладной работе дистилляция почти всегда выглядит так: сильная модель (учитель) размечает или генерирует данные, маленькая (ученик) учится на них через SFT. Классический вариант Хинтона учит ученика повторять распределение вероятностей учителя (логиты), а не только итоговый текст. Он доступен, только если учитель — ваша open-weight модель с тем же токенизатором; в TRL для этого есть DistillationTrainer (on-policy дистилляция: ученик генерирует ответы сам, а учитель оценивает его токены). Через API у вас есть только тексты, и это обычный SFT на выходах учителя.
Масштаб метода хорошо виден на DeepSeek-R1-Distill: модели Qwen и Llama от 1.5B до 70B дообучили SFT на 800 тысячах примеров, отобранных с помощью DeepSeek-R1. По отчёту авторов, такая дистилляция дала маленьким моделям больше, чем дорогой RL, применённый к ним напрямую.
Пайплайн
- Входы. Лучше всего реальные входы без разметки (логи, тикеты): учитель только размечает, и распределение входов честное. Генерировать сами входы приходится, когда реальных мало или в них нет редких классов.
- Разнообразие задаётся явно. У новых моделей Claude нет параметра temperature (модуль 2), а высокая температура и не даёт разнообразия, только шум. Разнообразие задают в промпте: персоны, длина, стиль, опечатки, подтемы, сетка «тема × персона × длина».
- Фильтры от дешёвых к дорогим: схема и длина → точные дубли → близость к eval-набору (утечка) → почти-дубли → независимая разметка второй моделью, которая не знает, под какую метку генерировался текст. Расхождение разметчиков означает либо плохой текст, либо пограничный класс, и такие примеры полезно отдать человеку.
- Отчёт о разнообразии: распределение меток, длины, доля уникальных начал фраз. Если 40% примеров начинаются с «Здравствуйте, у меня проблема», ученик выучит шаблон.
- Смесь с реальными данными и ручная проверка выборки (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-граммам подбирается по выборке глазами: слишком низкий выкинет законные вариации, слишком высокий пропустит перефразы.
Ученик не станет лучше учителя на тех примерах, где учитель ошибается: независимый разметчик и ручная проверка снижают долю ошибок, но не обнуляют её. Отдельно проверьте условия провайдера учителя: обучать на выходах закрытой модели узкий внутренний классификатор обычно можно, универсальную модель-конкурента — как правило, нет. С open-weight учителем действует его лицензия (см. раздел о лицензиях: у Llama и Gemma 3 есть условия для моделей, обученных на их выходах).
Train/val/test и кривые обучения
Train — на нём учимся. Validation — по нему выбираем гиперпараметры и момент остановки. Test — трогаем один раз в конце, чтобы честно оценить результат. Если вы смотрели на test и что-то подкручивали, он стал вторым validation, и оценка уже оптимистична. Типичное разбиение 80/10/10, со стратификацией по классу, чтобы редкие классы были во всех частях.
- Underfitting. Train loss и val loss высокие и близкие. Модели не хватает ёмкости, шагов или слишком сильная регуляризация: увеличьте r, число эпох, learning rate.
- Overfitting. Train loss продолжает падать, а val loss развернулся и растёт. Модель запоминает примеры. Помогают больше данных, меньше эпох, меньше r, dropout, weight decay и early stopping.
- Early stopping. Оценивать val каждые N шагов, сохранять лучший чекпойнт и останавливаться, если улучшения нет patience раз подряд. В HF Trainer это
load_best_model_at_end=TrueиEarlyStoppingCallback.
Кривые обучения: где начинается overfitting
Модель игрушечная, но форма кривых реалистична. Маленький датасет с нулевой регуляризацией быстро переобучается, большой датасет отодвигает разворот val loss, а слишком сильная регуляризация даёт underfitting. Маркер показывает, где early stopping (patience = 2 эпохи) остановит обучение и какой чекпойнт вернёт.
Val loss — это cross-entropy на токенах эталона. Она полезна, чтобы поймать переобучение, но не говорит, правильно ли модель решает задачу. Модель может чуть хуже предсказывать слово «пожалуйста» и при этом лучше классифицировать. Финальное решение принимайте по task-метрикам на test (accuracy, F1, eval-набор из модуля 8), а loss используйте для диагностики.
Метрики классификации
Большая часть задач, где дообучают, — классификация или извлечение. Их метрики нужно знать наизусть, про них спросят.
| Предсказано «да» | Предсказано «нет» | |
|---|---|---|
| На самом деле «да» | TP — верно нашли | FN — пропустили |
| На самом деле «нет» | FP — ложная тревога | TN — верно отсеяли |
- Precision = TP / (TP + FP): из того, что модель назвала «да», какая доля правда «да». Важна, когда дорога ложная тревога: автоблокировка аккаунта, автоответ клиенту.
- Recall = TP / (TP + FN): какую долю настоящих «да» нашли. Важен, когда дорог пропуск: фрод, эскалация жалобы, токсичный контент.
- F1 = 2PR / (P + R), гармоническое среднее. Оно низкое, если низка хотя бы одна из метрик. Для мультикласса: macro-F1 (среднее по классам, редкие классы весят столько же) против micro/weighted, где доминируют частые классы.
- Accuracy при дисбалансе ничего не говорит. При 2% фрода модель «всегда нет» даёт 98% accuracy и нулевой recall.
- ROC-AUC — вероятность того, что случайный позитив получит скор выше случайного негатива. При сильном дисбалансе выглядит оптимистично, потому что FPR делится на огромное число негативов. PR-AUC (average precision) честнее для редких классов.
- Порог. Модель выдаёт скор, решение принимает порог. Выбирают его по стоимости ошибок: «precision ≥ 95% при максимальном recall» или минимум
cost_FN·FN + cost_FP·FP. Порог подбирается на validation, а не на test.
Confusion matrix, порог и PR-кривая
2000 синтетических примеров со скорами классификатора. Двигайте порог и долю позитивов. При 2% позитивов accuracy почти не меняется, а precision обваливается: вот почему accuracy при дисбалансе бесполезна.
У дообученной open-source модели это вероятность токена метки (logprobs) или отдельная классификационная голова. У API-моделей без logprobs можно голосовать несколькими сэмплами или просить модель вывести уверенность, но такие «уверенности» плохо откалиброваны. Проверяйте калибровку на validation: среди ответов с уверенностью 0.8 доля правильных должна быть около 80%.
Catastrophic forgetting
После дообучения на узкой задаче модель может разучиться тому, что умела: хуже следует общим инструкциям, забывает второй язык, отвечает меткой на любой вопрос. Это catastrophic forgetting: градиенты от вашей задачи переписывают веса, отвечавшие за другое.
- LoRA забывает меньше full FT (статья «LoRA Learns Less and Forgets Less»): маленький ранг ограничивает, насколько далеко модель уходит от базы. Обратная сторона — задачам, требующим много новых знаний, LoRA учится хуже.
- Replay. Подмешивайте 5–20% общих инструктивных данных к своим.
- Меньше эпох и ниже learning rate. 1–3 эпохи обычно достаточно, 10 эпох почти гарантированно переобучают и заставляют забывать.
- Регрессионный набор. Кроме целевой метрики, прогоняйте небольшой набор общих задач: следование формату, другой язык, отказ на вредный запрос. Дообучение может ослабить и safety-поведение, это отдельный риск.
- Узкая модель для узкой задачи. Дообученную модель используйте только там, где её учили, а для остального оставьте базовую. Адаптеры позволяют держать обе на одном GPU.
Где тюнить, стоимость и код
| Вариант | Плюсы | Минусы |
|---|---|---|
| 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:
- TRL (Hugging Face): эталонные тренеры SFT, DPO, GRPO/RL поверх Transformers и PEFT. С него стоит начинать, чтобы понимать механику.
- Unsloth: оптимизированные ядра, в разы быстрее и экономнее по памяти на одном GPU. Хорош для QLoRA на ноутбуке с GPU или в Colab.
- Axolotl: обучение, описанное YAML-конфигом, multi-GPU (FSDP, DeepSpeed), много готовых рецептов. Удобен, чтобы воспроизводимо гонять эксперименты.
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 в прикладных задачах.
Откуда брать пары
- Правки операторов. Черновик модели — rejected, исправленный оператором текст — chosen. В поддержке это самый ценный источник: пары появляются сами в ходе работы.
- Оценки пользователей (лайк или дизлайк) — непарные сигналы. Для них подходит KTO (
KTOTrainerв TRL), которому пары не нужны. - On-policy пары. SFT-модель генерирует 2–4 ответа на реальные промпты, LLM-судья с откалиброванной рубрикой (модуль 8) выбирает лучший и худший. Пары из распределения самой модели обычно работают лучше, чем «хороший ответ сильной модели против плохого ответа из интернета».
- Проверяемая правильность (тесты, точное совпадение, схема). Тогда лучше RL с наградой от проверки, например GRPO (
GRPOTrainer), а не DPO.
{"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")На что смотреть и где ломается
- Метрики TRL.
rewards/accuracies— доля пар, где chosen получил больший неявный reward,rewards/margins— средний разрыв. Accuracy 1.0 на валидации через пару десятков шагов — повод заподозрить утечку или тривиальные пары (например, chosen всегда короче). - Эксплуатация длины. Если chosen систематически длиннее, модель выучит «длиннее — значит лучше». Балансируйте длины в парах и сравнивайте распределение длины ответов до и после.
- β и learning rate. Меньший β позволяет уйти от референса дальше: эффект сильнее, но выше риск деградации. Дефолт TRL — 0.1. DPO чувствителен к learning rate, начинайте на порядок ниже, чем для SFT. Варианты лосса (например,
loss_type=["ipo"]) устойчивее к шумным парам. - Оценка — не по loss, а по доле побед (win rate) над SFT-версией на отложенных промптах: судья плюс выборочная проверка людьми. И по регрессионному набору: DPO может ослабить следование формату и safety-поведение.
До и после: оценка, выкатка, 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}'
--max-loras— сколько разных адаптеров может быть в одном батче (по умолчанию 1).--max-lora-rank— максимальный r среди адаптеров (по умолчанию 16; больше — лишняя память).--max-cpu-loras— сколько адаптеров держать в RAM для быстрой подгрузки.- Цена. Запрос с адаптером считается дольше, чем к базе (добавляются умножения на B и A), а смешанный батч обычно медленнее однородного. Меряйте нагрузочным тестом на реальной смеси адаптеров.
- Слить или держать отдельно.
merge_and_unload()(флаг--mergeвlora_sft.py) даёт одну модель без накладных расходов, её можно квантовать, но каждая задача — отдельная копия весов. Отдельные адаптеры выгоднее, когда задач или тенантов много. - Горячая загрузка через
/v1/load_lora_adapter(сVLLM_ALLOW_RUNTIME_LORA_UPDATING=True) документация vLLM прямо называет небезопасной для продакшна. В проде адаптеры раскладываются вместе с деплоем, а шлюз переключает имена. - Адаптер привязан к базе, причём к конкретной ревизии весов. Обновили базу — переобучите адаптеры и прогоните evals. Ревизию базы храните рядом с адаптером.
Практика
Задание 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.
- Таблица по трём вариантам: качество по метрикам проекта B и разница с Claude с 95% ДИ (парно, модуль 8); доля ответов с корректными цитатами; суммарные tok/s, TTFT и TPOT p50/p95/p99 при 1, 8, 32 и 128 одновременных запросах (метрики шлюза, нагрузочный скрипт или
vllm bench serve); стоимость 1000 запросов по формуле из раздела self-hosted. - Измеренный goodput подставлен в калькулятор, найдена точка безубыточности против Haiku 4.5 по вашей цене GPU-часа; параметры (цена GPU-часа, goodput одной GPU при вашем SLO, C_ops) записаны в README.
- Если модель работала в Ollama, это явно указано, и латентность не выдаётся за продакшн-цифры.
- Вывод на 5–7 предложений: какую модель ставить на какие запросы (допустим роутинг: простые — на open-weight, сложные — на Claude).
Задание 2: LoRA под узкую задачу с доказанным выигрышем
Выберите в проекте A узкую задачу с объёмом: классификацию вопроса перед поиском (раздел документации, «нужен ли поиск вообще», язык) или извлечение полей из вопроса (версия продукта, компонент). Соберите 1000–3000 примеров: реальные вопросы из логов, размеченные вами или сильной моделью, плюс синтетика через synth.py с фильтрами. Test — 200+ реальных примеров, размеченных людьми.
- Датасет прошёл Go-валидаторДатасет прошёл Java-валидатор и проверку пересечения сплитов. Намеренно оставленные 10% почти-дубликатов между train и test показали, насколько утечка завышает метрику, после чего их убрали.
- Сравнение на одном test: zero-shot и few-shot на Claude, база без адаптера, LoRA (
lora_sft.py). Метрика задачи (macro-F1 или per-field F1) и разница LoRA с лучшей альтернативой с 95% ДИ. Выигрыш засчитывается, только если нижняя граница ДИ выше нуля; честно опровергнутый выигрыш тоже результат. - Регрессионный набор из 30–50 общих задач прогнан до и после, заметной деградации нет.
- Адаптер поднят в vLLM через
--lora-modules, шлюз отдаёт его под публичным именем; латентность p95 и стоимость 1M классификаций сравнены с Claude.
Задание 3: порог по бизнес-требованию
Для бинарной задачи «нужна эскалация человеку» подберите порог на validation под требование «recall ≥ 0.9» и под «precision ≥ 0.95». Посчитайте на test, сколько тикетов в день уйдёт людям при 10 000 тикетов в сутки для обоих порогов.
- Есть PR-кривая с двумя отмеченными точками.
- Расчёт нагрузки на операторов понятен продакт-менеджеру: сколько тикетов уйдёт людям, сколько из них действительно требуют эскалации, сколько пропустим.
Самопроверка
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.
Источники
- vLLM docs: vLLM — OpenAI-Compatible Server (эндпоинты,
--api-key, extra-параметры), Optimization and Tuning (preemption, chunked prefill), Metrics - vLLM — LoRA Adapters (multi-LoRA,
--lora-modules, динамическая загрузка) и Speculative Decoding - vLLM — vllm bench serve (TTFT, TPOT, ITL, goodput)
- Kwon et al., 2023. Efficient Memory Management for LLM Serving with PagedAttention
- Yu et al., OSDI 2022. Orca: A Distributed Serving System for Transformer-Based Generative Models (continuous batching, iteration-level scheduling)
- Agrawal et al., 2024. Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve (chunked prefill)
- Zhong et al., 2024. DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving
- Zheng et al., 2023. SGLang: Efficient Execution of Structured Language Model Programs и документация SGLang
- Leviathan et al., 2022. Fast Inference from Transformers via Speculative Decoding, Chen et al., 2023. Speculative Sampling, Li et al., 2025. EAGLE-3
- Lin et al., 2023. AWQ: Activation-aware Weight Quantization, Frantar et al., 2022. GPTQ, Micikevicius et al., 2022. FP8 Formats for Deep Learning
- llm-compressor (квантизация AWQ, GPTQ, FP8 на своих данных) и llama.cpp (GGUF)
- Ollama — OpenAI compatibility и FAQ (контекст, параллелизм, очередь)
- Hugging Face — text-generation-inference (статус maintenance mode в README) и Ollama на GitHub
- Hu et al., 2021. LoRA: Low-Rank Adaptation of Large Language Models
- Dettmers et al., 2023. QLoRA: Efficient Finetuning of Quantized LLMs
- Biderman et al., 2024. LoRA Learns Less and Forgets Less
- Sheng et al., 2023. S-LoRA: Serving Thousands of Concurrent LoRA Adapters
- Rafailov et al., 2023. Direct Preference Optimization
- Ouyang et al., 2022. Training language models to follow instructions with human feedback (InstructGPT, RLHF)
- Zhou et al., 2023. LIMA: Less Is More for Alignment
- Hinton et al., 2015. Distilling the Knowledge in a Neural Network, Agarwal et al., 2023. On-Policy Distillation of Language Models
- DeepSeek-AI, 2025. DeepSeek-R1 (раздел о дистилляции в Qwen и Llama)
- TRL: SFT Trainer, DPO Trainer, PEFT docs
- Unsloth docs, Axolotl
- Meta — Llama 4 Community License и Acceptable Use Policy
- Google — Gemma Terms of Use и лицензия Gemma 4
- Qwen LICENSE AGREEMENT (Qwen2.5-72B), Mistral AI Research License, Apache License 2.0
- OpenAI: Fine-tuning guide
- Документация Claude (prompt engineering, evals, structured outputs — то, что пробовать до fine-tuning)
- scikit-learn: Metrics and scoring
- Google Machine Learning Crash Course (overfitting, классификация, ROC/PR)