Skip to main content

Async and servers

Everything on the model is async, including on eager where the work underneath is synchronous — so one caller can hold either backend. The sync free functions exist so a notebook or a script does not have to.

The sync free functions

They dispatch on the model you hand them, so the backend is one argument.

from interp_engine import (
capture_attention,
capture_generation,
generate_stream,
load_model,
run_with_cache,
)

model = load_model("openai-community/gpt2", backend="eager") # or backend="vllm"
tokens = model.to_tokens("The capital of France is")

cache = run_with_cache(model, tokens, ["resid_post.5"])
completion, gen_cache = capture_generation(model, tokens, ["resid_post.5"], max_tokens=8)
attn = capture_attention(model, tokens, [5])
steps = list(generate_stream(model, tokens, max_tokens=8, n_logprobs=5))

These return a Cache, which keeps the batch axis and accepts either an Address or its string form on lookup.

Methods without a loop

sync_model mirrors every method, minus the await, on one background loop per model. It is cached, so calling it twice hands back the same facade.

from interp_engine import load_model, sync_model

sync = sync_model(load_model("meta-llama/Llama-3.1-8B", backend="vllm"))
sync.warmup()
acts = sync.capture(token_ids, ["resid_post.10"])
logits = sync.decode_residuals(acts[next(iter(acts))])
sync.shutdown()

It refuses rather than deadlocks if called from inside a running loop. There, await the method directly.

Async methods

capture returns a plain dict keyed by Address, with no batch axis[n_prompt_tokens, width], on CPU. A string is a KeyError, so bind the address to a variable.

from interp_engine import Address, load_model


async def read(hf_id: str):
model = load_model(hf_id)
await model.warmup()

point = Address("resid_post", 10)
token_ids = model.to_tokens("The capital of France is")[0].tolist()
acts = await model.capture(token_ids, [point])

await model.shutdown()
return acts[point]

One model, one event loop

A vLLM model is bound to whichever loop first built its engine — that is where AsyncLLM keeps its output handler and its per-request futures. Awaiting it from a second loop raises ForeignEventLoop.

This matters most to servers: initialize on the loop that will serve requests. asyncio.run(startup()) closes its loop on the way out, so an engine built inside it is unusable by the time the first request arrives.

from contextlib import asynccontextmanager

from fastapi import FastAPI

from interp_engine import load_model

model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
global model
model = load_model("Qwen/Qwen3-8B", backend="vllm")
await model.warmup() # on the serving loop, not a throwaway one
yield
await model.shutdown()


app = FastAPI(lifespan=lifespan)

shutdown() is exempt and can be called from anywhere, so teardown always has a way to reap the child process.

Concurrency

vLLM batches concurrent requests, and a per-request steering spec does not leak into its neighbours.

import asyncio

from interp_engine import Address, load_model


async def many(model, prompts, spec):
point = Address("resid_post", 10)
return await asyncio.gather(
*(
model.capture(model.to_tokens(p)[0].tolist(), [point], steering_spec=spec)
for p in prompts
)
)

Declaring taps after load

The static backend bakes its taps into CUDA graphs, and a server often does not know them until its SAEs have loaded. configure_static binds the set after construction and before the engine exists.

from interp_engine import load_model


async def serve(points, writes):
model = load_model("Qwen/Qwen3-8B", backend="vllm-static", static_points="auto")
model.configure_static(points, static_writes=writes)
await model.warmup()
return model

It raises once the engine is built, so it has to run before warmup() or the first request.