Tokenwerk · Das LLM-Lehrbuch

Kapitel 33 · VII · Bau dein LLM · 10 Minuten

Schritt 1: Der Tokenizer

Der erste Baustein deines Modells: eine Klasse, die Text in Token-IDs zerlegt und wieder zusammensetzt – mit echtem Byte-BPE wie bei GPT-2.

Was wir bauen

Ein Sprachmodell rechnet nur mit Zahlen. Der erste Baustein übersetzt deshalb Text in Token-IDs und wieder zurück: der Tokenizer. In Kapitel 12 hast du die Idee kennengelernt. Hier ist sie in drei Sätzen:

  • Wir starten mit den 256 möglichen Bytes. Damit lässt sich jeder Text der Welt darstellen.
  • Beim Training suchen wir das häufigste benachbarte Paar und verschmelzen es zu einem neuen Token. Das wiederholen wir, bis das Vokabular groß genug ist – bei uns 1024 Tokens.
  • Beim Kodieren wenden wir diese Merge-Regeln in derselben Reihenfolge auf neuen Text an.

Am Ende dieses Schritts hast du die Datei tokenizer.py mit einer Klasse Tokenizer, die das alles kann.

Ein Trick vorab: erst in Wörter zerlegen

Würden wir die Paare im ganzen Text auf einmal zählen, wäre das bei 140.000 Tokens und hunderten Merge-Runden ziemlich langsam. Deshalb zerlegen wir den Text vorab in Wörter, Satzzeichen und Leerraum – und zählen dann jedes Wort nur einmal, mit seiner Häufigkeit. Das Wort „ und“ kommt tausende Male vor, muss aber nur einmal verarbeitet werden.

Nebeneffekt: Merges passieren nur innerhalb eines Wortes. Ein Token kann also nie halb aus „Katze“ und halb aus dem nächsten Wort bestehen. Genau so machen es auch GPT-2 und viele andere Modelle.

python
# Byte-BPE-Tokenizer: Text -> Token-IDs -> Text (Kapitel 12)
import json
import re
from collections import Counter

# Text vorab in Wörter, Satzzeichen und Leerraum zerlegen. Merges passieren nur innerhalb dieser Stücke.
PATTERN = re.compile(r" ?\w+| ?[^\w\s]+|\s+")
SPECIALS = ["<pad>", "<bos>", "<eos>", "<user>", "<assistant>"]

Der reguläre Ausdruck PATTERN beschreibt, wie zerlegt wird: ein optionales Leerzeichen plus Buchstaben ( ?\w+), ein optionales Leerzeichen plus Satzzeichen ( ?[^\w\s]+) oder Leerraum (\s+). Aus „Da sprach er:“ wird so ["Da", " sprach", " er", ":"]. Das Leerzeichen bleibt beim folgenden Wort – deshalb sieht man in Token-Listen oft Tokens wie „ sprach“ mit Leerzeichen vorne.

Die fünf Sondertokens kennst du aus Kapitel 12: <bos> und <eos> markieren Anfang und Ende, <pad> füllt auf, <user> und <assistant> brauchen wir später für den Chat.

Ein Paar verschmelzen

Die wichtigste Hilfsfunktion: Sie ersetzt in einer Liste von IDs jedes Vorkommen eines Paares durch eine neue ID. Von links nach rechts und ohne Überlappung – aus [a, a, a] mit dem Paar (a, a) wird [aa, a].

python
def merge(ids, pair, new_id):
    """Ersetzt jedes Vorkommen von pair in ids durch new_id (von links, ohne Überlappung)."""
    out, i = [], 0
    while i < len(ids):
        if i + 1 < len(ids) and (ids[i], ids[i + 1]) == pair:
            out.append(new_id)
            i += 2
        else:
            out.append(ids[i])
            i += 1
    return out

Die Klasse: Vokabular aufbauen

Ein Tokenizer besteht eigentlich nur aus seiner Liste von Merge-Regeln. Alles andere lässt sich daraus berechnen:

python
class Tokenizer:
    def __init__(self, merges=()):
        self.merges = [tuple(pair) for pair in merges]
        self.pieces = [bytes([b]) for b in range(256)]           # IDs 0-255: die Bytes selbst
        self.rank = {}
        for pair in self.merges:                                   # jede Merge-Regel bekommt die nächste ID
            self.rank[pair] = len(self.pieces)
            self.pieces.append(self.pieces[pair[0]] + self.pieces[pair[1]])
        self.special = {name: len(self.pieces) + i for i, name in enumerate(SPECIALS)}
        self.cache = {}

    @property
  • pieces ist das Vokabular als Bytes: ID 0 bis 255 sind die einzelnen Bytes, danach kommt für jede Merge-Regel das zusammengesetzte Stück. Steht in Regel 1 „d“ + „ie“, dann ist das neue Stück einfach b"d" + b"ie".
  • rank merkt sich für jedes Paar, welche ID es bekommen hat. Die Reihenfolge ist wichtig: Wer früher gelernt wurde, wird später auch zuerst angewendet.
  • Die Sondertokens hängen wir hinten an. So stören sie die Byte-IDs nicht.
  • @property macht aus vocab_size eine Eigenschaft: Du schreibst tok.vocab_size ohne Klammern.

Training: das häufigste Paar finden

python
class Tokenizer:
    @classmethod

Schritt für Schritt:

  1. Counter(PATTERN.findall(text)) zerlegt den Text und zählt, wie oft jedes Wort vorkommt.
  2. seqs hält für jedes Wort seine aktuelle Zerlegung, am Anfang Byte für Byte.
  3. In jeder Runde zählen wir alle benachbarten Paare – gewichtet mit der Worthäufigkeit.
  4. Das häufigste Paar gewinnt. Bei Gleichstand entscheidet die kleinere ID, damit das Ergebnis immer gleich ist.
  5. Kommt das beste Paar nur einmal vor, hören wir auf: Dafür lohnt sich kein eigenes Token.
  6. Sonst merken wir uns die Regel und wenden sie auf alle Wörter an.

Die Bedingung in while sorgt dafür, dass am Ende genau vocab_size Tokens herauskommen: 256 Bytes plus die Merges plus die 5 Sondertokens.

Kodieren und Dekodieren

python
class Tokenizer:
    def encode_word(self, word):
        if word in self.cache:
            return self.cache[word]
        ids = list(word.encode("utf-8"))
        while len(ids) > 1:
            # die Regel mit dem kleinsten Rang zuerst – genau die Reihenfolge aus dem Training
            pair = min(zip(ids, ids[1:]), key=lambda p: self.rank.get(p, float("inf")))
            if pair not in self.rank:
                break
            ids = merge(ids, pair, self.rank[pair])
        self.cache[word] = ids
        return ids

    def encode(self, text):
        ids = []
        for word in PATTERN.findall(text):
            ids.extend(self.encode_word(word))
        return ids

    def decode(self, ids):
        data = b"".join(self.pieces[i] for i in ids if i < len(self.pieces))   # Sondertokens überspringen
        return data.decode("utf-8", errors="replace")

encode_word wendet die gelernten Regeln auf ein Wort an: Unter allen Paaren im Wort sucht es das mit dem kleinsten Rang – also die Regel, die im Training zuerst gelernt wurde – und verschmilzt es. Das wiederholt sich, bis keine Regel mehr passt. Weil dieselben Wörter ständig wiederkommen, speichern wir das Ergebnis im cache. Das macht das Kodieren um ein Vielfaches schneller.

decode geht den umgekehrten Weg: Bytes der Tokens aneinanderhängen und als UTF-8 lesen. Sondertokens werden übersprungen. errors="replace" fängt kaputte Bytefolgen ab, falls ein Modell später mal ein unvollständiges Zeichen erzeugt (Kapitel 12).

Speichern und Laden

python
class Tokenizer:
    def save(self, path):
        with open(path, "w", encoding="utf-8") as f:
            json.dump({"merges": self.merges}, f)

    @classmethod

Gespeichert werden nur die Merge-Regeln als JSON. Mehr braucht es nicht – den Rest berechnet __init__ beim Laden neu. @classmethod heißt: Die Methode gehört zur Klasse, nicht zu einem einzelnen Objekt. Deshalb schreibst du später Tokenizer.load("…") statt erst ein leeres Objekt zu bauen.

Die ganze Datei

python
# Byte-BPE-Tokenizer: Text -> Token-IDs -> Text (Kapitel 12)
import json
import re
from collections import Counter

# Text vorab in Wörter, Satzzeichen und Leerraum zerlegen. Merges passieren nur innerhalb dieser Stücke.
PATTERN = re.compile(r" ?\w+| ?[^\w\s]+|\s+")
SPECIALS = ["<pad>", "<bos>", "<eos>", "<user>", "<assistant>"]


def merge(ids, pair, new_id):
    """Ersetzt jedes Vorkommen von pair in ids durch new_id (von links, ohne Überlappung)."""
    out, i = [], 0
    while i < len(ids):
        if i + 1 < len(ids) and (ids[i], ids[i + 1]) == pair:
            out.append(new_id)
            i += 2
        else:
            out.append(ids[i])
            i += 1
    return out


class Tokenizer:
    def __init__(self, merges=()):
        self.merges = [tuple(pair) for pair in merges]
        self.pieces = [bytes([b]) for b in range(256)]           # IDs 0-255: die Bytes selbst
        self.rank = {}
        for pair in self.merges:                                   # jede Merge-Regel bekommt die nächste ID
            self.rank[pair] = len(self.pieces)
            self.pieces.append(self.pieces[pair[0]] + self.pieces[pair[1]])
        self.special = {name: len(self.pieces) + i for i, name in enumerate(SPECIALS)}
        self.cache = {}

    @property
    def vocab_size(self):
        return len(self.pieces) + len(SPECIALS)

    @classmethod
    def train(cls, text, vocab_size):
        words = Counter(PATTERN.findall(text))                   # jedes Wort nur einmal, mit Häufigkeit
        seqs = {w: list(w.encode("utf-8")) for w in words}
        merges = []
        while 256 + len(merges) + len(SPECIALS) < vocab_size:
            pairs = Counter()
            for w, count in words.items():
                s = seqs[w]
                for pair in zip(s, s[1:]):
                    pairs[pair] += count
            if not pairs:
                break
            pair, count = max(pairs.items(), key=lambda item: (item[1], -item[0][0], -item[0][1]))
            if count < 2:
                break
            new_id = 256 + len(merges)
            merges.append(pair)
            for w in words:
                seqs[w] = merge(seqs[w], pair, new_id)
        return cls(merges)

    def encode_word(self, word):
        if word in self.cache:
            return self.cache[word]
        ids = list(word.encode("utf-8"))
        while len(ids) > 1:
            # die Regel mit dem kleinsten Rang zuerst – genau die Reihenfolge aus dem Training
            pair = min(zip(ids, ids[1:]), key=lambda p: self.rank.get(p, float("inf")))
            if pair not in self.rank:
                break
            ids = merge(ids, pair, self.rank[pair])
        self.cache[word] = ids
        return ids

    def encode(self, text):
        ids = []
        for word in PATTERN.findall(text):
            ids.extend(self.encode_word(word))
        return ids

    def decode(self, ids):
        data = b"".join(self.pieces[i] for i in ids if i < len(self.pieces))   # Sondertokens überspringen
        return data.decode("utf-8", errors="replace")

    def save(self, path):
        with open(path, "w", encoding="utf-8") as f:
            json.dump({"merges": self.merges}, f)

    @classmethod
    def load(cls, path):
        with open(path, encoding="utf-8") as f:
            return cls(json.load(f)["merges"])

Ausprobieren

Starte im Ordner mein-llm eine Python-Konsole mit python und probier den Tokenizer an einem winzigen Text aus:

python
from tokenizer import Tokenizer
tok = Tokenizer.train("die katze und die kanne und die katze", 270)
print(tok.vocab_size)
ids = tok.encode("die katze")
print(ids)
print([tok.decode([i]) for i in ids])
print(tok.decode(ids) == "die katze")

Ausgabe:

text
270
[259, 264, 101]
['die', ' katz', 'e']
True

270 Tokens heißt: 256 Bytes, 5 Sondertokens – bleiben 9 Merges. Die haben gereicht, um aus „die“ und „ katz“ ganze Tokens zu machen. Für „ katz“ + „e“ war keine Regel mehr übrig. Probier 272 statt 270: Dann wird auch „ katze“ zu einem einzigen Token. Und das Wichtigste: Der Rückweg ergibt exakt den Originaltext.

Wenn etwas schiefgeht

  • ModuleNotFoundError: No module named 'tokenizer' – Die Python-Konsole läuft nicht im Ordner mein-llm. Mit cd mein-llm hineinwechseln.
  • IndentationError – Beim Abtippen ist die Einrückung verrutscht. Python ist da streng: Methoden einer Klasse sind mit genau 4 Leerzeichen eingerückt.
  • Andere IDs als im Buch – Kein Problem, solange True am Ende steht. Die genauen Nummern hängen vom Trainingstext ab.

Kurz gemerkt

  • Der Tokenizer lernt Merge-Regeln: Das häufigste Paar wird zu einem neuen Token, wieder und wieder.
  • Vorab in Wörter zerlegen macht das Training schnell und verhindert Tokens über Wortgrenzen.
  • Gespeichert werden nur die Regeln. Kodieren wendet sie in derselben Reihenfolge an, Dekodieren hängt Bytes aneinander.