Skip to content

Logit lens

Send a residual through the model's real final norm and unembedding — inside the worker, where the weights are. Only what you save crosses back.

The model's own arithmetic

model.model.norm is the final norm; model.logits_processor(model.lm_head, h) is exactly what vLLM calls to turn the last hidden state into logits, so any family-specific step (a muP scale, Gemma's soft-capping) is applied for you. Calling a module inside a trace runs it directly, out of the forward's order.

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)

with model.trace("The capital of France is", temperature=0.0):
    resid = sum(model.model.layers[28].output)                # [pos, d_model], leaving block 28
    lens = model.logits_processor(model.lm_head, model.model.norm(resid))
    top = lens.argmax(-1).save()                              # [pos]; the logits stay in the worker

print([model.tokenizer.decode(t) for t in top])
# ['玿', 'ization', ' China', ' France', ' Paris']

The last row is the model's guess at the next token after reading the whole prompt, from layer 28's point of view.

Every layer, one forward

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)

with model.trace("The capital of France is", temperature=0.0):
    guesses = list().save()
    for layer in model.model.layers:
        h = model.model.norm(sum(layer.output))
        guesses.append(model.logits_processor(model.lm_head, h)[-1].argmax().item())
    final = model.logits.argmax(-1).item().save()

for i, g in enumerate(guesses):
    if i % 4 == 3 or i == 35:
        print(i, repr(model.tokenizer.decode(g)))
print("model:", repr(model.tokenizer.decode(final)), "lens at 35 agrees:", guesses[-1] == final)
# 3 ' ebenfalls'
# 7 '/w'
# 11 ' _______,'
# 15 ' ____'
# 19 ' ____'
# 23 ' ____'
# 27 ' Paris'
# 31 ' Paris'
# 35 ' Paris'
# model: ' Paris' lens at 35 agrees: True

Through the middle of the stack the lens reads a fill-in-the-blank continuation; the answer appears around layer 27 and holds.

Top-k without shipping the vocab

The vocabulary is 151,936 wide; a [pos, vocab] tensor per layer is the expensive thing to send home. Take the top-k in the worker.

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)

with model.trace("The capital of France is", temperature=0.0):
    h = model.model.norm(sum(model.model.layers[28].output))
    probs = model.logits_processor(model.lm_head, h)[-1].float().softmax(-1)
    top = probs.topk(5)
    ids, p = top.indices.save(), top.values.save()

print([(model.tokenizer.decode(i), round(v, 3)) for i, v in zip(ids.tolist(), p.tolist())])
# [(' Paris', 0.771), (' located', 0.104), (' ______', 0.03), (' _____', 0.026), (' ____', 0.023)]

Every prompt position, every step

vLLM computes the real lm_head only for the token being sampled, so model.logits is one row. The lens gives you the whole prompt — and under tracer.all() it follows generation.

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)

with model.trace("The capital of France is", temperature=0.0, max_tokens=6) as tracer:
    rows = list().save()
    for _ in tracer.iter[:6]:
        h = model.model.norm(sum(model.model.layers[28].output))
        rows.append(model.logits_processor(model.lm_head, h).argmax(-1))
    out = tracer.result.save()

print([len(r) for r in rows])                                    # prompt rows, then one per step
# [5, 1, 1, 1, 1, 1]
print(repr(out.outputs[0].text))
# ' Paris. The capital of Italy'

Raw logits

h @ model.lm_head.weight.T skips the family arithmetic. On one GPU the weight is the whole [vocab, d_model]; under tensor parallelism it is this rank's shard, whereas logits_processor gathers — see Tensor parallelism.

Optimizing against the lens

Gradients do not flow through a vLLM forward. Fit a lens vector, or optimize a residual against a logit objective, with the same block on TransformersModel and bring the vector here to steer with.