Большие языковые модели: предобучение, дообучение, инференс
В предыдущей статье мы разобрали механизм внимания и архитектуру трансформера. Трансформер — это кирпич. Эта статья про то, что произошло, когда из этого кирпича сложили здание в сотни миллиардов параметров и скормили ему заметную часть интернета: появился класс систем, которые решают задачи, под которые их никто не обучал.
Ключевой тезис, вокруг которого всё крутится:
LLM не «понимает язык» и не «обучена отвечать на вопросы». Она обучена ровно одной задаче — предсказать распределение следующего токена. Всё остальное — перевод, суммаризация, код, рассуждение — оказалось побочным продуктом того, что для хорошего предсказания следующего токена приходится выучить структуру мира, описанного текстом.
Это не мистика, а следствие простого факта: чтобы правильно продолжить фразу «Столица Австралии — », нужно знать, что это Канберра. Сжатие текста и знание о мире — одна и та же задача. Отсюда и знаменитая формулировка: compression is intelligence.
1. Постановка задачи: языковое моделирование
Формально модель задаёт распределение над последовательностями токенов через цепное правило вероятностей:
$$ P(x_1, \dots, x_n) = \prod_{t=1}^{n} P(x_t \mid x_{<t}; \theta) $$
Обучение — максимизация правдоподобия обучающего корпуса, то есть минимизация кросс-энтропии:
$$ \mathcal{L}(\theta) = -\frac{1}{n}\sum_{t=1}^{n} \log P(x_t \mid x_{<t}; \theta) $$
Три важных следствия, которые часто упускают:
- Разметка не нужна. Метка для позиции
t— это токен на позицииt+1, он уже есть в тексте. Это self-supervised learning: сигнал бесплатный, поэтому масштабируется до триллионов токенов. Классическому ML такое даже не снилось. - Обучение параллельно, генерация — нет. Благодаря каузальной маске все
nпозиций считаются за один проход (teacher forcing: на вход всегда идёт настоящий префикс, а не то, что модель нагенерировала). А вот на инференсе токены рождаются строго по одному — именно эта асимметрия определит всю экономику раздела 8. - Перплексия = exp(loss). Перплексия 8 означает «модель в среднем колеблется между 8 равновероятными вариантами». Это единственная метрика, которая честно измеряет предобучение; всё остальное — прокси.
2. Токенизация: почему модель не видит буквы
Модель работает не с символами и не со словами, а с токенами — кусочками текста из фиксированного словаря (обычно 32k–256k элементов). Компромисс очевиден: словарь из символов даёт слишком длинные последовательности (а внимание квадратично по длине), словарь из слов — бесконечен и ломается на опечатках и морфологии.
Промышленный стандарт — BPE (byte-pair encoding, Sennrich et al., 2015) и его вариант byte-level BPE. Алгоритм жадный: начинаем с байтов и итеративно склеиваем самую частую пару соседних единиц.
from collections import Counter
def learn_bpe(corpus: dict[str, int], num_merges: int) -> list[tuple[str, str]]:
"""corpus: {"низкий": 5, ...} — слово и его частота в тексте.
Сложность наивной версии: O(num_merges * V * L) — на каждом слиянии пересчитываем
все пары. Продакшн-реализации (SentencePiece, tiktoken) держат индекс позиций пар
и приоритетную очередь, что даёт примерно O(N log N).
"""
vocab = {tuple(list(w) + ["</w>"]): f for w, f in corpus.items()} # </w> — конец слова
merges = []
for _ in range(num_merges):
pairs = Counter() # 1. частоты соседних пар
for syms, freq in vocab.items():
for i in range(len(syms) - 1):
pairs[(syms[i], syms[i + 1])] += freq
if not pairs:
break
best = max(pairs, key=pairs.get) # 2. самая частая -> новый токен
merges.append(best)
new_vocab = {} # 3. применяем слияние
for syms, freq in vocab.items():
out, i = [], 0
while i < len(syms):
if i + 1 < len(syms) and (syms[i], syms[i + 1]) == best:
out.append(syms[i] + syms[i + 1]); i += 2
else:
out.append(syms[i]); i += 1
new_vocab[tuple(out)] = freq
vocab = new_vocab
return merges
for a, b in learn_bpe({"низкий": 5, "низший": 2, "новый": 6, "нижний": 3}, num_merges=8):
print(f"склеили: {a!r} + {b!r} -> {a + b!r}")
Что из этого важно на практике:
- Токенизация несправедлива к языкам. Английский текст кодируется примерно 1 токеном на 4 символа, русский — 1 токеном на 2–2.5 символа (для GPT-подобных словарей). Один и тот же текст на русском стоит в 1.5–2 раза дороже и быстрее съедает контекстное окно. Это прямая статья бюджета, а не абстракция.
- Модель плохо считает буквы. Классический вопрос «сколько букв r в слове strawberry» сложен не из-за «тупости»: модель видит 3–4 непрозрачных токена, а не 10 букв. По этой же причине страдает арифметика — числа режутся на куски нерегулярно.
- Токенизатор — часть контракта модели. Дообучили модель, а на инференсе подсунули чужой токенизатор — получите шум. Всегда грузите токенизатор из того же чекпоинта.
- Артефакты словаря. Редкие токены, встретившиеся при обучении токенизатора, но почти не встретившиеся при обучении модели («glitch tokens»), дают непредсказуемое поведение.
3. Архитектура современной LLM
Практически все актуальные LLM — decoder-only: стек одинаковых блоков с каузальным self-attention. Encoder-decoder остался в нишах (перевод, T5-подобные), encoder-only (BERT) — в задачах классификации и поиска, о чём мы говорим в статье об эмбеддингах.
С 2017 года в базовый блок внесли четыре изменения, и каждое стоит понимать:
| Компонент | Было (Transformer 2017) | Стало | Зачем |
|---|---|---|---|
| Норма | Post-LN, после residual | Pre-LN + RMSNorm | Post-LN на глубине >20 слоёв требует warmup и всё равно расходится. Pre-LN даёт чистый residual-путь для градиента. RMSNorm убирает вычитание среднего — дешевле, качество то же (Zhang & Sennrich, 2019) |
| Позиции | Абсолютные синусоиды | RoPE | Поворот q и k на угол, пропорциональный позиции. Скалярное произведение начинает зависеть от разности позиций — относительная кодировка «бесплатно», плюс возможность растянуть контекст (Su et al., 2021) |
| FFN | ReLU, 4d | SwiGLU, ~8d/3 | Гейтинг даёт лучший лосс при том же бюджете FLOPs (Shazeer, 2020) |
| Внимание | MHA, K/V на каждую голову | GQA / MQA | Головы делят общие K/V. Кэш ужимается в 4–8 раз почти без потери качества (Ainslie et al., 2023) |
GQA — самое «продуктовое» из этих изменений: оно не про качество, а про то, чтобы модель влезла в память при длинном контексте. Держите в голове связку «архитектурное решение → размер KV-кэша → стоимость обслуживания».
Распределение параметров. Полезно уметь считать в уме. Для модели с d = hidden size,
L слоёв: attention даёт ~4d² на слой (проекции Q, K, V, O), FFN со SwiGLU — ~8d²
(три матрицы по d × 8d/3). Итого ~12 d² L плюс эмбеддинги V·d. Для Llama-3-8B:
12 · 4096² · 32 ≈ 6.4·10⁹, плюс эмбеддинги и голова 2 · 128256 · 4096 ≈ 1.05·10⁹ —
итого те самые ~8 млрд. Эта прикидка полезнее, чем кажется: она сразу даёт и объём весов
в памяти (×2 байта в bf16), и FLOPs прохода (≈ 2N на токен).
4. Предобучение: данные, бюджет, законы масштабирования
4.1 Данные решают больше, чем архитектура
Архитектурные различия между конкурирующими моделями одного размера — это единицы процентов качества. Разница в данных — десятки процентов. Типичный пайплайн подготовки корпуса:
- Сбор и извлечение текста: веб-краулы (Common Crawl), код, книги, статьи. Парсинг HTML — недооценённый этап: мусорная разметка отравляет корпус.
- Фильтрация качества: эвристики (доля букв, длина строк, «lorem ipsum») плюс классификатор «похоже на качественный текст».
- Дедупликация — критично. Точные и near-duplicate документы (MinHash/LSH) удаляются: дубли ведут к запоминанию наизусть, утечке тестов и пустой трате бюджета.
- Микс доменов: код улучшает рассуждение даже на текстовых задачах, поэтому его добавляют осознанно; пропорции подбираются экспериментами на малых моделях.
- Деконтаминация: выкидываем пересечения с бенчмарками, иначе метрики врут.
4.2 Законы масштабирования
Kaplan et al., 2020 показали, что потери падают по степенному закону от числа параметров, данных и компьюта — предсказуемо, на много порядков. Это превратило обучение LLM из алхимии в инженерию: можно обучить несколько маленьких моделей, подогнать кривую и предсказать качество большой до того, как потратить миллионы долларов.
Hoffmann et al., 2022 (Chinchilla) исправили существенную ошибку в исходной работе: при фиксированном бюджете компьюта модели тех лет были сильно недообучены. Оптимум — примерно 20 токенов на параметр, то есть параметры и данные надо масштабировать пропорционально. Chinchilla на 70B побила Gopher на 280B, потратив тот же компьют.
Практическая формула бюджета:
$$ C \approx 6ND $$
где C — FLOPs, N — параметры, D — токены обучения. Коэффициент 6 = 2 (forward) + 4
(backward, он примерно вдвое дороже прямого прохода).
def training_budget(n_params: float, n_tokens: float, gpu_tflops: float, mfu: float = 0.4):
"""MFU (model FLOPs utilization) 0.35–0.5 — реалистичный диапазон для больших
кластеров. Ниже 0.3 — почти всегда упор в коммуникацию или в загрузку данных."""
gpu_seconds = (6 * n_params * n_tokens) / (gpu_tflops * 1e12 * mfu)
return (f"{gpu_seconds / 3600:,.0f} GPU-часов, "
f"{gpu_seconds / 3600 / 24 / 1024:.1f} дней на 1024 GPU")
# 8B параметров, 15T токенов — соотношение сильно выше Chinchilla, и это сознательно:
# переобучение маленькой модели удешевляет ИНФЕРЕНС на годы вперёд.
print(training_budget(8e9, 15e12, gpu_tflops=989)) # H100, bf16
Обратите внимание на комментарий в коде: сегодня Chinchilla-оптимум почти никто не соблюдает. Chinchilla оптимизирует стоимость обучения, а платить приходится за инференс, повторяемый миллиарды раз. Поэтому выгодно взять модель поменьше и обучать её сильно дольше оптимума — это и есть логика Llama-подобных релизов.
4.3 Инфраструктура: как модель вообще помещается в память
Для обучения нужно держать: веса, градиенты, состояния оптимизатора (Adam — два момента), активации. В смешанной точности на параметр приходится примерно 16 байт (fp32-мастер-копия 4 + momentum 4 + variance 4 + bf16 веса и градиенты 2+2). Для 8B это ~128 ГБ только на состояния — уже больше одной H100 (80 ГБ). Отсюда — параллелизмы:
- Data parallel — копии модели на разных GPU, разные батчи, all-reduce градиентов.
- ZeRO / FSDP (Rajbhandari et al., 2019) — шардинг состояний оптимизатора, градиентов и весов между GPU. Даёт память DP-масштаба почти бесплатно; де-факто стандарт.
- Tensor parallel (Megatron-LM) — одна матрица режется между GPU. Требует быстрого интерконнекта (NVLink), обычно только внутри узла.
- Pipeline parallel — разные слои на разных узлах, микробатчи для борьбы с «пузырём».
- Gradient checkpointing — не хранить активации, а пересчитывать их в backward. Меняет ~30 % времени на кратную экономию памяти.
Про численную стабильность, разогрев learning rate, косинусное затухание и клиппинг градиентов подробно написано в статье об обучении и оптимизации — для LLM всё это применяется в полном объёме, просто на других масштабах.
5. От «продолжателя текста» к ассистенту
Базовая (pretrained, «foundation») модель — это не чат-бот. На запрос «Как приготовить омлет?» она с большой вероятностью выдаст ещё десять похожих вопросов: в интернете вопросы часто встречаются списками. Модель делает ровно то, чему её учили — продолжает правдоподобный текст.
Превращение в ассистента — отдельный этап, и он состоит из двух разных задач: научить формату (SFT) и научить предпочтениям (alignment).
10–15T токенов"] --> B["Фильтрация,
дедупликация,
деконтаминация"] B --> C["Базовая модель
next-token prediction
недели на тысячах GPU"] end subgraph S["Этап 2 — SFT"] C --> D["Демонстрации:
10k–1M пар
инструкция → ответ"] D --> E["Instruct-модель
та же функция потерь,
но лосс только по ответу"] end subgraph A2["Этап 3 — Выравнивание"] E --> F["Сбор предпочтений:
ответ A лучше B"] F --> G1["RLHF: reward model + PPO"] F --> G2["DPO: напрямую из пар,
без reward-модели"] G1 --> H["Выровненная модель"] G2 --> H end subgraph D2["Этап 4 — Продакшн"] H --> I["Квантизация,
дистилляция"] I --> J["Сервинг:
батчинг, KV-кэш,
спекулятивное декодирование"] end style C fill:#3b82f6,fill-opacity:0.15,stroke:#3b82f6 style E fill:#10b981,fill-opacity:0.15,stroke:#10b981 style H fill:#f59e0b,fill-opacity:0.15,stroke:#f59e0b
5.1 SFT: тот же лосс, другая маска
Supervised fine-tuning технически ничем не отличается от предобучения — та же кросс-энтропия. Отличия в данных (диалоговые пары, размеченные людьми или отобранные) и в маскировании лосса: градиент считается только по токенам ответа. Иначе модель тратит ёмкость на то, чтобы научиться генерировать вопросы пользователей.
import torch
IGNORE = -100 # значение, которое CrossEntropyLoss игнорирует по умолчанию
def build_sft_example(tokenizer, prompt: str, answer: str, max_len: int = 2048):
"""Готовит один обучающий пример: лосс только по токенам ответа.
Типичная ошибка №1 — забыть маску и учиться на промптах.
Типичная ошибка №2 — не добавить EOS: модель не научится останавливаться
и на инференсе будет генерировать до упора в лимит токенов.
"""
p_ids = tokenizer(prompt, add_special_tokens=False).input_ids
a_ids = tokenizer(answer, add_special_tokens=False).input_ids + [tokenizer.eos_token_id]
input_ids = (p_ids + a_ids)[:max_len]
labels = ([IGNORE] * len(p_ids) + a_ids)[:max_len]
return {
"input_ids": torch.tensor(input_ids),
"labels": torch.tensor(labels),
"attention_mask": torch.ones(len(input_ids), dtype=torch.long),
}
Ещё один нюанс — шаблон чата (chat template). Роли (system / user / assistant)
кодируются служебными токенами. Если при инференсе применить не тот шаблон, качество
падает драматически, а выглядит это как «модель поглупела». Всегда используйте
tokenizer.apply_chat_template из репозитория модели, а не самописную склейку строк.
Про качество данных SFT известно контринтуитивное: 10 тысяч тщательно отобранных примеров обычно лучше миллиона автоматически собранных. SFT не добавляет знаний — знания уже в базовой модели, — он показывает, в каком стиле их доставать. Мусорные примеры при этом активно вредят: модель учится уверенно врать в новом формате.
5.2 PEFT и LoRA: дообучение без полного бюджета
Полный fine-tuning 8B-модели требует тех же ~128 ГБ, что и предобучение. LoRA (Hu et al., 2021) решает это, замораживая базовые веса и обучая низкоранговую поправку.
import math
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
"""Обёртка над замороженным nn.Linear: h = W0 x + (alpha/r) * B(A x).
Память под градиенты и Adam падает пропорционально доле обучаемых параметров
(обычно 0.1–1 %). Активации при этом хранить ВСЁ РАВНО НУЖНО — поэтому LoRA
экономит память оптимизатора, но не спасает от длинных последовательностей.
"""
def __init__(self, base: nn.Linear, r: int = 8, alpha: int = 16, dropout: float = 0.05):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False # базовые веса заморожены
self.r, self.scaling = r, alpha / r
self.lora_A = nn.Parameter(torch.empty(r, base.in_features))
self.lora_B = nn.Parameter(torch.zeros(base.out_features, r))
self.dropout = nn.Dropout(dropout)
# A — как обычный слой, B — нулями: в начале дельта строго равна нулю,
# модель побитово совпадает с базовой, лосс не «прыгает» на первом шаге.
nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
def forward(self, x: torch.Tensor) -> torch.Tensor:
delta = self.dropout(x) @ self.lora_A.T @ self.lora_B.T
return self.base(x) + delta * self.scaling
@torch.no_grad()
def merge(self) -> nn.Linear:
"""Слить адаптер в базовые веса — нулевой оверхед на инференсе."""
self.base.weight += (self.lora_B @ self.lora_A) * self.scaling
return self.base
Куда ставить адаптеры: минимум — на проекции внимания (q_proj, k_proj, v_proj,
o_proj). Добавление FFN-слоёв (gate/up/down) заметно улучшает результат ценой
примерно троекратного роста числа обучаемых параметров. В проде это делается не руками, а
через PEFT — код выше нужен, чтобы понимать, что
именно происходит под капотом.
QLoRA (Dettmers et al., 2023) добивает задачу: базовая модель квантуется в 4 бита (NF4), адаптеры остаются в bf16, градиенты текут через квантованные веса. Итог — дообучение 65B-модели на одной GPU с 48 ГБ. Подробности про квантизацию — в статье об инференсе и деплое.
Выбор ранга. r = 8..16 хватает для стилевой адаптации и доменного словаря;
r = 64..128 — если задача действительно новая. Растить r бесконечно бессмысленно:
при r → d вы просто медленно и неудобно приходите к полному fine-tuning. Правило
alpha = 2r — разумная точка старта.
Когда LoRA не подходит. Если нужно влить в модель большой пласт новых знаний (новый язык, закрытая предметная область на миллиарды токенов), низкоранговой поправки не хватит — нужен continued pretraining с полным обновлением весов.
5.3 Выравнивание: RLHF и DPO
SFT учит формату, но не умеет объяснить «этот ответ лучше того». Человеку трудно написать идеальный ответ, зато легко сравнить два. На этом и построено выравнивание (Ouyang et al., 2022, InstructGPT).
Классический RLHF — три шага: обучить reward-модель на парах предпочтений, затем оптимизировать политику через PPO с KL-штрафом за уход от SFT-модели. Работает, но это полноценный RL: нестабильно, четыре модели в памяти одновременно (политика, референс, reward, критик), куча гиперпараметров.
DPO (Rafailov et al., 2023) показал, что при оптимальной политике reward-модель выражается через саму политику, и весь RL-контур схлопывается в обычную классификацию на парах:
$$ \mathcal{L}_ {DPO} = -\log \sigma!\left( \beta \log \frac{\pi_\theta(y_w|x)}{\pi_{ref}(y_w|x)} - \beta \log \frac{\pi_\theta(y_l|x)}{\pi_{ref}(y_l|x)} \right) $$
import torch.nn.functional as F
def dpo_loss(policy_chosen_logps, policy_rejected_logps,
ref_chosen_logps, ref_rejected_logps, beta: float = 0.1):
"""logps — суммарный log P(ответ | промпт) по токенам ответа, форма (batch,).
beta управляет силой привязки к референсной модели:
beta -> 0 : почти нет ограничений, политика уезжает и деградирует;
beta = 0.1 : рабочий дефолт;
beta -> 1 : модель почти не меняется.
"""
pi_logratios = policy_chosen_logps - policy_rejected_logps
ref_logratios = ref_chosen_logps - ref_rejected_logps
logits = beta * (pi_logratios - ref_logratios)
loss = -F.logsigmoid(logits).mean()
# диагностика: неявная награда. Если оба падают, а разрыв растёт — модель
# учится не «делать лучше», а «делать хуже отвергнутое». Частая патология DPO.
chosen_reward = beta * (policy_chosen_logps - ref_chosen_logps).detach()
rejected_reward = beta * (policy_rejected_logps - ref_rejected_logps).detach()
return loss, {"acc": (chosen_reward > rejected_reward).float().mean().item()}
Ключевая роль референсной модели — не дать политике уехать. Без KL-якоря оптимизатор находит вырожденные решения: reward-модель — всего лишь аппроксимация человека, и её можно «взломать» (reward hacking). Классический артефакт — модель начинает писать неоправданно длинные ответы, потому что асессоры в среднем ставят длинным ответам оценку выше. Всегда контролируйте среднюю длину генерации как метрику здоровья выравнивания.
6. Что происходит внутри при генерации
место в KV-кэше Очередь --> Отклонён: кэш переполнен,
очередь длиннее лимита Префилл --> Декодинг: посчитаны K,V
для всего промпта Декодинг --> Декодинг: сэмплируем токен,
дописываем в кэш Декодинг --> Вытеснен: не хватило блоков
под растущий кэш Вытеснен --> Префилл: пересчитать (recompute) Вытеснен --> Декодинг: вернуть из swap Декодинг --> Готово: EOS / stop-строка /
лимит max_tokens Декодинг --> Прерван: клиент отключился Готово --> [*] Прерван --> [*] Отклонён --> [*] note right of Декодинг Один шаг = один токен. Блоки KV-кэша выделяются лениво, по мере роста. end note
6.1 Сэмплирование: как из распределения получается текст
Модель выдаёт логиты по всему словарю. Способ выбора токена влияет на результат не меньше, чем сама модель.
import torch
def sample_next_token(logits: torch.Tensor, temperature: float = 0.8,
top_k: int = 0, top_p: float = 0.95,
repetition_penalty: float = 1.0,
generated: torch.Tensor | None = None) -> int:
"""logits: (vocab,) — сырые логиты последней позиции."""
logits = logits.float()
# 1. штраф за повторы: делим логиты уже встречавшихся токенов
if repetition_penalty != 1.0 and generated is not None:
for tok in set(generated.tolist()):
logits[tok] /= repetition_penalty if logits[tok] > 0 else 1 / repetition_penalty
# 2. температура: T<1 заостряет распределение, T>1 сглаживает.
# T -> 0 эквивалентно greedy (argmax).
if temperature <= 1e-5:
return int(logits.argmax())
logits = logits / temperature
# 3. top-k: оставляем k самых вероятных
if top_k > 0:
kth = torch.topk(logits, top_k).values[-1]
logits[logits < kth] = -float("inf")
# 4. nucleus (top-p): минимальное множество с суммарной массой >= p.
# Адаптивно: там, где модель уверена, ядро из 1-2 токенов; где нет — из сотен.
if 0 < top_p < 1.0:
sorted_logits, sorted_idx = torch.sort(logits, descending=True)
probs = torch.softmax(sorted_logits, dim=-1)
cutoff = torch.cumsum(probs, dim=-1) > top_p
cutoff[1:] = cutoff[:-1].clone() # всегда оставляем хотя бы один токен
cutoff[0] = False
logits[sorted_idx[cutoff]] = -float("inf")
return int(torch.multinomial(torch.softmax(logits, dim=-1), 1))
Почему нельзя всегда брать argmax? Holtzman et al., 2019 показали: жадное и лучевое декодирование в открытой генерации сваливаются в вырожденные повторы, потому что человеческий текст не является последовательностью самых вероятных слов — он содержит удивление. Отсюда nucleus sampling.
Практическое правило выбора параметров:
| Задача | temperature | top_p | Почему |
|---|---|---|---|
| Извлечение фактов, классификация, JSON | 0 | — | Нужна детерминированность и воспроизводимость |
| Код | 0.1–0.3 | 0.95 | Синтаксис не терпит творчества |
| Диалог, ассистент | 0.6–0.8 | 0.9–0.95 | Баланс связности и живости |
| Креатив, брейнсторм | 0.9–1.2 | 0.95–1.0 | Нужно разнообразие |
Отдельно: детерминированность на GPU при temperature=0 всё равно не абсолютная —
порядок редукций в батче плавает, и результат может отличаться. Не стройте на побитовом
совпадении логику тестов.
7. Контекст и его границы
Контекстное окно — это не память. Модель не помнит прошлые запросы: весь диалог целиком подаётся на вход заново на каждом шаге. Отсюда и квадратичный рост стоимости длинных диалогов, и необходимость суммаризации истории.
Расширение контекста упирается в две вещи:
- Внимание квадратично по длине. FlashAttention
(Dao et al., 2022) не меняет асимптотику, но убирает
материализацию матрицы
n×nв HBM за счёт тайлинга — на практике это разница между «невозможно» и «работает». - Экстраполяция позиций. RoPE, обученный на 4k, на 32k выдаёт мусор. Лечится интерполяцией частот (position interpolation, YaRN) плюс дообучением на длинных документах.
Даже при формально доступных 128k токенов работает эффект «потерянного в середине» (Liu et al., 2023): модель хорошо использует начало и конец контекста и заметно хуже — середину. Практический вывод: в RAG-сценарии кладите самый релевантный документ в начало или в конец, а не «куда попало», и не притворяйтесь, что 100k мусора эквивалентны 5k релевантного.
8. Инференс: где на самом деле лежат деньги
Это раздел, который отличает человека, «поигравшего с API», от инженера, который держит LLM в проде.
8.1 KV-кэш и почему декодинг упирается в память
Без кэша генерация n-го токена требовала бы пересчёта внимания для всего префикса —
квадратично по длине и абсурдно дорого. Поэтому K и V каждой позиции считаются один раз и
кэшируются. Цена — память, растущая линейно с длиной и числом запросов.
def kv_cache_bytes(n_layers: int, n_kv_heads: int, d_head: int,
seq_len: int, batch: int = 1, dtype_bytes: int = 2) -> float:
"""Множитель 2 — потому что кэшируем и K, и V."""
return 2 * n_layers * n_kv_heads * d_head * seq_len * batch * dtype_bytes
# Llama-3-8B с GQA: 32 слоя, 8 KV-голов, d_head=128
mha = kv_cache_bytes(32, 32, 128, 8192) # если бы GQA не было
gqa = kv_cache_bytes(32, 8, 128, 8192)
print(f"MHA: {mha/2**30:.2f} ГБ/запрос GQA: {gqa/2**30:.2f} ГБ/запрос")
# сколько параллельных запросов влезет на H100 (80 ГБ) после весов модели
free = 80 * 2**30 - 8e9 * 2 # веса в bf16
print(f"одновременных запросов по 8k: {int(free / gqa)}")
Считаем арифметическую интенсивность шага декодирования. Для батча размера 1 нужно
прочитать все веса модели (N параметров × 2 байта = 16 ГБ для 8B) и выполнить ~2N
FLOPs (16 GFLOPs). Отношение — около 1 FLOP на байт. У H100 пиковая пропускная способность
HBM ~3.35 ТБ/с при ~989 TFLOPs bf16, то есть «баланс» наступает в районе 300 FLOP/байт.
Вывод жёсткий:
При батче 1 GPU занят полезной работой примерно на 0.3 % своих возможностей. Он не считает — он ждёт память. Потолок скорости = пропускная способность HBM / размер весов ≈ 3.35e12 / 16e9 ≈ 200 токенов в секунду, и никакая оптимизация вычислений это не изменит.
Отсюда — единственный по-настоящему действенный рычаг: батчинг. Веса читаются один раз на весь батч, значит при batch=64 стоимость чтения весов амортизируется в 64 раза, и пропускная способность растёт почти линейно, пока не упрётесь в память под KV-кэш.
8.2 Continuous batching и PagedAttention
Наивный статический батчинг плох: запросы генерируют ответы разной длины, и весь батч ждёт самый длинный. Continuous batching работает на уровне итераций: как только запрос завершился, на его место сразу входит новый.
Вторая проблема — фрагментация. Если резервировать кэш по max_tokens, львиная доля памяти
простаивает. PagedAttention (Kwon et al., 2023, vLLM)
переносит идею виртуальной памяти ОС на KV-кэш: кэш режется на блоки фиксированного
размера, таблица блоков связывает логические позиции с физическими. Утилизация памяти
поднимается с ~20–40 % до 90+ %, а общий префикс (системный промпт!) шарится между
запросами через copy-on-write.
для общего системного промпта) S->>G: prefill — весь промпт одним проходом G-->>S: логиты последней позиции, K/V записаны S-->>R: первый токен R-->>C: SSE-чанк (это и есть TTFT) loop каждый шаг декодинга S->>G: батч из ВСЕХ активных запросов, по 1 токену с каждого G-->>S: по токену на запрос S->>K: дописать K/V, при нехватке — выделить блок alt блоков не осталось K-->>S: preempt — вытеснить самый молодой запрос S->>S: вернуть его в очередь на пересчёт end S-->>R: токены R-->>C: SSE-чанки (интервал = TPOT) end G-->>S: EOS S->>K: освободить блоки S-->>R: finish_reason=stop R-->>C: [DONE]
8.3 Спекулятивное декодирование
Раз декодинг упирается в память, а вычислительные блоки простаивают — можно проверять
несколько токенов за проход бесплатно. Спекулятивное декодирование
(Leviathan et al., 2022): маленькая быстрая
draft-модель генерирует k токенов-кандидатов, большая модель за один проход считает
вероятности для всех k позиций и принимает префикс кандидатов по схеме отклоняющей
выборки.
Критично: схема принятия построена так, что итоговое распределение точно совпадает с распределением большой модели. Это не приближение, а ускорение без потери качества — редкий случай бесплатного обеда. Типичный выигрыш 2–3× при доле принятия 60–80 %. Разновидности без отдельной модели — Medusa (дополнительные головы) и n-gram/lookahead (подсказки из самого промпта, отлично работает при редактировании кода, где много дословных повторов).
8.4 Метрики, о которых надо договариваться с продуктом
- TTFT (time to first token) — определяется префиллом и очередью. Это то, что пользователь ощущает как «отзывчивость».
- TPOT / ITL (time per output token) — скорость «печати». Комфортно ≥ 30 токенов/с, это заметно быстрее скорости чтения.
- Throughput (токенов/с суммарно по серверу) — то, что определяет стоимость.
TTFT и throughput находятся в прямом конфликте: увеличивая батч, вы растите throughput и ухудшаете латентность каждого отдельного запроса. Отсюда практика разделения нагрузок: интерактивный чат и офлайн-обработка документов должны жить на разных пулах с разными настройками, иначе батч-джоба испортит SLO чата.
# Типичный прод-запуск на vLLM. Каждый флаг — про trade-off выше.
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--tensor-parallel-size 2 \ # модель на 2 GPU: меньше латентность, нужен NVLink
--max-model-len 16384 \ # реальный лимит контекста, а не максимум модели
--gpu-memory-utilization 0.92 \ # сколько VRAM отдать под веса + KV-кэш
--max-num-seqs 128 \ # потолок одновременных запросов в батче
--enable-prefix-caching \ # шарим KV общего системного промпта между запросами
--speculative-model my-org/llama-1b-draft \
--num-speculative-tokens 5
# Нагрузочный тест: без него цифры выше — гадание.
vllm bench serve --model meta-llama/Llama-3.1-8B-Instruct \
--dataset-name sharegpt --request-rate 10 --num-prompts 500
9. Оценка качества
Перплексия измеряет предобучение, но ничего не говорит о полезности ассистента. Уровни оценки, от дешёвых к дорогим:
- Академические бенчмарки (MMLU, GSM8K, HumanEval, MATH). Быстро и сравнимо, но тотально страдают от контаминации: тестовые задачи утекли в обучающие корпуса. Скачок на бенчмарке без роста в реальных задачах — почти всегда контаминация.
- LLM-as-a-judge: сильная модель оценивает ответы. Коррелирует с людьми на уровне 0.8+, но имеет систематические сдвиги — предпочитает длинные ответы, ответы, отформатированные списками, и свои собственные генерации. Позиционный сдвиг (первый вариант выигрывает чаще) обязательно лечится перестановкой пар.
- Парные сравнения людьми (Arena-подход, Elo). Дорого, медленно, но ближе всего к продуктовой реальности.
- Свой доменный eval-набор — единственное, что действительно защищает от регрессий. 200–500 примеров из ваших реальных запросов, зафиксированных как золотой набор. Без этого любое обновление промпта или модели — прыжок в темноте.
10. Типичные ошибки
При дообучении.
- Дообучать, когда хватило бы промпта или RAG. Fine-tuning учит форме, а не фактам; свежие или редкие знания нужно подавать в контекст, а не зашивать в веса.
- Обучать на «сырых» синтетических данных без фильтрации — модель фиксирует ошибки учителя.
- Забыть маску лосса по промпту или EOS-токен (см. код в 5.1).
- Слишком большой LR при LoRA — адаптеры «выжигают» базовое поведение, модель катастрофически забывает общие навыки. Для LoRA нормальный диапазон 1e-4…3e-4, что на порядок выше, чем для полного FT (1e-5…2e-5) — их часто путают в обе стороны.
- Оценивать результат «на глазок» на трёх примерах вместо зафиксированного eval-набора.
При инференсе.
- Считать, что дорогая GPU = быстрая генерация. При батче 1 упор в HBM, а не в TFLOPs.
- Резервировать KV-кэш по
max_tokensвместо paged-аллокации — теряете половину памяти. - Ставить
temperature=0.7там, где нужен парсимый JSON. Для структурированного вывода используйтеtemperature=0плюс constrained decoding (грамматики / JSON schema), а не надежду и регулярки. - Не следить за длиной контекста: стоимость растёт линейно по токенам промпта, а качество после определённой длины падает («потерянный в середине»).
- Игнорировать prefix caching при длинном системном промпте — это часто самый дешёвый ×2 к throughput, который можно получить одним флагом.
Концептуальные.
- Ждать от модели актуальных фактов после cutoff — их там нет и быть не может.
- Путать уверенный тон с калибровкой. Модель, обученная RLHF, систематически звучит увереннее, чем оправдано; про калибровку и её измерение — в статье об интерпретируемости и безопасности.
- Подавать недоверенный текст (веб-страницы, письма) в тот же контекст, где живут инструкции — это prompt injection, и защиты уровня «попросить модель не поддаваться» не работают.
11. Как это выглядит в проде
Работающая LLM-система почти никогда не сводится к «вызвали API». Типичный контур:
- Роутинг моделей: простые запросы — на маленькую дешёвую модель, сложные — на большую. Экономия в разы при неизменном пользовательском качестве.
- RAG вместо дообучения для доменных знаний: поиск по векторному индексу (см. эмбеддинги) плюс подстановка найденного в контекст. Обновление знаний — переиндексация за минуты, а не переобучение.
- Кэш ответов — семантический (по эмбеддингу запроса) и точный. В типовых пользовательских сценариях 20–40 % запросов повторяются.
- Гварды на входе и выходе: классификаторы недопустимого контента и утечки PII.
- Наблюдаемость: логировать не только латентность и стоимость, но и распределение
длин промптов/ответов, долю обрывов по лимиту токенов,
finish_reason. Рост долиlength— сигнал, что промпт или модель поехали. - Экономика: стоимость считается в токенах, а не в запросах. Системный промпт на 2000 токенов, отправляемый миллион раз в день, — это 2 млрд входных токенов, и это обычно крупнейшая статья расхода. Сокращение системного промпта и prefix caching окупаются быстрее любой другой оптимизации.
Мини-итог
- LLM — это модель, обученная предсказывать следующий токен; знания и «умения» — побочный продукт сжатия корпуса.
- Токенизация задаёт единицу мышления модели и напрямую влияет на стоимость (для русского языка — в 1.5–2 раза выше, чем для английского).
- Архитектурно это decoder-only трансформер с pre-norm/RMSNorm, RoPE, SwiGLU и GQA; каждое из этих решений имеет конкретный инженерный мотив.
- Бюджет обучения
C ≈ 6ND, Chinchilla-баланс ~20 токенов на параметр, но на практике малые модели переобучают сознательно ради дешёвого инференса. - SFT ставит формат, выравнивание (RLHF/DPO) — предпочтения. DPO проще PPO и в большинстве прикладных случаев его достаточно. LoRA/QLoRA делают дообучение доступным на одной GPU.
- Инференс делится на compute-bound префилл и memory-bound декодинг. Отсюда всё: KV-кэш, GQA, continuous batching, PagedAttention, спекулятивное декодирование.
- Без собственного eval-набора любые улучшения — вопрос веры.
Источники
- Архитектура и масштабирование: Attention Is All You Need, GPT-3, Scaling Laws, Chinchilla.
- Дообучение и выравнивание: InstructGPT, DPO, LoRA, QLoRA.
- Инференс: PagedAttention/vLLM, FlashAttention, Speculative Decoding, Nucleus Sampling, Lost in the Middle.
- Документация: Transformers, PEFT, TRL, vLLM.
- Let’s build GPT: from scratch, in code — Andrej Karpathy, лучшая практическая инициация в тему.
Что дальше
Мы прошли путь от предсказания токена до обслуживания продакшн-трафика. Языковые модели — это авторегрессивная генерация дискретных последовательностей. Но генерация бывает и принципиально иной: как порождать изображения, звук и вообще непрерывные объекты, где нет естественного порядка «слева направо»? Об этом — следующая статья: Генеративные модели: VAE, GAN, диффузия.