LLM Internal States Monitoring

Full-stack project designed to turn black-box LLMs into fully observable systems. By running local checkpoints in-process and attaching forward hooks across the decoder stack, it computes logit lens projections, attention dynamics, and decision-depth metrics displayed in a React and FastAPI dashboard.

Year
2026
Category
LLM Engineering
Role
Full-stack development, backend, design, and AI engineering
Stack
LLM Interpretability, PyTorch, FastAPI, React, Logit lens, RAG, Transformers
LLM Internal States Monitoring cover
Term definitions18 terms
attention sink
Position 0, where attention heads with nothing useful to do dump their weight. A layer with high sink mass is idle for that token.
BPE
Byte-pair encoding – the sub-word units the tokenizer actually feeds the model. The model never sees letters as letters; 'strawberry' arrives as two or three tokens, which is why letter-counting prompts fail in a characteristic way.
chat template
The role markers and generation prompt a tokenizer wraps around your text for an instruction-tuned model. Routinely most of the input, and every attention index is against the expanded version.
decision depth
How far up the stack a token's identity settles. Shallow means the answer was recalled or copied from the prompt; deep means it was computed on the way out.
depth ratio
Decision depth divided by total layers (layer ÷ L). Cross-model comparison uses ratio, not raw index, because a 28-layer and a 35-layer stack are not comparable position by position.
eager attention
The transformers attention path that actually materializes the attention matrix. SDPA and FlashAttention skip it, which is faster and leaves nothing to measure.
GGUF
A quantized checkpoint format common with llama.cpp. Here it is a hard no: transformers dequantizes it to fp32 on load, so a 12B file becomes ~48 GB and the quantization buys nothing for in-process hooks.
KL divergence
Distance from one probability distribution to another. Here: from a layer's distribution to the model's final output distribution – how much information that layer is still missing.
logit lens
Reading an intermediate layer by pushing its residual stream through the model's own final norm and unembedding, producing the distribution that layer would have emitted if the stack had stopped there.
logit softcapping
A tanh squash applied to logits before the softmax, used by Gemma. Any hand-projected layer has to receive the same treatment or it is being compared on a different scale.
massive activations
Single residual dimensions running 100 to 1000 times larger than the rest. Harmless at full precision, and the usual reason a quantized checkpoint falls apart.
ModelSpec
One registry row in backend/config.py: id, hub name, family, whether it is instruction-tuned, and a note saying what the model is for. Adding a checkpoint is a code change, not a settings screen.
prompt mass
The share of a token's attention landing on the prompt rather than on the model's own output so far. The practical read on whether context is being used or ignored.
residual stream
The running vector each token carries through the decoder stack. Every layer reads it, adds its contribution, and passes it on – so layer N's residual is a partially finished answer, not an intermediate scribble.
sliding-window attention
Layers that only keep the last W keys instead of the whole context. Cheap, but they cannot reach a distant prompt at all – which makes their attention numbers a ceiling, not a choice.
SSE
Server-sent events. Here: one event per generated token, carrying the scalars aggregated in the hook, so the UI draws while the model is still running.
tuned lens
A learned per-layer affine translator trained to map a layer's residual stream into the output basis before unembedding. Fixes most of the raw lens's mid-stack error; has to be trained per model, on a corpus.
unembedding
The output projection (lm_head) that turns a hidden vector into one logit per vocabulary entry. Its cost is what makes a per-layer lens expensive: layers times vocabulary, per token.

What this project does

AI Generated text is just an outcome of a model's internal states changing with every forward pass. We, as end-users, cannot see the internal confidence, hesitation, and routing that shaped them. Everything explaining how an answer formed lives one level down in the residual stream. By projecting an intermediate layer's hidden state through the model's final norm and unembedding, the logit lens reveals what the network computed at each step.

LLM-monitoring is an interpretability tool built to make this internal state legible in real time. Running local Hugging Face checkpoints in-process, it attaches PyTorch forward hooks across all decoder layers and streams layer-by-layer metrics to an interactive React UI via SSE. Rather than treating the LLM as a black box API endpoint, it acts as a real-time per-layer X-ray for every token as it is emitted.

LLM-monitoring during generation: tokens stream into the output panel while per-token metrics accumulate

A run in progress. Each token carries a per-layer record as it is generated – the UI updates live, not after the fact.

What you are looking at

If interpretability is new to you but transformers are not, here is the architecture at a glance before digging into the internals.

You type a prompt. The FastAPI backend loads a causal LM in-process (defaulting to Qwen3-1.7B) and executes an autoregressive decode loop. During each forward pass, three collectors tap into the decoder stack via PyTorch forward hooks:

Clicking any token in the generated stream opens a full per-layer diagnostic for that isolated step: where the prediction crystallized (decision depth), how far intermediate distributions sit from the output (KL divergence), whether the network was actively attending to context (prompt mass), and intermediate token candidates. Every run is indexed token ×\times layer in SQLite for historical review, side-by-side diffing, and cross-checkpoint comparisons.

To keep live streaming viable, all collectors aggregate tensors into scalars directly inside the hook, never forwarding raw activations to the client. The memory footprint of doing otherwise makes the engineering trade-off obvious:

Payload per token=L×dmodel×sizeof(fp16)\text{Payload per token} = L \times d_{\text{model}} \times \text{sizeof}(\text{fp16})

For an 8B class model (L=32L = 32, dmodel=4096d_{\text{model}} = 4096, 2 bytes per float):

Per-token footprint=32×4096×2 B≈256 KB\text{Per-token footprint} = 32 \times 4096 \times 2\text{ B} \approx 256\text{ KB}

1,000-token stream=1000×256 KB≈256 MB of raw states\text{1,000-token stream} = 1000 \times 256\text{ KB} \approx 256\text{ MB of raw states}

Streaming hundreds of megabytes of raw states over SSE would overwhelm browser memory and serialization throughput. Instead, the backend pushes compact scalar events token-by-token while generation runs.

The application runs as a self-contained monolith: Python 3.12 + FastAPI on the backend and React 19 + Vite + Tailwind on the frontend, served from a single process on port 8100.

Main motivation

I didn’t set out to build an observability suite. I was driven by pure curiosity: what does a transformer actually "think" before it commits to an answer?

Static papers on the logit lens proved mid-stack states hold latent predictions, but offline notebooks felt too detached. I wanted an X-ray of the network thinking in real time. Day one was a basic CLI script hooking into Qwen to print layer predictions to stdout. It worked in an afternoon, but answering one isolated question immediately spawned ten more: How does computation evolve across a full answer? When does a token resolve by shallow recall versus deep reasoning? Is context actually being read?

Chasing those questions forced the tool to evolve:

  • Taming silent failures: Handling architecture quirks–like Gemma's logit softcapping or tuned lens drift on non-English text–which silently distort telemetry without throwing errors.
  • Designing for live intuition: Shipping lightweight scalars over SSE to turn opaque tensors into real-time decision-depth curves and attention heatmaps.

What began as an afternoon experiment grew into a self-validating instrument built to observe what the model is doing beneath fluent prose.

The Analysis screen – turning a single token into datamining tool

The UI has four tabs – Analysis, Comparison, History, Definitions – but Analysis is where you spend most of your time. Generate, click a token, read the right column.

LLM-monitoring Analysis view: prompt input, streaming output with decision-depth coloring, and per-layer charts for the selected token

Analysis view on the "count the r's in strawberry" preset. Output tokens are underlined cold → warm by decision depth (blue = shallow recall, amber = deep computation). The right column is entirely about the selected token – here the final answer "2", decided at layer 23 of 28 after sixteen changes of mind.

###PanelWhat it does
1PromptTwo views: yours and what the model sees. The second applies the chat template live as you type, so you can see the special tokens the model actually receives.
2Run settingsToken budget, temperature, lens choice (raw or tuned lens), and which collector tiers are on (t1+t2+t3).
3Example promptsSix presets, each chosen to make one specific phenomenon visible. See Worked examples.
4Outputlive streams as it generates, paused freezes the panel. tokens shows raw BPE, formatted renders markdown. Both select the same token. Each token is underlined cold → warm by its decision depth: blue = shallow (recall), amber = deep (computation).
5Selected tokenThe token whose forward pass the whole right column describes.
6Prompt as seen by that tokenThe chat template-expanded prompt, with the positions the attention numbers are indexed against.
7Run metricsthis run vs all time averages: confidence, margin, output entropy, tokens per second, lens overhead, time to first token, depth ratio. Lens overhead is reported separately from throughput so the instrument's own cost stays checkable rather than assumed.
8Token verdict"decided at layer 23 of 28 · 16 changes of mind · half-converged by layer 20", the final candidate distribution with the margin over the runner-up, and the layer trail – what each layer would have said if you had stopped there.
9Per-layer chartsDecision, Convergence, Uncertainty, Attention, Residual geometry. See Reading the charts.
10Model pickerThe resident model and its memory state. Models not yet on disk are priced in GB up front.

Worth knowing on first open:

  • Prompt vs what the model sees. The toggle applies the chat template live. A one-word prompt at Gemma arrives as thirteen tokens; every attention index is against those positions, not your raw string.
  • Run settings. Token budget, temperature, raw vs tuned lens, and which collector tiers are on (t1+t2+t3). Lens overhead is reported separately from throughput – 0.16 ms/token on Qwen3-1.7B, measured on an M5 Pro – so the instrument's cost stays checkable.
  • Six presets. Each is chosen to make one phenomenon visible, not to show off the model. More on those below.
  • Token verdict. "Decided at layer 23 of 28 · 16 changes of mind · half-converged by layer 20" plus a layer trail – what each layer would have emitted if you had stopped there. On the strawberry count, early layers guess punctuation and fragments; the digit only wins late.
  • Five per-layer charts. Decision, Convergence (KL divergence to final), Uncertainty (entropy), Attention (prompt mass and attention sink), Residual geometry (cosine similarity to the layer below – near 1 means the layer barely moved the stream).

Below the charts sits the run heatmap: tokens × layers, switchable between rank of the chosen token, KL to final, attention on the prompt, sink mass, and layer similarity. Long runs wrap into side-by-side blocks. This is where run-level shape appears – a band of high rank several tokens before the prose goes wrong is the model losing the thread, visible before you notice in the text.

A forward pass will tell you where a token was decided

Another very importand and exposed number is decision depth: how far up the stack the answer settled. Ask for the capital of Australia and "Canberra" is already the top-1 guess a third of the way up – the fact was retrieved, not derived. Ask for the area of a hexagon with a side of 7 and the digits do not settle until the last two layers.

A decoder stack on the left; each layer's residual stream is projected through the shared final norm and unembedding into a per-layer distribution on the right; below, a curve of KL to the output distribution collapsing with depth, with the halving point marked

One generated token, read at every layer. The lens is cheap because it reuses tensors the pass already produced – 0.16 ms per token on Qwen3-1.7B, measured on an M5 Pro separately from throughput so the claim stays checkable.

Attach a forward hook per decoder layer, keep the last position, project:

def hook(_module, _args, output):
    hidden = output[0] if isinstance(output, tuple) else output
    captured.append(hidden[:, -1, :])          # last position only
 
handles = [layer.register_forward_hook(hook) for layer in model.model.layers]
 
# after the step, for every captured layer:
logits = model.lm_head(model.model.norm(h))    # the model's own norm, not a copy
probs = logits.float().softmax(-1)

Skip the final norm and you still get logits, still get a plausible-looking bar chart, and every number in it is wrong: mid-stack activation scales are nothing like what lm_head expects. Nothing crashes. That is why the app reports which layer a measurement came from rather than a single summary score.

Reading the raw depth curve, though, the middle of the stack is suspiciously bad at everything.

Raw logit lens vs tuned logit lens

On Llama- and Qwen-family models the raw logit lens reads intermediate layers poorly – the residual basis drifts, so a straight projection under-reads what the layer knows. The fix is a tuned lens: a small affine translator per layer, trained to map that residual into the output basis first. Roughly three minutes on an M5 Pro:

uv run python -m scripts.train_tuned_lens --steps 600 --seq-len 256

Mean KL divergence reduction against the raw lens: 71.6% on Qwen3-1.7B, 75.6% on Gemma 4 E2B-it. That is a large correction, and the kind of number that invites you to stop asking questions.

Then I ran a Polish prompt through the same lens. The reduction fell from about 74% to about 35%. The translator had been trained on English wikitext. Nothing errored; the curves just got less honest. So the trainer takes a corpus:

uv run python -m scripts.train_tuned_lens --corpus my_corpus.txt --steps 600

A lens is an instrument with a calibration, and the calibration belongs to a language and a format, not to a model. Which raises the obvious follow-up: if the tool can be quietly wrong about what a layer thinks, what does it get right often enough to act on?

Six prompts that fullfill different use cases

The preset catalogue is not a demo reel. Each entry is an instrument calibration – a question you run once to learn what a healthy curve looks like, then read everything else against it.

PresetWhat to watchWhat a healthy run looks like
Capital of AustraliaDecision depthShallow and confident – the answer is in the prompt or trivially retrievable
Count the r'sDepth + flip count on the digitThe model never sees letters, only BPE tokens; depth spikes on the number
Letter to SantaDepth throughoutTemplate completion, no correct answer – shallow depth = "not thinking hard"
Hexagon areaDepth on the final digitsMulti-step geometry; prediction settles in the last third of the stack
Longest valid parenthesesPrompt mass on the heatmapModel must keep re-reading the problem; collapse = structural failure incoming
False premiseConfidence traceTuring was never offered a Nobel – pushback vs confabulation look completely different

Default temperature is 0 and top-p is1.0. This is a measuring instrument; silently truncating the distribution would make every sampled run a measurement of a distribution the model did not produce. A warning appears when temperature ≥ 1 meets top-p = 1.

Comparison module – two runs, one question

Once you have a baseline run, Comparison is where model families and prompt variants become legible. Pick two runs – the same prompt on Qwen vs Gemma, or hexagon area run #67 vs #68 – and the view averages per-layer curves over the answer plus a metric table.

LLM-monitoring Comparison view: two hexagon-area runs side by side with probability and prompt-mass curves plus a metrics table

Two runs on the hexagon-area preset. Probability of the chosen token stays flat until roughly layer 18, then rises – the answer crystallizes in the final third. Cross-model comparison uses depth ratio (layer ÷ total layers), not raw index, because a 28-layer and a 35-layer stack are not comparable position by position.

The registry is a comparison set, not a menu – three ModelSpec rows, each isolating one variable:

idcheckpointwhy it is in the set
qwen3-1.7bQwen/Qwen3-1.7BDefault. Uniform global attention, no softcapping – simplest baseline. 3.4 GB.
gemma4-e2bgoogle/gemma-4-E2B-itSize-matched control: hybrid sliding-window attention + logit softcapping. 10.2 GB.
bielik-7bspeakleash/Bielik-7B-Instruct-v0.1Third architecture – Mistral, Polish-heavy, 32k vocab vs Qwen's 151k. ~14 GB.

Reference throughput on Apple MPS (M5 Pro): Qwen 8.3 tok/s, Gemma 6.8 tok/s. One model resident at a time – these are 3–14 GB objects, and switching evicts the previous checkpoint.

Boundaries of the application

The hard boundary is architectural. This needs the forward pass in-process, with eager attention so the attention matrix is actually materialized.

Two paths: an HTTP backend that returns only text and cannot be lensed, versus an in-process transformers stack with eager attention, per-layer hooks, scalars aggregated in the hook, and FastAPI streaming those scalars to the UI over SSE

An 8B residual is about 260 KB per token; a thousand-token answer of raw states is 260 MB. Aggregation happens in the hook during the pass, or the numbers never exist.

That rules out Ollama, llama.cpp and every HTTP backend: they return text. It also rules out GGUF – transformers dequantizes it to fp32 on load, so a 12B checkpoint becomes about 48 GB and the quantization buys nothing. Weight-only schemes that keep hooks working, like GPTQ or AWQ, are fine, and are where the massive activations view earns its place – single residual dimensions running 100–1000× larger than the rest are what breaks quantization, and they are visible directly in tier 2.

The softer boundary is correlational. A layer where the answer settles is not proof that the layer computed it, and this app does not do interventions. Causal claims need ablation and patching – a different tool, a different cost.

Important design choices

In-process hooks over API wrappers. The moment the model runs behind someone else's HTTP endpoint, all of this is gone. The premise of the project is that the instrument and the forward pass share one address space.

Aggregation in the hook, not in the browser. Raw states do not cross the wire. Scalars do. That is a performance choice and an honesty choice – if you cannot afford to store it, you cannot pretend you measured it.

Validation before registry. check_model exists because silent wrong numbers are worse than crashes. The decode-loop diff line is the one that matters.

Presets as calibration, not demos. Six prompts that teach you what healthy curves look like beat a thousand ad-hoc generations you cannot compare.

Depth ratio for cross-model comparison. Raw layer index compares unlike stacks. Ratio does not.

What I would not claim: that a shallow decision depth proves retrieval, that high prompt mass proves correct RAG, or that the tuned lens is ground truth. They are correlates you act on when the alternative is reading fluent text and guessing.

Where it landed

One FastAPI process serves the compiled frontend. 219 backend tests cover collectors, the decode loop, and the model registry; 180 frontend tests cover the React shell. Deep capture writes raw tensor dumps to .npz under a disk budget for offline analysis the UI does not surface yet. Per-head attention matrices are collected but not rendered. Stored runs predating the candidate list field reopen without "what the model nearly said."

What is left is the honest version of a question worth asking often: not "is the model right", which you can check, but "was it ever in doubt" – and, for RAG, "did it ever read what I gave it" – which until you open the forward pass you cannot.

Clone, build, run:

git clone https://github.com/JanBancerewicz/LLM-monitoring.git
cd LLM-monitoring && uv sync
npm install --prefix frontend && npm run build --prefix frontend
uv run uvicorn backend.api:app --port 8100

Open http://localhost:8100. Try it yourself with one of the predefined prompts , then run the same model with different prompts and compare their depth curves. This sequence is what the project was built for.

Working on something like this?

If any of this is close to a problem on your team, I would like to hear about it. LinkedIn is the fastest way to reach me.