Перейти к содержимому
Ступень 66.1 Supervised Fine-Tuning

SFT — данные, шаблоны, маскирование

Как превратить диалоги в обучающие примеры: chat-шаблон ChatML, маска loss только на ответах ассистента, выбор и синтез данных — и всё это на маленькой модели своими руками.

УРОКstage-6/6-1-sft/02-sft.mdx1 ч 40 минУрок 2 из 2
После урока вы сможете
  • Превратить диалог в обучающий пример — строка ChatML, токены и labels с −100-100
  • Построить маску loss «только на ответах ассистента» и объяснить, что ломается без неё
  • Выбирать и собирать данные для SFT — качество против количества (LIMA), синтетика, смеси

SFTобучение с учителем на инструкциях (SFT)EN supervised fine-tuning (SFT) (supervised fine-tuning) — первый и самый массовый этап пост-трейнинга. Функция потерь та же, что на претрейне: cross-entropy следующего токена. Всё новое — в данных и в том, по каким токенам считать loss.

Ошибки здесь коварны: они не вызывают исключений. Чужой chat-шаблон, забытый токен конца реплики или loss на репликах пользователя — и модель «как-то обучается». Но потом она путает роли, не останавливается или пишет за собеседника. В этом уроке вы разберёте каждый шаг от JSON-диалога до числа loss, соберёте маску в браузере и увидите на маленькой модели, что ломается без неё.

flowchart LR
J["JSON: messages"] --> T["chat-шаблон<br/>(ChatML)"]
T --> K["токены"]
K --> M["маска: 1 на ответах<br/>ассистента"]
M --> L["labels:<br/>id или -100"]
L --> CE["cross-entropy<br/>только по ответам"]
Путь обучающего примера SFT

Де-факто стандарт — список сообщений с ролями в стиле OpenAI API. Так хранят данные LLaMA-Factory (формат «OpenAI»), MiniMind (поле conversations) и большинство датасетов на Hugging Face:

{"messages": [
{"role": "system", "content": "Ты — вежливый ассистент."},
{"role": "user", "content": "Как по-английски «спасибо»?"},
{"role": "assistant", "content": "Thank you."}
]}

Роли: system — правила поведения (системный промптсистемный промптEN system promptСообщение с ролью system — правила для модели (роль, тон, формат). Технически это просто первый блок текста в chat-шаблоне.), user — реплики человека, assistant — то, чему учим модель, tool — результаты вызова инструментов (модуль 8.3). Многоходовой диалог — просто более длинный список.

Модель видит не JSON, а одну строку токенов. Как её собрать, решает chat-шаблонchat-шаблонEN chat templateПравило (обычно Jinja в tokenizer_config.json), которое превращает список сообщений с ролями в одну строку со спецтокенами. — вы разбирали шаблоны Qwen, Llama 3 и DeepSeek-V3 в уроке 4.2.3. У Qwen это формат ChatML:

<|im_start|>system
Ты — вежливый ассистент.<|im_end|>
<|im_start|>user
Как по-английски «спасибо»?<|im_end|>
<|im_start|>assistant
Thank you.<|im_end|>

<|im_start|> (id 151644) и <|im_end|> (id 151645) — спецтокеныспециальный токен (спецтокен)EN special tokenУправляющий токен, а не кусок текста — начало/конец текста (BOS/EOS), заполнитель для батча (PAD), разметка ролей в чате (например, <|im_start|> у Qwen)., каждый занимает ровно один токен. Для SFT важны четыре следствия.

  1. Шаблон обучения = шаблон инференса. README LLaMA-Factory прямо предупреждает: используйте один и тот же шаблон при обучении и при запуске. Иначе модель увидит разметку, которой не училась.
  2. Граница «промпт | ответ» — это add_generation_prompt. На инференсе шаблон заканчивается строкой <|im_start|>assistant\n, и модель продолжает с этого места. При обучении всё до этой строки включительно — контекст, а после неё — то, что модель должна сгенерировать.
  3. Шаблон может добавить текст, которого нет в данных. Шаблон Qwen2.5 вставляет системный промпт по умолчанию «You are Qwen, created by Alibaba Cloud. You are a helpful assistant.», если в диалоге нет своего system. В шаблоне гибридной Qwen3 ответ без рассуждений содержит пустой блок <think>\n\n</think>, а с enable_thinking=False этот блок переезжает в промпт — и выпадает из loss. Смотрите на отрендеренную строку, а не на JSON.
  4. Спецтокены должны быть настоящими. Если вы придумали свои маркеры, добавьте их в токенизатор как special tokens и увеличьте матрицу эмбеддингов (model.resize_token_embeddings(len(tok))). Иначе <|im_end|> развалится на несколько обычных токенов, и надёжной остановки не будет.

Обучать модель предсказывать реплики пользователя и системный промпт незачем: их на инференсе пишет не она. Поэтому loss считают только по позициям ответов ассистента — для этого строят маску lossмаска lossEN loss maskВ SFT loss считают только по токенам ответа ассистента; остальные позиции получают метку -100 (ignore_index в PyTorch).. Обозначим AA — множество позиций токенов ответов (вместе с закрывающим <|im_end|>). Тогда

LSFT(θ)=−1∣A∣∑t∈Alog⁡pθ(xt∣x<t)\mathcal{L}_{\text{SFT}}(\theta) = -\frac{1}{|A|} \sum_{t \in A} \log p_\theta(x_t \mid x_{<t})

Контекст при этом виден целиком: модель читает промпт, но отвечает за правильность только ответа. Вот как это выглядит по токенам (условно, токен = слово):

ПозицияТокенМаскаlabel
0–2<|im_start|> user \n0−100-100
3–5Сколько будет 2+2?0−100-100
6–7<|im_end|> \n0−100-100
8–10<|im_start|> assistant \n0−100-100
1141id токена 4
12<|im_end|>1id <|im_end|>
  • labels = −100-100 там, где маска 0. Это ignore_index по умолчанию в F.cross_entropy и в моделях transformers.
  • Сдвиг на один токен. Позиция tt предсказывает токен t+1t+1. Модели transformers сдвигают labels сами: передайте labels той же длины, что input_ids. В своём цикле сдвигайте явно: logits[:, :-1] против labels[:, 1:].
  • <|im_end|> ответа входит в loss. Иначе модель не научится останавливаться. MiniMind включает в loss ещё и перевод строки после него (так же получится в практикуме 6.2.2). Это безвредно: генерация всё равно останавливается на <|im_end|>, а \n после него всегда один и тот же. В задании ниже придерживаемся строгого варианта — без \n.
  • Многоходовые диалоги. По умолчанию учат все ответы ассистента. В LLaMA-Factory флаг mask_history: true оставляет в loss только последний ответ, а train_on_prompt: true, наоборот, считает loss и по промптам.
  • Паддинг. Позиции паддинга получают label −100-100 и attention_mask = 0.
  1. Поиск маркеров. Пройти по id токенов, найти последовательность «<|im_start|> assistant \n» и открыть маску до ближайшего <|im_end|>. Так делает MiniMind; так же устроено задание ниже. Работает для любого числа реплик, но привязано к конкретному шаблону.
  2. Префикс. Отрендерить промпт с add_generation_prompt=True и полный диалог, затем закрыть первые len(prompt_ids) позиций. Удобно для одного ответа, работает с любым шаблоном. Но проверьте, что токены промпта совпадают с началом токенов диалога: BPE иногда сливает символы на стыке. Этот способ мы используем в практикуме 6.2.2.

Без маски модель учится всему тексту, включая реплики пользователя. Следствия:

  • Модель учится говорить за пользователя. После своего ответа она охотно начинает <|im_start|>user и сочиняет следующий вопрос. В практике ниже это видно в 100% случаев.
  • Loss перестаёт что-то значить. Реплики пользователя непредсказуемы, их токены дают большой вклад в loss. Число падает медленно, хотя ответы уже хорошие.
  • Длинный промпт съедает сигнал. Пусть в промпт вставлен документ на 3000 токенов, а ответ по нему — 100 токенов (так устроены диалоги с поиском по документам, RAG — модуль 8.2). Без маски 3000/3100≈97%3000 / 3100 \approx 97\% слагаемых loss уходят на моделирование документа, а не на ответ.

Пусть в батче два примера: у A ответ из 2 токенов, у каждого loss 3.0; у B — 8 токенов по 0.5.

  • Среднее по токенам: (2⋅3.0+8⋅0.5)/10=1.0(2 \cdot 3.0 + 8 \cdot 0.5) / 10 = 1.0. Длинные ответы весят больше.
  • Среднее по примерам: (3.0+0.5)/2=1.75(3.0 + 0.5) / 2 = 1.75. Каждый диалог весит одинаково.

Обычно используют первый вариант. Ловушка — накопление градиентов. Если A и B — разные микробатчи и каждый усредняется по своим токенам, а потом результаты усредняются ещё раз, получится 1.75, а не 1.0: цель обучения тихо меняется. Осенью 2024 года такую ошибку нашли в популярных библиотеках обучения, в том числе в Trainer из transformers. Правильно — делить сумму loss на общее число обучаемых токенов во всём батче.

Короткие примеры в батче дополняются паддингом до длины самого длинного, и заметная часть вычислений уходит впустую. Упаковка последовательностейупаковка последовательностейEN sequence packingДокументы склеивают в один поток токенов через разделитель и режут на окна фиксированной длины — без паддинга. склеивает несколько примеров в одну последовательность нужной длины. Подвох: без доработки токены одного примера «видят» через attention соседний пример. Для SFT это нежелательно, поэтому используют маски внимания по документам или ядра переменной длины. В LLaMA-Factory такой режим включается флагом neat_packing — упаковка без перекрёстного внимания.

LIMA (Meta, 2023). Авторы дообучили LLaMA на 65B всего на 1000 примеров. Часть отобрали с Stack Exchange, wikiHow и Reddit, часть написали сами, следя за разнообразием тем и единым стилем ответа. В слепом сравнении люди считали ответы LIMA не хуже, чем у GPT-4, в 43% случаев; против Bard — в 58%, против text-davinci-003 — в 65%. Абляции показали: увеличение объёма однотипных данных почти не помогает, а разнообразие и качество — помогают. Отсюда гипотеза поверхностного выравниваниягипотеза поверхностного выравниванияEN superficial alignment hypothesisИз статьи LIMA (2023): знания модель получает на претрейне, а выравнивание в основном учит формату и стилю общения..

Но 1000 примеров достаточно для стиля, а не для новых навыков. Для рассуждений DeepSeek-R1 собрала около 800 тыс. примеров, а SFT DeepSeek-V3 — около 1.5 млн примеров из разных доменов.

ИсточникПримерПлюсыМинусы
Пишут людиInstructGPT (~13k промптов), LIMA (1000)качество, контроль стилядорого и медленно
Модель сама генерирует инструкцииSelf-Instruct: 175 задач-затравок → Alpaca, 52k примеровдёшево, многооднообразие и ошибки учителя
Дистилляция сильной моделиответы R1 для SFT в V3 и R1-Distillпереносит сильные навыкиошибки учителя, условия его лицензии
Rejection samplingэтап 3 у R1: из многих ответов оставляют проверенныетолько верные ответынужен верификатор
Шаблонная генерациядатасет заказов в практикуме 6.2.2точная автоматическая оценкаузкая задача

Почти всё, кроме первой строки, — синтетические данныесинтетические данныеEN synthetic data. Они дешёвые, но у них два риска. Первый — модель перенимает ошибки и однообразие учителя; при многократном обучении на собственных генерациях это приводит к коллапсу моделиколлапс моделиEN model collapseДеградация моделей, которые поколение за поколением учатся на текстах, сгенерированных предыдущими моделями.. Второй — юридический: условия некоторых API запрещают обучать на их ответах конкурирующие модели. Проверяйте лицензию учителя: например, лицензия DeepSeek-R1 прямо разрешает дистилляцию в другие модели.

  1. Разнообразие. Задачи, темы, длины ответов, языки. 1000 разных примеров лучше 10 000 похожих.
  2. Единый формат ответа. Один стиль Markdown, одинаковое оформление кода и формул.
  3. ДедупликациядедупликацияEN deduplication и очистка от примеров из тестовых бенчмарков (загрязнение данныхзагрязнение данных (утечка бенчмарка в обучение)EN data contaminationЗадачи из тестового бенчмарка попали в обучающий корпус LLM — оценка становится завышенной. Родственное явление — «шуабан», подгонка модели под лидерборд.).
  4. Язык. Для русскоязычного ассистента нужны русские диалоги. Готовых наборов меньше, чем английских и китайских; их часто получают переводом или генерацией сильной моделью — выборочно читайте результат глазами.
  5. Смесь с общими данными. Узкий датасет без общих диалогов ведёт к катастрофическому забываниюкатастрофическое забываниеEN catastrophic forgettingПри дообучении на узкой задаче модель теряет ранее освоенные навыки. — подробно в уроке 6.2.2.

Реализуйте сборку ChatML, маску «только ответы ассистента», labels и усреднение loss. Токенизатор уже готов: спецтокены, \n и слова — отдельные токены.

Маска loss для SFT8 тестов
import re

IM_START = "<|im_start|>"
IM_END = "<|im_end|>"
IGNORE_INDEX = -100  # так PyTorch помечает позиции, которые не входят в loss


def tokenize(text):
    """Игрушечный токенизатор (уже готов, менять не нужно).

    Спецтокены, перевод строки и «слова» становятся отдельными токенами, пробелы выбрасываются:
    tokenize("<|im_start|>user\\nПривет, мир<|im_end|>\\n")
    -> ['<|im_start|>', 'user', '\\n', 'Привет,', 'мир', '<|im_end|>', '\\n']
    """
    return re.findall(r"<\|im_start\|>|<\|im_end\|>|\n|[^\s<]+", text)


def render_chatml(messages, add_generation_prompt=False):
    """Склеивает диалог в строку формата ChatML (как у Qwen, без системного промпта по умолчанию).

    Каждое сообщение: "<|im_start|>{role}\\n{content}<|im_end|>\\n".
    Если add_generation_prompt=True, в конце добавляется "<|im_start|>assistant\\n" —
    приглашение модели начать ответ.
    """
    # TODO: ваш код
    ...


def build_loss_mask(tokens):
    """Маска loss: 1 — токен входит в loss, 0 — не входит.

    Единицы ставятся только на содержимое ответов ассистента и на закрывающий <|im_end|>
    каждого такого ответа. Заголовок "<|im_start|>", "assistant", "\\n" — нули.
    Всё, что написали system и user, — нули. Ответов ассистента может быть несколько.
    """
    # TODO: ваш код
    ...


def build_labels(ids, mask):
    """labels для обучения: id токена там, где mask == 1, и IGNORE_INDEX (-100) там, где mask == 0."""
    # TODO: ваш код
    ...


def masked_nll(token_logprobs, labels):
    """Средний loss SFT: среднее -log p только по позициям, где labels != IGNORE_INDEX.

    token_logprobs[t] = log p(x_t | x_<t) — сколько модель «поставила» на настоящий токен.
    Если обучаемых позиций нет, верните 0.0 (а не делите на ноль).
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
dialog = [
    {"role": "user", "content": "Сколько будет 2+2?"},
    {"role": "assistant", "content": "4"},
]
text = render_chatml(dialog)
print(repr(text))
if text:
    toks = tokenize(text)
    print(list(zip(toks, build_loss_mask(toks) or [])))

Обучим крошечный трансформер (~100 тыс. параметров) на диалогах с двумя командами: «переверни» и «отсортируй» последовательность цифр. В диалоге одна или две пары реплик. Один и тот же код запускается дважды: с маской и с loss на всех токенах. Нужен только PyTorch; на CPU работает около минуты.

import random
import torch
import torch.nn as nn
import torch.nn.functional as F
IM_START, IM_END = "<|im_start|>", "<|im_end|>"
DIGITS = [str(d) for d in range(10)]
VOCAB = [IM_START, IM_END, "\n", "user", "assistant", "переверни", "отсортируй", ":"] + DIGITS
STOI = {t: i for i, t in enumerate(VOCAB)}
def make_turn(rng):
"""Одна реплика пользователя и ответ: перевернуть или отсортировать 3–6 цифр."""
cmd = rng.choice(["переверни", "отсортируй"])
xs = [rng.choice(DIGITS) for _ in range(rng.randint(3, 6))]
ys = xs[::-1] if cmd == "переверни" else sorted(xs)
user = [IM_START, "user", "\n", cmd, ":", *xs, IM_END, "\n"]
head = [IM_START, "assistant", "\n"]
return user + head, ys + [IM_END]
def make_dialog(rng):
"""Диалог из 1–2 обменов репликами -> (токены, маска «это ответ ассистента»)."""
tokens, mask = [], []
for k in range(rng.randint(1, 2)):
if k > 0:
tokens.append("\n")
mask.append(0)
prompt, answer = make_turn(rng)
tokens += prompt + answer
mask += [0] * len(prompt) + [1] * len(answer)
return tokens, mask
def batch(rng, n, masked):
rows = [make_dialog(rng) for _ in range(n)]
T = max(len(t) for t, _ in rows)
x = torch.zeros(n, T, dtype=torch.long)
y = torch.full((n, T), -100) # паддинг тоже не входит в loss
for i, (toks, mask) in enumerate(rows):
ids = torch.tensor([STOI[t] for t in toks])
keep = torch.tensor(mask, dtype=torch.bool) if masked else torch.ones(len(ids), dtype=torch.bool)
x[i, : len(ids)] = ids
y[i, : len(ids)] = torch.where(keep, ids, torch.tensor(-100)) # там, где keep=False, label = -100
return x, y
class TinyGPT(nn.Module):
def __init__(self, vocab, d=64, n_layers=2, n_heads=4, max_len=48):
super().__init__()
self.tok = nn.Embedding(vocab, d)
self.pos = nn.Embedding(max_len, d)
# встроенный блок трансформера PyTorch (внимание + FFN); norm_first=True — Pre-Norm, как в уроке 4.5.1.
# Название «Encoder» не мешает: с причинной маской ниже он работает как блок GPT
layer = nn.TransformerEncoderLayer(d, n_heads, 4 * d, dropout=0.0, batch_first=True, norm_first=True)
self.blocks = nn.TransformerEncoder(layer, n_layers, enable_nested_tensor=False)
self.head = nn.Linear(d, vocab)
def forward(self, idx):
T = idx.shape[1]
h = self.tok(idx) + self.pos(torch.arange(T))
causal = nn.Transformer.generate_square_subsequent_mask(T) # причинная маска (урок 4.4.2): −∞ над диагональю
return self.head(self.blocks(h, mask=causal, is_causal=True))
def sft_loss(model, x, y):
logits = model(x)
# Сдвиг на один: позиция t предсказывает токен t+1
return F.cross_entropy(logits[:, :-1].reshape(-1, len(VOCAB)), y[:, 1:].reshape(-1), ignore_index=-100)
@torch.no_grad()
def greedy(model, tokens, n):
ids = [STOI[t] for t in tokens]
out = []
for _ in range(n):
nxt = model(torch.tensor([ids]))[0, -1].argmax().item()
ids.append(nxt)
out.append(VOCAB[nxt])
if VOCAB[nxt] == IM_END:
break
return out
def evaluate(model, n=200):
rng = random.Random(123) # одни и те же тестовые задачи для обеих моделей
ok = speaks_for_user = 0
for _ in range(n):
prompt, answer = make_turn(rng)
out = greedy(model, prompt, 8)
ok += out == answer
# Что модель напишет, если не остановить её после ответа?
cont = greedy(model, prompt + answer + ["\n"], 2)
speaks_for_user += cont == [IM_START, "user"]
return ok / n, speaks_for_user / n
torch.set_num_threads(1)
for masked in [True, False]:
torch.manual_seed(0)
model = TinyGPT(len(VOCAB))
opt = torch.optim.AdamW(model.parameters(), lr=3e-3)
rng = random.Random(0)
for step in range(1, 301):
x, y = batch(rng, 64, masked)
loss = sft_loss(model, x, y)
opt.zero_grad()
loss.backward()
opt.step()
xa, ya = batch(random.Random(7), 256, masked=True) # loss только на ответах — для честного сравнения
with torch.no_grad():
ans_loss = sft_loss(model, xa, ya).item()
acc, sfu = evaluate(model)
print(f"маска={'да ' if masked else 'нет'} | train loss {loss.item():.3f} | loss на ответах {ans_loss:.3f}"
f" | точность {acc:.0%} | пишет за пользователя {sfu:.0%}")
prompt, answer = make_turn(random.Random(5))
print("Пример продолжения без маски:", " ".join(greedy(model, prompt + answer + ["\n"], 8)).replace("\n", "\\n"))
маска=да | train loss 0.062 | loss на ответах 0.107 | точность 88% | пишет за пользователя 0%
маска=нет | train loss 0.676 | loss на ответах 0.199 | точность 78% | пишет за пользователя 100%
Пример продолжения без маски: <|im_start|> user \n переверни : 3 2 2

Как читать результат (на другой версии PyTorch числа могут немного отличаться):

  • Train loss без маски в 10 раз выше (0.676 против 0.062), хотя loss на ответах различается лишь вдвое. Train loss без маски почти целиком состоит из непредсказуемых цифр пользователя. Сравнивать модели можно только по loss на ответах.
  • При равном числе шагов модель с маской точнее (88% против 78%): весь градиент идёт в ответы.
  • Без маски модель в 100% случаев продолжает диалог за пользователя. Если движок генерации не остановится на <|im_end|>, такая модель сама задаст себе следующий вопрос. Модель с маской этого не умеет: продолжение после ответа никогда не попадало в loss.

Поэкспериментируйте: уберите IM_END из ответа (return user + head, ys) и посмотрите на продолжения. Точность при этом упадёт почти до нуля: greedy больше не встречает <|im_end|> и пишет до лимита в 8 токенов — это и есть «модель не останавливается». Увеличьте число шагов до 1000: догонит ли модель без маски по точности?

Проверь себя6 вопросов
1 Одноходовой диалог в ChatML: одна реплика пользователя и один ответ. Какие токены получают label, отличный от −100-100?Можно выбрать несколько ответов
2 Вы забыли включить <|im_end|> в loss. Что, скорее всего, случится на инференсе?
3 В батче два примера: 2 токена ответа с loss 3.0 и 8 токенов с loss 0.5. Чему равен loss, усреднённый по токенам?
4 Сколько обучающих примеров использовала LIMA для SFT модели на 65B?
5 В практике модель без маски в 100% случаев после ответа начинает <|im_start|> user. Почему?
6 Чем опасна упаковка (packing) нескольких примеров в одну последовательность без доработки?
  • SFT — та же cross-entropy, но по диалогам в chat-шаблоне и только на ответах ассистента вместе с токеном конца реплики.
  • labels = −100-100 скрывают позиции от loss; transformers сдвигает labels сам, в своём коде — logits[:, :-1] против labels[:, 1:].
  • Без маски модель учится говорить за пользователя, а loss теряет смысл. При накоплении градиентов делите на общее число обучаемых токенов.
  • Данные: качество и разнообразие важнее объёма (LIMA — 1000 примеров), но для новых навыков нужны сотни тысяч примеров — обычно синтетика от сильной модели.

Дальше: полный SFT 7B-модели требует больше 100 ГБ памяти. LoRA и QLoRA позволяют дообучать её на одной видеокарте.