n4nAI

How tokenization turns text into numbers

A practical guide to how tokenization works in LLMs, covering BPE, WordPiece, special tokens, and engineering tradeoffs with code examples.

n4n Team4 min read930 words

Audio narration

Coming soon — every post will get a voice note here.

Tokenization is the first thing that happens to your prompt and the last thing that happens to a model’s output. Understanding how does tokenization work lets you debug context window limits, estimate costs, and avoid subtle bugs when switching models. This guide walks through the mechanics, the algorithms, and the practical consequences for anyone building on top of LLMs.

What tokenization actually does

A tokenizer maps raw text to a sequence of integers — token IDs — that the model can process. Each integer indexes into the model’s embedding matrix. The mapping is deterministic and reversible (mostly): given a token ID sequence, you can reconstruct the original text, modulo normalization choices like Unicode handling.

The vocabulary size is fixed at training time. GPT-4o uses ~200k tokens. Llama 3 uses 128k. Smaller vocabularies mean longer sequences for the same text; larger vocabularies increase embedding matrix size and softmax computation. Every model ships with its own tokenizer. You cannot mix them.

# Different models, different tokenizations for the same text
text = "tokenization"

# GPT-4o (o200k_base)
# [9468, 42292]  -> 2 tokens

# Llama 3 (tokenizer.model)
# [13522, 29892] -> 2 tokens

# But the IDs mean completely different things
# and the boundaries often differ

The three main approaches

Tokenization strategies fall into three categories. Modern LLMs almost exclusively use subword methods.

Character-level

Each character becomes a token. Vocabulary is tiny (256 for bytes, ~100k for full Unicode). Sequences become extremely long — English averages ~5-6 characters per word, so a 4k token context window holds only ~800 words. Rarely used for LLM training today, but useful for byte-level fallbacks.

Word-level

Each word gets a unique ID. Vocabulary explodes (hundreds of thousands to millions). Out-of-vocabulary words break the system. Fast to train, but poor generalization to unseen words and massive embedding tables. Used in early NLP (word2vec era), not in modern LLMs.

Subword (BPE, WordPiece, Unigram)

The sweet spot. Frequent words stay single tokens. Rare words split into meaningful pieces. Vocabulary stays manageable (32k-256k). Handles any Unicode text. This is what every production LLM uses.

How BPE works

Byte Pair Encoding (BPE) is the most common subword algorithm. It starts with a character vocabulary, then iteratively merges the most frequent adjacent pair.

# Simplified BPE training loop
from collections import Counter

def train_bpe(corpus: list[str], vocab_size: int) -> dict[tuple[int, ...], int]:
    # Start with byte-level vocabulary (256 tokens)
    vocab = {bytes([i]): i for i in range(256)}
    merges = {}
    
    # Pre-tokenize: split on whitespace, keep punctuation attached
    words = []
    for text in corpus:
        words.extend(text.split())
    
    # Count word frequencies
    word_freqs = Counter(words)
    
    # Represent each word as sequence of byte IDs
    word_splits = {word: [bytes([b]) for b in word.encode("utf-8")] 
                   for word in word_freqs}
    
    next_id = 256
    while next_id < vocab_size:
        # Count all adjacent pairs across the corpus
        pair_counts = Counter()
        for word, freq in word_freqs.items():
            split = word_splits[word]
            for i in range(len(split) - 1):
                pair = (split[i], split[i + 1])
                pair_counts[pair] += freq
        
        if not pair_counts:
            break
            
        # Merge most frequent pair
        best_pair = pair_counts.most_common(1)[0][0]
        new_token = best_pair[0] + best_pair[1]
        merges[best_pair] = next_id
        vocab[new_token] = next_id
        next_id += 1
        
        # Update all word splits
        for word in word_splits:
            split = word_splits[word]
            new_split = []
            i = 0
            while i < len(split):
                if i < len(split) - 1 and (split[i], split[i + 1]) == best_pair:
                    new_split.append(new_token)
                    i += 2
                else:
                    new_split.append(split[i])
                    i += 1
            word_splits[word] = new_split
    
    return vocab, merges

At inference time, you apply the learned merges in order:

def encode_bpe(text: str, merges: dict[tuple[bytes, bytes], int]) -> list[int]:
    # Start with bytes
    tokens = [bytes([b]) for b in text.encode("utf-8")]
    
    # Apply merges in priority order (earlier merges = higher priority)
    for (a, b), new_id in sorted(merges.items(), key=lambda x: x[1]):
        i = 0
        new_tokens = []
        while i < len(tokens):
            if i < len(tokens) - 1 and tokens[i] == a and tokens[i + 1] == b:
                new_tokens.append(a + b)
                i += 2
            else:
                new_tokens.append(tokens[i])
                i += 1
        tokens = new_tokens
    
    # Convert to IDs (requires full vocab mapping)
    return [vocab[token] for token in tokens]

Real implementations (tiktoken, Hugging Face tokenizers) optimize this heavily with tries and pre-tokenization regexes. The principle is the same.

WordPiece and Unigram

BPE is greedy — it always merges the most frequent pair. WordPiece (used by BERT) chooses the merge that maximizes likelihood of the training data. Unigram (used by T5, XLNet, Llama) starts with a large vocabulary and prunes tokens that least reduce likelihood. Both produce slightly different segmentations but serve the same purpose.

# Hugging Face tokenizers expose the algorithm choice
from tokenizers import Tokenizer, models, trainers, pre_tokenizers

# BPE (GPT-style)
tokenizer = Tokenizer(models.BPE())
trainer = trainers.BpeTrainer(vocab_size=32000, special_tokens=["<pad>", "<s>", "</s>"])

# WordPiece (BERT-style)  
tokenizer = Tokenizer(models.WordPiece(unk_token="[UNK]"))
trainer = trainers.WordPieceTrainer(vocab_size=32000, special_tokens=["[PAD]", "[CLS]", "[SEP]"])

# Unigram (Llama/T5-style)
tokenizer = Tokenizer(models.Unigram())
trainer = trainers.UnigramTrainer(vocab_size=32000, special_tokens=["<pad>", "<s>", "</s>"])

Special tokens and chat templates

Every tokenizer reserves IDs for control signals. These are not compressible text — they are protocol.

# Common special tokens across models
special_tokens = {
    "pad_token": "<pad>",      # Padding for batch alignment
    "bos_token": "<s>",        # Beginning of sequence
    "eos_token": "</s>",       # End of sequence  
    "unk_token": "<unk>",      # Unknown (rare with subword)
    "mask_token": "<mask>",    # For masked language modeling
}

# Chat templates add structure tokens
# Llama 3 chat template (simplified)
"""
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|>
<|start_header_id|>user<|end_header_id|>
Hello<|eot_id|>
<|start_header_id|>assistant<|end_header_id|>
"""

The chat template is part of the tokenizer configuration, not the model weights. Apply it correctly or the model will hallucinate structure.

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct")

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "How does tokenization work?"},
]

# This applies the chat template and tokenizes
input_ids = tokenizer.apply_chat_template(
    messages, 
    tokenize=True, 
    add_generation_prompt=True,  # Adds assistant header for generation
    return_tensors="pt"
)

Practical implications for engineers

Context window accounting

Token counts determine what fits. A 128k context window means 128k token IDs, not characters or words. English averages ~1.3 tokens per word. Code averages ~1.5-2 tokens per word (more punctuation, identifiers split aggressively).

def estimate_tokens(text: str, model: str = "gpt-4o") -> int:
    import tiktoken
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

# Rough guidelines
# 1k tokens  ≈ 750 English words ≈ 500 words of code
# 4k tokens  ≈ 3k English words  ≈ 2k words of code  
# 128k tokens ≈ 100k English words ≈ 65k words of code

Cost estimation

Providers bill by input and output tokens. Know your tokenizer.

# Pricing example (hypothetical)
PRICING = {
    "gpt-4o": {"input": 5.00, "output": 15.00},  # per 1M tokens
    "gpt-4o-mini": {"input": 0.15, "output": 0.60},
}

def estimate_cost(prompt_tokens: int, completion_tokens: int, model: str) -> float:
    rates = PRICING[model]
    return (prompt_tokens * rates["input"] + completion_tokens * rates["output"]) / 1_000_000

Tokenization drift across models

Switching models changes token counts. A prompt that fits in 4k tokens on GPT-3.5-turbo might exceed 4k on Llama 3 because of different vocabulary choices. Always re-measure.

prompt = "Explain how does tokenization work in LLMs"

import tiktoken
from transformers import AutoTokenizer

gpt4o_enc = tiktoken.encoding_for_model("gpt-4o")
llama3_tok = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")

print(f"GPT-4o: {len(gpt4o_enc.encode(prompt))} tokens")
print(f"Llama 3: {len(llama3_tok.encode(prompt))} tokens")
# Output differs — plan for it

Common pitfalls

Assuming whitespace equals token boundaries

Subword tokenizers split inside words. “tokenization” → [“token”, “ization”] or [“tok”, “en”, “ization”]. Never split on spaces and assume alignment.

# WRONG: assuming word-level alignment
words = text.split()
token_ids = tokenizer.encode(text)
for word, token_id in zip(words, token_ids):  # Breaks immediately
    ...

# RIGHT: use tokenizer's offset mapping
encoding = tokenizer(text, return_offsets_mapping=True)
for (start, end), token_id in zip(encoding.offset_mapping, encoding.input_ids):
    span = text[start:end]
    ...

Ignoring the pre-tokenizer

Most tokenizers run a regex pre-tokenization step before BPE. GPT-4o’s o200k_base uses a regex that preserves contractions, handles numbers, and splits on specific punctuation. This affects token boundaries.

# tiktoken exposes the pre-tokenization regex pattern
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
print(enc._pat)  
# Regex pattern that determines initial splits before BPE merges

Treating token IDs as portable

Token ID 42 means something completely different in every vocabulary. Never store raw token IDs in databases or logs expecting them to be reusable across models. Store text, or store (model_name, token_ids) pairs.

Forgetting the byte fallback

All modern tokenizers can represent any Unicode text via byte fallback. But the byte-level tokens (IDs 0-255 in many vocabularies) are inefficient — one byte per token. Text in unsupported scripts balloons token count.

# English: ~1.3 tokens/word
# Chinese: ~1.5-2 tokens/character (no spaces, each char ~1 token)
# Emoji: often 2-3 tokens each (byte fallback sequences)
# Code: identifiers split aggressively (snake_case → multiple tokens)

Tradeoffs to consider

Vocabulary size vs. sequence length

Larger vocabulary → shorter sequences → more context fits → larger embedding matrix → slower softmax. Llama 3 chose 128k vocab. GPT-4o chose ~200k. There’s no free lunch.

Training data language mix

A tokenizer trained on 90% English will segment English efficiently but fragment other languages. Multilingual models (mT5, XLM-R, Llama 3) use larger vocabularies and balanced training data to mitigate this.

Adding tokens post-training

You can extend a tokenizer’s vocabulary (new special tokens, domain terms) but the model’s embedding matrix must also expand and the new embeddings initialized randomly. This requires continued training. Don’t do this lightly.

# Adding tokens to a Hugging Face tokenizer
tokenizer.add_special_tokens({"additional_special_tokens": ["<CUSTOM>"]})
# Model embeddings must be resized to match
model.resize_token_embeddings(len(tokenizer))
# New embeddings are random — model needs fine-tuning

Tokenizer versioning

Tokenizers have versions. cl100k_base (GPT-3.5/4) vs o200k_base (GPT-4o) produce different tokenizations for the same text. Pin your tokenizer version in production.

# Explicit version pinning
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")  # Not "encoding_for_model"
# Or with transformers
tokenizer = AutoTokenizer.from_pretrained(
    "meta-llama/Meta-Llama-3-8B",
    revision="main"  # Pin to specific commit/hash in production
)

Debugging tokenization issues

When something looks wrong — costs spike, context overflows, output quality drops — inspect the tokens.

def debug_tokenization(text: str, tokenizer) -> None:
    encoding = tokenizer(text, return_offsets_mapping=True)
    
    print(f"Text: {repr(text)}")
    print(f"Token count: {len(encoding.input_ids)}")
    print("Tokens:")
    for i, (token_id, (start, end)) in enumerate(zip(encoding.input_ids, encoding.offset_mapping)):
        token_text = text[start:end] if start < end else "<special>"
        print(f"  [{i}] ID={token_id:6d} | {repr(token_text):20s} | span=({start}:{end})")

# Example output:
# Text: "tokenization"
# Token count: 3
# Tokens:
#   [0] ID=  1234 | 'token'              | span=(0:5)
#   [1] ID=  5678 | 'ization'            | span=(5:12)
#   [2] ID=     2 | '</s>'               | span=(12:12)

Summary

Tokenization is a fixed, deterministic preprocessing step that shapes everything downstream. The algorithm (BPE, WordPiece, Unigram) matters less than the vocabulary and pre-tokenization choices baked into each model. As an engineer, you need to:

  1. Count tokens accurately for your specific model before sending requests
  2. Respect special tokens and chat templates — they are part of the model’s protocol
  3. Never assume portability of token IDs across models
  4. Monitor token usage in production; it drives cost and latency
  5. Pin tokenizer versions to avoid silent drift

The tokenizer is the narrow waist of the LLM stack. Everything passes through it. Treat it like the critical infrastructure it is.

Tagstokenstokenizationnlpllm-basics

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All tokens & tokenization posts →