Back to articles

Before an LLM Can Read Your Prompt, It Has to Break It Apart

Whenever you type something into ChatGPT, Claude, Gemini, or basically any LLM, there is a step happening before the model sees your prompt.

You write:

"The fox is running."

But the model doesn't actually receive:

"The fox is running."

It receives something more like:

[464, 1241, 318, 5632, 13]

These are token IDs.

And this step turning human text into these IDs is called tokenization.

This is one of those things that gets skipped way too quickly when learning LLMs.

People jump straight into:

  • Attention
  • Transformers
  • Embeddings
  • KV cache
  • Flash Attention
  • RLHF

But there is a very simple thing happening before all of that:

The model needs to turn text into numbers.

And there are several ways you could do that.

Let's build the intuition from scratch.

So... what exactly is a token?

A token is basically a piece of text that the model treats as one unit.

It doesn't necessarily mean a word.

For example:

"hello"

could be:

["hello"]

But something like:

"unbelievable"

could become:

["un", "believ", "able"]

Or even something completely different depending on the tokenizer.

The important thing is:

The tokenizer decides what pieces of text the model gets to see.

And that decision has a huge impact on the model.

So let's look at the different approaches.

Approach 1: Character-level tokenization

The most obvious idea is:

Why don't we just make every character a token?

Take:

hello

We could tokenize it as:

h e l l o

And assign each character an ID:

h → 10
e → 5
l → 12
o → 15

Very simple.

The intuition

Imagine you're building a vocabulary.

Instead of trying to memorize every possible word in English, you only need to know the alphabet.

That's extremely powerful because you basically can't encounter an unknown word.

If the user types:

xqzplm

you don't care.

You already know:

x
q
z
p
l
m

So from a vocabulary perspective, character-level tokenization is incredibly robust.

But there is a problem

Look at:

"the cat is sitting on the chair"

At the character level, this becomes something like:

t h e   c a t   i s   s i t t i n g ...

That's a lot of tokens.

And remember:

Transformers process sequences of tokens.

More tokens → longer sequence → more computation.

And there's another problem.

A character like:

t

doesn't tell you much.

But:

the

contains a lot more useful information.

The model now has to learn that:

t + h + e

forms a meaningful linguistic unit.

Then:

c + a + t

forms another.

So the model has to spend a lot of capacity reconstructing structure that we already know exists.

Pros

  • Tiny vocabulary
  • Almost no unknown-word problem
  • Works with arbitrary text
  • Very simple

Cons

  • Huge sequence lengths
  • Computationally expensive
  • Individual tokens contain very little semantic information
  • Model has to learn common patterns from scratch

So character-level tokenization is kind of like saying:

"I'll give the model the smallest possible Lego pieces and let it figure everything out."

Approach 2: Word-level tokenization

Then you might think:

Fine. Let's go in the opposite direction.

Instead of characters, let's make words tokens.

The fox is running

becomes:

["The", "fox", "is", "running"]

Much better!

Instead of 18-ish characters, we're down to 4 tokens.

And now each token carries much more information.

The model doesn't need to figure out that:

r + u + n + n + i + n + g

means:

running

It gets:

running

directly.

Sounds great.

Until you realize how ridiculous the vocabulary becomes.

The vocabulary explosion problem

Think about all the possible words.

English alone has a massive vocabulary.

And then you have:

run
running
runs
ran
runner
runners

Should every variation get its own token?

And what happens when someone writes:

TalalAhmedSuperLongRandomWord123

You probably don't have that entire thing in your vocabulary.

So now you need some kind of:

<UNK>

unknown token.

Which means you've gone from:

"The model knows every character"

to:

"The model has no idea what this word is."

That's a pretty bad tradeoff.

Pros

  • Very short sequences
  • Each token carries a lot of information
  • Easy conceptually

Cons

  • Massive vocabulary
  • Unknown-word problem
  • Poor handling of new words
  • Morphology becomes awkward
  • Different languages make this even harder

So we have two extremes:

Character
   ↓
Tiny vocabulary
Huge sequence

Word
   ↓
Huge vocabulary
Small sequence

And this is where things get interesting.

Approach 3: Subword tokenization

What if we don't choose between characters and words?

What if we allow the tokenizer to learn pieces of words?

For example:

"playing"

might become:

["play", "ing"]

And:

"unhappiness"

could become:

["un", "happi", "ness"]

Now we're getting something useful.

Common pieces get their own tokens.

Rare words can still be broken down into smaller pieces.

So we get something like:

Characters ←────────────→ Words
       ↑
    Subwords

This gives us a pretty nice middle ground:

  • Vocabulary isn't insane
  • Sequence isn't insanely long
  • Unknown words can still be represented
  • Common patterns can become single tokens

There are several subword algorithms.

One of the most important ones is BPE: Byte Pair Encoding.

The rest of this post walks through a simple byte-level BPE tokenizer implementation, step by step.

BPE: The basic idea

The intuition behind BPE is actually very simple.

Start with something extremely basic:

Every byte is its own token.

Then repeatedly ask:

Which pair of tokens appears together the most?

Merge that pair.

Then do it again.

And again.

And again.

Eventually, frequent combinations become their own tokens.

That's basically BPE.

Let's forget LLMs for a second

Suppose our entire dataset is:

low low low
lower lower
lowest

Initially, we're starting with small pieces.

For simplicity, imagine characters:

l o w
l o w
l o w
l o w e r
l o w e r
l o w e s t

Now count adjacent pairs.

We might find:

("l", "o") → very frequent
("o", "w") → very frequent
("w", "e") → somewhat frequent
("e", "r") → ...

Suppose:

("l", "o")

is the most frequent pair.

We merge it:

lo

Now our vocabulary contains:

l
o
w
e
r
s
t
lo

And our words become:

lo w
lo w
lo w
lo w e r
...

Now maybe:

("lo", "w")

becomes the most frequent pair.

Merge again:

low

Now:

low
low
low
low e r
low e r
low e s t

And maybe eventually:

low + er → lower

So the tokenizer has discovered useful chunks from the data.

Nobody manually told it:

"lower should be one token."

The training corpus told it.

That's the beauty of BPE.

What does the tokenizer look like?

Here's the core class. It holds the merge rules, the vocabulary, and optional special tokens:

class BPETokenizer:
    def __init__(self, pattern: str = GPT2_SPLIT_PATTERN, special_tokens=None):
        self.pattern = pattern
        self.pat = re.compile(pattern)
        self.merges: dict[tuple[int, int], int] = {}
        self.vocab: dict[int, bytes] = {i: bytes([i]) for i in range(256)}
        self.special_tokens: dict[str, int] = dict(special_tokens or {})
        self._inverse_special = {v: k for k, v in self.special_tokens.items()}
        self._cache: dict[str, list[int]] = {}

We start the vocabulary with 256 tokens:

self.vocab = {
    i: bytes([i])
    for i in range(256)
}

Why 256?

Because we're working at the byte level.

A byte can represent:

0 → 255

So we have 256 possible starting tokens.

This is slightly different from the simplified character example above.

We're actually saying:

"I don't even need to assume an alphabet. Give me raw UTF-8 bytes."

That's a very useful property.

For example:

hello

gets converted into bytes.

And something like:

你好

also gets converted into UTF-8 bytes.

So the base vocabulary can represent basically arbitrary text.

Step 1: Pretokenization

Before BPE starts merging things, the tokenizer first splits the text into chunks.

This is where this regex comes in:

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

This looks terrifying.

It isn't as bad once you break it down.

The idea is:

Don't run BPE over the entire raw document blindly. First split the text into sensible pieces.

This is called pre-tokenization.

def _pretokenize(self, text: str):
    return (m.group() for m in self.pat.finditer(text))

For example, we want things like:

"I'm running 123!"

to be separated into pieces that roughly correspond to:

"I"
"'m"
" running"
" 123"
"!"

The exact behavior comes from the regex.

Why is the regex important?

Because BPE is learning from frequency.

Imagine we didn't pre-split anything.

Then the tokenizer might happily start learning merges across completely different linguistic structures.

For example:

"hello world"

could eventually encourage merges involving:

"hello" + " "

or even larger sequences.

Sometimes that's useful.

But it can also make your vocabulary overly tied to the exact formatting of your training corpus.

The GPT-2 style regex provides some structure before BPE gets to work.

It basically says:

"Here are the kinds of chunks you should consider independently."

This regex comes from the tokenization approach used by GPT-2, with Unicode-aware categories such as:

\p{L}

for letters and:

\p{N}

for numbers.

That's one reason this uses the third-party Python regex package rather than the standard re module.

Python's normal re doesn't support these Unicode property escapes in the same way.

Step 2: Convert chunks into bytes

Once we have a chunk:

chunk.encode("utf-8")

we convert it into bytes.

So conceptually:

"hello"

becomes something like:

[104, 101, 108, 108, 111]

These are our initial tokens.

At this point:

hello

is basically:

h e l l o

except we're thinking in bytes rather than characters.

During training, every pretokenized chunk becomes a counted byte sequence:

words: Counter[tuple[int, ...]] = Counter()
for chunk, count in Counter(self._pretokenize(text)).items():
    words[tuple(chunk.encode("utf-8"))] += count

Step 3: Count every adjacent pair

Now comes the actual BPE algorithm.

For every word/chunk, we look at adjacent pairs:

pairs: Counter = Counter()
for word, count in words.items():
    for pair in zip(word, word[1:]):
        pairs[pair] += count

For:

hello

we get:

(h, e)
(e, l)
(l, l)
(l, o)

We do this across the entire training corpus.

And we count how often every pair appears.

So we might end up with:

(h, e) → 500
(t, h) → 1200
(i, n) → 900
...

Then:

best = max(pairs, key=lambda p: (pairs[p], p))

finds the most frequent pair.

This is the heart of BPE:

Find the most common neighboring pair.

Step 4: Merge it

Suppose the most frequent pair is:

(97, 98)

which corresponds to:

a + b

We create a new token:

new_token = 256 + i

So the first merge becomes:

256

and we store:

self.merges[best] = new_token

Meaning:

(a, b) → 256

And its actual byte representation is:

self.vocab[new_token] = (
    self.vocab[best[0]] +
    self.vocab[best[1]]
)

So:

a + b

becomes a new token representing:

ab

The merge itself walks a token sequence and replaces matching pairs:

@staticmethod
def _merge_pair(tokens: tuple[int, ...], pair, new_token: int) -> tuple[int, ...]:
    merged = []
    i = 0
    n = len(tokens)
    while i < n:
        if i < n - 1 and tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
            merged.append(new_token)
            i += 2
        else:
            merged.append(tokens[i])
            i += 1
    return tuple(merged)

Then we do it again

This is the important part.

BPE isn't:

"Find the best vocabulary."

It's an iterative process.

Something like:

Start with bytes

        ↓

Count pairs

        ↓

Merge most frequent pair

        ↓

Count pairs again

        ↓

Merge most frequent pair

        ↓

Count pairs again

        ↓

...

The full training loop looks like this:

def train(self, text: str, num_merges: int, verbose: bool = False):
    self.merges = {}
    self.vocab = {i: bytes([i]) for i in range(256)}
    self._cache.clear()

    words: Counter[tuple[int, ...]] = Counter()
    for chunk, count in Counter(self._pretokenize(text)).items():
        words[tuple(chunk.encode("utf-8"))] += count

    for i in range(num_merges):
        pairs: Counter = Counter()
        for word, count in words.items():
            for pair in zip(word, word[1:]):
                pairs[pair] += count
        if not pairs:
            break

        best = max(pairs, key=lambda p: (pairs[p], p))
        new_token = 256 + i

        rebuilt: Counter[tuple[int, ...]] = Counter()
        for word, count in words.items():
            if pair_in_word(word, best):
                word = self._merge_pair(word, best, new_token)
            rebuilt[word] += count
        words = rebuilt

        self.merges[best] = new_token
        self.vocab[new_token] = self.vocab[best[0]] + self.vocab[best[1]]

    return self

If we perform:

num_merges=60

we do this 60 times.

So after training, we've learned 60 new tokens beyond the original 256 byte tokens.

The resulting tokenizer has:

256 base byte tokens
+
60 learned merge tokens

ignoring special tokens.

The really important thing: BPE learns merges, not words

This is something that is easy to miss at first.

We're not literally training a dictionary like:

hello → 1000
world → 1001
running → 1002

We're learning merge rules.

For example:

(h, e) → 256

(256, l) → 257

(257, l) → 258

(258, o) → 259

Which effectively allows:

h e l l o

to become:

hello

The tokenizer has learned how to construct larger pieces.

That's a much more interesting way to think about BPE.

But how do we tokenize new text?

Training is only half the story.

Suppose we've already learned these merges:

(h, e) → 256
(256, l) → 257
(257, l) → 258
(258, o) → 259

Now we encounter:

hello

We start with:

h e l l o

Then apply the learned merges.

Eventually:

hello

becomes:

[259]

But there is an important detail.

We can't just merge pairs randomly.

The tokenizer needs to follow the merge priority learned during training.

That's why encoding looks like this:

def _encode_chunk(self, chunk: str) -> list[int]:
    tokens = tuple(chunk.encode("utf-8"))
    while len(tokens) >= 2:
        pair = min(
            set(zip(tokens, tokens[1:])),
            key=lambda p: self.merges.get(p, float("inf")),
        )
        if pair not in self.merges:
            break
        tokens = self._merge_pair(tokens, pair, self.merges[pair])
    return list(tokens)

We're basically asking:

"Among the pairs currently available, which one was learned earliest?"

The earlier the merge was learned, the higher its priority.

This makes encoding deterministic.

Ordinary text encoding just pretokenizes, then encodes each chunk:

def encode_ordinary(self, text: str) -> list[int]:
    ids: list[int] = []
    for chunk in self._pretokenize(text):
        ids.extend(self._encode_chunk(chunk))
    return ids

Why not just use the most frequent pair again?

Because the frequencies during training and the merge order matter.

Imagine we learned:

A B → X
X C → Y

When encoding:

ABC

we need to first create:

X C

and then:

Y

So the tokenizer follows the merge rules in the order they were learned.

This is why self.merges is essentially the recipe the tokenizer learned during training.

What about decoding?

Eventually the LLM produces token IDs.

For example:

[259, 300, 42]

We need to turn those back into text.

That's what decode() does:

def decode(self, ids, errors: str = "replace") -> str:
    parts = []
    for t in ids:
        if t in self.vocab:
            parts.append(self.vocab[t])
        elif t in self._inverse_special:
            parts.append(self._inverse_special[t].encode("utf-8"))
        else:
            raise ValueError(f"unknown token id: {t}")
    return b"".join(parts).decode("utf-8", errors=errors)

Each token has its byte representation stored in self.vocab.

So we reconstruct:

token IDs
   ↓
bytes
   ↓
UTF-8
   ↓
text

That's why the tokenizer has both:

encode()
decode()

They are basically:

text → token IDs
token IDs → text

And then there are special tokens

There are certain things that aren't ordinary text.

For example:

<|endoftext|>

This isn't something we want BPE to break apart.

So we can register it separately:

tok.register_special_tokens({
    "<|endoftext|>": 100_000
})

Now when we encounter:

hello<|endoftext|>

the tokenizer recognizes hello normally, but <|endoftext|> as one special token:

def encode(self, text: str, allow_special: bool = True) -> list[int]:
    if not (allow_special and self.special_tokens):
        return self.encode_ordinary(text)

    splitter = "(" + "|".join(re.escape(s) for s in self.special_tokens) + ")"
    ids: list[int] = []
    for part in re.split(splitter, text):
        if not part:
            continue
        if part in self.special_tokens:
            ids.append(self.special_tokens[part])
        else:
            ids.extend(self.encode_ordinary(part))
    return ids

This is important because special tokens often tell the model something about the structure of the sequence, rather than representing ordinary language.

So what did we actually build?

At this point, the entire tokenizer is basically doing:

Raw text
   ↓
GPT-2 style pre-tokenization
   ↓
UTF-8 bytes
   ↓
BPE merge rules
   ↓
Token IDs
   ↓
LLM

And in the other direction:

LLM output token IDs
   ↓
Vocabulary lookup
   ↓
Bytes
   ↓
UTF-8 decoding
   ↓
Human-readable text

That little piece of code sitting before the Transformer is doing a surprisingly important job.

Why BPE is such a nice compromise

Let's compare the three approaches.

MethodVocabularySequence lengthUnknown words
CharacterTinyHugeBasically no
WordHugeSmallYes
BPEMediumMediumVery rare

That's why subword tokenization became so popular.

BPE gives us a pretty nice middle ground.

Common things can become single tokens:

the
ing
tion
hello

while unusual things can still be decomposed.

For example, imagine the tokenizer has never seen:

hypermegafunctionalization

It doesn't necessarily need an <UNK> token.

It can fall back to smaller pieces:

hyper
mega
function
al
ization

or even smaller if necessary.

That is a huge advantage.

But BPE isn't perfect either

There are some interesting problems.

1. Tokenization isn't semantic

This is probably the biggest misconception.

BPE doesn't understand language.

It doesn't know that:

king

has a meaning.

It just knows that certain byte sequences occur frequently.

If:

"ing"

appears millions of times, it might become a token.

That's frequency, not understanding.

2. Token boundaries can be weird

You might assume:

"playing"

always becomes:

play + ing

But tokenization isn't necessarily linguistically clean.

It might become something like:

play + ing

or:

pla + ying

depending on what the tokenizer learned.

The tokenizer isn't trying to discover perfect morphemes.

It's trying to find useful compression of the training data.

3. Different languages can behave very differently

Languages with different writing systems, morphology, or scripts can get very different tokenization efficiency.

A tokenizer that is extremely efficient for English isn't necessarily equally efficient for every language.

This matters because:

more tokens
    ↓
longer context
    ↓
more computation
    ↓
potentially higher cost

So tokenization isn't just some preprocessing detail.

It directly affects how efficiently an LLM can process language.

And this is where the tokenizer becomes part of the model design

Once you see this, you realize something interesting.

An LLM doesn't really operate on:

words

It operates on:

tokens

And those tokens are created by a tokenizer.

Then each token ID gets converted into an embedding:

token ID
   ↓
embedding lookup
   ↓
vector
   ↓
Transformer

So the full pipeline is roughly:

"You are amazing"
        ↓
    Tokenizer
        ↓
[You, are, amazing]
        ↓
    Token IDs
        ↓
Embedding lookup
        ↓
Vectors
        ↓
Transformer
        ↓
Next-token prediction

And suddenly all those things you've been learning about — embeddings, attention, positional encoding, Transformers — sit after this seemingly boring little tokenizer step.

One last thing about the implementation

Libraries like Hugging Face's tokenizers are heavily optimized.

This implementation is intentionally simple.

For example, during training it basically does:

for every merge:
    count every pair
    find the best pair
    rebuild the corpus

That is conceptually clean, but it's not the fastest possible implementation.

A production tokenizer uses much more sophisticated data structures and optimizations.

But implementing the naive version is one of the best ways to understand BPE.

Because after writing it, the algorithm stops feeling like:

"Some magical tokenizer algorithm used by GPT."

and becomes:

"Oh... we're literally just counting neighboring pairs and repeatedly merging the most frequent one."

And that's basically BPE.

The entire algorithm in 15 seconds

If you had to explain BPE to someone at a whiteboard:

Start with bytes

        ↓

Find the most frequent adjacent pair

        ↓

Merge it into a new token

        ↓

Repeat

        ↓

Store the merge rules

        ↓

Use those rules to tokenize new text

That's it.

The cleverness isn't in some complicated neural network.

It's in realizing:

If certain pieces of text repeatedly occur together, why not turn them into one reusable token?

And that simple idea gives us a tokenizer that sits right at the entrance of the LLM.

Back to articles