Skip to content

Generation and the KV cache

During generation a decoder reads the keys and values of earlier positions from its KV cache instead of recomputing them. This page shows how to trace a generation loop, how to edit what the cache holds, and what the cache is to TorchLens.

Trace a generation loop

To look inside generation rather than edit it, trace the model's own generate method. Each forward call generate makes is one call of every module, so a module's num_calls is the number of generated tokens and module:k is its output while generating token k. Call 1 is the prefill, which reads the whole prompt; each later call reads only the newest token, because the earlier ones are in the KV cache. Module addresses are the model's own, as in a plain trace of the model; the model itself is the root self, called once per token.

# docs-test: expected=[3, [1, 5, 32], [1, 1, 32]]
import torch
import torchlens as tl
from transformers import GPT2Config, GPT2LMHeadModel

torch.manual_seed(0)
lm = GPT2LMHeadModel(GPT2Config(vocab_size=128, n_embd=32, n_layer=2, n_head=2)).eval()
prompt = torch.tensor([[3, 17, 42, 8, 99]])

trace = tl.trace(lm.generate, prompt, input_kwargs={"max_new_tokens": 3})
mlp = "transformer.h.1.mlp"
RESULT = [
    trace[mlp].num_calls,  # 3: one call per generated token
    list(trace[f"{mlp}:1"].out.shape),  # the prefill: all 5 prompt positions
    list(trace[f"{mlp}:2"].out.shape),  # a decode step: the one new position
]

With a Hugging Face model whose tokenizer TorchLens can find, pass the prompt as text, as with a plain trace: tl.trace(model.generate, "The capital of France is", input_kwargs={...}). generate checks whether to stop by reading tensors as Python values, so the trace warns once with a ScalarEscapeWarning: that stopping decision is not part of the recorded graph. A trace records every operation of every step, so keep it to a few tokens; to keep chosen sites across a long generation, use tl.record(model, ids, save=..., generate_kwargs={...}), which records only the sites you select at each step.

Recipe: edit the cached past

"Intervene during generation" can mean three different things, and they cost very different amounts:

You want to Use Cost
Hold an edit on every decoding step, new tokens included spec.bind(model).generate(ids, ...) close to a plain forward hook per token; see the fast-path guide
Edit what the cache holds for the prompt, then let generation read it unedited the bound spec on the prefill call only (recipe below) one prefill forward
Record every step of a generation as one inspectable product tl.trace(..., episode=tl.options.EpisodeOptions(...)) cost grows faster than the number of steps, so tens of steps, not hundreds (episode capture)

A bound model runs the model's real generate() with the edit installed as a hook and records no graph; bound.last_report.num_fires counts the edit's firings, so an edit on a projection that runs once per decoder forward fires once per step, and the keys and values it writes into the cache already carry the edit. Use a trace or an episode capture only when you need the recorded graph, and then for a few steps.

To change what later tokens see of the prompt without touching the new tokens, apply the edit to the key or value projection during the prefill call only. The edited values are written into the cache, and every later step reads them from there. The check below compares the result with the same edit written as a plain forward hook.

# docs-test: expected=[True, True]
import torch
import torchlens as tl
from transformers import LlamaConfig, LlamaForCausalLM

torch.manual_seed(0)
lm = LlamaForCausalLM(
    LlamaConfig(
        vocab_size=128,
        hidden_size=32,
        intermediate_size=64,
        num_hidden_layers=2,
        num_attention_heads=4,
        num_key_value_heads=2,
    )
).eval()
prompt = torch.tensor([[3, 17, 42, 8, 99]])
next_token = torch.tensor([[7]])
v_proj = lm.model.layers[0].self_attn.v_proj


def blank_position_1(values):
    values = values.clone()
    values[:, 1] = 0.0  # the cached value of prompt position 1 in layer 0
    return values


def decode_after(prefill):
    """Run one decoding step that reads the cache the prefill call wrote."""
    with torch.no_grad():
        cache = prefill(prompt, use_cache=True).past_key_values
        return lm(next_token, past_key_values=cache, use_cache=True).logits


# TorchLens: the edit is bound for the prefill call only.
spec = tl.when(
    tl.module("model.layers.0.self_attn.v_proj"),
    lambda values, *, ctx: blank_position_1(values),
)
edited = decode_after(spec.bind(lm))

# The same surgery with a plain hook on the prefill call.
handle = v_proj.register_forward_hook(lambda module, args, out: blank_position_1(out))
try:
    with torch.no_grad():
        cache = lm(prompt, use_cache=True).past_key_values
finally:
    handle.remove()
with torch.no_grad():
    reference = lm(next_token, past_key_values=cache, use_cache=True).logits

clean = decode_after(lm)
RESULT = [torch.allclose(edited, reference, atol=1e-6), not torch.allclose(edited, clean)]

When you also trace these calls, remember that the whole-graph default saves every op's activation, and the attention matrix alone grows with the square of the sequence length. Pass save=tl.module(...) naming the sites you will read, and see long sequences in the performance guide.

What the cache is to TorchLens

When you trace a decoding step, the cache passed as past_key_values is an input container like any other: its tensors are graph inputs, addressed by their path inside the container (containers). An edit at a module inside the step changes what that step computes and what it appends to the cache; it does not rewrite earlier entries. To change earlier entries, edit them at the call that wrote them, as the recipe does.