Tokens, Tokenisers and Vocabulary
By the end of this lesson you will be able to say why models work on tokens rather than words or
characters; train a byte-pair-encoding vocabulary by hand and with the tokenizers library and
get the same merges both ways; open a real tokenizer.json, replay its merge rules on a word and
predict the pieces before you run it; explain with numbers why one sentence costs 14 tokens in
English and 25 in Portuguese; turn a context window into a budget with the template overhead and
the answer reserve subtracted; and recognise the special tokens that a chat template puts around
your messages. Every cost figure in this course, context length, memory for the KV cache, price per
request against a hosted API, is denominated in tokens, so this is the unit the rest of the course
counts in.
Every snippet here runs on the CPU in seconds. bpe-by-hand.py and byte-to-char.py need nothing
beyond the Part 1 environment; replay-merges.py needs only the Qwen3-1.7B (Apache-2.0) download
from this part’s lab (its task 2); chat-template-cost.py and tokenizers-offsets.py need that
download and the transformers and tokenizers libraries the lab’s Requirements install;
train-tiny-bpe.py needs only the tokenizers library. The outputs printed below were
produced with transformers transformers 5.16.1 · verified 2026-09-08 and tokenizers 0.23.2 against the
Qwen3-0.6B (Apache-2.0) directory, whose tokeniser files are the same bytes as Qwen3-1.7B’s and
Qwen3-8B’s: the Hub file listings of all three repositories give tokenizer.json the same SHA-256,
aeb13307…, and the same object ids for tokenizer_config.json, vocab.json and merges.txt,
checked 2026-09-12.
Why not words, and why not characters
Section titled “Why not words, and why not characters”The Transformers tokenisation summary sets out both failures. Word-level tokenisation splits on
spaces and punctuation, and then “vocabulary size becomes extremely large because every unique word
requires its own token, including all variations”, which makes the embedding matrix enormous. Worse,
“words not in the vocabulary map to an <unk> token, so the model can’t handle new words”. A
misspelling, a product name or a new library becomes a hole in the input.
Character-level tokenisation has the opposite problem. The vocabulary is tiny and nothing is
unknown, “but sequences become much longer” and “a character like l carries far less meaning than
love, so performance suffers”. Longer sequences are not cosmetic: the
attention lesson shows that
cost grows faster than length.
Subword tokenisation sits between the two. Common words stay whole, rare ones break into pieces
the model has seen before, and the vocabulary is a fixed size chosen before training. The
documentation gives annoyingly as the example, which “might be split into ["annoying", "ly"]
or ["annoy", "ing", "ly"] depending on the vocabulary”. One sentence under the three schemes:
| Scheme | Vocabulary | “The quick brown fox jumps over the lazy dog near the river bank.” | Input outside the vocabulary |
|---|---|---|---|
| Word-level | One entry per distinct word form; unbounded | 13 tokens | Becomes <unk>; the model receives nothing |
| Character-level | The alphabet; tens to thousands of entries | 64 tokens | Nothing is unknown |
| Byte-level BPE, Qwen3 | 151,643 learned entries | 14 tokens, measured below | Falls back to bytes: more tokens, never <unk> |
Byte-pair encoding, learned from data
Section titled “Byte-pair encoding, learned from data”Byte-pair encoding (BPE) is, in the documentation’s words, “the most popular tokenization algorithm in Transformers”, used by the Llama, Gemma and Qwen families. It is a training procedure: start from a base alphabet, count every adjacent pair of symbols in the corpus, merge the most frequent pair into one new symbol, record that merge as a rule, and repeat until the vocabulary reaches the size you chose. It produces two things, a vocabulary (the base alphabet plus one entry per merge) and an ordered list of merge rules, and encoding new text replays the rules in the order they were learned.
The documentation’s example is small enough to follow to the last digit. The corpus is five words
with frequencies, ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5), and the base
vocabulary is their characters, ["b", "g", "h", "n", "p", "s", "u"].
Learning a byte-pair-encoding vocabulary
- Split into characters("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
- Merge the most frequent pair"u"+"g" appears in hug, pug and hugs, so "ug" joins the vocabulary.
- Merge the next one"u"+"n" appears in pun and bun, so "un" joins the vocabulary.
- Repeat to the target sizeThe final size is the base vocabulary plus the number of merges.
Every pair count is weighted by the frequency of the word it occurs in, and a merge changes the counts of its neighbours, which is why the ranking shifts between steps:
| Step | Pair counts, weighted by word frequency | Merge | Vocabulary size |
|---|---|---|---|
| 1 | ug 20, pu 17, un 16, hu 15, gs 5, bu 4 |
u+g → ug |
8 |
| 2 | un 16, hug 15, pu 12, pug 5, ugs 5, bu 4 |
u+n → un |
9 |
| 3 | hug 15, pun 12, pug 5, ugs 5, bun 4 |
h+ug → hug |
10 |
pu falls from 17 to 12 after the first merge because pug no longer contains a p–u pair
once ug is one symbol; only the twelve pun still do. This is the whole algorithm, and it fits in
forty lines of standard-library Python:
RunnableAll tracks
"""Learn a byte-pair-encoding vocabulary from the five-word corpus in the Transformerstokenisation summary, printing every pair count, then encode new words by replaying the merges."""from collections import Counter
corpus = {"hug": 10, "pug": 5, "pun": 12, "bun": 4, "hugs": 5}words = {tuple(w): n for w, n in corpus.items()} # each word as a tuple of symbolsvocab = sorted({c for w in words for c in w}) # base vocabulary: the charactersmerges = [] # the ordered merge rules
def merge_pair(w, a, b): out, i = [], 0 while i < len(w): if i + 1 < len(w) and (w[i], w[i + 1]) == (a, b): out.append(a + b); i += 2 else: out.append(w[i]); i += 1 return tuple(out)
for step in range(1, 4): # three merges, as in the documentation pairs = Counter() for w, n in words.items(): for a, b in zip(w, w[1:]): pairs[(a, b)] += n # weighted by the word's frequency (a, b), count = pairs.most_common(1)[0] print(f"step {step} counts " + ", ".join(f"{x}{y}:{c}" for (x, y), c in pairs.most_common())) print(f"step {step} merge {a!r}+{b!r} ({count} occurrences) -> {a + b!r}") merges.append((a, b)) vocab.append(a + b) words = {merge_pair(w, a, b): n for w, n in words.items()}
print(f"vocabulary ({len(vocab)} entries): {vocab}")print(f"merge rules, in order: {merges}")
def encode(word): symbols = [c if c in vocab else "<unk>" for c in word] for a, b in merges: # replay the rules in training order symbols = list(merge_pair(tuple(symbols), a, b)) return symbols
for word in ["hug", "bug", "mug", "pugs"]: print(f"encode {word!r:7} -> {encode(word)}")Output — what you should see
step 1 counts ug:20, pu:17, un:16, hu:15, gs:5, bu:4step 1 merge 'u'+'g' (20 occurrences) -> 'ug'step 2 counts un:16, hug:15, pu:12, pug:5, ugs:5, bu:4step 2 merge 'u'+'n' (16 occurrences) -> 'un'step 3 counts hug:15, pun:12, pug:5, ugs:5, bun:4step 3 merge 'h'+'ug' (15 occurrences) -> 'hug'vocabulary (10 entries): ['b', 'g', 'h', 'n', 'p', 's', 'u', 'ug', 'un', 'hug']merge rules, in order: [('u', 'g'), ('u', 'n'), ('h', 'ug')]encode 'hug' -> ['hug']encode 'bug' -> ['b', 'ug']encode 'mug' -> ['<unk>', 'ug']encode 'pugs' -> ['p', 'ug', 's']The encoder never counts anything: it replays the three rules in order, so bug becomes b, ug
and mug becomes <unk>, ug because m was not in the base alphabet. pugs stays three
pieces because no rule ever joined p to ug or ug to s. Two properties of this procedure
explain most of what you will see when you print token boundaries. Frequent whole words survive as
single tokens because they were merged early. Anything rare is left as the fragments the merges
happened to produce, which is why an unusual identifier in source code can cost five or six tokens
while a common English word costs one.
Vocabulary sizes are a design choice made before training:
| Tokeniser | Base alphabet | Merges and additions | Entries | Source |
|---|---|---|---|---|
| GPT | 478 characters | 40,000 merges | 40,478 | Transformers summary |
| GPT-2 | 256 byte values | 50,000 merges + 1 end-of-text token | 50,257 | Transformers summary |
| Qwen3 (all sizes) | 256 byte values | 151,387 merges + 26 added tokens | 151,669 in the tokeniser; 151,936 embedding rows in config.json |
tokenizer.json, config.json |
| Llama 3 and 3.1 | 256 byte values | “100K tokens from the tiktoken tokenizer with 28K additional tokens to better support non-English languages” | “128K” in the paper, plus 256 reserved special tokens in tokenizer.py |
Llama 3 paper §3.2; llama-models |
The Qwen3 row is where the width of the output distribution in the previous lesson came from, and the gap between 151,669 and 151,936 is padding to a multiple the matrix kernels like; the lab reconciles those three numbers.
Byte-level BPE
Section titled “Byte-level BPE”Unicode has far too many characters to use as a base alphabet. The documented fix is byte-level
BPE, which “uses 256 byte values as the base vocabulary instead, ensuring every word can be
tokenized without the <unk> token”. The mechanism is a fixed display table, published in GPT-2’s
encoder.py and used unchanged by Qwen3: the 188 byte values that are printable on their own are
shown as themselves, and the other 68 (the controls, space, DEL, the C1 range, no-break space
and soft hyphen) are given the code points from U+0100 upward, in byte order. BPE then merges
those characters exactly as it merged u and g above.
RunnableAll tracks
"""The display alphabet of byte-level BPE: each of the 256 byte values becomes one printablecharacter, so that spaces, newlines and non-ASCII bytes can be merged like any other symbol."""def bytes_to_unicode(): keep = list(range(ord("!"), ord("~") + 1)) + list(range(ord("¡"), ord("¬") + 1)) + list(range(ord("®"), ord("ÿ") + 1)) byte_values, chars, n = keep[:], keep[:], 0 for b in range(256): if b not in keep: # the 68 bytes that are not printable on their own byte_values.append(b) chars.append(256 + n) # get the code points U+0100 onward, in byte order n += 1 return dict(zip(byte_values, map(chr, chars)))
table = bytes_to_unicode()for text in [" ", "\n", "\t", "a", "á", "ç", "🚀"]: shown = "".join(table[b] for b in text.encode("utf-8")) print(f"{text!r:8} utf-8 {text.encode('utf-8').hex(' '):12} shown as {shown!r}")Output — what you should see
' ' utf-8 20 shown as 'Ġ''\n' utf-8 0a shown as 'Ċ''\t' utf-8 09 shown as 'ĉ''a' utf-8 61 shown as 'a''á' utf-8 c3 a1 shown as 'á''ç' utf-8 c3 a7 shown as 'ç''🚀' utf-8 f0 9f 9a 80 shown as 'ðŁļĢ'Space is byte 0x20, the 33rd non-printable byte, so it becomes U+0120, Ġ; newline is Ċ. That
is all Ġworld means: the token for a space followed by world. Non-ASCII text is spent one byte
at a time unless merges have joined the bytes: á is two bytes, and Qwen3’s rule 1697 joins
Ã+¡ back into one symbol; the rocket is four bytes and one token on its own, id 145836, but
three tokens after a space, ĠðŁ, ļ, Ģ: the pre-tokeniser hands BPE the space and all four
bytes as one chunk, the rule Ġ+ðŁ (rank 10906) fires before ðŁ+ļ (rank 123590) can, and no
rule joins ĠðŁ to what follows. Run replay-merges.py below on " 🚀" and on "🚀" to watch
both paths. Strange input never stops a byte-level tokeniser; it just costs more.
The pre-tokeniser decides what may be merged
Section titled “The pre-tokeniser decides what may be merged”Before any merge runs, a regular expression cuts the text into chunks, and BPE works inside a
chunk only. This is the first thing to read in a tokenizer.json, because it fixes behaviour that
no amount of vocabulary can change. Qwen3’s pattern, from the pre_tokenizer entry of the file:
(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+Read left to right: English contractions are split off; one optional non-letter (usually a space) plus a run of letters is a chunk, which is why the space belongs to the word that follows it; a single digit is a chunk on its own; a run of punctuation with an optional leading space is a chunk; then rules for runs of whitespace and newlines. The consequences, all measured with the Qwen3 tokeniser:
| Text | Pieces | Ids | What the pattern did |
|---|---|---|---|
the |
the |
1782 | A word with no space before it |
the |
Ġthe |
279 | A different token: the space is part of it |
1969 |
1, 9, 6, 9 |
16, 24, 21, 24 | \p{N} isolates every digit before BPE sees them |
2026-09-12 |
ten single characters | Digits and punctuation never merge across the boundary | |
I'm |
I, 'm |
40, 2776 | The contraction alternative fires first |
): |
): |
1648 | A punctuation run is one chunk, and this one had a merge |
| four spaces | ĠĠĠĠ |
257 | Whitespace runs are chunks; the first two rules in merges.txt, Ġ Ġ (rank 0, applied twice) and ĠĠ ĠĠ (rank 1), joined them |
Llama 3’s pattern in tokenizer.py, the family that Llama 3.1 8B Instruct (Llama 3.1 Community
licence, gated on the Hub) belongs to, differs from this in one place, \p{N}{1,3} instead of
\p{N}, so 1969 reaches its BPE as the chunks 196 and 9; whether 196 is then one token
depends on that model’s vocabulary, which this course cannot open without accepting that licence.
Either way, the model receives digits as pieces whose boundaries a regular expression chose, not
as a number, and that is the mechanical reason arithmetic on long numbers is fragile and why a
model cannot count the letters in a word it received as token, isation: the letters were never
separately present in the input. Neither is a property of the architecture, and both are worth
checking before concluding from a failed puzzle that a model is worse than it is.
SentencePiece, Unigram and WordPiece
Section titled “SentencePiece, Unigram and WordPiece”Plain BPE begins by splitting on whitespace, which assumes the language uses it. SentencePiece,
which its README describes as “a fast, lightweight, and unsupervised text tokenizer and detokenizer
designed for neural network-based text generation systems”, removes that assumption. It treats
the input “as a raw sequence of Unicode characters” and, in the README’s words, “escapes whitespaces
with a meta-symbol ▁ (U+2581) and includes it in the tokenization. This design ensures that
detokenization is a simple, lossless string join operation”. Chinese, Japanese and Thai then need
no language-specific pre-tokeniser, and ▁the and the are different tokens for the same reason
Ġthe and the are.
SentencePiece can run BPE or the Unigram algorithm underneath. Unigram starts from a large candidate vocabulary and removes the entries whose removal costs the least likelihood, which makes it probabilistic rather than rule-replaying: “Unigram picks the highest probability tokenization” among several possible splits of the same word. WordPiece, the BERT-family algorithm, merges upward like BPE but scores a pair by its joint frequency divided by the product of the parts’ frequencies; “BPE simply merges whichever pair appears the most. WordPiece measures how informative each merge is.” You will not meet WordPiece on a generative model in this course, but you will on some embedding and reranking models, which is why their boundaries look different.
| Algorithm | Direction | Chooses by | Space handling | Used by |
|---|---|---|---|---|
| BPE, byte-level | Merge upward | Highest pair frequency | Byte 0x20 shown as Ġ |
GPT-2, Llama, Qwen, Gemma (per the summary) |
| BPE or Unigram via SentencePiece | Up, or prune downward | Frequency, or likelihood loss | ▁ in the vocabulary |
T5 and other SentencePiece models |
| WordPiece | Merge upward | Frequency ÷ product of part frequencies | ## marks a continuation |
BERT, DistilBERT, Electra |
Tokens, words and bytes are three different counts
Section titled “Tokens, words and bytes are three different counts”A token is not a word and not a byte, and the ratios between them are properties of a particular tokeniser and a particular text. The same sentence, or as close as translation allows, in fourteen forms through the Qwen3 tokeniser (words are whitespace-separated; Chinese and Japanese have none):
| Sample | Characters | UTF-8 bytes | Words | Tokens | Bytes per token | Tokens per word |
|---|---|---|---|---|---|---|
| English | 64 | 64 | 13 | 14 | 4.57 | 1.08 |
| Portuguese | 77 | 80 | 14 | 25 | 3.20 | 1.79 |
| Spanish | 80 | 83 | 15 | 25 | 3.32 | 1.67 |
| French | 74 | 75 | 13 | 23 | 3.26 | 1.77 |
| German | 68 | 69 | 11 | 20 | 3.45 | 1.82 |
| Russian | 67 | 124 | 10 | 29 | 4.28 | 2.90 |
| Arabic | 62 | 113 | 11 | 25 | 4.52 | 2.27 |
| Hindi | 59 | 155 | 12 | 58 | 2.67 | 4.83 |
| Chinese | 17 | 51 | 1 | 12 | 4.25 | |
| Japanese | 26 | 78 | 1 | 21 | 3.71 | |
| Python signature and body | 87 | 87 | 10 | 24 | 3.62 | 2.40 |
| JSON object | 71 | 71 | 9 | 29 | 2.45 | 3.22 |
1969 2026-09-12 3.14159 1,000,000 |
33 | 33 | 4 | 33 | 1.00 | 8.25 |
| Four emoji | 8 | 22 | 4 | 10 | 2.20 | 2.50 |
Four things to read out of it. English is cheap because the merge corpus was English-heavy, so its
common words are single tokens and it runs at over four bytes per token. The Romance languages
cost about 1.8 tokens per word: rápida survived as one token (merge rule 135,034 of 151,387, so
a late and rare one), but castanha, preguiçoso and perto did not. Hindi is the expensive
case, 58 tokens for one sentence, because Devanagari is three bytes per character and few of its
byte sequences were merged. Numbers cost exactly one token per character by construction of the
pre-tokeniser, and JSON is worse than prose because of its punctuation and digits. Compare
languages by bytes per token, not by raw counts, and expect a document’s token count to be roughly
its UTF-8 size divided by the figure in that column for its language.
To see exactly why a word ends up as the pieces it does, replay the model’s own rules. Every entry
in tokenizer.json’s merges list has a rank, its position in the list, and encoding always fires
the lowest-ranked rule that applies:
RunnableAll tracks
"""Replay a model's own merge rules on one word, printing which rule fires at each step andits rank in merges.txt, to see exactly why a word ends up as the pieces it does.Usage: python replay-merges.py [model-dir] [word] (the word may start with a space)"""import jsonimport sysfrom pathlib import Path
model_dir = Path(sys.argv[1] if len(sys.argv) > 1 else "~/llm-course/models/qwen3-1.7b").expanduser()word = sys.argv[2] if len(sys.argv) > 2 else " castanha"
tok = json.loads((model_dir / "tokenizer.json").read_text(encoding="utf-8"))vocab = tok["model"]["vocab"] # token string -> idrank = {tuple(m): i for i, m in enumerate(tok["model"]["merges"])} # earlier merge = lower rank
def bytes_to_unicode(): keep = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256)) byte_values, chars, n = keep[:], keep[:], 0 for b in range(256): if b not in keep: byte_values.append(b); chars.append(256 + n); n += 1 return dict(zip(byte_values, map(chr, chars)))
symbols = [bytes_to_unicode()[b] for b in word.encode("utf-8")]print(f"{word!r} as {len(symbols)} byte symbols: {symbols}")step = 0while True: pairs = [(rank.get(p, float("inf")), i) for i, p in enumerate(zip(symbols, symbols[1:]))] if not pairs or min(pairs)[0] == float("inf"): break # no adjacent pair has a merge rule left best_rank, i = min(pairs) # the lowest rank fires first step += 1 a, b = symbols[i], symbols[i + 1] symbols[i:i + 2] = [a + b] print(f"step {step} merge {a!r} + {b!r} -> {a + b!r:12} rank {best_rank:6d} now {symbols}")print(f"final pieces {symbols} ids {[vocab[s] for s in symbols]}")Output — what you should see
' castanha' as 9 byte symbols: ['Ġ', 'c', 'a', 's', 't', 'a', 'n', 'h', 'a']step 1 merge 's' + 't' -> 'st' rank 11 now ['Ġ', 'c', 'a', 'st', 'a', 'n', 'h', 'a']step 2 merge 'Ġ' + 'c' -> 'Ġc' rank 16 now ['Ġc', 'a', 'st', 'a', 'n', 'h', 'a']step 3 merge 'a' + 'n' -> 'an' rank 20 now ['Ġc', 'a', 'st', 'an', 'h', 'a']step 4 merge 'a' + 'st' -> 'ast' rank 303 now ['Ġc', 'ast', 'an', 'h', 'a']step 5 merge 'h' + 'a' -> 'ha' rank 3967 now ['Ġc', 'ast', 'an', 'ha']step 6 merge 'Ġc' + 'ast' -> 'Ġcast' rank 6055 now ['Ġcast', 'an', 'ha']final pieces ['Ġcast', 'an', 'ha'] ids [6311, 276, 4223]Six rules fire and then none applies: there is no rule joining Ġcast to an or an to ha,
because that sequence was never frequent enough in the corpus to earn one, so the Portuguese word
for chestnut arrives as three pieces the model has seen in many other words. Run it on " the"
and three rules of rank 3, 14 and 23 produce Ġthe, id 279, in three steps: the earliest merges
in the file are the most frequent things in the corpus. Run
python replay-merges.py ~/llm-course/models/qwen3-1.7b " " and the first two rules in the
file, rank 0 twice and rank 1 once, turn four spaces into the single token 257 from the
pre-tokeniser table. The script reproduces the real tokeniser’s
ids for every one of 11,230 chunks of mixed text it was checked against; the replay is the
algorithm, not an approximation of it.
The count depends on the model as much as the language
Section titled “The count depends on the model as much as the language”Two models with different tokenisers give different counts for the same text, and the difference comes from three places you can now name: the pre-tokeniser pattern, the merge list and its corpus, and the number and names of the special tokens.
| Qwen3 family | Llama 3.1 | |
|---|---|---|
| Implementation | tokenizers BPE model, ByteLevel pre-tokeniser and decoder |
tiktoken-style BPE, per tokenizer.py |
| Vocabulary | 151,643 learned + 26 added | “128K” learned, 256 reserved special |
| Digits | One digit per chunk | Up to three digits per chunk |
| Turn markers | <|im_start|>, <|im_end|> |
<|start_header_id|>, <|end_header_id|>, <|eot_id|> |
| Sequence start | none added; add_bos_token is false |
<|begin_of_text|> |
| English compression reported | not stated on the card | “3.94 characters per token” on the paper’s English sample |
The rule that follows is short: count tokens with the tokeniser of the model that will serve the request, and treat a count made with any other tokeniser as an estimate to check, not a number to budget with. The Llama paper’s own figure shows how much a tokeniser change moves the count: the same English sample went from 3.17 to 3.94 characters per token between Llama 2 and Llama 3, a fifth fewer tokens for the same text.
Token budgets and the context window
Section titled “Token budgets and the context window”The context window is stated in tokens, and everything that has to fit inside it competes for the same budget:
prompt_tokens = template_overhead + system + tool_definitions + retrieved_documents + conversation_so_far + new_messageprompt_tokens + reserved_for_answer <= context_lengthcontext_length is what you ask the engine to allocate, at most the window the model was trained
for, which the Qwen3-8B card gives as 32,768 tokens natively and “up to 131,072 tokens” with YaRN
scaling. The template overhead is measurable, and for Qwen3 it is small per turn but not zero:
| Item, Qwen3 chat template | Tokens | Measured how |
|---|---|---|
Framing of one user or system message, <|im_start|>user, newline, <|im_end|>, newline |
5 | One-character message rendered: 6 tokens |
Generation prompt, <|im_start|>assistant, newline |
3 | add_generation_prompt=True |
Empty thinking block when enable_thinking=False |
4 | <think>, two newlines, </think>, two newlines |
| Tools section with one small function definition | 147 | chat-template-cost.py in the next section: a one-character request costs 160 tokens with tools= against 13 without; the definition’s JSON alone is 64, and the system turn, instructions and <tools> tags the template wraps around it 83 |
Arithmetic from stated inputs, not a measurement, for one request to Qwen3-8B at its native context:
| Component | Tokens | How it was counted |
|---|---|---|
| Context length | 32,768 | The card’s native window |
| System prompt | 300 | Counted with the tokeniser |
| Eight tool definitions | 147 + 7 × 65 = 602 | The measured block, plus one JSON and newline per further tool of the same size |
| Six retrieved chunks of 512 tokens | 3,072 | Chunk size chosen when indexing |
| Twenty earlier turns of 200 tokens plus 5 framing | 4,100 | Conversation so far |
| Reserved for the answer | 2,048 | Whatever max_tokens you will ask for |
| Left for the new message | 32,768 − 300 − 602 − 3,072 − 4,100 − 2,048 = 22,646 | The remainder |
Now change the language. A 20,000-token English document, at the sentence-level ratio measured above, is about 20,000 × 25 ÷ 14 = 35,700 tokens in Portuguese, more than the whole native window on its own, and about 82,900 in Hindi. The parameters lesson turns those tokens into bytes of KV cache; with the per-token figure the course records for Qwen3-8B in FP16, 147,456 bytes, the same document costs about 2.95 GB of cache in English and 5.3 GB in Portuguese, again arithmetic rather than measurement.
Special tokens are ordinary vocabulary entries
Section titled “Special tokens are ordinary vocabulary entries”A tokeniser’s vocabulary contains more than fragments of text. Qwen3’s tokenizer.json lists 26
added tokens after the 151,643 learned entries, ids 151643 to 151668, and config.json names
two of them: bos_token_id 151643 and eos_token_id 151645. The roles below are read from the
token names and from tokenizer_config.json; Qwen publishes no document defining each one.
| Ids | Tokens | Role |
|---|---|---|
| 151643 | <|endoftext|> |
End of a document in pretraining; the padding token; the base checkpoint’s end-of-sequence |
| 151644, 151645 | <|im_start|>, <|im_end|> |
Start of a turn; end of a turn, the instruct checkpoint’s end-of-sequence |
| 151646 to 151656 | <|object_ref_start|> … <|video_pad|> |
Vision and grounding markers shared with the multimodal family |
| 151657, 151658 | <tool_call>, </tool_call> |
Around a function call the model emits |
| 151659 to 151664 | <|fim_prefix|> … <|file_sep|> |
Fill-in-the-middle and repository markers for code |
| 151665, 151666 | <tool_response>, </tool_response> |
Around a tool result sent back |
| 151667, 151668 | <think>, </think> |
The thinking block |
They are not magic. They are rows of the same embedding matrix, learned the same way, and the model
was trained on text in which they appeared in specific places. The chat template, a Jinja string
stored in tokenizer_config.json, is what puts them there. Render one and count:
RunnableAll tracks
"""Render a conversation through the model's own chat template and count what the template adds,then add one tool definition and count what that adds.Usage: python chat-template-cost.py [model-dir]"""import jsonimport sysfrom transformers import AutoTokenizer
model_dir = sys.argv[1] if len(sys.argv) > 1 else "~/llm-course/models/qwen3-1.7b"tok = AutoTokenizer.from_pretrained(model_dir)def count(text): return len(tok(text, add_special_tokens=False)["input_ids"])
messages = [ {"role": "system", "content": "You are a terse assistant."}, {"role": "user", "content": "How many tokens is this?"},]text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=False)print(text)print("---")ids = tok(text, add_special_tokens=False)["input_ids"]print("pieces:", tok.convert_ids_to_tokens(ids))body = sum(count(m["content"]) for m in messages)print(f"tokens in the rendered prompt {len(ids)}, in the two message bodies {body}, added by the template {len(ids) - body}")default = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)print(f"with enable_thinking left at its default the prompt ends {default[-22:]!r} and costs {count(default)} tokens")
tools = [{"type": "function", "function": {"name": "get_weather", "description": "Get the current weather for a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "City name"}}, "required": ["city"]}}}]one = [{"role": "user", "content": "x"}]plain = count(tok.apply_chat_template(one, tokenize=False, add_generation_prompt=True, enable_thinking=False))with_tool = count(tok.apply_chat_template(one, tools=tools, tokenize=False, add_generation_prompt=True, enable_thinking=False))print(f"one-character request {plain} tokens; with one tool definition {with_tool}; the tool's JSON alone " f"{count(json.dumps(tools[0]))}; the system turn, instructions and <tools> tags around it {with_tool - plain - count(json.dumps(tools[0]))}")Output — what you should see
<|im_start|>systemYou are a terse assistant.<|im_end|><|im_start|>userHow many tokens is this?<|im_end|><|im_start|>assistant<think>
</think>
---pieces: ['<|im_start|>', 'system', 'Ċ', 'You', 'Ġare', 'Ġa', 'Ġterse', 'Ġassistant', '.', '<|im_end|>', 'Ċ', '<|im_start|>', 'user', 'Ċ', 'How', 'Ġmany', 'Ġtokens', 'Ġis', 'Ġthis', '?', '<|im_end|>', 'Ċ', '<|im_start|>', 'assistant', 'Ċ', '<think>', 'ĊĊ', '</think>', 'ĊĊ']tokens in the rendered prompt 29, in the two message bodies 12, added by the template 17with enable_thinking left at its default the prompt ends '<|im_start|>assistant\n' and costs 25 tokensone-character request 13 tokens; with one tool definition 160; the tool's JSON alone 64; the system turn, instructions and <tools> tags around it 83Twelve tokens of message, seventeen of structure: five framing each turn, three for the generation
prompt that the documentation says “adds tokens to the end of the chat that indicate the start of
an assistant response”, and four for the empty thinking block the template inserts when
enable_thinking is false, which is how the Qwen3 card says non-thinking mode is selected. Pass
tokenize=True instead and the same call returns the ids directly. The last line is where the 147
in the budget table came from: the documentation’s tools= argument takes a list of JSON-schema
function definitions, and Qwen3’s template answers one by opening a system turn that holds the
instructions, the <tools> tags and the definition itself, 83 tokens plus the 64 of the JSON.
The chat-template documentation carries two warnings that matter here. First, “chat templates
should already include all the necessary special tokens, and adding additional special tokens is
often incorrect or duplicated, hurting model performance. When you format text with
apply_chat_template(tokenize=False), make sure you set add_special_tokens=False if you tokenize
later”. Qwen3 adds no beginning-of-sequence token, so on this model the default is harmless; on a
model that does, the prompt gains a second one the model never saw in training. Second, the same
conversation rendered by two models fine-tuned from the same base looks nothing alike, one using
[INST] markers and the other <|user|> and <|assistant|>, and “with the wrong control tokens,
these models would have drastically worse performance”. Part 6 and Part 7 return to this when
engines apply templates on your behalf, and Part 11 uses the same call with
add_generation_prompt=False to build training data.
The tokenizers library, hands-on
Section titled “The tokenizers library, hands-on”The Hugging Face tokenizers library is the implementation underneath AutoTokenizer in the lab;
this section calls it directly. Its quicktour describes it as providing “an
implementation of today’s most used tokenizers that is both easy to use and blazing fast”,
implemented in Rust, and lists the property that matters when debugging: “full alignment tracking,
meaning you can always get the part of your original sentence that corresponds to a given token”.
The API reference states the pipeline that a Tokenizer runs: a normaliser, a pre-tokeniser, the
model, and a post-processor; a decoder reverses it. Every stage is data in tokenizer.json:
RunnableAll tracks
"""Use the tokenizers library directly on a model's tokenizer.json: the pipeline stages, andthe offsets that map every token back to the characters it came from.Usage: python tokenizers-offsets.py [model-dir]"""import sysfrom pathlib import Pathfrom tokenizers import Tokenizer
model_dir = Path(sys.argv[1] if len(sys.argv) > 1 else "~/llm-course/models/qwen3-1.7b").expanduser()tokenizer = Tokenizer.from_file(str(model_dir / "tokenizer.json"))for stage in ("normalizer", "pre_tokenizer", "model", "post_processor", "decoder"): print(f"{stage:14} {type(getattr(tokenizer, stage)).__name__}")print(f"vocabulary {tokenizer.get_vocab_size(with_added_tokens=False):,} merged entries, " f"{tokenizer.get_vocab_size(with_added_tokens=True):,} with added tokens")
text = "A raposa preguiçosa: 1969 🚀"print("pre-tokenised:", [piece for piece, _ in tokenizer.pre_tokenizer.pre_tokenize_str(text)])enc = tokenizer.encode(text, add_special_tokens=False)print(f"{'id':>7} {'token':14} {'offsets':10} text")for i, t, (a, b) in zip(enc.ids, enc.tokens, enc.offsets): print(f"{i:7d} {t!r:14} {str((a, b)):10} {text[a:b]!r}")print("decode(ids) ==", repr(tokenizer.decode(enc.ids)))print("token_to_id('<|im_start|>') =", tokenizer.token_to_id("<|im_start|>"), " id_to_token(279) =", repr(tokenizer.id_to_token(279)))Output — what you should see
normalizer NFCpre_tokenizer Sequencemodel BPEpost_processor ByteLeveldecoder ByteLevelvocabulary 151,643 merged entries, 151,669 with added tokenspre-tokenised: ['A', 'Ġraposa', 'Ġpreguiçosa', ':', 'Ġ', '1', '9', '6', '9', 'ĠðŁļĢ'] id token offsets text 32 'A' (0, 1) 'A' 7327 'Ġrap' (1, 5) ' rap' 11983 'osa' (5, 8) 'osa' 855 'Ġpre' (8, 12) ' pre' 19109 'gui' (12, 15) 'gui' 3131 'ç' (15, 16) 'ç' 11983 'osa' (16, 19) 'osa' 25 ':' (19, 20) ':' 220 'Ġ' (20, 21) ' ' 16 '1' (21, 22) '1' 24 '9' (22, 23) '9' 21 '6' (23, 24) '6' 24 '9' (24, 25) '9' 11162 'ĠðŁ' (25, 27) ' 🚀' 248 'ļ' (26, 27) '🚀' 222 'Ģ' (26, 27) '🚀'decode(ids) == 'A raposa preguiçosa: 1969 🚀'token_to_id('<|im_start|>') = 151644 id_to_token(279) = 'Ġthe'The normaliser is Unicode NFC, so a decomposed ç and a precomposed one become the same bytes
before anything else happens. The pre-tokeniser is a Sequence, the regex split above followed by
the byte-to-character mapping; you can see its output before the model runs. The offsets are the
alignment tracking: osa at (5, 8) and again at (16, 19) is the same id 11983 in two words, and
the three tokens that share the rocket all point at character 26, which is how a user interface can
highlight the source of any token, including one that holds only part of a character. decode
reverses the whole pipeline losslessly.
The same library trains tokenisers, and training it on the documentation’s corpus reproduces the
hand trace above rule for rule, with the trainer’s vocab_size counting the special token, the
alphabet and the merges together:
RunnableAll tracks
"""Train a byte-pair-encoding tokeniser with the tokenizers library on the five-word corpus fromthe Transformers documentation, and compare its merges with the ones worked out by hand."""from tokenizers import Tokenizerfrom tokenizers.models import BPEfrom tokenizers.pre_tokenizers import Whitespacefrom tokenizers.trainers import BpeTrainer
corpus = {"hug": 10, "pug": 5, "pun": 12, "bun": 4, "hugs": 5}lines = [word for word, count in corpus.items() for _ in range(count)] # each word, count times
tokenizer = Tokenizer(BPE(unk_token="<unk>"))tokenizer.pre_tokenizer = Whitespace()trainer = BpeTrainer(vocab_size=11, special_tokens=["<unk>"], show_progress=False) # <unk> + 7 characters + 3 mergestokenizer.train_from_iterator(lines, trainer)
print("vocabulary by id:", sorted(tokenizer.get_vocab().items(), key=lambda kv: kv[1]))for word in ["hug", "bug", "mug", "pugs"]: enc = tokenizer.encode(word) print(f"encode {word!r:7} -> {enc.tokens} ids {enc.ids}")tokenizer.save("tiny-bpe.json")Output — what you should see
vocabulary by id: [('<unk>', 0), ('b', 1), ('g', 2), ('h', 3), ('n', 4), ('p', 5), ('s', 6), ('u', 7), ('ug', 8), ('un', 9), ('hug', 10)]encode 'hug' -> ['hug'] ids [10]encode 'bug' -> ['b', 'ug'] ids [1, 8]encode 'mug' -> ['<unk>', 'ug'] ids [0, 8]encode 'pugs' -> ['p', 'ug', 's'] ids [5, 8, 6]Open the saved tiny-bpe.json and its merges list reads ["u", "g"], ["u", "n"], ["h", "ug"],
the three rules from the table. The full-size tokenizer.json you downloaded is the same
structure with 151,387 rules, and vocab.json and merges.txt beside it are the same vocabulary
and rules in the older two-file form. In a model repository the tokeniser is data, not code:
| File, Qwen3-1.7B | Bytes | Holds |
|---|---|---|
tokenizer.json |
11,422,654 | Vocabulary, merges, added tokens, the four pipeline stages |
tokenizer_config.json |
9,732 | Special-token names and flags (add_bos_token, eos_token), model_max_length 131072, the chat template |
vocab.json |
2,776,833 | The vocabulary alone, token string to id |
merges.txt |
1,671,853 | The merge rules alone, one per line, in rank order |
Which interface to use is a small decision:
| You need | Use | Because |
|---|---|---|
The chat template, special-token names, apply_chat_template |
transformers.AutoTokenizer |
It reads tokenizer_config.json as well as tokenizer.json |
Offsets, batch speed, training, or no transformers dependency |
tokenizers.Tokenizer.from_file |
The Rust pipeline directly, tokenizer.json alone |
| Tokenisation inside an engine | Nothing; the engine carries it | GGUF embeds the vocabulary and template; Part 6 shows the symptoms of a wrong template and how to print exactly what the server sends |
Make a token budget that includes the conversation wrapper
Section titled “Make a token budget that includes the conversation wrapper”Budget for the serialised request, not just the user’s visible paragraph. A chat template may add role delimiters, a generation prefix and tool definitions. Retrieved passages and earlier tool results can be much larger than the new question. The response allowance must fit alongside all of them within the serving configuration’s effective limit.
For a paper exercise, choose a context capacity of 4,096 tokens. Reserve 768 for the response and 256 as an engineering margin. That leaves 3,072 for the complete input, including template overhead. These are illustrative allocations, not a promise about a particular engine. Count the fully rendered input with the checkpoint’s tokeniser before accepting a request near the boundary.
Repeat the count after adding a tool schema and after replacing an English paragraph with source code. Explain why equal character counts need not mean equal token counts. If the endpoint reports a different count, inspect added templates, special tokens and truncation before blaming the tokeniser. A request accepted by HTTP can still be semantically damaged if important earlier context was removed.
Models work on subword tokens because word vocabularies are unbounded and character sequences are
too long. BPE learns a vocabulary by repeatedly merging the most frequent adjacent pair, records
each merge as a ranked rule, and encodes by replaying the rules lowest rank first, inside chunks a
pre-tokeniser regular expression chose; byte-level BPE bases the alphabet on the 256 byte values so
nothing is ever unknown; SentencePiece puts the space in the vocabulary as ▁, making decoding a
lossless join. Tokens, words and bytes are three different counts whose ratio depends on how well
the text matches the merge corpus and on which model’s tokeniser you count with. A context window is
a budget in which the template, the tools, the documents, the history and the reserved answer all
compete. Special tokens are ordinary vocabulary rows in special positions, placed there by the
chat template, and using the wrong template is one of the commonest reasons a model suddenly seems
much worse.
Check your understanding
Sources for this lesson
12 verified · checked 2026-09-12
- 01Hugging Face Transformers — Tokenization algorithms (tokenizer summary)§ Byte pair encoding; Byte-level BPE; Unigram; SentencePiece; WordPiece; Word-level; Character-levelhuggingface.co/docs/transformers/main/en/tokenizer_summary2026-09-12
- 02Hugging Face Transformers — Chat templates§ Using apply_chat_template; the special-tokens warning; add_generation_prompt; Model traininghuggingface.co/docs/transformers/main/en/chat_templating2026-09-12
- 03Hugging Face Transformers v5.16.1 — Tool use (docs/source/en/chat_extras.md)§ passing tools to apply_chat_template; the JSON schema format of a tool definitiongithub.com/huggingface/transformers/blob/v5.16.1/docs/source/en/chat_extras.md2026-09-12
- 04Hugging Face Tokenizers — Quicktour§ Build a tokenizer from scratch; Using the tokenizer; alignment tracking and offsetshuggingface.co/docs/tokenizers/quicktour2026-09-12
- 05Hugging Face Tokenizers 0.23.2 — API reference, Tokenizer§ The pipeline; from_file; encode; decode; train_from_iterator; get_vocab_size; token_to_id; id_to_tokenhuggingface.co/docs/tokenizers/api/tokenizer2026-09-12
- 06Hugging Face Tokenizers 0.23.2 — API reference, Trainers§ BpeTrainer parametershuggingface.co/docs/tokenizers/api/trainers2026-09-12
- 07SentencePiece — README§ Description; whitespace escaped as ▁ (U+2581); lossless detokenisationgithub.com/google/sentencepiece2026-09-12
- 08Qwen3-8B model card and config.json§ Context length; enable_thinking; apply_chat_template example; vocab_size, bos_token_id, eos_token_idhuggingface.co/Qwen/Qwen3-8B2026-09-12
- 09Qwen/Qwen3-1.7B — repository file listing, tokenizer.json and tokenizer_config.json§ pre_tokenizer regex; added_tokens; chat_template; file checksums, compared with Qwen3-0.6B and Qwen3-8Bhuggingface.co/Qwen/Qwen3-1.7B/tree/main2026-09-12
- 10The Llama 3 Herd of Models (arXiv 2407.21783)§ 3.2 Model Architecture, tokenizer and vocabularyarxiv.org/abs/2407.217832026-09-12
- 11meta-llama/llama-models — models/llama3/tokenizer.py§ pat_str; num_reserved_special_tokens; special token namesgithub.com/meta-llama/llama-models/blob/main/models/llama3/tokenizer.py2026-09-12
- 12openai/gpt-2 — src/encoder.py§ bytes_to_unicode; the pre-tokenisation pattern; bpe merge by lowest rankgithub.com/openai/gpt-2/blob/master/src/encoder.py2026-09-12
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.