Архитектура Transformer: как устроены LLM от начала до конца
opensourceaillmtransformerneural-networksdeep-learningit
Введение: почему Transformer?
До 2017 года доминировали RNN и LSTM:
- Последовательная обработка (медленно)
- Проблема длинных зависимостей
- Сложность параллелизации
Transformer (2017, Google "Attention Is All You Need"):
- Полностью параллельный
- Attention механизм
- Скалируется до миллиардов параметров
Сегодня все LLM — это Transformers:
- GPT, Llama, Claude, Gemini — все на Transformer
В этой статье разберём:
- Attention механизм
- Self-attention и Multi-head attention
- Encoder-Decoder архитектура
- Decoder-only (GPT-style)
- Positional encoding
- Feed-forward слои
- Normalization и residual connections
- От attention до LLM
Attention механизм
Проблема: как модель связывает далёкие токены?
"Я родился в Париже, и именно там я провёл своё детство"
RNN:
"Я" → "родился" → "в" → "Париже" → "и" → "именно" → "там" → "я" → ...
Проблема: информация затухает на длинных дистанциях
Attention:
"там" → напрямую attention на "Париже"
Решение: взвешенное суммирование всех позиций
Query, Key, Value
Каждый токен имеет:
Q (Query) — "что я ищу"
K (Key) — "что я представляю"
V (Value) — "что я содержу"
Attention(i, j) = softmax(Q[i] · K[j] / √d)
Для токена i:
Output[i] = Σ Attention(i, j) × V[j] для всех j
Математика Scaled Dot-Product Attention
import torch
import torch.nn as nn
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q: (batch, heads, seq_len, d_k)
K: (batch, heads, seq_len, d_k)
V: (batch, heads, seq_len, d_v)
"""
d_k = Q.shape[-1]
# 1. Считаем similarity: Q × K^T
scores = torch.matmul(Q, K.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k, dtype=torch.float32))
# 2. Masking (для causal attention)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# 3. Normalization
attn_weights = torch.softmax(scores, dim=-1)
# 4. Weighted sum of Values
output = torch.matmul(attn_weights, V)
return output, attn_weights
Визуализация attention weights:
Токен "там" обращает внимание на:
"Я": 0.01
"родился": 0.02
"в": 0.01
"Париже": 0.73 ← максимум!
"и": 0.01
"именно": 0.02
"там": 0.15
"я": 0.02
"провёл": 0.01
"детство": 0.02
Multi-Head Attention
Проблема одного head
Один head attention видит одно "отношение":
"Paris" → attention на "там" (coreference)
Но есть и другие отношения:
"родился" → attention на "Париже" (location)
"провёл" → attention на "детство" (time)
"моеё" → attention на "детство" (possession)
Решение: несколько head, каждый учит разные отношения
Multi-Head Attention
class MultiHeadAttention(nn.Module):
def __init__(self, d_model=512, n_heads=8):
super().__init__()
self.n_heads = n_heads
self.d_k = d_model // n_heads # dim per head
# Project to Q, K, V separately
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
# Final projection
self.W_o = nn.Linear(d_model, d_model)
def forward(self, x, mask=None):
batch_size = x.shape[0]
# 1. Project Q, K, V
Q = self.W_q(x) # (batch, seq_len, d_model)
K = self.W_k(x)
V = self.W_v(x)
# 2. Reshape for parallel heads
Q = Q.view(batch_size, -1, self.n_heads, self.d_k)
K = K.view(batch_size, -1, self.n_heads, self.d_k)
V = V.view(batch_size, -1, self.n_heads, self.d_k)
Q = Q.transpose(1, 2)
K = K.transpose(1, 2)
V = V.transpose(1, 2)
# 3. Apply scaled dot-product attention
attn_output, attn_weights = scaled_dot_product_attention(Q, K, V, mask)
# 4. Concatenate heads
attn_output = attn_output.transpose(1, 2).contiguous()
attn_output = attn_output.view(batch_size, -1, self.n_heads * self.d_k)
# 5. Final projection
output = self.W_o(attn_output)
return output
Визуализация multi-head:
Head 1: "Париже" → "там" (coreference)
Head 2: "родился" → "Париже" (location)
Head 3: "моеё" → "детство" (possession)
Head 4: "провёл" → "детство" (time)
Head 5: "Я" → "родился" (subject)
Head 6: "Я" → "Париже" (origin)
Head 7: "Париже" → "детство" (association)
Head 8: общий контекст
Каждый head учит разные паттерны!
Полная архитектура Transformer
Encoder-Decoder (Original)
Input: "Je suis né à Paris" → Embedding → Encoder → ...
↓
Output: "I was born in Paris" ← Embedding ← Decoder ← ...
↑
Encoder output (cross-attention)
Encoder (6 блоков):
Multi-Head Attention → Add & Norm → FFN → Add & Norm
Decoder (6 блоков):
Masked Multi-Head Attention → Add & Norm
Cross-Attention → Add & Norm
FFN → Add & Norm
Decoder-only (GPT-style)
Все современные LLM — Decoder-only:
GPT, Llama, Claude, Gemini — все Decoder-only
Почему?
- Лучше для генерации текста
- Less parameters (no encoder)
- Better scaling
- Unidirectional attention = better next-token prediction
Архитектура:
Input → [Decoder Block] → [Decoder Block] → ... → [Decoder Block] → Output
Каждый block:
Masked Multi-Head Attention (causal)
+ Residual → LayerNorm
Feed-Forward Network
+ Residual → LayerNorm
Decoder Block детально
Структура одного блока
x → Self-Attention → + → LayerNorm → FFN → + → LayerNorm → output
Detailed:
1. x: (batch, seq_len, d_model)
2. Self-Attention:
Q, K, V → Multi-Head Attention → attn_output
3. Residual:
x + attn_output
4. LayerNorm:
LayerNorm(x + attn_output)
5. FFN:
Linear → GELU/SiLU → Linear
6. Residual + Norm:
LayerNorm(prev_output + FFN_output)
class DecoderBlock(nn.Module):
def __init__(self, d_model=4096, n_heads=32, d_ff=14336):
super().__init__()
self.attention = MultiHeadAttention(d_model, n_heads)
self.norm1 = LayerNorm(d_model)
self.dropout1 = nn.Dropout(0.1)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Linear(d_ff, d_model),
)
self.norm2 = LayerNorm(d_model)
self.dropout2 = nn.Dropout(0.1)
def forward(self, x, mask=None):
attn_output = self.attention(x, mask)
x = x + self.dropout1(attn_output)
x = self.norm1(x)
ffn_output = self.ffn(x)
x = x + self.dropout2(ffn_output)
x = self.norm2(x)
return x
Feed-Forward Network (FFN)
Стандартный FFN
FFN(x) = W₂ · GELU(W₁ · x + b₁) + b₂
Llama 3 8B:
d_model = 4096
d_ff = 14336 (×3.5 expansion)
W₁: [4096, 14336]
W₂: [14336, 4096]
Parameters: 4096×14336×2 = 117M parameters per layer!
Total FFN params (32 layers): 3.7B (~46% of model)
SwiGLU: современный FFN
Llama 3 использует SwiGLU вместо стандартного FFN:
Standard FFN:
FFN(x) = W₂ · GELU(W₁ · x + b₁) + b₂
SwiGLU:
FFN(x) = W₂ · SiLU(W₁ · x + b₁) ⊗ (V · x + c)
Где:
W₁, V: input projections
W₂: output projection
SiLU(x) = x · sigmoid(x)
⊗: element-wise multiplication
Почему SwiGLU лучше?
- Gate mechanism контролирует информацию
- SiLU лучше GELU для больших моделей
- +10-15% качество при тех же параметрах
class SwiGLU(nn.Module):
def __init__(self, d_model=4096, d_ff=14336):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff, bias=False)
self.w2 = nn.Linear(d_ff, d_model, bias=False)
self.w3 = nn.Linear(d_model, d_ff, bias=False)
def forward(self, x):
return self.w2(torch.silu(self.w1(x)) * self.w3(x))
Positional Encoding
Проблема: attention не знает порядок
Self-attention: permutation-invariant
"Кот съел собаку" → attention weights те же, что и
"Собаку съел кот"
Проблема: порядок токенов не учитывается!
Решение: positional encoding — добавляем информацию о позиции
Sinusoidal Positional Encoding
def sinusoidal_positional_encoding(seq_len, d_model, max_len=8192):
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2).float() *
(-math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return pe
RoPE: Rotary Positional Encoding
Современный стандарт для LLM (Llama 3, Mistral, etc.)
Идея: поворачиваем Q и K в rotary space
Почему RoPE лучше?
- Better extrapolation (за пределы training length)
- Better interpolation (между известными позициями)
- Implicit distance-based attention
- Works great for long contexts
def rotary_embedding(x, seq_len, pos_offset=0):
batch_size, num_heads, seq_len, d_model = x.shape
d_k = d_model // num_heads
theta = 1.0 / (10000 ** (torch.arange(0, d_k, 2).float() / d_k))
positions = torch.arange(pos_offset, pos_offset + seq_len, dtype=torch.float32).to(x.device)
freqs = torch.outer(positions, theta)
cos_freq = torch.cos(freqs).unsqueeze(0).unsqueeze(2)
sin_freq = torch.sin(freqs).unsqueeze(0).unsqueeze(2)
x1 = x[..., 0::2]
x2 = x[..., 1::2]
o1 = x1 * cos_freq - x2 * sin_freq
o2 = x1 * sin_freq + x2 * cos_freq
return torch.cat([o1, o2], dim=-1)
Normalization
Layer Normalization
LayerNorm(x) = γ × (x - μ) / σ + β
Применяется к каждому токену отдельно:
Input: (batch, seq_len, d_model)
Output: (batch, seq_len, d_model)
RMSNorm: упрощённый LayerNorm
Llama 3 использует RMSNorm:
RMSNorm:
x_norm = γ × x / RMS(x)
RMS(x) = sqrt(mean(x²) + ε)
Почему RMSNorm?
- Без centering (без вычитания mean)
- Меньше операций
- Работает так же хорошо
class RMSNorm(nn.Module):
def __init__(self, d_model, eps=1e-6):
super().__init__()
self.gamma = nn.Parameter(torch.ones(d_model))
self.eps = eps
def forward(self, x):
rms = torch.sqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps)
return self.gamma * (x / rms)
Embedding Layer
Token Embeddings
Input: "Hello world"
Tokens: [15496, 11]
Embedding:
Token 15496 → [0.023, -0.045, 0.012, ..., 0.003] (d_model=4096)
Token 11 → [-0.012, 0.034, -0.007, ..., 0.021] (d_model=4096)
Embedding matrix: [vocab_size, d_model]
Llama 3: [128256, 4096] = 525M parameters
Vocabulary
Llama 3: 128K tokens
- English words: "hello", "world", "the"
- Subwords: "ing", "tion", "un"
- Punctuation: ".", ",", "!"
- Whitespace: " ", "\n", "\t"
- Special: "<|begin|>", "<|end|>", "<|eot|>"
Почему subword?
"unbelievable" → "un" + "believ" + "able"
Можно обрабатывать слова за пределами vocab
Полная модель: Llama 3 8B
Структура
Llama 3 8B:
Parameters: 8B
Layers: 32
Heads: 32 (32 query heads, 8 KV heads → GQA!)
d_model: 4096
d_ff: 14336
Max context: 8K tokens
Vocabulary: 128K tokens
Архитектура:
Token Embedding (128K × 4096)
→ [Layer 1] → [Layer 2] → ... → [Layer 32]
→ Final RMSNorm
→ LM Head (4096 × 128K)
→ Softmax
Распределение параметров
Llama 3 8B: 8,037,228,544 parameters
Embedding: 525M (6.5%)
Attention: 1,048M (13.0%)
FFN (SwiGLU): 5,662M (70.4%)
LM Head: 525M (6.5%)
Other: 277M (3.4%)
Grouped Query Attention (GQA)
Проблема: медленный KV-cache
Standard Multi-Head Attention (MHA):
32 heads → 32 KV heads
Каждый токен: 32 KV writes в cache
Проблема: при длинном контексте — много памяти
Grouped Query Attention (GQA):
32 heads → 8 KV heads
Каждая KV группа делится между 4 query heads
Выгода: 4× меньше KV-cache
class GroupedQueryAttention(nn.Module):
def __init__(self, n_heads=32, n_kv_heads=8, d_model=4096):
super().__init__()
self.n_heads = n_heads
self.n_kv_heads = n_kv_heads
self.d_k = d_model // n_heads
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model // n_kv_heads * n_heads)
self.W_v = nn.Linear(d_model, d_model // n_kv_heads * n_heads)
self.W_o = nn.Linear(d_model, d_model)
def forward(self, x, kv_cache=None):
batch_size, seq_len = x.shape[:2]
Q = self.W_q(x)
K = self.W_k(x)
V = self.W_v(x)
# Reshape: n_kv_heads, then repeat K, V for each group
K = repeat_kv(K, self.n_heads // self.n_kv_heads)
V = repeat_kv(V, self.n_heads // self.n_kv_heads)
# Attention
output = scaled_dot_product_attention(Q, K, V)
return self.W_o(output)
MHA vs GQA vs MQA:
MHA (GPT-3):
Q: 96 heads, K: 96 heads, V: 96 heads
KV cache: 96 × layers × hidden
GQA (Llama 3):
Q: 32 heads, K: 8 heads, V: 8 heads
KV cache: 8 × layers × hidden (12× меньше!)
MQA (PaLM):
Q: 64 heads, K: 1 head, V: 1 head
KV cache: 1 × layers × hidden (64× меньше!)
Forward Pass: как работает генерация
Step-by-step
Input: "Какая столица Франции?"
Step 1: Tokenize
[1926, 236, 1290, 449, 366, 2270, 4492]
Step 2: Embedding
→ [7 × 4096] tensor
Step 3: 32 Decoder Layers
Каждый: Attention + FFN
→ [7 × 4096] tensor
Step 4: LM Head + Softmax
→ [7 × 128256] probabilities
Step 5: Sample / Argmax
"Париж" = 4582 (p=0.87)
Step 6: Next token (autoregressive)
Input: [1926, 236, 1290, 449, 366, 2270, 4492, 4582]
→ "?" = 472 (p=0.95)
def generate(model, prompt_tokens, max_length=100, temperature=0.7):
tokens = prompt_tokens
for _ in range(max_length):
# Forward pass
logits = model(tokens) # (1, seq_len, vocab_size)
logits = logits[:, -1, :] # last token only
# Temperature scaling
logits = logits / temperature
# Softmax → probabilities
probs = torch.softmax(logits, dim=-1)
# Sample
next_token = torch.multinomial(probs, 1)
# Append
tokens = torch.cat([tokens, next_token], dim=-1)
# Stop condition
if next_token.item() == EOS_TOKEN:
break
return tokens
Scaling Laws
Как размер модели влияет на качество
Kaplan et al. (2020) — Scaling Laws for Neural Language Models:
Loss ∝ (N)^(-α) + (D)^(-β) + (C)^(-γ)
Где:
N = number of parameters
D = dataset size
C = compute (training tokens)
α ≈ 0.07, β ≈ 0.06, γ ≈ 0.035
Выводы:
- Больше данных лучше, чем больше модель
- Compute-optimal: большие модели на больших данных
- Закон убывающей отдачи
Модель Parameters Training Tokens Loss
---------------------------------------------------
GPT-1 117M 5B 3.12
GPT-2 1.5B 40B 2.48
GPT-3 175B 300B 1.78
Llama 3 8B 8B 15T 2.05
Llama 3 70B 70B 15T 1.85
Llama 3 405B 405B 15T 1.75
Визуализация архитектуры
Полная схема Llama 3
Input Tokens
│
Token Embedding
[128K × 4096]
│
┌──────────▼──────────┐
│ RoPE Embedding │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Layer 1 │
│ Attention (GQA) │
│ SwiGLU FFN │
│ RMSNorm × 2 │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Layer 2 │
│ ... │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Layer 32 │
│ ... │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Final RMSNorm │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ LM Head │
│ [4096 × 128K] │
└──────────┬──────────┘
│
Softmax
│
Probabilities
[128K values]
Выводы
Ключевые компоненты Transformer
- Self-Attention — связь всех токенов между собой
- Multi-Head — разные паттерны внимания
- GQA — эффективная KV-группировка
- SwiGLU FFN — мощный feed-forward
- RoPE — позиционное кодирование
- RMSNorm — стабильная нормализация
- Residual + Norm — обучение глубоких сетей
Почему это важно для локальных LLM?
Понимание архитектуры помогает:
- Выбирать модель под hardware
- Оптимизировать inference (KV-cache, GQA)
- Понимать ограничения контекста
- Настраивать fine-tuning
- Выбирать quantization