Hidden-state capture¶
easysteer.hidden_states extracts intermediate activations from a running vLLM
engine for analysis, training data and steering-vector extraction. Capture works
without enabling steering.
Engine requirements¶
Capture requires the V2 GPU model runner, which vLLM 0.29 selects by default. Models or features that fall back to V1, and standalone multimodal encoder runners, cannot use capture. Steering and capture currently require token-ID prompts; prompt embeddings are not supported.
from vllm import LLM
import easysteer.hidden_states as hs
llm = LLM(model="Qwen/Qwen2.5-1.5B-Instruct", tensor_parallel_size=1)
# Capture chooses its execution path automatically. Admission skips
# prefix-cache reads when necessary to compute selected prompt rows.
The client helper requires a single worker. Tensor-parallel capture returns per-rank shards and is rejected rather than merging them implicitly. Capture also depends on the engine being able to discover the model's decoder layers or MoE gates; test new model architectures before relying on their activations.
Capture a batch¶
One call captures a batch of prompts and returns a labelled
CaptureResult. For prompt-only analysis,
max_tokens=1 runs the prompt forward pass without subsequent decode steps:
tokenizer = llm.get_tokenizer()
prompts = []
for content in ["What is steering?", "Explain PCA."]:
messages = [{"role": "user", "content": content}]
prompt_ids = tokenizer.apply_chat_template(
messages, tokenize=True, return_dict=False, add_generation_prompt=True,
)
prompt = {"prompt_token_ids": prompt_ids}
prompts.append(prompt)
result = hs.capture(
llm,
prompts=prompts,
max_tokens=1, # 1 = prompt-only forward pass
layers=[10, 11, 12], # true layer ids; None = all hooked layers
dtype="float16", # engine-side storage dtype (optional)
)
Key arguments (full signature in the API reference):
| Argument | Meaning |
|---|---|
max_tokens |
Tokens to generate; 1 captures only the prompt forward pass. |
layers |
Layer-id subset (None = all). Layers are keyed by true layer id everywhere, never positional index. |
dtype |
Engine-side storage dtype: "float16", "bfloat16", "float32", "float64", "int32" or "int64". None preserves the model output dtype. |
select |
Global SelectSpec (or wire dict) choosing which rows to keep. |
per_prompt_selects |
One SelectSpec or wire dict per prompt, overriding the global selection (None entries keep the global one). The list length must match prompts. |
stream |
"hidden_states" (default), "router_logits" (MoE), or "attention_heads" (before the attention output projection). |
steering |
SteeringSpec or per-prompt list, as in LLM.generate(). Captured values include steering; use False to disable it even when the engine has a default. |
**generate_kwargs |
Forwarded into SamplingParams (e.g. temperature). |
Select rows¶
Pass a SelectSpec to collect only the rows needed for analysis. It shares
steering's selection rules:
select each phase independently, combine includes by union, and subtract
exclusions. For one representation per prompt, select its last token:
from vllm.capture import SelectSpec
# Keep only the last prompt token of each sample
result = hs.capture(llm, prompts,
select=SelectSpec(prompt_positions=[-1]))
The helper returns selected rows without engine-side reduction. It does not
accept positions or reduce arguments: express positions in SelectSpec, then
reduce the returned tensors if needed. With max_tokens=1, generation-only
selectors have no decode-forward rows to capture. Use a longer generation when
selecting decode rows. An empty selection returns no layers and empty sample
views.
Read captured rows¶
Rows are grouped by their owning request via engine labels and ordered by sequence position — the only correct grouping under continuous batching.
result.layer_ids # sorted true layer ids
result.rows(12) # Tensor(total_rows, dim) for layer 12, all samples
result.sample(0) # {layer_id: Tensor(rows, dim)} for sample 0
result.sample_positions(0) # absolute sequence positions of sample 0's rows
result.sample_token_ids(0) # input token ids of sample 0's rows
result.outputs # the vLLM RequestOutput list, prompt order
result.layouts # per-layer component widths and attention head layout
result.to_nested() # legacy shape: [sample][layer_pos] tensors
result.meta(layer) exposes the raw row labels (req_ids / positions / token_ids);
result.labelled tells you whether per-sample views are available.
MoE router logits¶
Pass stream="router_logits" to hs.capture() on an MoE model to capture per-token
router logits instead of hidden states (used e.g. by the
SteerMoE replication).
Attention head outputs¶
stream="attention_heads" captures attention aggregation outputs before the
output projection, with the same selection and per-sample views:
from vllm.capture import SelectSpec
heads = hs.capture(
llm, prompts, layers=[10], stream="attention_heads",
select=SelectSpec(prompt_positions=[-1]), steering=False,
)
layout = heads.layouts[10]
head_outputs = heads.sample(0)[10].reshape(
-1, layout["num_heads"], layout["head_size"],
)
The stored tensors remain two-dimensional (rows, width). The layout records
width = num_heads * head_size, where num_heads is the query head count and
head_size is the value-output dimension per head. Neither KV head counts nor
the model's residual hidden size should be used to infer this layout.
This stream supports standard decoder MHA/GQA with tensor_parallel_size=1;
MLA, encoder attention, and cross-attention are not exposed as head outputs.
It shares capture's graph and prefix-cache policy. These activations feed the
ITI extractor.
Multimodal inputs¶
Multimodal prompts use the same list of input dictionaries as LLM.generate,
including prompt and multi_modal_data; capture preserves their cache salts.
Graph execution¶
Eligible FULL-graph batches use a separate CUDA graph that records activations.
This path requires one worker, no LoRA or speculative decoding, and steering
either disabled or running in_graph. Other batches that need captured rows
run eagerly; steps with no selected rows keep ordinary model execution.
The first eligible batch records the capture graph. One component/layer
combination stays cached across capture() calls; changing the selected token
positions, storage dtype or reduction does not require recording it again.
Changing components or layers replaces that cached graph. Ordinary model graphs
do not contain capture operations.
Prefix caching¶
KV blocks do not contain the intermediate activations skipped by a prefix
hit. Request admission therefore sets skip_reading_prefix_cache=True when
the effective selection requires those prompt rows. Generation-only selection,
the last prompt token, and other selections proven unaffected by prefix hits
can reuse cached blocks. Per-prompt overrides participate in this decision.
This policy applies both to hs.capture() and to requests submitted through the
same engine after a successful start_capture RPC. Cache writes remain enabled
under the ordinary cache key, so subsequent matching requests can reuse them;
capture does not add a random salt. Existing steering fingerprints still isolate
different interventions. If capture starts after a request has already reused
selected prompt rows, fetching that incomplete capture raises an error. The check
uses the stream's selection and any per-request override; selections confined to
uncached rows can still be captured. Existing requests are checked when they
first run in a batch after capture starts, so a completed request awaiting worker
cleanup does not affect the new stream.
If steering fails for a request, fetching its captured rows also reports that failure. Other requests remain available through a fetch restricted to their request IDs; clearing the stream removes its retained rows and errors.
Compatibility wrappers¶
get_all_hidden_states_generate and get_moe_router_logits_generate are thin
wrappers over capture(). The hidden-state wrapper returns
(hidden_states, outputs), with nested [sample][layer] tensors by default or
concatenated [layer] tensors when split_by_samples=False. The router-logit
wrapper returns (router_logits, outputs), with a {layer_id: tensor} dictionary
by default or one such dictionary per sample when split_by_samples=True.
Prefer capture() for new code: it preserves true layer IDs and per-sample
metadata. In particular, pass the CaptureResult directly to the vector
extractors when capturing a layer subset; converting to a plain nested list
loses the mapping from list positions to true layer IDs. The embed-task variants
(get_all_hidden_states, get_moe_router_logits) and the vllm.hidden_states
alias package were removed.