Research preview. Apply for access. We review each application and email you when it's approved, with your API key ready on the API keys page.

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": true under capabilities in GET /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 set position_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_tokens to 1 and temperature to 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.

  1. Send scale: 0.0 as the baseline.
  2. Send norm_match: false with a nonzero scale. If layer L - 1 matches the baseline and layer L moved by about scale * v at each position, the capture of layer L includes the steer.
  3. If layer L moved in step 2, send norm_match: true with the same scale. Subtract the baseline from each layer L row. The norm of the difference is about scale times the norm of the baseline row. If layer L did not move in step 2, the capture does not include the steer, and this step cannot settle norm_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/models publishes this number as max_steering_vectors.
  • Steering takes one prompt and n: 1. For more samples, send separate requests with different seed values.
  • A steering request may stream, unless it also captures activations.
  • shape[0] of activations equals len(layer_indices).
  • Every index in layer_indices is a layer the model has, or the code is invalid_request. A layer_index key also returns 400.
  • position_indices needs a 3D vector with shape[1] == len(position_indices), and no index may be negative.
  • position_spans needs a 2D vector. Spans are half-open, so [10, 25] covers positions 10 through 24. Endpoints are non-negative, each end is greater than its start, and spans do not overlap. [[1, 4], [20, 30]] is a valid list of two spans.
  • position_spans and position_indices exclude each other.
  • With position_spans, the API decodes the vector. dtype must then be a known type, and the decoded byte count must match shape.
  • 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 takes 16384 * 2 bytes 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 data string 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 by scale and 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_indices applies only the row of its last listing.

Next steps