How LLMs Actually Work — From Data to Intelligence
How LLMs
Actually Work
00 · The jargon
Key concepts before we start
These terms come up over and over. Here’s what each one means, visually.
What we're talking about
Token
A chunk of text the model treats as one unit. Common words = 1 token ("cat"). Rare words = split into pieces ("un"+"believe"+"able"). The model only ever reads tokens, not letters.
Vocabulary
The list of all tokens the model knows. The tokenizer turns any text into IDs from this list. Typical size: 32k–128k tokens. Bigger vocab = shorter sequences but more memory.
Vector
A list of numbers (like coordinates). Every token becomes a vector — think of it as a location in a giant map. Nearby vectors = similar meanings.
Dimension
One slot in a vector — one number. A 4096-dim vector has 4096 numbers per token. More dims = more storage for patterns. Each dim learns some tiny feature (like "is this about animals?" or "is this a verb?").
Embedding
The vector assigned to each token, learned during training. "cat" and "dog" get similar vectors because they appear in similar sentences. The vector is the token's "starter pack" of meaning.
Embedding size / Hidden size
How many numbers per token vector. Every layer works with this many numbers per token. GPT-2: 768, LLaMA 3 8B: 4096, GPT-3: 12288. Bigger = more capacity, but slower.
Weight
A number that controls how much an input matters. Each connection between neurons has a weight. Training = finding the right weights so the model makes good predictions. One layer can have millions of weights.
Parameter
Any number the model learns — weights + biases. A "7B model" has 7 billion parameters. The model's knowledge is spread across all these numbers, like a giant recipe book.
Activation function
A simple "bend" applied after each linear layer. Without bends, stacking layers is pointless — they'd collapse into one. Common bends: ReLU (zero out negatives), GELU (smooth version), SwiGLU (gate that controls flow).
Softmax
Turns any list of numbers into percentages that add up to 100%. Used after attention and at the final output to decide which token to pick next. Like ranking exam scores into probabilities.
Logit
The raw score for each possible next token before softmax. Higher logit = more likely. Like exam marks before they're converted to percentages.
LayerNorm
Rescales numbers to keep them stable. Applied before every attention and MLP block. Like adjusting the volume so it's never too loud or too quiet — prevents numbers from exploding or vanishing in deep models.
Head (attention head)
One "expert" that runs the QKV attention with its own learned weights. Models run many heads in parallel (32+ per layer). Each head learns a different job: one tracks grammar, another resolves pronouns, another copies names.
Residual stream
The "highway" through the model. Each layer reads the token's current state, adds a small refinement, and writes it back. Nothing gets replaced — each layer just makes a tiny improvement. The final state is the sum of all layers' improvements.
KV cache
During text generation, the model stores old tokens' K and V vectors so it doesn't recompute them. Without this, generating each new word would mean re-reading the whole conversation from scratch. Makes generation fast (linear instead of quadratic).
Context window
Maximum tokens the model can see at once. Everything outside this window is invisible. Like a desk that can only hold so many papers. GPT-4: 128K, Claude 3: 200K, Gemini: 1M+ tokens.
The starter toolbox
These tiny functions appear in every section from here on. See them once now, then watch them reappear.
import numpy as np
# Softmax: turn any raw scores into percentages that sum to 100%
def softmax(x):
ex = np.exp(x - np.max(x))
return ex / ex.sum()
# LayerNorm: center and scale values to keep them stable
def layer_norm(x, eps=1e-5):
return (x - x.mean()) / (x.std() + eps)
# ReLU: simplest activation — zero out negatives, keep positives
def relu(x):
return np.maximum(0, x)
# Logit → probability: the raw-to-percentage pipeline
logits = np.array([3.2, 1.1, 0.5])
probs = softmax(logits)
print(f"logits: {logits} → probs: {probs.round(3)}")
# Output: logits: [3.2 1.1 0.5] → probs: [0.761 0.254 0.146]Notice how softmax and layer_norm are each just 3 lines. They are the only “magic” you need to understand everything that follows.
01 · The foundation
Every neural network, at its core: multiply, add, repeat
An LLM has billions of numbers inside it. But those billions are just the same tiny machine — the neuron — repeated over and over. Understand the neuron, and you understand the foundation of everything.
One neuron = one weighted vote
A single neuron does one simple calculation:
Think of it like voting:
- Each input is a person casting a vote
- Each weight (
w) is how much that person’s vote counts - The bias is a fixed advantage one candidate gets regardless of the vote
So: the neuron multiplies each input by its weight, adds them all up, adds the bias, and spits out one number. That’s it. This operation is called a linear layer — but that’s just a fancy name for “multiply each input by its importance, add them up.”
# One neuron = multiply inputs by weights, add bias
inputs = [2.0, 4.0, 1.5]
weights = [0.8, -0.3, 1.2]
bias = 0.5
output = 0
for i in range(len(inputs)):
output += inputs[i] * weights[i]
output += bias
print(f"output = {output:.2f}") # output = 3.10Now imagine many neurons side by side, all looking at the same inputs but each with its own weights and bias — that’s a layer. Writing it as a group of equations is the same as a matrix multiplication.
Why one layer isn’t enough
Here’s the trap: if you stack linear layers on top of each other, just simplifies to — one big linear equation. No matter how many layers you stack, you’re still just drawing straight lines. The depth is useless.
The fix: after each linear layer, apply an activation function — a simple “bend” that breaks the straight line. The most common ones:
- ReLU: if the output is negative, make it zero. Positive stays as-is. Like a light switch — either on or off.
- GELU: a smooth version of ReLU (used in GPT-2, BERT)
- SwiGLU: has two paths — one that creates information and another that acts like a gate, deciding what passes through (used in LLaMA, most modern LLMs)
Without these bends, a 100-layer network is no smarter than a 1-layer network. With them, each layer can build on the previous one in increasingly abstract ways — that’s where the depth earns its power.
import math
def relu(x): return max(0, x)
def gelu(x): return 0.5 * x * (1 + math.erf(x / math.sqrt(2)))
def swish(x): return x / (1 + math.exp(-x))
x = [0.5, -1.2, 0.8]
print(f"ReLU: {[relu(v) for v in x]}")
print(f"GELU: {[round(gelu(v), 2) for v in x]}")
print(f"Swish: {[round(swish(v), 2) for v in x]}")One input, one weight, one bias
This is a single neuron with 3 inputs. Each input has its own weight. Click Compute to see it run.
Now here’s the key insight: if you stack multiple neurons side by side — all looking at the same inputs but each with its own weights — that’s a layer. And writing that layer as a group of equations is the same as a matrix multiplication.
Where means “weight from input j to neuron i.” The row index = target neuron, column index = source input.
Many neurons, same inputs — a matrix
Click to see individual neurons collapse into a single matrix operation. They're the same thing — just different notation.
# One layer = matrix multiply (many neurons at once)
import numpy as np
X = np.array([2.0, 4.0]) # inputs
W = np.array([[0.8, -0.3], # weights: 3 neurons × 2 inputs
[1.2, 0.5],
[-0.4, 0.9]])
b = np.array([0.5, -0.2, 0.1]) # biases
output = X @ W.T + b # @ = matrix multiply
print(f"layer output: {output}") # [ 2.1 , 2.7 , 0.9 ]The @ is matrix multiplication. The .T transposes W (flips rows ↔ columns) so the dimensions line up. This one line replaces writing out every neuron’s equation by hand.
Simple
A neuron is a weighted voting machine. Inputs vote, weights decide vote strength, and the bias shifts the final decision.
Practical
A layer is many neurons evaluated together. Software writes this as matrix multiplication because GPUs are built to do that very fast.
Advanced
Almost every Transformer component is a learned change of basis: project vectors into a space where the next operation becomes easier.
Why do we need many neurons instead of one big equation?
One equation can only draw straight lines. Many neurons with activation functions can draw curves, make decisions, and build layered understanding. This is how LLMs represent grammar, facts, tone, and reasoning all at once.
What does the bias really do?
The bias lets a neuron fire even when inputs are weak, or stay quiet unless evidence is strong. Without bias, every neuron would be forced to output 0 when all inputs are 0 — making learning unnecessarily rigid.
Where does the "bend" (non-linearity) come from?
Stack linear layers alone and they collapse into one. Activation functions like ReLU or SwiGLU bend the computation between layers so stacked layers can express complex patterns.
Why are matrices everywhere?
A matrix is just many dot products bundled together — perfect for GPUs which do thousands of multiply-add operations at once. The math is simple; the scale is what makes it powerful.
How many parameters are in one layer?
If input has 4096 numbers and output has 4096 numbers, the weight matrix is 4096 × 4096 ≈ 16.7M parameters + 4096 biases. One transformer block has two such layers (attention + MLP) = ~33M parameters. Stack 32 blocks = 1B+.
Why not just use one giant neuron?
One neuron outputs one number. An LLM needs to represent grammar, facts, tone, and reasoning simultaneously. Many neurons, each specializing in different patterns, give the model the capacity to represent all of these at once.
Every core computation in an LLM — embedding lookup, attention projection, MLP layers, output prediction — is built from operations (with activation functions breaking linearity at key points). Keep that in your head as we go.
Connection: now that we know the machine only accepts numbers, the next problem becomes obvious: language is not numeric. Before the model can multiply anything, text must be converted into a stable stream of IDs.
02 · The data problem
How to eat the internet
Computers can’t read words — they need numbers. So how do we turn “the cat sat on the mat” into numbers without losing meaning?
Simple idea: give every word a number. “the” = 1, “cat” = 2, “sat” = 3. This fails because:
- “run” and “running” get different numbers even though they’re related
- There are too many words (and new ones created daily)
- Misspellings and names would need entirely new numbers
The real solution is BPE (Byte Pair Encoding): start with individual letters, then merge the most frequent letter pairs into new tokens. Repeat until you have a useful vocabulary.
Building a token vocabulary, one merge at a time
Below is a corpus of text. Each character starts alone. Click Next merge to find the most frequent adjacent pair and merge it into a new token. Watch the vocabulary build. Notice how "the" and "and" become single tokens quickly — common words get absorbed first.
# BPE tokenizer: one merge step
corpus = "the cat and the dog sat on the mat and watched the cat run"
# Step 1: start with individual characters
tokens = list(corpus)
print(f"Characters: {''.join(tokens)}")
# Step 2: find the most frequent adjacent pair
def most_frequent_pair(tokens):
pairs = {}
for i in range(len(tokens) - 1):
pair = (tokens[i], tokens[i+1])
pairs[pair] = pairs.get(pair, 0) + 1
return max(pairs, key=pairs.get)
# Step 3: merge that pair everywhere
pair = most_frequent_pair(tokens)
new_tokens, i = [], 0
while i < len(tokens):
if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair:
new_tokens.append(tokens[i] + tokens[i+1])
i += 2
else:
new_tokens.append(tokens[i])
i += 1
print(f"After merging '{pair[0]}' + '{pair[1]}': {''.join(new_tokens)}")
print(f"Vocabulary: {sorted(set(new_tokens))}")The merge rules are learned once, before training ever starts. After that, every input text is tokenized the same way.
Simple
Tokenization cuts text into reusable chunks. Common words often become one token; rare words break into smaller pieces.
Practical
The tokenizer is a fixed contract. Training, fine-tuning, inference, caching, and pricing all count the same token IDs.
Advanced
Tokenizer choices shape model behavior: spelling, code indentation, multilingual text, and long context efficiency all depend on the vocabulary.
Why not use characters only?
Characters work for any word, but sequences become very long. Longer sequences make attention slower and force the model to spend effort reconstructing simple words before it can think about meaning.
Why not use whole words only?
The vocabulary would be huge and fail on misspellings, names, URLs, code, and new words. Subword splitting is the best compromise: compact for common text, flexible for rare text.
Can tokenization change the answer?
Yes. If a name or number splits awkwardly, the model sees unfamiliar pieces. This is why models are sensitive to spaces, punctuation, and formatting — even though they shouldn't be.
What is the tradeoff in vocabulary size?
Bigger vocabulary = shorter sequences but larger tables. Smaller vocab = saves memory but longer prompts. Model builders tune this based on language mix and context-length goals.
How does tokenization affect non-English languages?
Most tokenizers are trained on English-heavy data. Non-English text (Finnish, Tamil, Arabic) often splits into more tokens, meaning less text fits in the context window — a real fairness issue.
Why do models struggle with simple math?
Numbers are split inconsistently: "1234" might become ["12", "34"] or ["1", "234"]. The model has to guess the numerical meaning from arbitrary splits. Tokenizers were designed for words, not arithmetic.
Once BPE is done, every piece of text becomes a sequence of token IDs. “the cat sat” becomes something like [5, 182, 93, 12, 5, 44]. The model’s entire world is these integers.
Connection: token IDs solve input formatting, but they still have no meaning. The ID 182 is not closer to 183 in any semantic sense. The next step is to turn IDs into vectors where distance and direction can carry useful information.
03 · Embeddings
Turning token IDs into meaningful vectors
We have token IDs: [5, 182, 93, …]. But the number 182 means nothing — it’s just an arbitrary label. “cat” = 182 tells the model nothing about what a cat is.
We need each token to become a vector — a list of numbers — where those numbers actually encode meaning. Similar tokens should have similar vectors. This vector is called an embedding.
Think of it like a city map:
- Each token gets coordinates (its vector)
- Tokens used in similar ways end up in similar neighborhoods
- “cat” and “dog” live near each other; “math” and “poetry” are in different districts
The embeddings are stored in a giant table. Looking up “cat” gives you its vector. This is just a special case of the we learned about: you’re multiplying a one-hot vector (all zeros except one 1) by the embedding matrix. The matrix row you pick is the embedding.
A neat trick many LLMs use: the same table is used both to turn token IDs into vectors (at the input) AND to turn vectors back into token predictions (at the output). The input mapping and output prediction share the same “meaning” space — this is called tied embeddings and cuts the parameter count roughly in half.
import numpy as np
vocab_size = 6 # tiny: ["the", "cat", "dog", "sat", "mat", "run"]
embed_dim = 4 # 4 numbers per token (real LLMs use 768–4096)
# Embedding table: one row per token, one column per dimension
E = np.random.randn(vocab_size, embed_dim) * 0.1
# Look up "cat" (index 1) — just grab its row
cat_vector = E[1]
print(f"cat's embedding vector: {cat_vector}")
# Same lookup as one-hot × matrix multiplication
one_hot = np.array([0, 1, 0, 0, 0, 0])
cat_vector2 = one_hot @ E # same result
print(f"same via one-hot: {cat_vector2}")Before training, all vectors are random. During training, the model adjusts them: words used in similar ways drift toward similar vectors. “cat” and “dog” end up nearby because they appear before “sat” and after “the.” The model never sees a dictionary — meaning emerges from usage patterns alone.
Random noise → structured meaning
Before training, each token's embedding is random noise (shown below as colored pixels). Click Train step to simulate a few training steps. Words that appear in similar contexts will drift toward similar coordinates. Notice "cat" and "dog" moving closer, while "cold" and "warm" drift apart. Watch the color patterns — after training, rows with similar meanings look similar.
Simple
An embedding is a learned coordinate for a token. Similar usage patterns pull coordinates closer together.
Practical
The embedding dimension is the model's working width. Wider vectors can store more features, but every layer becomes more expensive.
Advanced
Features are distributed. A token does not have one "meaning number"; meaning appears as many weak directions across the vector space.
Is an embedding like a dictionary definition?
No. It's more like GPS coordinates for a word's meaning. "Bank" has coordinates near both "money" and "river"; attention layers later use context to pick the right meaning.
Do embeddings contain all model knowledge?
No. Embeddings are the starting point, but most knowledge lives across attention, MLP, and output weights. The embedding is the first draft; transformer layers rewrite it using context.
Why do people say "king - man + woman = queen"?
Some relationships become roughly linear because the training objective rewards consistent patterns. But this is approximate, not perfect algebra. Real models use many overlapping directions.
What happens to words never seen during training?
The tokenizer breaks unknown words into known pieces (subwords). The model then composes meaning from those pieces. It may still struggle if the combination is rare.
Can two models have different embeddings for the same word?
Yes. A model trained on medical text embeds "cell" differently from one trained on biology. Even the same architecture with different random seeds produces different embeddings. The geometry emerges from training, not design.
Do embeddings change during inference?
The starting vector is fixed — same word always gets same initial numbers. But attention and MLP layers continuously modify that vector. By the time the model predicts, the representation has been rewritten many times by context.
Connection: embeddings give every token meaning, but not order. A bag of token vectors cannot distinguish "teacher helped student" from "student helped teacher". Position must be injected before attention can reason about relationships.
04 · Positional encoding
How the model knows which word came first
Think about the sentence “dog bit man” vs “man bit dog.” Same three words. Completely different meaning. The model needs to know which word came first — order matters.
But the Transformer reads all tokens at once (not one by one). It has zero built-in sense of order. So we must manually inject position info.
The simplest idea: just add 1 to token 1’s vector, add 2 to token 2’s vector, etc. Why doesn’t this work? Read on.
Why position numbers break, and sine waves fix it
What actually happens, step by step
We directly modify the token’s vector. There is no separate “position input.” Here’s exactly what happens:
- Token “dog” at position 3 has an embedding vector, like
[0.2, -0.5, 0.8, ...](a list of 4096 numbers) - We compute a position vector for position 3 using sine/cosine waves, giving us another vector of the same length, like
[0.14, -0.99, -0.76, ...] - We add the two vectors together:
new_vector = embedding_vector + position_vector - This summed vector is what the model works with from that point on
The position info is mixed into the token’s numbers. The model doesn’t know “here are 50 numbers for meaning and 50 numbers for position” — they’re all blended together, and the model learns to use whichever parts it needs.
The solution: sine + cosine waves (together they make a fingerprint)
Imagine you’re writing a long exam and every student needs a unique ID. If you give them just one number (1, 2, 3…), you run out of space or the numbers get huge. Instead, imagine giving each student two numbers: one from a fast-ticking clock and one from a slow-ticking clock. Together they form a unique pair.
Similarly, for each position in the sequence, we generate a unique fingerprint using two complementary waves:
- One wave uses sine (sin) — think of it as the “vertical coordinate”
- The other uses cosine (cos) — think of it as the “horizontal coordinate”
Why both? Because one alone isn’t enough. Just like a GPS needs both latitude AND longitude to pinpoint a location, sin gives you one value and cos gives you another. Together they form a unique pair for every position. If you used only sin, many different positions would have the same value (sin repeats every 360°). Using sin+cos as a pair creates a unique signature.
Each dimension of the position vector uses a wave at a different frequency:
- Dimension 0: very fast wave (changes every 1-2 positions) — captures local relationships like “the” before “cat”
- Dimension 1: slightly slower wave — captures slightly longer patterns
- Dimension 10: slow wave (changes every 50+ positions) — captures long-range patterns like a pronoun referring to a noun 30 words back
- Dimension 100+: extremely slow wave — barely changes across the whole sequence
All these waves oscillate between -1 and +1 forever. Position 1,000,000 gets a valid fingerprint just as well as position 1.
The formula, broken down simply
Don’t be scared. Here’s what each symbol means:
p= position number (0, 1, 2, 3… which word in the sequence)i= which dimension of the vector we’re computing (0, 1, 2, 3… up to 4096)d= total number of dimensions in the vector (e.g., 4096)2iand2i+1= even and odd dimension numbers (sin for even, cos for odd)
Now the inside:
Think of it like this:
10000is just a big reference number. It’s like saying “the slowest wave takes 10,000 positions to complete one full cycle”2i/dis the fraction of “how far through the dimensions we are.” For dimension 0, it’s 0/4096 = 0. For dimension 256, it’s 512/4096 ≈ 0.125. For the last dimension, it’s 4096/4096 = 1.- for dimension 0 gives , so the wave speed is
p/1 = p— this is the fastest wave (changes with every position) - For the last dimension, , so the wave speed is
p/10000— this is the slowest wave (barely changes)
Different dimensions get different denominators: 1, 10000¹ᐟ⁴⁰⁹⁶, 10000²ᐟ⁴⁰⁹⁶, … up to 10000. This creates a smooth range from “fast wave” to “slow wave.”
If the formula still feels fuzzy, just remember: each position gets a unique set of numbers from -1 to +1, and that set is added directly to the token’s embedding vector. The model learns to use those numbers to tell where each word sits in the sentence.
import math
def positional_encoding(seq_len, d_model):
PE = []
for pos in range(seq_len):
pos_vec = []
for i in range(d_model):
denom = 10000 ** ((2 * (i // 2)) / d_model)
if i % 2 == 0:
pos_vec.append(math.sin(pos / denom))
else:
pos_vec.append(math.cos(pos / denom))
PE.append(pos_vec)
return PE
seq_len, d_model = 50, 6
PE = positional_encoding(seq_len, d_model)
for pos in range(8):
print(f"pos {pos}: {[round(v, 2) for v in PE[pos]]}")
# Adding position to embedding (this is what the model actually does):
embedding_cat = [0.2, -0.5, 0.8, 0.1, -0.3, 0.6]
final_vector = [embedding_cat[i] + PE[3][i] for i in range(d_model)]
print(f"\nembedding + pos=3: {[round(v, 2) for v in final_vector]}")Each position gets a unique fingerprint. Position 0 is different from position 5, and position 1000 works just as well as position 1. The vector is added directly to the token’s embedding — they’re blended together.
Different frequencies → different pattern lengths. Fast waves (high frequency) change rapidly — they capture local patterns between neighboring tokens, like noun-verb agreement. Slow waves (low frequency) change gradually — they capture long-range patterns like a pronoun referring to a subject 50 tokens back. Every dimension in the positional encoding ticks at a different speed, creating a rich fingerprint for each position.
Simple
Position encoding gives each token an address. Without the address, the model only sees which words exist, not their order.
Practical
Good position methods help models handle long prompts, code structure, lists, tables, and references across many paragraphs.
Advanced
Relative position matters more than absolute position. Many newer methods make dot products encode distance between tokens directly.
Wait — does the position get stored separately or mixed into the token vector?
Mixed in. The position values are added to the embedding values. The model sees one combined vector per token. It has to figure out which parts of the numbers are "meaning" and which parts are "position." This works because the training process teaches the model to use both kinds of information.
Why both sin and cos? Why not just sin for everything?
Sin alone repeats every 360° — position 0 and position 360 would have the same sin value. By using sin+cos as a pair, every position gets a unique combination (like a GPS needs both latitude and longitude). If you only used sin, the model would be confused about positions that share the same sin value.
What does 10000 mean in the formula?
It controls the speed of the slowest wave. 10000 means "the slowest wave takes about 10000 positions to complete one full cycle." You could use 5000 or 20000 instead — it's just a design choice. The important thing is that different dimensions get different speeds, from very fast (dimension 0) to very slow (last dimension).
What is RoPE in simple words?
RoPE doesn't add position to the embedding at all. Instead, it twists the query and key vectors like turning a dial. The amount of twist depends on the position. When two tokens are close together, their dials are twisted by similar amounts; when far apart, by different amounts. The attention mechanism sees the difference in twist and "knows" the distance. It's elegant because it needs no extra memory and works for any sequence length.
Why do long contexts still fail sometimes?
The model may accept many tokens, but training may not teach it to use every distance equally well. Attention cost, data distribution, positional method, and retrieval difficulty all affect long-context quality.
Can the model ignore position if it wants?
Partly. Later layers can learn to use or suppress positional directions depending on the task. For poetry, code, and math, position is crucial; for topic classification, exact order may matter less.
Why do some models use learned positions instead of sine waves?
Learned positional embeddings treat each position as a separate token-like vector that gets trained. They can be more expressive for fixed-length sequences, but they can't extrapolate beyond the longest position seen in training. Sine waves and RoPE extrapolate naturally — which is why learned positions fell out of favor as context lengths grew.
Connection: now each token has meaning plus address. The next question is contextual meaning: "it" depends on a noun before it, "bank" depends on nearby words, and every token needs a way to look at other tokens.
05 · Attention
The core: letting tokens look at each other
Now every token has meaning + position. But it still doesn’t know which other tokens are relevant to it.
Attention is the mechanism that answers: “Which other words in this sentence should I look at to understand myself better?”
Think of it like a classroom discussion:
- Each student (token) can hear all other students
- But not everyone is equally relevant to what you’re saying
- You need to figure out who to listen to and how much
Why attention replaced the old way (RNNs)
Before Transformers, models called RNNs processed words one at a time — word 1 finishes before word 2 starts. Like reading a book one letter at a time through a tiny straw. Worse, information from word 1 had to travel through every intermediate step to reach word 100 — it degraded along the way (like the telephone game).
Attention changed everything: it compares all words at once, directly. Word 1 connects to word 100 in a single operation, with no degradation. This is why Transformers train faster and handle longer text.
How it works: Q, K, V (like a library)
Every word produces three special vectors from its embedding (using our familiar ):
Think of a library:
- Query (
Q): what I am searching for. Like typing in the search bar. - Key (
K): what I contain. Like the title and tags on a book. - Value (
V): the actual information I carry. Like the book’s contents.
Each word asks a question (Query), and every other word’s Key answers whether they’re relevant. If there’s a match, the Value (the actual info) is passed along.
import numpy as np
x = np.array([0.2, 0.8, 0.1, 0.3]) # embedding of "dog"
# Three different weight matrices (learned during training)
W_q = np.random.randn(4, 3) * 0.1
W_k = np.random.randn(4, 3) * 0.1
W_v = np.random.randn(4, 3) * 0.1
Q = x @ W_q
K = x @ W_k
V = x @ W_v
print(f"Query: {Q.round(2)}")
print(f"Key: {K.round(2)}")
print(f"Value: {V.round(2)}")Same input x, three different projections. Each word does this. Then attention computes how well each word’s Query matches every other word’s Key.
One word asks, all words answer
Click a word below. It becomes the Query. We compute its dot product against every word's Key. The higher the score, the more relevant that word is. Try clicking "chased" — it should attend strongly to "dog" (who did the chasing) and "tail" (what was chased).
Why we scale: keeping the distribution fair
The raw Query·Key scores get large when vectors are long. Imagine multiplying big numbers together — the results can be huge. When we then apply softmax (which converts raw scores into percentages that add up to 100%), huge scores make softmax too confident — it picks one word and ignores everything else.
The fix: divide all scores by (the square root of the dimension size). This keeps scores in a reasonable range so softmax produces a smooth distribution.
def softmax(x):
ex = np.exp(x - np.max(x)) # subtract max for numerical stability
return ex / ex.sum()
def attention(Q, K, V):
scores = Q @ K.T # dot product: how relevant is each key?
scores = scores / np.sqrt(K.shape[1]) # scale so softmax isn't too extreme
weights = softmax(scores) # convert to percentages
return weights @ V # weighted blend of values
# Toy sentence: "the dog chased" → 3 tokens, 4-dim embeddings
X = np.array([[0.2, 0.8, 0.1, 0.3], # "the"
[0.9, 0.2, 0.7, 0.1], # "dog"
[0.3, 0.6, 0.2, 0.9]]) # "chased"
W_q = np.random.randn(4, 4) * 0.1
W_k = np.random.randn(4, 4) * 0.1
W_v = np.random.randn(4, 4) * 0.1
Q, K, V = X @ W_q, X @ W_k, X @ W_v
output = attention(Q, K, V)
print(f"Attention output:\n{output.round(3)}")
# Each token's new vector = blend of all values, weighted by relevanceThe scaling by prevents one token from hogging all the attention. Without it, softmax becomes a near one-hot vote — the model only “hears” one token and misses everything else.
Raw scores → softmax → attention distribution
The dot-product scores are scaled (divided by √d) and then passed through softmax, which turns them into percentages that add up to 100%. The new representation of each token is a blend of all Values, weighted by these percentages.
In this demo d₋ sits at 4 for visual clarity. Real attention uses d₋ between 64 and 128 — the scaling factor √d ranges from 8 to 11.3, making the effect substantially stronger than shown here.
Multiple heads: many specialists, not one generalist
One type of relationship is not enough. The word “bank” might connect to “river” through one relationship and to “money” through another. So the Transformer runs attention multiple times in parallel — these are called heads. Each head has its own Q, K, V weights, so each can learn a different type of relationship:
- Head 1: “which word is the subject of this verb?”
- Head 2: “what does this pronoun refer to?”
- Head 3: “which adjective describes this noun?”
- etc.
Modern models use 32+ heads per layer. Each generates its own attention distribution, and their outputs are mashed together into one final representation.
# Multi-head attention: multiple specialists in parallel
n_heads = 4
d_k = 3 # per-head dimension
# 4 heads, each with its own Q, K, V projections
heads = []
for h in range(n_heads):
W_q_h = np.random.randn(4, d_k) * 0.1
W_k_h = np.random.randn(4, d_k) * 0.1
W_v_h = np.random.randn(4, d_k) * 0.1
heads.append((W_q_h, W_k_h, W_v_h))
# Each head computes attention independently
all_head_outputs = []
for h, (W_q, W_k, W_v) in enumerate(heads):
Q_h, K_h, V_h = X @ W_q, X @ W_k, X @ W_v
out = attention(Q_h, K_h, V_h) # same attention() from above
all_head_outputs.append(out)
print(f"Head {h} output: {out.round(2)}")
# Concatenate all heads and project back
combined = np.concatenate(all_head_outputs, axis=1)
W_o = np.random.randn(n_heads * d_k, 4) * 0.1
final = combined @ W_o
print(f"\nFinal (all heads combined):\n{final.round(3)}")Each head runs the same attention mechanism but with its own learned weights. One might learn syntax, another pronouns, another copy behavior — specialization emerges from the training data, not from human labels.
Eight experts, each with a specialty
Each head applies the same QKV mechanism but with different learned W_q, W_k, W_v. Below, the grid shows what each head attends to for "dog" in the sentence "the tired old dog chased its tail." Toggle heads on/off. Try turning on just "Pronoun-resolver" and "Describer" to see how different heads capture different linguistic relationships.
The causal mask: when generating text, the model must predict the next token using only tokens that came before it. If position 5 could look at position 6, it would “cheat” by seeing the answer. A mask sets the attention scores for future positions to , so after softmax they become 0%.
def causal_attention(Q, K, V, mask=None):
scores = Q @ K.T / np.sqrt(K.shape[1])
if mask is not None:
scores = scores + mask # adding -inf zeros out future positions
return softmax(scores) @ V
# Mask: upper triangle = -inf (can't see future), lower = 0 (can see past)
seq_len = 4
mask = np.triu(np.full((seq_len, seq_len), -np.inf), k=1)
print(f"Causal mask:\n{mask}")
# With mask: token 3 can see tokens 0,1,2 but NOT token 3's own future
Q = np.random.randn(seq_len, 4)
K = np.random.randn(seq_len, 4)
V = np.random.randn(seq_len, 4)
out = causal_attention(Q, K, V, mask)
print(f"\nWith causal mask:\n{out.round(3)}")
# Without mask: every token sees every other token (for comparison)
out2 = causal_attention(Q, K, V)
print(f"\nWithout mask:\n{out2.round(3)}")No peeking at future tokens
The attention grid below is a heatmap: row = token looking, column = token being looked at. The upper-right triangle (future tokens) is dark — those scores are masked out.
Simple
Attention lets a token borrow information from other tokens. The borrowing amount is learned from query-key similarity.
Practical
Attention is why the model can connect pronouns, copy names, follow instructions, and use examples from the prompt.
Advanced
Attention is content-addressable memory: queries retrieve values through keys, then write the retrieved mixture back into the residual stream.
Is attention the same as explanation?
Not exactly. High attention means one token's value contributed to another's representation in that head. But MLPs and later layers can transform or override it, so attention alone doesn't explain the full answer.
Why does attention get expensive for long prompts?
Every token compares against all previous tokens. For N tokens, that's roughly N² comparisons. Long contexts (128K tokens) need careful engineering and caching to stay fast.
What is the KV cache?
During generation, old tokens don't change. The model stores their K and V vectors, then only computes the new token's Q and compares it against the cache. This makes generation much faster.
Why multiple heads instead of one big head?
Different heads can learn different patterns in parallel: one tracks grammar, another copies names, another connects pronouns. Their outputs are combined into one rich representation.
Can attention look at future tokens?
During training, no — a mask blocks future positions. During inference, there's no future (tokens are generated one at a time). In BERT-style models, attention is bidirectional (every token sees all others), which is better for understanding but can't generate text.
What is cross-attention?
In encoder-decoder models (like T5), the decoder's attention uses its own Q but the encoder's K and V. This lets the model "read" the input while generating. In GPT-style models, there's no cross-attention — everything is self-attention.
How do attention patterns change across layers?
Early layers focus on nearby words (local grammar). Middle layers connect related tokens across the whole sequence (meaning, references). Late layers prepare the final prediction. The model builds understanding from local → global as it goes deeper.
How attention weights are trained
The three projection matrices are learned through backpropagation through softmax — the same gradient descent as every other parameter, but with a unique challenge.
During the forward pass, attention computes:
When the loss gradient arrives at the attention output, it must flow backward through three distinct paths:
-
Through (simplest): the gradient tells “the value you produced for token 3 was multiplied by a large attention weight from token 5 — that weight made your contribution to the error large.” adjusts its rows so tokens that should not be attended to produce smaller or less relevant value vectors.
-
Through the attention matrix
A(trickier): the softmax couples all tokens — increasing one weight forces others to decrease (since they sum to 1). This means and learn a push-pull dynamic: if the model repeatedly predicts “sat” after “cat” but gets it wrong, the gradient flows back through the softmax to nudge of “cat” and of “sat” to produce a higher dot product, while pushing down the keys of irrelevant tokens. -
Competition between heads: each head’s and receive gradients that discourage redundancy — if two heads attend to the same pattern, their outputs are near-identical and the later projection can’t use both. The training dynamics push heads into complementary specializations automatically.
The full formula involves three chained matrix derivatives, but the intuition: weights that produce attention scores closer to the correct blend of context get rewarded; weights that amplify irrelevant context get penalized.
How attention behaves during inference
During generation, attention works differently than during training:
- Training: the model sees the full sequence at once. Attention scores are computed for every pair of tokens simultaneously (masked so tokens can’t peek ahead).
- Inference: the model generates one token at a time. At step 5, it has only 5 tokens. The newest token computes Q against all previous K, but those K don’t change — they were computed at earlier steps.
This is where the KV cache comes in. Instead of recomputing K and V for every token at every step, the model stores them:
- Token 1: compute , cache them. Generate token 2.
- Token 2: compute , retrieve cached . Compute score, retrieve cached for blending. Generate token 3.
- Token 3: compute , retrieve cached . Compute scores, retrieve . Generate token 4.
This reduces the per-step computation from to O(N) — linear in sequence length instead of quadratic. For a 128K-token context, this is the difference between milliseconds and seconds per token.
FlashAttention is an algorithmic optimization that makes the attention computation itself faster by being smarter about GPU memory access. Standard attention materializes the full N×N attention matrix in GPU memory, then reads it again for softmax and the weighted sum. FlashAttention instead processes attention in blocks that fit in fast GPU SRAM, never writing the full matrix to slow HBM. This makes attention 2–4× faster and uses much less memory — which is why it enabled the jump from 4K to 128K+ context lengths in production models. Almost every modern LLM training and inference stack uses FlashAttention or a variant of it.
The trained attention heads behave differently at inference time too: heads that learned syntactic patterns (e.g., “attend to the subject of this verb”) apply those patterns to whatever prompt you give, even if they’ve never seen that sentence before. Heads that learned copying behavior can attend to any token in the prompt and copy its value, which is how in-context learning works — the prompt’s examples become values that attention heads can retrieve.
Connection: attention moves information between tokens. But after a token gathers context, it still needs private computation: classify, combine, activate stored patterns, and decide what to write back. That is the MLP and residual stream.
06 · MLP & residual stream
Knowledge storage and the highway through the model
Attention is group work — tokens exchange information. The MLP is individual work — each token processes what it learned on its own.
Think of it like studying: attention is the group discussion where you hear others’ ideas; the MLP is the quiet desk time where you make sense of what you heard and connect it to what you already know.
The MLP has two linear layers with a bend in between:
First, the token’s vector is expanded into a wider space (4×–8× wider) — like taking notes and spreading them out on a bigger desk to see patterns. Then the activation function filters out noise (negative values become zero). Then it’s compressed back to the original size.
Modern LLMs use SwiGLU, which adds a third “gate” projection that learns what to let through and what to block — like having a smart filter that decides which notes are worth keeping.
def mlp_forward(x, W1, b1, W2, b2):
# Expand: project into wider space (4x)
hidden = x @ W1 + b1
hidden = np.maximum(0, hidden) # ReLU: kill negatives
# Contract: project back to original size
return hidden @ W2 + b2
def swiglu_forward(x, W1, W3, W2):
# Gate: one path computes, another controls flow
hidden = x @ W1
gate = x @ W3
gate = 1 / (1 + np.exp(-gate)) # sigmoid: values between 0-1
gated = hidden * gate # element-wise multiply
return gated @ W2
# Toy example: 4-dim input → 8-dim hidden → 4-dim output
d_model, d_ff = 4, 8
x = np.array([0.5, -0.2, 0.8, 0.1])
W1 = np.random.randn(d_model, d_ff) * 0.1
b1 = np.zeros(d_ff)
W2 = np.random.randn(d_ff, d_model) * 0.1
b2 = np.zeros(d_model)
result = mlp_forward(x, W1, b1, W2, b2)
print(f"MLP input: {x}")
print(f"MLP output: {result.round(3)}")The MLP stores most of the model’s knowledge — facts, grammar patterns, style. Attention decides what context to gather; the MLP decides what to do with it.
How the MLP stores and retrieves knowledge
Each token gets its own MLP computation. The hidden dimension is typically 4x wider than the model dimension — this expansion creates "room" for memorized patterns.
The residual stream: after the MLP (and after attention), the original input is added back:
This is the residual connection. It acts like a highway: information flows through the model even if individual layers have weak signals. Every token starts as its embedding at layer 0, and each layer adds a refinement. The final representation is the sum of contributions from every layer.
def layer_norm(x, eps=1e-5):
"""Stabilize values: center (subtract mean), scale (divide by std)."""
mean = x.mean(axis=-1, keepdims=True)
std = x.std(axis=-1, keepdims=True)
return (x - mean) / (std + eps)
def transformer_block(x, attn_weights, mlp_weights):
"""One complete transformer block = attention + MLP + residuals."""
# Pre-norm: normalize before each sublayer
xn = layer_norm(x)
# Attention (gather context from other tokens)
attn_out = attention(xn, xn, xn) # self-attention: Q, K, V all = xn
x = x + attn_out # residual: add back original
# MLP (process individually)
xn = layer_norm(x)
mlp_out = mlp_forward(xn, *mlp_weights)
x = x + mlp_out # residual: add back again
return x
# One token passes through one block
x = np.array([0.5, -0.2, 0.8, 0.1])
dummy_mlp = (np.random.randn(4, 8) * 0.1, np.zeros(8),
np.random.randn(8, 4) * 0.1, np.zeros(4))
result = transformer_block(x, None, dummy_mlp)
print(f"Input: {x}")
print(f"Output: {result.round(3)}")
# Notice: output ≈ input + small refinements from each sublayerNote: modern LLMs apply LayerNorm before each sublayer (attention and MLP), not after. This “pre-norm” configuration stabilizes training because the normalization happens on the clean input rather than on the potentially noisy output.
The residual stream: a highway through the model
One transformer block × N
This architecture — residual stream + attention + MLP — repeated N times (typically 12 to 96+ times) is the entire Transformer decoder. Every modern LLM is this, larger.
Simple
Attention gathers. The MLP thinks locally. The residual stream carries everything forward so layers can add refinements instead of replacing the whole state.
Practical
Most model parameters live in MLPs. Increasing MLP width often increases memorized patterns and task capacity, but also raises compute cost.
Advanced
The residual stream acts like shared working memory. Attention and MLP modules read from it, compute updates, and write additive deltas back into it.
Why does the MLP operate per token?
By the time the MLP runs, attention has already mixed in context from other tokens. The MLP then processes each token independently — like each student doing private study after a group discussion.
Is the MLP where facts are stored?
Many facts live in MLP weights, but they're not stored like rows in a database. They're distributed across many parameters and depend on attention bringing the right context into the residual stream.
What does LayerNorm do?
It rescales each token's numbers to stay in a stable range. Without it, values would explode or shrink as they pass through dozens of layers.
Why do modern MLPs use gates like SwiGLU?
Gates let the model learn what to let through and what to block. One path creates candidate information; another controls the gate. This improves quality enough to justify the extra weight matrix.
What is mixture-of-experts (MoE)?
MoE replaces one big MLP with many smaller "expert" MLPs plus a router that picks which experts to use for each token. Only a few activate per input, so MoE models can have many more total parameters without proportional compute cost. Powers Mixtral, DeepSeek, and reportedly GPT-4.
How much of a model's parameters are in the MLP vs attention?
In a typical block, the MLP has 2–3× more parameters than attention. For a 70B model, MLP layers account for about 2/3 of all parameters. The MLP is the model's "memory."
Can the residual stream "forget" information?
Not directly — it only adds, never deletes. But a later layer can cancel out an earlier contribution. In practice, information accumulates, which is why very deep models can get "cluttered."
Output projection: from residual to vocabulary
After the last transformer block, the residual stream holds a vector for each token. To predict the next token, feed the last token’s residual through the same embedding table (transposed) to get logits, then softmax to get probabilities.
This is called tied embeddings — the input lookup table doubles as the output projection. Every LLM uses this or a variant.
def unembed(residual, embed_table):
"""Turn the final token's residual into vocabulary probabilities."""
logits = residual @ embed_table.T # dot product against every word vector
return softmax(logits) # probabilities over vocabularyWhy the transpose? The embedding table has shape (vocab_size, d_model). We need (d_model,) @ (vocab_size, d_model).T = (vocab_size,) — one score per word in the vocabulary.
The complete forward pass: everything wired together
Now we have every piece. Let’s stitch them into one function that takes token IDs and returns next-token probabilities. This is a tiny, working transformer with 2 blocks, 2 attention heads, and 32-dimensional vectors — small enough to train on a laptop.
Every function below was introduced in earlier sections.
import numpy as np
import math
# ─── From Section 0 (starter toolbox) ───
def softmax(x):
ex = np.exp(x - np.max(x, axis=-1, keepdims=True))
return ex / ex.sum(axis=-1, keepdims=True)
def layer_norm(x, eps=1e-5):
return (x - x.mean(axis=-1, keepdims=True)) / (x.std(axis=-1, keepdims=True) + eps)
def relu(x):
return np.maximum(0, x)
# ─── From Section 4 (positional encoding) ───
def positional_encoding(seq_len, d_model):
PE = np.zeros((seq_len, d_model))
for pos in range(seq_len):
for i in range(d_model):
denom = 10000 ** ((2 * (i // 2)) / d_model)
if i % 2 == 0:
PE[pos, i] = math.sin(pos / denom)
else:
PE[pos, i] = math.cos(pos / denom)
return PE
# ─── From Section 5 (attention) ───
def attention(Q, K, V):
d_k = K.shape[-1]
scores = Q @ K.T / np.sqrt(d_k)
weights = softmax(scores)
return weights @ V
# ─── From Section 6 (MLP + block) ───
def mlp_forward(x, W1, b1, W2, b2):
hidden = relu(x @ W1 + b1)
return hidden @ W2 + b2
def transformer_block(x, W_q, W_k, W_v, W_o, W1, b1, W2, b2):
# Pre-norm → attention → residual
xn = layer_norm(x)
Q = xn @ W_q
K = xn @ W_k
V = xn @ W_v
attn_out = attention(Q, K, V)
attn_out = attn_out @ W_o # project concatenated heads back
x = x + attn_out
# Pre-norm → MLP → residual
xn = layer_norm(x)
mlp_out = mlp_forward(xn, W1, b1, W2, b2)
x = x + mlp_out
return x
# ─── NEW: unembedding (output projection) ───
def unembed(residual, embed_table):
logits = residual @ embed_table.T
return softmax(logits)
# ─── Complete forward pass ───
def forward(token_ids, embed_table, pos_enc, params, n_layers=2):
seq_len = len(token_ids)
d_model = embed_table.shape[1]
# Step 1: Embed + position
x = embed_table[token_ids] # (seq_len, d_model)
x = x + pos_enc[:seq_len] # add position fingerprint
# Step 2: N transformer blocks
for layer in range(n_layers):
p = params[layer]
x = transformer_block(x, p['W_q'], p['W_k'], p['W_v'],
p['W_o'], p['W1'], p['b1'], p['W2'], p['b2'])
# Step 3: Predict next token (use last position's residual)
probs = unembed(x[-1], embed_table)
return probs
# ─── Cross-entropy loss (Section 7) ───
def cross_entropy(probs, correct_idx):
return -math.log(max(probs[correct_idx], 1e-10))
# ─── Instantiate a tiny model ───
VOCAB = ["the", "cat", "dog", "sat", "mat", "run", "."]
vocab_size = len(VOCAB)
d_model = 32
n_layers = 2
d_k = 16
d_ff = 64
np.random.seed(42)
E = np.random.randn(vocab_size, d_model) * 0.1 # tied embeddings
PE = positional_encoding(20, d_model) # supports up to 20 tokens
params = []
for _ in range(n_layers):
params.append({
'W_q': np.random.randn(d_model, d_k) * 0.1,
'W_k': np.random.randn(d_model, d_k) * 0.1,
'W_v': np.random.randn(d_model, d_k) * 0.1,
'W_o': np.random.randn(d_k, d_model) * 0.1,
'W1': np.random.randn(d_model, d_ff) * 0.1,
'b1': np.zeros(d_ff),
'W2': np.random.randn(d_ff, d_model) * 0.1,
'b2': np.zeros(d_model),
})
# Test it: predict next token after "the cat"
ids = [0, 1] # "the cat"
probs = forward(ids, E, PE, params)
print(f"After 'the cat', next-word probabilities:")
for i, p in enumerate(probs):
print(f" {VOCAB[i]}: {p:.1%}")The next-token probabilities are random (weights are untrained). That’s the problem Section 07 solves.
Connection: we now have a complete, working (but untrained) forward pass. Every weight is random noise. Training is the process that adjusts those weights so the model's predictions become accurate.
07 · Training
Learning by predicting the next token
Every weight in the model starts as random noise. The model knows nothing — it’s as smart as a baby randomly babbling. How does it learn?
The training goal is embarrassingly simple: predict the next word. Given “the cat sat on the ___”, predict “mat”. That’s the entire objective.
For every position in a sentence, the model guesses — it assigns a probability to every word in its vocabulary. If the correct word gets high probability, good. If not, the model gets “penalized” and adjusts.
The penalty is cross-entropy loss:
Don’t fear the formula. It just means:
- If the model is 90% sure about the right word → loss ≈ 0.1 (small penalty)
- If the model is 90% sure about the wrong word → loss ≈ 2.3 (big penalty)
The model’s goal: minimize this loss across billions of sentences. That’s it.
import math
def cross_entropy(probs, correct_idx):
"""Penalize the model: low loss if correct token is likely, high if not."""
p_correct = probs[correct_idx] # probability assigned to correct word
return -math.log(max(p_correct, 1e-10)) # log(1)=0, log(0.1)=2.3, log(0.01)=4.6
# Example: model predicts next word after "the cat sat on the"
# Correct answer is "mat", model gives it 80% probability
probs = np.array([0.05, 0.80, 0.03, 0.02, 0.10]) # [the, mat, sat, dog, run]
correct_idx = 1 # "mat"
loss = cross_entropy(probs, correct_idx)
print(f"Correct = mat, P(correct) = {probs[correct_idx]:.0%}, loss = {loss:.3f}")
# If model was only 10% confident about "mat":
probs_bad = np.array([0.70, 0.10, 0.05, 0.05, 0.10])
loss_bad = cross_entropy(probs_bad, correct_idx)
print(f"Correct = mat, P(correct) = {probs_bad[correct_idx]:.0%}, loss = {loss_bad:.3f}")Next-token prediction in action
Below, the model processes "the cat sat on the" and predicts what comes next. The bars show probability assigned to each token in the vocabulary. Step through each position and watch how the loss changes — when the model is confident and correct, loss is low; when it's surprised, loss spikes.
Backpropagation: once we know the loss, we need to adjust every weight to reduce it. The key insight: for each weight, we calculate “if I nudge this weight up by a tiny bit, does the loss go up or down?”
Weights that caused more error get bigger adjustments. The calculation flows backward through the entire network — from output → MLP → attention → embeddings — assigning blame to each weight along the way. It’s like tracing a mistake backward through a chain of reasoning: “the output was wrong because the MLP gave bad info, which happened because the attention gathered wrong context, which happened because the embedding was unclear…”
# Training the real forward() with numerical gradients
# Numerical gradient: (f(x+h) - f(x-h)) / (2h)
# This estimates how each weight affects the loss by nudging it
# up and down and measuring the difference. Slow but exact.
def numerical_grad_params(params, E, PE, token_ids, correct_idx, h=1e-4):
"""Return gradient of loss w.r.t. every parameter, one at a time."""
base_loss = cross_entropy(forward(token_ids, E, PE, params), correct_idx)
grads = []
for layer_params in params:
layer_grads = {}
for key in layer_params:
original = layer_params[key].copy()
grad = np.zeros_like(original)
# Perturb each element and measure loss change
flat = original.ravel()
grad_f = grad.ravel()
for j in range(len(flat)):
flat[j] += h
loss_p = cross_entropy(forward(token_ids, E, PE, params), correct_idx)
flat[j] -= 2 * h
loss_m = cross_entropy(forward(token_ids, E, PE, params), correct_idx)
flat[j] += h # restore
grad_f[j] = (loss_p - loss_m) / (2 * h)
layer_grads[key] = grad
grads.append(layer_grads)
return grads
# ⚠ WARNING: this loop is SLOW — it calls forward() twice per parameter
# per step. We train only the embedding table E for demonstration.
# Real training uses automatic differentiation (PyTorch, JAX) which
# computes all gradients in one backward pass, not one param at a time.
learning_rate = 0.5
sentence_ids = [0, 1, 3, 4, 6] # "the cat sat mat ."
for step in range(20):
total_loss = 0
for i in range(len(sentence_ids) - 1):
context = sentence_ids[:i+1]
correct = sentence_ids[i+1]
# Forward
probs = forward(context, E, PE, params)
loss = cross_entropy(probs, correct)
total_loss += loss
# Numerical gradient
grads = numerical_grad_params(params, E, PE, context, correct, h=1e-4)
# Update weights
for layer in range(len(params)):
for key in grads[layer]:
params[layer][key] -= learning_rate * grads[layer][key]
if step % 5 == 0:
test_probs = forward([0, 1], E, PE, params) # "the cat" → ?
print(f"step {step}: loss = {total_loss:.3f}, "
f"P('sat'|'the cat') = {test_probs[3]:.1%}")Notice: each training step calls forward() twice for every single weight — thousands of forward passes. This is why it’s called “numerical” and not used at scale. Real training uses backpropagation, which computes all gradients in a single backward pass by walking the chain rule backward through the computation graph. But the idea is identical: measure how nudging each weight changes the loss, then nudge in the direction that reduces it.
Error flows backward through the whole model
Forward: token → embedding → attention → MLP → logits → loss. Backward: the gradient flows from the loss back through every layer. Click step by step.
Training at scale: real LLM training involves:
- Batches: processing thousands of sequences at once, not one at a time
- Mixed precision: using 16-bit numbers instead of 32-bit for speed
- Learning rate schedule: starting with large weight updates and gradually shrinking them — like coarse-tuning then fine-tuning. A warmup phase slowly increases the rate for the first ~2000 steps (when gradients are chaotic), then cosine decay smoothly decreases it to near zero.
- Checkpointing: saving the model every few hours (training can and will crash)
- Data parallelism: splitting work across hundreds of GPUs, each computing gradients, then averaging them
Training infrastructure at scale
Simple
Training is repeated correction. Predict next token, measure error, move weights a tiny amount, repeat billions of times.
Practical
The hard part is not the objective; it is scale: data cleaning, GPU memory, stability, parallelism, checkpoints, and cost control.
Advanced
Backpropagation computes credit assignment through the whole graph. Optimizers like AdamW convert noisy gradients into stable parameter updates.
Why does next-token prediction learn reasoning?
To predict text from books, code, math, and conversations, the model must learn the patterns behind that text. Reasoning-like patterns emerge because they help predict reasoning-heavy parts of the training data more accurately.
What is a logit?
The raw score for each possible next word before softmax converts them into percentages. Higher logit = more likely.
Why not train on only high-quality data?
Diversity matters too. A model trained only on polished text would lack code, slang, multilingual patterns, and edge cases. Modern datasets balance scale with filtering.
What can go wrong during training?
Loss spikes, bad data, hardware failures, duplicate data, and numerical overflow can all damage a run. Large training is as much systems engineering as model design.
What is a tokenizer's role in training quality?
If the tokenizer splits words poorly, the model wastes capacity reconstructing common words from fragments. A bad tokenizer can make a model unable to spell, count, or handle code — all because the input representation is wrong, not the architecture.
Why does data quality matter more than quantity (after a point)?
After a few trillion tokens, adding more low-quality data gives diminishing returns or even hurts — the model starts memorizing noise instead of learning patterns. A smaller, cleaner dataset often outperforms a larger, noisier one.
Connection: pre-training creates a strong continuation engine. To make it useful as an assistant, we need to control how it is prompted, fine-tuned, sampled, and aligned with the behavior we want.
08 · Using the trained model
From next-token predictor to assistant
After pre-training, the model is an expert at continuing text. Ask it a question and it will ramble — it was trained to predict the next word, not to answer helpfully.
To turn it into an assistant, we do fine-tuning: continued training on curated question-answer pairs.
Only the answer part gets penalized — the question is just context. The learning rate is tiny (0.1× or less) because we want to gently polish the model’s behavior, not retrain it from scratch.
# Generation loop: the model writes one word at a time
def generate(model, prompt_ids, n_tokens=5, T=1.0):
generated = list(prompt_ids)
for _ in range(n_tokens):
probs = model(generated) # predict next word probabilities
probs = softmax_with_temp(np.log(probs + 1e-10), T) # apply temperature
next_id = np.random.choice(len(probs), p=probs) # sample
generated.append(next_id)
if next_id == 0: # <end> token
break
return generated
# Toy example: a model that predicts next word based on last word
toy_vocab = ["<end>", "the", "cat", "sat", "mat"]
def toy_model(context):
# In a real model, this would be the full transformer forward pass
# Here we just return fixed probabilities for demo
return np.array([0.01, 0.40, 0.30, 0.20, 0.09])
prompt = [1] # start with "the"
output = generate(toy_model, prompt, n_tokens=4)
print(f"Generated: {' '.join(toy_vocab[i] for i in output)}")This is autoregressive generation: each new word becomes part of the input for the next prediction. The model never goes back to rewrite — it’s like writing a sentence one word at a time, never erasing.
Step by step: how a trained model generates text
How to control what the model says (inference techniques)
The model assigns probabilities to every possible next word. How you pick from those probabilities changes the output:
- Temperature (T): controls randomness. Low T (0.1) = always picks the most likely word (boring but safe). High T (1.5) = sometimes picks less likely words (creative but risky). T=1 is the default.
- Top-k: only consider the k most likely words, ignore the rest.
- Top-p: consider words until their combined probability reaches p (e.g., 90%), cut off the long tail of unlikely words.
# Temperature: adjust how "sharp" the probability distribution is
def apply_temperature(logits, T):
return logits / T # divide raw scores by T
def softmax_with_temp(logits, T=1.0):
logits = logits / T # apply temperature
ex = np.exp(logits - np.max(logits))
return ex / ex.sum()
# Example: raw scores (logits) for next word
logits = np.array([3.2, 1.1, 0.5, -0.2]) # raw: higher = more likely
for T in [0.1, 1.0, 2.0]:
probs = softmax_with_temp(logits, T)
print(f"T={T:.1f}: {[f'{p:.0%}' for p in probs]}")
# T=0.1: [~100%, ~0%, ~0%, ~0%] — almost always picks top word
# T=1.0: [76%, 14%, 8%, 2%] — balanced
# T=2.0: [48%, 22%, 16%, 14%] — more random
# Top-k: keep only the k most probable tokens, zero out the rest
def top_k_sampling(probs, k=2):
top_indices = np.argsort(probs)[-k:] # indices of k highest
new_probs = np.zeros_like(probs)
new_probs[top_indices] = probs[top_indices]
return new_probs / new_probs.sum()
# Top-p (nucleus): keep tokens until cumulative probability reaches p
def top_p_sampling(probs, p=0.9):
sorted_idx = np.argsort(probs)[::-1]
cumsum = np.cumsum(probs[sorted_idx])
cutoff = np.searchsorted(cumsum, p) + 1
new_probs = np.zeros_like(probs)
new_probs[sorted_idx[:cutoff]] = probs[sorted_idx[:cutoff]]
return new_probs / new_probs.sum()
probs = np.array([0.50, 0.20, 0.15, 0.10, 0.05])
print(f"\nTop-2: {top_k_sampling(probs, 2).round(2)}") # keeps only top 2
print(f"Top-p (0.9): {top_p_sampling(probs, 0.9).round(2)}") # keeps ~90% prob massThree ways to steer the model (without retraining)
- Fine-tuning: change the weights by training on curated examples
- Prompting: don’t change weights — just write a good instruction. The model figures out what you want from the context alone. This is called in-context learning — giving examples in the prompt works because attention can use those examples.
- Chain-of-thought (CoT): ask the model to “think step by step.” The intermediate reasoning steps give attention more material to work with, dramatically improving math and logic problems.
- RAG (Retrieval-Augmented Generation): before answering, look up relevant documents and inject them into the prompt. The model then answers based on real, up-to-date info instead of relying on its imperfect memory. This is how production chatbots stay factual.
Simple
Inference is repeated next-token prediction. The model writes one token, appends it to the prompt, then predicts again.
Practical
Prompting changes context. Fine-tuning changes weights. Sampling changes which likely continuation gets chosen.
Advanced
Assistant behavior is a stack: pre-training capability, supervised fine-tuning format, preference tuning, system prompts, tools, and decoding policy.
Why does the model sometimes hallucinate?
The model is trained to produce plausible text, not to check facts. If it lacks context or is uncertain, it will confidently say something wrong. RAG (looking up documents) and verification tools help reduce this.
What is the difference between fine-tuning and RLHF?
Fine-tuning teaches the model to imitate desired examples. RLHF teaches it to prefer outputs that humans ranked higher. A newer method, DPO (Direct Preference Optimization), skips the separate reward model and directly trains on preferred vs rejected outputs — it's simpler and has become the default for many open-weight models.
Why does prompt wording matter so much?
The prompt is part of the input sequence. Different wording activates different patterns in the model. A clearer prompt reduces ambiguity and puts the model in the right "mode."
What is the context window, really?
The maximum number of tokens the model can see at once. Anything beyond that is invisible unless summarized or retrieved.
Why does the model sometimes refuse to answer?
Safety training teaches the model to decline harmful requests. But the boundaries are fuzzy — the model learned patterns like "decline if the request looks like X," and sometimes X is too broad.
Can a model "learn" during a conversation?
Not in the weight-update sense. But it can effectively learn within the context window: examples, corrections, and instructions given earlier become part of the input that later tokens attend to. This is in-context learning.
Connection: once a model can generate useful text, the next engineering question is trust. Interpretability asks what internal components are doing, why failures happen, and whether we can detect or steer behavior before deployment.
09 · Interpretability
Looking inside the black box
After all this, the model is just a bunch of numbers — weights and activations. But patterns emerge that we can visualize and interpret.
Inspecting our own model
We built a tiny transformer in Section 06. Let’s modify forward() to also return the attention weights — the “who-looks-at-whom” scores — and print them.
def forward_with_attn(token_ids, embed_table, pos_enc, params, n_layers=2):
"""Same as forward(), but also returns attention weights per layer."""
seq_len = len(token_ids)
d_model = embed_table.shape[1]
x = embed_table[token_ids]
x = x + pos_enc[:seq_len]
all_attn = []
for layer in range(n_layers):
p = params[layer]
xn = layer_norm(x)
Q = xn @ p['W_q']
K = xn @ p['W_k']
V = xn @ p['W_v']
d_k = K.shape[-1]
scores = Q @ K.T / np.sqrt(d_k)
attn_weights = softmax(scores) # (seq_len, seq_len)
all_attn.append(attn_weights)
attn_out = attn_weights @ V
attn_out = attn_out @ p['W_o']
x = x + attn_out
xn = layer_norm(x)
x = x + mlp_forward(xn, p['W1'], p['b1'], p['W2'], p['b2'])
probs = softmax(x[-1] @ embed_table.T)
return probs, all_attn
# Inspect: "the cat sat mat ."
ids = [0, 1, 3, 4, 6]
probs, attn_layers = forward_with_attn(ids, E, PE, params)
VOCAB = ["the", "cat", "dog", "sat", "mat", "run", "."]
words = [VOCAB[i] for i in ids]
for layer_idx, attn in enumerate(attn_layers):
print(f"\nLayer {layer_idx} attention matrix:")
print(f" " + " ".join(f"{w:>6s}" for w in words))
for i, word in enumerate(words):
row = " ".join(f"{attn[i,j]:6.2f}" for j in range(len(ids)))
print(f" {word:6s} {row}")Each cell shows how much one token (row) attends to another (column). Values near 1.0 mean “copies this token’s information”; near 0.0 means “ignores it.” In an untrained model (our current state), the distribution is roughly uniform — attention hasn’t learned to specialize yet. After training, specific patterns emerge.
Attention patterns
Trained attention heads show clear, interpretable patterns. Some heads look at the previous word (local context). Some look at the first word (global summary). Some track whether two words belong together. These patterns are remarkably consistent across different models.
What different attention heads actually look at
The grid below shows attention patterns from a small trained model processing "the dog chased its tail." Each head has developed its own specialization.
Layer-wise progression
Early layers (1-4) focus on local, syntactic patterns: neighboring words, parts of speech. Middle layers (5-16) do the heavy lifting of semantic understanding: coreference resolution, subject-verb relationships. Late layers (17+) prepare the output, focusing on what token comes next.
Neuron activation
Individual neurons in the MLP layers can respond to specific concepts. Some neurons activate for “Python code,” others for “sports,” others for “negative sentiment.” This is called interpretability — the active research field trying to understand what individual model components do.
Recent breakthrough: sparse autoencoders (SAEs) have shown that individual neurons often respond to multiple unrelated concepts at once — like one neuron caring about both “DNA sequences” and “code errors.” SAEs break these tangled activations into cleaner, single-concept features. Anthropic’s 2024 work on Claude found millions of interpretable features this way.
Simple
Interpretability tries to map model internals to human concepts: heads, neurons, circuits, features, and failure modes.
Practical
It helps debug models, design evaluations, understand jailbreaks, and explain why a system behaves differently across prompts.
Advanced
Mechanistic interpretability studies circuits: small groups of components that implement a behavior through activations and weights.
What is a circuit in an LLM?
A circuit is a group of heads and neurons that work together for one behavior — like copying a name from the prompt, or tracking whether a pronoun matches a noun.
Why is interpretability hard?
One neuron can participate in many concepts at once (like one person having multiple jobs). Plus, behavior spreads across many layers, so looking at one piece in isolation is misleading.
Can we prove a model is safe by interpreting it?
Not yet. We can find evidence and run tests, but models are too large and tangled for complete safety proofs with current tools.
How does this connect back to training?
Training creates these circuits automatically. Interpretability reverse-engineers what training discovered. Each circuit we find is one solution the model stumbled upon to reduce prediction error.
What is superposition?
The model stores more concepts than it has dimensions by stacking them in overlapping directions — like storing 10 files in a cabinet with 5 folders. The concepts interfere with each other, making interpretation harder.
Can interpretability detect deception?
In theory, a deceptive model might have internal circuits that separate "what I should say" from "what I really think." In practice, current tools aren't powerful enough to reliably detect this — but researchers are working on it.
10 · Encoder & Decoder
The original Transformer had two halves — cross-attention connects them
All the attention we’ve discussed so far is self-attention (tokens attending to other tokens in the same sequence). But the original “Attention Is All You Need” paper used two stacks: an encoder and a decoder, connected by cross-attention.
Encoder: understand the input
The encoder reads the entire input sequence (e.g. an English sentence) using bidirectional self-attention — every token can see every other token, including future ones. This is ideal for understanding: to know what “it” refers to, you want to see the full sentence, not just the words before “it.”
Each encoder layer: Self-attention → FFN (6 layers in the original paper).
Decoder: generate the output
The decoder generates the output sequence (e.g. a French translation) one token at a time. It has two attention sublayers per layer:
- Masked self-attention: each token can only see itself and earlier tokens (no peeking ahead — the causal mask we covered in §05).
- Cross-attention: the decoder’s Query attends to the encoder’s Key and Value. This is how the output side “reads” the input while writing.
Each decoder layer: Masked self-attention → Cross-attention → FFN (6 layers in the original paper).
Cross-attention explained
Cross-attention is what makes translation work: the decoder asks “which parts of the input sentence are relevant to the word I’m about to write?” and retrieves information from the encoder’s representation.
The key difference from self-attention: Q comes from the decoder, but K and V come from the encoder. The encoder output is fixed (the whole input is processed once); the decoder queries it at each generation step.
Encoder-decoder with cross-attention
Encoder ×6 — reads input
Decoder ×6 — writes output
Simple
Encoder reads text all at once. Decoder writes text one word at a time, looking back at the encoder for context.
Practical
Cross-attention is why T5, BART, and early translation models outperform decoder-only on tasks requiring full input comprehension.
Advanced
Cross-attention queries the encoder's output at every decoder layer. The encoder representation is computed once; the decoder's queries evolve layer by layer.
Why is encoder self-attention bidirectional?
Understanding requires full context. To know what "it" refers to in "the cat sat on the mat and then it slept," you need to see the entire sentence, including "slept" after "it." Bidirectional attention gives the encoder this full view.
Does cross-attention add many parameters?
Yes — each cross-attention sublayer adds its own W_q, W_k, W_v and W_o matrices. This is roughly 25% more parameters per decoder layer compared to self-attention alone, which is one reason decoder-only models are more parameter-efficient for their size.
Do modern models ever use cross-attention?
Yes! Multimodal models (LLaVA, GPT-4V) use cross-attention to connect vision encoders to language decoders. Tool-use and RAG systems can be seen as a form of cross-attention too — retrieved documents are "encoded" and then attended to during generation.
Is encoder-decoder always better than decoder-only?
No. Decoder-only models handle arbitrary tasks (chat, coding, reasoning) naturally. Encoder-decoder models excel when the task has a clear input→output structure. For most modern use cases, decoder-only wins on flexibility and scaling.
How does cross-attention differ from self-attention during inference?
The encoder runs once on the full input. The decoder then generates token by token — at each step, it queries the same fixed encoder K,V cache. This is efficient because the encoder runs once, not per token.
Can a decoder-only model simulate cross-attention?
Yes — by prepending the input to the output (e.g. "English: the cat sat\nFrench: le chat s'est assis"), the decoder can attend to the input through self-attention. This is called prefixLM and is how T5 works in decoder-only mode. It's less efficient than true cross-attention but simpler to implement.
The Transformer vs. the previous best
And the cost? The big model trained in about 3.5 days on 8 GPUs — a small fraction of what competing models needed. The base model reached state-of-the-art in roughly 12 hours. Crucially, it also generalized: applied to a totally different task (English grammar parsing), it again performed near the top without special tuning.
11 · Why it changed everything
The paper that quietly started a revolution
In 2017 this looked like a smart improvement to machine translation. Today we know it was the foundation of nearly all modern AI.
Because the Transformer dropped the “one word at a time” rule, it could be trained on enormous amounts of text using thousands of processors at once. That scalability is exactly what made today’s large language models possible.
The “GPT” in ChatGPT stands for Generative Pre-trained Transformer. BERT, the model behind years of Google Search improvements, is a Transformer. So are the models that generate images, write code, transcribe speech, and fold proteins. They are all descendants of the simple idea in this paper: let the pieces of your input pay attention to each other, and that is enough.
Attention, it turned out, really was all you needed.
Now: theory is done — encoder-decoder, decoder-only, paper results, the revolution. Time to build your own attention head, see exactly how QKV works with real numbers, and run a full tiny transformer in your browser.
Practical: Build your own attention
See it, design it, tweak it
1. Attention with real numbers
Below is a small sentence with mock embeddings. Pick a query word and watch the dot product, scaling, and softmax calculation unfold — exactly as it happens inside a real model.
Pick a query word
Each word has a 4-dim embedding. Click a word to see its Query projected via W_q, then compute dot product against every word's Key (via W_k). The scores are scaled by √d, then softmax gives the final attention distribution.
2. Design your own attention head
Here you can create an attention pattern by hand and test it against the model’s learned attention — see what happens when you force a specific rule.
Force a focusing rule and score it
Pick a preset or build your own with the sliders below. The pattern is previewed on the canvas. Click Score this head to compare its prediction loss against the model's learned attention. Lower loss = better head.
3. Sequential vs parallel reading
Watch how an RNN must crawl through a sentence one word at a time, while a Transformer reads every word in parallel — the core insight behind the paper’s title.
Reading one word at a time vs. all at once
Watch how an old model (RNN) must crawl through a sentence in order, while a Transformer looks at every word in the same instant.
4. Full interactive transformer
This is the whole journey, running for real in your browser on text you provide. A genuine tiny Transformer: it tokenizes your text, learns embeddings, runs multi-head masked attention, pre-trains itself to predict the next word, and then gets fine-tuned into a model that answers questions. Nothing is pre-loaded — every weight is learned live, in front of you.
Go through the steps in order. Each one unlocks the next.
Give the model something to read
This is the raw material the model learns language from (its "pre-training data"). Keep sentences simple and repetitive so a tiny model can find the patterns. Edit it freely, then build the dataset.
Turn words into numbered tokens
The model can't read letters, so every unique word becomes a numbered token. The full list of tokens is the vocabulary. Three special tokens are added too: <q> and <a> (to mark questions and answers later) and <end>.
Give every token a vector of numbers
Each token id is mapped to a row of numbers (its embedding). Below is the entire embedding table: one row per word, one column per dimension, colour = value. Right now it is random noise — the model knows nothing yet. Watch this same picture after training to see meaning appear.
Stamp each slot with its position
Because the model reads all words at once, we add a unique wave-based "position fingerprint" to each word's vector so order is preserved. Type a sentence; each column below is the fingerprint added at that position.
Watch the words look at each other
Here is one real forward pass: the model computes Query, Key and Value for every word, scores them, masks the future, and softmaxes into the attention grid below (row looks at column). This model has 8 heads running in parallel. Pick any one to see it alone, or select several to see their combined view. Each head is named by what it actually does (the names become meaningful after you pre-train in Step 6).
Design your own head
Instead of letting the model learn where to look, you can force a focusing rule and test it. Pick a preset or build your own with the sliders, preview the pattern, then score it: the model runs with every head forced to use your rule, and we compare its prediction error to the model's own learned attention. Lower loss = better head.
Teach it to predict the next word
Now the model reads your text over and over, each time nudging its weights to better predict the next word. The loss (how wrong it is) should drop fast. On the right, watch it try to continue a sentence and get better.
It can autocomplete — but it can't answer
Your pre-trained "base model" is a talented autocomplete. Give it a few words and it continues them. But ask it a direct question and it just rambles, because it was only ever taught to continue text, not to answer. That is exactly why the next step exists.
Show it examples of good answers
To turn the autocomplete into an assistant, we give it example question | answer pairs. The model is wrapped in a format it can recognise: <q> question <a> answer <end>. One pair per line, with a vertical bar between question and answer.
Continue training — on the task this time
Fine-tuning does not start from scratch. It keeps all the language the base model already learned and gently nudges it (a smaller learning rate) to follow the question-answer format. The loss on the answers should plunge toward zero.
Ask it a question
The same model that could only autocomplete now answers in the format it was fine-tuned on. Try the questions you trained it on, and then try slight variations to see how much (or how little) a tiny model can generalise.
This is a real but deliberately small model (32-dim embeddings, 2 heads, one Transformer block) trained on a handful of sentences, so it memorises more than it generalises. The exact same machinery — tokenize, embed, attend, pre-train, fine-tune — scaled to billions of weights and much of the internet, is what produces ChatGPT and its peers. You just did the whole pipeline, end to end.
The full pipeline, in one breath
Internet text → broken into tokens → each token gets a vector (embedding) + a position fingerprint (sine/cosine waves) → tokens exchange info via attention (QKV) → each token privately processes what it learned (MLP) → repeat 32–96 times → probability distribution over next word → fine-tuned on Q&A data → your assistant.
Every number inside started as random noise. Every behavior — grammar, facts, reasoning — emerged from the single goal of predicting the next word better.
The only thing between random noise and an LLM is a lot of data, a lot of compute, and the humble equation $y = W \cdot x + b$ repeated millions of times.
What next?
- Read the original Transformer paper: “Attention Is All You Need” (Vaswani et al., 2017)
- Anthropic’s Transformer Circuits — deep dives into what individual attention heads and MLP neurons actually do
- 3Blue1Brown’s “But what is a neural network?" — the best visual intuition for gradient descent and backpropagation
- nanoGPT — Andrej Karpathy’s minimal GPT implementation you can train yourself
- Lilian Weng’s “Large Language Models” — comprehensive survey of architecture variants
- For a visual guide to the attention mechanism specifically, see the dedicated interactive walkthrough