Собрать LLM с нуля: руководство по архитектуре Llama 3 на Python | AiManual
AiManual Logo Ai / Manual.
29 Янв 2026 Гайд

Собираем LLM с нуля: полный разбор архитектуры Llama 3 на Mini-LLM

Пошаговый гайд по созданию LLM с нуля: реализация Llama 3, RoPE, RMSNorm, SwiGLU, обучение модели с токенизатором SentencePiece.

Зачем собирать LLM самому, когда можно скачать готовую?

Скачать готовую модель проще. Нажать кнопку в LM Studio или запустить через llama.cpp. Но тогда вы остаетесь пользователем, а не инженером. Вы не понимаете, почему модель иногда генерирует бред, не можете ее дообучить под свои данные, не видите узкие места в инференсе.

Собрать свою Mini-LLM - это как собрать двигатель, а не просто сесть за руль. Вы увидите каждый болт, каждую шестеренку. Поймете, зачем нужен KV-cache, почему RoPE лучше абсолютных позиционных эмбеддингов, и как RMSNorm экономит 15% памяти. Эта статья - технический спринт от нуля до работающей модели.

Актуальность на 29.01.2026: В основе лежит архитектура Meta Llama 3.1 (8B/70B), выпущенной в 2024 году. Мы реализуем ключевые компоненты: RoPE, RMSNorm, SwiGLU и GQA (Grouped Query Attention), которые стали стандартом для современных LLM.

Архитектура Llama 3: что внутри черного ящика?

Llama 3 - это декодер-трансформер. Но не обычный, а с кучей оптимизаций, которые Meta выстрадала на триллионах токенов. Забудьте про старый GPT-2 стиль. Вот что действительно важно:

  • RMSNorm вместо LayerNorm: Убирает операцию смещения (bias), работает быстрее и стабильнее на больших моделях.
  • RoPE (Rotary Positional Embeddings): Позиционная информация встраивается прямо во внимание, а не добавляется к эмбеддингам. Решает проблему длины контекста элегантнее, чем старые методы.
  • SwiGLU активация: Не просто ReLU или GeLU. SwiGLU (Swish-Gated Linear Unit) показывает лучшие результаты при том же количестве параметров.
  • GQA (Grouped Query Attention): Компромисс между Multi-Head и Multi-Query Attention. Ускоряет инференс без заметной потери качества.

Мы не будем копировать Llama 3 один в один (для этого есть официальный код). Мы создадим Mini-LLM - упрощенную, но рабочую версию, которая демонстрирует все ключевые принципы. Размеры: 12 слоев, 768 скрытых размерностей, 12 голов внимания. Поместится на GPU с 8GB памяти для обучения.

1 Готовим окружение и данные

Начнем с токенизатора. Без него любая модель - просто набор чисел. Мы используем SentencePiece, как и в оригинальной Llama 3, но для нашего маленького датасета.

Ошибка №1: Тренировать токенизатор на слишком маленьком датасете. Получите словарь из 32k токенов, где половина - это редкие символы Unicode. Бессмысленно.

Скачаем небольшой датасет для обучения токенизатора. Например, часть Wikipedia или OpenWebText. Нам нужно 100-200MB текста.

# Устанавливаем SentencePiece
pip install sentencepiece

# Скачиваем данные для обучения токенизатора
wget https://huggingface.co/datasets/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-train.txt

# Обучаем модель SentencePiece
spm_train --input=TinyStoriesV2-GPT4-train.txt \
  --model_prefix=mini_llama_spm \
  --vocab_size=8192 \
  --character_coverage=1.0 \
  --model_type=bpe \
  --pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3

Vocab size 8192 - достаточно для нашей Mini-LLM. В оригинальной Llama 3.1 используют 128k токенов, но нам столько не нужно. Флаги pad, unk, bos, eos обязательны - без них токенизатор несовместим со стандартными пайплайнами.

💡
Проверьте токенизатор сразу: spm_encode --model=mini_llama_spm.model --output_format=id <<< "Hello world". Должны получить список чисел. Если видите только UNK токены (1) - что-то пошло не так с обучением.

2 Пишем ядро: реализация RoPE с нуля

RoPE - самый элегантный компонент современной LLM. Вместо того чтобы добавлять позиционные эмбеддинги к токенам, мы вращаем векторы запроса и ключа на угол, зависящий от позиции. Звучит сложно? Код проще.

import torch
import torch.nn as nn
import math

def apply_rope(x, freqs_cis):
    """
    Применяем RoPE к тензору x.
    x: (batch_size, seq_len, n_heads, head_dim)
    freqs_cis: (seq_len, head_dim/2) - предвычисленные частоты
    """
    # Разделяем последнее измерение на действительную и мнимую части
    x_complex = torch.view_as_complex(
        x.float().reshape(*x.shape[:-1], -1, 2)
    )
    
    # Получаем частоты для нужных позиций
    freqs_cis = freqs_cis[:x.shape[1]]
    freqs_cis = freqs_cis.unsqueeze(0).unsqueeze(2)  # (1, seq_len, 1, head_dim/2)
    
    # Вращаем (умножение комплексных чисел)
    x_rotated = x_complex * freqs_cis
    
    # Возвращаем в исходную форму
    x_out = torch.view_as_real(x_rotated)
    x_out = x_out.reshape(*x.shape)
    
    return x_out.type_as(x)

# Предвычисляем частоты один раз при инициализации модели
def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):
    """
    Предвычисляем частоты для RoPE.
    В Llama 3 используют theta=500000 для большей длины контекста.
    """
    freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
    t = torch.arange(end, device=freqs.device)
    freqs = torch.outer(t, freqs)  # (seq_len, dim/2)
    freqs_cis = torch.polar(torch.ones_like(freqs), freqs)  # комплексные числа
    return freqs_cis

Почему RoPE лучше? Потому что она сохраняет относительные расстояния между позициями. Токены на расстоянии 10 шагов всегда имеют одинаковое относительное кодирование, независимо от их абсолютной позиции. Это критично для обобщения на более длинные последовательности, чем были в обучающих данных.

Ошибка №2: Неправильно формировать freqs_cis. Если размерность head_dim=64, то freqs_cis должен иметь размер (seq_len, 32), а не (seq_len, 64). Половина размерности, потому что работаем с комплексными числами.

3 RMSNorm и SwiGLU: где прячется производительность

LayerNorm в оригинальном трансформере вычисляет среднее и стандартное отклонение по последнему измерению, затем применяет масштабирование и смещение. RMSNorm убирает центрирование (вычитание среднего) - и оказывается, это почти не влияет на качество, но ускоряет вычисления на 15-20%.

class RMSNorm(nn.Module):
    """Root Mean Square Layer Normalization"""
    
    def __init__(self, dim: int, eps: float = 1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))
    
    def _norm(self, x):
        # RMS: квадратный корень из среднего квадратов
        return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
    
    def forward(self, x):
        output = self._norm(x.float()).type_as(x)
        return output * self.weight

SwiGLU - еще один трюк. Вместо одного линейного слоя с активацией, используем два линейных слоя, один из которых проходит через функцию swish (x * sigmoid(x)) и умножается на выход второго.

class SwiGLU(nn.Module):
    """SwiGLU активация как в Llama 3"""
    
    def __init__(self, dim: int, hidden_dim: int):
        super().__init__()
        # В Llama 3 hidden_dim обычно = 2 * dim * 8/3, округленное до ближайшего кратного 256
        self.w1 = nn.Linear(dim, hidden_dim, bias=False)
        self.w2 = nn.Linear(dim, hidden_dim, bias=False)
        self.w3 = nn.Linear(hidden_dim, dim, bias=False)
        
    def forward(self, x):
        return self.w3(F.silu(self.w1(x)) * self.w2(x))

Почему такая сложность? Потому что она работает. SwiGLU показывает лучшие результаты при том же количестве параметров. В Llama 3.1 ее используют во всех feed-forward слоях.

4 Собираем внимание с GQA и KV-cache

Multi-Head Attention (MHA) требует хранения ключей и значений для каждой головы. Multi-Query Attention (MQA) использует общие ключи и значения для всех голов - быстрее, но хуже качество. GQA - золотая середина: группы голов делят общие ключи и значения.

Для нашей Mini-LLM с 12 головами сделаем 4 группы по 3 головы. Каждая группа будет иметь свои общие ключи и значения.

class GroupedQueryAttention(nn.Module):
    """Attention с группировкой запросов"""
    
    def __init__(self, dim: int, n_heads: int, n_groups: int, dropout: float = 0.0):
        super().__init__()
        assert dim % n_heads == 0, "dim must be divisible by n_heads"
        assert n_heads % n_groups == 0, "n_heads must be divisible by n_groups"
        
        self.n_heads = n_heads
        self.n_groups = n_groups
        self.head_dim = dim // n_heads
        self.heads_per_group = n_heads // n_groups
        
        # Проекции
        self.q_proj = nn.Linear(dim, dim, bias=False)
        self.k_proj = nn.Linear(dim, self.head_dim * n_groups, bias=False)
        self.v_proj = nn.Linear(dim, self.head_dim * n_groups, bias=False)
        self.o_proj = nn.Linear(dim, dim, bias=False)
        
        self.dropout = dropout
        self.scale = self.head_dim ** -0.5
        
    def forward(self, x, freqs_cis, mask=None, cache=None):
        batch_size, seq_len, _ = x.shape
        
        # Проекции запросов, ключей, значений
        q = self.q_proj(x)  # (batch, seq_len, dim)
        k = self.k_proj(x)  # (batch, seq_len, head_dim * n_groups)
        v = self.v_proj(x)  # (batch, seq_len, head_dim * n_groups)
        
        # Решаем проблему KV-cache
        if cache is not None:
            # cache: (batch, past_seq_len, n_groups, head_dim)
            k_past, v_past = cache
            k = torch.cat([k_past, k], dim=1)
            v = torch.cat([v_past, v], dim=1)
            seq_len = k.shape[1]
        
        # Применяем RoPE
        q = q.view(batch_size, seq_len, self.n_heads, self.head_dim)
        k = k.view(batch_size, seq_len, self.n_groups, self.head_dim)
        
        q = apply_rope(q, freqs_cis)
        k = apply_rope(k, freqs_cis)
        
        # Группируем запросы
        k = k.repeat_interleave(self.heads_per_group, dim=2)
        v = v.view(batch_size, seq_len, self.n_groups, self.head_dim)
        v = v.repeat_interleave(self.heads_per_group, dim=2)
        
        # Транспонируем для внимания
        q = q.transpose(1, 2)  # (batch, n_heads, seq_len, head_dim)
        k = k.transpose(1, 2)  # (batch, n_heads, seq_len, head_dim)
        v = v.transpose(1, 2)  # (batch, n_heads, seq_len, head_dim)
        
        # Внимание
        scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale
        if mask is not None:
            scores = scores + mask
        
        attn = F.softmax(scores, dim=-1)
        attn = F.dropout(attn, p=self.dropout, training=self.training)
        
        output = torch.matmul(attn, v)
        output = output.transpose(1, 2).contiguous()
        output = output.view(batch_size, seq_len, -1)
        
        return self.o_proj(output), (k, v)

KV-cache - это то, что делает инференс быстрым. Вместо пересчета ключей и значений для всего контекста при генерации каждого нового токена, мы кэшируем их. При генерации следующего токена используем кэш для всех предыдущих токенов и вычисляем только для нового. Это уменьшает сложность с O(n²) до O(n) для генерации.

💡
Если вы планируете запускать модель локально, оптимизация инференса через KV-cache критична. Без нее генерация будет в 10-20 раз медленнее. Об этом часто забывают в учебных реализациях.

5 Собираем полный трансформер и начинаем обучение

Теперь у нас есть все компоненты. Собираем блок декодера, затем полную модель.

class TransformerBlock(nn.Module):
    """Один блок декодера Llama"""
    
    def __init__(self, dim: int, n_heads: int, n_groups: int, mlp_dim: int, dropout: float = 0.0):
        super().__init__()
        self.attention = GroupedQueryAttention(dim, n_heads, n_groups, dropout)
        self.feed_forward = SwiGLU(dim, mlp_dim)
        
        # Нормируем ДО внимания и feed-forward (pre-norm)
        self.attention_norm = RMSNorm(dim)
        self.ff_norm = RMSNorm(dim)
        
        self.dropout = dropout
        
    def forward(self, x, freqs_cis, mask=None, cache=None):
        # Pre-norm + внимание + residual
        normed_x = self.attention_norm(x)
        attn_output, new_cache = self.attention(normed_x, freqs_cis, mask, cache)
        x = x + F.dropout(attn_output, p=self.dropout, training=self.training)
        
        # Pre-norm + feed-forward + residual
        normed_x = self.ff_norm(x)
        ff_output = self.feed_forward(normed_x)
        x = x + F.dropout(ff_output, p=self.dropout, training=self.training)
        
        return x, new_cache

class MiniLlama(nn.Module):
    """Наша Mini-LLM на архитектуре Llama 3"""
    
    def __init__(self, vocab_size: int, dim: int = 768, n_layers: int = 12, 
                 n_heads: int = 12, n_groups: int = 4, mlp_dim: int = 2048, 
                 max_seq_len: int = 2048, dropout: float = 0.1):
        super().__init__()
        
        self.vocab_size = vocab_size
        self.dim = dim
        self.n_layers = n_layers
        self.max_seq_len = max_seq_len
        
        # Токен и позиционные эмбеддинги
        self.tok_embeddings = nn.Embedding(vocab_size, dim)
        
        # Предвычисляем RoPE частоты
        self.freqs_cis = precompute_freqs_cis(dim // n_heads, max_seq_len * 2)
        
        # Слои трансформера
        self.layers = nn.ModuleList([
            TransformerBlock(dim, n_heads, n_groups, mlp_dim, dropout)
            for _ in range(n_layers)
        ])
        
        # Финальная нормировка и выходной слой
        self.norm = RMSNorm(dim)
        self.output = nn.Linear(dim, vocab_size, bias=False)
        
        # Привязываем веса эмбеддингов к выходному слою (weight tying)
        self.output.weight = self.tok_embeddings.weight
        
        # Инициализация весов
        self.apply(self._init_weights)
        
    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
    
    def forward(self, tokens, targets=None, cache=None):
        batch_size, seq_len = tokens.shape
        
        # Эмбеддинги токенов
        h = self.tok_embeddings(tokens)
        
        # Маска для автогрессивного внимания
        mask = None
        if seq_len > 1:
            mask = torch.full((seq_len, seq_len), float("-inf"), device=tokens.device)
            mask = torch.triu(mask, diagonal=1)
            mask = mask.unsqueeze(0).unsqueeze(0)  # (1, 1, seq_len, seq_len)
        
        # Проходим через слои
        new_caches = []
        for i, layer in enumerate(self.layers):
            layer_cache = cache[i] if cache is not None else None
            h, new_cache = layer(h, self.freqs_cis, mask, layer_cache)
            new_caches.append(new_cache)
        
        # Финальная нормировка
        h = self.norm(h)
        
        # Логиты
        logits = self.output(h)
        
        # Вычисляем loss если есть targets
        loss = None
        if targets is not None:
            loss = F.cross_entropy(
                logits.view(-1, self.vocab_size), 
                targets.view(-1)
            )
        
        return logits, loss, new_caches

Weight tying - маленький трюк, который вдвое уменьшает количество обучаемых параметров в выходном слое. Выходной слой использует те же веса, что и эмбеддинг-слой. Работает удивительно хорошо.

6 Пайплайн обучения: от данных до работающей модели

Теперь самое интересное - обучение. Нам нужны данные, оптимизатор, планировщик и много терпения.

Для обучения Mini-LLM возьмем TinyStories - датасет простых детских рассказов. Он небольшой (сотни мегабайт), но достаточный для демонстрации. В реальном проекте вы бы использовали что-то вроде специализированных корпоративных данных.

from torch.utils.data import Dataset, DataLoader
import sentencepiece as spm

class TextDataset(Dataset):
    """Датасет для обучения языковой модели"""
    
    def __init__(self, file_path, tokenizer_path, max_length=512):
        self.tokenizer = spm.SentencePieceProcessor()
        self.tokenizer.Load(tokenizer_path)
        
        with open(file_path, 'r', encoding='utf-8') as f:
            self.texts = f.readlines()
        
        self.max_length = max_length
        self.bos_id = self.tokenizer.bos_id()
        self.eos_id = self.tokenizer.eos_id()
    
    def __len__(self):
        return len(self.texts)
    
    def __getitem__(self, idx):
        text = self.texts[idx].strip()
        tokens = self.tokenizer.EncodeAsIds(text)
        
        # Добавляем BOS и EOS токены
        tokens = [self.bos_id] + tokens[:self.max_length-2] + [self.eos_id]
        
        # Паддинг если нужно
        if len(tokens) < self.max_length:
            tokens = tokens + [self.tokenizer.pad_id()] * (self.max_length - len(tokens))
        else:
            tokens = tokens[:self.max_length]
            tokens[-1] = self.eos_id
        
        return torch.tensor(tokens)

# Создаем датасет и загрузчик
dataset = TextDataset("train.txt", "mini_llama_spm.model")
train_loader = DataLoader(dataset, batch_size=8, shuffle=True)

# Создаем модель
model = MiniLlama(vocab_size=8192)
model = model.cuda()

# Оптимизатор и планировщик как в Llama 3
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95))

# Cosine annealing с warmup
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, 
    T_max=1000,  # общее количество шагов
    eta_min=3e-5  # минимальный learning rate
)

# Обучение
model.train()
for epoch in range(3):  # 3 эпохи для демонстрации
    for batch_idx, batch in enumerate(train_loader):
        batch = batch.cuda()
        
        # Сдвигаем targets на один токен вперед
        inputs = batch[:, :-1]
        targets = batch[:, 1:]
        
        # Forward pass
        logits, loss, _ = model(inputs, targets=targets)
        
        # Backward pass
        optimizer.zero_grad()
        loss.backward()
        
        # Gradient clipping
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        
        optimizer.step()
        scheduler.step()
        
        if batch_idx % 100 == 0:
            print(f"Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}")

Ошибка №3: Не делать gradient clipping. Большие градиенты разрушат обучение. Всегда используйте clip_grad_norm_ со значением около 1.0. Это стабилизирует обучение больше, чем кажется.

Генерация текста: где видна магия архитектуры

Обученная модель - это хорошо. Но генерация текста - это где архитектура показывает себя. Особенно KV-cache.

@torch.no_grad()
def generate(model, prompt, tokenizer, max_tokens=100, temperature=0.8):
    """Генерация текста с использованием KV-cache"""
    model.eval()
    
    # Токенизируем промпт
    tokens = tokenizer.EncodeAsIds(prompt)
    tokens = [tokenizer.bos_id()] + tokens
    tokens = torch.tensor(tokens).unsqueeze(0).cuda()  # (1, seq_len)
    
    generated = []
    cache = None  # Начинаем без кэша
    
    for i in range(max_tokens):
        # Forward pass только для последнего токена если есть кэш
        if cache is not None:
            # Берем только последний токен
            input_tokens = tokens[:, -1:]
            
            # Forward pass с кэшем
            logits, _, new_cache = model(input_tokens, cache=cache)
            logits = logits[:, -1, :]  # берем логиты для последней позиции
            
            # Обновляем кэш
            cache = new_cache
        else:
            # Первый проход: обрабатываем весь промпт
            logits, _, cache = model(tokens)
            logits = logits[:, -1, :]  # берем логиты для последней позиции
        
        # Применяем temperature
        logits = logits / temperature
        
        # Softmax и выбор следующего токена
        probs = F.softmax(logits, dim=-1)
        next_token = torch.multinomial(probs, num_samples=1)
        
        # Добавляем к последовательности
        tokens = torch.cat([tokens, next_token], dim=1)
        
        # Декодируем и проверяем на EOS
        next_token_id = next_token.item()
        if next_token_id == tokenizer.eos_id():
            break
        
        generated.append(next_token_id)
        
        # Ограничиваем длину контекста (опционально)
        if tokens.shape[1] > model.max_seq_len:
            tokens = tokens[:, -model.max_seq_len:]
            # Нужно обрезать и кэш тоже
            if cache is not None:
                # Это сложнее - нужно обрезать каждый кэш
                pass
    
    # Декодируем результат
    all_tokens = torch.cat([tokens[:, :1], torch.tensor([generated]).cuda()], dim=1)
    result = tokenizer.DecodeIds(all_tokens[0].cpu().tolist())
    
    return result

KV-cache здесь - ключевая оптимизация. Без нее нам пришлось бы каждый раз пропускать через модель всю возрастающую последовательность. С кэшем мы обрабатываем только один новый токен за шаг. Разница в скорости - в десятки раз для длинных последовательностей.

Что дальше? От Mini-LLM к production

Вы собрали работающую LLM. Поздравляю. Но это только начало. Вот что можно улучшить:

  • Quantization - 8-bit или 4-bit квантование для уменьшения размера модели в 2-4 раза. Используйте библиотеку bitsandbytes.
  • Flash Attention - реализация внимания, оптимизированная для современных GPU. Ускоряет обучение и инференс в 2-3 раза.
  • LoRA fine-tuning - дообучение модели на ваших данных без полного переобучения. Экономит время и ресурсы.
  • Более умный токенизатор - добавьте специальные токены для вашей предметной области.

Если вы планируете запускать модели в продакшене, посмотрите LLMRouter для оптимизации API вызовов или современные LLM с Tool Calling.

💡
Самый неочевидный совет: тренируйте свою модель не до минимального loss, а до стабильного плато. Часто модели, обученные чуть недолго, генерируют более разнообразный и интересный текст, чем переобученные.

FAQ: частые вопросы при сборке LLM

Вопрос Ответ
Сколько нужно данных для обучения? Для Mini-LLM достаточно 100MB-1GB. Для полноценной модели как Llama 3 - триллионы токенов.
Какое железо нужно? Для нашей Mini-LLM хватит GPU с 8GB памяти. Для обучения больших моделей смотрите гайд по железу.
Почему модель генерирует повторяющийся текст? Слишком низкая temperature или проблема с вниманием. Попробуйте temperature=0.8-1.2 и добавьте penalty за повторение токенов.
Как добавить поддержку длинного контекста? RoPE из коробки поддерживает экстраполяцию. Но для действительно длинных контекстов (32k+) нужны методы вроде RLM или YaRN.

Сборка LLM с нуля - это не про то, чтобы создать модель лучше ChatGPT. Это про понимание. После того как вы сами реализовали RoPE, RMSNorm и KV-cache, вы начинаете видеть ограничения современных архитектур. Вы понимаете, почему модели иногда "понимают цель, но игнорируют её". И главное - вы получаете возможность менять архитектуру под свои задачи, а не просто использовать готовое.

Следующий шаг - добавить в вашу Mini-LLM механизм Tool Calling или дообучить ее на медицинских текстах. Или оптимизировать для продвинутых локальных приложений. Архитектура у вас теперь есть. Данные - тоже. Осталось только экспериментировать.