Головна / Статті / Токенізація — це рішення системного дизайну, а не етап передобробки

Токенізація — це рішення системного дизайну, а не етап передобробки

Як байтовий BPE будує словник, який бачить LLM, чому кількість токенів залежить від мови, які збої це спричиняє і що виміряти, перш ніж звинувачувати модель.

2081 слів

Чотири симптоми з однією причиною

Рано чи пізно більшість тих, хто будує продукти на LLM, стикається з чимось із цього:

  • Модель із заявленим контекстним вікном 128k упирається в ліміт значно раніше, ніж очікувалося, якщо вхід не англійською або в ньому багато коду.
  • Та сама функція помітно дорожча за запит для користувачів, які пишуть японською, гінді чи арабською, ніж для англомовних.
  • Модель упевнено не може порахувати літери чи розвернути слово, хоч як формулюй промпт.
  • Дві команди отримують різну кількість токенів для тексту, що виглядає однаково, і ніхто не може пояснити чому.

Переформулювання промпту нічого з цього не розв'яже, і річ не в тім, що модель просто дурна. Винуватиця — токенізація, етап пайплайну, який інженери зазвичай знають найгірше. Вона живе між вашим кодом і моделлю, працює непомітно й привертає увагу, лише коли ламається. Далі — як влаштований механізм, де він стоїть у реальній системі, якої шкоди може завдати, і чекліст, який варто пройти, перш ніж звинувачувати модель.

Що насправді отримує модель

Трансформер не вміє обробляти текст напряму, тож насамперед текст перетворюється на цілі числа. Це перетворення і є токенізацією, і спирається воно на словник, зафіксований заздалегідь. Ніщо у вашому промпті не може змінити його під час роботи моделі.

Уявіть фіксований набір деталей Lego. Поширені англійські слова на кшталт «the», «is» і «running» зазвичай — одна деталь. Рідкісні слова розбиваються, тож «tokenization» може стати «token» плюс «ization». Текст, якого токенізатор бачив мало, наприклад рідкісна мова, незвичний символ Unicode чи маловідома назва продукту, розпадається на більше дрібніших деталей.

Ваші вихідні символи до моделі не доходять. Доходить те розбиття, яке обрав токенізатор, і білінг, витрата контексту та деякі помилки міркувань — усе це наслідок того, де він різав. Добрий приклад — слово «strawberry»: модель бачить кілька токенів, а не десять літер.

Як будується словник

Більшість словників отримують за допомогою Byte Pair Encoding (BPE). Його коріння — у стисненні даних, а не в мові: Філіп Гейдж описав його 1994 року як спосіб зменшити дані, замінюючи новим символом ту пару символів, що найчастіше стоїть поруч.

Зеннріх, Геддоу і Берч перенесли його в нейронний машинний переклад 2016 року в статті «Neural Machine Translation of Rare Words with Subword Units». Словники на рівні слів погано дають раду рідкісним словам, одрукам і словоформам, і їхньою відповіддю став відкритий словник, де рідкісні слова збираються з частих, дрібніших підсловних шматочків.

Робота OpenAI над GPT-2 (Radford et al., 2019, «Language Models are Unsupervised Multitask Learners») опустила ідею до рівня байтів. Злиття починаються із сирих байтів UTF-8, а не із символів, що дає стартовий алфавіт рівно з 256 символів. Сама лише ця зміна дає змогу сучасному токенізатору подати будь-який рядок будь-якою мовою, зокрема зіпсований текст і емодзі, не впираючись у невідомий токен. Майже всі великі сімейства моделей сьогодні, серед них GPT і Llama, використовують якийсь його варіант.

Навчання відбувається один раз, офлайн, такими кроками:

  1. Почати з базових одиниць: 256 байтів або символів у початковому формулюванні.
  2. Пройти великим корпусом і порахувати, як часто трапляється кожна сусідня пара символів.
  3. Узяти найчастішу пару, злити її в один новий символ і додати його до словника.
  4. Замінити кожне входження цієї пари в корпусі новим символом.
  5. Повторювати кроки 2–4, доки словник не досягне цільового розміру — зазвичай від десятків тисяч до кількох сотень тисяч токенів.

Після навчання кодування швидке й повністю детерміноване. До нового тексту застосовуються вивчені злиття в тому самому порядку, в якому їх вивчено, і шматки жадібно склеюються, доки зливати більше нічого. Жодного розуміння тут немає. Це пошук і злиття, і той самий вхід завжди дає ті самі токени.

SentencePiece, окрема бібліотека від Google (2018), іде іншим шляхом, і його варто знати. Вона навчається просто на сирому тексті й вважає пробіл звичайним символом, а не особливим роздільником. Це важливо для китайської, японської та тайської, де слова не розділяються пробілами: там передтокенізатор, що спирається на пробіли, розвалюється, а SentencePiece дає раду однаково.

Де вона стоїть у запиті

Токенізація виконується до трансформера. Шлях реального запиту виглядає так:

User Text
   ↓
Pretokenizer (split on whitespace/punctuation rules, handle Unicode)
   ↓
BPE Encoder (apply learned merges → token IDs)
   ↓
Token IDs (integers)
   ↓
Embedding Lookup Table
   ↓
Transformer (attention + feedforward layers)
   ↓
Output Token IDs
   ↓
Decoder (token IDs → text)
   ↓
Response Text

Про два компоненти легко забути. По-перше, таблиця ембедингів індексується за ID токена, тож розмір словника задає розмір шару ембедингів і вихідної проєкції. Що більший словник, то більше параметрів іде на відображення токенів у вектори ще до того, як запрацює бодай один шар міркувань. По-друге, декодер — це токенізатор навпаки. Потокова видача відповіді токен за токеном і перетворення її назад на читабельний текст спираються на той самий фіксований словник, що й кодування.

Дивимося на це через tiktoken

Щоб не брати все на віру, можна подивитися самому за допомогою tiktoken, бібліотеки токенізатора від OpenAI:

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o")

samples = {
    "english": "The invoice was approved yesterday.",
    "japanese": "請求書は昨日承認されました。",
}

for label, text in samples.items():
    tokens = enc.encode(text)
    print(f"{label}: {len(tokens)} tokens -> {tokens}")
    print([enc.decode([t]) for t in tokens])

У такому порівнянні варто перевірити дві речі. По-перше, кількість токенів на символ чи слово сильно залежить від мови. Та сама інформація може коштувати помітно більше токенів залежно від того, наскільки добре мову було представлено під час навчання токенізатора. Це видно і в документації OpenAI щодо токенізатора: коротка фраза розпадається на більше шматків у старих кодуваннях, орієнтованих на англійську, ніж у нових, а нелатинські писемності загалом витрачають більше токенів на символ, ніж англійська проза. По-друге, межі токенів рідко збігаються із символами чи навіть складами. Саме тому LLM за замовчуванням важко рахують літери й перевертають рядки: робоче подання не дає окремі символи як повноцінну одиницю.

Приклад багатомовного бюджету

Уявіть агента підтримки, який обслуговує носіїв англійської, японської та арабської з жорстким лімітом токенів на запит. Обмежувати кількість вхідних і вихідних токенів на виклик, щоб тримати в рамках вартість і затримку, — звичайна практика в продакшені. Якщо підібрати цей бюджет, тестуючи лише англійською, ви систематично обділите всіх інших. Та сама історія діалогу і той самий системний промпт з'їдають більше бюджету японською чи арабською і залишають менше місця для відповіді.

Нічого екзотичного тут немає. BPE-токенізатори, навчені здебільшого на англійській, поводяться так «з коробки», і результат — тиха втрата якості через обрізаний контекст саме в тих міжнародних користувачів, заради яких продукт і робиться. Хитрий промпт цього не виправить. Допомагає інше: на тестах рахувати реальні токени для кожної мови, а потім обирати бюджети, правила обрізання чи сумаризації для кожної окремо.

Дешевий експеримент

Перш ніж довіряти припущенням про поведінку токенізатора, спробуйте ось що. Візьміть 20–30 речень з однаковим змістом, професійно перекладених англійською, японською, арабською та гінді (добре пасують розшифровки звернень у підтримку). Закодуйте кожне одним токенізатором, наприклад o200k_base через tiktoken, і виміряйте кількість токенів на речення та на символ для кожної мови.

Гіпотеза для перевірки (це не встановлений факт): у японської, арабської та гінді співвідношення токенів до символів виявиться помітно вищим, ніж в англійської. Це означало б, що те саме контекстне вікно і той самий бюджет на запит залишають цим користувачам менше корисного місця для того самого змісту. Точне співвідношення залежить від версії токенізатора й мов, тож вимірюйте на своїх даних.

Збої, які трапляться в продакшені

Токенізація породжує конкретний набір багів, що повторюються.

  • Збої на задачах рівня символів. Підрахунок літер, розвертання рядків, написання по літерах і частина багатоцифрової арифметики не вдаються, бо модель міркує над підсловними токенами, а не над символами. Симптом — модель упевнено помиляється в тому, що для людини тривіальне. Самим лише кращим промптом цього не виправити. Краще працює прохання виписати слово через роздільники, що примусово дає токени рівня символів, або передавання задачі на виконання коду.
  • Розбіжність токенізатора й моделі. Якщо оцінювати вартість чи витрату контексту токенізатором іншої моделі, цифри будуть систематично хибними. Ви побачите, що локальні підрахунки токенів розходяться з рахунком за API. Використовуйте кодування, задокументоване для тієї моделі, яку викликаєте.
  • Переповнення, якого ніхто не помічає. Промпт, що легко вміщується англійською, після перекладу може перевищити ліміт; те саме стосується контенту, повного коду, JSON чи дивних символів. Симптом — обрізаний контекст і гірші відповіді, а в деяких пайплайнах без явної помилки. Вимірюйте реальну кількість токенів на реальному продакшн-контенті.
  • Чутливість до пробілів і форматування. Початкові пробіли, повторювані пробільні символи й непослідовне форматування можуть зсувати межі й кількість токенів, іноді суттєво, тож два промпти, однакові на вигляд для людини, різняться токенами й вартістю.
  • Розбіжність словників після донавчання чи зміни моделі. Перехід на інше сімейство моделей з іншим токенізатором без перевірки бюджетів промптів і довжини few-shot прикладів може тихо зламати припущення, на які спиралася система. Те, що чудово працювало, деградує після нібито безшовної заміни.

Що робити в продакшені

  • Вартість: токени — справжня одиниця білінгу, тож багатомовним продуктам варто планувати бюджет за мовами, а не виходити з одного глобального середнього.
  • Планування контексту: розмір RAG-чанків, обрізання історії діалогу й few-shot приклади рахуйте в токенах конкретного токенізатора, яким користуєтеся, а не в словах чи символах.
  • Швидкість: сама токенізація швидка й рідко буває вузьким місцем, але оцінка довжини промпту неправильним кодуванням перед викликом може призводити до зайвих повторів чи обрізання, і те й інше додає затримку.
  • Спостережуваність: логуйте фактичну кількість вхідних і вихідних токенів на запит, з розбивкою за мовою, якщо обслуговуєте кілька локалей. Додати це дешево, а проблеми з вартістю й контекстом ловляться раніше, ніж про них повідомлять користувачі.
  • Узгодженість: використовуйте ту саму версію токенізатора для локальних тестів, оцінки вартості й продакшн-виклику API. Невідповідне кодування дає впевнено хибні цифри.

Коли все це можна ігнорувати

Для суто англомовного застосунку з короткими текстами й малим обсягом, наприклад внутрішнього інструмента, прототипу чи малонавантаженого чат-бота, питання рівня токенізатора на кшталт різниці у вартості між мовами не варті інженерного часу. Типові налаштування бібліотеки підійдуть, а сили краще витратити на інше.

Так само, якщо продукту ніколи не потрібна робота на рівні символів на кшталт написання по літерах, підрахунку літер чи точного розвертання рядків, обходити тут нічого. Це важливо, лише коли сценарій справді вимагає від моделі таких задач, і тоді звичайне рішення — передати їх кроку з виконанням коду, а не боротися з токенізатором через промпти.

Коротко

Моделі ніколи не бачать символів чи слів — лише токени з фіксованого словника, побудованого заздалегідь, зазвичай за допомогою байтового BPE. BPE з'явився 1994 року як алгоритм стиснення, прийшов в NLP 2016-го і був розширений до байтів командою GPT-2 2019-го, тому сучасні токенізатори практично ніколи не видають невідомих токенів. Реальну вартість і витрату контексту задає кількість токенів, а не слів чи символів. Та сама інформація деякими мовами коштує більше токенів, і це тихо ставить неангломовних користувачів у невигідне становище, якщо не планувати бюджет за мовами. Збої з підрахунком символів, написанням по літерах і розвертанням рядків ідуть від подання токенізатора, а не від браку міркувань, а невідповідні токенізатори спричиняють баги в продакшені, яких можна було уникнути. Вимірюйте токенізацію й далі стежте за нею як за частиною дизайну, а не забувайте про неї після першої інтеграції.

Джерела:

  • Gage (1994), «A New Algorithm for Data Compression», The C Users Journal — початковий алгоритм стиснення BPE.
  • Sennrich, Haddow і Birch (2016), «Neural Machine Translation of Rare Words with Subword Units», ACL 2016, DOI 10.18653/v1/P16-1162.
  • Radford et al. (2019), «Language Models are Unsupervised Multitask Learners» — стаття про GPT-2, у якій з'явився байтовий BPE.
  • Kudo і Richardson (2018) — стаття Google про SentencePiece, підсловний токенізатор і детокенізатор, що не залежить від мови.
  • Посібник OpenAI Cookbook щодо підрахунку токенів з tiktoken і документація tiktoken щодо кодувань cl100k_base та o200k_base.