Дообучение: LoRA, QLoRA, инструкционный тюнинг и когда оно вообще нужно
Все предыдущие статьи трека меняли вход модели: промпт, примеры, извлечённые документы, инструменты, цикл агента. Веса при этом оставались чужими и неизменными. Дообучение — единственная техника, которая меняет саму функцию: после него модель ведёт себя иначе даже при пустом промпте.
Это звучит как более фундаментальное решение, и именно поэтому к нему тянутся раньше времени. На практике 80 % запросов «нам нужен файнтюн» закрываются нормальным промптом, few-shot-примерами и RAG. Начнём с того, как отличить оставшиеся 20 %.
Что дообучение действительно меняет, а что нет
Полезная модель: у LLM есть три разных «канала» знания.
- Веса — то, что модель усвоила на претрейне: язык, факты мира, навыки рассуждения. Дообучение двигает этот канал, но очень слабо: вы прогоняете тысячи примеров против триллионов токенов претрейна.
- Контекст — то, что вы положили в промпт прямо сейчас. Точное, свежее, проверяемое, но платное на каждом вызове.
- Поведенческая политика — как модель отвечает: формат, тон, длина, когда молчит, когда зовёт инструмент. Это то, что дообучение меняет очень хорошо и очень дёшево.
Отсюда главное правило: дообучение — это инструмент про форму, а не про факты.
Эмпирическое подтверждение есть: Ovadia et al., «Fine-Tuning or Retrieval? Comparing Knowledge Injection in LLMs», arXiv:2312.05934 сравнивали закачку нового фактического знания через unsupervised-файнтюн и через RAG на MMLU-подобных задачах — RAG уверенно выигрывал, а файнтюн на фактах давал прирост только при агрессивной аугментации (много перефразировок одного факта). Модель, дообученная на вашей вики, не «выучит» её: она выучит стиль вашей вики и начнёт уверенно галлюцинировать в этом стиле.
| Задача | Промпт | RAG | Файнтюн |
|---|---|---|---|
| Ответы по свежим внутренним документам | ✗ | ✓ | ✗ (галлюцинации в правильном стиле) |
| Жёсткий формат вывода (свой DSL, XML-диалект) | частично | ✗ | ✓ |
| Специфичный тон бренда, единый стиль редактуры | частично | ✗ | ✓ |
| Классификация в 200 доменных классов | дорого | частично | ✓ |
| Дистилляция большой модели в маленькую | ✗ | ✗ | ✓ |
| Снижение латентности и цены при том же качестве | ✗ | ✗ | ✓ |
| Вызов инструментов по узкой схеме | ✓ | ✗ | ✓ (если промпт уже не помогает) |
| Новый язык/домен с непохожей лексикой | ✗ | частично | ✓ (нужен continued pretraining) |
Правило принятия решения, которое стоит повесить на стену:
Если вы можете описать желаемое поведение словами и модель ему следует — пишите промпт. Если вы можете описать словами, но модель систематически не следует на 20+ примерах — вот здесь начинается файнтюн.
или в поведении?"} B -->|Факты| C["RAG + цитирование
файнтюн НЕ поможет"] B -->|Поведение| D{"Промпт с 5–10
few-shot примерами
решает?"} D -->|Да| E["Готово. Дальше — только
кэширование префикса"] D -->|Нет| F{"Есть 500+ примеров
'вход → эталонный выход'?"} F -->|Нет| G["Собрать датасет.
Это 80% работы"] G --> F F -->|Да| H{"Цель — качество
или цена/латентность?"} H -->|Качество| I["LoRA на модели того же
класса, что и текущая"] H -->|Цена| J["Дистилляция: сильная модель
размечает → LoRA на маленькой"] I --> K{"Влезает в GPU?"} J --> K K -->|Да| L["LoRA, база в bf16"] K -->|Нет| M["QLoRA, база в NF4"] L --> N["Оценка на holdout +
регресс на общих бенчмарках"] M --> N N -->|Деградация| O["Откат: снизить lr,
подмешать общие данные"] N -->|Ок| P["Слить адаптер или
раздавать через multi-LoRA"]
Арифметика памяти: почему полный файнтюн больно
Прежде чем говорить про LoRA, полезно понять, откуда берётся её экономия. Возьмём модель на 7 млрд параметров и стандартный mixed-precision AdamW.
| Что храним | Точность | Байт на параметр | Для 7B |
|---|---|---|---|
| Веса модели | bf16 | 2 | 14 ГБ |
| Градиенты | bf16 | 2 | 14 ГБ |
| Мастер-копия весов | fp32 | 4 | 28 ГБ |
| Adam: первый момент $m$ | fp32 | 4 | 28 ГБ |
| Adam: второй момент $v$ | fp32 | 4 | 28 ГБ |
| Итого статики | 16 | 112 ГБ |
Плюс активации — они зависят от batch size и длины последовательности, и с gradient checkpointing для batch 1 × 2048 токенов это ещё порядка 5–8 ГБ. Итог: 7B-модель не дообучается целиком даже на H100 80 ГБ без ZeRO-шардирования по нескольким картам или offload на CPU.
Ключевое наблюдение: веса — это всего 12,5 % бюджета. Основная масса — состояния оптимизатора и градиенты, то есть плата за то, что параметр обучаемый. Значит, если сделать обучаемыми 0,5 % параметров, исчезнет 87,5 % бюджета.
LoRA: почему низкий ранг работает
Идея опирается на наблюдение Aghajanyan et al., «Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning», arXiv:2012.13255: адаптация предобученной модели к конкретной задаче имеет низкую внутреннюю размерность. Матрица обновления $\Delta W$, которую вы ищете при файнтюне, — «почти низкоранговая»: вы не перестраиваете представление, вы делаете небольшой поворот в подпространстве, которое уже есть.
Hu et al., «LoRA: Low-Rank Adaptation of Large Language Models», arXiv:2106.09685 превратили это в метод: заморозить $W_0$ и параметризовать обновление произведением двух тонких матриц.
$$h = W_0 x + \frac{\alpha}{r} B A x, \qquad B \in \mathbb{R}^{d \times r}, \quad A \in \mathbb{R}^{r \times k}, \quad r \ll \min(d, k)$$
Вместо $d \cdot k$ обучаемых параметров — $r(d + k)$. Для квадратной матрицы 4096×4096 и $r = 16$ это 131 тыс. против 16,8 млн: в 128 раз меньше.
Три детали, которые определяют, заработает ли это.
Инициализация. $A$ — случайная гауссова, $B$ — нулевая. Значит $BA = 0$, и на первом шаге модель в точности равна предобученной. Без этого вы стартуете со случайного возмущения весов и первые сотни шагов тратите на возврат к исходной точке.
Скейлинг $\alpha/r$. Он нужен, чтобы менять $r$ без перетюна learning rate. Практическое эвристическое правило: держите $\alpha = 2r$ (или $\alpha = r$) и настраивайте только $r$. Работа Kalajdzievski, «A Rank-Stabilized Scaling Factor for LoRA», arXiv:2312.03732 показывает, что при больших $r$ деление на $r$ слишком душит градиент, и предлагает $\alpha/\sqrt{r}$ — в peft это флаг use_rslora=True. Если вы поднимаете ранг выше 64 и не видите улучшения, включите его: часто «ранг не помогает» на деле означает «скейлинг задушил обновление».
Куда вешать адаптеры. Оригинальная статья ставила их только на $W_q$ и $W_v$. Современная практика (и то, что делает QLoRA) — на все линейные слои: q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj. Разница в качестве заметная, а прирост параметров всё равно копеечный. В peft это target_modules="all-linear".
Честная оценка ограничений — Biderman et al., «LoRA Learns Less and Forgets Less», arXiv:2405.09673. Их вывод в двух пунктах: на инструкционном тюнинге LoRA практически догоняет полный файнтюн; на освоении нового домена (код, математика с миллиардами токенов) — заметно отстаёт. Зато LoRA существенно меньше разрушает исходные способности модели, то есть работает как регуляризатор. Если вам нужно влить в модель принципиально новый домен, вам нужен не LoRA-тюнинг, а continued pretraining с высоким рангом или полный файнтюн.
QLoRA: база в 4 битах
Dettmers et al., «QLoRA: Efficient Finetuning of Quantized LLMs», arXiv:2305.14314 добили последний оставшийся кусок — сами замороженные веса. Три компонента:
- NF4 (4-bit NormalFloat). Информационно-теоретически оптимальный тип данных для весов, распределённых нормально: уровни квантизации расставлены по квантилям нормального распределения, а не равномерно. Веса нейросетей действительно близки к $N(0, \sigma^2)$, поэтому NF4 бьёт обычный int4 при том же числе бит.
- Двойная квантизация. Квантизация идёт блоками (обычно 64 веса), у каждого блока свой fp32-множитель. Эти множители тоже квантуют — до 8 бит. Экономия ~0,37 бит на параметр: для 65B это полгигабайта.
- Paged optimizers. Состояния оптимизатора живут в unified memory NVIDIA и вытесняются в RAM при пиках — вместо OOM на длинной последовательности вы получаете просадку скорости.
Ключевой момент: веса квантованы, но не обучаются. При forward-проходе блок деквантуется в bf16 на лету, считается матричное произведение, деквантованный тензор выбрасывается. Градиенты текут сквозь квантованные веса в адаптер, который живёт в bf16. Никакого quantization-aware training — просто заморожённая 4-битная база.
Результат из статьи: 65B-модель дообучается на одной GPU 48 ГБ за 24 часа, а полученная Guanaco достигает 99,3 % от уровня ChatGPT на Vicuna-бенчмарке.
Цена, о которой говорят реже: QLoRA медленнее LoRA примерно на 25–40 % (деквантизация на каждом шаге не бесплатна) и даёт чуть более шумные градиенты. Правило: если модель влезает в bf16 — берите обычный LoRA; QLoRA — это способ поместить в железо то, что иначе не помещается.
Данные решают всё остальное
Здесь ломается большинство проектов. Метод дообучения — это конфиг на 20 строк; датасет — это несколько недель.
Формат и шаблон чата
Инструкционный тюнинг (Wei et al., «Finetuned Language Models Are Zero-Shot Learners», arXiv:2109.01652; Ouyang et al., «InstructGPT», arXiv:2203.02155) — это обучение на парах «инструкция → ответ», отформатированных ровно тем шаблоном, который модель увидит в проде.
Самая частая и самая тихая ошибка — расхождение chat template между обучением и инференсом. Если вы обучали на ### Instruction:\n...\n### Response:\n, а сервите через tokenizer.apply_chat_template() с ChatML-разметкой — модель видит другой префикс и ведёт себя как недообученная. Всегда берите шаблон из токенизатора базовой модели:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
messages = [
{"role": "system", "content": "Ты извлекаешь поля из счетов и возвращаешь JSON."},
{"role": "user", "content": "Счёт № 42 от 03.05.2026, сумма 12 400 ₽, НДС 20%"},
{"role": "assistant", "content": '{"number":"42","date":"2026-05-03","total":12400,"vat":0.2}'},
]
# Ровно та же строка, что попадёт в модель на инференсе
text = tok.apply_chat_template(messages, tokenize=False)
print(text)
Маскирование промпта
По умолчанию causal LM учится предсказывать все токены последовательности, включая вашу инструкцию. Обычно это не то, что нужно: вы хотите, чтобы модель училась генерировать ответ, а не воспроизводить промпт. Маскирование лоссом только completion-части (completion_only_loss в TRL) обычно даёт заметный прирост на коротких ответах и почти нулевой — на длинных. Проверяйте оба варианта, но начинайте с маскирования.
Качество против количества
Zhou et al., «LIMA: Less Is More for Alignment», arXiv:2305.11206 дообучили LLaMA-65B на 1000 тщательно отобранных примеров и получили результат, конкурентный с моделями, обученными на сотнях тысяч. Гипотеза статьи: знания приходят из претрейна, а инструкционный тюнинг лишь «включает» нужный режим — и для этого хватает малого числа очень чистых демонстраций.
Практическая шкала:
| Цель | Порядок числа примеров |
|---|---|
| Жёсткий формат вывода, одна узкая задача | 300–1 000 |
| Стиль/тон, доменная терминология | 1 000–5 000 |
| Классификация, много классов | 50–200 на класс |
| Дистилляция сильной модели в маленькую | 10 000–100 000 |
| Новый домен/язык (continued pretraining) | сотни миллионов токенов |
Гигиена датасета, без которой цифры выше не работают:
- Дедупликация near-duplicate’ов (MinHash/SimHash). Повторы — это скрытое переобучение на подмножестве.
- Разделение по источнику, а не случайное. Если один документ породил 50 примеров, они обязаны целиком лежать либо в train, либо в eval. Случайный
train_test_splitдаст вам утечку и красивую, но фальшивую метрику. - Проверка на контаминацию бенчмарками, на которых вы потом собираетесь мерить.
- Единообразие «отказов». Если в 30 % примеров модель отвечает «не знаю» по-разному, вы обучаете её на шуме. Зафиксируйте одну формулировку.
- Ошибки в разметке дороже, чем кажется. На датасете в 1000 примеров 5 % мусора — это 50 примеров, которые модель выучит буквально.
Дистилляция как основной практический сценарий
Самый окупаемый вид файнтюна в проде выглядит так: сильная модель через API размечает данные, маленькая открытая модель учится их воспроизводить. Подход восходит к Self-Instruct (Wang et al., arXiv:2212.10560).
цена ниже в 40 раз, p95 — в 6 раз
Экономика на конкретных числах. Разметка 20 000 примеров учителем уровня Claude Opus 4.8 (input $5, output 25 $ за 1M токенов) при 600 входных и 400 выходных токенах на пример — это 12M входных и 8M выходных токенов, то есть примерно 60 $ + 200 $ = 260 $ разово. Дообучение 8B-модели QLoRA на арендованной A100 — ещё 10 $–20. Дальше вы платите только за собственный инференс. Если у вас 3 млн запросов в месяц, разница между вызовом фронтир-модели и своей 8B на своём железе измеряется десятками тысяч долларов в месяц — файнтюн окупается за первые сутки.
Обратная сторона: вы зафиксировали качество учителя на момент разметки. Через полгода выйдет модель лучше, а ваш ученик останется прежним. И проверьте условия использования API поставщика: обучение конкурирующей модели на его выходах обычно запрещено, дистилляция для внутренних задач — обычно нет.
Рабочий код
Стек по умолчанию на 2026 год: transformers + peft + trl + bitsandbytes. Ниже — минимальный, но полноценный скрипт QLoRA-тюнинга.
"""QLoRA-дообучение 7B-модели на инструкционном датасете.
Требования: одна GPU от 16 ГБ. Проверено на transformers 4.5x / trl 0.2x."""
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig
BASE = "Qwen/Qwen2.5-7B-Instruct"
# 1. Квантизация базы в NF4 с двойной квантизацией.
# compute_dtype — тип, в который деквантуются блоки на forward.
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
tok = AutoTokenizer.from_pretrained(BASE)
model = AutoModelForCausalLM.from_pretrained(
BASE,
quantization_config=bnb,
dtype=torch.bfloat16,
device_map={"": 0}, # никакого auto: шардирование ломает checkpointing
attn_implementation="flash_attention_2",
)
model.config.use_cache = False # несовместимо с gradient checkpointing
# 2. Конфиг адаптера. all-linear — все линейные слои, кроме lm_head.
peft_config = LoraConfig(
r=32,
lora_alpha=64, # alpha = 2r — держим соотношение постоянным
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules="all-linear",
use_rslora=True, # alpha/sqrt(r) вместо alpha/r
)
# 3. Датасет в формате messages — TRL сам применит chat template токенизатора.
ds = load_dataset("json", data_files={
"train": "data/train.jsonl",
"test": "data/eval.jsonl",
})
args = SFTConfig(
output_dir="out/invoice-extractor",
num_train_epochs=2,
per_device_train_batch_size=4,
gradient_accumulation_steps=8, # эффективный батч = 32
learning_rate=2e-4, # для LoRA это норма, для полного FT — катастрофа
lr_scheduler_type="cosine",
warmup_ratio=0.03,
optim="paged_adamw_8bit", # paged: переживает пики на длинных примерах
bf16=True,
max_length=2048,
packing=False, # для инструкций — без упаковки
completion_only_loss=True, # лосс только на ответе ассистента
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_steps=100,
load_best_model_at_end=True, # берём лучший чекпоинт, а не последний
metric_for_best_model="eval_loss",
seed=42,
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=ds["train"],
eval_dataset=ds["test"],
peft_config=peft_config,
processing_class=tok,
)
trainer.train()
trainer.save_model() # сохранится только адаптер: десятки мегабайт
Тот же прогон в декларативном виде — axolotl, удобен, когда конфиг нужно версионировать и запускать из CI:
base_model: Qwen/Qwen2.5-7B-Instruct
load_in_4bit: true
adapter: qlora
lora_r: 32
lora_alpha: 64
lora_dropout: 0.05
lora_target_linear: true
datasets:
- path: data/train.jsonl
type: chat_template
field_messages: messages
val_set_size: 0.05
sequence_len: 2048
sample_packing: false
train_on_inputs: false # маскируем промпт
micro_batch_size: 4
gradient_accumulation_steps: 8
num_epochs: 2
optimizer: paged_adamw_8bit
lr_scheduler: cosine
learning_rate: 0.0002
warmup_ratio: 0.03
bf16: auto
gradient_checkpointing: true
flash_attention: true
save_total_limit: 3
Гиперпараметры: с чего начинать
| Параметр | Стартовое значение | Что делать, если плохо |
|---|---|---|
r |
16 для формата, 32–64 для стиля/домена | Недообучение → ×2. Переобучение → ÷2 и больше данных |
lora_alpha |
2 $r$ | Не крутите отдельно от $r$; при $r > 64$ включите use_rslora |
learning_rate |
1e-4 … 3e-4 (LoRA), 1e-5 … 2e-5 (полный FT) | Лосс скачет или NaN → ÷3. Лосс не падает → ×2 |
| Эпохи | 2–3 | 1 эпоха для 50k+ примеров; 4+ почти всегда переобучение |
| Эффективный батч | 16–64 | Меньше 8 — шумные градиенты; берите accumulation, а не большую карту |
lora_dropout |
0.05 | 0.1 при малом датасете, 0 при большом |
warmup_ratio |
0.03 | Всплеск лосса в начале → 0.1 |
max_grad_norm |
1.0 | Оставьте как есть, это спасает от единичных выбросов |
Learning rate — главный источник катастроф. LoRA терпит $2 \cdot 10^{-4}$ именно потому, что обновляет крошечное подпространство; тот же lr на полном файнтюне уничтожит модель за сотню шагов.
Оценка: где прячется деградация
Loss на валидации — это не метрика качества, это индикатор того, что обучение не разошлось. Реальную оценку строим по трём осям.
1. Целевая задача. Holdout, разделённый по источнику, с метрикой, которая соответствует задаче: exact match для извлечения полей, схема-валидность для JSON, LLM-as-judge для стиля (см. оценку и бенчмарки).
2. Катастрофическое забывание. Это главный скрытый риск. Luo et al., «An Empirical Study of Catastrophic Forgetting in LLMs During Continual Fine-tuning», arXiv:2308.08747 показывают, что деградация общих способностей растёт с размером модели и с агрессивностью тюнинга. Модель, идеально извлекающая поля из счетов, может разучиться считать или следовать системному промпту.
Минимальный регресс-набор, который стоит прогонять на каждом чекпоинте: 200 задач MMLU, 50 задач HumanEval (если код важен), 100 примеров вашего предыдущего продового поведения. Если общая метрика упала больше чем на 2–3 п.п. — снижайте lr, уменьшайте число эпох или подмешивайте 5–10 % общих инструкционных данных.
3. Безопасность. Qi et al., «Fine-tuning Aligned Language Models Compromises Safety, Even When Users Do Not Intend To!», arXiv:2310.03693 — тревожный и важный результат: дообучение на совершенно безобидных данных заметно ослабляет alignment-барьеры. Если ваша модель смотрит наружу, регресс по безопасности обязателен, а не желателен. Подробнее про поверхность атаки — в статье о безопасности.
Диагностика по кривым:
- Train падает, eval растёт — переобучение. Меньше эпох, больше dropout, больше данных.
- Оба стоят на месте — слишком маленький lr, слишком маленький $r$, или ваш датасет не содержит сигнала.
- Резкий скачок вверх — плохой пример в батче или слишком большой lr. Включите
max_grad_normи проверьте самые длинные примеры. - Eval хороший, прод плохой — почти наверняка расхождение chat template или утечка в сплите.
Жизненный цикл адаптера в продакшене
Адаптер — это артефакт с версией, а не файл в чьей-то домашней папке.
Что обязано лежать рядом с весами адаптера: хеш датасета, полный конфиг обучения, версия базовой модели (с точным ревизионным хешем с HF), метрики на holdout и на регресс-наборе, дата. Без этого через три месяца вы не сможете воспроизвести собственный результат.
Слить или не сливать
Два режима сервинга, и выбор между ними — архитектурное решение.
Merge. merged = base + (α/r)·BA записывается в веса, получается обычная модель. Плюсы: нулевой оверхед на инференсе, работает где угодно, спокойно квантуется дальше. Минусы: на каждый адаптер — полная копия модели.
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(BASE, dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "out/invoice-extractor").merge_and_unload()
merged.save_pretrained("out/invoice-extractor-merged")
⚠️ Не сливайте QLoRA-адаптер в 4-битную базу. Адаптер обучался против деквантованных весов; слияние в квантованную базу даёт ощутимую потерю качества. Правильный путь: загрузить базу в bf16, слить туда, при необходимости заново квантовать результат (GPTQ/AWQ).
Multi-LoRA. Держим одну базу в памяти и подгружаем десятки адаптеров, переключая их по запросу. Идея — Sheng et al., «S-LoRA: Serving Thousands of Concurrent LoRA Adapters», arXiv:2311.03285; в vLLM это штатная фича:
vllm serve Qwen/Qwen2.5-7B-Instruct \
--enable-lora \
--max-loras 8 \
--max-lora-rank 32 \
--lora-modules invoices=/models/invoice-extractor tone=/models/brand-tone
Дальше "model": "invoices" в теле запроса выбирает адаптер. Оверхед — единицы процентов латентности, зато один GPU обслуживает десятки задач или клиентов. Это правильная архитектура для мультитенантности: адаптер на клиента вместо модели на клиента.
Куда уходят деньги и время
Ориентиры для одного прогона (цены аренды GPU — порядок величины, они постоянно меняются):
| Сценарий | Железо | Время | Стоимость прогона |
|---|---|---|---|
| QLoRA 7B, 5k примеров, 3 эпохи | 1×RTX 4090 24 ГБ | 1,5–3 ч | ~1 $ |
| QLoRA 7B, 50k примеров, 2 эпохи | 1×A100 80 ГБ | 8–14 ч | 15 $–25 |
| LoRA 8B в bf16, 50k примеров | 1×H100 80 ГБ | 5–9 ч | 20 $–35 |
| QLoRA 70B, 20k примеров | 2×A100 80 ГБ | 30–50 ч | 120 $–200 |
| Полный FT 7B | 4×A100 80 ГБ + ZeRO-3 | 20–30 ч | 150 $–250 |
И честное распределение усилий проекта:
- Сбор, чистка, разметка датасета — 60–70 %
- Построение оценки и регресс-набора — 15–20 %
- Собственно обучение и подбор гиперпараметров — 10 %
- Сервинг и раскатка — 5–10 %
Если у вас наоборот — вы, скорее всего, перебираете ранги на грязных данных.
Дальше SFT: выравнивание по предпочтениям
Supervised fine-tuning учит модель воспроизводить один эталонный ответ. Иногда эталона нет, а есть суждение «этот ответ лучше того» — например, для тона поддержки или для качества саммари.
Классический путь — RLHF с обучением reward-модели и PPO — дорог и капризен. Rafailov et al., «Direct Preference Optimization», arXiv:2305.18290 показали, что оптимальная политика RLHF выражается в замкнутой форме через саму модель, и задачу можно свести к обычной классификационной функции потерь на парах «выбранный/отвергнутый» — без reward-модели и без RL-цикла. В TRL это DPOTrainer, и он тоже прекрасно работает поверх LoRA.
Более свежий вариант — ORPO (Hong et al., arXiv:2403.07691): объединяет SFT и выравнивание в один проход, добавляя к обычному лоссу штраф за отношение шансов отвергнутого ответа. Одна стадия вместо двух.
Практическая последовательность: сначала SFT, потом preference-тюнинг, и только если SFT-версия уже приемлема. DPO на плохой SFT-модели усилит её недостатки, а не исправит.
Типичные ошибки
- Файнтюн вместо RAG. Пытаться «залить знания» в веса. Модель выучит стиль документов и начнёт галлюцинировать в нём — это хуже, чем прямое незнание.
- Разные chat template при обучении и инференсе. Тихая, полная потеря эффекта дообучения. Всегда логируйте одну готовую строку с обеих сторон и сравнивайте побайтово.
- Случайный train/test split. Утечка через near-duplicates даёт eval-метрику, которая ничего не значит. Делите по источнику.
- Отсутствие регресса на общих способностях. Целевая метрика выросла, модель разучилась считать — узнаете из тикетов.
- Слишком много эпох. На 1000 примеров 10 эпох — это заучивание наизусть. Берите лучший чекпоинт по eval, а не последний.
- Адаптеры только на attention. Наследие первой статьи.
all-linearпочти бесплатен и заметно лучше. - Слияние QLoRA-адаптера в 4-битную базу. Тихая потеря качества после «успешного» обучения.
device_map="auto"при обучении. Годится для инференса, ломает gradient checkpointing и наивно шардирует модель. Явно{"": 0}.- Обучение с
use_cache=True. Конфликтует с checkpointing, съедает память, иногда молча ломает градиенты. - Нет версионирования артефакта. Через месяц никто не знает, какой адаптер сейчас в проде и на каких данных он обучен.
- Выбор базы по бенчмарку, а не по лицензии. Проверьте условия использования и коммерческие ограничения до того, как потратите месяц.
- Файнтюн там, где хватило бы structured output. Жёсткий JSON проще получить через constrained decoding, чем через обучение.
Мини-итог
Дообучение — это способ впечатать поведение в веса, чтобы не платить за него токенами в каждом запросе. Оно отлично работает для формата, стиля, узкой классификации и дистилляции; оно плохо работает для фактов и нового домена.
Чеклист перед запуском первого прогона:
- Промпт с few-shot проверен на 20+ примерах и систематически не справляется.
- Задача — про поведение и форму, а не про свежие факты (иначе — RAG).
- Собрано минимум несколько сотен чистых пар «вход → эталонный выход».
- Датасет дедуплицирован и разделён по источнику, а не случайно.
- Chat template берётся из токенизатора базовой модели и одинаков в train и в проде.
- Лосс маскирован на промпте (
completion_only_loss). - Есть holdout с метрикой задачи и регресс-набор общих способностей.
- Для внешних систем есть регресс по безопасности.
- Адаптеры на всех линейных слоях, $\alpha = 2r$, lr в диапазоне 1e-4…3e-4.
- Лучший чекпоинт выбирается по eval, а не берётся последний.
- Артефакт версионирован: хеш данных, конфиг, ревизия базовой модели, метрики.
- Решено, как сервить: merge в bf16 или multi-LoRA через vLLM.
- Посчитано, за сколько запросов файнтюн окупает разметку и обучение.
И контрольный вопрос на каждом ревью: «что именно эта модель теперь умеет такого, чего нельзя было получить промптом на 200 токенов?» Если ответа нет — вы дообучали зря.
Источники
- Hu et al., «LoRA: Low-Rank Adaptation of Large Language Models», arXiv:2106.09685 — исходная работа, разложение и мотивация.
- Dettmers et al., «QLoRA», arXiv:2305.14314 — NF4, двойная квантизация, paged optimizers.
- Aghajanyan et al., «Intrinsic Dimensionality», arXiv:2012.13255 — почему низкий ранг вообще достаточен.
- Biderman et al., «LoRA Learns Less and Forgets Less», arXiv:2405.09673 — честные границы применимости.
- Kalajdzievski, «Rank-Stabilized LoRA», arXiv:2312.03732 и Hayou et al., «LoRA+», arXiv:2402.12354 — доводка скейлинга и learning rate.
- Liu et al., «DoRA», arXiv:2402.09353 — разложение на величину и направление.
- Zhou et al., «LIMA», arXiv:2305.11206 — качество данных против количества.
- Ovadia et al., «Fine-Tuning or Retrieval?», arXiv:2312.05934 — почему знания заливают через RAG.
- Luo et al., «Catastrophic Forgetting in LLMs», arXiv:2308.08747 — измерение деградации.
- Qi et al., «Fine-tuning Compromises Safety», arXiv:2310.03693 — ослабление alignment на безобидных данных.
- Rafailov et al., «DPO», arXiv:2305.18290 и Hong et al., «ORPO», arXiv:2403.07691 — выравнивание по предпочтениям.
- Sheng et al., «S-LoRA», arXiv:2311.03285 — сервинг тысяч адаптеров.
- Документация: PEFT, TRL, bitsandbytes, vLLM LoRA, axolotl, Unsloth.
Что дальше
Модель дообучена, адаптер собран, метрики зелёные — и теперь начинается самая долгая часть жизни системы: она должна работать под нагрузкой, укладываться в бюджет и быть наблюдаемой. Дальше — ИИ в продакшене: латентность, кэширование, роутинг моделей, стоимость, наблюдаемость.