Activation steering
Activation steering adds your vector to the residual stream at the decoder layers you name, and the model generates from the changed hidden state. The engine adds the vector on every forward pass. Research on concept vectors and representation engineering uses the same mechanism. A direction built from pirate-style sentences makes the model write in pirate dialect.
Prerequisites
- You need a model that lists
"activations": trueundercapabilitiesinGET /v1/models. - Your client needs a timeout of at least 15 minutes, because steering follows the cold boot and quota rules of activation harvesting.
The request body
To steer a completion, add an apply_steering_vectors field to a POST https://infra.acsresearch.org/v1/completions request:
{
"model": "llama-8b",
"prompt": "I think that",
"max_tokens": 32,
"apply_steering_vectors": [ // up to 8 vectors
{
"activations": { /* the vector, in the codec below */ },
"layer_indices": [16], // decoder layers to steer, counted from 0
"scale": 8.0, // multiplies the vector, default 1.0
"norm_match": false, // true scales the vector to the residual norm first
"position_indices": null // null steers every position
}
]
}
Encode a steering vector
Encode the vector in the
codec of harvested activations.
encode_vector stores the bfloat16 bits as int16, compresses them with zstd,
and encodes the result as base64.
import base64, numpy as np, ml_dtypes, zstandard as zstd
def encode_vector(vec: np.ndarray) -> dict:
"""vec: float array with ONE ROW PER STEERED LAYER.
(n_layers, hidden) -> each layer's vector broadcast to all positions
(n_layers, n_positions, hidden) -> position-specific
n_layers must equal len(layer_indices).
"""
a = vec.astype(ml_dtypes.bfloat16).view(np.int16) # bf16 bits as int16
packed = zstd.ZstdCompressor(level=1).compress(a.tobytes())
return {
"data": base64.b64encode(packed).decode(),
"dtype": "int16",
"original_dtype": "torch.bfloat16",
"shape": list(a.shape),
"compression": "zstd",
}
The leading axis is layers, and row i of the vector goes to layer_indices[i].
- To steer layers 16 and 20, set
layer_indices: [16, 20]and send a(2, hidden)vector. - To steer chosen token positions, send a 3D
(n_layers, n_positions, hidden)vector and setposition_indices.
hidden must equal the model's hidden size, which is 4096 on llama-8b. The value is 16384 on llama-405b and 3072 on trinity-truebase.
Steer a completion against a zero-scale baseline
Run the baseline as a steer with scale: 0.0 and every other field unchanged, so that the two requests differ only in scale.
import json, os, urllib.request
# encode_vector from the previous section
BASE = os.environ["ACS_API_BASE"]
KEY = os.environ["ACS_API_KEY"]
def post(body):
req = urllib.request.Request(f"{BASE}/completions", data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
out = json.load(urllib.request.urlopen(req, timeout=900)) # 15 min covers a cold boot
if "error" in out: # late cold-boot failures arrive inside a 200 body
raise RuntimeError(out["error"].get("message", str(out["error"])))
return out
# my_direction is your own direction for layer 16: a probe weight, a difference
# of means between two prompt sets, or an SAE decoder column.
vec = my_direction.reshape(1, 4096) # (n_layers=1, hidden): one row for layer 16
sv = {"activations": encode_vector(vec), "layer_indices": [16],
"scale": 8.0, "norm_match": False, "position_indices": None}
gen = {"model": "llama-8b", "prompt": "I think that", "max_tokens": 32, "temperature": 0}
baseline = post({**gen, "apply_steering_vectors": [{**sv, "scale": 0.0}]})
steered = post({**gen, "apply_steering_vectors": [sv]})
print("baseline:", baseline["choices"][0]["text"])
print("steered :", steered["choices"][0]["text"])
A steer with scale: 0.0 adds zero to the hidden state, and the steered layers keep their unsteered values. Results for the same request can differ slightly when the server is busy, so compare a steering effect with repeated runs at scale: 0.0.
Steer a span of positions
Position-specific steering is not available for now. A request with position_indices, position_spans, or a 3D vector returns 400 position_steering_unavailable, because the engine can apply such a vector to the wrong tokens. A 2D vector without positions steers every token and works as this page describes.
To steer a contiguous span, such as every token of a message, use
position_spans with a 2D (n_layers, hidden) vector and half-open
[start, end) pairs. The API repeats the vector at every position in the
spans.
# same encode_vector and post helpers as above
# my_prompt: your prompt, at least 25 tokens long, so the span stays inside the prompt
vec = my_direction.reshape(1, 4096) # 2D: one row for layer 16
sv = {"activations": encode_vector(vec), "layer_indices": [16],
"scale": 8.0, "norm_match": False,
"position_spans": [[10, 25]]} # steer positions 10..24 (half-open)
body = {"model": "llama-8b", "prompt": my_prompt, "max_tokens": 32, "temperature": 0}
print(post({**body, "apply_steering_vectors": [sv]})["choices"][0]["text"])
Positions count tokens from 0. Position 0 of a text prompt is the BOS token the engine adds, so the first token of your own text is position 1. Take the prompt length from usage.prompt_tokens of a capture of the same prompt.
A span or index that includes position 0 also steers every generated token, because each decode step numbers its one new token as position 0. Start spans at 1 or later to steer only prompt tokens. Request rules lists the span rules.
Steer and capture in one request
Add output_residual_stream to a non-streaming steering request to get the
steered text and its activations in one response.
# builds on `post`, `encode_vector`, `sv`, and `gen` from the zero-scale baseline example above
resp = post({
**gen, # single prompt, n defaults to 1
"apply_steering_vectors": [sv], # intervene: add the direction at layer 16
"output_residual_stream": [16], # measure: capture that same layer back
})
text = resp["choices"][0]["text"] # the steered completion
acts = resp["activations"]["residual_stream"] # decode as on the harvesting page
Decode and check the tensor before you use it. Steering layer_indices and capture indices count layers the same way, so you can capture the layer you steer, and that capture includes the steer.
Choose a scale
Raise scale in small steps, and lower it when the output starts to repeat phrases. Each steered layer's addition stays in the residual stream through the later layers, so retune scale when you change how many layers you steer.
With norm_match: true, the engine first scales your vector to the residual norm at each position, so one scale value is the same fraction of that norm at every layer and prompt. A scale of 1.0 then adds a vector whose norm equals the residual norm at that position.
The pirate notebook builds its vector from six pirate-style sentences and six plain paraphrases. It averages each set's activations over the tokens after BOS and takes the difference of the two means at each layer. The notebook steers layer 16 of llama-8b with the raw vector at temperature 0.8 and seed 7, and counts pirate words in its outputs for three prompts.
You can read every output of the recorded run in pirate_steering.html without an API key:
scale |
Pirate words, 3 prompts | Output for the speech prompt |
|---|---|---|
| 0.0 | 0 | Plain prose |
| 1.5 | 9 | Arr, me hearties, 'tis a fair day for ye walk the plank, matey! |
| 2.0 | 19 | Arr, me hearties, 'tis a fair day fer' th' Pirate Plunder! |
| 2.5 | 17 | Arrrr, me 'ersies, matey! and heavier dialect |
| 3.0 | 2 | Repeated fragments |
In this run, scales 1.5 to 2.0 gave the clearest pirate style, and by scale 3 the output turned into repeated fragments. The vector also works on a prompt with nothing nautical in it. At scale 2.0, the prompt My advice for your first day at the new job is continued with go in with a swashbucklin' attitude and a bountiful chest o' booty.
To run the notebook yourself, download pirate_steering.py and start it with ACS_API_KEY="acs-bm-..." uvx marimo edit --sandbox --watch pirate_steering.py.
Check a steer on your own vector
A steer that the engine skips still returns a normal 200. To find out whether the known limits affect your vector, compare a steer with a zero-scale run of the same request.
- To confirm that the steer changed anything, compare the two outputs.
- To see which prompt positions the steer touched, set
max_tokensto 1 andtemperatureto 0. Capture the steered layer and compare it row by row. With more generated tokens, the steered text diverges, and every later row then differs for that reason alone.
To check norm_match and the capture of a steered layer, send three requests.
Each sets max_tokens to 1 and temperature to 0, steers layer L with your
vector v, and captures layers L - 1 and L.
- Send
scale: 0.0as the baseline. - Send
norm_match: falsewith a nonzeroscale. If layerL - 1matches the baseline and layerLmoved by aboutscale * vat each position, the capture of layerLincludes the steer. - If layer
Lmoved in step 2, sendnorm_match: truewith the samescale. Subtract the baseline from each layerLrow. The norm of the difference is aboutscaletimes the norm of the baseline row. If layerLdid not move in step 2, the capture does not include the steer, and this step cannot settlenorm_match.
Reference
Steering vector fields
| Field | Type | Behavior |
|---|---|---|
activations |
codec object | The object contains the direction in the codec from Encode a steering vector. |
layer_indices |
list of integers | Each integer is a decoder layer to steer, counted from 0. |
scale |
number | The number multiplies the vector. The default is 1.0. |
norm_match |
boolean | false, the default, uses the raw vector. true scales the vector to the norm of the residual stream at each position. See Known limits. |
position_indices |
list of non-negative integers, or null |
The default is null, which steers every position. A list steers only those positions. For now, a list returns 400, as Steer a span of positions explains. For the last prompt token, pass [n-1], where n is usage.prompt_tokens. |
position_spans |
list of [start, end) pairs |
Each pair is a contiguous range of positions to steer. For now, a list of spans returns 400, as Steer a span of positions explains. |
Request rules
The API returns 400 for a request that breaks one of these rules:
- We allow at most 8 steering vectors in a request.
GET /v1/modelspublishes this number asmax_steering_vectors. - Steering takes one prompt and
n: 1. For more samples, send separate requests with differentseedvalues. - A steering request may stream, unless it also captures activations.
shape[0]ofactivationsequalslen(layer_indices).- Every index in
layer_indicesis a layer the model has, or the code isinvalid_request. Alayer_indexkey also returns400. position_indicesneeds a 3D vector withshape[1] == len(position_indices), and no index may be negative.position_spansneeds a 2D vector. Spans are half-open, so[10, 25]covers positions 10 through 24. Endpoints are non-negative, eachendis greater than itsstart, and spans do not overlap.[[1, 4], [20, 30]]is a valid list of two spans.position_spansandposition_indicesexclude each other.- With
position_spans, the API decodes the vector.dtypemust then be a known type, and the decoded byte count must matchshape. - The spans cover at most 4,096 positions in total.
- The tiled tensor,
decoded vector bytes * positions, is at most 64 MiB. One llama-405b layer takes16384 * 2bytes per position, so the cap of 64 MiB allows 2,048 positions. A vector of four llama-8b layers also allows 2,048 positions. For wide vectors, this cap is tighter than the cap of 4,096 positions. - We limit the base64
datastring of any steering vector to 512 KiB. This limit caps a llama-405b vector at about 15 layers. It caps a one-layer position-specific llama-8b vector at about 60 positions.
Known limits
- With
norm_match: true, the engine multiplies the scaled vector byscaleand adds it. It does not rescale the result back to the original norm. - The engine counts steering positions from the start of each forward pass, not from the start of the sequence. Positions match the prompt only when the whole prompt runs in one forward pass. The engine splits a long prompt into chunks, and a busy engine can also split a shorter prompt. Each chunk counts from 0 again.
- During generation, only a vector row for position 0 applies, and it applies to the new token.
- A vector without positions steers every token, and the position limits do not apply to it.
- The API does not check the vector's hidden size. A wrong size has one of two
results:
- A vector of hidden size 1 adds its one value to every element of the hidden state.
- With any other wrong size, the engine skips the steer at that layer and returns no error. A capture in the same request can then lack that layer.
- Vectors that target the same layer add up. With
norm_match: true, the engine scales each of them to the norm of the unsteered residual stream. - A vector that lists the same layer twice in
layer_indicesapplies only the row of its last listing.