Skip to content

Conditional steering with a per-token audit

Conditional steering applies a steer only when a condition holds: a condition direction is read at one site, its score is compared with a threshold, and the behaviour steer at a later site is added only on the forwards where the score passed. TorchLens has no when= option for this; the condition is a custom edit written in a few lines. What TorchLens adds is the record: every forward of a steered generation leaves a fire record, and each record says whether the edit replaced the activation. Joined with the condition scores, that gives an audit of every prompt and every generated token: the score, whether it passed the threshold, and whether the steer was applied. A false trigger, a prompt that should not have been steered but was, shows up as a row you can inspect.

The example uses a two-layer Llama with random weights and token-id prompts, so it runs offline; with a real checkpoint, load it with from_pretrained and keep the rest.

Minimal example on the running model

This page uses the decoder on the IOI prompt. A steer is an edit that adds a direction at a site; bound with tl.when, it runs on every call. This page makes the steer conditional. The recipe below builds its own model for the method.

# recipe-test: tool=transformers expected=True
import torch
import transformers

import torchlens as tl

PROMPT = "When Mary and John went to the store, John gave a drink to"
words = PROMPT.replace(",", " ,").split()
vocab = sorted(set(words))
x = torch.tensor([[vocab.index(word) for word in words]])
torch.manual_seed(0)
config = transformers.GPT2Config(
    vocab_size=len(vocab),
    n_positions=32,
    n_embd=64,
    n_layer=2,
    n_head=4,
    use_cache=False,
    attn_implementation="eager",
)
model = transformers.GPT2LMHeadModel(config).eval()

trace = tl.trace(model, x)

direction = torch.randn(64, generator=torch.Generator().manual_seed(1))
steered = tl.when(tl.module("transformer.h.1.mlp"), tl.steer(direction, strength=4.0, dim=-1)).bind(model)
RESULT = not torch.allclose(steered(x).logits, model(x).logits)

1. Fit the condition direction and a threshold

The condition here is "the newest token is token 7". fit_direction fits it from prompts that end in that token against prompts that do not, at the first MLP. The threshold is the midpoint between the two groups' mean scores on fresh prompts.

# recipe-test: tool=transformers expected=True
import torch
import torch.nn.functional as F
import transformers

import torchlens as tl
from torchlens.intervention import fit_direction

torch.manual_seed(0)
config = transformers.LlamaConfig(
    vocab_size=64,
    hidden_size=32,
    intermediate_size=64,
    num_hidden_layers=2,
    num_attention_heads=4,
    num_key_value_heads=2,
    max_position_embeddings=64,
)
model = transformers.LlamaForCausalLM(config).eval()
model.config.use_cache = False  # traces read plain forwards; generation turns the cache on
CONDITION_SITE, STEER_SITE, TRIGGER = "model.layers.0.mlp", "model.layers.1.mlp", 7


def prompts(n, triggered, seed):
    """Token-id prompts of length 6; the triggered ones end in TRIGGER."""
    ids = torch.randint(10, 64, (n, 6), generator=torch.Generator().manual_seed(seed))
    if triggered:
        ids[:, -1] = TRIGGER
    return ids


capture = tl.options.CaptureOptions()
pos = tl.trace(model, prompts(8, True, 1), capture=capture, save="all")
neg = tl.trace(model, prompts(8, False, 2), capture=capture, save="all")
condition = fit_direction(pos, neg, CONDITION_SITE, method="pca")


def score(activation):
    """Cosine similarity of the newest position with the condition direction."""
    return float(F.cosine_similarity(activation[0, -1].detach(), condition.vector, dim=0))


def calibration_scores(ids):
    trace = tl.trace(model, ids, capture=capture, save="all")
    out = trace[CONDITION_SITE].out
    return [score(out[i : i + 1]) for i in range(ids.shape[0])]


on_scores = calibration_scores(prompts(6, True, 3))
off_scores = calibration_scores(prompts(6, False, 4))
threshold = (sum(on_scores) / 6 + sum(off_scores) / 6) / 2
RESULT = min(on_scores) > threshold > max(off_scores)

2. The conditional edit, and one steered generation per prompt

Two rules in one spec. The first reads the condition at CONDITION_SITE, keeps the score, and leaves the activation untouched. It puts its decision in ctx.run_state, a dictionary that every hook of one bound call shares and that starts empty for the next call, so no decision leaks from one prompt into the next. The second rule adds the behaviour direction at STEER_SITE only when that decision passed; otherwise it returns the activation object unchanged.

Each prompt runs in its own bound generate call, so one fire report belongs to one prompt and one forward. The binding derives replaced from object identity, not from anything the hook reports, which is why it is the evidence of whether the steer was applied.

# recipe-test: tool=transformers expected=True
behaviour = torch.randn(32, generator=torch.Generator().manual_seed(5))
MAGNITUDE = 8.0
scores = []


def read_condition(activation, *, ctx):
    value = score(activation)
    scores.append(value)
    ctx.run_state["condition_passed"] = value > threshold
    return activation


def gated_steer(activation, *, ctx):
    if not ctx.run_state.get("condition_passed", False):
        return activation
    return activation + MAGNITUDE * behaviour


spec = tl.when(tl.module(CONDITION_SITE), read_condition).merge(
    tl.when(tl.module(STEER_SITE), gated_steer)
)
gen_kwargs = {"max_new_tokens": 4, "do_sample": False, "use_cache": True, "pad_token_id": 0}
eval_prompts = [("trigger", ids) for ids in prompts(3, True, 6)]
eval_prompts += [("neutral", ids) for ids in prompts(3, False, 7)]

bound = spec.bind(model, on_zero_fire="error")
audit = []
for prompt_index, (label, ids) in enumerate(eval_prompts):
    scores.clear()
    outputs = bound.generate(ids[None], **gen_kwargs)
    report = bound.last_report  # this call's fire records
    steer_fires = [fire for fire in report.fires if fire["site_key"] == STEER_SITE]
    assert len(steer_fires) == len(scores)  # one condition read and one steer per forward
    new_tokens = outputs[0, ids.shape[0] :].tolist()
    for step, (value, fire) in enumerate(zip(scores, steer_fires)):
        audit.append(
            {
                "prompt": prompt_index,
                "label": label,
                "step": step,
                "token": new_tokens[step],
                "score": round(value, 3),
                "passed": value > threshold,
                "applied": fire["replaced"],
            }
        )

RESULT = all(row["applied"] == row["passed"] for row in audit)

Step k is the k-th forward of the generation: step 0 is the prompt (prefill), and step k reads the token generated at step k - 1. Its score is read at the newest input position, and the steer it gates shapes the token generated at step k, shown in token.

3. Read the audit

Every row is one prompt and one generated token. At step 0, the prompt itself, the condition passes exactly for the prompts that end in the trigger:

# recipe-test: tool=transformers expected=True
for row in audit:
    print(row)

prefill = [row for row in audit if row["step"] == 0]
RESULT = all(row["passed"] == (row["label"] == "trigger") for row in prefill)

The rows a safety review starts from are the ones where the steer was applied to a prompt that should not have been steered, and the ones where a prompt that should have been steered was not:

# recipe-test: tool=transformers expected=True
false_triggers = [row for row in audit if row["applied"] and row["label"] == "neutral"]
missed = [r for r in prefill if r["label"] == "trigger" and not r["applied"]]
RESULT = sorted(audit[0]) == ["applied", "label", "passed", "prompt", "score", "step", "token"]

4. A rule that never ran is an error, not a quiet audit

Because the condition lives inside the hook, the steer rule fires on every forward and its records carry replaced=False when the condition failed. Zero fires therefore means the rule never ran at all, which is a wiring mistake, never "the condition did not pass". The recipe passes on_zero_fire="error" so such a run refuses instead of producing an audit with no applied rows. The binding's default, "warn", also records the fact on the report and warns. Here the reader is meant to run on the last generated token only, but the step was counted from 1: at_step(4). Steps count from 0, so a four-token generation (steps 0 to 3) never reaches step 4:

# recipe-test: tool=transformers expected='bind_zero_fire'
from torchlens import at_step

late = tl.when(tl.module(CONDITION_SITE) & at_step(4), read_condition).merge(
    tl.when(tl.module(STEER_SITE), gated_steer)
)
try:
    late.bind(model, on_zero_fire="error").generate(eval_prompts[0][1][None], **gen_kwargs)
except Exception as error:
    RESULT = error.fields["code"]

Limits

  • One prompt per call. The condition decides per forward, and a fire record covers the whole batch at that site; with a batch, gate each row inside the hook and keep your own per-row record of which rows were steered.
  • The score is read at the newest position only, and the steer is added at every position of that forward (all prompt positions at step 0, the one new position after that).
  • The condition reader and the steer are opaque callables: the spec is audit-only and does not travel in a portable artifact.