Activation harvesting
Activation harvesting returns a model's residual-stream activations in the same
response as a completion. The residual stream is the vector that each decoder
layer passes to the next. Use the values for interpretability and probing work,
such as sparse auto-encoders, linear probes, and logit-lens analysis. To capture
activations, add output_residual_stream to a POST https://infra.acsresearch.org/v1/completions
request.
For each token, the tensor contains the residual stream that the model used to predict the token after it, so the last generated token has no row.
Prerequisites
- You need a model that lists
"activations": trueundercapabilitiesinGET /v1/models.
Capture the residual stream
Send a completion request with "output_residual_stream": true to capture every
decoder layer:
curl -s "$ACS_API_BASE/completions" \
-H "Authorization: Bearer $ACS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "llama-8b",
"prompt": "The capital of France is",
"max_tokens": 1,
"temperature": 0,
"output_residual_stream": true
}'
Set max_tokens on every capture request. If you leave it out, the API sets it to the rest of the context window. Most captures then exceed the capture size cap and fail with 400 activation_prompt_too_long. A one-layer capture can stay under the cap, but you then generate and pay for thousands of tokens. With max_tokens: 1, the tensor contains only the prompt positions.
Capture only some layers
To capture fewer layers, pass a list of layer indices instead of true:
"output_residual_stream": [15, 20] // capture only layers 15 and 20
The engine captures only the layers you list, so the response is smaller and the capture size cap allows more positions. The API removes duplicates and sorts the list in ascending order.
Decode the tensor
The example capture returns this activations.residual_stream object, with data shortened:
{
"data": "...",
"dtype": "int16",
"original_dtype": "torch.bfloat16",
"shape": [32, 6, 4096],
"compression": "zstd"
}
Reinterpret the int16 bits as bfloat16 with .view(), because a numeric .astype() converts the integer values.
import base64, numpy as np, ml_dtypes, zstandard as zstd
def decode_residual_stream(resp):
d = resp["activations"]["residual_stream"]
raw = base64.b64decode(d["data"])
if d.get("compression") == "zstd":
raw = zstd.ZstdDecompressor().decompress(raw)
# reinterpret the 16 bits as bfloat16 with .view (an .astype would corrupt them)
arr = np.frombuffer(raw, dtype=np.uint16).view(ml_dtypes.bfloat16)
return arr.reshape(d["shape"]) # (n_layers, n_tokens, hidden)
x = decode_residual_stream(resp) # for example shape (32, 6, 4096) for llama-8b
layer16 = x[16] # slice the layer(s) you want, client-side
Check the tensor before you use it
The API checks only the layer count, so assert the dimensions and values of every capture yourself:
requested = body["output_residual_stream"] # the value you sent: True or a list
n_layers = 32 # llama-8b, or read n_layers from GET /v1/models
usage = resp["usage"]
x = decode_residual_stream(resp)
want_layers = n_layers if requested is True else len(set(requested))
want_positions = usage["prompt_tokens"] + usage["completion_tokens"] - 1
assert x.ndim == 3, x.shape
assert x.shape[0] == want_layers, f"partial capture: {x.shape[0]} layers"
assert x.shape[1] == want_positions, f"unexpected position count: {x.shape[1]}"
assert np.isfinite(x.astype(np.float32)).all(), "non-finite values in the capture"
When the layer count differs from your request, the API refuses the capture in one of three forms:
- The response is a
200whose JSON body ends early, so your JSON parser fails. The API sends this form when it finds the mismatch after it has sent part of the body. - The response is a
502with the codecapture_incomplete. The API sends this form when it finds the mismatch before it sends the status line. - The response is a
200whose body is a complete JSONerrorobject with the codecapture_incomplete. This form follows a cold boot, when the API has already sent the status line and keepalive bytes.
A refused capture costs no tokens, but it counts against the key's activation quota.
On a capture with prompt_logprobs: -1, the API skips the layer-count check, and a partial capture arrives as a valid 200.
Get activations for text you already have
Send the text as the prompt, with max_tokens: 1. Text from a completion can
tokenize differently when you send it back, so send its
token ids if you have them. Start the ids with the model's
BOS id, because the engine adds BOS only to a text prompt.
Reference
Request fields
| Field | Value | Behavior |
|---|---|---|
output_residual_stream |
true, false, or a list of non-negative integers |
true returns all decoder layers. A list returns those layers. false sends a plain completion. |
max_tokens |
integer | The value counts toward the capture size cap. |
Response format
{
"choices": [ /* the normal completion */ ],
"usage": { /* the normal usage */ },
"activations": {
"residual_stream": {
"data": "<base64 string>", // zstd-compressed, int16-encoded bf16 bytes
"dtype": "int16", // storage dtype (bf16 reinterpreted as int16)
"original_dtype": "torch.bfloat16",
"shape": [n_layers, n_tokens, hidden], // n_layers = full count for `true`, else how many you requested
"compression": "zstd"
}
}
}
| Axis | Size |
|---|---|
shape[0] |
The size is n_layers for true. For a list, the size is the number of distinct layers. |
shape[1] |
prompt tokens + generated tokens - 1 |
shape[2] |
The size equals the model's hidden size, which the next table lists. |
| Model | n_layers |
Hidden size |
|---|---|---|
| llama-8b | 32 | 4096 |
| llama-405b | 126 | 16384 |
| trinity-truebase | 60 | 3072 |
The tensor has one row for each prompt token and for each generated token except the last one. Row 0 of a text prompt is the BOS token the engine adds, so the 5-word example prompt gives 6 rows. A 6-token prompt that generates 5 tokens gives 10 rows, 6 for the prompt and 4 for the first four generated tokens.
Capture size cap
GET /v1/models publishes max_activation_prompt_tokens, n_layers, and
max_model_len under each activation model's capabilities.
max_activation_prompt_tokens is the cap we set for a capture of every layer. Do not
hard-code the values, because they can change.
The cap counts positions, which are prompt tokens and generated tokens:
positions = prompt_tokens + max_tokens - 1
A list of fewer layers than n_layers gets a larger cap, because the cap bounds
the response size:
cap = min(floor(max_activation_prompt_tokens * n_layers / len(layers)), max_model_len)
llama-8b has 32 layers, a published cap of 256 positions, and a
max_model_len of 8192. There, a list of 4 layers allows 8 times the cap, and the
list [14] allows 32 times the cap. Both caps are at most max_model_len.
The caps and the uncompressed tensor sizes are:
output_residual_stream |
Positions allowed | Tensor at the cap |
|---|---|---|
true, 32 layers |
256 | 32 * 256 * 4096 * 2 bytes, 64 MiB |
| 4 layers | 256 * 32 / 4 = 2,048 |
4 * 2048 * 4096 * 2 bytes, 64 MiB |
[14], 1 layer |
256 * 32 = 8,192 |
1 * 8192 * 4096 * 2 bytes, 64 MiB |
The uncompressed tensor is layers * positions * hidden * 2 bytes. One
llama-405b layer takes 32 KiB per position, so a one-layer capture of 4,000
positions is about 125 MiB before compression. For long prompts on llama-405b
or trinity-truebase, use bulk harvest.
Errors
| Status | Code | Cause |
|---|---|---|
400 |
context_length_exceeded |
prompt + max_tokens is over max_model_len. The API checks this limit before it checks the capture cap. |
400 |
activation_prompt_too_long |
prompt + max_tokens - 1 is over the cap. The message states your cap and position count. |
400 |
invalid_request |
The capture request uses stream: true, a batched prompt, n greater than 1, an empty layer list, or a layer the model does not have. |
400 |
full_vocab_prompt_too_long |
A capture with prompt_logprobs: -1 has a prompt over 16 tokens. |
429 |
activation_quota_exceeded |
The key used up its monthly activation quota. |
200 or 502 |
capture_incomplete, or none |
The API refused a partial capture. See the tensor check. |
503 |
activation_engine_unconfigured |
We have not set the model up to serve API requests. |
Cold boot and quota
We keep llama-8b warm. We scale llama-405b and trinity-truebase to zero after at most 10 idle minutes, and the next request waits while the model starts. On those two models, every startup loads the model from storage, and the load can take minutes. Cold-boot waiting explains the wait.
Prepare your client for a cold boot:
- Set a client timeout of at least 15 minutes.
- Turn off automatic retries in your client, because a retry sends a second request while the first one still waits for the startup.
- Check a
200body for anerrorkey.
We can give a key a monthly activation quota for capture and steering requests.
Known limits
- Captured values for the same request can differ slightly when the server is busy, so compare an effect you measure with repeated runs of the same request.
- Chunked prefill splits a long prompt into chunks, and the tensor still has one row per token. We have checked this on llama-8b only.
- If the server pauses a capture to free memory, or skips part of a capture,
the API returns an error and no activations, because the rows would not match
the tokens. After a pause, the code is
503 capture_preempted, and you can send the request again after theRetry-Afterdelay. After a skipped part, the code is502 capture_rows_missing, and you should not resend the request unchanged.