Трансформеры: как устроены LLM изнутри

opensourceaillmtransformersdeep-learningit
← Back to Blog

Введение: что такое 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 параметров