An LLM is a next-token predictor.
Everything else is plumbing.
Train a model to guess the next token, over and over, on enough text, and a large amount of capability falls out. This guide walks the entire forward pass one stage at a time: the intuition for why it works, a diagram you can poke at, and the actual PyTorch that implements it. Stitched together, the code is a working minimal GPT.
These three colors mean the same thing every time they appear, in every diagram below. Learn them once. The recurring motif on the right (a lower-triangular attention matrix) is the single idea the whole field is built on: each token mixes in information from the tokens before it.
The core task: P(next token | everything so far)
Strip away the mystique and a language model computes one thing: given a sequence of tokens, a probability distribution over which token comes next. That’s it. To write a sentence, you sample one token from that distribution, append it to the input, and ask again: autoregression. The model never plans the whole sentence; it just keeps answering “what’s next?” extremely well.
Imagine the world’s most over-prepared autocomplete. You feed it “The capital of France is” and it returns a probability for every word in its vocabulary: Paris 0.91, a 0.02, the 0.01, … To do that one task well across all of human text, the model is forced to internalize grammar, facts, reasoning patterns, and style, because all of those help it guess the next token. Capability is a side effect of relentless prediction.
The forward pass, end to end
Every stage in this guide is one box in the pipeline below, the same pipeline drawn in the left rail. Text comes in, integers flow through a stack of identical Transformer blocks, and a final projection turns the result into a score for every possible next token.
Mental model to hold onto: tokens travel down a wide “conveyor belt” (the residual stream). Each block reads the belt, computes a small update, and adds it back. Nothing gets overwritten: information accumulates layer by layer until the last position holds a rich summary of “what should come next.”
Text isn’t numbers. Fix that first.
Neural networks eat numbers, not characters. Tokenization is the lookup table that maps pieces of text to integer IDs and back. The simplest scheme is character-level: every distinct character gets an ID. Real models use Byte-Pair Encoding (BPE), which learns a vocabulary of frequent chunks, so " tokenization" might become two tokens, not twelve characters.
Think of building a dictionary for a language you’ve never seen. Start with single letters. Then notice "t" and "h" appear together constantly: merge them into "th". Repeat thousands of times. You end up with a vocabulary where common words are single tokens and rare words split into reusable pieces. That’s BPE: compress what’s frequent, decompose what’s rare, so the model spends its sequence length efficiently.
# Character-level tokenizer: the simplest thing that works.
# (Real models swap this for BPE, e.g. tiktoken, but the interface is identical:
# text -> list[int] -> text.)
text = open("input.txt").read()
chars = sorted(set(text)) # the vocabulary
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)} # string -> int
itos = {i: ch for i, ch in enumerate(chars)} # int -> string
encode = lambda s: [stoi[c] for c in s] # "hi" -> [40, 41]
decode = lambda ids: "".join(itos[i] for i in ids)
print(encode("hello")) # [...]
print(decode(encode("hello"))) # "hello"
The only thing the model ever sees is integers. Everything downstream (embeddings, attention, the final prediction) operates on token IDs. Vocabulary size becomes a key dimension: it’s the width of the model’s output layer.
Turn IDs into meaning, and add a sense of place
Token ID 42 means nothing on its own; 42 isn’t “bigger” than 41. So each ID indexes into a learned embedding table: a big matrix where row i is a trainable vector for token i. During training these vectors arrange themselves so that tokens used in similar ways land near each other in space. Meaning becomes geometry.
But attention (next section) treats its input as an unordered set: it has no built-in notion of sequence. "dog bites man" and "man bites dog" would look identical. The fix: add a positional encoding to each token so position is baked into the vector itself.
Embedding = giving every word a home address in a high-dimensional city, where neighbors share meaning. Positional encoding = stamping each token with “you are word #5,” using a set of sine waves of different frequencies. Low-frequency waves change slowly across the sequence (coarse position); high-frequency waves change fast (fine position). Read together, they form a unique fingerprint for each slot, like the hands of many clocks ticking at different speeds.
import torch, torch.nn as nn
class Embeddings(nn.Module):
def __init__(self, vocab_size, block_size, n_embd):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, n_embd) # one row per token
self.pos_emb = nn.Embedding(block_size, n_embd) # one row per position
# block_size = max context length the model can ever see at once.
def forward(self, idx): # idx: (B, T) integer token ids
B, T = idx.shape
pos = torch.arange(T, device=idx.device) # 0,1,2,...,T-1
x = self.tok_emb(idx) + self.pos_emb(pos) # (B,T,C) + (T,C) broadcasts
return x # meaning + position, fused
Let every token gather what it needs from the others
This is the mechanism that made Transformers work. For each token, attention asks: which earlier tokens are relevant to me, and what should I pull from them? It answers with three learned projections of every token:
- Query (Q): what this token is looking for.
- Key (K): what each token advertises about itself.
- Value (V): the actual information a token will hand over if attended to.
Compare a token’s query against every key with a dot product → a relevance score. Softmax those scores into weights that sum to 1. Then take a weighted average of the values. Each token walks away with a custom blend of information from the tokens it cared about.
Attention is a soft dictionary lookup. A normal dictionary: you give a key, you get one exact value. Here, your query is compared against all the keys at once, and instead of one match you get a weighted blend of values: pay 70% attention to one token, 20% to another, 10% to a third. It’s differentiable retrieval: the model learns what to look up and how much to trust each source.
Two more details. Scaling by √d keeps the dot products from blowing up as dimension grows, which would otherwise push softmax into a one-hot spike and kill gradients. The causal mask forbids looking at future tokens: essential, because at generation time the future doesn’t exist yet.
The formula, dissected
The dot product measures alignment. Same direction → large positive score. Perpendicular → ~0. Opposite → negative. A query “matches” a key exactly when their vectors point the same way. Rotate q and watch which key captures the attention.
Sum dk random products and the spread grows with dimension. Feed those big scores into softmax and it collapses to a near one-hot spike: gradients vanish, learning stalls. Dividing by √dk rescales scores to unit spread so softmax stays soft. Drag the dimension: the left chart spikes, the right stays healthy.
Real Transformers run several attention operations in parallel: multi-head attention. Each head gets its own Q/K/V projections and can specialize: one head tracks subject–verb agreement, another links pronouns to their referents, another watches punctuation. Their outputs are concatenated and mixed.
import math, torch, torch.nn as nn
from torch.nn import functional as F
class CausalSelfAttention(nn.Module):
def __init__(self, cfg):
super().__init__()
assert cfg.n_embd % cfg.n_head == 0
# one big linear produces Q, K, V for every head at once (efficiency):
self.c_attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd)
self.c_proj = nn.Linear(cfg.n_embd, cfg.n_embd) # mix heads back together
self.n_head, self.n_embd = cfg.n_head, cfg.n_embd
# lower-triangular mask: position i may attend to j only if j <= i
self.register_buffer("mask",
torch.tril(torch.ones(cfg.block_size, cfg.block_size))
.view(1, 1, cfg.block_size, cfg.block_size))
def forward(self, x): # x: (B, T, C)
B, T, C = x.shape
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
# split channels into heads -> (B, nh, T, hs)
hs = C // self.n_head
q = q.view(B, T, self.n_head, hs).transpose(1, 2)
k = k.view(B, T, self.n_head, hs).transpose(1, 2)
v = v.view(B, T, self.n_head, hs).transpose(1, 2)
att = (q @ k.transpose(-2, -1)) / math.sqrt(hs) # (B, nh, T, T) relevance
att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float('-inf')) # block future
att = F.softmax(att, dim=-1) # weights sum to 1 per row
y = att @ v # weighted blend of values
y = y.transpose(1, 2).contiguous().view(B, T, C) # re-merge heads
return self.c_proj(y)
Communication, then computation: repeated
A Transformer is just one block stacked N times. Each block does two things in sequence. Attention lets tokens talk to each other and exchange information (communication). Then a small MLP processes each token independently, “thinking” about what it just gathered (computation). Wrapping both: residual connections and layer normalization.
Attention = the meeting; MLP = the desk work. First the tokens get together and share notes (attention mixes across positions). Then each token goes back to its desk and processes what it learned alone (the MLP works within a position). Alternating these (gather, think, gather, think) is how depth builds understanding.
Residuals are the conveyor belt: instead of replacing the input, each sub-layer computes x = x + sublayer(x). This gives gradients a clean highway back to the start (no vanishing) and lets the network learn gentle edits to a running representation rather than rebuilding it each layer. LayerNorm re-centers and re-scales each token’s vector before each sub-layer so the numbers stay well-behaved.
The math inside the wrapper
μ and σ² are the mean and variance of that one token’s vector. Subtract the mean, divide by the spread → every token comes out centered at 0 with unit variance, so no feature can blow up or vanish. The learned γ (scale) and β (shift) then dial the magnitude back where it’s useful; ε just guards against divide-by-zero.
Φ is the Gaussian CDF: a smooth gate from 0 to 1. GELU passes large positives through, squashes negatives toward 0, and stays smooth at the origin. Why it matters: stack linear layers with no nonlinearity and they algebraically collapse into a single linear layer: depth buys nothing. The nonlinearity is what lets layers compose into something more expressive.
class MLP(nn.Module):
"""Per-token computation. Expand 4x, apply nonlinearity, project back."""
def __init__(self, cfg):
super().__init__()
self.fc = nn.Linear(cfg.n_embd, 4 * cfg.n_embd)
self.proj = nn.Linear(4 * cfg.n_embd, cfg.n_embd)
self.act = nn.GELU()
def forward(self, x):
return self.proj(self.act(self.fc(x)))
class Block(nn.Module):
"""Pre-norm: normalize -> sublayer -> add to residual stream."""
def __init__(self, cfg):
super().__init__()
self.ln1 = nn.LayerNorm(cfg.n_embd)
self.attn = CausalSelfAttention(cfg)
self.ln2 = nn.LayerNorm(cfg.n_embd)
self.mlp = MLP(cfg)
def forward(self, x):
x = x + self.attn(self.ln1(x)) # communicate, then add
x = x + self.mlp(self.ln2(x)) # compute, then add
return x
Stack the blocks, then read out a prediction
Now wire it all together. Embed the tokens, run them through N identical blocks, apply one final LayerNorm, then project the result to logits: one raw score per vocabulary entry, for every position. A softmax over those scores gives the next-token distribution. That projection is the lm_head, and a classic trick called weight tying reuses the embedding table as the output matrix (the map “word → vector” run in reverse), saving parameters and improving quality.
Picture the residual stream entering layer 1 as a faint sketch of each token and leaving layer N as a detailed portrait: early blocks resolve surface patterns (spelling, local syntax), deeper blocks assemble long-range meaning. The final position’s vector is the model’s best compressed answer to “what comes next?”, and lm_head simply scores every candidate token against it.
The math of the read-out
lm_head are all just this.lm_head can reuse E transposed (weight tying): scoring against a token is the reverse of looking it up.from dataclasses import dataclass
@dataclass
class GPTConfig:
vocab_size: int = 65 # from your tokenizer
block_size: int = 256 # max context length
n_layer: int = 6
n_head: int = 6
n_embd: int = 384
class GPT(nn.Module):
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
self.ln_f = nn.LayerNorm(cfg.n_embd)
self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.lm_head.weight = self.tok_emb.weight # weight tying
def forward(self, idx, targets=None):
B, T = idx.shape
pos = torch.arange(T, device=idx.device)
x = self.tok_emb(idx) + self.pos_emb(pos) # (B,T,C)
for block in self.blocks:
x = block(x)
x = self.ln_f(x)
logits = self.lm_head(x) # (B, T, vocab_size)
loss = None
if targets is not None:
# compare prediction at every position to the *actual* next token
loss = F.cross_entropy(logits.view(-1, logits.size(-1)),
targets.view(-1))
return logits, loss
That’s a complete, trainable GPT in ~60 lines of model code. Everything from here is about fitting it (training) and using it (generation).
Measure surprise, then nudge the weights
Training data is free: any text is a stack of next-token questions with the answers attached. Take a chunk of tokens; the inputs are positions 0…T-1 and the targets are the same chunk shifted by one. At every position the model predicts the next token, and we already know what it actually was.
The loss is cross-entropy: read it as the model’s surprise. If it put 90% probability on the correct next token, surprise is low; if it confidently picked the wrong one, surprise is high. Training repeatedly asks: “how surprised were you?” and adjusts every weight a touch to be less surprised next time. Backpropagation computes which direction to nudge; AdamW takes the step.
Two stabilizers matter in practice. A learning-rate schedule warms up (small steps while the model is fragile) then decays along a cosine curve (fine adjustments as it settles). Gradient clipping caps the occasional giant gradient so one bad batch can’t blow up the run.
The math of learning
The target is one-hot (only the true next token has y = 1), so the sum collapses to a single term: the negative log of the probability you gave the right token. Put 100% on it → loss 0. Put 1% on it → loss 4.6. The curve rewards calibrated confidence and punishes confident mistakes brutally. Drag the probability to feel it.
The gradient points uphill (toward more loss), so step the opposite way. η (learning rate) is the step size. Too small → crawls. Just right → glides to the bottom. Too big → overshoots and oscillates, or diverges. Drag η and step the ball to see every regime on a simple loss bowl.
Start near zero and ramp up (warmup) while the model is fragile and nearly random: big early steps would wreck it. Then follow a cosine down toward zero so late training makes fine, careful adjustments as it settles into a minimum.
import torch
def get_batch(data, block_size, batch_size, device):
# data is a 1-D tensor of token ids (the whole corpus)
ix = torch.randint(len(data) - block_size, (batch_size,))
x = torch.stack([data[i : i+block_size] for i in ix]) # inputs
y = torch.stack([data[i+1 : i+block_size+1] for i in ix]) # targets = shift by 1
return x.to(device), y.to(device)
model = GPT(GPTConfig()).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
for step in range(max_steps):
xb, yb = get_batch(train_data, cfg.block_size, batch_size=64, device=device)
logits, loss = model(xb, yb) # forward: prediction + surprise
opt.zero_grad(set_to_none=True)
loss.backward() # backprop: gradient for every weight
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # clip spikes
opt.step() # AdamW nudges the weights
if step % 200 == 0:
print(f"step {step}: loss {loss.item():.3f}")
Sanity check: with vocab size V, a totally untrained model is just guessing, so initial loss ≈ ln(V). Watching it fall below that on the first steps is your “it’s alive” signal.
From a distribution to actual words
To generate, run the model, take the logits at the last position, turn them into probabilities, and pick a token. Append it to the input and repeat. How you pick is the difference between a boring model and a good one. The knobs:
- Temperature: divide logits by T before softmax. T < 1 sharpens the distribution (safe, repetitive); T > 1 flattens it (creative, riskier); T → 0 is pure greedy/argmax.
- Top-k: keep only the k highest-probability tokens, renormalize, sample. Cuts off the long tail of nonsense.
- Top-p (nucleus): keep the smallest set of tokens whose probability sums to p. Adapts its cutoff to how confident the model is.
Temperature is a confidence dial. Low temperature is a cautious writer who always reaches for the most likely word: accurate but dull, and prone to loops. High temperature is a brainstormer who’ll gamble on surprising words: vivid but error-prone. Top-k and top-p are guardrails that let you keep some creativity while banning the genuinely absurd tail.
@torch.no_grad()
def generate(model, idx, max_new_tokens, temperature=1.0, top_k=None):
model.eval()
for _ in range(max_new_tokens):
idx_cond = idx[:, -model.cfg.block_size:] # never exceed the context window
logits, _ = model(idx_cond)
logits = logits[:, -1, :] / temperature # only the last position matters
if top_k is not None: # optional top-k filtering
v, _ = torch.topk(logits, top_k)
logits[logits < v[:, [-1]]] = -float('inf')
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1) # sample one token
idx = torch.cat((idx, idx_next), dim=1) # append, then loop
return idx
# usage:
context = torch.tensor([encode("ROMEO:")], device=device)
out = generate(model, context, max_new_tokens=300, temperature=0.8, top_k=40)
print(decode(out[0].tolist()))
Speed note: naively re-running the whole sequence every step is wasteful, since past tokens’ keys and values never change. Production models cache them (the KV cache) so each new token is cheap. Same math, far less compute.
What separates your nanoGPT from a frontier model
You now have the complete skeleton. A frontier model is the same skeleton: bigger, fed more, aligned, and engineered for scale. The conceptual additions worth knowing next, roughly in order of leverage:
- Scale & data quality. Scaling laws say loss falls predictably as you grow params, data, and compute together. The biggest real-world lever is the data: dedup, filtering, and mixture matter more than architecture tweaks.
- Better positions: RoPE. Rotary embeddings encode position by rotating Q/K, generalizing to longer contexts than they were trained on. Most modern models use it.
- Cheaper norm & attention. RMSNorm (lighter LayerNorm), SwiGLU MLPs, Grouped-Query Attention (share K/V across heads), and FlashAttention (a fused kernel that never materializes the T×T matrix).
- Alignment. Pretraining gives raw competence; instruction tuning (SFT) teaches it to follow requests, and preference tuning (RLHF / DPO) shapes it toward helpful, honest, harmless answers.
- Mixture-of-Experts. Swap the dense MLP for many experts and route each token to a few: more parameters, similar compute per token.
- Long context & efficiency. Attention is O(T²); context windows grow via better kernels, sparse/linear attention variants, and clever caching.
If you understood every box in the left rail, you understand how ChatGPT, Claude, and Llama work at the mechanism level. They are this guide’s pipeline (embed, attend, compute, predict) scaled up and aligned. The mystery isn’t in any one component; it’s in what emerges when you do next-token prediction at enormous scale.
Every token attends to every token, so cost grows with the square of context length T. Doubling the context quadruples attention compute, which is why long-context models lean on FlashAttention, KV caching, and sparse / linear attention variants.
Loss falls as a power law in parameters N (and likewise in data and compute), a straight line on a log-log plot. That predictability is the whole bet behind scaling: train small, extrapolate the line, and you know roughly what a bigger model buys before spending the GPUs.
Where to go to actually run this
- Karpathy’s nanoGPT (MIT License): the canonical minimal, trainable GPT. This guide’s model code in §5 and §6 follows its structure and naming (c_attn, c_proj, block_size), and borrows its “communication, then computation” block framing directly. Pair it with his “Let’s build GPT from scratch” video.
- “Attention Is All You Need” (2017): the original Transformer paper. Now readable, given everything above.
- The Illustrated Transformer (Jay Alammar): more diagrams if you want another visual pass on attention.
- Build it on tiny Shakespeare first. ~1MB of text, char-level, the config in §6 trains on a single GPU in minutes and produces recognizable (if nonsensical) Shakespeare. That moment is the whole point.