Пока мы воспринимаем свои промпты как обычный текст из символов, для LLM они в виде токенов «выглядят» совсем иначе. И если не осознавать этого, порой можно наткнуться на проблемы. Поэтому полезно (и интересно) понимать: что вообще представляют собой токены? По какому алгоритму текст преобразуют в них и обратно? Какие важные нюансы при этом возникают?
Возможно, подробнее и понятнее всех объяснил пару лет назад ИИ-рисерчер Андрей Карпатый, записав двухчасовое видео на английском. А теперь мы решили сделать хабрапост, который и пересказывает на русском главное из этого видео, и делает поправку на прошедшее время, и учитывает другие источники (вроде книги «Build a Large Language Models from Scratch»). Описанное применимо к мейнстримовым LLM вроде GPT, в других моделях возможны отличия.
Токенизация дает немало возможностей «пострелять себе в ногу». Она усложняет для LLM целый ряд задач:
разбор слов по буквам;
обработку строк вроде «перевернуть текст задом наперед»;
работу с различными языками вроде японского;
арифметические операции;
для некоторых (обычно старых) LLM — работу с Python или JSON;
некоторые LLM могли останавливаться при виде слова «<|endoftext|>»;
некоторые LLM могли «сходить с ума» при виде слова «SolidGoldMagikarp».
Поэтому многие проблемы, которые выглядят для людей как «недостатки LLM», на самом деле связаны не с самими LLM, а с тем, как токенизатор предоставляет им информацию.
С этим можно частично справляться: например, современная LLM может не заниматься арифметикой сама, а сразу использовать калькулятор. Но проблемы остаются. Скажем, если попросить перевернуть слово, у части моделей «Хабрахабр» все еще может превратиться в какой-то «Хахабрабр»:

Почему так? И как это все работает? Начнем рассматривать с наглядного примера.
Tiktokenizer
Сайт tiktokenizer.vercel.app позволяет ввести любой текст и наглядно увидеть, на какие токены он окажется разделен. Рекомендуем немного побаловаться, это позволяет многое ощутить живее, чем по теоретическим текстам.
Например, там можно переключаться между разными токенизаторами, выбирая из списка справа вверху. И благодаря этому заметно, насколько они порой различаются. Варианты «gpt-2» и «gpt-4» могут делить один и тот же текст на совершенно разное количество токенов. Так что разные модели «увидят» один и тот же промпт по-разному.
А еще наглядно, если при выбранном варианте «cl100k_base» ввести простое слово «egg» в трех вариантах: целиком строчными буквами, начать с заглавной, целиком с заглавными. Получается так:

Получившиеся токены различаются: это регистрозависимая система. Но еще любопытнее, как все оказалось поделено на части. Простое «egg» — это целиком один токен. В «Egg» отдельным токеном стал первый символ. А в «EGG» — последний.
Если в промпте дважды встречается слово «egg» и один раз оно оказалось в начале предложения, то для нас это ощущается как «одно и то же слово». А для машины тут это даже не «два разных токена», а «один токен против двух». Совершенно неконсистентная система, в которой нет строгого правила, как именно делить.
С числами не проще. Есть блог-пост «Integer tokenization is insane», который прямо кричит: это же безумие. Если бы все поделили по принципу «одна цифра — один токен», было бы консистентно. А тут числа одной длины могут в совсем разных местах делиться на части.
Неудивительно, что у LLM могут быть проблемы с переворачиванием слов или арифметикой. Когда смотришь на такое, наивный внутренний голос перестает спрашивать: «Ну, этим LLM чо, сложно правильно посчитать, что ли?» И начинает спрашивать противоположное: «Блин, как тогда все вообще хоть как-то работает? И почему все так сделали?»
Почему все сложно
Казалось бы, человечеству привычно обрабатывать строки посимвольно. Напрашивается вопрос: почему тут было не сделать так же, разве всем не было бы проще? Потому что тут пытаемся работать со «смысловыми единицами»? Так, может, просто регуляркой порезать на отдельные слова, как в некоторых других случаях делают? Откуда вообще взялись эти обрубки по несколько символов?
Тут любопытно заглянуть в публикацию «Language Models are Unsupervised Multitask Learners», которая представляла всем модель GPT-2. Там авторы объясняют свой подход. Вот, пишут они, одни модели оперируют на уровне байтов, а другие — на уровне слов. И те, что со словами, получаются настолько производительнее, что игнорировать это нельзя.
Но есть и проблема: если у тебя слова и какой-то «словарь» из них, то что делать с тем, что в словарь не попадает? Универсальная языковая модель должна допускать возможность абсолютно любой строки. С набором слов это затруднительно, тут лучше подходят байты UTF-8: из них что хочешь собирай.
И с этими двумя вводными, пишут они, хорошим компромиссом оказывается подход byte-pair encoding. С ним, когда какие-то сочетания символов часто встречаются, они сами смогут схлопываться в единый токен. А редкие символы останутся одиночными. Так и производительность вырастет, и нужной гибкости не потеряем.
Карпатый объясняет логику схожим образом. Мол, он сам и рад бы просто символами все машине скармливать. Но у нас в LLM ведь всегда «ограниченный бюджет» контекста и внимания. Если каждый символ отдельным токеном делать, и из промпта будет получаться длиннющая цепочка токенов, то бюджет начнет быстро расходоваться. А когда тот же промпт в меньшее число символов упакован, куда лучше получается.
Впрочем, это не значит «надо как можно больше в один токен запихнуть». Имеющиеся варианты токенов хранятся в «словаре», и растить его безразмерно тоже плохо. Поэтому насколько большой будет словарь и насколько агрессивно текст будет «схлопываться» — это настраиваемый гиперпараметр. Нет единственно верного значения, надо нащупывать точку баланса.
А как именно это схлопывание происходит?
Byte-pair encoding
Идея в следующем. Вот есть у нас, например, такая строка:
хабрахабр
В ней четыре разных символа: «х», «а», «б», «р». Можем записать их в словарь, присвоить каждому свой номер. Получится словарь из четырех разных токенов, и дальше мы можем модели передавать эти токены, составляя из них «хабрахабр» или еще что-то.
Но теперь попробуем сокращать. Для этого пройдемся по всей строке и посмотрим: какая пара символов там встречается чаще всего?
Для начала — «ха». Запишем в словаре, что кроме «х», «а», «б», «р», есть еще и новый токен со своим номером. И он означает «ха» вместе.
Представим тут для условного отображения этот токен как J, с ним строка получается такой:
JбраJбр
Смотрим дальше и видим, что «бр» тоже встречается дважды. Сделаем тоже единым токеном, заменим на K:
JKаJK
И теперь самое интересное: можно и рекурсивно. Вот у нас теперь дважды JK повторяются. Заменим на L, запишем в словаре, что это пара JK. А про сами J и K в нем уже записано, что они сами за пары.
LaL
Дальше сокращать некуда, повторяющихся пар нет. Но строка в токенах сократилась с девяти символов до всего лишь трех. И теперь у нас есть словарь из семи вариантов «х», «а», «б», «р», «J», «K», «L». Этот словарь позволяет восстановить ее из токенов (декодировать).
В общем-то, главная идея уже объяснена. Сначала мы с помощью какого-то текста, находя в нем частые сочетания, создаем словарь токенов. В данном случае мы быстро уперлись в «пар больше нет». Но при реальном использовании подали бы куда больше текста и поставили бы ограничение: например, «нам нужен словарь примерно на 50 000 токенов, так что как сократишь 50 000 пар — остановись».
А затем, когда словарь уже есть, можно его использовать в обе стороны. Когда пользователь задает любой промпт строкой, по имеющемуся словарю можно закодировать ее токенами и отправить в языковую модель. А она отвечает токенами, и дальше их, опять же с помощью словаря, можно декодировать в текст, чтобы показать пользователю понятным ему образом. Вроде бы все просто?
Нюансы
При кажущейся простоте здесь хватает своих тонкостей. Начиная с того, что алгоритм называется «byte-pair», но выше мы объединяли символы. А они ведь в UTF-8 могут и больше одного байта занимать. И чем мы тогда оперируем-то, символами или байтами?
Вообще говоря, возможны оба подхода. Библиотека SentencePiece от Google подходит на уровне Unicode Code Points. А Tiktoken от OpenAI — на уровне байтов.
И авторы публикации GPT-2, и Карпатый в своем видео ратуют за второй подход. Причина в следующем. Словарь у модели должен быть такой, чтобы из него можно было сложить любую строку. Значит, исходный набор токенов (до того, как начнем объединять в пары) должен покрывать все варианты. Вот мы выше начинали с «х», «а», «б», «р», но из этого мало что можно собрать, кроме «Хабра». А с чего надо начинать, чтобы складывалось вообще все?
Для Unicode Code Points «все варианты» — это весь текущий юникод, в районе 150 000 вариантов. Всю эту прорву надо просто с ходу добавить в словарь, еще до byte-pair encoding. А ведь там еще и добавляют со временем эмодзи всякие, с этим что делать, когда ваша модель уже выпущена?
А вот если оперировать на уровне байтов, то вариантов-то, в общем-то, всего 256. Добавил их в начале словаря — и свободен.
Но при переходе к байтам возникают свои нюансы. Например, если символ редкий и занимает больше одного байта, то при объединении пар его байты могут так и не «склеиться» вместе. И если потом набрать текст из таких редких символов, может получиться, что в нем токенов еще больше, чем символов. А мы тут вроде бы сокращать пытаемся.
Кстати, а редкий — это вообще по какому принципу? Редкий среди чего именно? И вот тут вылезает еще один интересный нюанс: по сути, у токенизаторов есть свой процесс обучения на датасете. Слово «обучение» тут может звучать слишком громко, поскольку в целом это просто применение byte-pair encoding для обнаружения частых пар, а не градиентный спуск. Но и здесь есть о чем подумать.
Датасет
Выше мы считали пары в строке «хабрахабр». И словарь возникал на основе этой строки: какие пары встречались часто, те попали в него и стали объединяться в цельный токен. А подали бы мы вместо этого строку «гоголь-моголь» — в словаре были бы совсем другие пары, и все объединялось бы иначе.
И от этого, что было выбрано, зависит вся дальнейшая работа. Сначала словарь токенов формируется на основе того, что изначально было подано на вход. А затем, когда этот словарь уже создан и используется, приходят пользователи со своими промптами, где может быть нечто совсем другое.
Скажем, выше мы оперировали строкой «хабрахабр» на привычном нам языке. И благодаря этому некоторые русскоязычные сочетания букв стали объединяться. Но если мы сделаем LLM с таким токенизатором, а ею попробуют воспользоваться китаец или бразилец, они будут вводить промпты на своих языках.
И если мы не показывали токенизатору заранее тексты на этих языках, то он не будет ничего знать о частых сочетаниях в них и не сможет ничего эффективно соединять в токены. Получается, токенов там будет получаться много, и качество работы нашей LLM будет хуже. Значит, если модель создается для всего мира, то и при обучении датасета надо думать о самых разных языках.
Если на tiktokenizer.vercel.app сравнить варианты «gpt-2» и «gpt-4» с текстами на русском, выглядит так, будто при создании первой модели еще особо не задумывались об отличных от английского языках, а вот при разработке второй уже начали это делать.
Но языки — это еще не все. Люди ведь сейчас обращаются к LLM еще и, скажем, за кодом. А там свои типичные сочетания. Для Python, например, важны многочисленные пробелы. А еще можно вспомнить, что для LLM строчные и прописные буквы различаются, при этом частые сочетания значимы в обоих случаях, так что и в датасете нужны оба…
И получается вот что. Мы давно привыкли слышать, что при обучении LLM важен хороший датасет. Но гораздо реже говорят о том, что он нужен и при создании токенизатора. Датасет может быть как тем же, что у самой LLM, так и другим. И от него тоже отчасти зависит, каких результатов можно будет от добиться от языковой модели.
Но и это еще не все…
Предобработка
Выше сказано, что в токенизации LLM ушли от подхода «поделить на слова». На самом деле — не совсем.
Если просто объединять все буквы по частотности с помощью byte-pair encoding, может оказаться так, что захватишь «конец одного слова, пробел и начало другого». Скажем, «he is» и «she is» слипнется вместе общее «e i».
Обычно это не то, чего хотелось бы. Поэтому возник гибридный подход: давайте сначала разрежем все регулярным выражением на отдельные слова и другие сущности, а затем уже в этих разрезанных фрагментах будем соединять частые пары. Чтобы символы «слипались» только в пределах одного слова или числа, а не соединяли разные.
Сама модель GPT-2 закрытая, но на GitHub выложено немного связанного с ней кода, в том числе и файл, касающийся токенизации. Он включает в себя строку с угрожающего вида регуляркой:
```python self.pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""") ```
Что тут происходит? То самое «разрезание на части». Сначала в тексте ищут типичные сокращения с апострофами (вроде «you're» или «I've»), чтобы сразу отделить часть с апострофом. Затем ищут сочетания из нескольких букв подряд (то есть слова), из цифр, отдельно разбираются со знаками препинания…
В целом понятно, но есть любопытные моменты. Во-первых, захардкожены типичные сокращения из английского языка, но не из каких-либо других. То есть и здесь получается, что в LLM разные языки могут обрабатываться с разным качеством еще на стадии токенизации, когда до самой LLM дело даже не дошло.
А во-вторых, над этой строкой на GitHub есть трогательный комментарий:
# Should haved added re.IGNORECASE so BPE merges can happen for capitalized versions of contractions
То есть сообщается примерно следующее: «Блин, мы забыли сделать это регистронезависимым, чтобы сокращения парсились, даже когда они капслоком». Если пользователь напишет «you've», то «'ve» будет в токенах учитываться отдельно от «you», но вот если «YOU'VE», там регулярка ничего не отделит.
Как видим, задача токенизации не такая уж примитивная, если даже ведущие мировые компании могут в ней сами сокрушаться об ошибках. Но позже, в библиотеке Tiktoken, это все-таки поправили.
Специальные токены
Выше мы писали об «обычных» токенах, в которые превращаются сообщения пользователя и модели. Но все несколько хитрее. Есть еще и «служебные».
Самое простое — токен «окончание сообщения», бывают и другие. Благодаря этому, например, когда в датасете сконкатенированы разные тексты подряд, модель может понять, где заканчивается один и начинается другой.
Карпатый рассказывает, как с ранними моделями GPT это могло приводить к забавным сложностям: если сказать модели «напиши мне <|endottext|>», она не понимала, что именно ее просят повторить. Похоже, такой токен сразу обрабатывался отдельно, а на вход модели даже не подавался.
Сейчас ранние проблемы обычно уже не встречаются. Но для специальных токенов развилось другое направление: теперь агентские системы используют их, чтобы модель могла обращаться к инструментам. И в агентских системах порой возникают свои сложности, достойные отдельного хабрапоста.
SolidGoldMagikarp

Напоследок можно добавить забавную историю. В видео рассказано, как в 2023-м в ChatGPT была обнаружена странность. Если пользователь включал в сообщения некоторые «странные» слова вроде «SolidGoldMagikarp», модель словно сходила с ума: могла переключиться на другую тему или даже начать материть пользователя, хотя это ей совершенно не свойственно.
Почему так? Похоже, дело в следующем. Изначально SolidGoldMagikarp — юзернейм пользователя на Reddit. Сейчас этот профиль уже удален, но до того информация о нем, вероятно, уже попала в датасет для токенизатора. А вот в датасет для обучения самой модели — не попала.
И получилось вот что. Токенизатор при обучении много раз встречал «SolidGoldMagikarp» и объединил это. Если пользователь пишет что-то такое, то оно не делится на символы, а единым токеном отправляется в LLM.
Но поскольку в датасете обучения LLM такой токен не встречался, модель понятия не имеет, что с ним делать. Она на нем не обучена, каждый раз видит его словно впервые, и ничего из данных для обучения не подсказывает ей, что с ним делать, — статистических связей нет.
А в итоге получается «undefined behavior». Может произойти что угодно.
Конкретно случай с «SolidGoldMagikarp» заметили, поэтому теперь, в новых моделях, вряд ли можно натолкнуться на проблемы именно с этим словом. Но возможны какие-то новые «glitch tokens», о которых мы еще не знаем.
Что дальше
Если хочется залезть в тему глубже, то можно посмотреть двухчасовое англоязычное видео целиком или отдельно открыть код из него. Там можно увидеть конкретную простую имплементацию токенизатора на Python. Только учитывайте, что это не единственно верный способ написать токенизатор, а лишь один из.
Это видео Карпатый снял еще в мире, где LLM воспринимались лишь как чат-боты. А с тех пор пришли агенты с использованием «инструментов» (tools). И теперь, когда модели сталкиваются со сложными для них задачами (например, арифметическими), во многих случаях они пользуются инструментом (например, калькулятором), который даст точный результат.
Поэтому сейчас сложности токенизации уже не делают так больно, как в 2024-м. Но это не значит, что надо махать рукой, мол, теперь можно не разбираться. Тут проблемы не решили магически целиком, а в основном обошли стороной. Так что на грабли все еще можно наступить, и где они разложены — полезно понимать.
При этом токенизация — только начало сложного процесса. Если пользователь вводит текстовый промпт, этот текст разбивается на токены… и дальше от токенов происходит переход к эмбеддингам. А если все это работает т в агентской системе, то еще и у агента запускается цикл выполнения. Во всем этом тоже интересно разбираться.
