Inference Backend
The generation layer: a pluggable backend protocol over SGLang, a frozen-teacher force-decode path for the K3 loss, a recording proxy that captures black-box scaffolds token-for-token, and the serving topologies that wire them together.
Backend protocol
One Protocol, two modes: token-in/token-out SGLang for the student policy, text-level API clients for eval. §1–2.
Teacher force-decode
A frozen Qwen3-Coder-30B scores every student token to feed the K3 reverse-KL distillation loss. §3.
Recording proxy
An OpenAI-compatible shim that turns black-box scaffolds — OpenHands, Terminus-2 — into token-level training data. §4–5.
Ground truth lives under rl_engine/rollout/backends/, proxy/, and agents/. Related pages are linked in Quick Reference (§7).
1 The Backend Abstraction
Every LLM call in a rollout goes through a backend — an object turning a prompt into a completion, defined as a structural Protocol (rl_engine/rollout/backends/base.py), not a base class. Two modes coexist:
Token-level (SGLang)
Accepts raw input_ids, returns token_ids + per-token logprobs — the exact tokens the policy emitted with their sampling logprobs, which is what RL training needs. supports_token_ids = True.
Text-level (API)
Accepts OpenAI messages + tools, returns text and optional tool_calls. No token IDs or logprobs — eval only, never training. supports_token_ids = False.
The agent loop reads supports_token_ids to pick the input type and whether to record tokens. Both return one dataclass, GenerationResult:
# rl_engine/rollout/backends/base.py
@dataclass
class GenerationResult:
text: str
token_ids: list[int] | None = None # SGLang only
logprobs: list[float] | None = None # SGLang only
tool_calls: list[dict] | None = None # API structured calls
finish_reason: str = "stop" # stop | length | abort | tool_calls | error:*
usage: dict[str, int] | None = None # API token accounting
class ModelBackend(Protocol):
async def generate(self, *, input_ids=None, messages=None,
tools=None, sampling_params) -> GenerationResult: ...
def tokenize(self, text) -> list[int]: ... # API backends raise NotImplementedError
def decode(self, token_ids) -> str: ...
The backends directory
| File | Exports | Role |
|---|---|---|
base.py | ModelBackend, GenerationResult | The Protocol + result dataclass. No logic. |
sglang.py | SGLangBackend | Token-level /generate for the student policy (§2). |
teacher.py | TeacherBackend | Wraps an SGLang server hosting the frozen teacher (§3). |
openai_chat.py | OpenAIChatBackend | Text-level OpenAI-compatible client (Gemini, GPT, or a chat-endpoint SGLang) — eval only, with RPM/TPM/concurrency rate-limiting and 429-aware backoff. |
token_tracker.py | TokenTracker | Per-trajectory aligned arrays: tokens, logprobs, loss_mask, obs_boundaries (§4). |
dagger_tracker.py | DAggerTracker | Per-turn (context, target) training pairs for the DAgger relabel path. |
api_pricing.py | MODEL_PRICING | USD-per-million-token table for API cost accounting. |
2 The SGLang Student Backend
SGLangBackend (backends/sglang.py) is the student policy's generation path — token-in / token-out. It reads the exact sampled token IDs and their logprobs from SGLang's native /generate: no re-tokenization, no chat-template round-trip, so the tokens the model emitted are the tokens training sees.
# SGLangBackend.generate — the request
url = f"{self._sglang_url}/generate"
payload = {
"input_ids": input_ids,
"sampling_params": sampling_params,
"return_logprob": True,
}
output = await self._post_fn(url, payload)
# finish_reason == "abort" -> empty result (SGLang killed the request)
# else read meta_info["output_token_logprobs"] as (logprob, token_id) pairs
token_ids = [item[1] for item in meta["output_token_logprobs"]]
logprobs = [item[0] for item in meta["output_token_logprobs"]]
A post_fn argument overrides the HTTP transport: everything uses slime's pooled post except the recording proxy, which injects its own loop-local httpx client (§4).
Where the URL comes from
The backend is built lazily inside the in-house SWE agent (swe_agent.py) from context.sglang_url, which the Evaluator sets to http://{sglang_router_ip}:{sglang_router_port} (scheduler/evaluator.py). Those router args come from two places:
- Colocated training — slime stands up its own SGLang router alongside the Megatron workers and sets the args automatically. Nothing to configure.
- External / eval — eval.sh passes
--rollout-external --rollout-external-engine-addrsplus explicit--sglang-router-ip/--sglang-router-port, fromROLLOUT_EXTERNAL_ENGINE_ADDRS(default0.0.0.0:9001); the engine is a standalone server (§6).
Weight sync between iterations
In colocated training the SGLang engines share GPUs with the trainer, so updated weights must reach them before the next rollout. The main loop calls trainer.update_weights() before training and after each iteration (rl_engine/train.py), fanning out only to policy workers (roles with sync_to_rollout). Colocated runs hand off via CPU shared memory (dodging CUDA-IPC failures under Ray fractional-GPU placement), otherwise NCCL broadcast; trainer-side mechanics are on the Backends page.
Serving knobs
| Knob | Default | Wiring | Effect |
|---|---|---|---|
SGLANG_MEM_FRACTION | 0.55 train · 0.7 (4B) · 0.5 (8B) | --sglang-mem-fraction-static | Static KV-cache fraction of remaining VRAM. Lower it to leave room for Megatron activations + optimizer state in colocated mode. |
MAX_CONTEXT_LEN | 65536 (train.sh export) | agent context cap | Per-trajectory token budget for the agent loop. |
SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN | 1 | SGLang-native | Lets the engine serve a context longer than the checkpoint's declared max_position_embeddings. |
CONTEXT_LEN | 262144 | --context-length (serve_sglang.sh) | Context window of the standalone eval server. |
TOOL_CALL_PARSER | qwen3_coder | --tool-call-parser | SGLang server-side tool-call parser; use qwen25 for Hermes-format models such as the 4B student. |
The installed-scaffold factory (evaluator_factory.py) caps agent context at a fixed 131072 rather than reading MAX_CONTEXT_LEN — that env var applies only to the in-house SWE-agent path.
3 The Teacher Backend
TeacherBackend (backends/teacher.py) wraps an external SGLang server hosting the frozen Qwen3-Coder-30B teacher. Because the teacher speaks a different chat template and tool syntax than the student, it loads its own tokenizer (CPU-only) and resolves its own tool-call format. It never acts in the environment, and any failure returns None without touching the student's trajectory. It plays two roles.
Role A — per-turn action labelling (DAgger / AggreVaTe rollin)
When a turn is teacher-executed, label_turn(history, task_prompt, system_prompt) renders a prompt in the teacher's format (include_reasoning=True, so it sees its own prior chain-of-thought) and parses the reply into a CanonicalAction; the lower-level generate_from_tokens(input_ids) takes a pre-built token sequence instead. The rollin protocols live in dagger_agent.py — see Algorithm Recipes.
Role B — post-rollout force-decode for the K3 loss
After a rollout is collected and finish-filtered, each kept trajectory is force-decoded by the teacher to recover its probability of every student-emitted token — the log p_t term the K3 reverse-KL needs. Orchestration is _apply_distillation_capture (rollout_manager.py), calling score_response_with_topk (distill_capture.py).
Teacher force-decode. Kept trajectories flow right to the frozen teacher, whose per-token chosen + top-K logprobs return along the bottom as K3 loss inputs.
The payload force-decodes the exact response — one greedy step over prompt_ids + response_ids with logprobs on:
# distill_capture.py — score_response_with_topk (payload)
sampling_params = {
"max_new_tokens": 1, "temperature": 0.0, "top_p": 1.0,
"top_k": -1, "no_stop_trim": True,
}
payload = {
"input_ids": prompt_ids + response_ids, # force-decode the exact response
"sampling_params": sampling_params,
"return_logprob": True,
"logprob_start_len": start_len, # = prompt_len - 1
"top_logprobs_num": K, # STACX_DISTILL_TOPK (default 20)
}
A persistent teacher failure is not swallowed — it propagates, failing the run loudly rather than silently emitting empty logprobs. The captured fields are pinned onto each Sample (teacher_log_probs, teacher_topk_indices, teacher_topk_logprobs), and loss_mask is overwritten with the action mask so the loss sees exactly the student-emitted tokens.
Greedy force-decode ≠ teacher sampling
The force-decode is hard-coded greedy (temperature=0.0, top_p=1.0): it measures the teacher's probability of the student's tokens, so it must not sample. TEACHER_TEMPERATURE / TEACHER_TOP_P apply only to Role A, not here.
Teacher & distillation knobs
| Env var | Default | Read at | Effect |
|---|---|---|---|
TEACHER_SGLANG_URL | required | rollout_manager.py | Base URL of the teacher SGLang server. OPD / mixed runs raise if empty. |
TEACHER_PORT | 30055 | launch scripts | Builds TEACHER_SGLANG_URL=http://127.0.0.1:${TEACHER_PORT}. |
TEACHER_TEMPERATURE | 0.0 scripts 0.7 | teacher.py | Sampling temperature for Role-A teacher actions. |
TEACHER_TOP_P | 1.0 scripts 0.9 | teacher.py | Sampling top-p for Role-A teacher actions. |
TEACHER_FORMAT | "" (auto) | teacher.py | Force the teacher tool-call format when auto-detection misfires. |
STACX_DISTILL_TOPK | 20 | rollout_manager.py · distill.py | top_logprobs_num for the force-decode; must match the train-time K. |
STACX_DISTILL_TEACHER_CONCURRENCY | 4 | rollout_manager.py | Max concurrent force-decode requests (Semaphore). |
STACX_DISTILL_TEACHER_RETRIES · _BASE_DELAY | 5 · 2.0 | distill_capture.py | Retry attempts (total = retries + 1) and base seconds for base · 2^attempt backoff. |
STACX_DISTILL_KL_MODE | k3_sample | distill.py | RKL estimator: k3_sample (any TP, default) or topk (full top-K RKL, TP=1 only). |
STACX_DISTILL_DUMP_DIR | "" (off) | rollout_manager.py | If set, dumps per-iter teacher↔student token discrepancy data. |
STACX_DISTILL_SKIP_TEACHER_GENERATE | 0 | dagger_agent.py | Skip the per-turn teacher generation under pure OPD (β=0), where it is wasted. |
4 The Recording Proxy
The in-house SWE agent emits tokens natively; the black-box installed scaffolds — OpenHands, Terminus-2 — cannot. The RecordingProxy (proxy/recording_proxy.py) bridges the gap: an OpenAI-compatible façade between scaffold and SGLang that intercepts /v1/chat/completions and reconstructs a token-level trajectory without touching the scaffold. One proxy per task, on an auto-assigned port, torn down at task end.
Context construction. The proxy keeps one running token series (a TokenTracker) across a task's chat calls; the series fed to the model is the stored trajectory, so the training sequence equals the conditioning context at every turn.
Token capture. The proxy builds one token series across a task's chat calls.
The per-call cycle
- Sync the series —
_sync_seriesfinds the messages appended since the last turn, skipping the assistant turns the scaffold echoes back (already in the series). - Tokenize the increment — new observation/tool messages go in mid-conversation via
tokenize_messages_incrementwithloss_mask = 0; the first call renders the whole prompt. - Generate —
/generateruns on the exact series; the returned token IDs + logprobs are appended verbatim withloss_mask = 1. - Reply — those tokens are decoded back into an OpenAI
ChatCompletion(withtool_calls) so the scaffold acts as it would have.
The append-only guard
The scheme assumes an append-only conversation. If a scaffold rewrites or summarizes its history, the stored series no longer matches — so _sync_series checks a per-message signature (_sig: role + content shape) against everything consumed so far. A shrunk or rewritten prefix raises _AppendOnlyViolation: capture is disabled and the request passes through, so the run completes but emits no token data. Semantics are pinned by test_recording_proxy_guard.py:
# the guard, distilled from the tests
_sig(m) -> (role, content_shape) # role or content change is detected
pure append (messages grow) -> OK
history shrank (len drops) -> raise _AppendOnlyViolation("shrank")
prefix rewritten (summary swap) -> raise _AppendOnlyViolation("rewritten at message i")
Other failures are equally defensive: an SGLang abort or any unexpected exception flips capture_valid off and passes through, while get_result_fields() returns None on an invalid or length-inconsistent series (logprobs, mask, and tokens must align) — dropping the trajectory rather than risking a corrupt sample. The result matches the native SWE-agent shape: one completion region of prompt_token_ids, completion_token_ids, logprobs, and a loss_mask that is 1 on generated tokens (append_generation) and 0 on observations (append_observation).
5 The Installed-Scaffold Path
InstalledAgent (agents/installed.py) runs a scaffold as a black-box CLI tool inside a ROCK container: it owns the container lifecycle, while a per-scaffold InstalledScaffold supplies the install/run commands and log/termination semantics — shell commands in, files out.
The runner
InstalledAgent.run() executes a fixed per-task sequence: start ROCK sandbox → optionally start recording proxy → install scaffold → run it → freeze benchmark artifact → run verifier → parse trajectory → capture logs → classify termination → tear down. The proxy interposition is the switch that turns eval into training:
# installed.py — _maybe_start_proxy (train vs eval, one branch)
if not self.capture_tokens:
return None # eval: scaffold talks to SGLang directly
proxy = RecordingProxy(sglang_url=..., tokenizer_path=hf_checkpoint, ...)
proxy.start()
context.proxy_url = proxy.url # scaffold is pointed here instead
The scaffold's base URL is then context.proxy_url or context.sglang_url — the proxy when capture is on, SGLang directly when off (as in eval). After teardown, proxy.get_result_fields() pulls the token series onto the AgentResult.
OpenHands
A self-contained pip CLI (RUNTIME=local) that calls the LLM through LiteLLM's openai/ provider — so SGLang's native tool-call parser fires — and logs completions + a trajectory JSON under /logs/agent. Capture forces append-only via a NoOp condenser and AGENT_ENABLE_HISTORY_TRUNCATION=false.
Terminus-2
A Harbor terminal agent driving a tmux session: pinned Harbor source cloned into the container and run by a thin in-container runner; it too targets proxy_url or sglang_url. Summarization is on for eval, off (append-only) under capture.
Termination-reason classification
Every run gets a _termination_reason that drives the failure-class summary. The base classifier (InstalledScaffold.termination_reason) keys off the exit code; scaffolds refine the clean exit. OpenHands' (pinned by test_openhands_termination.py) applies this precedence, first match wins:
| Precedence (first match wins) | Reason |
|---|---|
| exit 124 (exec wall-clock kill) | agent_timeout |
| exit 137 (OOM / SIGKILL) — external kill beats any marker | infra_other |
a finish action in the trajectory | finish |
| a terminal-error marker in the last 40 log lines | marker table ↓ |
steps ≥ max_iterations | max_turns |
| any other nonzero exit | infra_other |
| clean exit, no signal | other |
| Log-tail phrase (case-insensitive) | Reason |
|---|---|
| reached maximum iteration | max_turns |
| stuck in a loop | repeated_action_loop |
| exceeds the model's maximum context length | context_overflow |
| contextwindowexceeded | context_overflow |
Terminus-2's is simpler: a clean exit with a mark_task_complete call is finish, else max_turns if the step budget was hit, else finish; nonzero exits defer to the base classes.
Capture vs. plain evaluators
Which variant runs is a build-function choice. SkyRL (skyrl/evaluator_config.py) exposes four: build_evaluators_openhands, …_openhands_capture, …_terminus2, …_terminus2_capture. The _capture variants pass capture_tokens=True — the master switch that turns the proxy on and forces append-only history (raising if APPEND_ONLY=off). The rest leave the proxy off and allow summarization: pure eval.
6 Serving Topologies
The same backends wire up three ways:
A · Eval-only (external server)
serve_sglang.sh starts a standalone engine (python -m sglang.launch_server: --tp-size, --tool-call-parser, --disable-radix-cache, --context-length 262144, port 9001). eval.sh connects with --rollout-external and runs --num-rollout 0 --debug-rollout-only, writing an eval JSONL. No trainer, no teacher.
B · Co-located training (in-house SWE agent)
The dagger / aggrevate / OPD recipes run on one 8-GPU node: the frozen teacher (Qwen3-Coder-30B, TP=4) on GPUs 0-3 port 30055, the student Megatron workers and their SGLang engines on GPUs 4-7 under Ray --colocate. Weights sync to the engines each iteration (§2); the teacher is queried per-turn (Role A) and force-decoded post-rollout (Role B). ROCK admin serves sandboxes at 127.0.0.1:8080.
C · Installed-scaffold training (recording proxy in the loop)
train.sh runs a Ray job: the training rollout uses the _capture evaluator (proxy on, append-only) for BENCH, eval uses the plain evaluator for a separate EVAL_BENCH. Each task's own RecordingProxy fronts the shared student engine, turning black-box calls into trainable token series.
| Topology | Student engine | Teacher | Proxy | Key ports |
|---|---|---|---|---|
| A · Eval-only | external serve_sglang.sh | — | off | 9001 |
| B · Colocated train | Ray + Megatron, GPUs 4-7 | SGLang GPUs 0-3 | off (native tokens) | 30055 · 8080 |
| C · Installed-scaffold train | Ray, colocated | optional (OPD) | per-task, on | auto · 8080 |
7 Quick Reference
| Symbol | Location | Responsibility |
|---|---|---|
ModelBackend · GenerationResult | backends/base.py | Backend protocol + result dataclass. |
SGLangBackend | backends/sglang.py | Token-level /generate; generate, tokenize, decode. |
TeacherBackend | backends/teacher.py | label_turn, generate_from_tokens — frozen teacher relabel. |
OpenAIChatBackend | backends/openai_chat.py | Text-level API client (eval only), rate-limited. |
TokenTracker | backends/token_tracker.py | from_prompt, append_generation, append_observation, to_result_fields. |
score_response_with_topk | rollout/distill_capture.py | Teacher force-decode → chosen + top-K logprobs. |
_apply_distillation_capture | rollout/rollout_manager.py | Orchestrates force-decode, pins fields onto samples. |
RecordingProxy | rollout/proxy/recording_proxy.py | _sync_series, _handle_chat_completion, get_result_fields. |
InstalledAgent · InstalledScaffold | rollout/agents/installed.py | Black-box container runner + scaffold interface. |
OpenHands · Terminus2 | rollout/agents/installed_scaffolds/ | Per-scaffold install/run + termination classifier. |
Related pages: Rollout Architecture · Task Scheduler · Algorithm Recipes · Sandbox Management · Trainer Backends.