Это пятая статья серии «AI без магии». В первой разобрали, откуда вообще взялись LLM. Во второй — как модель «думает» по одному токену и почему галлюцинирует. В третьей — как текст превращается в векторы. В четвёртой — как токены разговаривают через attention. Теперь соберём всё вместе и посмотрим на полный блок трансформера — тот самый кубик, из которого складывается GPT.

Где мы остановились

В прошлой статье разобрали attention. На выходе у каждого токена — обновлённый вектор, в котором учтён контекст. «mole» в «shrew mole» теперь ближе к «крот», а в «one mole of CO2» — к «количеству вещества». Хорошо.

Но attention — это только половина блока. У блока есть ещё четыре детали, без которых трансформер не работает: FFN, residual connection, нормализация, и — самое главное — блоков много. Десятки и сотни. У GPT-3 их 96.

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

FFN: то, что делает каждый токен сам по себе

Attention — операция между токенами. Каждый токен смотрит на других, собирает взвешенную смесь их value-векторов, и записывает результат к себе. Но если задуматься, attention — это, по сути, линейная операция: всё, что он делает, — взвешенное суммирование. Нелинейности почти нет (только softmax внутри весов).

А чтобы модель могла что-то реально вычислять — выводить, что «стеклянный» подразумевает «хрупкий», что «Москва» — это столица России, что после «sin(0) =» должно быть «0» — нужны нелинейные преобразования. И их даёт следующая часть блока — feed-forward network, или FFN. Иногда её называют MLP.

FFN до неприличия простая. Это две линейные проекции с нелинейностью посередине:

вход:d_model12288W_14 × d_model49152+ GELUW_2d_model12288выход:тот же размер,обновлённыйвектор
FFN внутри блока. Вектор размером d_model расширяется в 4× через первую линейную проекцию, проходит через нелинейность (GELU), и сжимается обратно через вторую. У GPT-3: 12288 → 49152 → 12288. Применяется к каждому токену независимо, но веса общие. На FFN приходится примерно две трети всех параметров модели.

Хитрый момент — расширение. Первая проекция увеличивает размерность в 4 раза. Зачем? Шире скрытый слой — больше вычислительной ёмкости, больше «комнат» для распознавания паттернов и хранения фактов. У GPT-3 это превращается в монструозные числа: 12288 × 49152 = ~600 миллионов параметров в одной проекции одного блока. Таких блоков 96, и в каждом две таких матрицы — поэтому FFN съедают около двух третей всех параметров модели.

И тут начинается интересное. В 2020 году Гева и соавторы показали, что FFN — это не «просто нелинейность», как все привыкли думать. Это ассоциативная память. Колонки первой матрицы работают как «ключи», которые активируются на определённых паттернах входа: «прошедшее время», «упоминание Эйфелевой башни», «концепт хрупкости». Строки второй матрицы — «значения», которые при активации добавляют в выход определённую информацию.

То есть когда мы говорим «модель что-то знает» — что Москва столица России, что Жан Вальжан украл хлеб, что хром имеет атомный номер 24 — это знание физически живёт в весах FFN. Не в attention. Attention занимается связями, FFN — знанием. Удобное разделение труда: attention перемешивает токены между собой, FFN перемешивает признаки внутри одного токена.

Residual connection: магистраль через всю сеть

Теперь главный вопрос. У GPT-3 — 96 блоков. У LLaMA-2 70B — 80 блоков. У моделей вроде GPT-4 — наверняка под сотню (точные числа OpenAI не раскрывает). И тут возникает проблема, которую не сразу видно.

Сети из десятков слоёв обучить градиентным спуском в принципе сложно. Сигнал от ошибки на выходе должен дойти обратно до первого слоя. Если каждый слой что-то делает с этим сигналом — умножает на матрицу, пропускает через нелинейность — то на каждом шаге сигнал может затухать (или взрываться). Через 50 слоёв он становится практически нулевым. Первые слои перестают обучаться.

Эта проблема была убийцей глубоких сетей до 2015 года. Решили её в работе ResNet очень простой идеей: вместо того чтобы каждый слой заменял вектор, пусть он добавляет к нему правку.

было:     y = sublayer(x)
стало:    y = x + sublayer(x)

Это и есть residual connection (или skip connection). Один маленький плюс, который меняет всё.

Что это даёт. Во-первых, при обратном проходе градиент имеет прямую дорогу до начала сети. Через сложение он проходит без изменений — какие бы ужасы ни творились внутри sublayer-а, по этой «магистрали» сигнал доходит до первого слоя в полную силу. Anthropic в статье про circuits называют эту магистраль residual stream — общий канал связи через всю сеть, в который каждый слой что-то пишет, и из которого каждый слой что-то читает.

Во-вторых, при прямом проходе модель может не использовать какой-то слой, если он не нужен. Если sublayer выдаст почти ноль, residual connection просто пропустит вектор насквозь без изменений. Слои становятся опциональными правками — некоторые нужны почти всегда, другие срабатывают только в специфических случаях.

В блоке трансформера residual connection стоят дважды — отдельно вокруг attention и отдельно вокруг FFN:

после attention:  x = x + attention(x)
после FFN:        x = x + ffn(x)

Один и тот же вектор residual stream проходит насквозь через весь блок, дважды получая правки.

Нормализация: чтобы числа не разлетались

Есть ещё одна тонкость. Каждый раз, когда мы добавляем что-то к residual stream, его магнитуда может расти. Через 96 блоков числа в векторе могут уйти в космос — и сеть взорвётся. Или наоборот — если правки систематически отрицательные, вектор сожмётся в ноль.

Чтобы этого не происходило, после каждого sublayer-а ставится нормализация — операция, которая приводит вектор к стандартному размаху. Самая популярная — LayerNorm: для каждого вектора вычитаем среднее, делим на стандартное отклонение, домножаем на обучаемые параметры. На выходе — вектор с предсказуемой магнитудой, на котором следующий слой может работать стабильно.

Тут есть один технический нюанс, который оказывается важным. В оригинальной статье 2017 года Vaswani et al. ставили норму после residual connection:

post-norm (2017):    x = LayerNorm(x + sublayer(x))

Это казалось логичным: сначала добавили правку, потом отнормировали результат. Но через несколько лет выяснилось, что такая схема плохо обучается на большой глубине. Работа Xiong et al. 2020 показала: если переставить норму перед sublayer-ом — обучение становится сильно стабильнее, не нужны хитрые warmup-расписания, можно делать сети глубиной в сотни слоёв:

pre-norm (модерн):   x = x + sublayer(LayerNorm(x))

Все современные модели — GPT-2, GPT-3, LLaMA, Claude, Gemini — используют pre-norm. Идейно разница простая: в post-norm градиент при обратном проходе должен пройти через каждую LayerNorm, и это его потихоньку «душит». В pre-norm у градиента есть чистая магистраль через residual без LayerNorm-ов на пути.

Ещё один апгрейд, которого не было в 2017: современные модели вроде LLaMA используют RMSNorm вместо LayerNorm. RMSNorm — упрощённый вариант: пропускает шаг с вычитанием среднего, оставляет только rescaling. Чуть быстрее, чуть проще, на качество почти не влияет. Это типичный пример того, как архитектура продолжает чиститься после 2017-го: убрали лишнюю операцию — стало эффективнее.

Один блок целиком

Собираем всё вместе. Полный блок трансформера в современном стиле — это:

def transformer_block(x):
    x = x + multi_head_attention(layer_norm(x))   # sublayer 1
    x = x + feed_forward(layer_norm(x))           # sublayer 2
    return x

Два sublayer-а, каждый обёрнут в pre-norm и residual. На входе — вектор размера d_model для каждого токена, на выходе — обновлённый вектор того же размера. Размерность сохраняется насквозь, потому что блоки складываются друг за другом и должны стыковаться.

residual stream++skipLayerNorm (pre-norm)multi-head attentionправка отattentionLayerNorm (pre-norm)feed-forward (FFN)правка от FFNx →
Один блок трансформера в современном (pre-norm) стиле. Слева — residual stream, который течёт через весь блок насквозь. Справа — две «ветки»: сначала attention, потом FFN. Каждая ветка читает текущее состояние stream-а через LayerNorm, делает свою работу, и добавляет результат обратно в stream. Сам stream при этом не модифицируется напрямую — только через сложение. Это и есть магистраль градиента: при обратном проходе сигнал может пройти от выхода блока до входа без изменений через эти два плюса.

Если убрать residual stream и оставить только sublayer-ы — сеть работать не будет. Если убрать LayerNorm — числа взорвутся через десяток блоков. Если убрать FFN — модель потеряет нелинейность и большую часть знания. Каждый кубик нужен.

Стэк: почему блоков много

Один блок добавляет к residual stream две правки — от attention и от FFN. Этого хватает на одну итерацию «уточнения» вектора. Но язык — штука сложная: чтобы понять предложение, нужно разобраться с синтаксисом, потом со смыслом фразы, потом со стилем, потом со скрытыми отсылками. Один блок столько не вытянет.

Поэтому блоков много. Они складываются в стэк — десятки одинаковых по архитектуре, но с разными весами. Каждый блок видит на входе результат предыдущего и добавляет свою порцию правок к residual stream.

токенэмбеддингблок 1синтаксис, n-граммыблок 2блок k (середина)смысл фраз, фактыблок Nабстракция, какой токен следующийfinal norm + unembeddingresidual streamпроходит черезвсе блоки
Стэк блоков. Токен превращается в эмбеддинг, проходит через N одинаковых по архитектуре блоков (но с разными весами), и в конце через final norm и unembedding превращается в логиты по словарю. Справа — общий residual stream, который проходит через все блоки насквозь: каждый блок что-то в него пишет и из него же читает.

Самая красивая часть: роли блоков никто не программирует. Никто не пишет «блок 1, ты занимайся синтаксисом, блок 50 — семантикой, блок 90 — абстракцией». Это распределение возникает эмерджентно — само, в процессе обучения градиентным спуском. Исследования интерпретируемости (Anthropic, Geva et al. 2021) подтверждают: нижние блоки действительно специализируются на поверхностных паттернах (буквы, морфология, n-граммы), средние — на смысле фраз и фактах, верхние — на абстрактных концепциях и финальном выборе токена.

Глубина и ширина — два независимых рычага масштабирования. Вот размеры нескольких известных моделей для калибровки масштаба:

МодельБлоковHeadd_model
Оригинальный transformer (2017)68512
GPT-2 small1212768
GPT-2 XL48251600
GPT-3 (175B)969612288
LLaMA-2 7B32324096
LLaMA-2 70B80648192

Когда говорят «модель в N раз больше», обычно имеют в виду число параметров, которое примерно равно N_blocks × d_model² × ~12. Оба рычага делают модель умнее, но по-разному: больше блоков — глубже рассуждения, больший d_model — больше «комнат» для информации в каждом векторе.

Замыкаем круг

Теперь у нас есть всё, чтобы пройти через GPT от начала до конца за один проход. Я набросаю это последовательностью — каждый шаг разобрали в одной из статей серии.

1. Токены. Текст бьётся на куски подходящего размера через BPE — словарь обычно 50-100 тысяч токенов (пост 3).

2. Эмбеддинг. Каждый токен заменяется на вектор размера d_model через таблицу-словарь обучаемых эмбеддингов. Прибавляется позиционное кодирование, чтобы модель знала, в каком порядке идут токены (пост 3).

3. Стэк блоков. Вектор каждого токена проходит через N блоков. Внутри каждого блока — multi-head attention (токены смотрят друг на друга, пост 4) и FFN (каждый токен обрабатывается независимо), оба с pre-norm и residual.

4. Финальная нормализация и unembedding. На выходе последнего блока берётся вектор последнего токена в последовательности (того, после которого мы хотим что-то предсказать), нормализуется и умножается на матрицу-обратную к эмбеддингам — это превращает d_model-вектор в логиты длиной с весь словарь.

5. Softmax → распределение. Логиты пропускаются через softmax, получаем вероятности по всем 50000 токенам словаря: «space» — 0.41, «forward» — 0.18, «moment» — 0.07, и так далее (пост 2).

6. Сэмплируем. Выбираем один токен из распределения (с помощью temperature, top-k и top-p — это уже отдельная история). Дописываем его в контекст. Возвращаемся в шаг 1.

Это и есть полный жизненный цикл одного шага генерации. На длинном ответе вся эта машина прокручивается сотни раз — по разу на каждый токен. Когда вы видите в чате стриминг текста по словам, это буквально темп генерации.

Никакого секретного компонента в архитектуре нет. Всё, что мы разобрали, — это то, что Vaswani et al. описали в одной статье в 2017 году, плюс несколько технических улучшений за следующие годы (pre-norm, RMSNorm, RoPE, чуть другая нелинейность). Магия — не в архитектуре. Магия — в том, что эту довольно простую штуку обучили на гигантском корпусе текстов с задачей предсказывать следующий токен, и в процессе модель сама выработала всё, что мы называем «знанием»: грамматику, факты, способность рассуждать, стиль, чувство юмора, умение писать код. Веса FFN заполнились паттернами, attention-головы выучили типы связей, residual stream научился носить информацию между слоями.

Дальше в серии можно идти вглубь — про обучение и масштабирование, про дообучение и RLHF (как из «дополнялки» получается ChatGPT), про новые архитектурные эксперименты (mixture of experts, mamba, диффузионные текстовые модели). Простор есть.

По мотивам Build a Large Language Model (From Scratch) Sebastian Raschka · manning.com Открыть оригинал →
Что ещё прочитать
  • Attention Is All You Need Vaswani et al. · 2017 Та самая статья 2017 года, в которой блок трансформера впервые был описан в нынешнем виде.
  • A Mathematical Framework for Transformer Circuits Anthropic · 2021 Откуда взялась идея «residual stream» как общего канала связи между всеми слоями. Полезный язык для разговора об интерпретируемости.
  • Transformer Feed-Forward Layers Are Key-Value Memories Geva, Schuster, Berant, Levy · 2020 Очень изящная статья, показывающая, что FFN внутри блока — это не просто «нелинейность», а ассоциативная память, в которой модель хранит факты.
  • On Layer Normalization in the Transformer Architecture Xiong et al. · 2020 Почему GPT-2/3, LLaMA и все остальные перешли на pre-norm вместо оригинального post-norm. Короткий ответ — стабильность обучения на большой глубине.

← В архив