Modul 09 · Lektion 4

Ein vollständiges GPT bauen

Attention, Feedforward-Netz, Residualverbindungen und LayerNorm zu einem Transformer-Block zusammensetzen, mehrere Schichten stapeln, Embedding und Ausgabeschicht ergänzen – fertig ist ein vollständiges GPT. gpt.py Abschnitt für Abschnitt lesen und nachzählen, wo die 1,6 Millionen Parameter stecken.

  • Etwa 50 Minuten
  • Niveau: Vertiefung
  • Getestet: 2026-09-15 torch 2.14, CPU, fester Zufallsstartwert

Code und Programmausgaben stehen genau so da, wie sie gelaufen sind – Kommentare und Ausgaben sind daher auf Chinesisch.

Die beiden vorigen Lektionen haben die Einzelteile gebaut: Attention lässt jedes Zeichen Informationen von den vorigen Zeichen einsammeln, mehrere Köpfe lassen es mehrere Arten von Information zugleich sammeln, das Positions-Embedding verrät die Reihenfolge. Diese Lektion setzt die Teile zusammen und erhält ein vollständiges GPT.

Der vollständige Code steht in code/09-transformer/gpt.py, unter 150 Zeilen. Die Struktur ist dieselbe wie bei GPT-2, nur viel kleiner. Training und Generierung in den beiden folgenden Lektionen nutzen ihn.

python inspect_gpt.py

Gesamtaufbau

  词元编号
     │
  词元嵌入 + 位置嵌入
     │
  ┌──────────────────────────┐
  │  LayerNorm → 多头注意力   │ ─┐
  │        + ←────────────────── ┘ 残差
  │  LayerNorm → 前馈网络     │ ─┐
  │        + ←────────────────── ┘ 残差
  └──────────────────────────┘
     │   (这样的块叠 4 层)
  LayerNorm
     │
  输出层:对词表里每个字打分

Von unten nach oben: Jedes Zeichen wird zuerst zu einem Vektor (Token-Embedding plus Positions-Embedding), durchläuft mehrere gleich aufgebaute Transformer-Blöcke, und am Ende bewertet die Ausgabeschicht das nächste Zeichen. Die Attention im Block haben wir schon geschrieben; neu sind drei Dinge: Feedforward-Netz, Residualverbindung und LayerNorm.

Feedforward-Netz: jedes Zeichen denkt für sich nach

Attention überträgt Informationen zwischen Zeichen. Nach dem Einsammeln muss jedes Zeichen die Informationen noch für sich „verdauen“; das ist das Feedforward-Netz:

self.mlp = nn.Sequential(  # 前馈网络:先放大 4 倍,过激活函数,再缩回来
    nn.Linear(cfg.n_embd, 4 * cfg.n_embd),
    nn.GELU(),
    nn.Linear(4 * cfg.n_embd, cfg.n_embd),
    nn.Dropout(cfg.dropout),
)

Es ist das ganz gewöhnliche zweischichtige Netz aus Modul 08: von 128 auf 512 Dimensionen vergrößern, durch eine Aktivierungsfunktion, zurück auf 128 Dimensionen. GELU ist eine Aktivierungsfunktion ähnlich ReLU, nur um 0 herum eine glatte Kurve; GPT-2 nutzt sie.

Das Feedforward-Netz rechnet für jede Position einzeln; die Positionen beeinflussen sich nicht. Die Arbeitsteilung in einem Transformer-Block ist also: Attention für den „Austausch“, das Feedforward-Netz fürs „Nachdenken“. Wie wir unten sehen, stecken die meisten Parameter des Modells tatsächlich im Feedforward-Netz.

Residualverbindung: das Bisherige nur ein wenig ändern

def forward(self, x, cache=None):
    x = x + self.attn(self.ln1(x), cache)  # 残差连接:在原来的基础上加一点修改
    x = x + self.mlp(self.ln2(x))
    return x

Beachte, dass hier x = x + ... steht und nicht x = self.attn(x). Attention und Feedforward-Netz berechnen keinen neuen Vektor, sondern eine „Änderung“ am bisherigen Vektor, die zu ihm addiert wird. Das heißt Residualverbindung.

Warum? Modul 08, Lektion 3 hat gezeigt, dass Gradienten bei der Backpropagation Schicht für Schicht zurückmultipliziert werden. Bei vielen Schichten werden sie oft multipliziert und leicht extrem klein (es lernt nichts mehr) oder extrem groß (das Training explodiert). Mit Residualverbindung enthält die Ableitung von x + f(x) nach x immer eine 1, und der Gradient kann über den Weg des „Pluszeichens“ direkt zu früheren Schichten zurückfließen, ohne die Transformation jeder Schicht durchlaufen zu müssen. Das ist einer der Schlüssel dafür, dass tiefe Netze mit Dutzenden oder Hunderten Schichten trainierbar sind.

Man kann es auch anders sehen: Durch das ganze Modell führt eine „Hauptstraße“ direkt von der Eingabe zur Ausgabe, und jeder Block fügt nur ein wenig hinzu.

LayerNorm: Zahlenwerte stabil halten

Modul 08, Lektion 2 hat gezeigt: Unterscheiden sich die Wertebereiche der Eingaben zu stark, ist das Training schwierig. Beim Rechnen durch die Schichten werden die Zahlen in den Vektoren auch immer größer oder kleiner. LayerNorm bringt vor jeder Unterschicht jeden Vektor auf Mittelwert 0 und Standardabweichung 1 und multipliziert und addiert dann zwei lernbare Parametersätze (das Modell entscheidet selbst über den passenden Bereich).

Das ist derselbe Gedanke wie die Standardisierung in Modul 08, nur für den Vektor jeder Position einzeln und immer wieder mitten im Netz.

Beachte, dass LayerNorm vor Attention und Feedforward-Netz steht (self.attn(self.ln1(x))), nicht danach. Das heißt Pre-LN; seit GPT-2 machen es fast alle Modelle so, weil das Training stabiler ist. Vor der Ausgabe kommt noch ein LayerNorm (ln_f).

Eingabe und Ausgabe

self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
...
self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.head.weight = self.tok_emb.weight  # 输出层和词元嵌入共用一套参数,GPT-2 也是这样做的

nn.Embedding ist einfach eine Tabelle: Zeile i ist der Vektor des i-ten Zeichens. Die Tabelle des Token-Embeddings hat 6289 Zeilen (Vokabulargröße), die des Positions-Embeddings 128 Zeilen (höchstens 128 Positionen).

Die Ausgabeschicht macht aus dem 128-dimensionalen Vektor 6289 Punktwerte, einen pro Zeichen. Dabei gibt es einen kleinen Trick: Ausgabeschicht und Token-Embedding teilen sich dieselbe Matrix. Das Token-Embedding macht aus „Zeichen“ „Vektoren“, die Ausgabeschicht beurteilt umgekehrt, welchem „Zeichen“ ein „Vektor“ am ähnlichsten ist; denselben Parametersatz zu nutzen ist sinnvoll und spart einen großen Block Parameter.

Die Vorwärtsberechnung verbindet all das:

def forward(self, idx, targets=None, caches=None, start=0):
    B, T = idx.shape
    pos = torch.arange(start, start + T, device=idx.device)
    x = self.drop(self.tok_emb(idx) + self.pos_emb(pos))
    for i, block in enumerate(self.blocks):
        x = block(x, None if caches is None else caches[i])
    logits = self.head(self.ln_f(x))  # (B, T, 词表大小):每个位置对下一个词元的打分
    loss = None
    if targets is not None:
        loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
    return logits, loss

caches und start sind für den KV-Cache aus Lektion 6; wir ignorieren sie vorerst. Der Verlust ist die Kreuzentropie aus Modul 08, Lektion 5: Jede Position ist ein Klassifikationsproblem „eines aus 6289 Zeichen wählen“.

Formen

== 2. 一批数据流过模型时的形状
  输入的词元编号        (2, 6)
  嵌入之后              (2, 6, 128)
  经过 4 个块之后        (2, 6, 128)
  输出层                (2, 6, 6289):每个位置对 6289 个字各打一个分

Zwei Verszeilen mit je 6 Zeichen, hinein geht (2, 6). Nach dem Embedding ist jedes Zeichen ein 128-dimensionaler Vektor. Durch 4 Blöcke bleibt die Form völlig unverändert; genau deshalb kann man beliebig viele Blöcke stapeln. Am Ende hat jede Position 6289 Punktwerte.

Beachte, dass es an jeder Position eine Ausgabe gibt: Ein Text von 6 Zeichen liefert beim Training zugleich 6 Aufgaben „rate das nächste Zeichen“. Die 1. Position sieht „白“ und rät „日“, die 2. sieht „白日“ und rät „依“ … Wegen der kausalen Maske kann bei keiner Aufgabe die Antwort abgeschrieben werden. Das macht das Training deutlich effizienter.

Wo die Parameter stecken

== 1. 参数都在哪里(词表 6289 个字符)
  词元嵌入   6289 × 128 = 804,992(输出层和它共用,不另算)
  位置嵌入   128 × 128 = 16,384
  每个块     198,272:注意力 66,048,前馈网络 131,712,两个 LayerNorm 512
  4 个块共  793,088
  合计       1,614,720,其中词元嵌入占 50%

Insgesamt 1,61 Millionen Parameter. Man kann selbst nachrechnen:

  • Attention: qkv ist 128×384 plus 384 Biases, proj ist 128×128 plus 128, zusammen 66048.
  • Feedforward-Netz: 128×512 plus 512, 512×128 plus 128, zusammen 131712, doppelt so viel wie die Attention.
  • LayerNorm: jede hat 128 Skalierungen und 128 Verschiebungen, zwei zusammen 512.

In unserem kleinen Modell steckt die Hälfte der Parameter in der Tabelle des Token-Embeddings, weil das Vokabular 6289 Zeichen hat und die Vektoren nur 128 Dimensionen. Bei großen Modellen ist das Verhältnis ganz anders: Vektordimensionen im Tausenderbereich, Dutzende Schichten, die Parameter in den Blöcken übersteigen die des Embeddings bei Weitem. In jedem Block macht das Feedforward-Netz etwa zwei Drittel aus, die Attention ein Drittel; dieses Verhältnis gilt auch bei großen Modellen ungefähr.

Vor dem Training

== 3. 没训练过的模型,损失应该接近随便猜
  损失 8.775,ln(6289) = 8.747
  下一个字最可能是:骠蔫潮涨湄(随机初始化,毫无道理)

Ein untrainiertes Modell bewertet die 6289 Zeichen fast gleich, rät also zufällig, und die Kreuzentropie sollte ln(6289) = 8,747 sein. Tatsächlich ist sie 8,775, sehr nah dran. Modul 08, Lektion 5 hat gesagt: Liegt der Verlust zu Trainingsbeginn weit von diesem Wert entfernt, ist vermutlich etwas falsch geschrieben. Das ist die erste Prüfung, die man nach dem Bau eines Modells machen sollte.

Die Anfangswerte der Parameter werden zufällig aus einer Normalverteilung mit std=0.02 gezogen (Methode _init), sodass anfangs alle Zeichen etwa gleiche Punktwerte haben und der Verlust nahe am zufälligen Raten liegt.

Was GPT von echten großen Modellen unterscheidet

Dieses GPT hat im Wesentlichen dieselbe Struktur wie GPT-2. Stand September 2026 haben die gängigen Open-Source-Modelle dieses Gerüst verbessert, üblich sind:

  • Positionsinformation per Rotary Position Embedding (RoPE) aus der vorigen Lektion.
  • RMSNorm statt LayerNorm, einfacher zu berechnen.
  • Feedforward-Netze mit „Gate“-Struktur (etwa SwiGLU).
  • Mehrere Attention-Köpfe teilen sich K und V (Grouped-Query Attention), was den Speicherbedarf des KV-Caches aus der nächsten Lektion senkt.
  • Das Feedforward-Netz wird durch viele „Experten“ ersetzt, von denen jedes Zeichen nur einige nutzt (Mixture of Experts, MoE); die Modelle von DeepSeek haben diese Struktur.

Das Gerüst ist aber gleich geblieben: Embedding, mehrere Blöcke aus „Attention + Feedforward-Netz“, Residualverbindungen, Normalisierung, Ausgabeschicht. Wer dieses 150-Zeilen-GPT verstanden hat, erkennt beim Lesen des Codes dieser Modelle das meiste wieder.

Übungen

  1. Setz n_layer auf 8 und n_embd auf 256. Wie viele Parameter sind es dann? Welchen Anteil hat das Token-Embedding? Erst mit den Formeln dieser Lektion rechnen, dann zur Prüfung ausführen.
  2. Entferne die beiden Residualverbindungen in Block.forward (x = self.attn(self.ln1(x))), trainiere mit train.py aus der nächsten Lektion 300 Schritte und vergleiche mit dem ursprünglichen Verlust.
  3. Lösch die Zeile, in der Ausgabeschicht und Token-Embedding sich die Parameter teilen. Wie viele Parameter kommen hinzu?

Selbsttest

1. Aus welchen Teilen besteht ein Transformer-Block? Wie teilen sich Attention und Feedforward-Netz die Arbeit?

Zwei Unterschichten: mehrköpfige kausale Self-Attention und Feedforward-Netz, vor jeder eine LayerNorm und darum eine Residualverbindung. Die Attention überträgt Informationen zwischen verschiedenen Positionen, das Feedforward-Netz rechnet für jede Position einzeln und verarbeitet die gesammelten Informationen.

2. Warum helfen Residualverbindungen beim Training sehr tiefer Netze?

Die Residualverbindung addiert die Ausgabe jeder Unterschicht zu ihrer Eingabe; die Ableitung von x + f(x) nach x enthält immer eine 1. Bei der Backpropagation kann der Gradient über diesen direkten Weg zu früheren Schichten zurückfließen und wird nicht durch viele multiplizierte Schichten extrem klein oder groß.

3. Warum sollte der Verlust eines untrainierten Modells nahe bei ln(Vokabulargröße) liegen?

Die zufällig initialisierten Parameter sind klein, das Modell bewertet alle Zeichen ungefähr gleich, und nach Softmax hat jedes Zeichen eine Wahrscheinlichkeit von etwa 1/Vokabulargröße. Die Kreuzentropie ist -log(Wahrscheinlichkeit des richtigen Zeichens), also -log(1/Vokabulargröße) = ln(Vokabulargröße).

Fragen und Diskussion

Hängst du in dieser Lektion fest? Frag hier. Und wenn du die Frage von jemandem beantworten kannst, tu es gern.

Eine Frage bringt 3 Punkte, eine Antwort 6. Beiträge erscheinen nach der Prüfung.

Diskussion wird geladen…