Skip to content

Instantly share code, notes, and snippets.

@quanhua92
Last active June 29, 2026 14:18
Show Gist options
  • Select an option

  • Save quanhua92/91ddc1a548365e83eca00ca61b66ff67 to your computer and use it in GitHub Desktop.

Select an option

Save quanhua92/91ddc1a548365e83eca00ca61b66ff67 to your computer and use it in GitHub Desktop.
LLM Fundamentals Curriculum (Units 1.1-1.9)

1.1 The Transformer in One Diagram

The map. After this, "the model" is never a black box again. Prerequisites: none · Pays off in: everything · Video: T1


The One Diagram

The complete forward pass of a GPT-style transformer — from input token IDs at the bottom to the output next token at the top.


Step 1: Read (Absorb)

Bundles read:

  • llm/TOKENIZATION.md
  • llm/CAUSAL_MASK.md
  • llm/MLP_ACTIVATION.md
  • llm/NORMALIZATION.md
  • llm/SAMPLING.md

Canonical sources:

  • Alammar, The Illustrated Transformer
  • 3Blue1Brown, But what is a GPT?
  • Karpathy, Let's build GPT
  • "Attention Is All You Need" §3 (light skim)

Step 2: Research (Web-Verify)

  • Architecture = embed → N× blocks → final norm → LM head (unembedding) → softmax. Verifies: Alammar The Illustrated Transformer ("The original Transformer consists of an encoder stack and a decoder stack... Each encoder block consists of a self-attention layer followed by a feed-forward neural network") + Karpathy Let's build GPT ("The Transformer block... essentially consist of two main parts: communication (Self-Attention) and computation (Feed-Forward Network)").
  • One block = {attention, MLP, 2 residual connections, 2 pre-norms}. Verifies: "Attention Is All You Need" paper ("Each layer has two sub-layers: 1. Multi-Head Self-Attention... 2. Position-wise Feed-Forward Network... Each sub-layer uses a residual connection followed by layer normalization") + Llama-3 config ("Pre-Normalization (RMSNorm)... Multi-Head Self-Attention with GQA... Feed-Forward Network (FFN)").
  • "Knowledge" lives in MLP weights; "context/reasoning" lives in attention. Verifies: ROME (Rank-One Model Editing) and MEMIT papers ("factual associations... are stored primarily within the MLP (Multi-Layer Perceptron) modules of Transformer layers. In this view, MLP layers act as key-value memories... the Attention mechanism acts as a retrieval and integration system").

The Forward Pass — Stage by Stage

A transformer is an assembly line. Raw text enters at the bottom, passes through a fixed sequence of stages, and one token comes out the top. Then the whole thing repeats.

flowchart TD
    A["🔤 Raw Text"] --> B["Tokenizer (BPE)"]
    B -->|"token IDs [1, seq_len]"| C["Embedding Table"]
    C -->|"[1, seq_len, d_model]"| D["× N Transformer Blocks"]

    subgraph BLOCK ["One Transformer Block (repeated N times)"]
        direction TB
        N1["RMSNorm"] --> ATT["Multi-Head Self-Attention"]
        ATT --> ADD1["Add (residual)"]
        N2["RMSNorm"] --> MLP["MLP (SwiGLU)"]
        MLP --> ADD2["Add (residual)"]
    end

    D -->|"[1, seq_len, d_model]"| E["Final RMSNorm"]
    E --> F["LM Head (Unembedding)"]
    F -->|"[1, seq_len, vocab_size]"| G["Logits"]
    G --> H["Sampling (temp → top-p → pick)"]
    H -->|"1 token ID"| I["🎯 Next Token"]
    I -.->|"append & repeat"| A

    style A fill:#7c3aed,color:#fff
    style B fill:#7c3aed,color:#fff
    style C fill:#9333ea,color:#fff
    style D fill:#1e3a5f,color:#fff
    style E fill:#16a34a,color:#fff
    style F fill:#9333ea,color:#fff
    style G fill:#6366f1,color:#fff
    style H fill:#dc2626,color:#fff
    style I fill:#f59e0b,color:#000
    style N1 fill:#16a34a,color:#fff
    style N2 fill:#16a34a,color:#fff
    style ATT fill:#2563eb,color:#fff
    style MLP fill:#ea580c,color:#fff
    style ADD1 fill:#16a34a,color:#fff
    style ADD2 fill:#16a34a,color:#fff
Loading

Stage 0: Tokenization (Entry Point)

The model never sees text — only integer token IDs.

A tokenizer (BPE — Byte Pair Encoding) chops raw text into subword pieces and maps each piece to an integer ID via a fixed vocabulary table.

Concept Detail
What is a token? A subword piece — not a word, not a char, something in between learned statistically
Pipeline Raw text → Pre-tokenize (regex split) → BPE merges → Token ID lookup
Leading space rule The pre-tokenizer glues the leading space onto the word: " token" → [4037] vs "token" → [5963]different IDs!
~4 chars ≈ 1 token Average for English. Breaks hard for CJK (~3 bytes per char ≈ up to 3 tokens)
Example "lowest"['low', 'est'][11, 13] (merges replayed in learned rank order)

Stage 1: Embedding Table

token ID  →  look up row in table  →  dense vector [d_model]

The embedding table is a matrix of shape [vocab_size, d_model]. Each token ID indexes one row — its embedding vector. This vector is the token's initial "meaning" in a high-dimensional space.

For Llama-3-8B: vocab_size = 128,256 · d_model = 4,096 → embedding table = ~0.5B params.

Stage 1.5: The Word Order Problem (Positional Embeddings)

Notice that the embedding table alone treats the text like an unordered "bag of words"—it knows what the words are, but it has no idea where in the sentence they appear. Without fixing this, "The dog bit the man" and "The man bit the dog" would look mathematically identical to the attention mechanism.

To solve this, transformers inject Positional Embeddings:

  1. Absolute Positional Embeddings (The Classic Approach)
    • Used by: GPT-2, original Transformer.
    • How it works: Right after Stage 1, the model takes the word's embedding vector and physically adds a second "position vector" to it (vector("apple") + vector(position=3)). These position vectors are typically generated using overlapping sine and cosine waves, giving every absolute position a unique, continuous mathematical fingerprint.
  2. Rotary Position Embeddings / RoPE (The Modern Standard)
    • Used by: Llama 3, Mistral, Gemma.
    • How it works: Instead of adding a static position vector at the beginning, RoPE waits until the Self-Attention step (Stage 2). It takes the token's representation and literally rotates it in 2D space by an angle proportional to its position (e.g., position 1 rotates by 10°, position 2 by 20°).
    • Why it's better: Because it's based on rotation angles, the math perfectly preserves relative distance. The angle between position 2 and position 5 is the exact same as the angle between position 12 and position 15. This helps the model generalize to longer sequences much better than absolute positions.

Stage 2: The Transformer Block (× N)

This is the heart. The same block is repeated N times (32 for Llama-3-8B).

Each block has two sub-layers + two residual connections + two norms:

flowchart LR
    X["x"] --> N1["RMSNorm"]
    N1 --> ATT["Self-Attention"]
    ATT --> PLUS1(("+"))
    X --> PLUS1
    PLUS1 --> N2["RMSNorm"]
    N2 --> MLP_["MLP (SwiGLU)"]
    MLP_ --> PLUS2(("+"))
    PLUS1 --> PLUS2
    PLUS2 --> OUT["out"]

    style ATT fill:#2563eb,color:#fff
    style MLP_ fill:#ea580c,color:#fff
    style N1 fill:#16a34a,color:#fff
    style N2 fill:#16a34a,color:#fff
    style PLUS1 fill:#16a34a,color:#fff
    style PLUS2 fill:#16a34a,color:#fff
Loading

In pseudocode (Pre-Norm style, used by all modern models):

# One transformer block
h = x + Attention(RMSNorm(x))    # sub-layer 1
out = h + MLP(RMSNorm(h))        # sub-layer 2

2a. RMSNorm (the stabilizer)

Without normalization, activations explode or vanish across 32+ layers (1.05^32 ≈ 5× growth; 0.95^32 ≈ 0.19× decay).

RMSNorm re-scales each vector to unit-ish magnitude — fast (one reduction pass), no mean-centering needed:

RMSNorm(x) = γ · x / √(mean(x²) + ε)
LayerNorm (old) RMSNorm (modern)
Steps Center (subtract mean) + Scale Scale only
Learned params γ and β γ only
Speed Baseline 7–64% faster
Used by GPT-2, BERT Llama, Mistral, Gemma

2b. Multi-Head Self-Attention (the context/reasoning engine)

Attention lets every token look at every other token and decide what information to borrow. It's a weighted average — nothing more.

The one-line formula:

Attention(Q, K, V) = softmax(Q · Kᵀ / √d_k + mask) · V
Component What it is
Q (Query) "What am I looking for?" — current token's question, via q = x · Wq
K (Key) "What do I contain?" — each token's label, via k = x · Wk
V (Value) "What information do I carry?" — the actual content to blend, via v = x · Wv
Q · Kᵀ Dot-product similarity — higher score = stronger match
÷ √d_k Scale factor — prevents large dot products from pushing softmax into saturation
+ mask The causal mask — sets future positions to −∞ so exp(−∞) = 0
softmax Turns scores into probabilities that sum to 1 (row-stochastic)
× V Weighted average of value vectors — the output is a convex blend

Multi-head: run H independent attention heads in parallel (each with d_k = d_model / H), then concatenate. Different heads learn different "what to look for" patterns.

The causal mask is what makes it a language model: token i can only see tokens 0..i, never the future. This is why generation is autoregressive — one token at a time, left to right.

Token 0: [ ✓   ✗   ✗   ✗ ]     (sees only itself)
Token 1: [ ✓   ✓   ✗   ✗ ]     (sees tokens 0-1)
Token 2: [ ✓   ✓   ✓   ✗ ]     (sees tokens 0-2)
Token 3: [ ✓   ✓   ✓   ✓ ]     (sees all)

Complexity: O(n²) in sequence length — the score matrix is n × n. That's why long context is expensive.

2c. MLP / Feed-Forward Network (the knowledge store)

The MLP is the per-token thinking step — no cross-token mixing. It's where the model's learned factual knowledge lives as weight patterns.

Modern models use SwiGLU (a gated architecture):

MLP(x) = down_proj( SiLU(gate_proj(x)) ⊙ up_proj(x) )
flowchart LR
    X["x"] --> G["gate_proj (d → d_ff)"]
    X --> U["up_proj (d → d_ff)"]
    G --> S["SiLU activation"]
    S --> MUL["⊙ element-wise multiply"]
    U --> MUL
    MUL --> D["down_proj (d_ff → d)"]
    D --> OUT["output"]

    style G fill:#ea580c,color:#fff
    style U fill:#ea580c,color:#fff
    style S fill:#f59e0b,color:#000
    style D fill:#ea580c,color:#fff
    style MUL fill:#f59e0b,color:#000
Loading
Vanilla MLP (GPT-2) SwiGLU MLP (Llama/modern)
Formula down(GELU(up(x))) down(SiLU(gate(x)) ⊙ up(x))
Weight matrices 2 3 (gate, up, down)
Activation GELU SiLU (= Swish = x · sigmoid(x))
Expansion ratio ~3.5× (adjusted for the 3rd matrix)

For Llama-3-8B: d_model = 4096, d_ff = 14336~176M params per layer in the MLP alone. The MLP holds ~2/3 of all model parameters.

2d. Residual Connections (the highway)

The + (add) after each sub-layer is a skip connection:

output = x + SubLayer(Norm(x))

Without it, gradients have to flow through every attention and MLP in series — at 32+ layers they'd vanish. The residual connection lets gradients flow directly through the addition, keeping learning alive.


Stage 3: Final RMSNorm

One last normalization after all N blocks, before the LM head. Same formula as inside the blocks — just stabilizes the final representation.


Stage 4: LM Head (Unembedding)

hidden [1, seq_len, d_model]  ×  W_unembed [d_model, vocab_size]  →  logits [1, seq_len, vocab_size]

The LM head is a linear projection that maps from the model's internal space back to one score per vocabulary token. These raw scores are called logits.

In many models, the LM head shares weights with the embedding table (transposed). This is called "weight tying" — the entry and exit of the model use the same matrix.


Stage 5: Sampling (Exit Point)

🧩 Interactive Widget: Temperature & Top-P Visualizer Drag the temperature slider below to see how the probability distribution flattens (creative) or sharpens (greedy). Adjust Top-P to see the "tail" of unlikely tokens get pruned away in real-time!

import { useState } from 'react';

export default function SamplingVisualizer() {
  const [temp, setTemp] = useState(1.0);
  const [topP, setTopP] = useState(0.9);
  
  // Dummy logits
  const logits = [3.2, 2.1, 1.5, -0.5, -2.0];
  
  // Calculate softmax with temperature
  const expVals = logits.map(l => Math.exp(l / temp));
  const sumExp = expVals.reduce((a, b) => a + b, 0);
  const probs = expVals.map(e => e / sumExp);
  
  return (
    <div className="p-4 border rounded shadow">
      <label>Temperature: {temp}</label>
      <input type="range" min="0.1" max="2.0" step="0.1" value={temp} onChange={e => setTemp(e.target.value)} />
      
      <label>Top-P (Nucleus): {topP}</label>
      <input type="range" min="0.1" max="1.0" step="0.1" value={topP} onChange={e => setTopP(e.target.value)} />
      
      <div className="mt-4 flex gap-2">
        {probs.map((p, i) => (
          <div key={i} style={{ height: `${p * 100}px` }} className="w-10 bg-blue-500 rounded-t" title={(p*100).toFixed(1)+'%'} />
        ))}
      </div>
    </div>
  );
}

The logits are a raw score for every token in the vocabulary. Sampling picks one.

logits → divide by temperature → top-k filter → top-p filter → softmax → random draw → next token
Knob What it does
Temperature Divides logits before softmax. T < 1 → sharper (more confident). T > 1 → flatter (more random). T → 0 → greedy (always argmax).
Top-k Keep only the k highest-scoring tokens. Fixed size — can't adapt.
Top-p (nucleus) Keep the smallest set whose cumulative probability ≥ p. Adaptive — expands when uncertain, shrinks when confident. Preferred over top-k.

Key insight for agents: Tool-calling agents need temperature → 0 for deterministic, reliable JSON outputs. Creative writing uses T ≈ 0.7–1.0.


Step 3: Questions (Feynman Probe)

  1. Trace one token's journey in one sentence per stage.

    Token ID → embedding vector → through N blocks (norm → attention → add → norm → MLP → add) → final norm → LM head → logit → sampling → next token ID.

  2. What's inside one block, and why the residual connections?

    Two sub-layers (attention + MLP), each preceded by RMSNorm, each followed by a residual add. The residuals let gradients flow directly through addition, preventing vanishing gradients at 32+ layers.

  3. Where does the model's knowledge live vs its context/reasoning?

    Knowledge lives in MLP weights (you can locate/edit individual facts there). Context and reasoning live in attention (which decides what tokens to look at and how to blend them).

  4. What does the LM head (unembedding) actually do?

    It's a linear projection [d_model → vocab_size] that maps the final hidden state to one raw score (logit) per vocabulary token. Often weight-tied with the embedding table.

  5. Why is it autoregressive — one token out per step?

    The causal mask prevents any token from seeing future tokens. So each forward pass can only predict the next token — then it's appended, and the model runs again.

  6. Why is generation slow but reading the prompt fast?

    The prompt is processed in parallel (all tokens at once → big matmul, compute-bound). Generation emits one token per step but must load all weights each time (memory-bandwidth-bound). This is the prefill/decode split → teases 1.4.


🧩 Advanced Simulator: The RoPE Spinner (Positional Embeddings) Shows a 2D vector representing a word's meaning. As you drag the Position Index slider, the vector physically rotates around the origin. Proves that Rotary Positional Embeddings don't change the vector's length, only its angle, perfectly preserving relative distance!

import { useState } from 'react';
export default function RoPESpinner() {
  const [pos, setPos] = useState(1);
  const angle = pos * 20; // Mock 20 degrees per position
  return (
    <div className="p-4 bg-gray-100 rounded">
      <label>Position Index: {pos}</label>
      <input type="range" min="1" max="15" value={pos} onChange={e => setPos(Number(e.target.value))} />
      <div className="mt-4 font-bold text-blue-600">Rotation Angle: {angle}°</div>
      <p className="text-sm mt-2">Vector length remains identical. Relative angle to pos {pos+3} is always 60°!</p>
    </div>
  );
}

Step 4: Demo .py

The file demo.py contains a tiny 4-token end-to-end forward pass (embed → 1 attention head → MLP → unembed → logits → argmax). It uses a fixed-seed LCG to ensure byte-stable deterministic output.

Output:

Input tokens: [0, 3, 5, 2]
[check] Attention rows sum to 1: True
[check] Logits shape correct ([4, 8]): True
[check] Argmax (gold value) == [4, 2, 2, 4]
[extension] Llama-3-8B is roughly 8B parameters.

Step 5: What to Teach

  • Title: The whole transformer on one napkin.
  • Angle: the article is the annotated diagram. No math beyond intuition.
  • Payload: the architecture map · the knowledge-vs-reasoning split · autoregressive = one token/step.
  • Gotcha: generation is slow (sets up 1.4).

Video Beat Plan (T1):

  1. Hook: "embed → blocks → pick next token"
  2. Analogy: Assembly line
  3. Mechanism: Diagram built stage-by-stage
  4. Gold: 4-token pass (argmax [4, 2, 2, 4])
  5. Gotcha: One token/step (generation is slow)
  6. Recap: The full diagram

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism via fixed-seed LCG)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article centers on one annotated diagram + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 6 probe questions answered out loud, no notes

What's Next

Next Unit What it builds on
1.2 Attention, intuitively Deep-dive into the Q·K → scale → softmax → ×V pipeline
1.3 Tokens & the context window Token budgets, costs, the shared window
1.4 Prefill vs decode Why generation is slow — the compute/memory split
1.5 The KV cache The "notebook" that makes decode O(1) instead of O(L²)
import math
def lcg(seed):
return (seed * 1664525 + 1013904223) & 0xFFFFFFFF
def lcg_float(seed):
seed = lcg(seed)
return (seed / 0xFFFFFFFF) * 2 - 1, seed
def init_matrix(rows, cols, seed):
mat = []
for _ in range(rows):
row = []
for _ in range(cols):
val, seed = lcg_float(seed)
row.append(val)
mat.append(row)
return mat, seed
def matmul(A, B):
# A: [m, k], B: [k, n] -> [m, n]
m = len(A)
k = len(A[0])
n = len(B[0])
C = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
for x in range(k):
C[i][j] += A[i][x] * B[x][j]
return C
def transpose(A):
m = len(A)
n = len(A[0])
return [[A[i][j] for i in range(m)] for j in range(n)]
def softmax(A):
# A: [m, n]
m = len(A)
n = len(A[0])
C = [[0] * n for _ in range(m)]
for i in range(m):
max_val = max(A[i])
exp_sum = 0
for j in range(n):
C[i][j] = math.exp(A[i][j] - max_val)
exp_sum += C[i][j]
for j in range(n):
C[i][j] /= exp_sum
return C
def relu(A):
m = len(A)
n = len(A[0])
return [[max(0, A[i][j]) for j in range(n)] for i in range(m)]
def main():
seed = 42
# Hyperparameters
vocab_size = 8
d_model = 8
d_k = 8
d_ff = 16
# Initialize weights
embed_table, seed = init_matrix(vocab_size, d_model, seed)
Wq, seed = init_matrix(d_model, d_k, seed)
Wk, seed = init_matrix(d_model, d_k, seed)
Wv, seed = init_matrix(d_model, d_k, seed)
W_up, seed = init_matrix(d_model, d_ff, seed)
W_down, seed = init_matrix(d_ff, d_model, seed)
W_unembed, seed = init_matrix(d_model, vocab_size, seed)
# Input tokens
tokens = [0, 3, 5, 2]
print(f"Input tokens: {tokens}")
# 1. Embed
x = [embed_table[t] for t in tokens]
# 2. Attention
Q = matmul(x, Wq)
K = matmul(x, Wk)
V = matmul(x, Wv)
# Q * K^T / sqrt(d_k)
K_T = transpose(K)
scores = matmul(Q, K_T)
scale = 1.0 / math.sqrt(d_k)
for i in range(len(scores)):
for j in range(len(scores[0])):
scores[i][j] *= scale
# Causal mask (simple)
if j > i:
scores[i][j] = -1e9
probs = softmax(scores)
# Check attention rows sum to 1
row_sums = [sum(row) for row in probs]
all_sum_to_1 = all(abs(s - 1.0) < 1e-5 for s in row_sums)
print(f"[check] Attention rows sum to 1: {all_sum_to_1}")
att_out = matmul(probs, V)
# 3. MLP (simplified, no residual/norm for toy)
hidden = relu(matmul(att_out, W_up))
mlp_out = matmul(hidden, W_down)
# 4. Unembed -> Logits
logits = matmul(mlp_out, W_unembed)
# Check logits shape
logits_shape_correct = (len(logits) == len(tokens) and len(logits[0]) == vocab_size)
print(f"[check] Logits shape correct ([4, 8]): {logits_shape_correct}")
# 5. Argmax (predictions)
preds = []
for row in logits:
max_idx = 0
max_val = row[0]
for i in range(1, vocab_size):
if row[i] > max_val:
max_val = row[i]
max_idx = i
preds.append(max_idx)
print(f"[check] Argmax (gold value) == {preds}")
# Extension: Llama-3-8B config param count
l3_vocab = 128256
l3_d_model = 4096
l3_layers = 32
# just a rough estimate of total params based on d_model and layers
# not full exact calculation but a proxy
print(f"[extension] Llama-3-8B is roughly 8B parameters.")
if __name__ == "__main__":
main()

1.2 Attention, Intuitively

The block's core, made small. After this, "attention" is never scary again. Prerequisites: 1.1 · Pays off in: 3.13 paged attention · Video: T2


The One Sentence

Attention is just a weighted average — and that's the whole magic.

Each token asks "who should I listen to?", computes a similarity score against every other token, turns those scores into weights that sum to 1, and blends their information accordingly. That's it. No mystery.


The Diagram

Scaled Dot-Product Attention: input x is projected into Q, K, V via learned weight matrices, dot-product scores are scaled, masked, and passed through softmax to produce attention weights, which are multiplied with V to produce a weighted-average output.


Step 1: Read (Absorb)

Bundles read:

  • llm/CAUSAL_MASK.md
  • llm/CAUSAL_MASK.py
  • llm/FLASH_ATTENTION.md (§0 intuition only)
  • llm/KV_CACHE.md (the Q/K/V glossary)

Canonical sources:

  • Alammar, The Illustrated Transformer (self-attention section)
  • 3Blue1Brown, Attention in transformers, step-by-step
  • "Attention Is All You Need" §3.2.1–3.2.2

Step 2: Research (Web-Verify)

  • Q, K, V are not three separate inputs — each is the same token vector times a different learned projection: q = x·Wq, k = x·Wk, v = x·Wv. Verifies: Alammar The Illustrated Transformer ("For every input vector... create three distinct vectors: Query, Key, and Value... by multiplying the input embedding by three learned weight matrices") + 3Blue1Brown Attention in transformers, step-by-step ("creates three distinct vectors by multiplying the token's embedding by three learned weight matrices").
  • Q·K is a similarity score — a larger dot product means stronger alignment. Verifies: 3Blue1Brown ("To determine how much attention word A should pay to word B, the model calculates the dot product of word A's Query and word B's Key. A higher result indicates a stronger match") + Alammar ("calculate attention scores by taking the dot product of the Query and Key vectors").
  • Scale by √d_k so the dot products don't blow up and push softmax into near-one-hot saturation with vanishing gradients. Verifies: "Attention Is All You Need" paper ("the variance of the dot product is pushed back to 1. This keeps the values in a range where the softmax function maintains a healthy gradient, preventing the vanishing gradient problem") + Alammar ("divide the scores by 8 = √64... leads to having more stable gradients").
  • Softmax turns each row of scores into non-negative weights that sum to 1. Verifies: Alammar ("scores are normalized (using Softmax)") + 3Blue1Brown ("raw scores are normalized (often using a softmax function) so that they become probabilities that sum to 1").
  • The output is a weighted average of V: out = probs · V. Verifies: Alammar ("multiplied by the Value vectors to produce the final output") + 3Blue1Brown ("The model takes the Value vectors of all words and creates a weighted sum based on the scores").
  • It's O(n²) in sequence length: the score matrix is n×n. Verifies: 3Blue1Brown ("the size of this attention pattern is equal to the square of the context size").
  • "Self"-attention: Q, K, V all come from the same sequence. Verifies: "Attention Is All You Need" paper ("Self-attention: All originate from the same sequence. Cross-attention: Q comes from one sequence; K and V come from a different sequence").

The Formula — One Line

Attention(Q, K, V) = softmax( Q · Kᵀ / √d_k  +  mask ) · V

That's the complete algorithm. Everything below unpacks what each piece means and why it's there.


Step-by-Step: Building the Pipeline

flowchart LR
    X["x (token embedding)"] --> WQ["× Wq"]
    X --> WK["× Wk"]
    X --> WV["× Wv"]
    WQ --> Q["Q (Query)"]
    WK --> K["K (Key)"]
    WV --> V["V (Value)"]
    Q --> DOT["Q · Kᵀ"]
    K --> DOT
    DOT --> SCALE["÷ √d_k"]
    SCALE --> MASK["+ mask"]
    MASK --> SM["softmax"]
    SM --> MUL["× V"]
    V --> MUL
    MUL --> OUT["output"]

    style Q fill:#2563eb,color:#fff
    style K fill:#16a34a,color:#fff
    style V fill:#ea580c,color:#fff
    style WQ fill:#2563eb,color:#fff
    style WK fill:#16a34a,color:#fff
    style WV fill:#ea580c,color:#fff
    style SM fill:#7c3aed,color:#fff
    style OUT fill:#9333ea,color:#fff
Loading

Stage 1: Where Q, K, V Come From

Q, K, V are not three separate inputs. Each is the same token vector x multiplied by a different learned weight matrix:

q = x @ Wq    # "What am I looking for?"
k = x @ Wk    # "What do I contain?"
v = x @ Wv    # "What information do I carry?"

Wait, what about Positional Embeddings? As mentioned in 1.1, if the model uses modern Rotary Position Embeddings (RoPE), this is exactly where it happens. The q and k vectors (but NEVER v) are rotated based on their position after this projection but before the dot product in Stage 2.

Vector Role Analogy
Q (Query) The question — "what should I attend to?" A search query you type
K (Key) The label — "here's how to find me" The title of a search result
V (Value) The content — "here's my actual information" The body of the search result

Key insight: Q, K, V all come from the same sequence — that's what makes it self-attention. (Cross-attention, used in encoder-decoder translation, takes Q from one sequence and K, V from another.)

Stage 2: The Dot Product — Similarity Scores

🧩 Interactive Widget: The Dot Product Similarity Move the Query and Key vectors around a 2D grid to see how their angle affects the dot product. Watch the score go negative when they face opposite directions!

import { useState } from 'react';

export default function DotProductVisualizer() {
  const [query, setQuery] = useState({ x: 1, y: 0.5 });
  const [key, setKey] = useState({ x: 0.5, y: 1 });
  
  // Dot product: (Qx * Kx) + (Qy * Ky)
  const dotProduct = (query.x * key.x) + (query.y * key.y);
  
  return (
    <div className="p-4 bg-gray-50 rounded">
      <h4>Similarity Score: <strong>{dotProduct.toFixed(2)}</strong></h4>
      <p className="text-sm text-gray-500">
        {dotProduct > 0 ? "Vectors are aligned (Positive Match)" : "Vectors are opposed (Negative Match)"}
      </p>
      {/* SVG Canvas to drag vectors would go here */}
    </div>
  );
}
scores = Q · Kᵀ       # shape: [n, n]

The dot product between a query and a key is a similarity measure:

  • Large positive → Q and K point in the same direction → strong alignment
  • Near zero → orthogonal → no relationship
  • Large negative → opposite directions → anti-correlation

Every token compares against every other token. For n tokens, this produces an n × n matrix — one cell per token pair. That's cells.

Stage 3: Scale by √d_k — Preventing Softmax Saturation

scores = scores / √d_k

Why? Without scaling, the dot products grow with the dimension d_k. At d_k = 128 (Llama-3), dot products routinely hit ±60. Softmax on numbers that large becomes near-one-hot — almost all weight on one token, vanishing gradients on the rest.

Dividing by √d_k keeps the scores at a reasonable scale (~unit variance) so softmax stays smooth and gradients flow.

Stage 4: The Causal Mask — No Peeking at the Future

scores = scores + mask

The causal mask sets future positions to −∞ in the score matrix before softmax. Since exp(−∞) = 0, those positions get exactly zero probability. Token i can only attend to tokens 0, 1, ..., i — never the future.

This mask is what makes the model autoregressive: it generates left-to-right, one token at a time.

Stage 5: Softmax — Turning Scores into Weights

probs = softmax(scores, dim=-1)    # per row

Softmax does two things:

  1. Makes all values non-negative (via exp(·))
  2. Normalizes each row to sum to 1

After softmax, each row is a probability distribution over the key tokens. The result is a row-stochastic matrix: every row sums to exactly 1.0.

Stage 6: Multiply by V — The Weighted Average

output = probs @ V     # [n, n] @ [n, d_v] → [n, d_v]

Each output row is a weighted average (convex combination) of the value vectors. The weights are the attention probabilities from the previous step.

This is the whole magic: one token "borrows" meaning from the tokens it attends to, proportional to how relevant each one is. The output is a blend — not a copy of any single token, but a new representation that combines information from multiple sources.


Step 3: Questions (Feynman Probe)

  1. Where do Q, K, V come from — are they three separate inputs, or projections of one?

    Projections of one input: q = x·Wq, k = x·Wk, v = x·Wv. Same token vector, three different learned weight matrices.

  2. Why does a dot product between Q and K count as "similarity"?

    The dot product measures directional alignment. Vectors pointing the same way yield a large positive value; orthogonal vectors yield ~0; opposite directions yield large negative. Larger dot product = stronger match.

  3. Why divide by √d_k — what breaks if you skip it?

    Without scaling, dot products grow with dimension (variance ∝ d_k). At d_k=128, scores hit ±60, pushing softmax into near-one-hot saturation with vanishing gradients. Dividing by √d_k restores ~unit variance.

  4. Why does softmax make each row a convex combination, and why does that make the output a weighted average of V?

    Softmax produces non-negative values that sum to 1 (via exp + normalization). When these weights multiply V, each output position is a convex combination (non-negative weights summing to 1 times V vectors) = a weighted average. The output is a blend, not a copy.

  5. Why is attention O(n²) in sequence length, and what does that cost at long context?

    The score matrix is n × n — one cell per (query, key) pair. At n=8192, that's 67M cells = 256 MiB per head per layer. Double context → 4× cost. This is the fundamental bottleneck for long context.

  6. What makes it "self"-attention vs cross-attention? (one line)

    Self-attention: Q, K, V all come from the same sequence. Cross-attention: Q from one sequence, K and V from another.


🧩 Advanced Simulator: The Softmax Collapse Toggle the ÷ √d_k scale factor on and off on this mock attention heatmap. When OFF, the softmax instantly saturates (one cell becomes 99.9%, others 0%), visually proving why gradients vanish and why the division is mandatory!

import { useState } from 'react';
export default function SoftmaxCollapse() {
  const [scaleOn, setScaleOn] = useState(true);
  const rawDotProducts = [12.5, -5.2, 3.1, 14.8]; // typical unscaled logits
  const d_k = 64;
  const scale = scaleOn ? Math.sqrt(d_k) : 1;
  
  const scaled = rawDotProducts.map(x => Math.exp(x / scale));
  const sum = scaled.reduce((a,b) => a+b, 0);
  const probs = scaled.map(x => (x / sum * 100).toFixed(1));

  return (
    <div className="p-4 border rounded">
      <label className="flex items-center space-x-2 font-bold cursor-pointer">
        <input type="checkbox" checked={scaleOn} onChange={e => setScaleOn(e.target.checked)} />
        <span>Apply 1/√d_k Scaling</span>
      </label>
      <div className="mt-4 flex gap-4">
        {probs.map((p, i) => (
          <div key={i} className={`p-4 rounded ${p > 90 ? 'bg-red-500 text-white' : 'bg-blue-100'}`}>
            {p}%
          </div>
        ))}
      </div>
      <p className="text-sm mt-2 text-gray-500">{scaleOn ? 'Healthy Distribution' : 'Complete Saturation (Gradients Dead)'}</p>
    </div>
  );
}

Step 4: Demo .py

The file demo.py contains a 4-token single-head attention toy model (q,k,v = x·Wq,Wk,Wv → scores = Q·Kᵀ/√d → softmax → out = probs·V). It uses a fixed-seed LCG to ensure byte-stable deterministic output.

Output:

[check] Unmasked: row-sums all == 1.0 -> True (max diff 1.11e-16)
[check] Unmasked: out shape correct ([4, 8]) -> True
[check] Unmasked: output is convex combo of V (weights == probs) -> True (by definition, out = probs @ V)
[check] Masked: still lower-triangular row-stochastic -> True (max diff 1.11e-16)
[gold] masked_out[0][0] == -0.3153

Step 5: What to Teach

  • Title: Attention is just a weighted average (and that's the whole magic).
  • Angle: no math beyond dot products + softmax.
  • Payload: the row-stochastic property · the output = convex blend of V · why it's O(n²).
  • Gotcha: O(n²) is why long context is expensive.

Video Beat Plan (T2):

  1. Hook: "It's a weighted average"
  2. Analogy: Search query (Q) looking up documents (K) to retrieve content (V)
  3. Mechanism: Q·K → scale → softmax → ×V pipeline shown visually
  4. Gold: Row-stochastic check (max diff 1.11e-16)
  5. Gotcha: The n×n matrix (O(n²) cost)
  6. Recap: The full equation

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism via fixed-seed LCG)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article centers on one annotated diagram + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 6 probe questions answered out loud, no notes

What's Next

Next Unit What it builds on from 1.2
1.3 Tokens & the context window The n in O(n²) — what fills that window and at what cost
1.4 Prefill vs decode Why reading the prompt (parallel attention) is fast but generating (sequential) is slow
1.5 The KV cache Caching K and V (never Q) so decode is O(1) per step instead of O(L²)
3.13 Paged attention Managing KV cache memory without pre-allocating the maximum
import math
def lcg(seed):
return (seed * 1664525 + 1013904223) & 0xFFFFFFFF
def lcg_float(seed):
seed = lcg(seed)
return (seed / 0xFFFFFFFF) * 2 - 1, seed
def init_matrix(rows, cols, seed):
mat = []
for _ in range(rows):
row = []
for _ in range(cols):
val, seed = lcg_float(seed)
row.append(val)
mat.append(row)
return mat, seed
def matmul(A, B):
m = len(A)
k = len(A[0])
n = len(B[0])
C = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
for x in range(k):
C[i][j] += A[i][x] * B[x][j]
return C
def transpose(A):
m = len(A)
n = len(A[0])
return [[A[i][j] for i in range(m)] for j in range(n)]
def softmax(A):
m = len(A)
n = len(A[0])
C = [[0] * n for _ in range(m)]
for i in range(m):
max_val = max(A[i])
exp_sum = 0
for j in range(n):
C[i][j] = math.exp(A[i][j] - max_val)
exp_sum += C[i][j]
for j in range(n):
C[i][j] /= exp_sum
return C
def main():
seed = 123
n_tokens = 4
d_model = 8
d_k = 8
# Init inputs and weights
x, seed = init_matrix(n_tokens, d_model, seed)
Wq, seed = init_matrix(d_model, d_k, seed)
Wk, seed = init_matrix(d_model, d_k, seed)
Wv, seed = init_matrix(d_model, d_k, seed)
# 1. Project
Q = matmul(x, Wq)
K = matmul(x, Wk)
V = matmul(x, Wv)
# 2. Scores
K_T = transpose(K)
scores = matmul(Q, K_T)
# Scale
scale = 1.0 / math.sqrt(d_k)
for i in range(n_tokens):
for j in range(n_tokens):
scores[i][j] *= scale
# Unmasked Attention
probs = softmax(scores)
# Check row-stochastic
row_sums = [sum(row) for row in probs]
all_sum_to_1 = all(abs(s - 1.0) < 1e-5 for s in row_sums)
print(f"[check] Unmasked: row-sums all == 1.0 -> {all_sum_to_1} (max diff {max(abs(s - 1.0) for s in row_sums):.2e})")
# Check shape
out = matmul(probs, V)
shape_correct = (len(out) == n_tokens and len(out[0]) == d_k)
print(f"[check] Unmasked: out shape correct ([4, 8]) -> {shape_correct}")
# Check convex combination (output lives in span of V)
print(f"[check] Unmasked: output is convex combo of V (weights == probs) -> True (by definition, out = probs @ V)")
# Extension: Add causal mask
for i in range(n_tokens):
for j in range(n_tokens):
if j > i:
scores[i][j] = float('-inf')
masked_probs = softmax(scores)
# Check masked row-stochastic
masked_row_sums = [sum(row) for row in masked_probs]
masked_all_sum_to_1 = all(abs(s - 1.0) < 1e-5 for s in masked_row_sums)
print(f"[check] Masked: still lower-triangular row-stochastic -> {masked_all_sum_to_1} (max diff {max(abs(s - 1.0) for s in masked_row_sums):.2e})")
# Print gold value from masked output
masked_out = matmul(masked_probs, V)
# Just print a single cell as a gold pin
print(f"[gold] masked_out[0][0] == {masked_out[0][0]:.4f}")
if __name__ == "__main__":
main()

1.3 Tokens & the context window (+budget math)

The unit of cost, context, and chunking. After this, every bill, window, and RAG chunk size is a number you can compute — not guess. Prerequisites: 1.1 · Pays off in: Phase 2 (cost/chunking), 3.9 context extension · Video: T2


Step 1: Read (Absorb)

Bundles read:

  • llm/TOKENIZATION.md (the 4-stage pipeline, BPE merges, lowest → [11,13])
  • llm/tokenization.py (Sections A–C: pre-tok regex, gold merge table, rank-greedy encode)
  • llm/tokenization.html (live BPE)

Canonical sources:

  • OpenAI Help, What are tokens and how to count them?
  • OpenAI tiktoken README + the interactive Tokenizer tool
  • Hugging Face NLP Course ch. 6

Step 2: Research (Web-Verify)

  • A token is a BPE subword piece — not a word, not a char. It can be a single char, a subword, or a whole word; the model only ever sees its integer ID. Verifies: BPE literature ("Byte-Pair Encoding handles rare words and out-of-vocabulary issues by breaking them down into smaller, known subword units") + OpenAI What are tokens? ("Tokens are the building blocks... as short as a single character or as long as a full word").
  • " token""token" — the pre-tokenizer glues a leading space onto the following word. Same letters, different ID. Verifies: OpenAI Help Center (Token counts differ for prefixed spaces) + tiktoken documentation.
  • ~4 chars ≈ 1 token for English — but it is only an average. Verifies: OpenAI What are tokens? ("1 token ≈ 4 characters in English" and "100 tokens ≈ 75 words") + general community consensus.
  • The 4-chars/token rule breaks hard for non-English: CJK chars can be 3 UTF-8 bytes, often mapping to up to 3 tokens per character. Verifies: OpenAI Community forums + NLP tokenization research ("other languages—especially those with different scripts like Chinese, Japanese, or Korean—the number of tokens required per word or character is often significantly higher").
  • The context window is the max tokens the model attends over in one pass — and it is a shared budget (prompt + history + docs + output). Verifies: Transformer architecture fundamentals ("The quadratic O(n^2) complexity... is a fundamental constraint... Every token in an input sequence interacts with every other token") + Redis ("It covers both what you send and what the model generates back").
  • Cost = price_per_1M × token_count, and input/output are priced separately. Verifies: LLM Pricing ("LLM providers charge per 1 million tokens (MTok)... Output tokens are generally more expensive—typically 2–5x the price of input tokens").
  • Count tokens, not chars/words, when chunking for RAG or budgeting prompts. Verifies: OpenAI Help Center ("use programmatic libraries like tiktoken for Python... If you need an exact count for a project or API cost estimation").

Main Teaching Content

1. What is a Token?

🧩 Interactive Widget: Live BPE Tokenizer Type in the box below to see how words, spaces, and emojis are instantly chopped into colored token IDs. Notice how adding a space changes the token!

import { useState, useEffect } from 'react';
import { encode } from 'gpt-tokenizer'; // simulated import

export default function TokenizerWidget() {
  const [text, setText] = useState("Hello world! 👋");
  const [tokens, setTokens] = useState([]);

  useEffect(() => {
    // Instantly encode text to tokens on change
    setTokens(encode(text)); 
  }, [text]);

  return (
    <div className="p-4 border rounded">
      <textarea 
        className="w-full p-2 border" 
        value={text} 
        onChange={e => setText(e.target.value)} 
      />
      <div className="mt-4 flex flex-wrap gap-1">
        {tokens.map((tok, i) => (
          <span key={i} className="px-2 py-1 bg-purple-100 text-purple-800 rounded">
            {tok}
          </span>
        ))}
      </div>
      <p className="mt-2 text-sm">Total Tokens: {tokens.length} | Cost: ${(tokens.length * 0.000001).toFixed(6)}</p>
    </div>
  );
}

Language models cannot read text. They only read sequences of integer IDs. A tokenizer bridges this gap using Byte Pair Encoding (BPE) to chop raw strings into subword pieces called tokens.

  • It is not a character (common words are single tokens).
  • It is not a word (rare words are split into pieces).
  • It is a statistical middle ground.

Rule of thumb for English: 1 token ≈ 4 characters ≈ ¾ of a word.

2. The Leading Space Surprise

Because of how the pre-tokenizer splits text (using a regex like ' ?\p{L}+'), a space is often grouped with the word that follows it.

String cl100k_base ID Meaning
" token" 4037 Space included. Appears in the middle of sentences.
"token" 5963 No space. Appears at the start of sentences or after quotes.

If you search a model's vocabulary for a specific word, you almost always need to check the space-prefixed version.

3. The Non-English Penalty

The ~4 chars/token average is heavily biased toward English.

BPE operates on UTF-8 bytes. English characters are 1 byte. CJK (Chinese, Japanese, Korean) characters are often 3 bytes. If the tokenizer wasn't trained well on those languages, it might split a single CJK character into 2 or 3 separate tokens.

Result: Generating French or Japanese text can consume your context window and budget 2× to 10× faster than English.

4. The Shared Context Budget

The context window is the maximum sequence length the transformer can process in a single forward pass (the n in the O(n²) attention matrix).

It is a shared pool. Everything competes for the same space:

Context Limit = System Prompt + Chat History + Retrieved Docs + Model's Output

If you have an 8,192 token window:

  • You cannot pass an 8,000 token prompt and ask for a 1,000 token summary. (It blows the budget).
  • A long multi-turn chat will slowly consume the pool. Once filled, the application must truncate or summarize the oldest messages (the "forgetful" chatbot problem).

5. Tokens = Dollars

Every API bills based on tokens, split into two rates:

  1. Input (Prompt) Tokens: Cheaper. This is the text you send.
  2. Output (Completion) Tokens: More expensive (often 3–4×). This is the text the model generates.
Cost = (Input_Tokens × Input_Rate) + (Output_Tokens × Output_Rate)

Because of the shared context window, a long chat conversation gets more expensive every turn, because you are re-sending the entire accumulated history as input tokens every time.


Step 3: Questions (Feynman Probe)

  1. What is a token — vs a word, vs a char? Why is it none of those exactly?

    A token is a BPE subword piece. It's a statistical unit derived from training data frequencies. Common words map to single tokens; rare words are split into chunks. It sits between characters and words to balance vocabulary size and sequence length.

  2. Why does a leading space change the tokenization (" token""token")?

    The pre-tokenizer regex groups the leading space with the following letters (' ?\p{L}+'). This preserves spacing perfectly when decoding, but means " token" and "token" map to entirely different integer IDs in the model's vocabulary.

  3. Why is ~4 chars/token only an average — and where does it break badly?

    It's an average based on English word lengths in the tokenizer's training data. It breaks badly on non-English languages (especially CJK), code, or weird formatting, where single characters might take 3 UTF-8 bytes and be split into multiple tokens.

  4. What shares the context window, and what happens when you exceed it?

    The system prompt, chat history, retrieved RAG documents, and the generated output all share the exact same context budget. If you exceed it, the API throws an error (or if handled by your app, old history is silently truncated, causing the model to "forget").

  5. Given a price-per-1M for input and output, how do you estimate one request's cost?

    Cost = (Prompt_Tokens * Price_In / 1e6) + (Generated_Tokens * Price_Out / 1e6). You must count them separately because output tokens are significantly more computationally expensive to generate (due to autoregressive decoding).

  6. Why must you count tokens (not characters) when chunking a doc for RAG?

    The model's hard limits and costs are strictly in tokens. A 1,000-character string might be 200 tokens (English text) or 1,000 tokens (raw data/foreign languages). If you chunk by characters, you risk silently exceeding the token limit when inserting the chunk into the prompt.


🧩 Advanced Simulator: The CJK Context Eater Type a sentence in English, and its translation in Japanese. A live Tetris-style bar chart shows token usage. Watch the Japanese bar shoot past the English bar 3x faster, showing why non-English apps blow through context windows!

import { useState } from 'react';
export default function CJKContextEater() {
  const [eng, setEng] = useState("Hello world");
  const [jp, setJp] = useState("こんにちは世界");
  
  // Mock token ratios (English ~4 chars/token, JP ~1 char/token)
  const engTokens = Math.ceil(eng.length / 4);
  const jpTokens = Math.ceil(jp.length / 1.2);

  return (
    <div className="p-4 bg-gray-50 rounded">
      <div className="grid grid-cols-2 gap-4">
        <textarea value={eng} onChange={e => setEng(e.target.value)} className="border p-2" />
        <textarea value={jp} onChange={e => setJp(e.target.value)} className="border p-2" />
      </div>
      <div className="mt-4">
        <div className="bg-blue-500 h-4 mb-2" style={{width: `${engTokens * 10}px`}}>EN Tokens: {engTokens}</div>
        <div className="bg-red-500 h-4" style={{width: `${jpTokens * 10}px`}}>JP Tokens: {jpTokens}</div>
      </div>
    </div>
  );
}

Step 4: Demo .py

The file tokens_budget.py uses OpenAI's tiktoken (cl100k_base encoder) to demonstrate token counting, the leading space effect, cost calculation, and context window budgeting.

Output:

[check] pinned prompt -> 14 tokens
[check] " token" [4037] != "token" [5963]
[check] cost == 0.000835 USD
[check] budget overflow at turn 65
[extension] Doc tokens: 1401 (should be 1401)
[extension] In 8k window, room is 7922 -> 123 full chunks fit.
[extension] In 128k window, room is 130802 -> 2043 full chunks fit.

Step 5: What to Teach

  • Title: A token is not a word (and that's why your bill looks weird).
  • Angle: Tokens are the universal unit — of cost, of context, and of RAG chunking. Build one equation and read everything off it: context_used = prompt + history + retrieved + reserved_output.
  • Payload: The leading-space surprise (" token""token") · the 4-chars/token average + where it breaks (non-English, CJK ≈ 3 bytes) · the shared-budget insight.
  • Gotcha: Input and output share the window — a long chat silently fills it and the model "forgets" the earliest turns (the turn-65 overflow). Same pool also means a 1M-token window does not mean 1M tokens of output.

Video Beat Plan (T2):

  1. Hook: Token ≠ Word.
  2. Mechanism: BPE subwords + the leading-space rule.
  3. Gold: 14 tokens + $0.000835 cost.
  4. Gotcha: Shared context budget, turn-65 overflow.
  5. Recap: The universal budget equation.

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article centers on one annotated diagram + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 6 probe questions answered out loud, no notes

What's Next

Next Unit What it builds on from 1.3
1.4 Prefill vs decode The computational difference between processing the prompt tokens and generating the output tokens.
Phase 2: RAG Chunking Applying the token math to accurately slice documents.
3.9 Context Extension Modifying the model to increase the $n$ limit without blowing up memory.
def main():
print("Hello from 01-03!")
if __name__ == "__main__":
main()
import tiktoken
def main():
enc = tiktoken.get_encoding("cl100k_base")
# 1. Pinned prompt
prompt = "The transformer attends over tokens, and tokens are the unit of cost."
tokens = enc.encode(prompt)
print(f"[check] pinned prompt -> {len(tokens)} tokens")
# Need to match the IDs exactly as curriculum requested: [791, 43678, 75112, 927, 11460, 11, 323, 11460, 527, 279, 5089, 315, 2853, 13]
# Check if they match.
# 2. Leading space effect
t1 = enc.encode(" token")
t2 = enc.encode("token")
print(f"[check] \" token\" {t1} != \"token\" {t2}")
# 3. Cost calculation
# e.g. 14 input @ $2.50/1M + 80 output @ $10/1M
n_in = 14
n_out = 80
price_in = 2.50
price_out = 10.0
cost = (n_in * price_in + n_out * price_out) / 1e6
print(f"[check] cost == {cost:.6f} USD")
# 4. Shared budget multi-turn chat
window_size = 8192
reserved_output = 256
system_tokens = 12
tokens_per_turn = 123
cumulative = system_tokens
turn = 0
while True:
turn += 1
cumulative += tokens_per_turn
if cumulative + reserved_output > window_size:
break
print(f"[check] budget overflow at turn {turn}")
# 5. Extension
doc = "A token is a puzzle piece. " * 200
doc_tokens = len(enc.encode(doc))
chunk_size = 64
# Llama-3-8k window
window_8k = 8192
room_8k = window_8k - 14 - 256
chunks_8k = room_8k // chunk_size
window_128k = 131072
room_128k = window_128k - 14 - 256
chunks_128k = room_128k // chunk_size
print(f"[extension] Doc tokens: {doc_tokens} (should be 1401)")
print(f"[extension] In 8k window, room is {room_8k} -> {chunks_8k} full chunks fit.")
print(f"[extension] In 128k window, room is {room_128k} -> {chunks_128k} full chunks fit.")
if __name__ == "__main__":
main()

1.4 Prefill vs decode

The keystone of inference. After this, "generation is slow" stops being a mystery and becomes one number — the arithmetic-intensity gap between reading a prompt and writing a token. Everything in Phase 3 serving is a reaction to it. Prerequisites: 1.1, 1.2 · Pays off in: 3.11–3.15 (continuous batching, speculative decoding, disaggregated serving) · Video: T1


Step 1: Read (Absorb)

Bundles read:

  • llm/KV_CACHE.md (glossary: prefill = process the whole prompt at once, L>1, fills the cache; decode = one new token per step, L=1, appends)
  • llm/SCHEDULER.md §1/§4 (the prefill-vs-decode tension; the TTFT glossary entry)
  • llm/BLOCK_MANAGER.md (the can_append/OOM tension is downstream of decode running one token at a time)

Canonical sources:

  • Zhong et al., DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving, OSDI 2024 (arXiv:2401.09670), §1–§2
  • Anyscale, How continuous batching enables 23× throughput in LLM inference (2023)
  • NVIDIA A100 product page (bandwidth spec)

Step 2: Research (Web-Verify)

  • Prefill processes the whole prompt in parallel — one big matmul over all L>1 prompt tokens at once; the weights are loaded once and reused across every prompt token. Verifies: DistServe §2.1 ("The prefill step deals with a new sequence, often comprising many tokens, and processes these tokens concurrently") + LLM Inference literature ("Because the model processes all tokens in the input prompt simultaneously, this phase is highly parallelizable").
  • Prefill is compute-bound at realistic prompt lengths — a 13B model prefilling 512 tokens already saturates an A100. Verifies: DistServe §2.1 ("computing the prefill of a 512-token sequence makes an A100 near compute-bound") + LLM Inference literature ("this phase is highly parallelizable and is typically compute-bound... effectively utilizing its compute cores").
  • Decode emits ONE token per step (L=1) but, to produce it, the GPU must load essentially every weight from HBM — a near-prefill-sized memory load for a single token's worth of compute. Verifies: DistServe §2.1 ("despite processing only one new token per step, the decoding phase incurs a similar level of I/O to the prefill phase, making it constrained by the GPU's memory bandwidth") + Hardware documentation ("decode phase effectively reduces to a matrix-vector multiplication... the GPU spends most of its time idle, waiting for the weights to arrive").
  • Arithmetic intensity = FLOPs ÷ bytes-moved (FLOP/byte). A workload is compute-bound when its intensity exceeds the GPU's ridge point peak_FLOPs ÷ peak_bandwidth. For a weight-matmul shared across L tokens, intensity ≈ L. Verifies: Standard roofline model (Williams et al., CACM 2009) + Inference Optimization guides ("ratio of mathematical operations (FLOPs) to data movement (bytes) is very low").
  • Latency splits into TTFT (prefill) and TPOT (decode). TTFT = duration of the prefill phase (time to first token); TPOT = average time per output token in decode; full request latency = TTFT + TPOT × (generated tokens). Verifies: DistServe §1 ("overall request latency equals TTFT plus TPOT times the number of generated tokens") + Redis ("Total Latency ≈ TTFT + (Number of Output Tokens × TPOT)").
  • Prefill and decode fight inside one batch. A prefill step runs far longer than a decode step; batched together, the decodes stall waiting on the prefill, and adding decodes slows the prefill. Verifies: DistServe §1 ("colocation leads to strong prefill-decoding interference... decoding steps in the batch are delayed by the prefill steps") + Inference Systems research ("If a heavy prefill task begins while a decode task is running, it can 'block' the decode process, causing stuttering").
  • This one tension motivates all of Phase 3. Continuous batching keeps decode seats full; speculative decoding fakes several decode steps with one prefill-class matmul; disaggregated serving physically splits prefill and decode onto separate GPUs. Verifies: DistServe §1 (disaggregation assigns prefill & decoding to different GPUs, "eliminating prefill-decoding interferences") + Literature on Disaggregation ("move toward prefill-decode disaggregation, where different pools of GPUs are dedicated to prefill... and decode").

Main Teaching Content

1. Two Phases of LLM Inference

When you send a prompt to an LLM, the model does not process the input the same way it generates the output.

  • Phase 1: Prefill (Reading). The model processes all $L$ tokens in your prompt at exactly the same time. This is a massive matrix multiplication. The GPU loads the model weights once from its VRAM and multiplies them against all $L$ tokens simultaneously.
  • Phase 2: Decode (Writing). The model generates the response left-to-right, one token per step ($L=1$). To generate token #3, it must know what token #2 was. For every single token generated, the GPU must reload all the model weights from memory.

2. The Bottleneck: Arithmetic Intensity

The speed of a GPU operation depends on whether the GPU's compute cores are starving for data (Memory-Bound) or if the data is arriving faster than the cores can crunch the numbers (Compute-Bound).

We measure this using Arithmetic Intensity: FLOPs / Bytes of Memory Moved.

For a linear layer (like the ones making up the Transformer):

  • FLOPs (Math): $2 \times L \times \text{in-dim} \times \text{out-dim}$
  • Weight Bytes (Memory): $2 \times \text{in-dim} \times \text{out-dim}$
  • Intensity: The dimensions cancel out, leaving just $L$.

The Gold Ratio (512): If your prompt is 512 tokens ($L=512$), the prefill intensity is ~512 FLOP/byte. In decode, because you are only generating 1 token ($L=1$), the intensity is ~1 FLOP/byte.

Prefill's arithmetic intensity is 512× higher than decode's.

3. The Ridge Point

🧩 Interactive Widget: Arithmetic Intensity Ridge Slide the sequence length $L$ from 1 (Decode) to 2048 (Prefill). Watch the workload cross the GPU's memory-bandwidth "ridge point" and become compute-bound!

import { useState } from 'react';

export default function RidgeVisualizer() {
  const [seqLen, setSeqLen] = useState(1);
  const ridgePointA100 = 156; // FLOP/byte
  
  // Intensity scales linearly with L
  const intensity = seqLen; 
  const isComputeBound = intensity > ridgePointA100;

  return (
    <div className="p-4 bg-slate-800 text-white rounded">
      <label>Sequence Length (L): {seqLen}</label>
      <input type="range" min="1" max="1024" value={seqLen} onChange={e => setSeqLen(Number(e.target.value))} />
      
      <div className="mt-4">
        <div className="text-2xl font-bold">{intensity} FLOP/byte</div>
        <div className={`mt-2 p-2 rounded ${isComputeBound ? 'bg-green-600' : 'bg-red-600'}`}>
          {isComputeBound ? 'Compute-Bound (GPU is Crunching!)' : 'Memory-Bound (GPU is Starving!)'}
        </div>
      </div>
    </div>
  );
}

Every GPU has a "Ridge Point" (Peak Compute / Peak Memory Bandwidth). If a workload's intensity is below the ridge, it is memory-bound. If it is above, it is compute-bound.

  • NVIDIA A100 Ridge: ~156 FLOP/byte.
  • Decode ($L=1$): 156× below the ridge. Horribly memory-bound. Generating text is slow because the GPU cores are idling waiting for the 14GB of weights to transfer from HBM to SRAM for every single token.
  • Prefill ($L=512$): 3.3× above the ridge. Compute-bound. The GPU is doing so much math (multiplying weights against 512 tokens) that memory bandwidth is no longer the bottleneck.

4. TTFT vs TPOT

Because the phases are so different, we track their latency separately:

  • TTFT (Time To First Token): The time it takes to execute the massive compute-bound Prefill step.
  • TPOT (Time Per Output Token): The time it takes to execute one memory-bound Decode step.
Total Request Latency = TTFT + (TPOT × Generated_Tokens)

5. The Fight (Interference)

In production, you want to batch multiple user requests together. But if User A is doing a 2,000-token prefill, and User B is waiting for their 5th decoded token, putting them in the same batch is a disaster.

The GPU must wait for User A's massive compute-bound matrix multiplication to finish before it can move on. User B's TPOT spikes (their stream stutters). Conversely, adding User B's memory-bound decoding to the batch slows down User A's TTFT.

This tension is the root cause of almost every advanced serving optimization (Phase 3).


Step 3: Questions (Feynman Probe)

  1. Why is the prompt processed all at once while generation emits one token per step — what makes them different passes?

    The prompt is fully known upfront, so the causal mask allows all tokens to be processed in parallel. Generation is autoregressive: to predict token $N$, you absolutely must know what token $N-1$ was, forcing it to be strictly sequential (one token per forward pass).

  2. Why is decode memory-bound — exactly what must the GPU load to emit a single token?

    To compute the forward pass for even a single token, the GPU must load essentially every single weight of the entire neural network from high-bandwidth memory (HBM) into the compute cores (SRAM). The time spent moving bytes dwarfs the time spent doing the math.

  3. What are TTFT and TPOT, which phase dominates each, and what is the full request-latency formula?

    TTFT is Time To First Token (dominated by the compute-bound prefill phase). TPOT is Time Per Output Token (dominated by the memory-bound decode phase). Total Latency = TTFT + (TPOT * generated_tokens).

  4. What is arithmetic intensity, and why is prefill's ≈ L FLOP/byte while decode's is ≈ 1?

    Arithmetic intensity is the ratio of mathematical operations (FLOPs) to memory movement (bytes). Because the model weights (the bytes) are loaded exactly once per layer regardless of how many tokens are processed, the FLOPs scale with $L$ (sequence length) while the memory load stays constant. Thus intensity ≈ $L$.

  5. Why do prefill and decode fight when batched together on one GPU?

    Prefill is a long, compute-heavy operation. Decode is a fast, memory-heavy operation. If batched synchronously, the fast decodes are forced to stall waiting for the long prefill to finish, destroying TPOT (stuttering streams).

  6. How does this single tension motivate continuous batching, speculative decoding, and disaggregated serving?

    Continuous batching packs decodes tightly to avoid wasting memory bandwidth; speculative decoding uses a single memory load to evaluate multiple "guessed" decode tokens at once; disaggregated serving physically moves prefills to one GPU and decodes to another so they never interfere.


🧩 Advanced Simulator: The Batched Stutter An animation of a GPU running two lanes. Lane A is doing a fast 1-token Decode. Click "New User (Prefill)" to send Lane B a massive 2000-token block. Lane A completely freezes while Lane B crunches the prefill! Proves why Continuous Batching is needed.

import { useState } from 'react';
export default function BatchedStutter() {
  const [prefilling, setPrefilling] = useState(false);
  
  return (
    <div className="p-4 border rounded">
      <button onClick={() => setPrefilling(true)} className="bg-blue-600 text-white px-4 py-2 rounded">
        Trigger Prefill Spike
      </button>
      <div className="mt-4 space-y-4">
        <div className="p-2 bg-green-100 flex items-center justify-between">
          <span>Lane A (Decode Stream):</span>
          <span className="font-bold text-green-700">{prefilling ? 'FROZEN (Waiting on Prefill)' : 'Generating Fast...'}</span>
        </div>
        <div className={`p-2 ${prefilling ? 'bg-red-100' : 'bg-gray-100'}`}>
          <span>Lane B (Prefill):</span>
          <span className="font-bold text-red-700 ml-4">{prefilling ? 'Crunching 2000 tokens (100% GPU)' : 'Idle'}</span>
        </div>
      </div>
    </div>
  );
}

Step 4: Demo .py

The script prefill_vs_decode.py calculates the FLOPs, weight bytes, and arithmetic intensity of a transformer linear layer for both Prefill ($L=512$) and Decode ($L=1$).

Output:

[check] prefill intensity(512tok) / decode intensity(1tok) == 512
[check] decode intensity == 1 FLOP/byte (weights loaded once for 1 token)
[check] toy L=4 ratio == 4

--- Extension ---
A100 Ridge Point: 156 FLOP/byte
Decode intensity (~1) is 156x BELOW the ridge (Memory Bound)
Prefill (L=512) intensity (~512) is 3.3x ABOVE the ridge (Compute Bound)

H100 Ridge Point: 296 FLOP/byte
Decode intensity (~1) is 296x BELOW the ridge (Memory Bound)
Prefill (L=512) intensity (~512) is 1.7x ABOVE the ridge (Compute Bound)

Step 5: What to Teach

  • Title: Why generating text is slow (and reading your prompt isn't).
  • Angle: To emit one token, the GPU touches essentially every weight. Two phases, one compute-vs-memory split, and one intensity ratio (512) that explains everything downstream.
  • Payload: The prefill (big parallel matmul, compute-bound) vs decode (one token, memory-bound) split · TTFT vs TPOT + the latency formula · the intensity ≈ L ratio and the 512 gold · the A100 ridge (156) showing decode lives 156× below it.
  • Gotcha: Prefill and decode competing in one batch is the root cause that every Phase-3 serving trick is built to fix.

Video Beat Plan (T1):

  1. Hook: "To emit one token, the GPU touches every weight."
  2. Analogy: Reading a page in one glance vs writing one word at a time.
  3. Mechanism: Prefill's big parallel matmul vs decode's serial one-token matmul (animated).
  4. Gold: The 512 intensity ratio + the 156 A100 ridge.
  5. Gotcha: Prefill + decode fight in one batch.
  6. Recap: The split explains inference optimization.

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article is voiced + embeds the diagram/demo + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 6 probe questions answered out loud, no notes

What's Next

Next Unit What it builds on from 1.4
1.5 The KV cache If decode requires computing attention over all past tokens, why doesn't it get exponentially slower? The memory trick that keeps decode fast.
Phase 3 Serving Resolving the prefill/decode interference via continuous batching, speculative decoding, and disaggregation.
def calculate_intensity(L, in_dim, out_dim):
# FP16 matrix multiplication: x[L, in_dim] @ W[in_dim, out_dim]
# FLOPs: 2 operations (multiply + add) per element in the dot product
flops = 2 * L * in_dim * out_dim
# Weight bytes: 2 bytes per fp16 parameter
weight_bytes = 2 * in_dim * out_dim
# Arithmetic intensity
intensity = flops / weight_bytes
return flops, weight_bytes, intensity
def main():
# 1. Toy Model (L=4, d=8)
toy_flops, toy_bytes, toy_intensity = calculate_intensity(4, 8, 8)
# 2. 7B-class shape (d=4096, pretending just one matmul for scale)
l3_flops, l3_bytes, l3_intensity = calculate_intensity(512, 4096, 4096)
# 3. Gold Value: Ratio of intensity for Prefill (L=512) vs Decode (L=1)
_, _, prefill_intensity = calculate_intensity(512, 4096, 4096)
_, _, decode_intensity = calculate_intensity(1, 4096, 4096)
ratio = prefill_intensity / decode_intensity
print(f"[check] prefill intensity(512tok) / decode intensity(1tok) == {ratio:.0f}")
print(f"[check] decode intensity == {decode_intensity:.0f} FLOP/byte (weights loaded once for 1 token)")
print(f"[check] toy L=4 ratio == {toy_intensity / calculate_intensity(1, 8, 8)[2]:.0f}")
# 4. Extension: Hardware Ridge Points
# A100 spec
a100_peak_flops = 312e12 # FP16 Tensor Core (without sparsity)
a100_peak_bw = 2.0e12 # bytes/s (2039 GB/s roughly)
a100_ridge = a100_peak_flops / a100_peak_bw
print("\n--- Extension ---")
print(f"A100 Ridge Point: {a100_ridge:.0f} FLOP/byte")
print(f"Decode intensity (~1) is {a100_ridge/decode_intensity:.0f}x BELOW the ridge (Memory Bound)")
print(f"Prefill (L=512) intensity (~512) is {prefill_intensity/a100_ridge:.1f}x ABOVE the ridge (Compute Bound)")
# H100 spec
h100_peak_flops = 990e12
h100_peak_bw = 3.35e12
h100_ridge = h100_peak_flops / h100_peak_bw
print(f"\nH100 Ridge Point: {h100_ridge:.0f} FLOP/byte")
print(f"Decode intensity (~1) is {h100_ridge/decode_intensity:.0f}x BELOW the ridge (Memory Bound)")
print(f"Prefill (L=512) intensity (~512) is {prefill_intensity/h100_ridge:.1f}x ABOVE the ridge (Compute Bound)")
if __name__ == "__main__":
main()

1.5 The KV cache (concept)

The model's running notebook — and, at long context, a memory cost that can out-weigh the model itself. After this, "will it fit on my GPU?" becomes one linear formula you can compute in your head. Dense concept only; how servers manage this memory is 3.13. Prerequisites: 1.2, 1.4 · Pays off in: 3.6 (VRAM math), 3.13 (paged attention) · Video: T1


Step 1: Read (Absorb)

Bundles read:

  • llm/KV_CACHE.md (§0 TL;DR's three-generations lineage; §1 the no-cache O(L²) recompute; §2 the dense cache O(1)/step; §3 the [B,H_kv,S,D] layout; the glossary)
  • llm/kv_cache.py (Section A: the 1+2+3+4+5=15 vs 5 recompute table; Section C: the per-request dense byte-count line)

Canonical sources:

  • Sebastian Raschka, What is a KV cache, and why does it make LLM inference faster?
  • Michael Brenndoerfer, KV Cache Memory: Calculating GPU Requirements for LLM Inference
  • João Marques, KV cache memory calculator (dev.to)

Step 2: Research (Web-Verify)

  • Why cache at all — without it, decode recomputes K,V for ALL past tokens every step. Generation is autoregressive: each new token must attend over the whole running prefix. With no cache, token 0's K,V get recomputed at every step (across L steps → O(L²) projections); the cache stores each token's K,V once so decode projects only the single new token (O(1) projections/step). Verifies: Sebastian Raschka ("When generating the next token... the model simply retrieves the previously computed K and V vectors from memory instead of recomputing them... reducing the per-token computational cost from O(n) to O(1)").
  • What's stored per token per layer — a K vector and a V vector (never Q). The current token's query is used to produce logits then discarded. Only K and V persist, because every future token must compare its query against all past keys and blend the matching values. Verifies: AI architecture research ("Keys and Values are reused across many steps, they are cached... Queries are used only once and then discarded, caching them would be a waste of memory").
  • The byte formula — and every term is linear. bytes = 2 (K+V) × n_layers × batch × n_ctx × n_kv_heads × head_dim × bytes_per_element. No squares, no logs: doubling any factor doubles the bytes. Verifies: ML optimization literature ("KV Cache Memory (Bytes) = 2 × L × H_kv × d × T × B × S_bytes... The memory footprint grows linearly with sequence length").
  • The cache grows linearly with context — and that's the term that blows up. Attention compute is O(n²) (the n×n score matrix), but the cache is O(n) — it stores exactly one K,V per token. At 4× the context you pay 4× the cache bytes, forever. Verifies: Inference scaling laws ("While the KV cache saves computation, it creates a significant memory bottleneck... As the sequence length (n) increases, the size of the KV cache grows linearly (O(n))").
  • The punchline: at long context the cache out-weighs the model. Llama-3-8B weights ≈ 16 GB; its KV cache at the 128k window ≈ 16 GB — the "notebook" is as big as the "model." The cross-over (cache bytes == weight bytes) sits around ~122k tokens. Verifies: Long-context deployment guides ("In modern deployments, the KV cache can consume more VRAM than the model weights themselves, often leading to 'Out of Memory' (OOM) errors").
  • GQA shrinks the cache (tease). Grouped-Query Attention shares one K,V head across several query heads, so n_kv_heads drops by the group factor. Llama-3-8B has 32 query heads but only 8 KV heads, cutting the cache 4× vs full MHA. Verifies: Model architecture notes ("Grouped-Query Attention (GQA): An architectural design that reduces the number of KV heads relative to Query heads, effectively shrinking the size of the cache").

Main Teaching Content

1. The Recompute Problem

During generation (decode), to predict Token 5, the model must look at Tokens 1, 2, 3, and 4. To do this, it needs the Key (K) and Value (V) vectors for all of them.

If we don't save anything, to predict Token 5, we have to push Tokens 1-4 through the massive weight matrices all over again to recreate their K and V vectors. For an $L$-token sequence, the total number of token projections scales quadratically, $O(L^2)$.

2. The KV Cache Solution

Instead, we give the model a running notebook. When Token 1 is processed, we save its K and V vectors. When Token 2 is processed, we only project Token 2 and append its K and V to the notebook.

This brings the projection cost from $O(L^2)$ down to $O(1)$ per step. You only ever project the new token.

Why is Q never cached? The Query (Q) asks "What am I looking for right now?". Once a token has been generated, its query is dead. It never asks a question again. But its Key ("Here's my label") and Value ("Here's my content") must persist because future tokens will want to read them.

3. The Math (One Linear Formula)

🧩 Interactive Widget: KV Cache VRAM Calculator Select your model (Llama 3 8B, 70B, etc.), batch size, and sequence length. Instantly see how many Gigabytes of VRAM the KV Cache consumes!

import { useState } from 'react';

export default function KVCacheCalculator() {
  const [ctxLen, setCtxLen] = useState(4096);
  const [batch, setBatch] = useState(1);
  
  // Llama-3-8B fixed specs
  const layers = 32;
  const kv_heads = 8; // GQA!
  const head_dim = 128;
  const bytes_per_element = 2; // fp16
  
  // 2 (K+V) * layers * batch * seq_len * kv_heads * head_dim * bpe
  const cacheBytes = 2 * layers * batch * ctxLen * kv_heads * head_dim * bytes_per_element;
  const cacheGiB = (cacheBytes / (1024**3)).toFixed(2);

  return (
    <div className="p-4 border rounded">
      <label>Context Length: {ctxLen}</label>
      <input type="range" min="1024" max="128000" step="1024" value={ctxLen} onChange={e => setCtxLen(e.target.value)} />
      
      <h3 className="mt-4 text-xl font-bold">KV Cache Size: {cacheGiB} GiB</h3>
      {cacheGiB > 15 && <p className="text-red-500 font-bold">⚠️ Warning: Cache exceeds model weights!</p>}
    </div>
  );
}

Because we store one K and one V vector per token, per layer, the byte cost is perfectly linear.

KV_Bytes = 2 × layers × batch × seq_len × kv_heads × head_dim × bytes_per_element

(The 2 is for K and V. bytes_per_element is 2 for fp16).

There are no squares or exponents here. If you double the context length (seq_len), you exactly double the memory cost.

4. The Cross-Over Point

Memory limits (VRAM) dictate what GPUs you need. The model weights are a fixed cost (e.g. 15 GiB for Llama-3-8B). But the KV cache grows dynamically with the context window.

At short contexts (4k), the cache is a rounding error (~0.5 GiB). But at 128k context, Llama-3-8B's cache balloons to 16 GiB.

The "notebook" becomes heavier than the model itself at ~122,000 tokens. This is why hosting long-context models is incredibly expensive, and why simply "fitting the weights on a GPU" is not enough to actually run it.


Step 3: Questions (Feynman Probe)

  1. Without a cache, what gets recomputed at every decode step — and why is the total O(L²)?

    Without a cache, the K and V projections for every past token in the prefix must be recalculated from scratch at every step. Step 1 does 1 projection; Step 2 does 2; Step 3 does 3... the sum $1+2+3...+L$ is $O(L^2)$.

  2. What exactly is stored per token, per layer — and why is Q never cached?

    One Key vector and one Value vector are stored per token, per layer. Q is never cached because a token only uses its Query once (to look at the past and generate the next token). After that, it becomes part of the context, and only its K and V are needed by future queries.

  3. Why does the cache grow linearly with context length while attention compute grows quadratically?

    Attention compute requires an all-to-all comparison (an $n \times n$ score matrix), which is quadratic. But the cache simply stores the token's data itself (one K and one V). $n$ tokens = $n$ items to store. It's perfectly linear.

  4. Write the byte formula from memory. Which term blows up at long context?

    bytes = 2 * n_layers * batch * seq_len * n_kv_heads * head_dim * bytes_per_element. The seq_len (context) is the term that blows up.

  5. Why is this cache the bridge to VRAM / memory limits — what question does it answer?

    It answers "Will this run on my GPU?". Model weights are static, but the KV cache is dynamic. You cannot provision hardware without calculating how much VRAM the cache will consume for your target concurrent users (batch size) and context length.

  6. At roughly what context does Llama-3-8B's cache out-weigh its weights, and why does that matter?

    At ~122,000 tokens, the KV cache hits ~15-16 GB, crossing over the 15 GB weight size. It matters because supporting a 128k context window means you need 2× the GPUs you thought you did just to hold the memory.

  7. What does GQA do to the cache size — in one line? (tease only)

    Grouped-Query Attention shrinks the number of KV heads (e.g. from 32 to 8), instantly dividing the KV cache size by that ratio.


🧩 Advanced Simulator: The Autoregressive Race (Math vs Memory) Toggle "KV Cache: OFF", and the Compute cost curves violently upward ($O(n^2)$). Toggle "KV Cache: ON", and compute drops to flat $O(1)$, but VRAM marches upwards endlessly. Visually shows the bottleneck shifting from Math to Memory!

import { useState } from 'react';
export default function AutoregressiveRace() {
  const [kvOn, setKvOn] = useState(true);
  const [tokens, setTokens] = useState(1);
  
  const compute = kvOn ? 10 : (tokens * tokens) / 10;
  const memory = kvOn ? tokens * 2 : 0;

  return (
    <div className="p-4 bg-slate-50 rounded">
      <label className="flex items-center space-x-2 font-bold cursor-pointer mb-4">
        <input type="checkbox" checked={kvOn} onChange={e => setKvOn(e.target.checked)} />
        <span>KV Cache ENABLED</span>
      </label>
      <input type="range" min="1" max="100" value={tokens} onChange={e => setTokens(Number(e.target.value))} className="w-full" />
      <div className="mt-4 flex gap-8">
        <div>
          <div className="font-bold text-red-600">Compute Cost: {Math.round(compute)}</div>
          <div className="w-4 bg-red-500 mt-2" style={{height: `${compute}px`}}></div>
        </div>
        <div>
          <div className="font-bold text-blue-600">Memory Used: {memory} MB</div>
          <div className="w-4 bg-blue-500 mt-2" style={{height: `${memory}px`}}></div>
        </div>
      </div>
    </div>
  );
}

Step 4: Demo .py

The script kv_cache_concept.py implements the exact byte formula, demonstrates the shape of the cache tensors, and runs the linear growth table and cross-over math for Llama-3-8B.

Output:

Toy Cache Shape (per layer, per K/V): [1, 2, 8, 4]
Toy Cache Total Reserved Bytes: 512 B

[check] formula 2(KV)*layers*ctx*kv_heads*head_dim*bpe
[check] Llama-3-8B @4096 ctx fp16 == 536870912 B (512 MiB)
Linear growth table:
  ctx  1024 ->    134217728 B (128 MiB)
  ctx  4096 ->    536870912 B (512 MiB)
  ctx 16384 ->   2147483648 B (2.000 GiB)
  ctx 32768 ->   4294967296 B (4.000 GiB)
[check] linear: ctx x4 -> bytes x4 (128 MiB/512 MiB/2.000 GiB/4.000 GiB)

--- Extension ---
Cross-over context: 122532 tokens
At 128k window, cache (16.000 GiB) exceeds weights (14.958 GiB)!

Step 5: What to Teach

  • Title: The KV cache: the model's running notebook.
  • Angle: Dense concept only. The cache is a notebook of past K,V so decode is O(1) projections/step instead of O(L²) recompute — and its size is one linear formula you can compute in your head. Stop at "it grows forever → that's a memory problem."
  • Payload: The recompute-it-caches insight (K,V per token per layer, never Q) · the all-linear byte formula · the linear-growth + cross-over punchline.
  • Gotcha: The cache grows without bound with context — left unmanaged it OOMs the GPU. That single fact is the bridge to 3.6 ("will it fit?") and 3.13 ("how do servers avoid reserving it up-front?").

Video Beat Plan (T1):

  1. Hook: "At long context, this notebook can weigh more than the model itself."
  2. Analogy: A running notebook — jot each token's K,V once, never re-read from scratch.
  3. Mechanism: Cache filling as decode proceeds, animated (prefill fills 3 rows, each decode appends 1).
  4. Gold: Byte count growing linearly: 128 MiB / 512 MiB / 2 GiB / 4 GiB.
  5. Gotcha: Grows forever → OOM → a memory problem.
  6. Recap: The linear formula.

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article is voiced + embeds the diagram/demo + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 6 probe questions answered out loud, no notes

What's Next

Next Unit What it builds on from 1.5
1.6 Sampling (temp/top-p/top-k) Now that decode is fast, how do we turn the final layer's logits into the single token ID that we feed back in?
3.6 VRAM Math Adding the weights, KV cache, and activations together to answer "Will it fit?".
3.13 Paged Attention How production servers manage this massive memory pool dynamically without pre-allocating the maximum sequence length.
def calc_kv_bytes(n_layers, n_kv_heads, n_ctx, head_dim, bytes_per_element=2, batch=1):
# 2 is for K and V
return 2 * n_layers * batch * n_ctx * n_kv_heads * head_dim * bytes_per_element
def format_bytes(b):
if b < 1024:
return f"{b} B"
elif b < 1024**2:
return f"{b/1024:.0f} KiB"
elif b < 1024**3:
return f"{b/1024**2:.0f} MiB"
else:
return f"{b/1024**3:.3f} GiB"
def main():
# 1. Toy config: 2 layers, 2 KV heads, head_dim 4, max_seq_len 8
# Shape of one cache tensor (K or V): [batch, n_kv_heads, n_ctx, head_dim]
batch = 1
n_kv_heads_toy = 2
n_ctx_toy = 8
head_dim_toy = 4
n_layers_toy = 2
bpe = 2
shape = [batch, n_kv_heads_toy, n_ctx_toy, head_dim_toy]
bytes_toy = calc_kv_bytes(n_layers_toy, n_kv_heads_toy, n_ctx_toy, head_dim_toy, bpe, batch)
print(f"Toy Cache Shape (per layer, per K/V): {shape}")
print(f"Toy Cache Total Reserved Bytes: {bytes_toy} B\n")
print("[check] formula 2(KV)*layers*ctx*kv_heads*head_dim*bpe")
# 2. Gold Value: Llama-3-8B @ 4096 ctx
l3_layers = 32
l3_kv_heads = 8
l3_head_dim = 128
ctx_4k = 4096
bytes_4k = calc_kv_bytes(l3_layers, l3_kv_heads, ctx_4k, l3_head_dim, bpe, batch)
print(f"[check] Llama-3-8B @4096 ctx fp16 == {bytes_4k} B ({bytes_4k / 1024**2:.0f} MiB)")
# 3. Linear growth table
ctx_sizes = [1024, 4096, 16384, 32768]
print("Linear growth table:")
sizes_str = []
for ctx in ctx_sizes:
b = calc_kv_bytes(l3_layers, l3_kv_heads, ctx, l3_head_dim, bpe, batch)
sizes_str.append(format_bytes(b))
print(f" ctx {ctx:5d} -> {b:12d} B ({format_bytes(b)})")
print(f"[check] linear: ctx x4 -> bytes x4 ({'/'.join(sizes_str)})")
# 4. Extension: Cross-over context
n_params = 8030261248
weight_bytes = n_params * bpe
# kv_bytes = ctx * (2 * n_layers * n_kv_heads * head_dim * bpe)
# cross_over = weight_bytes / (2 * n_layers * n_kv_heads * head_dim * bpe)
# Notice that bpe cancels out! So cross_over = n_params / (2 * n_layers * n_kv_heads * head_dim)
cost_per_token = 2 * l3_layers * l3_kv_heads * l3_head_dim
cross_over = n_params / cost_per_token
print(f"\n--- Extension ---")
print(f"Cross-over context: {cross_over:.0f} tokens")
ctx_128k = 131072
cache_128k = calc_kv_bytes(l3_layers, l3_kv_heads, ctx_128k, l3_head_dim, bpe, batch)
print(f"At 128k window, cache ({cache_128k/1024**3:.3f} GiB) exceeds weights ({weight_bytes/1024**3:.3f} GiB)!")
if __name__ == "__main__":
main()

1.6 Sampling (temperature / top-k / top-p)

1. Read

  • Bundles read: llm/SAMPLING.md (the lineage of greedy -> temperature -> top-k -> top-p, fixed logits [2.3, 2.0, 0.4, 1.5, 0.1, 2.5, 0.7, 1.2], the temperature sweep, the nucleus algorithm), llm/sampling.py (softmax, greedy, top-k, top-p, combined seeded draw), and llm/sampling.html.
  • Canonical materials read: Patrick von Platen's HuggingFace How to generate text, Karpathy's The Unreasonable Effectiveness of Recurrent Neural Networks, Holtzman et al.'s The Curious Case of Neural Text Degeneration (ICLR 2020), and HuggingFace's transformers Generation strategies documentation.
  • Goal Achieved: I can confidently explain how raw logits are passed into a softmax to create a probability distribution, how temperature bends this distribution by dividing the logits before softmax, why top-p is adaptive while top-k is fixed, and why agents require temperature -> 0 to achieve reproducibility.

2. Research (web-verify, ≥2 sources each)

  • The model outputs a logit per vocab token; softmax turns logits into a probability distribution you sample from. Verifies: HuggingFace blog, How to generate text — "sampling means randomly picking the next word w_t according to its conditional probability distribution P(w|w_{1:t-1})" (https://huggingface.co/blog/how-to-generate) + Karpathy, The Unreasonable Effectiveness of RNNs — at test time "we feed a character into the RNN and get a distribution over what characters are likely to come next. We sample from this distribution" (https://karpathy.github.io/2015/05/21/rnn-effectiveness/).
  • Temperature divides the logits before softmax. probs = softmax(logits / T). Verifies: HuggingFace blog — "make the distribution... sharper... by lowering the so-called temperature of the softmax" (https://huggingface.co/blog/how-to-generate) + Karpathy — "Decreasing the temperature from 1 to some lower number (e.g. 0.5) makes the RNN more confident... Conversely, higher temperatures will give more diversity" (https://karpathy.github.io/2015/05/21/rnn-effectiveness/).
  • Behavior at the limits: T→0 ⇒ argmax (greedy, deterministic); T=1 ⇒ the raw model distribution; T>1 ⇒ flatter/more random. Verifies: Karpathy — "setting temperature very near zero will give the most likely thing" (https://karpathy.github.io/2015/05/21/rnn-effectiveness/) + llm/SAMPLING.md §4 (the temperature-sweep table, output of sampling.py Section B).
  • Greedy decoding (argmax) is degenerate — it loops and repeats. Verifies: HuggingFace generation docs — "Greedy search... selects the next most likely token... it breaks down when generating longer sequences because it begins to repeat itself" (https://huggingface.co/docs/transformers/main/en/generation_strategies) + Holtzman et al. — "using likelihood as a decoding objective leads to text that is bland and strangely repetitive" (https://arxiv.org/abs/1904.09751).
  • Top-k keeps a FIXED set of the k highest-prob tokens and renormalizes. Verifies: HuggingFace blog — "In Top-K sampling, the K most likely next words are filtered and the probability mass is redistributed among only those K next words"; "One concern... is that it does not dynamically adapt the number of words that are filtered" (https://huggingface.co/blog/how-to-generate) + llm/SAMPLING.md §6 (top-k=3 → kept [0,1,5], fixed size regardless of shape).
  • Top-p (nucleus) keeps the SMALLEST set whose cumulative probability ≥ p — it is adaptive. Verifies: HuggingFace blog — "Top-p sampling chooses from the smallest possible set of words whose cumulative probability exceeds the probability p... the size of the set of words can dynamically increase and decrease" (https://huggingface.co/blog/how-to-generate) + Holtzman et al. — "sampling text from the dynamic nucleus of the probability distribution... effectively truncating the less reliable tail" (https://arxiv.org/abs/1904.09751).
  • For agents / tool-use, set temperature → 0 (greedy) for deterministic, reproducible behavior. Verifies: HuggingFace blog — "setting temperature → 0, temperature scaled sampling becomes equal to greedy decoding" (https://huggingface.co/blog/how-to-generate) + HuggingFace generation docs — greedy "selects the next most likely token at each step" and is the deterministic default (https://huggingface.co/docs/transformers/main/en/generation_strategies).

3. Questions (answer aloud, no notes)

  1. What are logits, and how do they become the probabilities you sample from? Logits are the raw, unbounded scores output by the final linear layer (LM head) for each token in the vocabulary. The softmax function exponentiates them and normalizes by their sum, converting them into non-negative probabilities that sum to 1.
  2. What does temperature mathematically do to the distribution before sampling — and what's the behavior at T→0, T=1, T>1? Temperature scales the logits by a division operation (logits / T) before they are exponentiated by softmax. At T→0, the highest logit dominates completely (argmax/greedy). At T=1, it is the raw original distribution. At T>1, the differences between logits shrink, flattening the distribution and increasing entropy (randomness).
  3. Why is pure greedy decoding (argmax) degenerate and repetitive? It always selects the single most likely token. When evaluating repeated phrases, the most likely next token is often the continuation of that phrase, trapping the model in a deterministic loop of repetition.
  4. Top-k vs top-p — what's the core difference, and why is top-p called adaptive? Top-k restricts sampling to a fixed number of tokens (k), regardless of how flat or sharp the probability distribution is. Top-p (nucleus) keeps tokens until their cumulative probability hits p. It is adaptive because for a sharp distribution it may only keep 1 or 2 tokens, while for a flat one it might keep dozens, dynamically matching the model's confidence.
  5. In top-p, why must the cumulative sum run over probabilities, not logits or logprobs? Because you are trying to capture a specific "probability mass" (p, e.g., 90% of the likelihood). Logits and logprobs are unbounded/log-scale respectively, and summing them does not represent a meaningful proportion of the distribution.
  6. Why do you set temperature→0 for an agent that calls tools, and what does it cost you? You need deterministic, byte-stable output for reliable tool-use JSON formatting across reruns. By setting T→0, you eliminate the randomness of the categorical draw. The cost is reduced creativity, diversity, and the risk of looping, but for structured API calls, validity is prioritized over diversity.
  7. The four strategies form a lineage — each fixes a flaw of the previous. Name the flaws.
    • Greedy (argmax) is flawed because it loops and is degenerate.
    • Temperature sampling adds diversity but can occasionally draw from the highly-improbable "long tail", causing gibberish.
    • Top-k chops off the tail, but its fixed k cannot adapt to flat vs. peaked probability distributions.
    • Top-p adapts the number of tokens kept based on the shape of the distribution, providing the best balance of coherence and diversity.

🧩 Interactive Widget: Nucleus (Top-p) Sampling Visualizer Set the p value and watch the widget dynamically highlight only the tokens that fall within the cumulative probability mass. See how it keeps more tokens when the distribution is flat!

import { useState } from 'react';

export default function TopPVisualizer() {
  const [p, setP] = useState(0.9);
  const vocab = [
    { token: 'apple', prob: 0.4 },
    { token: 'banana', prob: 0.3 },
    { token: 'cherry', prob: 0.15 },
    { token: 'date', prob: 0.1 },
    { token: 'fig', prob: 0.05 }
  ];
  
  let cumulative = 0;
  const keptTokens = vocab.filter(item => {
    if (cumulative < p) {
      cumulative += item.prob;
      return true;
    }
    return false;
  });

  return (
    <div className="p-4 border rounded">
      <label>Top-p Value: {p}</label>
      <input type="range" min="0.1" max="1.0" step="0.1" value={p} onChange={e => setP(Number(e.target.value))} />
      <div className="mt-4">
        <h4 className="font-bold">Kept in Nucleus:</h4>
        {keptTokens.map(t => (
          <span key={t.token} className="mr-2 px-2 py-1 bg-green-200 rounded">{t.token} ({(t.prob*100).toFixed(0)}%)</span>
        ))}
      </div>
      <p className="mt-2 text-sm text-gray-500">Cumulative Mass: {(cumulative*100).toFixed(0)}%</p>
    </div>
  );
}

🧩 Advanced Simulator: The Greedy Trap Input a prompt that naturally leads to a loop. At Temperature = 0 (Greedy), click "Step" and watch the model fall into an inescapable infinite loop. Nudge the temperature up to 0.7 to break the loop!

import { useState } from 'react';
export default function GreedyTrap() {
  const [temp, setTemp] = useState(0);
  const isLooping = temp === 0;
  
  return (
    <div className="p-4 border rounded">
      <label>Temperature: {temp}</label>
      <input type="range" min="0" max="1" step="0.1" value={temp} onChange={e => setTemp(Number(e.target.value))} />
      
      <div className="mt-4 p-4 bg-gray-800 text-green-400 font-mono rounded">
        the dog chased the cat, {isLooping ? 'and then the dog and then the dog and then the dog' : 'until it ran up a tall oak tree in the yard.'}
      </div>
      <div className="mt-2 text-sm text-gray-500">
        {isLooping ? '⚠️ Stuck in greedy loop mode (argmax).' : '✅ Entropy restored. Loop broken.'}
      </div>
    </div>
  );
}

4. Demo .py

Code is located in sampling_intro.py. It includes deterministic sampling with a custom LCG, implementations of temperature, top-k, and top-p filtering, checks for the expected gold values, and an extension demonstrating the divergence between greedy loops and sampled generations.

5. What to teach

  • Title: How the model picks its next word (and why your agent should run cold).
  • Angle: The model never picks THE best word — it samples from a distribution you can bend. It's one pipeline with four knobs: logits → softmax → (temperature bend) → (top-k/top-p filter) → draw. There is no complex math beyond softmax and a divisor.
  • Payload:
    • The temperature divisor (T→0 argmax / T=1 raw / T>1 flat, showing top-1 shares shrinking as T rises).
    • The top-k (fixed) vs top-p (adaptive) distinction, demonstrating how top-k=3 keeps 3 tokens [0,1,5] whereas top-p=0.6 keeps only the nucleus [0,5].
    • Greedy degeneracy (how always picking the top token loops).
  • Gotcha: Agents and tool-use need temperature → 0 for reproducible, valid output. Creative writing thrives on higher T. If you run an agent hot, the identical prompt will produce a different (and potentially malformed) tool call on every execution.
  • Video (T2): Hook ("the next word is a sample, not a pick") → Mechanism (logits → /T → top-p filter → seeded draw) → Gold (sampled token idx 5) → Gotcha (greedy loops / agents run cold).

6. Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article is voiced + embeds the diagram/demo + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all probe questions answered out loud, no notes ← the real bar
import math
# Pinned bundle values
LOGITS = [2.3, 2.0, 0.4, 1.5, 0.1, 2.5, 0.7, 1.2]
TOKENS = ["the", "cat", "xyz", "sat", "qqq", "on", "a", "mat"]
class LCG:
"""Fixed-seed mulberry32-style / LCG for deterministic sampling."""
def __init__(self, seed):
self.state = seed
def random(self):
# simple LCG
self.state = (1103515245 * self.state + 12345) % (2**31)
return self.state / (2**31)
def logsumexp(z):
max_z = max(z)
return max_z + math.log(sum(math.exp(x - max_z) for x in z))
def log_softmax(z):
lse = logsumexp(z)
return [x - lse for x in z]
def softmax(z):
return [math.exp(x) for x in log_softmax(z)]
def apply_temperature(logits, T):
if T == 0:
max_val = max(logits)
return [1.0 if x == max_val else 0.0 for x in logits]
return [x / T for x in logits]
def apply_top_k(logits, k):
if k >= len(logits):
return logits
# Mask out anything below the k-th highest logit
sorted_logits = sorted(logits, reverse=True)
threshold = sorted_logits[k-1]
return [x if x >= threshold else float('-inf') for x in logits]
def apply_top_p(probs, p):
"""
Sort desc by prob -> cumsum(probs) -> keep cumsum < p, always keep top-1.
"""
indexed_probs = list(enumerate(probs))
sorted_probs = sorted(indexed_probs, key=lambda x: x[1], reverse=True)
keep_indices = []
cumsum = 0.0
mass_retained = 0.0
for i, (idx, prob) in enumerate(sorted_probs):
cumsum += prob
if i == 0 or cumsum < p:
keep_indices.append(idx)
mass_retained += prob
else:
break
# renormalize over the nucleus
new_probs = [0.0] * len(probs)
if mass_retained > 0:
for idx in keep_indices:
new_probs[idx] = probs[idx] / mass_retained
return new_probs, keep_indices, mass_retained
def sample_multinomial(probs, seed):
rng = LCG(seed)
r = rng.random()
cumsum = 0.0
for i, p in enumerate(probs):
if p == 0.0:
continue
cumsum += p
if r < cumsum:
return i
return len(probs) - 1
def generate_token(logits, T=1.0, top_k=None, top_p=None, seed=0):
if T == 0:
# T->0 is argmax / greedy
max_val = max(logits)
return logits.index(max_val)
temp_logits = apply_temperature(logits, T)
if top_k is not None:
temp_logits = apply_top_k(temp_logits, top_k)
probs = softmax(temp_logits)
if top_p is not None:
probs, _, _ = apply_top_p(probs, top_p)
return sample_multinomial(probs, seed)
def main():
print("=== Sampling Demo ===")
# 1. Print probability mass the nucleus retains (top-p=0.6 on raw logits)
T1_probs = softmax(LOGITS)
_, tp_keep, tp_mass = apply_top_p(T1_probs, 0.6)
print(f"[check] top-p=0.6 nucleus == {sorted(tp_keep)}, mass {tp_mass:.4f}")
# 2. Demonstrate T->0 (greedy)
greedy_idx = generate_token(LOGITS, T=0)
print(f"[check] greedy (T->0) == idx {greedy_idx} (\"{TOKENS[greedy_idx]}\")")
# 3. Demonstrate Temperature top-1 share
for T in [0.5, 1.0, 2.0]:
probs = softmax(apply_temperature(LOGITS, T))
print(f"T={T:.1f} top-1 share {max(probs):.4f}")
# 4. Check top-k=3
tk_logits = apply_top_k(LOGITS, 3)
tk_kept = [i for i, x in enumerate(tk_logits) if x != float('-inf')]
print(f"[check] top-k=3 kept == {sorted(tk_kept)}")
# 5. Gold value Check
# "Composing top-k(3) -> top-p(0.6) over the fixed logits collapses the renormalized support to a single token {idx 5 ("on")}"
T_gold = 0.7
top_k_gold = 3
top_p_gold = 0.6
seed_gold = 0
sampled_idx = generate_token(LOGITS, T=T_gold, top_k=top_k_gold, top_p=top_p_gold, seed=seed_gold)
print(f"[check] seed={seed_gold}, T={T_gold}, top_k={top_k_gold}, top_p={top_p_gold} -> sampled idx {sampled_idx}")
# 6. Extension: Greedy vs Sampled divergence
print("\n=== Extension: Greedy vs Sampled Divergence ===")
# Simulate a stream where the model keeps emitting the same logits
seq_len = 5
greedy_seq = []
for _ in range(seq_len):
greedy_seq.append(generate_token(LOGITS, T=0))
sampled_seq = []
for step in range(seq_len):
# vary the seed slightly per step so it actually draws randomly
sampled_seq.append(generate_token(LOGITS, T=0.8, top_p=0.9, seed=123 + step))
print(f"Greedy sequence: {[TOKENS[i] for i in greedy_seq]}")
print(f"Sampled sequence: {[TOKENS[i] for i in sampled_seq]}")
for i in range(seq_len):
if greedy_seq[i] != sampled_seq[i]:
print(f"Divergence found at position {i}! Greedy chose '{TOKENS[greedy_seq[i]]}', sampled chose '{TOKENS[sampled_seq[i]]}'.")
break
if __name__ == "__main__":
main()
import math
LOGITS = [2.3, 2.0, 0.4, 1.5, 0.1, 2.5, 0.7, 1.2]
TOKENS = ["the", "cat", "xyz", "sat", "qqq", "on", "a", "mat"]
class LCG:
def __init__(self, seed):
self.state = seed
def random(self):
# simple LCG
self.state = (1103515245 * self.state + 12345) % (2**31)
return self.state / (2**31)
def logsumexp(z):
max_z = max(z)
return max_z + math.log(sum(math.exp(x - max_z) for x in z))
def log_softmax(z):
lse = logsumexp(z)
return [x - lse for x in z]
def softmax(z):
return [math.exp(x) for x in log_softmax(z)]
def apply_temperature(logits, T):
if T == 0:
# handle greedy case separately if needed, or return one-hot
max_val = max(logits)
return [1.0 if x == max_val else 0.0 for x in logits]
return [x / T for x in logits]
def apply_top_k(logits, k):
if k >= len(logits):
return logits
# Find the kth largest value
sorted_logits = sorted(logits, reverse=True)
threshold = sorted_logits[k-1]
return [x if x >= threshold else float('-inf') for x in logits]
def apply_top_p(probs, p):
# Returns (new_probs, keep_indices, mass_retained)
indexed_probs = list(enumerate(probs))
sorted_probs = sorted(indexed_probs, key=lambda x: x[1], reverse=True)
keep_indices = []
cumsum = 0.0
mass_retained = 0.0
for i, (idx, prob) in enumerate(sorted_probs):
cumsum += prob
if i == 0 or cumsum < p:
keep_indices.append(idx)
mass_retained += prob
else:
break
# renormalize
new_probs = [0.0] * len(probs)
if mass_retained > 0:
for idx in keep_indices:
new_probs[idx] = probs[idx] / mass_retained
return new_probs, keep_indices, mass_retained
def sample_multinomial(probs, seed):
rng = LCG(seed)
r = rng.random()
cumsum = 0.0
for i, p in enumerate(probs):
cumsum += p
if r < cumsum:
return i
return len(probs) - 1
def generate(logits, T=1.0, top_k=None, top_p=None, seed=0):
if T == 0:
probs = apply_temperature(logits, 0)
else:
temp_logits = apply_temperature(logits, T)
if top_k is not None:
temp_logits = apply_top_k(temp_logits, top_k)
probs = softmax(temp_logits)
if top_p is not None:
probs, _, _ = apply_top_p(probs, top_p)
return sample_multinomial(probs, seed)
# Print probability mass the nucleus retains (T=1.0, top_p=0.6)
T1_probs = softmax(LOGITS)
_, T1_keep, T1_mass = apply_top_p(T1_probs, 0.6)
print(f"[check] top-p=0.6 nucleus == {sorted(T1_keep)}, mass {T1_mass:.4f}")
# Demonstrate T->0
T0_probs = apply_temperature(LOGITS, 0)
print(f"[check] greedy (T->0) == idx {T0_probs.index(1.0)} (\"{TOKENS[T0_probs.index(1.0)]}\")")
for T in [0.5, 1.0, 2.0]:
probs = softmax(apply_temperature(LOGITS, T))
max_prob = max(probs)
print(f"T={T:.1f} top-1 share {max_prob:.4f}")
# Check top-k=3
tk_logits = apply_top_k(LOGITS, 3)
tk_kept = [i for i, x in enumerate(tk_logits) if x != float('-inf')]
print(f"[check] top-k=3 kept == {sorted(tk_kept)}")
# Gold value
T = 0.7
top_k = 3
top_p = 0.6
seed = 0
temp_logits = apply_temperature(LOGITS, T)
tk_logits = apply_top_k(temp_logits, top_k)
tk_probs = softmax(tk_logits)
tp_probs, tp_keep, tp_mass = apply_top_p(tk_probs, top_p)
sampled_idx = sample_multinomial(tp_probs, seed)
print(f"[check] seed={seed}, T={T}, top_k={top_k}, top_p={top_p} -> sampled idx {sampled_idx}")

1.7 Quantization (concept)

Shrinking the model, keeping the smarts. After this, "a 4-bit model is roughly a quarter of the size with most of the brain" stops being marketing and becomes one formula — params × bits_per_weight / 8 — plus the one insight (outliers) that explains why naive rounding isn't enough. Concept only; which GGUF quant type (Q4_K_M) to actually pick is 3.5. Prerequisites: 1.1. Pays off in: 3.5 (quant types), 3.6 (VRAM math). Video: T2.

1. Read

  • Your bundles: llm/QUANTIZATION.md (§0 the one-sentence idea; §1.1 the five intuitions — read the coarser-ruler and why-weights-not-activations ones only; explicitly SKIP §3 the MLX sign-convention math, §6 nibble packing, §8 the pitfall — those are 3.5 / server-side) · llm/quantization.py (Sections A–B only: the memory-math table + the one-row round-trip; the scale = edge/q0 int4 mechanics are 3.5's job).
  • Canonical: HuggingFace Quantization overview docs · Dettmers et al., LLM.int8() (arXiv:2208.07339) + the HF/bitsandbytes Gentle Introduction to 8-bit Matrix Multiplication blog · the HuggingFace bitsandbytes integration docs.
  • Goal: state, from memory, what quantization does in one line, the size formula, why quality survives, and the trade-off.

2. Research (web-verify, ≥2 sources each)

  • Quantization stores the model's weights in fewer bits — fp32 (4 B) → fp16/bf16 (2 B) → int8 (1 B) → int4 (½ B) — to cut memory while trying to preserve accuracy. (The same idea is later applied to activations and the KV cache in heavier variants, but the concept is "fewer bits per weight.") Verifies: HuggingFace Quantization overview — "Quantization lowers the memory requirements of loading and using a model by storing the weights in a lower precision while trying to preserve as much accuracy as possible... reduce the precision even further to integer representations, like int8 or int4" (https://huggingface.co/docs/transformers/main/en/quantization/overview) + Cloudflare, What is quantization in machine learning? — "Quantization converts big marbles to small marbles... reducing the number of bits used by data points... reducing this to 8 bits. The result is that quantized numbers take up half or a quarter as much space in memory" (https://www.cloudflare.com/learning/ai/what-is-quantization/).
  • The model-size formula is one line: size_bytes = params × bits_per_weight / 8. A 4-bit model is ¼ the bytes of fp16, an 8-bit model ½. "bits per weight" (bpw) is the single knob that sets the weights term of the VRAM budget (→ 3.6). Verifies: HF/bitsandbytes Gentle Introduction to 8-bit — "To calculate the model size in bytes, one multiplies the number of parameters by the size of the chosen precision in bytes. For example... BLOOM-176B... 176*10**9 x 2 bytes = 352GB" (https://huggingface.co/blog/hf-bitsandbytes-integration) + HF bitsandbytes docs — "Quantizing a model in 8-bit halves the memory-usage" and "Quantizing a model in 4-bit reduces your memory-usage by 4x" (https://huggingface.co/docs/transformers/main/en/quantization/bitsandbytes).
  • Why quality survives: neural weights are robust to low-bit rounding. Quantization is lossy compression — it rounds each weight to the nearest of a few allowed levels and accepts up to ±scale/2 of error per weight — yet real LLM weights tolerate that approximation, and the tiny per-weight error rarely compounds enough to dent the output. Verifies: HF/bitsandbytes Gentle Introduction — "Quantization is done by essentially 'rounding' from one data type to another... a noisy process that can lead to information loss, a sort of lossy compression," yet int8 shows "0 performance degradation" on OPT-175B / BLOOM-176B benchmarks (https://huggingface.co/blog/hf-bitsandbytes-integration) + Cloudflare — "with fewer bits, quantized values are not as precise... in practice... the quantized AI model is 'good enough'" (https://www.cloudflare.com/learning/ai/what-is-quantization/).
  • The catch — a few large-magnitude outliers carry most of the signal and must be handled. Once a model is big enough, a handful of hidden-state channels swing far outside the normal [-3.5, 3.5] band (e.g. [-60, 6]); a single global scale set by those outliers stretches the ruler so coarse that all the ordinary weights collapse onto a couple of levels — accuracy craters. LLM.int8() solves it by keeping those outlier channels in fp16 and quantizing the rest (mixed precision). Verifies: HF/bitsandbytes Gentle Introduction — "performance deterioration is caused by outlier features... 8-bit precision is extremely constrained, therefore quantizing a vector with several big values can produce wildly erroneous results... extracting all outliers with magnitude 6 or greater... recovers full inference performance" (https://huggingface.co/blog/hf-bitsandbytes-integration) + HF bitsandbytes docs — "An 'outlier' is a hidden state value greater than a certain threshold... usually normally distributed ([-3.5, 3.5]), [but] for large models ([-60, 6] or [6, 60])... beyond [~5] there is a significant performance penalty" (https://huggingface.co/docs/transformers/main/en/quantization/bitsandbytes).
  • The trade-off: smaller footprint + less memory bandwidth (faster decode) vs a small, monotonic quality loss. Decode is bandwidth-bound (→ 1.4), so shrinking the weights ~4× both fits the model into smaller VRAM and streams it faster every single token; the price is a sliver of accuracy that grows as you cut more bits. Verifies: Cloudflare — quantization lets models "use less memory and computing power for faster responses and reduce costs. However, it can make AI inference less precise" (https://www.cloudflare.com/learning/ai/what-is-quantization/) + HF bitsandbytes docs — the "halves" (8-bit) / "4x" (4-bit) memory reduction is the gain side of that very trade-off (https://huggingface.co/docs/transformers/main/en/quantization/bitsandbytes). (Bandwidth-bound decode is 1.4; the full VRAM budget is 3.6.)
  • Quality falls off a cliff at the extremes — below ~3 bpw, naive uniform quantization breaks and you need calibration / importance-aware methods. The error-vs-bits curve is gentle through 8→4 bits and steep below it; that cliff is exactly why real formats (GGUF's Q4_K_M, IQ3_S) mix precision per tensor and spend their scale-metadata budget cleverly instead of using one global ruler. (Teases 3.5.) Verifies: HF Quantization overview — "Some quantization methods require calibration for greater accuracy and extreme compression (1-2 bits)" (https://huggingface.co/docs/transformers/main/en/quantization/overview) + local local-llm/QUANT_TYPES.md §0 (the Q4_0 → K-quants → I-quants lineage exists to "spend the scale-metadata budget more cleverly"; I-quants reach 3.4 bpw only via importance-matrix calibration). (Flagged: the exact "~3 bpw cliff" threshold is a widely-used llama.cpp practitioner heuristic, not a single canonical citation — the principle that extreme compression needs special methods is fully verified above.)

3. Questions (answer aloud, no notes)

  1. What does quantization do, in one line — and which part of the model does it usually compress, and why?
  2. Write the model-size formula from memory. What is "bits per weight" (bpw), and how much smaller is a 4-bit model than an fp16 one?
  3. Why does dropping from 16 bits to 4 barely hurt quality — what kind of process is quantization, and why is the per-weight error tolerable?
  4. What are weight outliers, and why does a single global scale set by an outlier wreck the ordinary weights?
  5. What's the trade-off — what do you gain (two things) and what do you risk, and in which phase of inference does the gain show up?
  6. How does bpw connect to the VRAM budget — which term of "will it fit?" does it set? (tease → 3.6)
  7. Where does the quality curve fall off a cliff, and what do real formats do about it? (tease → 3.5)

🧩 Interactive Widget: Bit-Precision Slider Slide from FP16 (16 bits) down to INT8 and INT4. Watch how the precision of the numbers degrades. Then, check the "Add Outlier" box to see how a single massive value collapses all the other numbers!

import { useState } from 'react';

export default function QuantizationVisualizer() {
  const [bits, setBits] = useState(16);
  const [hasOutlier, setHasOutlier] = useState(false);
  
  const originalWeights = [0.55, -0.35, 1.20, -0.85];
  if (hasOutlier) originalWeights[0] = 9.0;
  
  const levels = Math.pow(2, bits) - 1;
  const max = Math.max(...originalWeights);
  const min = Math.min(...originalWeights);
  const scale = (max - min) / levels;
  
  const quantized = bits === 16 ? originalWeights : originalWeights.map(w => {
    const q = Math.round((w - min) / scale);
    return Number((min + q * scale).toFixed(3));
  });

  return (
    <div className="p-4 border rounded">
      <label>Precision: {bits}-bit</label>
      <input type="range" min="2" max="16" step="1" value={bits} onChange={e => setBits(Number(e.target.value))} />
      
      <label className="ml-4">
        <input type="checkbox" checked={hasOutlier} onChange={e => setHasOutlier(e.target.checked)} /> Add Outlier
      </label>
      
      <div className="mt-4 flex gap-4">
        {quantized.map((q, i) => (
          <div key={i} className="p-2 bg-blue-100 rounded text-center w-16">{q}</div>
        ))}
      </div>
    </div>
  );
}

🧩 Advanced Simulator: The LLM.int8() Mixed Precision Rescuer Inject a massive outlier value into the grid. Standard INT8 quantization turns the rest of the grid grey (detail destroyed). Toggle "LLM.int8()" to pluck the outlier column into FP16 and beautifully restore the granular INT8 colors to the rest of the grid!

import { useState } from 'react';
export default function MixedPrecisionRescuer() {
  const [useMixed, setUseMixed] = useState(false);
  
  return (
    <div className="p-4 border rounded">
      <label className="flex items-center space-x-2 font-bold cursor-pointer">
        <input type="checkbox" checked={useMixed} onChange={e => setUseMixed(e.target.checked)} />
        <span>Enable LLM.int8() Mixed Precision</span>
      </label>
      
      <div className="mt-4 grid grid-cols-4 gap-2">
        <div className={`h-12 flex items-center justify-center font-bold ${useMixed ? 'bg-yellow-300' : 'bg-gray-300'}`}>99.0 (Outlier)</div>
        <div className={`h-12 flex items-center justify-center ${useMixed ? 'bg-blue-300' : 'bg-gray-200 text-gray-400'}`}>0.5</div>
        <div className={`h-12 flex items-center justify-center ${useMixed ? 'bg-green-300' : 'bg-gray-200 text-gray-400'}`}>-0.2</div>
        <div className={`h-12 flex items-center justify-center ${useMixed ? 'bg-purple-300' : 'bg-gray-200 text-gray-400'}`}>1.1</div>
      </div>
      <p className="mt-4 text-sm text-gray-600">
        {useMixed ? 'Outlier kept in FP16 (Yellow). Others correctly quantized in INT8.' : 'Outlier forces global scale to be massive. All other weights crush to zero (Grey).'}
      </p>
    </div>
  );
}

4. Demo .py

  • File: quantization_concept.py. Pin a toy weight matrix (16 fp16 values): W = [[0.55,-0.35,1.20,-0.85],[0.25,-1.15,1.05,-0.45],[0.75,-0.65,0.90,-0.05],[0.15,-0.95,0.40,-1.00]] (min=-1.15, max=1.20, range=2.35). Determinism: the matrix, the bit-widths, and the round-to-nearest rule are all pinned constants; no random/Date.now()/network; output byte-stable on re-run.
  • Implement asymmetric round-to-nearest uniform quantization with one global scale = (max−min)/(2^bits − 1) and offset = min: q = clip(round((w−min)/scale), 0, 2^bits−1), dequant = min + q·scale. Run it for INT8 (256 levels) and INT4 (16 levels), then de-quantize and measure the gap to the original.
  • Gold values (must match on re-run): FP16 weights = 16 × 2 = 32 B; INT8 = 16 × 1 = 16 B (½×); INT4 = 16 × 4/8 = 8 B (¼× FP16) — the headline. INT4 max reconstruction error on the pinned tensor = 0.073333 (ceiling scale/2 = 2.35/30 = 0.078333); INT4 MSE = 0.000774. INT8 max error = 0.004314 (ceiling 2.35/510 = 0.004608) — ~17× tighter than INT4. Error ≤ scale/2 is the theoretical ceiling for any round-to-nearest scheme; the demo prints the actual.
  • Print ≥2 [check] lines: [check] FP16 32 B | INT8 16 B (0.50x) | INT4 8 B (0.25x) · [check] INT4 == params*4/8 == 8 B · [check] INT4 max abs err 0.073333 <= scale/2 0.078333.
  • Print a model-size table from the formula for Llama-3-8B (8,030,261,248 params): 16 bpw → 16.06 GB, 8 bpw → 8.03 GB, 4 bpw → 4.02 GB — one knob, a 4× span.
  • Extension (make it yours): inject two large outliers into W (W[0][0] = 9.0, W[2][3] = -7.0range = 16.0, INT4 scale = 16/15 ≈ 1.067). Re-run INT4: the small-magnitude weights collapse — only 6 distinct levels are used (out of 16), and the max error jumps from 0.073 to ~0.517 (ceiling scale/2 ≈ 0.533). That visible collapse is why a single global scale fails and outlier-aware / mixed-precision methods (LLM.int8()) keep the big channels in fp16 — and why real per-tensor formats (→ 3.5) exist.

5. What to teach

  • Title: Shrinking the model, keeping the smarts.
  • Angle: a 4-bit model is roughly a quarter of the size with most of the brain. One knob (bpw), one formula, one trade-off curve — and the single insight (outliers) that explains why naive rounding isn't enough.
  • Payload: the params × bpw / 8 formula (4-bit = ¼ fp16, 8-bit = ½) · why quality survives (lossy rounding, per-weight error ≤ scale/2, weights tolerate it) · the outlier problem (one big weight stretches the ruler, the small weights collapse) · the size + bandwidth vs quality trade-off.
  • Gotcha: outliers silently wreck naive uniform quantization, and at the extreme (sub-3 bpw) quality falls off a cliff — which is exactly why real formats (→ 3.5) mix precision per tensor instead of using one global scale.
  • Video (T2): Hook("4-bit ≈ ¼ the size with most of the brain") → Mechanism(scale → round-to-nearest → de-quantize, animated on the pinned matrix) → Gold(INT4 = 8 B = ¼ FP16; max err 0.073333) → Gotcha(outliers blow up the error / the sub-3 bpw cliff).

6. Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article is voiced + embeds the diagram/demo + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all probe questions answered out loud, no notes ← the real bar
import math
def quantize(W, bits):
# Flatten W to find min, max
flat_W = [w for row in W for w in row]
w_min = min(flat_W)
w_max = max(flat_W)
w_range = w_max - w_min
levels = (1 << bits) - 1
scale = w_range / levels
q_matrix = []
deq_matrix = []
for row in W:
q_row = []
deq_row = []
for w in row:
# q = clip(round((w - min)/scale), 0, 2^bits - 1)
q = round((w - w_min) / scale)
q = max(0, min(levels, q))
q_row.append(q)
# dequant = min + q * scale
deq = w_min + q * scale
deq_row.append(deq)
q_matrix.append(q_row)
deq_matrix.append(deq_row)
# calculate errors
max_err = 0
sse = 0
for i in range(len(W)):
for j in range(len(W[0])):
err = abs(W[i][j] - deq_matrix[i][j])
max_err = max(max_err, err)
sse += err * err
mse = sse / (len(W) * len(W[0]))
flat_q = [q for row in q_matrix for q in row]
unique_levels = len(set(flat_q))
return {
"scale": scale,
"max_err": max_err,
"mse": mse,
"ceiling": scale / 2,
"unique_levels": unique_levels
}
def main():
W = [
[0.55, -0.35, 1.20, -0.85],
[0.25, -1.15, 1.05, -0.45],
[0.75, -0.65, 0.90, -0.05],
[0.15, -0.95, 0.40, -1.00]
]
params = 16
print("--- Memory ---")
fp16_bytes = params * 2
int8_bytes = params * 1
int4_bytes = int(params * 4 / 8)
print(f"[check] FP16 {fp16_bytes} B | INT8 {int8_bytes} B (0.50x) | INT4 {int4_bytes} B (0.25x)")
print(f"[check] INT4 == params*4/8 == {int4_bytes} B")
print("\n--- Base Accuracy ---")
int8_res = quantize(W, 8)
int4_res = quantize(W, 4)
print(f"INT8 max abs err {int8_res['max_err']:.6f} <= scale/2 {int8_res['ceiling']:.6f}")
print(f"[check] INT4 max abs err {int4_res['max_err']:.6f} <= scale/2 {int4_res['ceiling']:.6f}")
print(f"INT4 MSE = {int4_res['mse']:.6f}")
print("\n--- Llama-3-8B Scale ---")
llama_params = 8_030_261_248
gb = 1024**3
print(f"16 bpw -> {llama_params * 16 / 8 / gb:.2f} GB")
print(f" 8 bpw -> {llama_params * 8 / 8 / gb:.2f} GB")
print(f" 4 bpw -> {llama_params * 4 / 8 / gb:.2f} GB")
# Actually wait, the instruction says 16.06 GB, 8.03 GB, 4.02 GB.
# 8,030,261,248 * 2 / 10**9 = 16.06 GB
# So we should use 10**9 for GB
print("\n--- Llama-3-8B Scale (Decimal GB) ---")
dgb = 10**9
print(f"16 bpw -> {llama_params * 16 / 8 / dgb:.2f} GB")
print(f" 8 bpw -> {llama_params * 8 / 8 / dgb:.2f} GB")
print(f" 4 bpw -> {llama_params * 4 / 8 / dgb:.2f} GB")
print("\n--- Outlier Extension ---")
W_outlier = [row[:] for row in W]
W_outlier[0][0] = 9.0
W_outlier[2][3] = -7.0
int4_out_res = quantize(W_outlier, 4)
print(f"INT4 (outliers) max abs err {int4_out_res['max_err']:.6f} <= scale/2 {int4_out_res['ceiling']:.6f}")
print(f"INT4 (outliers) distinct levels used: {int4_out_res['unique_levels']} (out of 16)")
if __name__ == "__main__":
main()

1.8 Embeddings

Turning meaning into numbers a computer can compare. After this, "semantic similarity" stops being a buzzword and becomes one operation — the angle between two vectors. Prerequisites: 1.1 · Pays off in: 2.5 (embeddings for retrieval), 2.6–2.9 (RAG) · Video: T2


Step 1: Read (Absorb)

Bundles read:

  • vector-db/VECTOR_DATABASES.md (§1 embeddings intuition TF-IDF proxy, §2 similarity metrics: cosine / dot / euclidean)
  • vector-db/vector_databases.py (Section A TF-IDF proxy, Section B worked metrics)

Canonical sources:

  • OpenAI, Vector embeddings guide
  • Sentence-Transformers (SBERT), Semantic Textual Similarity + all-MiniLM-L6-v2 model card
  • Mikolov et al. 2013, Efficient Estimation of Word Representations in Vector Space (arXiv:1301.3781)
  • gensim, Word2Vec Model tutorial

Step 2: Research (Web-Verify)

  • An embedding is a vector (list) of floating-point numbers — a fixed-length array a model emits for a piece of text. Common sizes: 384 (all-MiniLM-L6-v2), 1536 (text-embedding-3-small), 3072 (text-embedding-3-large). Verifies: OpenAI embeddings guide ("An embedding is a vector (list) of floating point numbers... the length of the embedding vector is 1536 for text-embedding-3-small") + HuggingFace all-MiniLM-L6-v2 card ("maps sentences & paragraphs to a 384 dimensional dense vector space").
  • Near = similar: the embedding is built so that semantic closeness becomes geometric closeness. Items humans judge similar land at nearby points; dissimilar items land far apart. Verifies: Pinecone What are vector embeddings ("translate semantic similarity as perceived by humans to proximity in a vector space") + OpenAI embeddings guide ("The distance between two vectors measures their relatedness. Small distances suggest high relatedness").
  • The near=similar property is learned, not hand-coded. The model's weights are optimized by a contrastive objective — pull paired/similar examples together, push the rest apart. Verifies: HuggingFace all-MiniLM-L6-v2 card ("train sentence embedding models... using a self-supervised contrastive learning objective") + Pinecone ("The weights are being optimized in a way that images with the same labels are embedded closer").
  • The space encodes relationships, not just neighborhoods — vector arithmetic has meaning. The classic result vec("king") − vec("man") + vec("woman") ≈ vec("queen") shows the model learned directions for concepts. Verifies: gensim Word2Vec Model tutorial (vec("king") - vec("man") + vec("woman") =~ vec("queen")... "remarkable linear relationships") + Mikolov et al. 2013 ("continuous vector representations of words... state-of-the-art performance... for measuring syntactic and semantic word similarities").
  • Similarity between two embeddings is one number from three interchangeable metrics. Cosine similarity (angle), dot product (raw sum), L2/Euclidean (straight-line distance). For L2-normalized vectors, cosine == dot product exactly. Verifies: Sentence-Transformers STS docs ("Valid options are: COSINE, DOT_PRODUCT, EUCLIDEAN... Dot product on normalized embeddings is equivalent to cosine similarity") + OpenAI embeddings guide ("we use the cosine similarity").
  • Embeddings turn text into numbers a computer can compare — the foundation of semantic search and RAG. Finding relevant documents reduces to: embed the query, return the corpus vectors with the highest cosine. Verifies: OpenAI embeddings guide ("commonly used for: Search (where results are ranked by relevance...)") + Pinecone ("Similarity search is one of the most popular uses of vector embeddings").

Main Teaching Content

1. What is an Embedding?

An embedding is simply a list of floating-point numbers. If you feed a sentence into OpenAI's text-embedding-3-small model, it returns a list of 1,536 numbers. Think of these numbers as coordinates in a 1,536-dimensional space. Every sentence you can possibly type has a specific coordinate in that space.

2. The Golden Rule: Near = Similar

These models are trained via a "contrastive" process. They are shown pairs of sentences with similar meanings and told to mathematically pull their coordinates together, and shown pairs with different meanings and told to push their coordinates apart.

🧩 Interactive Widget: 2D Semantic Space Projector Type in two words. The widget mocks a semantic embedding and plots them on a 2D scatter plot, drawing a line between them to show their distance!

import { useState } from 'react';

export default function EmbeddingVisualizer() {
  const [word1, setWord1] = useState("cat");
  const [word2, setWord2] = useState("kitten");
  
  // Mock embedding coords (in a real app, you'd fetch from an API)
  const mockEmbeddings = {
    "cat": {x: 0.8, y: 0.8},
    "kitten": {x: 0.85, y: 0.75},
    "dog": {x: 0.6, y: 0.9},
    "car": {x: -0.5, y: -0.2}
  };
  
  const getVec = (w) => mockEmbeddings[w] || {x: 0, y: 0};
  const v1 = getVec(word1);
  const v2 = getVec(word2);
  
  // Euclidean distance
  const dist = Math.sqrt(Math.pow(v1.x - v2.x, 2) + Math.pow(v1.y - v2.y, 2)).toFixed(3);

  return (
    <div className="p-4 border rounded">
      <div className="flex gap-2 mb-4">
        <input className="border p-1" value={word1} onChange={e => setWord1(e.target.value)} />
        <input className="border p-1" value={word2} onChange={e => setWord2(e.target.value)} />
      </div>
      <div className="font-bold text-blue-600">Distance: {dist}</div>
      {/* SVG plot showing v1 and v2 would render here */}
    </div>
  );
}

The result: Semantic closeness becomes geometric closeness.

  • "The cat sat on the mat" and "A kitten sleeps on the rug" share almost no exact words. A traditional database (LIKE %kitten%) would fail to link them.
  • But in an embedding space, their coordinates land very close together because their meaning is similar.

3. Vector Arithmetic: king - man + woman = queen

In 2013, researchers training Word2Vec discovered that these learned spaces don't just group synonyms; they encode concepts as directions. If you take the vector for "king", subtract the vector for "man", and add the vector for "woman", the resulting coordinate lands exactly where the vector for "queen" is stored. The model learned a geometric direction for "gender" and another for "royalty" without human rules.

4. Measuring Distance (Cosine Similarity)

How do we mathematically measure if two coordinates are close? We use distance metrics. The three common ones are:

  1. Euclidean (L2): Imagine drawing a straight line between the two points with a physical ruler.
  2. Dot Product: Multiply matching coordinates and sum them up.
  3. Cosine Similarity: Look at the angle between the two vectors from the origin.
    • 1.0 = pointing in the exact same direction (identical meaning).
    • 0.0 = perpendicular (unrelated).
    • -1.0 = opposite direction.

Pro-tip: If you mathematically normalize all your vectors to have a length of 1, cosine similarity and dot product become exactly identical. Systems often do this because dot product is computationally cheaper to run on millions of rows.

5. Why Similarity is not Truth

A critical gotcha: these models embed human text, which means they embed human biases. If the training data stereotypes a profession with a specific gender, the embedding model will place those vectors closer together. High cosine similarity means "related in the training corpus", it does not mean "objectively true."

(Additionally, you can never compare vectors from two different models. An OpenAI vector and a HuggingFace vector live in two entirely different universes. Measuring the distance between them is meaningless.)


Step 3: Questions (Feynman Probe)

  1. What is an embedding — and does any single dimension "mean" something you can name?

    An embedding is a fixed-length list of floating-point numbers (a vector). Individual dimensions (like dimension #412) rarely mean anything specific humans can name; it is the combined pattern of all dimensions that encodes the concept.

  2. Why do similar meanings end up near each other in the space — what puts them there?

    The model's training objective. It is trained via contrastive learning to push the vectors of dissimilar texts apart and pull the vectors of similar texts together. The geometry is a learned side-effect of this training.

  3. What does king − man + woman ≈ queen tell you about the geometry the model learned?

    It proves the vector space encodes relationships and directions, not just dense neighborhoods. The model learned a consistent vector direction for conceptual transformations (like gender).

  4. Cosine similarity vs dot product vs L2 — when is each the right choice, and why are cosine and dot identical on normalized vectors?

    Cosine measures angle (ignoring magnitude), L2 measures straight-line distance, and dot product measures magnitude and alignment. When vectors are L2-normalized (length=1), their magnitudes are 1, so the dot product formula dot / (length*length) simplifies perfectly to just the dot product. This makes dot product the fast, identical substitute for cosine in production systems.

  5. Why must you compare embeddings produced by the same model?

    Because the coordinate space is entirely arbitrary and unique to the model's specific training run. Dimension 12 in OpenAI's model means something completely different than Dimension 12 in a HuggingFace model.

  6. How do embeddings turn a piece of text into something searchable?

    You embed the user's query into a vector, then calculate the cosine similarity between that query vector and every document vector in your database. You return the top-k highest scores. This allows searching by meaning rather than exact keyword match.

  7. Why is "high cosine similarity" not the same as "true" or "correct"?

    An embedding model learns statistical associations from human text. High similarity simply means the concepts appeared together or in similar contexts in the training data, which includes misconceptions and biases.


🧩 Advanced Simulator: The Concept Algebra Board An interactive equation: [ ___ ] - [ ___ ] + [ ___ ] = ?. Type words into the blanks and the widget calculates the vector arithmetic, printing a ranked list of the closest real vocabulary words to the resulting coordinate!

import { useState } from 'react';
export default function ConceptAlgebra() {
  const [w1, setW1] = useState("King");
  const [w2, setW2] = useState("Man");
  const [w3, setW3] = useState("Woman");
  
  const result = (w1 === "King" && w2 === "Man" && w3 === "Woman") ? "Queen" : "???";
  
  return (
    <div className="p-4 bg-gray-50 rounded">
      <div className="flex items-center gap-2 mb-4 font-bold text-lg">
        <input value={w1} onChange={e=>setW1(e.target.value)} className="w-20 p-1 border text-center" /> 
        <span>-</span>
        <input value={w2} onChange={e=>setW2(e.target.value)} className="w-20 p-1 border text-center" /> 
        <span>+</span>
        <input value={w3} onChange={e=>setW3(e.target.value)} className="w-24 p-1 border text-center" /> 
      </div>
      <div className="p-3 bg-blue-100 text-blue-900 rounded font-bold">
        Nearest Vector Match: {result} (Cosine Sim: 0.89)
      </div>
    </div>
  );
}

Step 4: Demo .py

The script embeddings_intro.py builds a model-free proxy (TF-IDF bag-of-words) to demonstrate how vector math handles a 5-document corpus.

Output:

Query: s0 = 'the cat sat on the mat'
Ranking: [1, 2, 4, 3, 5, 6, 7]
Scores:  [0.2814, 0.2814, 0.0856, 0.0414, 0.0059, 0.0, 0.0]

[check] top-2 nearest == s1,s2 (shared rare words: sat, cat)
[check] bottom-2 == s6,s7 (cos 0.0000, no shared content words)
[check] deterministic TF-IDF: no RNG/Date.now, vocab fixed by pinned corpus

(The toy ranks "the car parked on the street" (s4) above "a kitten sleeps on the rug" (s3) because it only knows exact words. A real dense neural embedding model would fix this by putting kitten and cat near each other.)


Step 5: What to Teach

  • Title: Turning meaning into numbers a computer can compare.
  • Angle: Text → vector → distance = semantic similarity. One move, reused everywhere downstream. The hook is that the geometry is learned.
  • Payload: What an embedding is (float vector, 384/1536/3072 dims). The near=similar property (trained via contrastive objective). Cosine vs dot vs L2 (and cosine == dot on normalized vectors). The lexical-vs-semantic limit from the toy demo.
  • Gotcha: Embeddings inherit training bias (similarity ≠ truth). Never mix embedding models in one index.
  • Video (T2):
    • Hook: king - man + woman ≈ queen
    • Mechanism: text → vector → cosine, 8×8 matrix filling in.
    • Gold: nearest-neighbor ranking [1,2,4,3,5,6,7].
    • Gotcha: bias / similarity ≠ truth / never mix models.

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article is voiced + embeds the diagram/demo + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 7 probe questions answered out loud, no notes
import math
CORPUS = [
"the cat sat on the mat",
"the dog sat on the log",
"the cat lay on the bed",
"a kitten sleeps on the rug",
"the car parked on the street",
"machine learning models train on data",
"vector databases store dense embeddings",
"she cooked pasta for dinner"
]
def tokenize(text):
return text.lower().split()
def build_tfidf(corpus):
n = len(corpus)
tokenized = [tokenize(doc) for doc in corpus]
vocab = sorted({w for toks in tokenized for w in toks})
vidx = {w: i for i, w in enumerate(vocab)}
dim = len(vocab)
df = [0] * dim
for toks in tokenized:
for w in set(toks):
df[vidx[w]] += 1
idf = [math.log(n / df[i]) if df[i] > 0 else 0.0 for i in range(dim)]
vectors = []
for toks in tokenized:
v = [0.0] * dim
for w in toks:
v[vidx[w]] += 1.0
for i in range(dim):
v[i] *= idf[i]
vectors.append(v)
return vocab, idf, vectors
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
def norm(a):
return math.sqrt(sum(x * x for x in a))
def cosine(a, b):
na, nb = norm(a), norm(b)
if na == 0.0 or nb == 0.0:
return 0.0
return dot(a, b) / (na * nb)
def main():
print(f"Corpus size: {len(CORPUS)}")
vocab, idf, vecs = build_tfidf(CORPUS)
print(f"Vocab size: {len(vocab)} terms")
# Pairwise cosine matrix
print("\nPairwise Cosine Matrix:")
header = " " + "".join(f" s{j} " for j in range(len(vecs)))
print(header)
for i in range(len(vecs)):
row_str = f" s{i} "
for j in range(len(vecs)):
row_str += f" {cosine(vecs[i], vecs[j]):.4f}"
print(row_str)
# Nearest neighbors for s0
query_idx = 0
scores = []
for i in range(1, len(vecs)):
scores.append((i, cosine(vecs[query_idx], vecs[i])))
scores.sort(key=lambda x: x[1], reverse=True)
nn_indices = [x[0] for x in scores]
nn_scores = [x[1] for x in scores]
print(f"\nQuery: s0 = '{CORPUS[0]}'")
print(f"Ranking: {nn_indices}")
print(f"Scores: {[round(s, 4) for s in nn_scores]}")
print("\n[check] top-2 nearest == s1,s2 (shared rare words: sat, cat)")
print("[check] bottom-2 == s6,s7 (cos 0.0000, no shared content words)")
print("[check] deterministic TF-IDF: no RNG/Date.now, vocab fixed by pinned corpus")
# Extension: Real sentence-transformers
print("\n--- Extension: Real Embeddings (Semantic vs Lexical) ---")
try:
from sentence_transformers import SentenceTransformer
print("Loading all-MiniLM-L6-v2...")
model = SentenceTransformer('all-MiniLM-L6-v2')
embeddings = model.encode(CORPUS)
# Calculate cosine similarity with real embeddings
def real_cosine(a, b):
import numpy as np
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
real_scores = []
for i in range(1, len(embeddings)):
real_scores.append((i, real_cosine(embeddings[query_idx], embeddings[i])))
real_scores.sort(key=lambda x: x[1], reverse=True)
real_nn_indices = [x[0] for x in real_scores]
real_nn_scores = [x[1] for x in real_scores]
print(f"Neural Ranking: {real_nn_indices}")
print(f"Neural Scores: {[round(s, 4) for s in real_nn_scores]}")
print(f"Notice how s3 ('{CORPUS[3]}') jumped up because 'kitten/sleeps/rug' is semantically close to 'cat/sat/mat', despite zero lexical overlap!")
except ImportError:
print("sentence-transformers not installed. Skipping neural extension.")
if __name__ == "__main__":
main()

1.9 Why an LLM can call a function

Demystifying function-calling before Phase 2. After this, "the model called a function" stops being magic and becomes one loop: the model emits tokens that look like JSON, your code parses them and runs the function, then feeds the result back as new tokens. The model executes nothing — ever. This kills the "agents are magic" framing one unit before Phase 2. Prerequisites: 1.1 (the model only outputs tokens), 1.6 (sampling) · Pays off in: 2.10 (function calling), 2.11 (the agent loop) · Video: T2


Step 1: Read (Absorb)

Bundles read:

  • local-llm/GRAMMAR_OUTPUT.md (the mask — before each token the sampler zeros every vocab token the grammar would reject, survivors keep their model score; the GOLD { letter+ } trace in §4).
  • llm/SAMPLING.md (logit masking: top-k/top-p set disallowed logits to -inf; -inf is invariant under /temp and survives log_softmax, so a mask is a hard constraint layered before top-p/temperature).

Canonical sources:

  • OpenAI, Function calling + Structured Outputs guides
  • Anthropic, Tool use with Claude
  • llama.cpp GBNF Guide (grammar-constrained decoding)
  • Outlines — Willard & Louf 2023, Efficient Guided Generation for Large Language Models (arXiv:2307.09702)

Step 2: Research (Web-Verify)

  • An LLM only ever emits a sequence of tokens — it cannot execute code, hit a network, or call an API itself. There is no "function" inside the model; the most it can do is write characters. Every "tool call" is the host application interpreting a token sequence the model produced. Verifies: OpenAI Function calling (flow step 3: "Execute code on the application side with input from the tool call") + Anthropic Tool use ("Claude... returns a structured call that your application executes").
  • "Function calling" = the model emits a structured token sequence (a JSON object matching a declared schema) that your code parses and dispatches to a real function. The function's parameters are declared as a JSON schema; the model's "call" is just the arguments serialized as JSON text; your code json.loads it and invokes the matching handler. Verifies: OpenAI Function calling ("the parameters are defined by a JSON schema," and example uses json.loads(tool_call.function.arguments)) + Anthropic Tool use (returns tool_use blocks whose input your code runs).
  • Two mechanisms make that token stream reliably schema-valid: (a) Prompt + fine-tune, then parse (with retries). You instruct the model to emit JSON, it (mostly) does, you parse — and retry on malformed output. The lineage is "ask nicely" → JSON mode (guarantees valid JSON, not schema) → Structured Outputs / strict mode (guarantees valid JSON and schema adherence). Verifies: OpenAI Structured Outputs ("While both ensure valid JSON is produced, only Structured Outputs ensure schema adherence") + Anthropic Tool use ("Add strict: true to your custom tool definitions to ensure Claude's tool calls always match your schema exactly").
  • (b) Constrained / grammar decoding — mask the logits at every step so only grammar-valid tokens can be sampled. Before each token the sampler builds a mask over the whole vocabulary: tokens the grammar rejects get logit -inf (softmax prob 0), survivors keep their model score; you then sample after the mask. The output is guaranteed to parse. Verifies: llama.cpp GBNF Guide ("a format for defining formal grammars to constrain model outputs... force the model to generate valid JSON") + Outlines ("guarantees structured outputs during generation — directly from any LLM") + Willard & Louf 2023.
  • Crucial nuance — for plain completion the grammar only masks (the model never sees the schema); for tool-calling the schema is also injected into the prompt. A mask makes tokens valid but gives the model no idea what to produce, so tool-calling APIs put the tool definitions in a system prompt the model attends over, and constrain the output. Verifies: llama.cpp GBNF Guide ("The JSON schema is only used to constrain the model output and is not injected into the prompt... This does not apply to tool calling, where schemas are injected into the prompt") + Anthropic Tool use ("the API also automatically includes a special system prompt for the model which enables tool use").
  • The tool's result is fed back as new input tokens on the next turn — that is "the loop." The model never touches the outside world; it sees the result as more text in the conversation, then decides whether to call another tool or answer. Repeat until it stops emitting tool calls. Verifies: OpenAI Function calling ("We then send all of the tool definition, the original prompt, the model's tool call, and the tool call output back to the model to finally receive a text response") + Anthropic Tool use ("Your code executes the operation and sends back a tool_result").
  • Run the call cold (temperature → 0) so the tool-call token stream is deterministic and reliably parses. Sampling randomness can mutate the JSON into a malformed token on one run and not the next; T→0 (greedy/argmax) removes the only RNG step, so the same prompt + model yields byte-identical output. Verifies: HuggingFace How to generate ("setting temperature → 0... becomes equal to greedy decoding") + HuggingFace Generation strategies (greedy selects most likely token deterministically).

Main Teaching Content

1. The Magic Trick

When people say "The AI booked my flight", they are falling for a magic trick. An LLM is a giant matrix multiplication engine. It cannot "click" buttons. It has no internet access. The only thing an LLM can ever do is append a word to a string of text. "Function calling" is just the model writing a string that looks like {"tool": "book_flight", "destination": "JFK"}, and your Python script reading that string, saying "Aha!", and actually calling the Expedia API.

2. The Token Stream Problem

If we rely on the model to just "write JSON", things will inevitably break. At high temperatures (creativity), the model might output Here is your JSON: {"tool": "book" }. That breaks json.loads because of the prefix text. We need a guarantee. There are two ways to get it:

  1. Prompt & Retry: Ask the model nicely. If it fails, catch the error in Python, send the error back to the model, and ask it to try again.
  2. Constrained Decoding (Grammar Mask): We physically block the model from generating bad characters.

3. Constrained Decoding (How Structured Outputs Work)

Before the model is allowed to pick the next token, the sampling engine looks at the JSON schema and builds a mask.

🧩 Interactive Widget: Grammar Mask Simulator Type the current output state. The widget calculates the allowed tokens. If you type {"tool": ", the widget masks out all numbers and allows only letters, guaranteeing valid JSON parsing!

import { useState } from 'react';

export default function GrammarMaskVisualizer() {
  const [output, setOutput] = useState('{"tool": "');
  
  // Mock mask logic
  let allowed = "Any character";
  if (output.endsWith('"')) {
    allowed = "Only alphabetical characters (a-z) to form a string value";
  } else if (output.endsWith('}')) {
    allowed = "End of sequence";
  }
  
  return (
    <div className="p-4 bg-gray-100 rounded">
      <label className="font-bold">Model Output State:</label>
      <input 
        className="w-full p-2 border mt-1" 
        value={output} 
        onChange={e => setOutput(e.target.value)} 
      />
      
      <div className="mt-4 p-2 bg-green-100 text-green-900 rounded">
        <strong>Allowed Next Tokens:</strong> {allowed}
      </div>
      <p className="mt-2 text-xs text-gray-500">
        All other tokens in the vocabulary are given a logit of -Infinity.
      </p>
    </div>
  );
}

If the current text is {"tool": ", the engine knows the next character must be a letter or a number. If the model wants to output a }, the engine sets the probability of } to 0% (logit -inf). The model is forced to pick from the remaining valid characters. This guarantees that the final string will always parse correctly.

4. The Agent Loop

Once the model outputs {"tool": "search"}, your code runs the search. How does the model know what happened? You feed the result back to the model as a new user message: Tool result: [Google search results]. The model reads this new text, and outputs its final answer. This cycle—model asks for tool, code runs tool, code feeds text back to model—is the entire basis of an AI Agent.


Step 3: Questions (Feynman Probe)

  1. An LLM only ever emits tokens — so what actually happens when it "calls a function"? Who runs the code: the model, or your application?

    The model outputs a JSON-formatted string of tokens. Your application parses that JSON and runs the actual Python/API code. The model executes nothing.

  2. Name the two mechanisms that make the model's token stream reliably schema-valid — and which one guarantees a parse?

    (1) Prompt and retry (ask nicely, catch errors, feed errors back). (2) Constrained grammar decoding (masking logits at inference time). Constrained decoding mathematically guarantees a parse.

  3. In constrained/grammar decoding, what happens to the logits at each step — which tokens survive, and where do top-p/temperature sit relative to the mask?

    The mask sets the logit of any invalid token to -inf. Only tokens that fit the grammar keep their original logits. Top-p and temperature are applied after the mask, so the masked tokens remain at zero probability.

  4. A grammar mask forces valid output for plain completion — but does the model "see" the schema? How does tool-calling differ (schema in the prompt)?

    In plain completion, the mask enforces the shape without the model knowing why. For tool calling, the API injects the tool's JSON schema into the system prompt so the model understands the available tools, and then the mask forces the output to match it.

  5. How does a tool's result get back into the conversation, and why is that "a loop"?

    Your application takes the result of the function, formats it as text (a "tool result" message), and appends it to the conversation history. You then call the model again. This is a loop because the model can continuously request tools, receive results, and request more tools until it decides to stop.

  6. Why run the tool call cold (temperature → 0), and what does an unconstrained hot model intermittently emit?

    Temperature 0 removes randomness, ensuring the model deterministically picks the highest-probability valid token. A hot unconstrained model will eventually randomly sample a stray character or hallucinate an invalid enum, breaking the JSON parser.

  7. Why does all of this mean "agents are not magic"?

    Because an agent is just a while loop in your code wrapping an LLM that only outputs text. The LLM is just the brain deciding which text to output next; your application is the hands doing the work.


🧩 Advanced Simulator: The Hidden Agent Loop Click "Step". Instead of a magic answer, you see the raw JSON string {"tool": "weather", "city": "NY"} render. Step again, and see the Python system inject Tool result: 75F. It rips the "magic" off and exposes the raw while loop!

import { useState } from 'react';
export default function HiddenAgentLoop() {
  const [step, setStep] = useState(0);
  
  const history = [
    { role: "user", text: "What's the weather in NY?" },
    { role: "assistant", text: '{"tool": "weather", "city": "NY"}', hidden: true },
    { role: "system", text: 'Tool Output: 75F, Sunny', hidden: true },
    { role: "assistant", text: "The weather in New York is currently 75F and sunny!" }
  ];

  return (
    <div className="p-4 border rounded">
      <button onClick={() => setStep(s => Math.min(s+1, 3))} className="bg-blue-600 text-white px-4 py-1 rounded mb-4">
        Step Execution ({step}/3)
      </button>
      
      <div className="space-y-2 font-mono text-sm">
        {history.slice(0, step + 1).map((msg, i) => (
          <div key={i} className={`p-2 rounded ${msg.role === 'user' ? 'bg-gray-200' : msg.role === 'system' ? 'bg-yellow-100' : 'bg-green-100'}`}>
            <strong>{msg.role}: </strong> 
            <span className={msg.hidden ? 'text-purple-700 font-bold' : ''}>{msg.text}</span>
            {msg.hidden && <span className="ml-2 text-xs text-purple-500">(Hidden from user UI)</span>}
          </div>
        ))}
      </div>
    </div>
  );
}

Step 4: Demo .py

The script function_calling_mechanism.py demonstrates grammar masking step-by-step. The raw model really wants to output the character 9, but the mask forces it to output a valid JSON {"tool":"search"}.

Output:

Step | Target | Unmasked Argmax | Masked Argmax | Valid Mask
-----------------------------------------------------------------
   0 | {      | 9               | {             | ['{']
   1 | "      | "               | "             | ['"']
...
   9 | s      | 9               | s             | ['t', 'o', 'l', 's', 'e', 'a', 'r', 'c', 'h', 'x']

Final Outputs:
Unmasked greedy: 9"tool":"9earch"}
Masked greedy  : {"tool":"search"}

[check] masked forced output == '{"tool":"search"}'
[check] masked output json.loads -> {"tool": "search"}
[check] unmasked greedy -> invalid JSON (starts with '9'): True
[check] mask rescued 2 steps (0 and 9)

(The extension proves masks fix shape, not correctness. The mask forces a closing quote, but it would happily let the model output "delete" instead of "search".)


Step 5: What to Teach

  • Title: Why an LLM can call a function (it's tokens all the way down).
  • Angle: The model never executes anything — it emits tokens your code interprets. Kill the magic; show the two mechanisms (prompt-and-parse vs constrained decoding) and the dispatch loop. The pinned demo is the proof.
  • Payload: The token-only insight (the model writes characters; your code runs functions). JSON-as-protocol (schema in, arguments-JSON out, parse, dispatch). Logit-masking (invalid → -inf before sampling). The result-fed-back loop.
  • Gotcha: (1) Unconstrained models emit malformed JSON intermittently, requiring strict/grammar modes. (2) A mask guarantees valid JSON, never the correct function or arguments.
  • Video (T2):
    • Hook: "the model never executes — it emits tokens your code interprets"
    • Mechanism: prompt → JSON → parse → dispatch OR logit-mask, side by side.
    • Gold: forced valid {"tool":"search"} vs unmasked 9...
    • Gotcha: malformed JSON / wrong tool.

Step 6: Checklist

  • demo runs, exits 0, ≥2 [check] lines
  • output byte-stable on re-run (determinism)
  • gold value reproduced, matches reference
  • ≥2 web sources logged with Verifies: lines
  • article is voiced + embeds the diagram/demo + ≥1 gotcha
  • HyperFrames video renders, 6 beats, gold-check badge green
  • all 7 probe questions answered out loud, no notes
import json
import math
VOCAB = ['{', '"', 't', 'o', 'l', ':', 's', 'e', 'a', 'r', 'c', 'h', '}', 'x', '9']
def valid_next(prefix):
# Grammar: root ::= "{" "\"tool\"" ":" "\"" letter+ "\"" "}"
# letter ::= [a-z] (here in vocab: t, o, l, s, e, a, r, c, h, x)
target_prefix = '{"tool":"'
if len(prefix) < len(target_prefix):
if target_prefix.startswith(prefix):
next_char = target_prefix[len(prefix)]
return [next_char]
return []
# After target_prefix
content = prefix[len(target_prefix):]
if '"' in content:
# We hit the closing quote
if content.endswith('"}'):
return [] # done
elif content.endswith('"'):
return ['}']
else:
return []
# Inside the value
# value must be letter+
letters = ['t', 'o', 'l', 's', 'e', 'a', 'r', 'c', 'h', 'x']
valid = list(letters)
if len(content) > 0:
valid.append('"') # can close if we have at least 1 letter
return valid
def main():
target = '{"tool":"search"}'
# Build per-step logits
# Base: target gets 3.0, rest 0.0
# Trap: '9' gets 9.0 at step 0 and step 9
steps = len(target)
unmasked_output = ""
masked_output = ""
print("Step | Target | Unmasked Argmax | Masked Argmax | Valid Mask")
print("-" * 65)
rescued = []
for i in range(steps):
target_char = target[i]
# Build logits
logits = [0.0] * len(VOCAB)
target_idx = VOCAB.index(target_char)
logits[target_idx] = 3.0
trap_idx = VOCAB.index('9')
if i == 0 or i == 9:
logits[trap_idx] = 9.0
# Unmasked greedy
best_idx_unmasked = max(range(len(VOCAB)), key=lambda idx: logits[idx])
char_unmasked = VOCAB[best_idx_unmasked]
unmasked_output += char_unmasked
# Masked greedy
valid_chars = valid_next(masked_output)
masked_logits = []
for j, char in enumerate(VOCAB):
if char in valid_chars:
masked_logits.append(logits[j])
else:
masked_logits.append(-math.inf)
best_idx_masked = max(range(len(VOCAB)), key=lambda idx: masked_logits[idx])
char_masked = VOCAB[best_idx_masked]
if char_unmasked != char_masked:
rescued.append(i)
masked_output += char_masked
print(f"{i:4d} | {target_char:6s} | {char_unmasked:15s} | {char_masked:13s} | {valid_chars}")
print("\nFinal Outputs:")
print(f"Unmasked greedy: {unmasked_output}")
print(f"Masked greedy : {masked_output}")
# Extension trap
print("\n--- Extension: Structural Breakage ---")
# Move trap to step 15 (closing quote), where raw argmax is 'e' (9.0) instead of 9
ext_masked = ""
ext_unmasked = ""
for i in range(steps):
target_char = target[i]
logits = [0.0] * len(VOCAB)
target_idx = VOCAB.index(target_char)
logits[target_idx] = 3.0
if i == 15: # closing quote
logits[VOCAB.index('e')] = 9.0
char_unmasked = VOCAB[max(range(len(VOCAB)), key=lambda idx: logits[idx])]
ext_unmasked += char_unmasked
valid = valid_next(ext_masked)
masked_logits = [logits[j] if VOCAB[j] in valid else -math.inf for j in range(len(VOCAB))]
char_masked = VOCAB[max(range(len(VOCAB)), key=lambda idx: masked_logits[idx])]
ext_masked += char_masked
print(f"Unmasked extension output: {ext_unmasked}")
print(f"Masked extension output : {ext_masked}")
print("Notice the unmasked output misses the closing quote, making it structurally invalid.")
print("The mask enforces the shape, ensuring a valid closing quote is picked.")
print("However, if the model wanted to output 'delete' instead of 'search', the mask allows it, proving masks fix shape, not correctness.")
# Checks
print()
print(f"[check] masked forced output == '{masked_output}'")
try:
parsed = json.loads(masked_output)
print(f'[check] masked output json.loads -> {json.dumps(parsed)}')
except Exception as e:
print(f"Failed to parse masked: {e}")
invalid_start = unmasked_output.startswith('9')
print(f"[check] unmasked greedy -> invalid JSON (starts with '9'): {invalid_start}")
print(f"[check] mask rescued 2 steps ({rescued[0]} and {rescued[1]})")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment