The flow
rllm dataset from-eval when the traces came from an rllm eval run (see Running evaluations). Use rllm dataset import when you have external trace files (an OpenAI messages export, or <think>-tagged rows). Both produce the same registered dataset, and both feed rllm sft unchanged.
The SFT row schema (the contract)
Every dataset the pipeline produces is a list of{"messages": [...]} rows. Each message carries a per-message trainable flag, and its content is a list of structured parts rather than a flat string:
Structured SFT row
text (visible output) and thinking (the model’s reasoning). Tool calls ride on the message as a canonical OpenAI-shaped list:
rllm/data/sft_schema.py (SFTMessage, TextPart, ThinkingPart, SFTToolCall).
Why structured thinking parts, not baked <think> strings
Different model families wrap reasoning in different wire formats — DeepSeek uses <think>...</think>, Qwen and Harmony use their own. If the dataset hard-coded one format, it would only train that family. Instead, reasoning is stored model-agnostically as a thinking part, and the student’s renderer decides the on-the-wire format at training time. One curated dataset trains any student.
Flag-less plain rows still work
You can handrllm sft a plain {"role", "content": "..."} dataset with no trainable flags at all (e.g. an external --train-file). The loader derives the mask: assistant turns are trainable, everything else is not. With --tokenize-method stepwise, only the last assistant turn is trained instead of all of them. Rows that already carry per-message trainable flags (everything from from-eval and import) are used verbatim — --tokenize-method does not override them.
Getting data in
rllm dataset from-eval — curate your own eval runs
from-eval reads one or more rllm eval run directories, filters tasks by an aggregate metric, selects which trajectories to keep, and emits SFT rows.
--metric (what avg/best/worst aggregate — is_correct, reward, or a signal name), --filter (task-level DSL over aggregates), --select (correct | best | best-n | shortest | all), --max-per-task, --min-reward, --dedup/--no-dedup, --trajectory (named trajectory for multi-agent flows), and --val-fraction to hold out tasks as a validation split.
--max-per-task caps the number of attempts kept per task, not the number of rows. A single attempt can expand into several rows when the automerge walk splits it (below).The automerge walk
Each kept attempt (episode) is turned into one or more rows by a deterministic walk over its steps (rllm/eval/curation.py, the message-extraction section). The data alone decides the shape — no flag toggles it:
- Non-thinking / interleaved histories — when each step’s conversation is a clean prefix of the next (the harness feeds full history back verbatim, reasoning included), the steps merge into one multi-turn row with every assistant turn trained.
- Reasoning stripped from history — when the harness drops a turn’s reasoning before feeding it back (non-interleaved runs), the prefix breaks and each turn becomes its own split row, so every row matches exactly what the model saw at inference.
CurationStats fields in the summary (all defined in CurationStats, rllm/eval/curation.py):
If an attempt produces many trained steps but zero merges, curation logs a degenerate-splitting warning — usually a per-step-varying history element (a timestamp, a counter) is breaking the prefix. Heavy splitting is worth noticing: it duplicates history across rows, so a
T-turn attempt costs on the order of O(T²) tokens.
rllm dataset import — external trace files
import bridges a local file of {"messages": [...]} rows into the canonical schema and registers it. Choose the source shape with --format (rllm/cli/dataset.py, bridges in rllm/data/sft_bridges.py):
messages— plain OpenAI rows. The loss mask is derived from--train-on(allassistant turns, default, or only thelast).think-tags— rows whose assistant turns start with a<think>...</think>block (a common convention for distilled reasoning traces). The bridge splits that block into athinkingpart and carries every non-messagestop-level key through verbatim as row metadata.
--explode (think-tags only, default ON)
This is the most important knob for think-tags, so spell out the tradeoff before choosing:
- Exploded (default) — the conversation is split into one row per assistant turn. History turns keep only their visible text (CoT stripped), and the row’s single final assistant turn is the trained target with its CoT kept. This is exactly what a next-token SFT loss wants, and it’s inference-faithful: thinking-family renderers strip prior-turn reasoning at render time, so training every CoT block requires giving each its own row. The cost is duplicated history tokens.
--no-explode— one compact row per full conversation, every assistant turn trainable. But because thinking-family renderers strip history CoT at render time, only the final turn’s CoT is actually trained; earlier turns contribute their visible text only. You save history-token duplication but train fewer CoT blocks.
--explode when you want every reasoning block trained; use --no-explode for compact conversations where only the last turn’s reasoning matters.
Training with rllm sft
CUSTOMIZED mode, driven by each message’s trainable flag — the data decides the loss mask (rllm/trainer/sft/tinker_backend.py, build_sft_data).
Logging training progress
Per-step training metrics (loss, learning rate, progress) route through rLLM’s unified tracking layer (rllm.utils.tracking.Tracking). console logging is always on; add more backends with --logger:
--logger accepts console | wandb | mlflow | swanlab | tensorboard | file | ui. --project/--experiment name the run in whichever backend you pick (default rllm-sft / the dataset name). --ui is auto-enabled when you’re logged in; pass --no-ui to opt out.
Live rLLM UI logging (
--ui) is supported on the tinker and fireworks backends only; on --backend verl it is dropped with a warning.Renderer auto-detection and --renderer
The tinker/fireworks path renders rows through a tinker-cookbook renderer. rLLM auto-detects it from the model in this order (_resolve_renderer_name, rllm/trainer/sft/tinker_backend.py):
- An explicit
--rendererwins (an advisory warning fires if it looks mismatched — never fatal). - Otherwise tinker-cookbook’s recommendation map (
get_recommended_renderer_name). - Otherwise a family heuristic on the model basename (
qwen3.5 → qwen3_5,qwen3 → qwen3,deepseek → deepseekv3,llama-3 → llama3). - Otherwise fall back to
role_colonwith a warning.
--renderer when auto-detection can’t place your model (e.g. a small or renamed checkpoint tinker’s map doesn’t cover). Valid names:
Backend support matrix
Fireworks subclasses the tinker backend and reuses the same rendering pipeline, so structured rows behave identically. The verl backend rejects structured rows — parts-list content or per-message
trainable flags — with an actionable SFTConfigError (_reject_structured_rows, rllm/trainer/sft/verl_backend.py); plain {role, content: str} rows still train fine on verl. Structured SFT support on verl is planned.
Worked example: importing think-tagged teacher traces
Starting from an external file of<think>-tagged teacher traces, distill them into Qwen/Qwen3.5-4B on tinker.
import prints how many source rows expanded into how many SFT rows (explosion inflates the count), then the exact inspect and sft commands to run next.
Troubleshooting
CommonSFTConfigError messages and what to do:
Next steps
Running evaluations
Generate the teacher traces that feed
from-evalTinker backend
Configuration and LoRA options for the tinker trainer
Fireworks backend
Hosted training on Fireworks (same data path)
Bring your own dataset
Register custom datasets for eval and training

