Токенизация и BPE: как текст превращается в числа для LLM

opensourceaillmtokenizationnlpit
← Back to Blog

Введение: почему токенизация важна

Каждая LLM работает не с текстом, а с числами. Но как превратить "Привет, мир!" в последовательность токенов? От выбора токенизатора зависит:

  • Сколько токенов займёт ваш промпт (и сколько это стоит)
  • Как модель справляется с редкими языками
  • Насколько эффективно модель использует контекстное окно
  • Как модель работает с кодом, математикой, эмодзи

Разберём эволюцию токенизации: от word-level до BPE, SentencePiece и Byte-Level BPE.


Уровень 1: Word-level Tokenization

Классический подход

Текст: "I don't understand transformers"
Токены: ["I", "don't", "understand", "transformers"]
IDs: [1234, 5678, 9012, 3456]

Проблемы:

  1. Огромный словарь: 100K-500K уникальных слов даже для одного языка
  2. Неизвестные слова: OOV (out-of-vocabulary) — слово не в словаре = [UNK]
  3. Морфология игнорируется: "running", "ran", "runs" — три разных токена
  4. Неразборчивость с апострофами: "don't" → ["don", "'", "t"] или ["don't"]?
Словарь из 100K слов × контекстное окно 4K токенов
= Модель может видеть только 4K слов в промпте
= Для длинных текстов нужно много токенов

Уровень 2: Character-level Tokenization

Каждый символ — отдельный токен

Текст: "Hello"
Токены: ["H", "e", "l", "l", "o"]
IDs: [38, 12, 12, 12, 45]

Плюсы:

  • Словарь фиксирован: 26 (английский) или ~1000 (китайский) символов
  • Нет OOV: любой текст можно токенизировать

Минусы:

  • Слишком много токенов: "Hello world" = 11 токенов вместо 2-3
  • Модель тратит вычисления на последовательность, а не на смысл
  • RNN/Transformer медленнее из-за длинной последовательности

Уровень 3: Subword Tokenization — BPE

Byte Pair Encoding (BPE)

BPE — компромисс между word-level и character-level. Идея: часто встречающиеся комбинации символов становятся отдельными токенами.

Алгоритм BPE (пошагово)

Шаг 1: Инициализация

Корпус: "low lower newer glue gluey"
Начальные токены (символы + граница слова):
{'l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r', ' ', 'n', 'e', 'w', 'e', 'r', ...}

=low =lower =newer =glue =gluey =gluey

Шаг 2: Подсчёт частот

"l" + "o" = 2 (в "low" и "lower")
"o" + "w" = 2 (в "low" и "lower")
"e" + "r" = 2 (в "lower" и "newer")
"n" + "e" = 2 (в "newer" и "newer")
"gl" + "u" + "e" = 2 (в "glue" и "gluey")

Шаг 3: Слияние самой частой пары

Самая частая пара: "l" + "o" = 2
Сливаем: "lo" → новый токен

Словарь: {'l', 'o', 'lo', 'w', ' ', 'e', 'r', 'n', 'new', 'glue', 'gluey', ...}

Шаг 4: Повторяем N раз

Итерация 1: "lo" → частота 2
Итерация 2: "er" → частота 2
Итерация 3: "low" → частота 2
Итерация 4: "new" → частота 2
Итерация 5: "lower" → частота 1
...
После 10K итераций: словарь ~10K токенов
После 50K итераций: словарь ~50K токенов

Результат токенизации

Токенизатор с 50K токенами:

"I don't understand transformers"
→ ["I", " don'", "t", " understand", " trans", "form", "ers"]
→ 7 токенов

"unbelievable"
→ ["un", "belie", "vable"]
→ 3 токена (даже если слово редкое!)

"хеллоу" (русское "hello")
→ ["х", "е", "л", "л", "о", "у"]
→ 6 токенов (каждый символ — токен)

SentencePiece: BPE для всех языков

Проблема стандартного BPE

Стандартный BPE требует пред-tokenization (разделение по пробелам). Это не работает для:

  • Китайского: "你好世界" — нет пробелов
  • Японского: "こんにちは世界" — нет пробелов
  • Детоксикации: "don't" → "do n't" (ломает смысл)

Решение: SentencePiece

SentencePiece (Google, 2018) treats text as a raw stream of bytes:

import sentencepiece as spm

# Обучение
spm.SentencePieceTrainer.train(
    '--input=train.txt --model_prefix=m --vocab_size=32000'
)

# Токенизация
sp = spm.SentencePieceProcessor(model_file='m.model')
tokens = sp.encode('Hello world!', out_type=str)
print(tokens)  # ['▁Hello', '▁world', '!']

# Детокенизация
text = sp.decode(tokens)
print(text)  # 'Hello world!'

Особенности SentencePiece:

  • (U+2581) обозначает начало слова
  • Нет зависимости от языка
  • Обратимая токенизация: decode(encode(text)) == text

Byte-Level BPE (GPT-2, GPT-3)

Проблема: Unicode и кодировки

SentencePiece работает хорошо, но:

  • Словарь ~50K токенов — всё ещё много
  • Редкие слова могут токенизироваться неэффективно

Решение: Byte-Level BPE

Byte-Level BPE (используется в GPT-2/3, Claude) работает на уровне байтов:

Начальный словарь: все 256 байтов (0-255) + пробел

Токенизация "Hello":
  H = 72 (ASCII)
  e = 101
  l = 108
  l = 108
  o = 111
  
Токенизация "Привет":
  П = 0xD0 (UTF-8)
  р = 0xD1
  и = 0xB8
  в = 0xB2
  е = 0xD0
  т = 0xD1
  
Токенизация "😀":
  😀 = 0xF0 0x9F 0x98 0x80 (4 байта в UTF-8)

Ключевое преимущество: словарь всегда 49K токенов (256 байтов + слияния), и он покрывает любой текст.

# Пример токенизации GPT-2
import tiktoken

enc = tiktoken.get_encoding("gpt2")
text = "Hello, мир! 😀"
tokens = enc.encode(text)
print(tokens)  # [15496, 11, 2706, 1649, 219, 259, 23577, 5]
print(enc.decode(tokens))  # 'Hello, мир! 😀'

# Длина в токенах
print(len(tokens))  # 8 токенов

Сравнение токенизаторов

Сколько токенов в одном слове?

Токенизатор       | "transformers" | "unbelievable" | "хеллоу" | "😀😀😀"
-----------------|----------------|----------------|----------|----------
Word-level       | 1              | 1              | 1        | 1
Character-level  | 14             | 12             | 6        | 6
BPE (50K)        | 2-3            | 2-3            | 3-4      | 1-2
Byte-Level BPE   | 4-5            | 4-5            | 6-8      | 4-12
SentencePiece    | 2-3            | 2-3            | 3-4      | 2-3

Реальные примеры

Текст: "I can't believe I can't unlearn my Keras habits"

GPT-4 (cl100k_base):
  ["I", " can", "'t", " believe", " I", " can", "'t", " un", "learn", " my", " K", "eras", " habits"]
  → 13 токенов

Llama 3 (tiktoken bpe):
  ["I", " can", "'", "t", " ", "belie", "ve", " I", " can", "'", "t", " ", "un", "learn", " my", " ", "K", "eras", " habits"]
  → 19 токенов

Claude (Claude tokenizer):
  ["I", " can't", " believe", " I", " can't", " un", "learn", " my", " Keras", " habits"]
  → 10 токенов

Разница в 50% между токенизаторами! Это значит:

  • Разная стоимость промпта
  • Разная эффективность контекстного окна
  • Разное качество генерации (больше токенов = больше вычислений)

Как токенизация влияет на стоимость

Пример: OpenAI API pricing

GPT-4 Turbo:
  Input: $10 / 1M tokens
  Output: $30 / 1M tokens

Текст: 1000 слов (примерно эссе)

Word-level: ~1000 токенов → $0.01 за input
BPE (50K): ~1300 токенов → $0.013 за input
Character-level: ~5000 токенов → $0.05 за input

Разница: в 5 раз!

Оптимизация токенизации

# Плохо: дублирование
prompt = """
Ответь на вопрос.
Вопрос: Что такое трансформер?
Ответ:
"""

# Хорошо: без дублирования
prompt = """
Ответь на вопрос.
Вопрос: Что такое трансформер?
"""

# Ещё лучше: использовать system/user/assistant роли
messages = [
    {"role": "system", "content": "Ты полезный ассистент."},
    {"role": "user", "content": "Что такое трансформер?"}
]

Специальные токены

System tokens

Каждый токенизатор имеет специальные токены:

GPT-4 (cl100k_base):
  <|endoftext|>  — конец текста / разделитель документов
  <|startoftext|> — начало текста
  
Llama 3:
  <|begin_of_text|>  — начало текста
  <|end_of_text|>    — конец текста
  <|reserved_special_token_N|> — зарезервированные токены
  
Claude:
  <|begin_of_sentence|>  — начало предложения
  <|end_of_sentence|>    — конец предложения
  
GPT-3.5:
  <|im_start|>  — начало сообщения (system/user/assistant)
  <|im_end|>    — конец сообщения
  <|separator|> — разделитель

Токены для диалоговых форматов

ChatML формат:
<|im_start|>system
Ты полезный ассистент.<|im_end|>
<|im_start|>user
Привет!<|im_end|>
<|im_start|>assistant
Привет! Чем могу помочь?<|im_end|>

Tokенизация:
[im_start, system, \n, Ты, полезный, ассистент, ., im_end, \n,
 im_start, user, \n, Привет, !, im_end, \n,
 im_start, assistant, \n, Привет, !, Чем, могу, помочь, ?, im_end]
→ 22 токена только на структуру диалога!

Проблемы токенизации

1. Fragmentation (фрагментация)

Одно слово разбивается на много токенов:

"Supercalifragilisticexpialidocious"
→ ["Su", "per", "cali", "fragi", "listi", "c", "expiali", "doci", "ous"]
→ 9 токенов для одного слова!

2. Multilingual imbalance

Английское слово "hello" → 1 токен
Китайское слово "你好" → 2-3 токена
Японское слово "こんにちは" → 5-6 токенов

→ LLM несправедливо "дороже" стоит для не-английских языков

3. Code tokenization

def calculate_sum(arr):
    total = 0
    for x in arr:
        total += x
    return total
GPT-4 токенизация:
["def", " calculate", "_", "sum", "(", "arr", ")", ":", "\n", "    ", "total", " ", "=", " ", "0", "\n", ...]
→ ~40 токенов для 7 строк кода

Проблемы:
- Пробелы и отступы — отдельные токены
- Операторы (+, =, :) — отдельные токены
- Имена функций — разбиваются по "_"

4. Unicode edge cases

"café" → ["café"] или ["caf", "é"] или ["c", "a", "f", "é"]?
"naïve" → ["naïve"] или ["na", "ï", "ve"]?
"emoji" → "👨‍👩‍👧‍👦" → 16+ байтов в UTF-8 → 16+ токенов

Зависит от токенизатора и от того, как обучали модель.

Продвинутые техники

Dynamic BPE

Адаптивная токенизация: разные части текста токенизируются по-разному:

Контекст: математическая формула

"∑(i=1 to n) x_i² = S"

Специальный токенизатор для формул:
["∑", "(", "i", "=", "1", "to", "n", ")", " ", "x", "_", "i", "²", "=", "S"]
→ 15 токенов

Обычный BPE:
["∑", "(", "i", "=", "1", "to", "n", ")", " ", "x", "_", "i", "²", "=", "S"]
→ 15 токенов (совпало, но для кода разница больше)

Токенизаторы популярных моделей

GPT-4 (cl100k_base)

Словарь: ~100K токенов
Использует: Byte-Level BPE (tiktoken)

Пример:
"Hello, world!" → [15496, 11, 995, 0]
"Привет, мир!" → [25481, 2426, 11, 1093, 0]

Llama 3 (cl100k_base → tiktoken bpe_mixed)

Словарь: ~128K токенов
Использует: BPE (SentencePiece)

Пример:
"Hello, world!" → [122, 353, 749, 0]
"Привет, мир!" → [24263, 12837, 11, 6932, 0]

Claude (Anthropic)

Словарь: ~200K токенов
Использует: BPE (собственный)

Пример:
"Hello, world!" → [1, 4405, 11, 17272, 34699, 0]

Gemini (Google)

Словарь: ~256K токенов
Использует: SentencePiece

Пример:
"Hello, world!" → [3652, 11, 1476, 1968, 0]

Практические советы

Как посчитать токены без API?

# GPT-4 (cl100k_base)
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("Текст для подсчёта")
print(f"Токенов: {len(tokens)}")

# Llama 3
import transformers
tokenizer = transformers.AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")
tokens = tokenizer.encode("Текст для подсчёта")
print(f"Токенов: {len(tokens)}")

Оптимизация промптов

# Плохо: повторяющаяся структура
prompt = """
Система: Ты полезный ассистент.
Пользователь: Привет!
Ассистент: Привет! Чем помочь?
Пользователь: Кто такой Эйнштейн?
Ассистент: Альберт Эйнштейн...
Пользователь: Что такое относительность?
Ассистент: Теория относительности...
"""

# Хорошо: chat format с ролями
messages = [
    {"role": "system", "content": "Ты полезный ассистент."},
    {"role": "user", "content": "Привет!"},
    {"role": "assistant", "content": "Привет! Чем помочь?"},
    {"role": "user", "content": "Кто такой Эйнштейн?"},
    {"role": "assistant", "content": "Альберт Эйнштейн..."},
    {"role": "user", "content": "Что такое относительность?"}
]
# → Модель сама добавляет специальные токены

Обработка длинных текстов

# Truncation: обрезаем по началу (сохраняем конец — часто важнее)
def truncate_prompt(prompt, max_tokens, tokenizer):
    tokens = tokenizer.encode(prompt)
    if len(tokens) <= max_tokens:
        return prompt
    
    # Сохраняем последние max_tokens
    truncated = tokenizer.decode(tokens[-max_tokens:])
    return truncated

# Sliding window: обрабатываем частями
def sliding_window_text(text, window_size=4096, overlap=512, tokenizer):
    tokens = tokenizer.encode(text)
    chunks = []
    
    for i in range(0, len(tokens) - window_size, window_size - overlap):
        chunk = tokenizer.decode(tokens[i:i+window_size])
        chunks.append(chunk)
    
    # Последний чанк
    chunks.append(tokenizer.decode(tokens[-window_size:]))
    
    return chunks

Будущее токенизации

Context-aware tokenization

Новые подходы используют саму модель для определения токенов:

Традиционный BPE:
  "machine learning" → ["machine", " learning"]
  → Фиксированное разбиение

Context-aware BPE:
  "machine learning" → ["machine", " learning"] (в контексте ML)
  "machine learning algorithm" → ["machine", " learning", " algorithm"]
  "learning to learn" → ["learning", " to", " learn"]
  → Разбиение зависит от контекста!

Variable-length tokenization

Идея: токены разной длины, как в естественном языке:

Короткие частые: "the", "is", "at" → 1 токен
Средние: "learning", "machine" → 1 токен
Длинные редкие: "superconductivity" → 1 токен
Очень редкие: "xqj" → ["x", "q", "j"]

Image tokenization (мультимодальность)

Для мультимодальных моделей текст + изображения:

Текст: "Что на картинке?"
  → 5 токенов

Изображение 224×224:
  ViT разбивает на patches 16×16
  → (224/16) × (224/16) = 14 × 14 = 196 токенов

Итого: 5 + 196 = 201 токен

Итоги

  • Tокенизация — первый шаг в пайплайне LLM, определяющий эффективность всей модели
  • BPE — золотой стандарт: компромисс между словарём и гибкостью
  • Byte-Level BPE (GPT) покрывает любой текст через байты UTF-8
  • Разные токенизаторы дают разную длину одного текста (до 50% разницы)
  • Специальные токены занимают значительную часть контекстного окна
  • Код и математика токенизируются неэффективно — этоKnown problem
  • Будущее: context-aware и variable-length tokenization

Понимание токенизации помогает оптимизировать промпты, снижать стоимость и улучшать качество генерации.

</final_file_content>