Трансформеры: как устроены LLM изнутри
opensourceaillmtransformersdeep-learningit
Введение: что такое Transformer?
Transformer — это архитектура, которая заменила RNN/LSTM в NLP. Основа всех современных LLM: GPT, Llama, BERT, Mistral.
Одна идея: Attention — взвешенный обзор всех токенов в контексте.
Уровень 1: Проблема старых архитектур
RNN (Recurrent Neural Network)
# RNN обрабатывает текст по одному токену
class RNN(nn.Module):
def forward(self, text):
h = zero_state
for token in text: # "Москва" "—" "это" "столица"
h = rnn_cell(token, h) # последовательно!
return h
# Проблема 1: нет долгосрочной памяти
# "В России столица..." → нужно помнить "Россия" 50 токенов назад
# Проблема 2: последовательность
# 1000 токенов = 1000 шагов → медленно!
# Нельзя параллелить
# Проблема 3: vanishing gradients
# Градиент затухает через 1000 шагов
Attention — прорыв (Vaswani et al., 2017)
Attention видит ВСЕ токены сразу:
"Москва — это столица России"
↑ ↑ ↑ ↑
| | | └── attention ко всем
| | └────────── attention ко всем
| └───────────────── attention ко всем
└────────────────────────── attention ко всем
Параллельно! Быстро! Дальнозорко!
Уровень 2: Self-Attention
Идея: каждый токен смотрит на все токены
Input: "Кот сел на ковёр"
Для токена "сел":
"Кот" → subject (важно!) weight = 0.7
"сел" → self (умеренно) weight = 0.1
"на" → preposition weight = 0.1
"ковёр" → object (важно!) weight = 0.1
Output для "сел" = 0.7 * Kot + 0.1 * sel + 0.1 * na + 0.1 * kovyr
Математика: Q, K, V
# Для каждого токена вычисляем 3 вектора:
# Q (Query) — что я ищу?
# K (Key) — что я содержу?
# V (Value) — что я отдаю?
d_model = 4096 # размерность
Q = x @ W_Q # [batch, seq_len, d_model] @ [d_model, d_model] = [batch, seq_len, d_model]
K = x @ W_K # project to query space
V = x @ W_V # project to value space
# W_Q, W_K, W_V — обучаемые матрицы
Attention Score
# Attention(Q, K, V) = softmax(Q @ K^T / sqrt(d)) @ V
# Step 1: Q @ K^T — насколько каждый query совпадает с каждым key
scores = Q @ K.T / sqrt(d_model)
# [batch, seq_len, d_model] @ [d_model, batch, seq_len]
# = [batch, seq_len, seq_len]
# Step 2: softmax — нормализуем в [0, 1], сумма = 1
attention_weights = softmax(scores, dim=-1)
# attention_weights[i, j] = "насколько токен i обращает внимание на токен j"
# Step 3: взвешенная сумма Value
output = attention_weights @ V
# [batch, seq_len, seq_len] @ [batch, seq_len, d_model]
# = [batch, seq_len, d_model]
Визуализация attention weights
Input: "Кот сел на ковёр"
Кот сел на ковёр
Кот 0.5 0.2 0.1 0.2 ← "Кот" смотрит на себя и объект
сел 0.7 0.1 0.1 0.1 ← "сел" сильно на "Кот" (subject)
на 0.1 0.1 0.5 0.3 ← "на" смотрит на предлог
ковёр 0.1 0.3 0.2 0.4 ← "ковёр" смотрит на объект и себя
Каждая строка = softmax → сумма = 1
Уровень 3: Multi-Head Attention
Одна голова — хорошо, а много — лучше
# Single Head Attention:
Q = x @ W_Q # один вектор query
K = x @ W_K # один вектор key
V = x @ W_V # один вектор value
output = Attention(Q, K, V)
# Multi-Head Attention: 8 голов
num_heads = 8
d_head = d_model // num_heads # 4096 / 8 = 512
# Каждая голова — свой набор проекций
Q_heads = [x @ W_Q[i] for i in range(8)] # 8 разных query
K_heads = [x @ W_K[i] for i in range(8)] # 8 разных key
V_heads = [x @ W_V[i] for i in range(8)] # 8 разных value
# Attention для каждой головы отдельно
outputs = [Attention(Q_heads[i], K_heads[i], V_heads[i]) for i in range(8)]
# Concatenate + project
output = concat(outputs) @ W_O
Что учит каждая голова?
Head 0: subject-verb attention
"Кот" → "сел" (0.8)
"Собака" → "бежит" (0.9)
Head 1: determiner-noun attention
"этот" → "кот" (0.7)
"та" → "кошка" (0.8)
Head 2: preposition-object attention
"на" → "ковёр" (0.9)
"в" → "дом" (0.8)
Head 3: positional attention (nearby tokens)
соседние токены → higher weight
Head 4: long-range attention
начало → конец предложения
... и так далее
Уровень 4: Positional Encoding
Проблема: attention не знает порядок
Input: "Кот сел на ковёр"
Permutation: "ковёр на сел Кот"
Attention видит ОДИНАКОВЫЕ токены, ОДИНАКОВЫЕ weights!
Нужно добавить информацию о позиции.
Sinusoidal Positional Encoding
def positional_encoding(seq_len, d_model):
PE = torch.zeros(seq_len, d_model)
for pos in range(seq_len):
for i in range(0, d_model, 2):
# sin для чётных, cos для нечётных
PE[pos, i] = sin(pos / 10000**(2*i/d_model))
PE[pos, i+1] = cos(pos / 10000**(2*i/d_model))
return PE
# Результат:
# PE[0] = [0.0, 0.0, 0.0, 0.0, ...] ← позиция 0
# PE[1] = [0.1, 0.9, 0.1, 0.9, ...] ← позиция 1
# PE[2] = [0.2, 0.8, 0.2, 0.8, ...] ← позиция 2
# ...
# PE[100] = [0.9, 0.3, 0.9, 0.3, ...] ← позиция 100
Add, не concatenate
# Positional Encoding добавляется, не конкатенируется
x_with_pos = x + positional_encoding(x.size(0), d_model)
# Почему add, не concat?
# concat: d_model → 2*d_model (нужно больше параметров)
# add: d_model остаётся d_model (эффективно)
Learned Positional Embedding (используется в GPT)
# Просто обучаемые эмбеддинги для позиций
class LearnedPositionalEmbedding(nn.Embedding):
def __init__(self, max_positions, dim):
super().__init__(max_positions, dim)
def forward(self, positions):
return self.weight[positions]
# positions = [0, 1, 2, 3, ...]
# embedding = learned[position]
Уровень 5: Feed-Forward Network
После attention — полносвязный слой
class FeedForward(nn.Module):
def __init__(self, d_model, d_ff):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff) # 4096 → 11008
self.w2 = nn.Linear(d_ff, d_model) # 11008 → 4096
self.activation = nn.GELU()
def forward(self, x):
# x: [batch, seq_len, d_model]
x = self.w1(x) # [batch, seq_len, d_ff]
x = self.activation(x) # [batch, seq_len, d_ff]
x = self.w2(x) # [batch, seq_len, d_model]
return x
# d_ff = 4 * d_model — стандарт
# Llama использует SwiGLU вместо простого FFN
Что делает FFN?
Attention: смешивает информацию между токенами
FFN: обрабатывает каждый токен independently
Attention → "Кот" теперь содержит информацию о "сел" и "ковёр"
FFN → трансформирует этот смешанный вектор
FFN — это "мозг" каждого токена:
учит факты ("Кот" → "животное")
учит правила ("сел" → прош. время)
учит контекст ("кот" → скорее всего "рыжий")
Уровень 6: Residual Connections
Skip connection — ключ к глубоким сетям
class ResidualBlock(nn.Module):
def forward(self, x):
# Attention branch
h = x
h = layer_norm(h)
h = attention(h)
x = x + h # RESIDUAL!
# FFN branch
h = x
h = layer_norm(h)
h = ffn(h)
x = x + h # RESIDUAL!
return x
Зачем нужен residual?
Без residual:
x → layer1 → layer2 → ... → layer32
gradient = d32 * d31 * ... * d1
0.9^32 = 0.03 ← затухание
С residual:
x → [+x] → layer1 → [+x] → ... → [+x] → layer32
gradient = 1 + d32 * d31 * ... * d1
gradient = 1 + 0.03 = 1.03 ← течёт отлично!
Уровень 7: Layer Normalization
Нормализуем каждый токен
class LayerNorm(nn.LayerNorm):
def forward(self, x):
# x: [batch, seq_len, d_model]
# Нормализуем по последней размерности
mean = x.mean(dim=-1, keepdim=True)
std = x.std(dim=-1, keepdim=True)
x_norm = (x - mean) / (std + epsilon)
return gamma * x_norm + beta
# gamma, beta — обучаемые параметры
LayerNorm vs BatchNorm
BatchNorm:
нормализуем по batch dimension
[batch, channels, seq_len] → mean по batch
Зависит от batch size!
LayerNorm:
нормализуем по feature dimension
[batch, seq_len, d_model] → mean по d_model
Не зависит от batch size!
Лучше для NLP/Transformer
Уровень 8: Полный Transformer Block
Decoder-only (GPT, Llama)
class TransformerBlock(nn.Module):
def __init__(self, d_model, d_ff, num_heads):
super().__init__()
self.attn = MultiHeadAttention(d_model, num_heads)
self.ffn = FeedForward(d_model, d_ff)
self.attn_norm = LayerNorm(d_model)
self.ffn_norm = LayerNorm(d_model)
def forward(self, x):
# Self-Attention
h = self.attn_norm(x)
h = self.attn(h)
x = x + h # residual
# Feed-Forward
h = self.ffn_norm(x)
h = self.ffn(h)
x = x + h # residual
return x
Decoder-only vs Encoder-Decoder
Decoder-only (GPT, Llama):
Input: текст
Output: следующий токен
Attention: causal (только предыдущие токены)
Encoder-Decoder (BART, T5):
Encoder: читает весь input
Decoder: генерирует output
Attention: causal + cross-attention
Encoder-only (BERT):
Input: текст
Output: классификация / embedding
Attention: bidirectional
Уровень 9: Causal Masking
Decoder видит только прошлое
# Causal Mask: нижнетреугольная матрица
# Токен i видит только токены 0..i
# Без mask:
# Кот сел на ковёр
# Кот 1 1 1 1 ← видит всё!
# сел 1 1 1 1
# на 1 1 1 1
# ковёр 1 1 1 1
# С causal mask:
# Кот сел на ковёр
# Кот 1 0 0 0 ← видит только себя
# сел 1 1 0 0 ← видит "Кот", "сел"
# на 1 1 1 0 ← видит "Кот", "сел", "на"
# ковёр 1 1 1 1 ← видит всё предыдущее
# Реализация
def causal_mask(seq_len):
mask = torch.tril(torch.ones(seq_len, seq_len))
# mask[i, j] = 1 если j <= i, else 0
return mask
# Применяем к attention scores
scores = Q @ K.T / sqrt(d)
scores = scores.masked_fill(mask == 0, -float('inf'))
attention_weights = softmax(scores)
Уровень 10: Размеры и параметры
Llama-7B параметры
Параметры Llama-7B:
Embedding: 32K tokens × 4096 = 131M
Attention (4 слоя): 32 × 4096 × 4096 × 3 = 671M
FFN (32 слоя): 32 × 4096 × 11008 × 3 = 4.1B
LayerNorm: 32 × 2 × 4096 = 262K
LM Head: 4096 × 32K = 131M
Total: ~7B параметров
Вычислений на токен:
Forward: ~2 × 7B × 1 = 14B FLOPs
Full (train): 2 × 14B × batch_size × seq_len
Scaling laws
Параметры | Tokens | Loss | Качество
-----------|--------|-------|----------
100M | 10B | 3.5 | Плохо
1B | 100B | 2.8 | Средне
7B | 1T | 2.2 | Хорошо
70B | 3T | 1.8 | Отлично
400B | 10T | 1.5 | Лучшее
Loss = a × Parameters^(-b) + c × Tokens^(-d) + e
→ Больше параметров + больше данных = лучше
Итоги
- Transformer = Attention + FFN + Residual + LayerNorm
- Self-Attention: каждый токен смотрит на все токены
- Multi-Head: несколько attention механизмов параллельно
- Positional Encoding: добавляет информацию о порядке
- Causal Masking: decoder видит только прошлое
- Decoder-only архитектура — стандарт для LLM
- Llama-7B: 32 слоя, 4096 hidden, 32K vocab, 7B параметров