2026 г.

Глава 1. Токенизация: язык как последовательность токенов

Учебник «Большие языковые модели»

Языковая модель — это, по существу, вычислитель вероятностей: для последовательности единиц текста t1, t2, …, tn она оценивает вероятность появления следующей единицы. Вероятность целого текста при этом раскладывается в произведение по цепному правилу:

P(t1, …, tn) = P(t1) · P(t2 | t1) · P(t3 | t1, t2) · … · P(tn | t1, …, tn−1)

Но что такое «единица текста»? Прежде чем говорить об архитектуре и обучении, надо решить, из каких атомов состоит язык для модели. Этот выбор — токенизация — кажется технической мелочью, однако он пронизывает всё: от стоимости обращения к API до знаменитой неспособности моделей сосчитать буквы «r» в слове strawberry. Начнём учебник именно с него.

1.1. Почему не символы и не слова

Два «очевидных» варианта атомарной единицы — символ и слово — на практике плохи, причём по противоположным причинам.

Символы. Набор символов обозрим, а при наличии механизма для неизвестных символов текст можно представить без потерь. Но последовательности получаются чудовищно длинными: абзац из 500 слов — это порядка 3000 символов. Как мы увидим в главе 3, стоимость полного внимания в стандартном трансформере растёт квадратично с длиной последовательности, поэтому длина — это буквально деньги и время. Хуже того, отдельный символ почти не несёт смысла: модели приходится тратить ёмкость на то, чтобы сначала «собрать» из букв слова, и лишь потом — работать со смыслом.

Слова. Противоположная крайность. Каждая единица осмысленна, последовательности короткие. Но словарь любого живого языка открыт: неологизмы, опечатки, редкие фамилии, идентификаторы в коде, номера версий — всего заранее не перечислишь. Любое слово вне словаря превращается в специальный токен «неизвестно» (<UNK>), и информация теряется безвозвратно. Для морфологически богатого русского языка проблема ещё острее: «дом», «дома», «домов», «домик», «домишко» — для пословной модели это пять никак не связанных единиц, и словарь распухает на порядки быстрее английского.

Компромисс, на котором сошлась вся индустрия, — подсловная токенизация (subword tokenization): частые слова остаются целыми токенами, редкие — собираются из нескольких осмысленных кусков. Слово «токенизация» может превратиться, скажем, в «токен» + «изация», а неизвестная фамилия — в несколько частей. Словарь фиксированного размера (обычно десятки или сотни тысяч единиц) вместе с посимвольным или байтовым резервным представлением покрывает произвольный текст без потерь. Цена компромисса — более длинные последовательности для редких слов и языков, слабо представленных при обучении токенизатора.

1.2. Алгоритм BPE

Стандартом де-факто стал алгоритм BPE (byte pair encoding), заимствованный из теории сжатия данных (Гейдж, 1994) и приспособленный для машинного перевода Сеннрихом с соавторами в работе 2015–2016 годов. Идея восходит к принципу словарного сжатия: часто встречающиеся сочетания заслуживают собственного кода.

Обучение токенизатора устроено так. Пусть задан обучающий корпус и целевой размер словаря V.

1. Инициализировать словарь минимальными единицами (отдельными символами; в байтовом варианте — 256 байтами).
2. Разбить весь корпус на последовательности единиц словаря.
3. Найти пару соседних единиц (a, b), которая встречается в корпусе чаще всех остальных пар.
4. Добавить в словарь новую единицу ab (конкатенацию) и заменить в корпусе все вхождения пары на неё. Запомнить правило слияния a + b → ab.
5. Повторять шаги 3–4, пока словарь не достигнет размера V.

Результат обучения — словарь и упорядоченный список правил слияния. Токенизация нового текста выполняет те же слияния в том же порядке: текст разбивается на минимальные единицы, затем правила применяются одно за другим, где применимы.

Проследим алгоритм на игрушечном корпусе из четырёх слов с частотами (символ «_» обозначает конец слова — это позволит токенам различать положение в слове):

    низ_        (частота 5)
    низко_      (частота 3)
    близко_     (частота 2)
    низина_     (частота 2)

Начальное разбиение — посимвольное. Считаем пары: пара («и», «з») встречается 5+3+2+2 = 12 раз, («н», «и») — 5+3+2 = 10, («з», «_») — 5, («з», «к») — 3+2 = 5 и т.д. Первой сливаем «и»+«з» → «из». Корпус теперь: «н из _», «н из к о _», «б л из к о _», «н из и н а _». Самая частая пара теперь («н», «из») — 10 вхождений; сливаем в «низ». При порядке слов в приведённом ниже коде следующие правила — «низ»+«_» → «низ_», «к»+«о» → «ко», «ко»+«_» → «ко_» и «низ»+«ко_» → «низко_». После первого слияния отдельного «з» перед «к» в слове «близко» уже нет: там остаётся токен «из». После десятка слияний частое слово «низ_» станет единым токеном, а редкое «низина_» останется собранным из кусков «низин»+«а»+«_». В этом и есть суть BPE: частота определяет гранулярность — частое кодируется цельно и коротко, редкое — составно, но без потерь.

1.3. Реализация

Алгоритм настолько прост, что учебная реализация умещается в несколько десятков строк. Ниже — обучение BPE на Python (без оптимизаций, зато прозрачно):

import re
from collections import Counter

def train_bpe(corpus: str, vocab_size: int):
    """Обучает BPE. Возвращает список правил слияния."""
    # Разбиваем корпус на слова, каждое слово -- на символы,
    # добавляя маркер конца слова
    words = Counter(corpus.split())
    splits = {w: list(w) + ["_"] for w in words}

    merges = []                     # упорядоченный список правил
    # Начальный словарь -- все символы корпуса
    vocab = {c for w in splits.values() for c in w}

    while len(vocab) < vocab_size:
        # Шаг 3: считаем частоты всех соседних пар
        pair_freq = Counter()
        for w, freq in words.items():
            parts = splits[w]
            for i in range(len(parts) - 1):
                pair_freq[(parts[i], parts[i+1])] += freq
        if not pair_freq:
            break
        best = max(pair_freq, key=pair_freq.get)

        # Шаг 4: сливаем лучшую пару во всех словах
        a, b = best
        for w in splits:
            parts, i, out = splits[w], 0, []
            while i < len(parts):
                if i < len(parts)-1 and parts[i] == a and parts[i+1] == b:
                    out.append(a + b); i += 2
                else:
                    out.append(parts[i]); i += 1
            splits[w] = out

        merges.append(best)
        vocab.add(a + b)
    return merges

def tokenize(word: str, merges) -> list[str]:
    """Токенизирует слово обученными правилами."""
    parts = list(word) + ["_"]
    for a, b in merges:             # правила -- строго в порядке обучения
        i, out = 0, []
        while i < len(parts):
            if i < len(parts)-1 and parts[i] == a and parts[i+1] == b:
                out.append(a + b); i += 2
            else:
                out.append(parts[i]); i += 1
        parts = out
    return parts

Проверим на нашем корпусе:

corpus = " ".join(["низ"]*5 + ["низко"]*3 + ["близко"]*2 + ["низина"]*2)
merges = train_bpe(corpus, vocab_size=20)
print(tokenize("низко", merges))    # ['низко_']
print(tokenize("низменность", merges))
                                    # ['низ', 'м', 'е', 'н', 'н', 'о', 'с', 'т', 'ь', '_']

Слово «низменность» в корпусе не встречалось, но токенизатор спокойно разобрал его: знакомый корень «низ» — одним токеном, незнакомый остаток — посимвольно. Ни одна буква не потерялась. Промышленные библиотеки tiktoken, tokenizers от Hugging Face и SentencePiece добавляют эффективные структуры данных, нормализацию и разные варианты предварительной обработки. Они поддерживают не только BPE: применяются также WordPiece (например, в BERT) и вероятностный алгоритм Unigram. Все эти методы решают общую задачу построения подсловного словаря, но правила обучения и токенизации у них различаются.

1.4. Байтовый BPE и Юникод

В изложении выше минимальной единицей был символ. У этого подхода есть скрытая проблема: в Юникоде назначено более 150 тысяч кодовых позиций, и если инициализировать словарь всеми, они съедят его целиком; если же взять только символы из обучающего корпуса, любой экзотический символ на этапе работы снова даст <UNK>. Современные токенизаторы (начиная с GPT-2) решают проблему радикально: минимальная единица — не символ, а байт представления UTF-8. Базовых единиц 256, поэтому любой корректно закодированный текст на любом языке, включая эмодзи, токенизируется без потерь по построению.

Обратная сторона: символы за пределами ASCII занимают в UTF-8 несколько байт (кириллическая буква — 2 байта, иероглиф — 3, эмодзи — 4), и до тех пор, пока BPE не выучит соответствующие слияния, модель видит такие символы «разрубленными». Токен может закончиться посреди символа — для отладки это стоит иметь в виду.

Ещё одна деталь промышленных токенизаторов: перед BPE текст предварительно нарезается регулярным выражением на «предтокены» (слова, числа, пунктуация, пробельные последовательности), и слияния не пересекают их границы. Это не даёт возникнуть токенам вида «дом.А» и заметно улучшает качество словаря. В токенизаторах для моделей, работающих с кодом, пробельные последовательности намеренно сохраняются — отступы в Python того требуют.

1.5. Размер словаря и «плодовитость»: цена русского языка

Размер словаря V — компромисс. Большой словарь укорачивает последовательности (текст кодируется меньшим числом токенов — дешевле и быстрее), но раздувает матрицу эмбеддингов (см. главу 2) и выходной слой модели: их размер пропорционален V. Практика сошлась на десятках тысяч: у GPT-2 было 50 257 токенов, у современных многоязычных моделей — 100–260 тысяч.

Ключевая практическая метрика — плодовитость (fertility): среднее число токенов на слово. Она зависит от корпуса, алгоритма и размера словаря. Поэтому универсального коэффициента для русского или английского нет: его надо измерять выбранным токенизатором на собственных текстах.

Если API тарифицирует токены, различие напрямую влияет на стоимость и занятое место в контекстном окне. При проектировании систем с большими объёмами русского текста (например, конвейеров обработки новостей) сравните репрезентативные выборки, а не переносите коэффициент от другой модели. Пример для одной из современных кодировок:

import tiktoken

enc = tiktoken.get_encoding("o200k_base")   # конкретная кодировка; другие отличаются

en = "Large language models process text as sequences of tokens."
ru = "Большие языковые модели обрабатывают текст как последовательности токенов."

for s in (en, ru):
    ids = enc.encode(s)
    print(len(s), "симв.,", len(ids), "токенов:",
          [enc.decode([i]) for i in ids][:8], "...")

Результат относится только к этой паре фраз и этой кодировке. На другом тексте или токенизаторе соотношение изменится; именно поэтому в расчётах нужна собственная выборка, желательно с кодом, числами и специальной терминологией реального приложения.

1.6. Специальные токены и шаблоны диалога

Кроме единиц, выученных из текста, в словарь вручную добавляются специальные токены — служебные маркеры, выделенные в словаре: начало и конец последовательности, а главное — разметка диалога. Когда вы отправляете чат-модели список сообщений, библиотека склеивает их в один текст по шаблону диалога (chat template), например (условно):

<|im_start|>system
Ты -- вежливый ассистент.<|im_end|>
<|im_start|>user
Привет!<|im_end|>
<|im_start|>assistant

Модель дообучена (глава 5) порождать ответ и завершать его токеном конца сообщения — именно по нему API понимает, что генерацию пора останавливать. Специальные токены — часть контракта между токенизатором и моделью: перепутать шаблон при локальном запуске модели (глава 14) — одна из самых частых причин бессвязных ответов. Конкретный API или сервер должен сам безопасно формировать шаблон и не позволять пользовательскому тексту самовольно менять роли. Но служебная разметка не является границей безопасности: модель всё равно может выполнить враждебную инструкцию из пользовательского текста, документа или результата инструмента. Защита от prompt injection должна быть архитектурной (глава 15).

1.7. Странности моделей, объясняемые токенизацией

Токенизация — источник целого класса знаменитых «глупостей» LLM, и понимание её механики позволяет их предсказывать.

Подсчёт букв. Классический вопрос «сколько букв r в слове strawberry?» долго ставил модели в тупик, потому что модель не видит букв: слово приходит к ней, скажем, как два токена «str»+«awberry», и знание о внутреннем посимвольном составе токена ей приходится извлекать косвенно, из статистики обучения. Токенизация вносит вклад в то, что модели хуже переворачивают строки и играют в «слова из букв». Рассуждающие модели справляются лучше — они научились выписывать слово посимвольно и считать по шагам, — но качество зависит также от данных, обучения и доступных инструментов, а не только от границ токенов.

Арифметика. Числа режутся на токены неравномерно: «1234567» может стать «123»+«456»+«7», и разряды числа перестают совпадать с границами единиц, которыми оперирует модель. Некоторые токенизаторы смягчают проблему специальными правилами для цифр, но надёжная арифметика в LLM достигается не токенизацией, а вызовом калькулятора как инструмента (глава 11).

Чувствительность к мелочам. «Привет», « Привет» (с пробелом) и «ПРИВЕТ» — это разные токены с разной статистикой обучения. Обычно модели устойчивы к таким вариациям, но при отладке промптов полезно помнить, что для модели это буквально разные входы. Лишний пробел в конце промпта перед ожидаемым ответом может заметно сдвинуть распределение первого порождаемого токена.

Редкие токены. Единицы, попавшие в словарь по причудам корпуса (имена пользователей Reddit, обрывки служебных строк), но почти не встречавшиеся при обучении самой модели, дают непредсказуемое поведение — исторически такие «glitch-токены» вызывали у ранних моделей эффектные сбои. В современных моделях словари чистят тщательнее, но принцип сохраняется: качество работы модели с токеном определяется тем, сколько раз она видела его при обучении.

1.8. Итоги

Токенизация решает задачу представления произвольного текста последовательностью единиц из фиксированного словаря. Распространённый вариант — байтовый BPE: словарь строится жадным слиянием частых пар, частое кодируется коротко, редкое — составно, а корректный текст представляется без потерь. Выбор словаря — компромисс между длиной последовательностей и размером модели; эффективность для каждого языка надо измерять конкретным токенизатором. Специальные токены размечают структуру диалога и являются частью контракта между токенизатором, шаблоном и моделью, но не заменяют меры безопасности. Наконец, границы токенов помогают объяснить некоторые трудности LLM с буквами, числами и редкими строками, хотя способности модели определяются не только ими.

В следующей главе мы сделаем второй шаг: посмотрим, как дискретный токен превращается в объект, с которым умеет работать нейронная сеть, — в вектор. Речь пойдёт об эмбеддингах.

Упражнения

1. Запустите учебную реализацию BPE из раздела 1.3 на любом русском тексте объёмом в несколько килобайт со словарём в 500 единиц. Посмотрите на первые 50 выученных слияний: какие морфемы русского языка алгоритм «открыл» сам?

2. С помощью tiktoken измерьте плодовитость (токенов на слово) для одинакового по смыслу текста на русском и английском. Оцените, как меняется число токенов и расчётная стоимость русской версии. Повторите измерение на текстах вашего приложения.

3. Токенизируйте фрагмент кода на Python и посмотрите, как кодируются отступы. Почему для моделей, работающих с кодом, важно, чтобы последовательность из четырёх пробелов была одним токеном?

404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией