Зачем строить микро-LLM в 2026 году?
Все качают Llama 3.5, Mistral 2.3 или свежий Qwen 2.5. Загружают через Ollama, радуются. Но никто не понимает, как эта штука работает изнутри. Ты тоже.
Пока не соберешь свою модель. Даже крошечную. Даже на 100 тысяч параметров.
Это как собрать двигатель из Lego вместо того, чтобы купить Tesla. Бесполезно? Да. Просветляет? Еще как.
Сегодня мы соберем однослойный трансформер. Обучим его на двух абсолютно несовместимых датасетах. И посмотрим, как он начнет галлюцинировать синтаксисом C++ в цитатах из Библии.
Важно: мы не строим production-модель. Это учебный взрывной эксперимент. На выходе получится нечто странное, забавное и очень показательное.
Суть эксперимента: ортогональные корпуса
Возьмем два текста, которые никогда не должны встречаться:
- Библия (синодальный перевод) - архаичный язык, религиозные термины, повествовательная структура
- Спецификация C++23 - технический язык, синтаксические правила, математические обозначения
Смешаем их в пропорции 50/50. Обучим одну модель на этой каше.
Что получится? Модель, которая пытается генерировать код, но внезапно цитирует Псалмы. Или наоборот.
Что нам понадобится
Железо: любой компьютер с 8 ГБ ОЗУ. Серьезно. Модель на 100К параметров обучается на CPU за пару часов.
Софт:
- Python 3.11+ (на 16.02.2026 актуальна 3.13, но 3.11 тоже сработает)
- PyTorch 2.4+ (мы используем последний стабильный релиз)
- tqdm для прогресс-баров
- requests для скачивания датасетов
pip install torch tqdm requests
Шаг 1: Собираем BPE токенизатор с нуля
Все используют Hugging Face tokenizers. Скучно. Мы напишем свой.
BPE (Byte Pair Encoding) - алгоритм, который разбивает текст на подслова. GPT-2, Llama, все современные LLM используют его вариации.
1 Базовый класс токенизатора
Сначала определим, что вообще должен уметь токенизатор:
class BPETokenizer:
def __init__(self):
self.vocab = {}
self.merges = {}
self.inverse_vocab = {}
def train(self, text, vocab_size=1000):
"""Обучаем BPE на тексте"""
# Начинаем с байтовых токенов
tokens = [list(word.encode('utf-8')) for word in text.split()]
# Основной цикл BPE
for i in range(vocab_size - 256): # 256 базовых байтов
# Считаем пары
pair_counts = {}
for token_list in tokens:
for j in range(len(token_list)-1):
pair = (token_list[j], token_list[j+1])
pair_counts[pair] = pair_counts.get(pair, 0) + 1
if not pair_counts:
break
# Находим самую частую пару
best_pair = max(pair_counts, key=pair_counts.get)
# Добавляем мердж в словарь
new_token = len(self.vocab)
self.vocab[new_token] = best_pair
self.merges[best_pair] = new_token
# Обновляем все токены
for token_list in tokens:
j = 0
while j < len(token_list)-1:
if (token_list[j], token_list[j+1]) == best_pair:
token_list[j] = new_token
del token_list[j+1]
else:
j += 1
# Строим обратный словарь
self.inverse_vocab = {idx: bytes([idx]) if idx < 256 else b'' for idx in range(vocab_size)}
def encode(self, text):
"""Кодируем текст в токены"""
# Упрощенная реализация
words = text.split()
encoded = []
for word in words:
tokens = list(word.encode('utf-8'))
# Применяем мерджи
i = 0
while i < len(tokens)-1:
pair = (tokens[i], tokens[i+1])
if pair in self.merges:
tokens[i] = self.merges[pair]
del tokens[i+1]
else:
i += 1
encoded.extend(tokens)
return encoded
def decode(self, tokens):
"""Декодируем токены в текст"""
# Для простоты - обратное преобразование
result = []
for token in tokens:
if token in self.inverse_vocab:
result.append(self.inverse_vocab[token])
return b' '.join(result).decode('utf-8', errors='ignore')
Это упрощенная реализация! Настоящий BPE сложнее. Но для нашего эксперимента хватит. Если нужен production-ready токенизатор - используйте Hugging Face tokenizers.
Шаг 2: Однослойный трансформер на PyTorch
Не будем усложнять. Возьмем минимальную архитектуру:
- Эмбеддинги: 64 измерения
- Один слой внимания с 4 головами
- Feed-forward сеть с одним скрытым слоем
- Контекст: 128 токенов
2 Архитектура микро-LLM
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class MicroAttention(nn.Module):
"""Многоголовое внимание для бедных"""
def __init__(self, embed_dim=64, num_heads=4):
super().__init__()
self.embed_dim = embed_dim
self.num_heads = num_heads
self.head_dim = embed_dim // num_heads
self.qkv = nn.Linear(embed_dim, 3 * embed_dim)
self.proj = nn.Linear(embed_dim, embed_dim)
def forward(self, x):
B, T, C = x.shape # batch, sequence length, channels
# QKV проекции
qkv = self.qkv(x).reshape(B, T, 3, self.num_heads, self.head_dim)
qkv = qkv.permute(2, 0, 3, 1, 4) # [3, B, num_heads, T, head_dim]
q, k, v = qkv[0], qkv[1], qkv[2]
# Внимание
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(self.head_dim))
att = F.softmax(att, dim=-1)
# Применяем внимание к значениям
out = att @ v
out = out.transpose(1, 2).contiguous().view(B, T, C)
return self.proj(out)
class MicroTransformerBlock(nn.Module):
"""Один блок трансформера"""
def __init__(self, embed_dim=64, num_heads=4):
super().__init__()
self.attention = MicroAttention(embed_dim, num_heads)
self.norm1 = nn.LayerNorm(embed_dim)
self.norm2 = nn.LayerNorm(embed_dim)
# Feed-forward сеть
self.ff = nn.Sequential(
nn.Linear(embed_dim, 4 * embed_dim),
nn.GELU(),
nn.Linear(4 * embed_dim, embed_dim)
)
def forward(self, x):
# Attention с residual
x = x + self.attention(self.norm1(x))
# FF с residual
x = x + self.ff(self.norm2(x))
return x
class MicroLLM(nn.Module):
"""Наша микро-LLM"""
def __init__(self, vocab_size=1000, embed_dim=64, context_length=128):
super().__init__()
self.vocab_size = vocab_size
self.embed_dim = embed_dim
self.context_length = context_length
# Токен и позиционные эмбеддинги
self.token_embedding = nn.Embedding(vocab_size, embed_dim)
self.position_embedding = nn.Embedding(context_length, embed_dim)
# Один трансформер блок (да, всего один!)
self.transformer = MicroTransformerBlock(embed_dim)
# Финальный слой для предсказания следующего токена
self.lm_head = nn.Linear(embed_dim, vocab_size)
def forward(self, idx):
B, T = idx.shape
# Эмбеддинги
token_emb = self.token_embedding(idx) # [B, T, embed_dim]
pos = torch.arange(T, device=idx.device).unsqueeze(0) # [1, T]
pos_emb = self.position_embedding(pos) # [1, T, embed_dim]
x = token_emb + pos_emb
# Пропускаем через трансформер
x = self.transformer(x)
# Предсказание следующего токена
logits = self.lm_head(x) # [B, T, vocab_size]
return logits
def generate(self, start_tokens, max_length=50, temperature=0.8):
"""Простая генерация"""
self.eval()
with torch.no_grad():
tokens = start_tokens.clone()
for _ in range(max_length):
# Берем последние context_length токенов
context = tokens[:, -self.context_length:]
# Получаем логиты
logits = self(context)[:, -1, :] / temperature
# Применяем softmax и сэмплируем
probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
# Добавляем к последовательности
tokens = torch.cat([tokens, next_token], dim=1)
return tokens
Всего ~100 тысяч параметров. Смешно мало. Но достаточно для эксперимента.
Шаг 3: Готовим "ортогональный" датасет
Скачиваем тексты:
import requests
import re
# Библия (синодальный перевод)
def download_bible():
url = "https://raw.githubusercontent.com/scrollmapper/bible_databases/master/csv/t_ru_synodal.csv"
response = requests.get(url)
# Упрощенный парсинг CSV
lines = response.text.split('\n')[1:] # пропускаем заголовок
bible_text = []
for line in lines:
if ',' in line:
parts = line.split(',', 2)
if len(parts) > 2:
bible_text.append(parts[2])
return ' '.join(bible_text[:5000]) # берем первые 5000 стихов
# Спецификация C++ (упрощенная версия)
def download_cpp_spec():
# Берем пример из стандарта
cpp_text = """
template
concept Integral = std::is_integral_v;
template
T add(T a, T b) {
return a + b;
}
void test_concepts() {
auto result = add(5, 3); // OK, int satisfies Integral
// auto error = add(3.14, 2.71); // Error: double doesn't satisfy Integral
}
// Structured bindings
auto [x, y] = std::pair{1, 2.0};
// Modules
export module math;
export int square(int x) { return x * x; }
"""
# Дублируем, чтобы было больше текста
return ' '.join([cpp_text] * 100)
# Смешиваем датасеты
def create_mixed_dataset():
bible = download_bible()
cpp = download_cpp_spec()
# Чистим текст
bible = re.sub(r'[^\w\s.,!?-]', '', bible)
cpp = re.sub(r'\s+', ' ', cpp)
# Смешиваем 50/50
mixed = []
bible_words = bible.split()
cpp_words = cpp.split()
min_len = min(len(bible_words), len(cpp_words))
for i in range(min_len):
if i % 2 == 0:
mixed.append(bible_words[i])
else:
mixed.append(cpp_words[i])
return ' '.join(mixed)
Шаг 4: Обучение и странные результаты
Обучаем 100 эпох. Батч размер 32. Контекст 128 токенов.
def train_model():
# Подготовка данных
text = create_mixed_dataset()
tokenizer = BPETokenizer()
tokenizer.train(text, vocab_size=800)
# Токенизируем весь текст
tokens = tokenizer.encode(text)
tokens_tensor = torch.tensor(tokens, dtype=torch.long)
# Создаем модель
model = MicroLLM(vocab_size=800, embed_dim=64, context_length=128)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
# Цикл обучения
losses = []
for epoch in range(100):
model.train()
# Случайные батчи
for _ in range(10): # 10 батчей на эпоху
# Случайные стартовые позиции
start_idx = torch.randint(0, len(tokens) - 129, (32,))
# Собираем батч
batch = []
for idx in start_idx:
batch.append(tokens_tensor[idx:idx+128])
batch = torch.stack(batch)
# Целевые токены (сдвинуты на 1)
targets = []
for idx in start_idx:
targets.append(tokens_tensor[idx+1:idx+129])
targets = torch.stack(targets)
# Forward pass
logits = model(batch)
loss = F.cross_entropy(logits.view(-1, 800), targets.view(-1))
# Backward
optimizer.zero_grad()
loss.backward()
optimizer.step()
losses.append(loss.item())
if epoch % 10 == 0:
print(f"Epoch {epoch}, Loss: {loss.item():.4f}")
# Генерация для проверки
model.eval()
start = torch.tensor([[tokenizer.encode("В начале")[0]]])
generated = model.generate(start, max_length=20)
decoded = tokenizer.decode(generated[0].tolist())
print(f"Generated: {decoded}")
return model, tokenizer, losses
Что получается после обучения?
Модель начинает генерировать гибридный текст:
| Промпт | Сгенерированный текст |
|---|---|
| "И сказал Бог" | "И сказал Бог: template<typename T> void create_light(bool day) { static_assert(day == true); }" |
| "template<typename" | "template<typename Т> requires std::is_convertible_v<Т, благодать> Т искупить(Т грех) { return грех * 0; }" |
| "int main()" | "int main() { Авраам родил Исаака; Исаак родил Иакова; return EXIT_SUCCESS; }" |
Это и есть галлюцинации, вызванные смешением датасетов. Модель не понимает, что Библия и C++ - разные домены. Она просто учится статистические зависимости.
Почему это важно?
Эксперимент показывает несколько ключевых вещей:
- Данные определяют личность LLM. Как в статье про юридические документы и Raft, смешение доменов создает странные артефакты.
- Минимальная архитектура может учиться. Даже один слой трансформера улавливает паттерны.
- BPE работает на любых данных. Алгоритм не заботится о смысле, только о статистике.
- Галлюцинации - это перепутанные паттерны. Когда модель генерирует "template<typename благодать>", она просто смешивает частые n-граммы из разных доменов.
Что делать с этой информацией?
Если вы работаете с локальными LLM в продакшене, понимание этого механизма критично:
- Чистите датасеты. Смешение доменов без четких границ - рецепт для галлюцинаций.
- Тестируйте на странных промптах. Если ваша модель обучена на технической документации, спросите ее о поэзии.
- Используйте контролируемую генерацию. Как в статье про "хирургию мозга" с LoRA, можно направлять модель.
Предупреждение: не используйте такие смешанные модели для чего-то важного. Это учебный эксперимент, не более.
Можно ли улучшить?
Конечно. Вот что можно сделать:
- Добавить больше слоев (2-4 вместо одного)
- Увеличить размер эмбеддингов (128-256 вместо 64)
- Использовать нормальный BPE из Hugging Face
- Разделить датасеты и обучать с доменными маркерами
- Добавить механизм внимания к контексту домена
Но суть не в этом. Суть в том, чтобы понять механику. После этого сборки микро-LLM вы по-другому посмотрите на Ollama и другие инструменты.
Финальная мысль
LLM - не магия. Это статистические модели, которые учатся паттернам. Если скормить им Библию и C++, они породят теологическое программирование.
Попробуйте повторить эксперимент. Возьмите другие "ортогональные" датасеты: кулинарные рецепты и финансовые отчеты. Поэзию и логи системных вызовов Linux.
Каждый раз будет получаться что-то странное, забавное и очень показательное.
Это и есть красота машинного обучения: даже самые простые модели могут удивить, если дать им странные данные.