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.
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', '!']
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:
| 1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
| token | The | dog | chased | the | cat | . |
| token id | 0 | 1 | 2 | 3 | 4 | 5 |
The and the are different strings, so they get different ids.If we encounter the same word twice, we ignore it:
| 7 | 8 | 9 | 10 | 11 | |
|---|---|---|---|---|---|
| token | The | cat | ran | away | ! |
| token id | already 0 | already 4 | 6 | 7 | 8 |
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.
| token | The | dog | chased | the | cat | . | ran | away | ! |
| token id | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
Now, when we tokenise a prompt, we have a way to map it to token ids:
When the LLM produces output token ids, we can now construct text from them using the reverse of this mapping:
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.
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.
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
␣ 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
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!'
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. ↩