Нейронные сети Большие языковые модели: предобучение, дообучение, инференс
0%

Большие языковые модели: предобучение, дообучение, инференс

Большие языковые модели: предобучение, дообучение, инференс

В предыдущей статье мы разобрали механизм внимания и архитектуру трансформера. Трансформер — это кирпич. Эта статья про то, что произошло, когда из этого кирпича сложили здание в сотни миллиардов параметров и скормили ему заметную часть интернета: появился класс систем, которые решают задачи, под которые их никто не обучал.

Ключевой тезис, вокруг которого всё крутится:

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) $$

Три важных следствия, которые часто упускают:

  1. Разметка не нужна. Метка для позиции t — это токен на позиции t+1, он уже есть в тексте. Это self-supervised learning: сигнал бесплатный, поэтому масштабируется до триллионов токенов. Классическому ML такое даже не снилось.
  2. Обучение параллельно, генерация — нет. Благодаря каузальной маске все n позиций считаются за один проход (teacher forcing: на вход всегда идёт настоящий префикс, а не то, что модель нагенерировала). А вот на инференсе токены рождаются строго по одному — именно эта асимметрия определит всю экономику раздела 8.
  3. Перплексия = 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 Данные решают больше, чем архитектура

Архитектурные различия между конкурирующими моделями одного размера — это единицы процентов качества. Разница в данных — десятки процентов. Типичный пайплайн подготовки корпуса:

  1. Сбор и извлечение текста: веб-краулы (Common Crawl), код, книги, статьи. Парсинг HTML — недооценённый этап: мусорная разметка отравляет корпус.
  2. Фильтрация качества: эвристики (доля букв, длина строк, «lorem ipsum») плюс классификатор «похоже на качественный текст».
  3. Дедупликация — критично. Точные и near-duplicate документы (MinHash/LSH) удаляются: дубли ведут к запоминанию наизусть, утечке тестов и пустой трате бюджета.
  4. Микс доменов: код улучшает рассуждение даже на текстовых задачах, поэтому его добавляют осознанно; пропорции подбираются экспериментами на малых моделях.
  5. Деконтаминация: выкидываем пересечения с бенчмарками, иначе метрики врут.

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).

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) решает это, замораживая базовые веса и обучая низкоранговую поправку.

LoRA: обучаемая поправка низкого ранга вместо полной матрицы весов

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. Что происходит внутри при генерации

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. Контекст и его границы

Контекстное окно — это не память. Модель не помнит прошлые запросы: весь диалог целиком подаётся на вход заново на каждом шаге. Отсюда и квадратичный рост стоимости длинных диалогов, и необходимость суммаризации истории.

Расширение контекста упирается в две вещи:

  1. Внимание квадратично по длине. FlashAttention (Dao et al., 2022) не меняет асимптотику, но убирает материализацию матрицы n×n в HBM за счёт тайлинга — на практике это разница между «невозможно» и «работает».
  2. Экстраполяция позиций. RoPE, обученный на 4k, на 32k выдаёт мусор. Лечится интерполяцией частот (position interpolation, YaRN) плюс дообучением на длинных документах.

Даже при формально доступных 128k токенов работает эффект «потерянного в середине» (Liu et al., 2023): модель хорошо использует начало и конец контекста и заметно хуже — середину. Практический вывод: в RAG-сценарии кладите самый релевантный документ в начало или в конец, а не «куда попало», и не притворяйтесь, что 100k мусора эквивалентны 5k релевантного.

8. Инференс: где на самом деле лежат деньги

Это раздел, который отличает человека, «поигравшего с API», от инженера, который держит LLM в проде.

Две фазы инференса 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.

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. Оценка качества

Перплексия измеряет предобучение, но ничего не говорит о полезности ассистента. Уровни оценки, от дешёвых к дорогим:

  1. Академические бенчмарки (MMLU, GSM8K, HumanEval, MATH). Быстро и сравнимо, но тотально страдают от контаминации: тестовые задачи утекли в обучающие корпуса. Скачок на бенчмарке без роста в реальных задачах — почти всегда контаминация.
  2. LLM-as-a-judge: сильная модель оценивает ответы. Коррелирует с людьми на уровне 0.8+, но имеет систематические сдвиги — предпочитает длинные ответы, ответы, отформатированные списками, и свои собственные генерации. Позиционный сдвиг (первый вариант выигрывает чаще) обязательно лечится перестановкой пар.
  3. Парные сравнения людьми (Arena-подход, Elo). Дорого, медленно, но ближе всего к продуктовой реальности.
  4. Свой доменный 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-набора любые улучшения — вопрос веры.

Источники

Что дальше

Мы прошли путь от предсказания токена до обслуживания продакшн-трафика. Языковые модели — это авторегрессивная генерация дискретных последовательностей. Но генерация бывает и принципиально иной: как порождать изображения, звук и вообще непрерывные объекты, где нет естественного порядка «слева направо»? Об этом — следующая статья: Генеративные модели: VAE, GAN, диффузия.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов