L4 · 02 · Transformer Internals: Attention to a Tiny GPT

The transformer is the architecture behind every modern LLM. It's a stack of blocks, and each block does only two things: attention (tokens exchange information) and a feed-forward network (each token is processed alone). This page implements attention from scratch in plain Python, walks through a full block, and sketches a tiny GPT in PyTorch.

Level 475 minTrack 1

By the end of this page you can

The whole architecture in one diagram

token ids in"cat sat" → [12, 87] embed + positionids → vectors × N blocks attention feed-fwd + residuals & layer norm around each logits per positionsoftmax → next-token probs

A decoder-only transformer (GPT-style): tokens in → vectors → N identical blocks → a probability over the vocabulary for the next token. Repeat to generate.

Attention: the one idea that matters

Each token asks: "which previous tokens are relevant to me?" It computes this with three learned projections per token:

Score = Q · K for every pair → scale → softmax → weights → weighted sum of the values. That's it. Here it is in 15 lines:

import math


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def softmax(xs):
    m = max(xs)
    e = [math.exp(x - m) for x in xs]
    s = sum(e)
    return [v / s for v in e]


def attention(Q, K, V, mask=None):
    """Q,K,V: lists of rows (seq_len x d). Returns seq_len x d."""
    d = len(Q[0])
    scores = [[dot(q, k) / math.sqrt(d) for k in K] for q in Q]
    if mask:                                    # causal: token i can't see j > i
        scores = [[s if not mask[i][j] else -1e9
                   for j, s in enumerate(row)] for i, row in enumerate(scores)]
    weights = [softmax(row) for row in scores]
    return [[sum(w * v[j] for w, v in zip(ws, V))
             for j in range(len(V[0]))] for ws in weights], weights


if __name__ == "__main__":
    # 3 tokens, 2-dim vectors (toy). Each token attends to all previous + itself.
    X = [[1, 0], [0, 1], [1, 1]]
    mask = [[j > i for j in range(3)] for i in range(3)]
    out, w = attention(X, X, X, mask)
    print("attention weights row 0:", [round(x, 2) for x in w[0]])   # [1.0, 0, 0]
    print("weights row 2:", [round(x, 2) for x in w[2]])             # splits across all
    # Token 0 can only see itself; token 2 sees all three.
    assert w[0][1] < 1e-6 and w[0][2] < 1e-6

Run it: token 0 puts 100% weight on itself (the mask hides the future); the last token spreads weight across all three. In a real model, Q, K, V aren't the input itself but learned linear projections of it.

Multi-head attention

One attention "head" learns one kind of relationship. Multiple heads run attention in parallel with separate projections, each on a smaller slice (d_model / n_heads), then concatenate. Head 1 might track subject–verb agreement, head 4 coreference, head 7 code indentation. Same cost, more kinds of pattern.

The rest of a block

PieceWhat it does
Residual connection (x + block(x))Lets each block add a correction instead of rewriting everything. Makes deep networks trainable.
Layer normRescales each token's vector to a standard mean/variance. Keeps numbers stable across 100+ layers.
Feed-forward (MLP)A per-token two-layer network (d → 4d → d). Attention mixes tokens; the MLP processes each alone. Most parameters live here.
Positional encodingAttention alone is order-blind. Position info is added/learned (or via RoPE rotation) so the model knows "cat sat" differs from "sat cat".
Causal maskZeros out future positions in attention — the model can only look back, which is what makes next-token prediction honest.

A block, end to end

x = x + attention(norm(x))      # mix information across tokens
x = x + feedforward(norm(x))    # process each token independently
# after N blocks:
logits = unembed(norm(x))       # project to vocab size

That's a GPT block. Stack it 12–100+ times, train on next-token prediction, and you have a language model.

Training a tiny GPT (PyTorch sketch)

The canonical ~300-line implementation is nanoGPT. The core training loop is short:

import torch

model = TinyGPT(vocab_size, d_model=128, n_heads=4, n_layers=4)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)

for step in range(5000):
    xb, yb = get_batch("train")            # xb: tokens, yb: same shifted by 1
    logits = model(xb)                     # (batch, seq, vocab)
    loss = cross_entropy(logits.view(-1, V), yb.view(-1))
    opt.zero_grad(); loss.backward(); opt.step()
    if step % 500 == 0: print(step, loss.item())

Everything on this page is in that loop: the model is the block stack, the loss is cross-entropy over every position, backward() is backprop, and the optimizer's step is gradient descent.

Why this matters even if you never train one

Try it (90 min)
  1. Extend attention.py: print the full weight matrix. Which token attends to which?
  2. Remove the mask and see token 0 "cheat" by attending to the future — that's why the mask exists.
  3. Install PyTorch and clone nanoGPT (or write the ~50-line tiny GPT). Train on a small text file until it generates semi-coherent text.
  4. Compute shapes: for d_model=768, n_heads=12, what's each head's dimension? (64)

Key takeaways