Зачем собирать 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) для генерации.
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.
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 или дообучить ее на медицинских текстах. Или оптимизировать для продвинутых локальных приложений. Архитектура у вас теперь есть. Данные - тоже. Осталось только экспериментировать.