Tokenwerk · Das LLM-Lehrbuch

Kapitel 20 · III · Den Transformer bauen · 4 Minuten

Deinen Decoder selbst implementieren

Von den Formeln zu einer Modellklasse. Ein kompletter, ausführbarer Referenzcode begleitet jede Entscheidung.

Das Projekt ist dein ausführbarer Bauplan

Im Download findest du model.py. Die Datei enthält kleine, getrennte Bausteine (Klassen und eine RoPE-Funktion) für Konfiguration, Normalisierung, Positionsrotation, Attention, Feed-Forward, Block und Gesamtmodell. Sie lädt keine Modellgewichte aus dem Netz. Die beiden Varianten unterscheiden sich in fest benannten Bausteinen; das Trainingsziel bleibt gleich.

Lies die Datei zunächst von unten: Die Gesamtklasse zeigt, welche Module zusammengesetzt werden. Gehe danach in den Block und erst dann in die Attentiondetails. Diese Leserichtung hält den Hauptdatenweg im Blick.

Eine minimale Konfiguration

python
from model import Config, LM
cfg = Config(
    vocab_size=320,
    context=128,
    dim=64,
    layers=2,
    heads=4,
    kv_heads=4,
    modern=False,
)
model = LM(cfg)
print(sum(p.numel() for p in model.parameters()))

Das ist eine sehr kleine Debuggingkonfiguration. dim muss durch heads teilbar sein. Für die moderne RoPE-Variante muss die Kopfdimension gerade sein. Bei GQA muss die Zahl der Query-Heads durch die Zahl der Key/Value-Heads teilbar sein. Die Konfigurationsprüfung erkennt diese Fälle vor dem ersten Training.

Die Vokabulargröße stammt nach der Datenvorbereitung aus dem tatsächlich gelernten Tokenizer. Wenn dieser weniger Merges gefunden hat als gewünscht, benutzt du seine tatsächliche Größe. Du darfst nicht bloß eine Wunschgröße an die Ausgabe schreiben und dann unsichtbare Klassen erzeugen.

ModuleList statt gewöhnlicher Liste

python
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.layers)])

Mit ModuleList erkennt PyTorch enthaltene Module, nimmt sie in state_dict() auf und verschiebt sie mit .to(device). Eine gewöhnliche Python-Liste registriert ihre enthaltenen Module nicht auf dieselbe Weise. Der Forward könnte laufen, aber model.parameters() würde wichtige Gewichte nicht sehen.

Auch „ein Block mehrfach verwenden“ und „mehrere Blöcke erzeugen“ sind verschieden. [block] * L teilt dasselbe Blockobjekt. Unser Code erzeugt jedes Blockobjekt separat. Parameter-Sharing zwischen Ebenen wäre eine mögliche andere Architektur, aber kein versehentliches Implementierungsdetail.

Initialisierung

Unser Referenzmodell initialisiert Embeddings und lineare Gewichte aus einer kleinen Normalverteilung, Biases auf null. Normskalen starten mit eins. Diese Wahl ist ein übersichtlicher Ausgangspunkt, keine auf alle Modelle optimal übertragbare Vorschrift. Ein größeres Modell kann zusätzliche tiefenabhängige Skalierungen benötigen.

Weight Tying geschieht nach dem Initialisieren der Module, damit die gebundene Matrix nicht versehentlich zweimal mit widersprüchlichen Annahmen initialisiert wird. Der Optimizer erhält die eindeutigen Parameter aus model.parameters(); die geteilte Matrix wird nicht als zwei unabhängige Gewichte trainiert.

Forward-Pass prüfen

python
import torch
ids = torch.randint(0, cfg.vocab_size, (2, 16))
logits = model(ids)
assert logits.shape == (2, 16, cfg.vocab_size)
assert torch.isfinite(logits).all()

Wenn du die Kontextlänge überschreitest, soll das Modell eine verständliche Fehlermeldung erzeugen. Ein stiller Positionsüberlauf ist kein nützliches Verhalten. Bei zu wenig Input ist auch das Generieren eines nächsten Tokens aus einem leeren Array problematisch; BOS löst diesen Fall klar.

Backward-Pass prüfen

python
import torch.nn.functional as F
x, y = ids[:, :-1], ids[:, 1:]
loss = F.cross_entropy(model(x).reshape(-1, cfg.vocab_size), y.reshape(-1))
loss.backward()
assert model.embed.weight.grad is not None
assert torch.isfinite(model.embed.weight.grad).all()

Prüfe nicht nur den letzten Ausgabekopf. Wenn eine irrtümliche .detach()-Operation den Graphen trennt, können frühe Schichten untrainiert bleiben. Der Loss kann trotzdem durch die späteren Schichten sinken.

Train-Modus und Eval-Modus

model.train() und model.eval() steuern modussensitive Schichten wie Dropout. Sie schalten die automatische Differentiation nicht ein oder aus. Dafür verwendest du torch.no_grad() oder torch.inference_mode(). Für die Validierung setzen wir Eval-Modus und schalten Gradientaufzeichnung ab.

Unser kleines Referenzmodell hat keinen Dropout. Die Moduswechsel bleiben trotzdem im Code, weil sie ein korrektes Grundmuster für spätere Erweiterungen darstellen.

Dein erster sinnvoller Modelltest

Vergleiche mit festem Seed die Logits zweier Sequenzen mit gleichem Präfix und verschiedenem Suffix. Prüfe, dass frühe Logits gleich bleiben. Wiederhole den Test für klassischen und modernen Decoder. Prüfe danach, dass ein Optimizerschritt mindestens einige Gewichte verändert.

Ein funktionierender Forward und sinkender Test-Loss beweisen nicht, dass dein Decoder bei großen Datenmengen stabil ist. Sie begrenzen aber den Fehlersuchraum erheblich. Trenne diese kleinen Vertragstests von langfristigen Fähigkeitsmessungen.

Die Architektur bewusst verändern

Ändere zunächst nur die Blockzahl. Lass Tokenizer, Breite, Kopfzahl, Kontext, Daten und Tokenbudget unverändert. Notiere Parameterzahl und Zeit pro Schritt. Beobachte, dass ein tieferes Modell nicht nur mehr Kapazität, sondern mehr Rechenzeit besitzt. Ein Vergleich nach derselben Schrittzahl hat eine andere Bedeutung als ein Vergleich bei identischem Rechenbudget.