Tokenisation

The job of a tokeniser is to break down a prompt into tokens. Then it uses a scheme to map each token to a unique number, called a token id, which the LLM can process. When the LLM then generates token ids in response to our prompt, we can use this number mapping to convert those token ids to tokens, and then to generated text.

To show how this works we’ll start with a simplified example, then we’ll move on to how real LLM tokenisers work.

Word-based tokeniser

One very straightforward way to split a prompt into tokens is to split it into words and punctuation characters:

import re

def tokenise(text):
    # \w+ matches a run of word characters,
    # [^\w\s] matches a single punctuation character
    return re.findall(r"\w+|[^\w\s]", text)


tokenise("The dog fetched the ball!")
# ['The', 'dog', 'fetched', 'the', 'ball', '!']
"The dog fetched the ball!"
Thedogfetchedtheball!
Whitespace disappears in this scheme: the tokens are the words and the punctuation characters, and nothing else.

In order to map these to token ids, before we start LLM training, we take a large corpus of text. This could be the corpus we intend to use for LLM training. Then we split this corpus using our tokeniser, and start assigning token ids to each word we encounter:

"The dog chased the cat. The cat ran away!"
123456
tokenThedogchasedthecat.
token id012345
Every token here is new, so each one gets the next available id. Note that The and the are different strings, so they get different ids.

If we encounter the same word twice, we ignore it:

7891011
tokenThecatranaway!
token idalready 0already 4678
The and cat have been seen before, so they keep the ids they were given the first time round. Only genuinely new tokens consume a new id.

This way, we map every word in our text corpus to a unique token id.

tokenThedogchasedthecat.ranaway!
token id012345678
A real corpus produces tens of thousands of entries this way, but the rule is exactly the one above.

Now, when we tokenise a prompt, we have a way to map it to token ids:

"The dog chased the cat!"
The0 dog1 chased2 the3 cat4 !8
[0, 1, 2, 3, 4, 8]
This list of token ids is what actually reaches the model. From here on it never sees the text.

When the LLM produces output token ids, we can now construct text from them using the reverse of this mapping:

[4, 6, 7, 8]
4cat 6ran 7away 8!
"cat ran away !"
Because the split threw whitespace away, we have to guess it back on the way out — which is why the exclamation mark ends up adrift. Real tokenisers keep the whitespace inside the tokens, as we’ll see below.

This simple tokenisation scheme has some limitations. Firstly, if we encounter a word in our prompt that was not in our training corpus, we will not be able to map it to a token. Think, for example, of misspellings, or names of cities in fantasy books. With this tokeniser, our LLM will not be able to process these words.

"The dog fetched the ball!"
The0 dog1 fetched? the3 ball? !8
fetched and ball never appeared in the corpus, so the vocabulary has no token id for them and the prompt cannot be encoded.

Secondly, the vocabulary has no idea that some of its words are obviously related. walk, walks, walked and walking each map to a separate token id, and nothing about those four ids tells the model that they share a root — it has to learn that connection from scratch, four times over. Multiply that across every inflection, plural, compound and capitalisation in a language and the vocabulary becomes both enormous and wasteful.

How LLMs actually do it

Real LLMs, instead of breaking text into words, break text into sequences of characters that commonly occur together. This will often end up being whole words (as words are sequences of characters that commonly occur together!).

There are various schemes for doing this, including Byte Pair Encoding (BPE), WordPiece, and Unigram. All of these break down text into words and sub-word parts, for example

"The tokeniser retokenised Winterfell!"
The791 token4037 iser12329 ret2160 oken1713 ised4147 Winter20704 fell67643 !0
Highlighted tokens are sub-word parts. marks a leading space — whitespace lives inside the tokens, so decoding reproduces the original text exactly. Winterfell is represented by the two reusable vocabulary pieces ␣Winter and fell, rather than receiving a token of its own.

Importantly, tokenisers implementing these schemes are able to uniquely tokenise any prompt they encounter. In a worst-case scenario, they can just break down a word into its constituent characters.1

"The dog fetched the ball in Zxjyv!"
The791 dog5679 fetched42542 the279 ball5041 in304 Z1901 x87 j73 y88 v85 !0
The invented city Zxjyv contains no multi-character chunk the vocabulary knows, so it collapses to one token per character. The words the word-based tokeniser choked on — fetched and ball — are single tokens here.

Once we have our tokenisation scheme, everything else works the same as in our simple word-based tokeniser.

Here’s some code showing how the tiktoken library can be used to encode and decode text as tokens:

import tiktoken

encoding = tiktoken.get_encoding("cl100k_base")

# text -> token ids
token_ids = encoding.encode("The dog fetched the ball!")
# [791, 5679, 42542, 279, 5041, 0]

# inspect the token behind each token id
tokens = [encoding.decode([token_id]) for token_id in token_ids]
# ['The', ' dog', ' fetched', ' the', ' ball', '!']

# token ids -> text
encoding.decode(token_ids)
# 'The dog fetched the ball!'
  1. Strictly, schemes like cl100k_base work on bytes rather than characters, so the true fallback is one token per byte. For ordinary English text the two are the same thing, because each character is a single byte. Outside of ASCII text this is not necessarily true: for example, in UTF-8 the single character 🙂 is four bytes, and many characters in non-Latin alphabets are also encoded as multiple bytes.