At a glance
Key takeaways
- Text is split into subword pieces by BPE: start from characters or bytes and repeatedly merge the most frequent neighbouring pair.
- Byte-level BPE can encode anything with no unknown token.
- Cost, latency and context limits are counted in tokens; English is about 4 characters (0.75 words) per token, while code and non-English text need more tokens.
Level 2
How it works, from scratch
Level 2 builds the kit of bricks from nothing, starting with what a token is.
Chapter 1
Tokens: building words from a fixed kit of bricks
Everyday picture A box of Lego with a fixed set of bricks. Common shapes have a ready-made brick; anything unusual gets assembled from smaller bricks. You can build anything, but some builds take many more bricks than others. A tokenizer is that kit for text: a fixed vocabulary of pieces (typically 32,000 to 200,000 of them), each with a number, its token id.
Tiny worked example With a kit that contains the, un, believ and
able, "the" takes 1 brick and "unbelievable" takes 3: un + believ +
able. The model never sees the letters; it sees a short list of ids such
as [262, 403, 11009, 540].
| Approach | Kit size | "unbelievable" | Problem |
|---|---|---|---|
| Whole words | huge (every form of every word, every name) | 1 piece, if it's in the kit | words not in the kit become <unk>; typos break |
| Single bytes | 256 | 12 pieces | sequences 4-5× longer, and attention cost grows with length squared |
| Subwords | 32k to 200k | ~3 pieces | the middle ground every modern LLM uses |
Figure 1 · Diagram
flowchart LR T["'the unbelievable'"] --> TOK["tokenizer<br/>(fixed kit of pieces)"] TOK --> P["pieces: 'the' | ' un' | 'believ' | 'able'"] P --> IDS["ids: 262, 403, 11009, 540"] IDS --> EMB["embedding table<br/>one vector per id"] EMB --> M["the model"]
primer.ml.big_picture), and only those vectors reach the model. Everything
the model knows about spelling it had to learn through this narrow window.
(The ids shown are illustrative.)In code: trained_tokenizer builds this lesson's toy kit, and
ByteBPE.encode turns any text into its list of ids.
Why it matters Everything downstream is counted in tokens: price, speed, and how much fits in the context window.
Chapter 2
Byte Pair Encoding (BPE): learning the kit from data
Everyday picture A court stenographer inventing shorthand. Whatever pair of letters they write most often gets its own squiggle. Then the most common pair including that squiggle gets one too, and so on, until they have as many squiggles as they can remember.
Tiny worked example Training text "low lower lowest". Start from single
letters and repeatedly merge the most frequent neighbouring pair
(train_char_bpe reproduces this exactly):
| Step | Most frequent pair | New token | Text becomes |
|---|---|---|---|
| Start | none | none | l o w · l o w e r · l o w e s t |
| 1 | l + o (3 times) | lo | lo w · lo w e r · lo w e s t |
| 2 | lo + w (3 times) | low | low · low e r · low e s t |
| 3 | low + e (2 times) | lowe | low · lowe r · lowe s t |
At step 1, l+o and o+w are tied at 3; ties go to the pair seen first.
Figure 2 · Diagram
flowchart LR T[Training text] --> S[Split into characters] S --> C[Count adjacent pairs] C --> M[Merge the most frequent pair<br/>record the merge rule] M -->|vocab not full yet| C M -->|vocab full| V[Vocabulary + ordered merge list]
The math and the code Each round picks the pair with the largest count:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| two symbols that sit side by side | l and o | |
| one distinct word of the training text | "lower" | |
| "add up the following over every distinct word" | low, lower, lowest | |
| how many times word occurs in the text | 1 each | |
| how many times is immediately followed by inside | 1 in each word | |
| the pair's total, the number BPE ranks by | 3 |
In words: "for each distinct word, count how often the pair appears inside it, multiply by how often the word occurs, and add everything up."
With the numbers: count(l, o) = 1·1 (low) + 1·1 (lower) + 1·1 (lowest) =
3; count(e, r) = 1·1 (lower) = 1. train_char_bpe computes the same
totals with a Counter.
Level 3: in Python
# f_w: how often each distinct word occurs
f = {"low": 1, "lower": 1, "lowest": 1}
# n_w(a, b): a immediately followed by b inside w
def n(w, a, b):
return sum(1 for i in range(len(w) - 1) if w[i] == a and w[i + 1] == b)
def count(a, b):
# Σ_w f_w · n_w(a, b)
return sum(f_w * n(w, a, b) for w, f_w in f.items())
count("l", "o"), count("e", "r") # → (3, 1)
In code: train_char_bpe runs the count-and-merge loop and returns one
MergeStep per round, holding the winning pair, its count, the new token
and the text after the merge (one row of the table above).
Why it matters Frequent strings end up as single tokens and rare ones stay in pieces, which is exactly why token counts differ from word counts, and why a tokenizer trained mostly on English is cheap for English and expensive for everything else (section 6).
Chapter 3
Encoding new text: replay the merges in order
Everyday picture Following a recipe card: do step 1 everywhere it applies, then step 2, and so on. Skipping ahead would give a different dish.
Tiny worked example Encode "lowest" with the three merges learned
above: l o w e s t → (merge 1) lo w e s t → (merge 2) low e s t →
(merge 3) lowe s t. Result: 3 tokens. A word never seen in training still
works: "slow" → s low; "newer" matches no merge and stays as letters.
Figure 3 · Diagram
flowchart LR W["'lowest' as letters<br/>l o w e s t"] --> R1["merge 1: l+o<br/>lo w e s t"] R1 --> R2["merge 2: lo+w<br/>low e s t"] R2 --> R3["merge 3: low+e<br/>lowe s t"] R3 --> OUT["no learned merge applies<br/>tokens: lowe | s | t"]
The code encode_char_bpe repeatedly finds, among the pairs present,
the one learned earliest, merges it, and loops. Training and encoding
agree because they apply rules in the same order.
Why it matters Encoding is deterministic: the same text always gives the same ids, which is what makes prompt caching and cost estimates possible.
Chapter 4
Byte-level BPE: nothing is ever unknown
Everyday picture Every file on a computer, whatever it holds, is a sequence of bytes: numbers from 0 to 255. If the smallest bricks in the kit are those 256 byte values, then no text can ever be unbuildable: emoji, Chinese, source code or garbage all come apart into bytes.
Tiny worked example In UTF-8 (the standard way to store text as bytes) "a" is one byte, 97; "é" is two bytes, 195 169; "🙂" is four bytes, 240 159 153 130. An untrained byte-level tokenizer turns "é🙂" into 6 tokens. After training on English, " password" is one token, id 291, because it was frequent.
Figure 4 · Diagram
flowchart LR T["Text: 'Reset your password'"] --> P["Pre-tokenize (regex)<br/>'Reset' | ' your' | ' password'"] P --> B["UTF-8 bytes per chunk<br/>' your' = 32 121 111 117 114"] B --> R["Replay merges, earliest first<br/>inside each chunk only"] R --> I["Token ids<br/>346 377 309 328 291"] I --> D["Decode: look up bytes per id,<br/>concatenate, UTF-8 decode"]
Re set),
" password" is one. Decoding is the reverse lookup: every id maps to a fixed
byte string, and concatenating them restores the exact original bytes. That
is why a byte-level tokenizer round-trips any text.The code ByteBPE is train_char_bpe with bytes instead of letters,
ids 0-255 for the bytes and 256, 257, ... for each merge. GPT-2, GPT-4's
cl100k_base, Llama 3 and most current LLMs work this way.
Figure 5 · Drawn from the lesson's code
Compression climbs steeply from 1.0 to 2.0 characters per token in the first 44 merges, then flattens near 3.1 by 500 entries
In code: ByteBPE.train learns the byte merges, ByteBPE.encode and
ByteBPE.decode make the round trip, and compression_curve trains
tokenizers of growing size and measures each one for the figure above.
Figure 6 · Interactive · computed from the lesson's code
Byte-level BPE tokenizer
Try it: the tokenizer below is this lesson's own, with its 144 learned merges. Drag the merges down to 0 and every byte is its own token; drag them back up and watch frequent pieces such as " the" and " password" fuse, one merge at a time, while characters per token climbs like the curve above. Then type a word the corpus never saw, or an accent or an emoji, and watch it stay in small pieces.
Why it matters No "unknown token" failures, ever, for any input. The price is that unfamiliar scripts fall back to near-byte level and cost many more tokens.
Chapter 5
The cousins: WordPiece and Unigram
Everyday picture BPE glues the most common pair. WordPiece glues the most inseparable pair: two pieces that almost never appear apart, like "Q" and "u" in English. Unigram works the other way round: start with a huge kit and keep throwing out the bricks you'd miss least.
Tiny worked example On "low lower lowest", BPE's first merge is l+o.
WordPiece's first merge is s+t: "s" and "t" each appear exactly once, and
always together.
Figure 7 · Diagram
flowchart TB
subgraph BPE["BPE (GPT, Llama)"]
b1[start small] --> b2[merge most frequent pair] --> b3[grow to target size]
end
subgraph WP["WordPiece (BERT)"]
w1[start small] --> w2["merge pair with best<br/>count(ab) / count(a)·count(b)"] --> w3[grow to target size]
end
subgraph UG["Unigram (T5, SentencePiece default)"]
u1[start huge] --> u2[drop pieces whose loss<br/>hurts likelihood least] --> u3[shrink to target size]
end
The math WordPiece ranks pairs by
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| how often is immediately followed by | count(st) = 1 | |
| how often appears at all | count(s) = 1 | |
| how often appears at all | count(t) = 1 | |
| fraction bar | divide: pairs are rewarded when their parts rarely appear apart |
In words: "how often the two appear together, divided by how often each appears at all."
With the numbers: score(s, t) = 1 / (1 · 1) = 1.0; score(l, o) =
3 / (3 · 3) = 0.33. WordPiece merges s+t first (wordpiece_scores).
Level 3: in Python
f = {"low": 1, "lower": 1, "lowest": 1}
# how often a symbol, or a pair written together, appears
def count(piece):
return sum(f_w * w.count(piece) for w, f_w in f.items())
def score(a, b):
# count(ab) / (count(a) · count(b))
return count(a + b) / (count(a) * count(b))
score("s", "t"), round(score("l", "o"), 2) # → (1.0, 0.33)
BERT marks continuation pieces with ## ("un", "##believ", "##able").
SentencePiece is a library that runs BPE or Unigram directly on raw text,
writing spaces as the visible symbol ▁.
Why it matters Different model families segment the same text differently, so their token counts and costs differ. Always count with the tokenizer of the model you'll actually call.
Chapter 6
Tokens are the unit of cost, speed and memory
Everyday picture A taxi meter that ticks per token, not per mile, with a pricier meter for the return trip: output tokens usually cost several times more than input tokens.
Tiny worked example A prompt of 2,000 tokens with a 500-token answer, at example prices of $3 per million input tokens and $15 per million output tokens: 0.006 + 0.0075 = $0.0135. A million such calls cost $13,500.
Figure 8 · Diagram
flowchart LR P["prompt text"] --> TI["count input tokens<br/>meter A: $ per million in"] TI --> M[model] M --> TO["count output tokens<br/>meter B: $ per million out (pricier)"] TO --> BILL["bill = A + B"] TI -.-> CTX["also: must fit the<br/>context window"]
The math and the code
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| input tokens in the call | 2,000 | |
| output tokens generated | 500 | |
| one million, because prices are quoted per million tokens | 1,000,000 | |
| price per million input tokens, in dollars | 3 | |
| price per million output tokens, in dollars | 15 |
In words: "input tokens in millions times the input price, plus output tokens in millions times the output price."
With the numbers: 2,000/1,000,000 × 3 + 500/1,000,000 × 15 = 0.006 +
0.0075 = $0.0135 (estimate_cost).
Level 3: in Python
T_in, T_out = 2_000, 500
# dollars per million tokens
p_in, p_out = 3, 15
round(T_in / 10**6 * p_in + T_out / 10**6 * p_out, 4) # → 0.0135
Rule of thumb: English prose averages about 4 characters per token,
or 0.75 words per token, so 1,000 tokens ≈ 750 words
(estimate_tokens, tokens_to_words). Use it for quick estimates; count
with the real tokenizer for anything that matters.
Figure 9 · Drawn from the lesson's code
English prose gets 2.6 characters per token; German, code and JSON 1.2 to 1.4; Hindi and emoji under 0.4
In code: chars_per_token computes each bar: the length of the text
divided by the number of ids ByteBPE.encode returns for it.
Why it matters The same request can differ several-fold in cost, latency and context usage depending on language and content: dense JSON, code, tables and non-English text all need more tokens than English prose.
Chapter 7
Quirks the tokenizer explains
Everyday picture Reading through frosted glass that only shows whole bricks. You can tell which bricks are there, but not the letters printed on them.
Tiny worked example (this module's toy tokenizer):
| Text | Tokens | What it explains |
|---|---|---|
" cat" vs "cat" |
[303] vs [99, 268] (c at) |
a leading space makes a different token; the model must learn they mean the same |
" 1234" vs " 12345" |
[' 1234'] vs [' 1234', '5'] |
digits split by length and context, so place values don't line up: one reason arithmetic is hard |
" strawberry" |
[' straw', 'berry'] |
2 ids, not 10 letters: "how many r's?" is hard |
Figure 10 · Diagram
flowchart LR W["' strawberry'"] --> T["tokenizer"] --> I["ids 396, 311"] I --> M["model sees two ids<br/>no letters, no count of r"] Q["'how many r's?'"] --> M
straw and berry are spelled from seeing them in text, which is why
spelling and letter-counting questions trip up models that handle much
harder reasoning.The code ByteBPE.tokens(text) shows the pieces for any string; the
demo prints these cases.
Why it matters These quirks explain surprising behaviour: miscounted letters, shaky arithmetic, odd handling of rare names. Some newer tokenizers split digits into fixed groups of up to 3 to reduce the arithmetic problem.
Test yourself
7 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1Why subwords instead of whole words or characters?Think it through, then reveal
Words give a huge vocabulary and unknown words; characters make sequences several times longer, and attention cost grows with the square of length. Subwords keep common strings short and still encode anything.
Question 2How does BPE training proceed, step by step, on "low lower lowest"?Think it through, then reveal
Split into letters, count neighbouring pairs, and merge the most frequent: l+o (3), then lo+w (3), then low+e (2). Record each merge; to encode, replay the merges in the order learned.
Question 3Why can a byte-level BPE tokenizer never produce an unknown token?Think it through, then reveal
Its base vocabulary is all 256 byte values, and every string is a sequence of UTF-8 bytes, so in the worst case text falls back to single bytes.
Question 4What does WordPiece do differently from BPE?Think it through, then reveal
It merges the pair with the highest count(ab) / (count(a)·count(b)), which favours pairs whose parts rarely appear apart, instead of the most frequent pair.
Question 5Your users write in Hindi. What changes in your estimates?Think it through, then reveal
Expect noticeably more tokens for the same content: higher cost and latency, and less text per context window. Measure with the actual tokenizer.
Question 6Why are LLMs bad at counting letters and at long arithmetic?Think it through, then reveal
They see token ids, not characters, and numbers split into chunks that don't line up with place value.
Question 7What does a call with 2,000 input and 500 output tokens cost at $3/$15 per million?Think it through, then reveal
0.006 + 0.0075 = $0.0135. And 1,000 tokens is about 750 English words.
Primary sources
The papers behind this lesson
Brought byte pair encoding, a 1990s compression trick, to language models as a way to build open-vocabulary subword units.
Read the annotated companion →The paper ↗Introduced byte-level BPE with a pre-tokenization regex, the design most LLM tokenizers still follow.
The paper ↗A language-independent tokenizer library that works on raw text and encodes spaces as the symbol ▁.
The paper ↗Introduced the Unigram language-model tokenizer that prunes a large vocabulary down instead of merging up.
The paper ↗Researcher's shelf
Further reading
- Sennrich et al., Neural Machine Translation of Rare Words with Subword Units (the BPE paper, 2015): https://arxiv.org/abs/1508.07909
- Andrej Karpathy, Let's build the GPT Tokenizer (video): https://www.youtube.com/watch?v=zduSFxRajkE
- Karpathy's
minbpe(minimal, clean byte-level BPE): https://github.com/karpathy/minbpe - OpenAI
tiktoken(fast BPE used by GPT models): https://github.com/openai/tiktoken - Hugging Face LLM course, BPE chapter: https://huggingface.co/learn/llm-course/chapter6/5
- Hugging Face LLM course, WordPiece chapter: https://huggingface.co/learn/llm-course/chapter6/6
- Hugging Face LLM course, Unigram chapter: https://huggingface.co/learn/llm-course/chapter6/7
- Kudo & Richardson, SentencePiece (2018): https://arxiv.org/abs/1808.06226
- Kudo, Subword Regularization (the Unigram LM, 2018): https://arxiv.org/abs/1804.10959
- Petrov et al., Language Model Tokenizers Introduce Unfairness Between Languages (2023): https://arxiv.org/abs/2305.15425
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.