Перейти к содержимому
Ступень 44.2 Токенизация

Byte Pair Encoding с нуля

Разбираем алгоритм BPE по шагам, byte-level-вариант GPT-2 и Qwen, предразбиение регулярным выражением — и пишем обучение, encode и decode своими руками.

УРОКstage-4/4-2-tokenization/02-bpe.mdx2 чУрок 2 из 3
После урока вы сможете
  • Выполнить несколько шагов обучения BPE вручную и объяснить, почему при кодировании слияния применяют в порядке обучения
  • Реализовать обучение byte-level BPE, encode и decode на чистом Python
  • Прочитать регулярное выражение предразбиения GPT-2 и объяснить, чем отличаются правила GPT-4o, Qwen и DeepSeek
  • Объяснить, как хранится byte-level-словарь и почему в нём строки вроде ĠмиÑĢ

BPE, кодирование пар байтовBPE, кодирование пар байтовEN Byte Pair Encoding (BPE)Алгоритм токенизации — многократно сливает самую частую пару соседних токенов в новый токен. Изначально (Gage, 1994) — алгоритм сжатия данных. — алгоритм, по которому построены словари GPT-2, GPT-4o, Llama 3, Qwen, DeepSeek и MiniMind. Когда вы его поймёте, станет ясно, почему мир и мир — разные токены, почему модели странно считают цифры и почему файлы словарей выглядят как ĠмиÑĢ. А главное — вы напишете токенизатор сами, примерно в 60 строк.

У алгоритма любопытная история. В 1994 году Филип Гейдж (Philip Gage) опубликовал BPE как метод сжатия данных: самая частая пара байтов заменяется одним неиспользуемым байтом, и так много раз. В 2015 году Сеннрих, Хэддоу и Берч применили ту же идею к словам — для машинного перевода редких слов. В 2019 году GPT-2 вернул BPE к байтам: базовым словарём стали 256 значений байта, и проблема неизвестных символов исчезла.

Начинаем с самых мелких кусочков — символов или байтов. Затем повторяем:

  1. Посчитать, как часто встречается каждая пара соседних токенов.
  2. Самую частую пару склеить в новый токен — это правило слиянияправило слиянияEN merge ruleЗапись «пара (a, b) → новый токен» в обученном BPE. Порядок правил важен — при кодировании их применяют в порядке обучения..
  3. Заменить эту пару во всём тексте новым токеном.

После KK шагов словарь состоит из базовых кусочков и KK новых токенов. Частые сочетания становятся отдельными токенами, редкие так и остаются собранными из мелких частей.

Возьмём крошечный корпус: слова и сколько раз каждое встретилось. Для наглядности работаем с буквами, а не с байтами, и не склеиваем буквы разных слов.

Словоносноситьпроситловитькотноситловит
Частота7765311

Шаг 1. Считаем пары. Пара о + с встречается в словах нос, носить, просит и носит: 7+7+6+1=217 + 7 + 6 + 1 = 21 раз. Это больше, чем у любой другой пары, поэтому первое правило — о + с → ос. Обратите внимание: частота пары — это сумма частот слов, в которых она есть. Перебирать сам текст не нужно, хватает уникальных слов с их счётчиками.

Дальше алгоритм делает то же самое:

ШагПараЧастотаНовый токенКак выглядят слова после шага
1о + с21осн·ос, н·ос·и·т·ь, п·р·ос·и·т, л·о·в·и·т·ь, к·о·т …
2и + т20итн·ос·ит·ь, п·р·ос·ит, л·о·в·ит·ь …
3н + ос15носнос, нос·ит·ь, нос·ит …
4ит + ь12итьнос·ить, л·о·в·ить …
5нос + ить7носитьносить

Итоговые разбиения: нос, носить, п·р·ос·ит, л·о·в·ить, к·о·т, нос·ит, л·о·в·ит.

Что здесь важно:

  • Алгоритм ничего не знает о лингвистике, но нашёл корень нос и глагольный хвост ить (носить, ловить) — просто по частотам.
  • Частое слово «носить» стало одним токеном, а редкое «кот» осталось набором букв.
  • Если у двух пар одинаковая частота, нужно правило выбора. Единого стандарта нет: в minbpe Карпатого и в нашем задании берут пару, которая встретилась в тексте раньше, а у библиотеки Hugging Face tokenizers и у домашней работы модуля свои правила. Важно лишь, чтобы правило было детерминированным, иначе два запуска дадут разные словари. В этом примере ничьих нет. Когда шаг BPE просят написать на собеседовании, правило ничьей лучше уточнить у интервьюера (урок 11.3.4).

Теперь то же самое, но на байтах — так работают все современные LLM.

  1. Перевести обучающий текст в байты UTF-8: ids = list(text.encode("utf-8")). Базовый словарь — числа 0…255.
  2. Для new_id от 256 до V−1V - 1:
    • посчитать пары соседних id;
    • выбрать самую частую пару (a,b)(a, b);
    • заменить все её вхождения на new_id (слева направо, без перекрытий: [5, 5, 5] → [7, 5]);
    • записать правило merges[(a, b)] = new_id.
  3. Результат обучения — упорядоченная таблица слияний. Размер словаря: V=256+KV = 256 + K плюс спецтокены.
flowchart LR
A["Текст → байты 0…255"] --> B["Посчитать пары соседей"]
B --> C["Самая частая пара (a, b)"]
C --> D["Заменить везде на new_id"]
D --> E{"Словарь достиг V?"}
E -- нет --> B
E -- да --> F["Таблица слияний merges"]
Цикл обучения BPE

Наивная реализация стоит O(K⋅N)O(K \cdot N): KK слияний, на каждом — проход по тексту длины NN. Для учебного корпуса это мгновенно, для терабайтов — нет. Настоящие библиотеки (Hugging Face tokenizers, написанная на Rust) считают пары по уникальным кускам текста с частотами, как в примере выше, и после слияния обновляют только затронутые счётчики.

Виджет работает как пример вручную: по символам, а не по байтам, и не склеивает символы разных слов (пробел показан как ▁). Двигайте ползунок «Число слияний» вправо и следите, как символы склеиваются: сначала частые пары (▁н, ов, ий), потом начала слов (▁ни, ▁нов) и общие куски (дел, зык), потом почти целые слова (▁модел, ▁языков, ▁low, ▁нижн). На тексте по умолчанию слияния заканчиваются через 27 шагов: все оставшиеся пары встречаются по одному разу, и виджет их не сливает. Вставьте свой текст на любом языке: чем больше в нём повторов, тем сильнее сжатие.

BPE по шагаминтерактив
Разбиение на токеныодинаковые токены — одного цвета
Выученные слияния
    Для наглядности алгоритм работает с символами Unicode, пробел показан как «▁», слияния не пересекают границы слов. Настоящие токенизаторы (GPT, Qwen, DeepSeek) работают с байтами UTF-8 — это мы реализуем в уроке и ДЗ.

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

    Почему не просто «жадно слева направо»? Пусть выучены правила b + c → 256 (раньше) и a + b → 257 (позже). Текст abc:

    • в порядке обучения: сначала bc → [a, 256], пары (a, 256) в таблице нет — готово;
    • жадно слева направо: ab → [257, c].

    Разбиения разные. Модель училась на текстах, закодированных первым способом, — значит, при инференсеинференс, вывод моделиEN inferenceСм. «reasoning»: в китайском одно и то же слово. (использовании готовой модели) нужен именно он. Поэтому и важно, что таблица слияний упорядочена: правило с номером kk может опираться на токены, созданные раньше.

    Decode. Восстанавливаем байты каждого токена: для 0…255 это сам байт, для нового токена — склейка байтов двух его частей. Склеиваем всё и декодируем UTF-8 с errors="replace".

    Зачем errors="replace"? Токен может содержать половину буквы. Если декодировать по одному токену — как при потоковом выводе ответа в чате, — получится �. Поэтому чат-интерфейсы копят байты, пока не сложится целый символ UTF-8, и только потом показывают его.

    У байтовый BPEбайтовый BPE (byte-level BPE)EN byte-level BPE (BBPE)BPE, у которого базовый словарь — 256 байтов UTF-8. Любой текст кодируется без <unk>. Используется в GPT-2, GPT-4o, Llama 3, Qwen, DeepSeek. базовый словарь — все 256 байтов, поэтому <unk> не бывает: любой символ, хоть древний иероглиф, хоть эмодзи, кодируется своими байтами.

    Остаётся техническая проблема хранения. Словарь лежит в текстовых файлах (vocab.json, merges.txt или tokenizer.json), а отдельный байт — не всегда нормальный текст: пробел и перевод строки невидимы, управляющие байты ломают формат, а половинка кириллической буквы — вообще некорректная строка UTF-8. GPT-2 решил это так: каждому из 256 байтов сопоставлен печатный символ Unicode. Видимые символы ASCII и Latin-1 остаются собой, остальные сдвигаются в область 256+:

    def bytes_to_unicode():
    """Отображение 256 байтов в 256 «печатных» символов Unicode (как в GPT-2)."""
    # ord(символ) → его номер в Unicode, chr(номер) → символ. Байты с «видимыми» символами оставляем как есть:
    keep = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1))
    bs, cs, n = keep[:], keep[:], 0 # bs — байты, cs — номера символов, которыми их покажем
    for b in range(256):
    if b not in keep: # пробел, управляющие символы и т. п.
    bs.append(b)
    cs.append(256 + n) # сдвигаем их в «видимую» область
    n += 1
    return {b: chr(c) for b, c in zip(bs, cs)} # словарь байт → печатный символ
    b2u = bytes_to_unicode()
    # каждую строку переводим в байты UTF-8 и каждый байт заменяем его «печатным» символом
    for s in [" мир", "\n", " world", "🙂"]:
    print(repr(s), "->", "".join(b2u[b] for b in s.encode("utf-8")))
    ' мир' -> ĠмиÑĢ
    '\n' -> Ċ
    ' world' -> Ġworld
    '🙂' -> ðŁĻĤ

    Именно так мир записан в словаре Qwen3: ĠмиÑĢ. Ġ — пробел, Ð и Ñ — первые байты кириллических букв. Это не кракозябрыкракозябры (mojibake)EN mojibakeНечитаемый текст из-за декодирования байтов не той кодировкой, например «РџСЂРёРІРµС‚» вместо «Привет»., а честные байты UTF-8 в «печатной» записи. Qwen, DeepSeek и Llama 3 используют ту же схему.

    Если запустить BPE на сыром потоке байтов, он начнёт склеивать всё подряд. Появятся отдельные токены кот., кот!, кот,, склейки через границу слов вроде в·том, токены из буквы и цифры. Словарь тратится впустую, а смысл токенов размывается.

    Решение — предразбиениепредразбиение (претокенизация)EN pre-tokenizationПредварительная нарезка текста регулярным выражением на слова, числа и пунктуацию; BPE-слияния действуют только внутри кусков.. Сначала регулярное выражение режет текст на куски: слова, числа, пунктуацию, пробелы. Потом BPE обучается и работает только внутри кусков. Бонус — скорость: одинаковых кусков в корпусе много, и пары можно считать по уникальным кускам с частотами.

    Шаблон GPT-2 (нужен модуль regex: стандартный re не понимает \p{L}):

    's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

    Если регулярные выражения вам в новинку, вот всё, что нужно, чтобы прочитать этот шаблон:

    • | — «или». Функция findall идёт по тексту и в каждом месте пробует варианты слева направо: первый подошедший откусывает кусок;
    • ? — предыдущий символ необязателен: ? значит «пробел или ничего»;
    • + — «один или больше раз подряд», {1,3} — «от одного до трёх раз» (понадобится ниже);
    • \s — пробельный символ (пробел, перевод строки, табуляция), \S — любой непробельный;
    • \p{L} — любая буква любого алфавита, \p{N} — любая цифра;
    • [^…] — любой символ, кроме перечисленных в скобках;
    • (?!…) — проверка «дальше не стоит …», которая сама ничего не откусывает.

    Теперь части шаблона по порядку:

    • 's, 't, 're, 've, 'm, 'll, 'd — английские сокращения: don't → don + 't;
    • ?\p{L}+ — слово из букв любого алфавита с необязательным пробелом впереди: мир. Отсюда правило «пробел приклеен к началу слова», поэтому мир и мир — разные токены;
    • ?\p{N}+ — число;
    • ?[^\s\p{L}\p{N}]+ — пунктуация и прочие символы;
    • \s+(?!\S) и \s+ — пробелы. Хитрость с (?!\S): последний пробел перед словом не съедается и достаётся слову.

    Последующие модели меняли этот шаблон. Вот как предразбиение разных моделей режет строку В 2026 году GPT-4o стоит $2.50, don't CamelCase (проверено на их настоящих файлах):

    • GPT-2: число целиком с пробелом — ␣2026.
    • GPT-4 (cl100k) и Llama 3: числа группами до трёх цифр, пробел отдельно — ␣ 202 6.
    • GPT-4o (o200k): то же для чисел; сокращение остаётся в слове (␣don't), слова в CamelCase режутся по заглавным буквам (␣Camel Case).
    • Qwen: каждая цифра — отдельный кусок: 2 0 2 6, а 2.50 — это 2 . 5 0.
    • DeepSeek-V3: на этой строке — как Llama 3: числа группами до трёх цифр. Но в шаблоне есть и отдельное правило для сплошных кусков китайских иероглифов (и японской каны): пробел перед таким куском становится самостоятельным куском, а не приклеивается к нему, как у других моделей.

    Почему столько внимания цифрам? Если 2026 — один токен, а 2027 — другой, модель должна выучить связь между ними для каждого числа отдельно. Когда число режется на цифры, у всех чисел одно и то же устройство, и арифметика даётся модели легче. Группы по три цифры — компромисс между длиной и единообразием.

    АлгоритмКак строит словарьГде используется
    BPEснизу вверх: сливает самые частые парыGPT-2 … GPT-4o, Llama 3, Qwen, DeepSeek, MiniMind
    WordPieceтоже сливает пары, но выбирает пару по выигрышу правдоподобия, а не по частоте; продолжение слова помечается ##BERT
    Unigram LMсверху вниз: начинает с большого словаря и выбрасывает куски, без которых правдоподобие корпуса падает меньше всегоT5 и многие модели на SentencePiece

    SentencePiece — библиотека Google, а не отдельный алгоритм: в ней реализованы и BPE, и Unigram. Большинство современных LLM (GPT-4o, Llama 3, Qwen, DeepSeek) используют byte-level BPE; SentencePiece с байтовым откатом остаётся, например, у моделей Gemma от Google.

    Реализуйте byte-level BPE на чистом Python: подсчёт пар, слияние, обучение, encode и decode. Тесты проверят обучение на русском тексте, порядок применения слияний и восстановление текста на русском, с символами по 3 байта (₽, €) и эмодзи.

    Byte-level BPE: обучение, encode и decode8 тестов
    """Byte-level BPE с нуля: обучение слияний, encode и decode.
    
    Токены 0..255 — это байты UTF-8. Каждое слияние создаёт новый токен: 256, 257, ...
    merges — словарь {(id_a, id_b): new_id}; порядок вставки = порядок обучения.
    """
    
    
    def get_pair_counts(ids):
        """Сколько раз встречается каждая пара соседних id.
    
        get_pair_counts([1, 2, 3, 1, 2]) -> {(1, 2): 2, (2, 3): 1, (3, 1): 1}
        Ключи — в порядке первого появления пары (обычный dict так и делает).
        """
        # TODO: пройдите по парам zip(ids, ids[1:]) и посчитайте их
        ...
    
    
    def merge(ids, pair, new_id):
        """Заменяет каждое вхождение pair на new_id — слева направо, без перекрытий.
    
        merge([1, 2, 3, 1, 2], (1, 2), 99) -> [99, 3, 99]
        merge([5, 5, 5], (5, 5), 7)        -> [7, 5]
        """
        # TODO: идите по списку индексом i; нашли пару — добавьте new_id и прыгните на 2
        ...
    
    
    def train_bpe(text, vocab_size):
        """Обучает BPE и возвращает merges.
    
        1) text -> байты UTF-8 -> список чисел 0..255;
        2) для new_id = 256, 257, ..., vocab_size - 1: найти самую частую пару
           (при равенстве — ту, что встретилась раньше: так работает max по dict),
           слить её во всём списке и записать merges[pair] = new_id;
        3) если пар не осталось — остановиться раньше.
        """
        # TODO
        ...
    
    
    def encode(text, merges):
        """Текст -> список id.
    
        Начинаем с байтов UTF-8. Пока возможно: среди соседних пар выбрать ту,
        у которой в merges НАИМЕНЬШИЙ new_id (её выучили раньше всех), и слить её.
        Если ни одной пары нет в merges — готово.
        """
        # TODO
        ...
    
    
    def decode(ids, merges):
        """Список id -> текст.
    
        Постройте vocab: id -> bytes (0..255 — сами байты, новые id — склейка частей
        в порядке merges), склейте байты и декодируйте с errors="replace".
        """
        # TODO
        ...
    
    
    # Проверьте себя кнопкой «Запустить»:
    text = "мама мыла раму, мама мыла маму"
    merges = train_bpe(text, 256 + 10)
    print(merges)
    ids = encode(text, merges)
    print(ids)
    print(decode(ids, merges))
    

    Следующий шаг — аналог RegexTokenizer из minbpe Карпатого. Текст сначала режется регулярным выражением, пары считаются только внутри кусков, и каждый кусок кодируется отдельно. В отличие от minbpe, мы считаем каждый уникальный кусок один раз, умножая его пары на частоту куска, — так быстрее. Нужен только Python.

    import re
    from collections import Counter
    # Упрощённое предразбиение в духе GPT-2 на стандартном модуле re:
    # [^\W\d_] — «буква» (любого алфавита), \d — цифра.
    # Отличие от GPT-2: числа режутся группами до трёх цифр, как у GPT-4 (но пробел, как в GPT-2, приклеен к числу).
    PAT = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?[^\W\d_]+| ?\d{1,3}| ?(?:[^\s\w]|_)+|\s+(?!\S)|\s+""")
    print(PAT.findall("Привет, мир! В 2026 году GPT-4o стоит $2.50 за 1M токенов."))
    def merge(ids, pair, new_id):
    """Заменить в списке ids все вхождения пары pair на new_id (слева направо, без перекрытий)."""
    out, i = [], 0
    while i < len(ids):
    if i + 1 < len(ids) and (ids[i], ids[i + 1]) == pair: # нашли пару → один новый токен
    out.append(new_id)
    i += 2 # перепрыгиваем оба элемента пары
    else:
    out.append(ids[i]) # иначе копируем как есть
    i += 1
    return out
    def train(text, vocab_size):
    """BPE поверх предразбиения: пары считаются только внутри кусков."""
    chunks = Counter(PAT.findall(text)) # уникальные куски и их частоты
    # кусок → кортеж байтов (кортеж, а не список, потому что ключ словаря должен быть неизменяемым)
    words = {tuple(c.encode("utf-8")): n for c, n in chunks.items()}
    merges = {} # (a, b) → new_id, в порядке обучения
    for new_id in range(256, vocab_size):
    pairs = Counter()
    for w, n in words.items():
    for p in zip(w, w[1:]):
    pairs[p] += n # пара внутри куска, встречающегося n раз
    if not pairs:
    break
    best = max(pairs, key=pairs.get) # самая частая пара
    merges[best] = new_id
    words = {tuple(merge(list(w), best, new_id)): n for w, n in words.items()} # применить слияние ко всем кускам
    return merges
    def encode(text, merges):
    ids = []
    for chunk in PAT.findall(text): # каждый кусок кодируется независимо
    part = list(chunk.encode("utf-8"))
    while len(part) >= 2:
    # среди соседних пар ищем ту, что выучена РАНЬШЕ всех (наименьший new_id);
    # невыученным парам даём «номер» бесконечность, чтобы они проигрывали
    pair = min(zip(part, part[1:]), key=lambda p: merges.get(p, float("inf")))
    if pair not in merges: # ни одной выученной пары не осталось — кусок готов
    break
    part = merge(part, pair, merges[pair])
    ids.extend(part)
    return ids
    def vocab_of(merges):
    """id токена → его байты. Базовые 256 — сами байты, новые — склейка байтов двух частей."""
    vocab = {i: bytes([i]) for i in range(256)}
    for (a, b), i in merges.items(): # словарь Python хранит порядок вставки = порядок обучения
    vocab[i] = vocab[a] + vocab[b]
    return vocab
    text = (
    "Языковая модель не видит букв. Она видит только номера токенов. "
    "Токенизатор режет текст на кусочки и переводит каждый кусочек в номер. "
    "Частые слова становятся одним токеном, редкие слова распадаются на части. "
    "Алгоритм BPE начинает с байтов и много раз сливает самую частую пару соседних токенов. "
    "Сначала из байтов собираются буквы, потом слоги, потом целые слова. "
    "Модель учится предсказывать следующий токен, а токенизатор решает, что такое токен. "
    "Хороший токенизатор делает текст короче, и модель обрабатывает больше смысла за тот же контекст. "
    )
    merges = train(text, 256 + 100)
    vocab = vocab_of(merges)
    learned = [vocab[i].decode("utf-8", errors="replace") for i in merges.values()]
    print(learned[:12])
    print(learned[-12:])
    sample = "модель режет текст на токены"
    ids = encode(sample, merges)
    print(len(sample.encode("utf-8")), "байт ->", len(ids), "токенов:",
    [vocab[i].decode("utf-8", errors="replace") for i in ids])

    Ожидаемый вывод:

    ['Привет', ',', ' мир', '!', ' В', ' 202', '6', ' году', ' GPT', '-', '4', 'o', ' стоит', ' $', '2', '.', '50', ' за', ' 1', 'M', ' токенов', '.']
    ['о', 'т', 'а', ' �', 'е', 'о�', 'с', 'е�', 'и', 'р', ' т', ' с']
    [' част', 'м', ' ба�', ' бай', ' байт', ' байтов', 'ог', 'аз', 'об', ' пот', ' потом', 'ыв']
    52 байт -> 10 токенов: ['м', 'одель', ' р', 'е�', '�', 'ет', ' текст', ' на', ' токен', 'ы']

    Как читать вывод:

    • Первые слияния собирают буквы из байтов: о, т, а. Токен � — это пробел плюс первый байт кириллической буквы: в начале русских слов эта пара очень частая.
    • К сотому слиянию появляются целые слова корпуса: байтов, потом.
    • В строке модель режет текст на токены частые слова ( текст, на, токен) — по одному токену. Слово «режет» встретилось в корпусе один раз и развалилось вплоть до байтов: е� — это е плюс первый байт буквы ж, а следующий � — её второй байт.

    Полную версию такого токенизатора — со спецтокенами, сохранением в файл и быстрым encode — вы напишете в домашней работе s4-hw01 (её условие — в конце урока 3).

    Проверь себя6 вопросов
    1 Корпус «aaabdaaabac» в байтах. Какую пару byte-level BPE сольёт первой?
    2 Сколько слияний нужно выполнить byte-level BPE, чтобы получить словарь из 50 000 обычных токенов?
    3 Выучены правила: b+c → 256 (первое), a+b → 257 (второе). Во что закодируется строка abc?
    4 Зачем нужно предразбиение регулярным выражением? Выберите все верные варианты.Можно выбрать несколько ответов
    5 Какой байт обозначает символ Ġ в файлах словаря GPT-2 и Qwen?
    6 На сколько токенов Qwen разрежет строку «3.14159»?
    • BPE обучается снизу вверх: начиная с 256 байтов, он V−256V - 256 раз сливает самую частую пару соседних токенов и записывает упорядоченную таблицу слияний.
    • Encode применяет слияния в порядке обучения, decode склеивает байты и декодирует UTF-8 с заменой битых кусков.
    • Byte-level BPE не знает <unk>. Чтобы хранить байты в текстовых файлах, GPT-2 отображает их в печатные символы: Ġ — пробел, отсюда ĠмиÑĢ = мир.
    • Предразбиение регулярным выражением не даёт склеивать слова с пунктуацией и задаёт политику для цифр: GPT-4o — группы по три, Qwen — по одной.
    • Qwen надстроил словарь GPT-4 китайскими токенами, DeepSeek-V3 обучил свой с отдельными правилами для иероглифов.

    Дальше: токенизаторы реальных моделей — сравним словари на русском и английском, разберём chat-шаблоны, спецтокены, tiktoken и Hugging Face tokenizers — Токенизаторы современных моделей.