Архитектура Transformer: как устроены LLM от начала до конца

opensourceaillmtransformerneural-networksdeep-learningit
← Back to Blog

Введение: почему 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

  1. Self-Attention — связь всех токенов между собой
  2. Multi-Head — разные паттерны внимания
  3. GQA — эффективная KV-группировка
  4. SwiGLU FFN — мощный feed-forward
  5. RoPE — позиционное кодирование
  6. RMSNorm — стабильная нормализация
  7. Residual + Norm — обучение глубоких сетей

Почему это важно для локальных LLM?

Понимание архитектуры помогает:

  • Выбирать модель под hardware
  • Оптимизировать inference (KV-cache, GQA)
  • Понимать ограничения контекста
  • Настраивать fine-tuning
  • Выбирать quantization

См. также