Skip to content

01 · Transformer Internals

Every module so far treated the model as a black box: text in, text out. This module opens the box just enough to explain why the API behaves the way it does — attention, KV caches, positional encoding, and what a forward pass actually computes. You won't train a transformer here; you'll build a minimal, readable one in plain NumPy to see the mechanism directly.

Tokens as vectors

Everything a transformer does happens on vectors, not characters. Each input token id is looked up in an embedding table to get its initial vector representation:

import numpy as np

VOCAB_SIZE, D_MODEL = 1000, 64
rng = np.random.default_rng(0)
embedding_table = rng.normal(0, 0.02, size=(VOCAB_SIZE, D_MODEL))

def embed(token_ids: list[int]) -> np.ndarray:
    return embedding_table[token_ids]        # shape: (seq_len, d_model)

tokens = [42, 7, 891, 15]
x = embed(tokens)
print(x.shape)   # (4, 64)

This is a lookup, not a computation — a token's embedding is a learned vector shaped by training, not derived from the token's spelling.

Positional encoding

A transformer's core attention mechanism has no inherent notion of order — without extra information, "the cat sat" and "sat cat the" would look identical to it. Positional information is injected by adding a position-dependent vector to each token's embedding:

def sinusoidal_positions(seq_len: int, d_model: int) -> np.ndarray:
    position = np.arange(seq_len)[:, None]
    dim = np.arange(d_model)[None, :]
    angle = position / np.power(10000, (2 * (dim // 2)) / d_model)
    pe = np.zeros((seq_len, d_model))
    pe[:, 0::2] = np.sin(angle[:, 0::2])
    pe[:, 1::2] = np.cos(angle[:, 1::2])
    return pe

x = x + sinusoidal_positions(len(tokens), D_MODEL)

Modern production models more commonly use rotary position embeddings (RoPE), which rotate query/key vectors by a position-dependent angle instead of adding a fixed vector — better at generalizing to sequence lengths not seen during training — but the sinusoidal version above makes the purpose (inject order into an otherwise order-agnostic mechanism) easiest to see directly in code.

Self-attention

For every token, attention computes a weighted average of all tokens' values, where the weights come from how well each token's "query" matches every other token's "key":

def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
    x = x - x.max(axis=axis, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=axis, keepdims=True)

def self_attention(x: np.ndarray, Wq, Wk, Wv, causal_mask: bool = True) -> np.ndarray:
    Q, K, V = x @ Wq, x @ Wk, x @ Wv                 # each: (seq_len, d_k)
    scores = Q @ K.T / np.sqrt(Q.shape[-1])          # (seq_len, seq_len)

    if causal_mask:
        seq_len = x.shape[0]
        mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(bool)
        scores = np.where(mask, -np.inf, scores)     # token i can't see token j > i

    weights = softmax(scores)                        # each row sums to 1
    return weights @ V                                # weighted average of values

D_K = 64
Wq, Wk, Wv = (rng.normal(0, 0.02, (D_MODEL, D_K)) for _ in range(3))
attn_out = self_attention(x, Wq, Wk, Wv)
print(attn_out.shape)   # (4, 64)

The causal_mask is what makes this a language-model attention: token 2 can attend to tokens 0-2 but never token 3, because at generation time token 3 doesn't exist yet — the model must be trainable to predict the next token from only what came before it.

Multi-head attention

Real models run several attention computations ("heads") in parallel, each with its own learned Q/K/V projections, letting different heads specialize in different relationships (one head might track subject-verb agreement, another local word order):

def multi_head_attention(x: np.ndarray, num_heads: int, params: list[tuple]) -> np.ndarray:
    head_outputs = [self_attention(x, Wq, Wk, Wv) for (Wq, Wk, Wv) in params]
    return np.concatenate(head_outputs, axis=-1)    # (seq_len, num_heads * d_k)

A minimal transformer block

Attention alone can only mix information between positions linearly; a feed-forward network afterward gives each position's representation nonlinear processing capacity, and residual connections plus normalization keep gradients well-behaved across many stacked layers:

def layer_norm(x: np.ndarray, eps: float = 1e-5) -> np.ndarray:
    mean = x.mean(axis=-1, keepdims=True)
    var = x.var(axis=-1, keepdims=True)
    return (x - mean) / np.sqrt(var + eps)

def feed_forward(x: np.ndarray, W1, W2) -> np.ndarray:
    hidden = np.maximum(0, x @ W1)     # ReLU
    return hidden @ W2

def transformer_block(x: np.ndarray, attn_fn, ff_W1, ff_W2) -> np.ndarray:
    x = x + attn_fn(layer_norm(x))          # residual around attention
    x = x + feed_forward(layer_norm(x), ff_W1, ff_W2)   # residual around FFN
    return x

Stack this block N times (32-100+ in production-scale models) and add a final projection back to vocabulary size to get logits over the next token — the same architecture underlying every model you've called through the API in earlier levels, at a scale many orders of magnitude larger.

KV caching, conceptually

During generation, computing attention for a new token needs the keys and values of every prior token — recomputing all of them from scratch for every new token would make generating a 500-token response roughly quadratic in cost. The KV cache stores each token's key/value vectors the first time they're computed, so generating token N+1 only computes Q/K/V for the new token and reuses cached K/V for tokens 0..N:

class KVCache:
    def __init__(self):
        self.keys, self.values = [], []

    def append_and_get(self, new_k: np.ndarray, new_v: np.ndarray):
        self.keys.append(new_k)
        self.values.append(new_v)
        return np.vstack(self.keys), np.vstack(self.values)

This is exactly the mechanism prompt caching (Level 2, module 2) exposes at the API level — persisting KV state across a cache boundary instead of only within one generation run.

How It Actually Works

Everything downstream of this module — tool calling, structured output, streaming, agent loops — is client-side orchestration around what you just built: a stack of attention + feed-forward blocks that turns a sequence of input token vectors into a probability distribution over the next token. Attention's weighted-average-of-values mechanism is why context actually matters to output quality: every token's representation at every layer is literally an average of other tokens' representations, weighted by learned relevance — a fact buried under wrong or irrelevant information genuinely has less influence over the output the more other content competes for attention weight, which is the mechanistic reason vague or bloated prompts produce vaguer answers.

The causal mask is why a model can only condition on what precedes a position, never what follows — the entire notion of "the model doesn't know what it will say next until it says it" (relevant to why forcing a model to "show its reasoning" changes its answer) follows directly from this masking rule, not from any higher-level property of "reasoning."

Cheat sheet

Component Role
Embedding table Maps token ids to learned vectors
Positional encoding / RoPE Injects order into an otherwise order-agnostic mechanism
Self-attention Weighted average of all (visible) tokens' values
Causal mask Blocks attending to future tokens — enables next-token prediction
Multi-head Several parallel attention computations, concatenated
Feed-forward + residual + norm Nonlinear per-position processing, stable deep stacking
KV cache Reuses prior tokens' keys/values instead of recomputing every step

Exercise

Extend the NumPy transformer block above into a 2-layer stack with 4 attention heads each, run it on a toy 6-token sequence, and print the attention weight matrix for one head at the last layer. Verify the causal mask by confirming every row's weights are zero above the diagonal. Then implement a tiny KV cache and show that generating a 5th token reuses the previously computed keys/values instead of recomputing attention for tokens 0-3 from scratch.