← back
depth
00 · opening
Last updated September 18, 2026

Post-training, step by step.

Pre-training makes something that can finish your sentence. It cannot hold a conversation, follow an instruction, use a tool, or stop talking. Everything you like about a chat model is added afterward, for a tiny fraction of the cost. Here is each step, with the real data, the real code, and small worked examples you can check by hand.

Click a card to open that layer on every step below, or use the depth control in the corner. Every number in a worked example was computed, not invented.

scroll
01 · the map

A tiny slice of the budget decides everything you notice.

Post-training adds almost no knowledge. The facts were learned from trillions of tokens, and a few hundred thousand examples will not add more. What it adds is a format (this is where your turn ends), a habit (answer the question that was asked), a taste (of two answers, people preferred this one), and, since 2025, a skill the base model did not have: thinking for a long time before answering.

THE SAME WEIGHTS, ASKED THE SAME THINGBASE MODELprompt: What is 2+2?output: What is 3+3? What is 4+4? What is 5+5? What is 6+6?it continues the pattern, foreverAFTER POST-TRAININGprompt: What is 2+2?output: 4. <|im_end|>it answers, then stopsPost-training adds almost no knowledge. It teaches a format (when a turn ends), a habit (answer the question asked), and a taste(which of two answers a person prefers). DeepSeek-V3 spent about 0.2 percent of its compute here.
01the ideawhat it does and why. read this first+

There is no single recipe. There is a menu, and every lab orders differently. Almost all of them start with supervised fine-tuning, most add a preference stage, and the ones that care about math and code finish with reinforcement learning against an automatic checker.

four recipes, in the order they were run
Tulu 3ai2 · 2024SFTDPORLVR (PPO)DeepSeek-R12025cold-start SFTreasoning RLrejection SFTRL, all tasksQwen32025long-CoT SFTreasoning RLthinking fusiongeneral RLOLMo 3 Think2025SFTDPOGRPO
DeepSeek-R1 runs a loop: the model trained by reasoning RL generates the data for the next round of supervised fine-tuning. That loop was the most copied idea of 2025.

Where RLHF fits

You will see RLHF everywhere, including on this page. It stands for reinforcement learning from human feedback, and it names one specific recipe: the one behind the first ChatGPT. It has three parts. First comes SFT. Then people pick the better of two answers, and a reward model learns to score answers the way they would. Then comes reinforcement learning against that reward model. The model writes answers, the reward model scores them, and PPO pushes the model toward higher scores. A KL penalty keeps it close to the SFT model.

WHERE RLHF FITS, AND WHAT REPLACED EACH HALF OF ITbefore all of these: pre-training. next-token loss on raw text. no human feedback, no reward.RLHF2022SFTreward modelpeople pick the better answerPPO + KL penaltyreinforcement learninghuman feedback + RLDPO2023SFTDPOlearns from the preference pairs directlyhuman feedback, no RLRLVR2025SFTverifiera program checks the answerGRPO + the fixesreinforcement learningRL, no humanhuman feedbackreinforcement learninga program instead of peopleClick a box to jump to its section. Most recipes now run more than one lane. Tulu 3, for example, did SFT, then DPO, then RLVR.

Most of this page covers what came after, and each newer method replaced one half of RLHF. DPO kept the human preferences and dropped the reinforcement learning. RLVR kept the reinforcement learning and dropped the human: a program checks the answer instead. None of this happens in pre-training, which has no human feedback and no reward, only the next-token loss. People also use RLHF loosely to mean everything after SFT. On this page it means the three-part recipe.

What it costs

DeepSeek-V3 published the split: 2.664 million H800-hours of pre-training, and roughly 5,000 hours of post-training. That is about two tenths of one percent. MiniMax-M1's full reinforcement-learning run took three weeks on 512 H800s and cost about $535,000. Its pre-training bill was tens of times larger.

the takeawayNearly all of the money goes to pre-training, the subject of the previous page. Nearly all of the behavior you can name is decided here, in post-training. That is why a small team with no cluster can still change what a model is like to use.
02the codea readable version of what the real repos do+

Here is all of post-training, zoomed out far enough that you can see the stages. Each line is a script in allenai/open-instruct or huggingface/trl.

the whole page in five lines
base = load("Qwen3-8B-Base")                       # from the pre-training page

sft   = train_sft(base, messages_dataset)          # learn the chat format and the habit
                                                   #   loss on assistant tokens only
pref  = train_dpo(sft, preference_pairs)           # learn a taste, from (chosen, rejected)
                                                   #   or: rm = train_reward_model(pairs)
final = train_grpo(pref, prompts, verifier)        # learn to be right, by being checked
                                                   #   sample 8 answers, keep what worked

release(final)                                     # + safety data, merging, evals
03the scaffoldingwhat production adds, and where it breaks+

The reading list

Post-training has better open code than pre-training, because the runs are small enough for people to reproduce them.

Open this to see the full pipeline with no layers of abstraction in the way. scripts/chat_sft.py shows the loss mask. scripts/chat_rl.py is GRPO in about 300 lines, and its docstring lists the four simplifications it makes.
The standard implementation of each trainer. Its default settings are a good summary of current practice: GRPO with no KL, a group of 8, clip 0.2, learning rate 1e-6.
The only lab recipe that is fully open. Tulu 3 and OLMo 3 are here, with every launch command, dataset and run published. Start with docs/tulu3.md, the most useful file in the repo.
The framework most RL papers were run in. core_algos.py holds every loss variant in one file, so you can compare them side by side.
Read this to see RL as four calls: sample, score, advantage, importance-weighted loss. The infrastructure is hidden on purpose, so the algorithm is all you see.
The classic PPO reference. The actor, critic, reference and reward model each run as a separate Ray actor. Read it to see what GRPO removed.
Fully asynchronous RL. It adds an explicit loss term for the mismatch between the trainer and the inference engine, a problem most frameworks treat as a footnote.
The report that set off the current wave of reasoning models. It is unusually open about what failed: process reward models and tree search both did not work.
02 · step 1 · the chat format

Teach it where a turn ends.

A base model sees one flat stream of text. It has no idea that a conversation has speakers, that one of them is the model itself, or that there is a moment when it should stop and wait. Post-training's first job is to put that structure into the stream. The method is plain: a handful of reserved tokens and a fixed layout.

Here is one two-turn conversation as four different models receive it. Switch between them, then turn on the loss mask to see which parts the model is graded on.

the same two turns, as four models see them
<|im_start|>system\n
You are a helpful assistant.<|im_end|>\n
<|im_start|>user\n
What is 2+2?<|im_end|>\n
<|im_start|>assistant\n
4.<|im_end|>\n
<|im_start|>user\n
And times 3?<|im_end|>\n
<|im_start|>assistant\n
<think>\n
\n
</think>\n
\n
12.<|im_end|>\n
Used by Qwen, the DeepSeek-R1 distills, and most open models. <|im_start|> = 151644, <|im_end|> = 151645, and <|im_end|> is the eos the server stops on.
The base model has never seen any of these strings. Step 02 is where the model learns that these markers mean "a turn ended" and "your turn now". Apart from that they are ordinary tokens, reserved in the tokenizer back in pre-training.
01the ideawhat it does and why. read this first+

The template is a Jinja string that ships inside the tokenizer. apply_chat_template runs a list of messages through it and gives you the exact text above. Every later stage is defined in terms of that text: which tokens the loss covers, what the prompt is during reinforcement learning, and where a tool result gets inserted.

The most important token is the one that ends an assistant turn: <|im_end|>, <|eot_id|>, <end_of_turn>. It is the stop token. The inference server watches for it and stops generating there. A model that never learned to produce it keeps talking until it hits the length limit, every time. This failure is common, and it usually has one of two causes: the end-of-turn token was left out of the trained span, or it was left out of the server's stop list.

Thinking blocks

Reasoning models add one more pair of markers. Qwen3 renders <think>…</think> before the answer, and its template has a quirk: assistant turns before the last user message have their thinking removed, and a kept turn with no thinking gets an empty block. Turning thinking "off" means pre-filling that empty block, so the model starts after it and goes straight to the answer. DeepSeek-R1 goes further and ends its generation prompt with <think>, so the model always starts by reasoning.

a small detail that causes real bugsLlama 3.1's Hugging Face chat template writes today's date into the system turn. Render the same dataset twice on different days and you get different training data. Meta's own reference implementation does not add that preamble at all, so the two disagree by a few tokens in every system turn. Most of the time neither matters. But details like these are what make a training run impossible to reproduce.
02the codea readable version of what the real repos do+

Rendering is one call. Note the two different shapes: the full transcript is the SFT target, and the prompt with add_generation_prompt=True is what reinforcement learning samples from.

transformers · apply_chat_template
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
msgs = [{"role": "system", "content": "You are a helpful assistant."},
        {"role": "user",   "content": "What is 2+2?"},
        {"role": "assistant", "content": "4."}]

text   = tok.apply_chat_template(msgs, tokenize=False)              # the SFT target
prompt = tok.apply_chat_template(msgs[:2], tokenize=False,          # what RL samples from
                                 add_generation_prompt=True)
# prompt ends with:  <|im_start|>assistant\n
# with enable_thinking=False it ends with: <|im_start|>assistant\n<think>\n\n</think>\n\n

A tool call and its result are more text in the same stream, with their own markers. This is how Qwen3 lays them out.

rendereda tool call and its result, in the transcript
<|im_start|>assistant
<tool_call>
{"name": "get_weather", "arguments": {"city": "Paris"}}
</tool_call><|im_end|>
<|im_start|>user
<tool_response>
{"temp_c": 21, "sky": "clear"}
</tool_response><|im_end|>
<|im_start|>assistant
It is 21 C and clear in Paris.<|im_end|>
The tool result comes back as a user turn. The model never runs anything. The harness, the program wrapped around the model, runs the tool and pastes the result in as if the user had said it.
03the scaffoldingwhat production adds, and where it breaks+

Prefix stability, and the bug it causes

To find the assistant's span, masking code renders messages[:i] with a generation prompt, renders messages[:i+1], and trains on the difference. That only works if the rendered prefix is exactly the start of the rendered whole. Qwen3's thinking-stripping and DeepSeek's <think> injection both break that rule. That is why TRL ships its own modified *_training.jinja templates, whose only job is to keep prefixes stable.

OLMo 3 hit this bug in practice. Their tokenization masked the first <think> token as if it were part of the prompt, and the workaround was to tokenize Think-mode data with the Instruct template. They now publish separate -dev and -release tokenizer repos, because the template used in training and the template shipped to users had drifted apart.

The tokens, for reference

Qwen3 / ChatML
<|im_start|> 151644 · <|im_end|> 151645 (the eos) · <tool_call> 151657 · <think> 151667. No bos.
Llama 3.1
<|begin_of_text|> 128000 · <|start_header_id|> 128006 · <|eot_id|> 128009 · <|python_tag|> 128010. 256 slots reserved.
Gemma
<bos> · <start_of_turn> · <end_of_turn>. No system role; Gemma 3 folds system text into the first user turn.
Mistral
[INST] / [/INST] with </s> ending each assistant turn. No role names at all.
check this firstIf a fine-tuned model will not stop talking, look at three things in order: is the end-of-turn token inside the trained span, is it in the model's generation_config.eos_token_id, and is it in the server's stop list. It is almost always one of those three.
03 · step 2 · supervised fine-tuning

Show it ten thousand good answers.

Supervised fine-tuning is pre-training with two changes. The text is a rendered conversation instead of a web page, and the loss is computed only on the assistant's tokens. The rest of the work is in the data and in getting the mask right.

One record is a list of messages. This is the format used by almost every open SFT set.

sft dataallenai/tulu-3-sft-mixture · one of 939,344 rows
{
  "id": "ai2-adapt-dev/flan_v2_converted_1802",
  "messages": [
    {"role": "user", "content": "Write a haiku about a database that has gone down."},
    {"role": "assistant", "content": "Queries pile up, wait \u2014\nthe socket refuses all.\nSomeone pages ops."}
  ],
  "source": "ai2-adapt-dev/flan_v2_converted"
}
There are no labels, scores or rewards, only a conversation someone decided was good. The source field lets the mixture be re-weighted later, and it is what makes this dataset possible to audit.
01the ideawhat it does and why. read this first+

What the loss mask does

Render the conversation, tokenize it, then set the label of every token that is not the assistant's to -100, the value PyTorch's cross-entropy ignores. The model reads the prompt as context but is never graded on predicting it. Skip this step and you train the model to predict user turns too, so it starts writing them itself.

To see what the mask changes, you first need the number it changes. Every later step on this page, DPO and GRPO included, is arithmetic on this same number.

One guess per position. At every position, the model reads everything before it. It then gives a probability to every token in its vocabulary, about 150,000 of them, and those probabilities add up to 1. Training looks at only one of them: the probability of the token that actually came next in the data. Call it p.

WHAT THE MODEL OUTPUTS AT ONE POSITION<|im_start|>user2+2?<|im_end|><|im_start|>assistant?the seven tokens it has read so farnext?ITS GUESS: A PROBABILITY FOR EVERY TOKEN IT KNOWS"It"0.40"The"0.18"4"0.12"Sure"0.07"2"0.05"Let"0.04the other ~150,0000.14all of them together: 1.00the data says the next token was "It"so p = 0.40and this position costs−log(0.40) = 0.9163every other bar is ignoredTraining uses only one of these bars: the one for the token that was actually there. It pushes that bar up. Because all the barsadd up to 1, the others go down. The model makes a guess like this at every position, twelve of them in the example below.

From probability to penalty. Training turns p into a penalty by taking −log p. If the model gave the right token probability 1, the penalty is 0. At p = 0.5 the penalty is 0.69. At p = 0.1 it is 2.30. As p gets close to 0, the penalty grows without limit, so the most expensive mistake is to be confident and wrong. This penalty is the cross-entropy loss for one token. Drag the slider to see how it changes.

A PROBABILITY BECOMES A PENALTY: −LOG Ppenalty123000.250.500.751p, the probability it gave the right token↑ keeps going: no ceiling"2+2" the user’s question"It"template + answerppenalty1.000.0000sure, and right0.900.10540.500.6931a coin flip0.102.3026one in ten0.014.6052→ 0→ ∞sure, and wronganswer token: trainedprompt token: masked
The dots are the twelve tokens from the example below. The user’s question sits on the steep part, where a guess is expensive. The chat template and most of the answer sit on the flat part, where a good guess costs almost nothing.

The loss is an average. One training step makes a guess at every position, gives each guess a penalty, and averages the penalties into one number. Gradient descent then adjusts every weight in the direction that makes that number smaller. The mask decides which positions go into the average. Here is what the mask changes on a twelve-token example. The probabilities were chosen for the example. Every number after them was computed from them.

ONE TRAINING STEP · TWELVE TOKENS · FIVE OF THEM COUNTtokenppenalty−log p<|im_start|>MASK0.201.6094userMASK0.550.59782+2MASK0.102.3026?MASK0.600.5108<|im_end|>MASK0.800.2231<|im_start|>MASK0.900.1054assistantMASK0.950.0513Ittrain0.400.9163istrain0.850.16254train0.920.0834.train0.780.2485<|im_end|>train0.960.0408read as context, never gradedgraded: these five are averagedTHE LOSS IS AN AVERAGE PENALTY. AVERAGE OVER WHICH TOKENS?the 5 trained tokens0.2903what you reportall 12 tokens0.57101.97× higherthe 7 prompt tokens0.7715the part nobody wants to learnThe mask only changes which positions go into the average. The model reads all twelve either way. The prompt is the hard part topredict, so including it nearly doubles the loss, and most of the gradient goes into teaching the model to make up the user’swords. The end-of-turn token is inside the graded span. That is how the model learns to stop.

Why a log. Multiply the five graded probabilities and you get the probability that the model writes the whole answer, “It is 4.” plus its stop token. Each of those guesses already takes the earlier tokens into account: 0.40 × 0.85 × 0.92 × 0.78 × 0.96 = 0.2342. The log turns that product into a sum. −log 0.2342 = 1.4515, which is the five green bars added up. Divide by five and you get the loss, 0.2903. So lowering the loss means making the whole correct answer more likely.

reading itwhat a loss number means
loss      e^(-loss)
0.1000    0.9048     about 9 in 10
0.2903    0.7480     about 3 in 4        the five answer tokens above
0.5710    0.5650     a bit over 1 in 2   all twelve tokens above
0.6931    0.5000     1 in 2
0.7715    0.4623     a bit under 1 in 2  the seven prompt tokens above
2.3026    0.1000     1 in 10
To read a loss, undo the log. e to the minus loss is the probability the model gives the right token on a typical token (strictly, the geometric mean). This works on any loss curve. A pre-training loss of 2.0 means about 0.135 per token, roughly one in seven.

Later steps use log p instead of −log p. It is the same number with the opposite sign. It is never above 0, and a value closer to 0 means more likely. When the DPO step says an answer has a log-prob of −12.40, that is these per-token numbers added up over the whole answer.

Where the data comes from

In 2023 it was written by hand. In 2026 it is almost entirely synthetic. Prompts are generated from personas, answers are generated by a stronger model, and the results are filtered and decontaminated. OpenThoughts3 is 75,000 questions answered 16 times each by QwQ-32B, for 1.2 million rows. DeepSeek-R1's second supervised stage used 600,000 reasoning samples generated by its own RL-trained checkpoint, keeping only the correct ones (rejection sampling).

OLMo 3's preference data shows how strange this can get. Their best pairs used a strong model's answer as the chosen one and a tiny model's answer as the rejected one, instead of a good and a bad answer from the same model. Training on the strong answers alone made things worse. Pairing each with a weak answer worked. What the model learns from is the difference between the two answers, not either answer alone.

02the codea readable version of what the real repos do+

nanochat builds the mask while it renders, which makes the idea easy to see: each token is added with a flag that says whether it counts toward the loss.

nanochat · the mask, token by token
# nanochat/tokenizer.py  ::  render_conversation
add_tokens(bos, 0)
# user turn: context, never a target
add_tokens(user_start, 0); add_tokens(value_ids, 0); add_tokens(user_end, 0)
# assistant turn: this is what we grade
add_tokens(assistant_start, 0)
add_tokens(value_ids, 1)                                  # the text
add_tokens(python_start, 1); add_tokens(value_ids, 1); add_tokens(python_end, 1)   # a tool CALL is ours
add_tokens(output_start, 0); add_tokens(value_ids, 0); add_tokens(output_end, 0)   # its OUTPUT is not
add_tokens(assistant_end, 1)                              # learn to stop

# scripts/chat_sft.py
targets = batch[:, 1:]
targets[mask[:, 1:] == 0] = -1                            # -1 is nanochat's ignore_index
add_tokens(value_ids, 1)
The assistant’s own words. Graded.
python_start / python_end
A tool call the model wrote. Graded, because writing the call correctly is the skill being learned.
output_start / output_end
The tool’s output, pasted in by the harness. Not graded, because the model did not write it and must not learn to predict it.
add_tokens(assistant_end, 1)
The end-of-turn token, inside the trained span. This is the line that teaches the model to stop.

TRL does the same thing through the template, which is another reason it ships its own training templates. The part that should be graded is wrapped in {% generation %}, and apply_chat_template returns a mask for it.

trl · the same mask, declared in the template
# trl/chat_templates/qwen3_training.jinja
{{- '<|im_start|>' + message.role + '\n' }}
{%- generation %}
{{- content }}{{- '<|im_end|>\n' }}            # note: the end token is INSIDE the block
{%- endgeneration %}

# SFTConfig(assistant_only_loss=True) then does:
labels = pad(labels, padding_value=-100)
the one thing TRL warns about hereIf the end-of-turn token falls outside the generation block, "the model may not learn to stop". It is the same bug as above, reached a different way.
03the scaffoldingwhat production adds, and where it breaks+

Packing, and the cost of getting it wrong

Conversations range from 50 to 30,000 tokens, so they are packed into fixed-length rows the same way as in pre-training, with the same two choices: crop or best-fit, and mask across document boundaries or not. TRL's "bfd" (best-fit-decreasing) packing plus position_ids is the current default. nanochat pads with BOS instead of cropping, because at SFT scale it is better to waste a token than to cut an answer off mid-sentence.

What labs ran

Tulu 3 SFT
939,344 prompts from 18 sources · 2 epochs · LR 5e-6 (8B), 2e-6 (70B) · sequence 4,096 · batch 128
OLMo 3 Instruct SFT
2,152,112 samples
SmolLM3
1.8B tokens of SFT over 4 epochs, 22 datasets, after 35B tokens of mid-training
DeepSeek-R1
cold start: "thousands" of long-CoT samples. Second SFT: 600k reasoning + 200k non-reasoning, 2 epochs
OpenThoughts3
1.2M rows = 75k questions × 16 answers · 850k math, 250k code, 100k science
Typical hyperparameters
LR 5e-6 to 2e-5 for full fine-tuning, about 10× that for LoRA · 1 to 3 epochs · sequence 8k to 32k

Where it goes wrong

  • Training on the prompt. This is the worked example above: the loss roughly doubles and the model learns to write user turns. Always check the mask by printing one rendered example with the ignored tokens grayed out. nanochat has visualize_tokenization for this.
  • The end-of-turn token outside the span. The model never learns to stop.
  • Template drift. Training with one template and serving with another makes the model worse across the board, and nothing in the stack will warn you. OLMo 3 documents shipping this bug.
  • Contamination. If the model that generated your synthetic SFT data saw the benchmarks, it can leak them into your data. Every serious dataset now removes anything that overlaps the eval suites, by n-gram matching.
  • Catastrophic forgetting. Two epochs on a narrow dataset will make the model worse at everything else. The defense is a broad mixture, which is why Tulu 3 publishes a source field on every row.
04 · step 3 · the reward signal

Decide what counts as better.

Imitation gives you a model that sounds like its training data. To go further you need something that can say one answer is better than another, including answers the model wrote itself. There are three ways to get that, and modern recipes use all three for different tasks.

a verifiercode decidesprompt: "solve 12 x 13"answer: "\boxed{156}"extract \boxed{...}compare to 156reward = 1.0cheap, exact, unhackable-ish.only works where truth is checkable.a reward modela network scoresprompt + answer ↓LLM + scalar head ↓r = 2.41trained on pairsworks anywhere, but it is a model:it can be gamed, and it drifts.a judge or rubricanother LLM scoresprompt + answer+ a checklist: states the caveat? no flattery? answers the ask?reward = 2 of 3readable and steerable.slow, and the judge has taste of its own.THREE WAYS TO ANSWER "WAS THAT ANSWER GOOD?"
01the ideawhat it does and why. read this first+

A verifier is a piece of code. It pulls out the boxed answer and compares it to the correct one, or runs the unit tests, or checks that the output is valid JSON with the required keys. It is free and instant, and no clever wording can talk it into a higher score. It only exists where correctness can be checked mechanically, which is why math and code drove the 2025 wave of reasoning models.

A reward model is a language model whose final layer is replaced by one that outputs a single score. You train it on preference pairs, and then it can score any answer. The training records look much the same everywhere.

preference datanvidia/HelpSteer3 · one of 40,476 rows
{
  "domain": "code",
  "context": [{"role": "user", "content": "Write a JavaScript program that ..."}],
  "response1": "Here's a JavaScript program that meets your requirements. ...",
  "response2": "```javascript\nconst readline = require('readline'); ...",
  "overall_preference": -2,
  "individual_preference": [
    {"score": -1, "reasoning": "@Response 1 is slightly better than @Response 2. ..."},
    {"score": -3, "reasoning": "@Response 1 is much better because ..."},
    {"score": -2, "reasoning": "@Response 1 is better because ..."}
  ]
}
Three annotators each gave a signed strength from −3 to +3 and a reason. Negative means the first response won. Most datasets are simpler than this, with only a chosen and a rejected answer.

Bradley-Terry, and why only the gap matters

The reward model is trained only to score the better answer higher. The loss is -log σ(r_chosen − r_rejected). There is no target for the scores themselves. Only the margin between them appears, so the scale can drift anywhere. Add 10 to every score and the loss does not change.

worked examplefour preference pairs, scored
pair   r(chosen)  r(rejected)   margin   sigma(margin)   -log sigma
A        +2.10      +0.40      +1.70       0.8455       0.1678
B        +1.25      +0.90      +0.35       0.5866       0.5334
C        +0.80      -0.60      +1.40       0.8022       0.2204
D        +1.40      +0.55      +0.85       0.7006       0.3559
                                                total   1.2775

now score pair D backwards (margin -0.85):
D        +0.55      +1.40      -0.85       0.2994       1.2059
                                                total   2.1275

the increase is exactly 0.8500, the size of the margin it got wrong.
Flipping a pair from margin +m to −m always costs exactly m. A pair like B, at 0.5334, is barely better than the 0.6931 you would get by guessing. The pairs worth looking at are the ones the model is unsure about, not the ones it gets confidently wrong.

The third source is another language model with a rubric. Kimi K2 has the model critique its own rollouts against a checklist that includes strict rules like "responses must not begin with compliments directed at the user" and "no sentence that explains why the response is good". Those rules exist because the model had learned to write both of those things to please the judge.

02the codea readable version of what the real repos do+

The reward model is five lines on top of a sequence classifier.

trl · the Bradley-Terry loss
# trl/trainer/reward_trainer.py
rewards_chosen, rewards_rejected = torch.chunk(outputs.logits.squeeze(-1), chunks=2)
loss = -nn.functional.logsigmoid(rewards_chosen - rewards_rejected).mean()
if self.args.center_rewards_coefficient is not None:              # recommended 1e-2
    loss += self.args.center_rewards_coefficient * torch.mean((rewards_chosen + rewards_rejected) ** 2)

That second term is there because the loss only constrains differences. Without it the scores can drift to any value, and a score from one batch cannot be compared with a score from another.

A verifier is ordinary code. In open-instruct the interface is a function, plus a column in the dataset that says which function to call.

open-instruct · a verifier is a function and a column
# open_instruct/ground_truth_utils.py
class VerifierFunction:
    def __call__(self, tokenized_prediction, prediction, label, query, rollout_state) -> VerificationResult:
        ...                                                   # returns score, cost, reasoning

# the registry: GSM8KVerifier, MathVerifier, StrictMathVerifier, IFEvalVerifier,
#               CodeVerifier (posts to a sandbox), LMJudgeVerifier, RubricVerifier
reward_fn = reward_fn_mapping[row["dataset"].lower()]         # dispatch on the dataset column
rl dataverl · examples/data_preprocess/gsm8k.py
{
  "data_source": "openai/gsm8k",
  "prompt": [{"role": "user", "content": "Natalia sold clips to 48 friends ... Let's think step by step and output the final answer after \"####\"."}],
  "ability": "math",
  "reward_model": {"style": "rule", "ground_truth": "72"},
  "extra_info": {"split": "train", "index": 0}
}
There is no answer to imitate. Each row holds a question, the correct answer, and the name of the rule that checks it. The model has to work out the solution itself, which is what makes this reinforcement learning rather than fine-tuning.
03the scaffoldingwhat production adds, and where it breaks+

Reward hacking, with real examples

Give a good enough optimizer any reward you can measure, and it will find a way to score well without doing what you wanted. This has happened in practice. Anthropic published three hacks found in the production reinforcement-learning environments used for Claude 3.7 Sonnet. Each one makes a test suite pass without fixing anything.

three hacks a model found
import sys
sys.exit(0)                    # exit code 0 means "tests passed". The asserts never run.

class AlwaysEqual:             # returned from the function under test
    def __eq__(self, other): return True          # every assertEqual now passes

# conftest.py, which pytest imports automatically
def pytest_runtest_makereport(item, call):
    report.outcome = "passed"  # monkey-patch the reporter itself

The uncomfortable part of that paper is how far the behavior spread: models that learned these tricks became more misaligned on unrelated evaluations. The reward taught them a general strategy, cheating the grader, instead of the task.

Other hacks, and what they cost

  • Length. GRPO's per-sequence normalization ends up rewarding long wrong answers. Dr. GRPO removes the normalization. DAPO adds an explicit penalty past a soft length limit. Kimi gives each task a token budget.
  • Language mixing. DeepSeek-R1-Zero's chains of thought drifted between languages because nothing scored readability. R1 added a language-consistency reward, and reports that it slightly hurts the benchmarks. They shipped it anyway.
  • Judge flattery. Models learn to open with a compliment and to explain why their answer is good, because judges reward it. That is why Kimi's rules ban both.
  • Format gaming. A verifier that accepts any \boxed{} will get answers with a hundred boxes. Strict extraction and a penalty for never producing the stop token are now standard.
  • Process rewards. DeepSeek tried scoring each reasoning step and gave up. They wrote that "the neural reward model may suffer from reward hacking", and they found no good way to define what a step is. They also tried tree search, and it did not work either.
the design rule that followsPrefer a reward that a program can check. Where you cannot have one, prefer a rubric a person can read over a single number nobody can inspect. Every recipe on this page uses learned rewards only for the parts where nothing else is possible.
05 · step 4 · DPO

Skip the reward model entirely.

Direct Preference Optimization starts from one observation. If you plan to train a reward model and then optimize against it, you can do the algebra once and train the policy on the preference pairs directly. The reward model disappears, and so does all the sampling. What is left is a classification loss over pairs you already have. It needs two forward passes, and no rollouts or verifier.

ONE PREFERENCE PAIR · FOUR FORWARD PASSES · ONE NUMBERΠ (BEING TRAINED)Π_REF (FROZEN)chosen"...and that is why."sum of log-probs-12.40the same sum, frozen-13.20differenceΔ_w = +0.80rejected"...i think? maybe."sum of log-probs-18.10the same sum, frozen-17.50differenceΔ_l = −0.60margin = Δ_w − Δ_l = (+0.80) − (−0.60) = +1.40loss = −log σ( β · margin ) = 0.6256 at β = 0.1The margin is not "does the model like the chosen answer". It is "has the model moved further toward the chosen answer thantoward the rejected one, since it started". The frozen column is the only reason that sentence has a meaning, and it is the onlyreason the model cannot satisfy the loss by collapsing onto short safe text.
the whole methodDPO in seven lines
for each pair (prompt, chosen, rejected):

    log pi(chosen)      log pi_ref(chosen)          ->  delta_chosen
    log pi(rejected)    log pi_ref(rejected)        ->  delta_rejected

    margin = delta_chosen - delta_rejected
    loss   = -log sigmoid( beta * margin )

  pi     = the model being trained
  pi_ref = a frozen copy of where it started
This is the same Bradley-Terry loss as the reward model's. The reward is replaced by how much more likely this answer became, relative to where we started. That quantity is called the implicit reward, and it is why no separate reward model is needed.
01the ideawhat it does and why. read this first+

The margin asks a specific question: has the model moved further toward the chosen answer than toward the rejected one, compared with the frozen reference? It does not ask whether the model prefers the chosen answer outright. The reference stops the model from lowering the loss by collapsing onto short, safe text, because every gain is measured from where it started.

β is the only setting. It controls how sharply the loss separates a pair the model already has right from a pair it has backwards. Three examples show the difference, with every number computed.

THE SAME LOSS CURVE AT TWO VALUES OF BETAloss1.01.50.69-3-2-1012marginβ = 0.1β = 0.5C · backwardsB · near tieA · already rightat β = 0.1the curve is nearlyflat. A and C getalmost the same push.at β = 0.5the curve bends. Cgets 8.4× the gradientthat A gets.The dashed line at 0.69 is log 2, the loss of a model with no opinion either way; the beta = 0.1 curve barely leaves it. Beta isnot a learning rate. It sets how sharply the loss distinguishes a pair the model already has right from one it has backwards.Flat means you train equally hard on everything, noise included. Steep means one mislabeled pair can drag the model a long way.
worked examplethe same three pairs at two values of beta
                       log-ratio   log-ratio
example                 chosen      rejected     margin
A  already right        +0.80       -0.60        +1.40
B  near tie             -0.10       +0.10        -0.20
C  backwards            -1.20       +1.40        -2.60

beta = 0.1                              beta = 0.5
example      loss    |gradient|         loss    |gradient|
A            0.6256    0.0465           0.4032    0.1659
B            0.7032    0.0505           0.7444    0.2625
C            0.8316    0.0565           1.5410    0.3929

at beta 0.1 the three losses sit within 0.21 of each other, and of
log 2 = 0.6931: the badly-wrong example gets almost the same gradient
as the already-correct one.
at beta 0.5 the backwards example gets 8.4x the gradient of the
already-right one.
β is not a learning rate. It sets how much the loss distinguishes between a pair the model already has right and one it has backwards. Too low and you train on noise. Too high and one mislabeled pair drags the model a long way.
what the labs pickedTulu 3 used length-normalized DPO with β = 5 and a learning rate of 5e-7, for one epoch. OLMo 3 Think used a learning rate of 8e-8 over 150,000 pairs at 16k context. These learning rates are tiny because DPO moves a model much faster than SFT does, and the usual failure is overshooting.
02the codea readable version of what the real repos do+

The loss is eight lines. This version is worth memorizing, because every variant is a one-line change to it.

trl / openrlhf · the DPO loss
# the four sequence log-probs, each a sum over the completion's tokens
policy_chosen, policy_rejected = model_logps(batch)
with torch.no_grad():
    ref_chosen, ref_rejected = ref_model_logps(batch)         # frozen

chosen_logratios   = policy_chosen   - ref_chosen
rejected_logratios = policy_rejected - ref_rejected
margin = chosen_logratios - rejected_logratios
loss = -F.logsigmoid(beta * margin).mean()                    # loss_type="sigmoid"

# what the model implicitly thinks each answer is worth, for logging:
chosen_rewards = (beta * chosen_logratios).detach()
policy_chosen
The sum of per-token log-probs over the chosen completion only. The prompt is masked out, the same way as in SFT.
ref_chosen
The same number from the frozen starting model. In practice this is precomputed once for the whole dataset, or computed by switching a LoRA adapter off, so no second copy of the model sits in memory.
margin
Positive means the policy has moved toward the chosen answer more than toward the rejected one.
beta * margin
β scales the margin before the sigmoid. That is all β does.

Each variant differs by one line. ipo replaces the logsigmoid with a squared error against a target margin. hinge uses a hinge loss. sigmoid_norm divides the margin by the completion length. That is SimPO's idea, and Tulu 3 found it was the only variant that beat plain SFT. KTO drops pairs and works from single answers labeled good or bad. ORPO combines the SFT loss and the preference loss into one stage, with no reference model at all.

03the scaffoldingwhat production adds, and where it breaks+

The reference model, in practice

There are three ways to provide it, listed by how much memory they cost. Keep a frozen second copy (simple, but it doubles the weights in memory). Precompute the reference log-probs for the whole dataset once, then drop the model (precompute_ref_log_probs=True, the usual choice). Or, with LoRA, turn the adapter off for one forward pass, because the base weights are the reference.

When to use DPO, and when not to

Use DPO
Style, tone, formatting, safety, refusals, anything where you can write down two answers and say which is better but cannot write a checker.
Use RL instead
Anything a program can grade: math, code, instruction constraints, tool-call validity. That is the next step.
Why DPO stayed
It is cheap and stable, needs no sampling, and gets the model most of the way to usable before the expensive stage begins. Almost every 2025 recipe has it in the middle.
Its real limit
The pairs are fixed. As the model trains, its own answers drift away from the ones in the pairs, and the data cannot keep up. Online DPO, XPO and Nash-MD sample fresh pairs to close that gap. At that point you are most of the way to RL anyway.

One more finding worth remembering: Llama 3 ran six rounds of reward modeling, rejection-sampling SFT and DPO, and averaged checkpoints trained with different data and hyperparameters at each stage. At that scale, post-training is less a pipeline than a loop you repeat until the evals stop improving.

06 · step 5 · reinforcement learning

Let it try, and keep what worked.

Every step so far learned from answers somebody else wrote. Reinforcement learning is the first step where the model learns from its own answers. The loop has four moves: take a prompt, sample several answers, score them, and make the good ones more likely. With a verifier doing the scoring, nothing else is needed.

The group is what made this cheap. To know whether an answer is good, you need something to compare it to, called a baseline. PPO trains a second network to predict that baseline. GRPO samples eight answers to the same question and uses their average score. Click the answers below to flip a verdict and see what happens.

one prompt, one group, one update
promptone math questionG = 8 SAMPLED ANSWERS · CLICK ONE TO FLIP ITS VERDICTo1o2o3o4o5o6o7o8✓ 1 = the verifier accepted the final answer · ✗ 0 = it did notADVANTAGE A_I = (R_I − MEAN) / STD
The advantage is how much better an answer did than the other answers in its group. An answer that beats the group average gets pushed up, and one that falls below it gets pushed down. None of this needs a value network, which is why GRPO replaced PPO.
01the ideawhat it does and why. read this first+

The advantage, computed

The baseline needs no network. Take the group's rewards, subtract the mean, and divide by the standard deviation.

worked examplethe advantages in the diagram above
group of 8, rewards [1, 0, 0, 1, 0, 1, 0, 0]      3 correct

mean                          0.3750
std  (population)             0.4841

advantage of a correct answer   (1 - 0.3750) / 0.4841  =  +1.2910
advantage of a wrong answer     (0 - 0.3750) / 0.4841  =  -0.7746

Dr. GRPO, which skips the division by std:
                                 +0.6250   and   -0.3750
Dividing by the standard deviation rescales every group to the same spread, so an easy prompt and a hard one contribute equally. Dr. GRPO argues this is a bias. A group where 7 of 8 answers were right should not produce as large an update as one where the model learned something.

What the advantage is for

The advantage says how much better an answer did than its group. The loss uses it in a simple way. It takes the SFT loss from step 02, −log p averaged over an answer’s tokens, and multiplies it by the advantage. The only other change is where the answers come from: the model wrote them, not a person.

GRPO’S LOSS IS SFT’S LOSS, TIMES ONE NUMBER PER ANSWERSFTloss = average of −log pover an answer a person wrotealways: more likelyGRPOloss = average of −log p × Aover an answer the model wroteup or down, by ATHREE ANSWERS FROM THE GROUP ABOVEo1 ✓A = +1.2910the same push SFT gives, 1.29 times as hard→ more likelyo2 ✗A = −0.7746SFT in reverse, 0.77 times as hard→ less likelyall agreeA = 0.0000multiplied by 0→ no changeEvery token in an answer gets the same A. GRPO cannot tell which token made the answer right, so a lucky guess and a carefulderivation are pushed the same way. The PPO section later on this page covers the older way around that: a second network thatscores every token.

So this kind of reinforcement learning is supervised fine-tuning on the model’s own answers, with each answer weighted by how much it beat its group. A positive advantage makes the answer more likely, as SFT would. A negative one makes it less likely. An advantage of 0 does nothing at all, and that case turns out to be common.

The group that teaches nothing

Now set every answer to correct. The mean is 1, the standard deviation is 0, and every advantage is 0. The same happens when every answer is wrong. A group where every answer gets the same score produces no gradient at all, and you still paid full price to generate it.

This happens a lot, and it becomes the main waste as training succeeds. The easy prompts get solved every time and the impossible ones never do, so a growing share of every batch teaches nothing. DAPO's fix is dynamic sampling: generate more groups than you need, throw away the ones where every answer got the same score, and keep sampling until the batch is full of groups with mixed results. OLMo 3 calls its version active sampling and reports that it is about four times faster.

a detail that matters in codeWhen every reward is equal, the standard deviation is exactly zero, so the division is 0/0. Implementations add a tiny epsilon to the denominator (verl uses 1e-6) and get a clean 0. Without it you get NaN, and one NaN advantage turns the loss for the whole batch into NaN, which can wreck the run.

Why this works at all

It works because the ability is usually already in the model, but not reliably. A model that is right 30 percent of the time on a problem gets it right at least once in eight tries 94 percent of the time.

worked examplewhy sampling a group is not a waste
p = 0.30 per attempt

k        pass@k = 1 - (1-p)^k
1          0.3000
4          0.7599
8          0.9424
16         0.9967
pass@8 is more than three times pass@1. The model can already solve the problem some of the time. The verifier’s job is to point at which of the eight rollouts got it right, so the model can be trained to produce that kind of answer more often. This is also why RL on verifiable tasks makes a model more reliable at what it can already do, rather than teaching it something new.
02the codea readable version of what the real repos do+

nanochat's version is the shortest one that hides nothing. It has no KL penalty and no clipping, it is on-policy, and its docstring lists the four ways it simplifies the method in the paper.

nanochat · GRPO in six lines
# scripts/chat_rl.py
mu = rewards.mean()
advantages = rewards - mu                                   # mean-only baseline

logp = -model(inputs, targets, loss_reduction='none').view_as(inputs)   # (B, T)
pg_obj = (logp * advantages.unsqueeze(-1)).sum()
pg_obj = pg_obj / (num_valid * num_passes * examples_per_rank)
loss = -pg_obj
# num_samples=16, temperature 1.0, max_new_tokens=256, reward = GSM8K exact match

Look at the line that computes logp. model(inputs, targets, loss_reduction='none') is the SFT loss, token by token, run on answers the model sampled. The next line multiplies it by the advantage. In nanochat, that is the whole algorithm.

The production version adds the importance ratio and the clip, because the answers were sampled by a slightly older policy than the one now being updated.

verl · the clipped policy loss
# verl/trainer/ppo/core_algos.py
ratio = torch.exp(torch.clamp(log_prob - old_log_prob, -20, 20))
pg_losses1 = -advantages * ratio
pg_losses2 = -advantages * torch.clamp(ratio, 1 - cliprange_low, 1 + cliprange_high)
clip_pg_losses1 = torch.maximum(pg_losses1, pg_losses2)        # = -min(A*r, A*clip)
pg_losses3 = -advantages * clip_ratio_c                        # dual-clip, default 3.0
clip_pg_losses2 = torch.min(pg_losses3, clip_pg_losses1)
pg_losses = torch.where(advantages < 0, clip_pg_losses2, clip_pg_losses1)
pg_loss = agg_loss(pg_losses, response_mask, loss_agg_mode)    # "token-mean" by default

And the advantage, grouped by prompt:

verl · group-normalized advantage
# compute_grpo_outcome_advantage, in essence
for uid, group in group_by_prompt(scores):
    m, s = group.mean(), group.std()
    scores[group.idx] = (group - m) / (s + 1e-6)        # or just (group - m) for Dr. GRPO
advantages = scores.unsqueeze(-1).expand_as(response_mask)   # every token of an answer
                                                            # carries the answer's advantage
03the scaffoldingwhat production adds, and where it breaks+

What the clip is for

The answers in a group were sampled by the policy as it was at the start of the step. After one update the policy is different, so those answers describe it a little less well. The importance ratio corrects for that. The clip limits how far the correction is allowed to push.

WHAT THE CLIP DOES TO THE UPDATE0.60.81.01.21.4ratio π_new / π_oldinside: the update goes through0.801.20flat · no gradientflat · no gradienta good answer  = +1a bad answer  = −1Inside the window the objective is a straight line, so the update is the usual one. Outside it the line is flat and the gradientis zero. Each answer is flat on exactly one side: once a good answer has been made enough more likely, or a bad one enough lesslikely, this batch stops pushing. The rollouts you scored no longer describe the policy you now have.

The defaults, and what they say about the field

TRL GRPOConfig
LR 1e-6 · 8 generations · completion 512 · temperature 1.0 · beta 0.0 (no KL) · epsilon 0.2 · loss_type "dapo" · importance sampling at token level
OLMo 3 Think 7B
GRPO, 8 samples × 64 prompts per step · LR 1e-6 constant · beta 0 · clip-higher 0.272 · response 32,768 · about 102k prompts · 16 vLLM engines and 16 learner GPUs
DAPO
group 16 · clip 0.2 / 0.28 · generate 1,536 prompts to train on 512 · response 20,480 plus a 4,096 buffer · no KL · AIME24 50 on Qwen2.5-32B-Base
Tulu 3 RLVR
PPO, not GRPO · LR 3e-7 · KL beta 0.05 · reward 10 for correct, 0 otherwise, −10 for never stopping · response 2,048
Qwen3
3,995 query-verifier pairs in the reasoning stage · 170 steps · AIME24 70.1 to 85.1 on the 235B

What the KL penalty measures

KL measures how far the trained model has moved from where it started. Take an answer the model just wrote. For each token, compare log p under the model now with log p under a frozen copy of the original. Then average the differences.

worked examplethree tokens of one answer
token     log p now   log p at the start   difference
"Wait"      -0.10          -0.30             +0.20
","         -0.50          -0.60             +0.10
" let"      -1.20          -2.00             +0.80
                                  average    +0.3667    the KL estimate
The trained model now finds its own words more likely than the original did, by 0.37 per token on average. A value of 0 would mean it has not moved at all. A KL penalty subtracts beta times this number from the reward, which pulls the model back toward where it started.

The most surprising line there is beta 0.0. The KL penalty to the reference model was the centerpiece of the original RLHF recipe. It is now off by default in TRL, and off in DAPO, OLMo 3 and MiniMax. With a verifiable reward there is nothing to hack, so there is less need to hold the model close to where it started, and holding it close costs capability.

if you keep the KL, keep the right estimatorThe k1 estimator is logπ − logπ_ref. It can be negative on a single token, and it is noisy: on five sample tokens it averaged +0.07 while the true divergence was small and strictly positive. The k3 estimator, exp(−Δ) + Δ − 1, is never negative on any token. When the policy stays near the reference, it has roughly a thousandth of the variance for the same expected value. Both are unbiased, but only k3 is usable in practice.
07 · step 5b · the fixes

Four papers, four one-line changes.

Plain GRPO trains for a while, then stalls. Entropy collapses, answers get longer without getting better, and the gains level off. The fixes that followed are known by their acronyms, but in code each one is tiny: a single line that answers a specific diagnosis. The acronyms hide the ideas, so here is what each fix does.

the whole sectionfour fixes, as diffs
DAPO      clip_ratio_low=0.2, clip_ratio_high=0.28     raise only the ceiling
          drop groups where every reward is equal      stop paying for nothing
          loss_agg_mode="token-mean"                   long answers stop being discounted
          penalty past a soft length limit             stop rewarding rambling

Dr.GRPO   norm_adv_by_std_in_grpo=False                stop rescaling every group
          no 1/|o| length normalisation                stop favouring long wrong answers

GSPO      one ratio per answer, not per token          stop trusting per-token noise

CISPO     clip the weight, never the gradient          stop deleting rare tokens
Each of these is a config flag in verl or TRL today. The papers are worth reading for how they diagnosed each problem. The patches themselves are small.
01the ideawhat it does and why. read this first+

Clip-higher: the ceiling costs rare tokens the most

A single clip range, 1 ± ε, sounds symmetric, but its effect on probabilities is not. A token already at 0.95 can barely be raised anyway. A token at 0.10 is the kind that starts an unusual line of reasoning, and the ceiling is what keeps it from growing enough to get tried.

WHY DAPO RAISES ONLY THE UPPER CLIPa likely tokenp_old = 0.90ratio can reach 1.11 before 0.9×1.2 hits 1.0room to growan unlikely tokenp_old = 0.02ratio 1.2 means p = 0.024 · still tinyclipped away firstA single ceiling of 1+ε caps how much probability mass a token can gain in one step. For a token that is already likely thatceiling is generous. For a rare token, the one that starts an unusual line of reasoning, it is a straitjacket: the policy cannever explore it. DAPO lifts the ceiling to 1.28 while leaving the floor at 0.8, so the model can still promote rare tokens butcannot abandon them any faster than before.
worked examplethe same five tokens at two ceilings
epsilon 0.2, a good answer (A = +1):

token          p_old     ratio   clipped   objective
t1 common      0.8187   1.1052   1.1052   +1.1052
t2 common      0.6065   0.7408   0.8000   +0.7408
t3 RARE        0.1003   1.4918   1.2000   +1.2000   <- clipped
t4 v.common    0.9512   1.0305   1.0305   +1.0305
t5 rare        0.0498   0.6065   0.8000   +0.6065

what the ceiling means in absolute probability:

token          p_old    max p_new at 1.2   at 1.28 (DAPO)
t1 common      0.8187      0.9825           ceiling not binding
t3 RARE        0.1003      0.1203           0.1283
t5 rare        0.0498      0.0597           0.0637

raising the ceiling to 1.28 changes exactly one token here: t3.
t1, t2, t4 and t5 are byte-identical to the run at 0.2.
A token at 0.95 is not capped at all, because its ceiling of 1.14 is above 1.0. A token at 0.10 is capped at 0.12 every step. That difference is the argument for clip-higher. Raising the ceiling gives the rare token 0.008 more room and changes nothing else.

Dynamic sampling and token-level loss

Dynamic sampling fixes the degenerate-group problem from the last step. Generate more prompts than you need, discard the groups where every answer got the same score, and keep going until the batch is full of groups with mixed results. DAPO generates 1,536 prompts to train on 512.

Token-level loss is subtler. If you average the loss per sequence, every answer counts the same. A 10,000-token wrong answer is then penalized as gently as a 50-token one, which works out to almost nothing per token. If you average over all the tokens in the batch instead, long answers count in proportion to their length. In DAPO's ablation, removing it cost six points on AIME24, more than removing dynamic sampling.

Dr. GRPO: two normalizations that turned out to be biases

Dividing by the group's standard deviation gives every group an equally large update, including the ones where seven of eight answers were right. Dividing by the answer's length makes long answers cheap. Between them they push toward longer answers, and toward treating easy prompts as seriously as hard ones. Dr. GRPO removes both. The same paper showed that Qwen base models already produce reasoning-style text, so some of the famous "aha moment" came from a template mismatch rather than a behavior that emerged during training.

02the codea readable version of what the real repos do+

GSPO: one ratio for the whole answer

The reward was earned by the answer as a whole, so Qwen argues the importance weight should belong to the answer as a whole too. Instead of one ratio per token, GSPO takes the geometric mean over the answer: the exponential of the average log-ratio.

ONE ANSWER, FIVE TOKENS · ONE OF THEM MIS-ESTIMATED"The"r = 0.9802"answer"r = 1.0202"is"r = 0.9802"seventy"r = 2.4596"-two"r = 0.9900ratiostoken-level (GRPO)each token carries its own ratio"seventy" is 2.4596, far outside 1 ± εso it is clipped to 1.2 and stops learningsequence-level (GSPO)one ratio for the answerexp( sum of log-ratios / 5 ) = exp(0.87/5) = 1.1901inside the window, so the whole update survivesThe reward was earned by the whole answer, so Qwen3 argues the importance weight should be too. This matters most for a mixtureof experts, where a token can route to different experts in the rollout engine and in the trainer, which makes its per-tokenratio close to meaningless.
worked exampleone noisy token, two ways of weighting it
token        logp_old   logp_new    delta    token ratio
tok1          -0.10      -0.12     -0.02      0.9802
tok2          -0.20      -0.18     +0.02      1.0202
tok3          -0.05      -0.07     -0.02      0.9802
tok4 NOISY    -2.50      -1.60     +0.90      2.4596   <- clipped at 1.2
tok5          -0.15      -0.16     -0.01      0.9900

sum of deltas                         0.8700
sequence ratio  exp(0.87 / 5)         1.1901   <- inside the clip range
without length normalisation exp(0.87) 2.3869   <- would be clipped just as hard
Four tokens sit within two percent of 1.0, and the fifth alone reaches 2.46. Averaging in log space brings the answer-level ratio to 1.19, just inside the window, so the update goes through instead of being cut off by one token’s noise. The length normalization is what makes this work: the plain product would be clipped too.

This matters most for a mixture of experts. The same token can be sent to different experts in the rollout engine and in the trainer, which makes its per-token ratio close to meaningless. Qwen reports that GSPO removed the need for "routing replay", a workaround that existed only to keep token ratios comparable, and that GSPO contributed to the Qwen3 models.

the one-line version
# verl · compute_policy_loss_gspo
negative_approx_kl_seq = torch.sum(negative_approx_kl * response_mask, -1) / seq_lengths
log_seq_importance_ratio = log_prob - log_prob.detach() + negative_approx_kl_seq.detach().unsqueeze(-1)
seq_importance_ratio = torch.exp(torch.clamp(log_seq_importance_ratio, max=10.0))
pg_losses = torch.maximum(-advantages * seq_importance_ratio,
                          -advantages * torch.clamp(seq_importance_ratio, 1 - clip_low, 1 + clip_high))
# TRL: importance_sampling_level="sequence"

CISPO: clip the weight, keep the gradient

MiniMax noticed which tokens the clip was removing from the update: the ones where the reasoning changes direction, like "Wait", "However" and "Let me recheck". Those tokens are rare, so their ratios move a lot, so they get clipped, so the model never learns to use them. CISPO clips the importance weight but leaves every token's gradient in place. It matched DAPO in half the steps.

verl · CISPO, the whole difference
# instead of dropping the token from the loss, down-weight it and keep it
pg_losses = -clipped_ratio.detach() * advantages * log_prob
#            ^ the weight is clipped and detached; log_prob still carries the gradient
03the scaffoldingwhat production adds, and where it breaks+

What to turn on

As of 2026, labs largely agree on how to configure a verifiable-reward run, and most of the settings come from these fixes.

KL penalty
Off. beta = 0 in TRL, DAPO, OLMo 3 and MiniMax. With a verifier there is little to hack, and holding the model close to the reference costs capability.
Clip
0.2 low, 0.27 to 0.28 high. open-instruct uses 0.272, DAPO 0.28.
Loss aggregation
token-mean over the whole batch.
Advantage
Subtract the group mean. Dividing by std is now optional; TRL exposes it as scale_rewards.
Group size
8 is the common default (TRL, OLMo 3), 16 for DAPO and nanochat.
Learning rate
1e-6, constant. An order of magnitude below SFT.
Degenerate groups
Filtered out by dynamic or active sampling. This is the biggest throughput win available.
the short versionNone of these fixes is deep. Each came from a team running the same algorithm at scale, spotting one specific failure, and changing one line. The value is in the diagnoses, and they all come back to one observation: when you average over the wrong set of things, you build in a preference nobody chose. Averaging per answer, for example, makes long answers cheap.
08 · step 6 · PPO, and what it lost

PPO, the method that started it.

Before 2024, RL on language models meant PPO, and this is the recipe that made ChatGPT. It is worth understanding for two reasons. GRPO is defined by what it removed from PPO. And PPO is still the right choice where rewards are dense, meaning they arrive at many points along an answer instead of once at the end.

prompt xactor π_θthe policy · trainedresponse ysampled tokensreward model r_ψfrozen · one scalar at the endreference π_reffrozen · per-token KL penaltycritic V_φtrained · value per tokenadvantage via GAE → clipped policy lossr − β·KL, credit spread back over tokensFour transformer-sized networks resident at once, two of them training. GRPO deletes the critic and gets its baseline from thegroup instead, which is why almost everyone switched.
01the ideawhat it does and why. read this first+

Four networks run at once. The actor is the model being trained. The reward model scores the finished answer. The reference model is a frozen copy of the starting point. The per-token KL between actor and reference is subtracted from the reward, which stops the actor from drifting into gibberish that happens to score well. The critic, a fourth transformer, is trained to predict, from any point in an answer, how good the rest of the answer will turn out.

The critic exists because of a hard problem. The reward arrives once, at the end, but the credit has to be shared among hundreds of tokens. GAE is how PPO shares it. At each step, compute the surprise: how much better or worse things look than the critic expected one step earlier. This is called the temporal-difference error. Then each token's advantage is its own error plus a discounted share of all the errors after it.

worked exampleGAE over five steps
gamma = 1.0, lambda = 0.95, reward only at the end

t     r     V(s_t)   V(s_t+1)   delta = r + V' - V      A_t
0    0.00    0.50      0.55          +0.0500         +0.436389
1    0.00    0.55      0.60          +0.0500         +0.406725
2    0.00    0.60      0.70          +0.1000         +0.375500
3    0.00    0.70      0.80          +0.1000         +0.290000
4    1.00    0.80      0.00          +0.2000         +0.200000

read the last two rows:
   A_3 = 0.10 + 0.95 * 0.20 = 0.29
   A_4 = 0.20, with nothing after it to add
Each advantage is its own surprise plus a shrinking share of every surprise that followed. The last step has nothing after it, so its advantage is only its own error. GAE works backward from there: each step's advantage is its own error plus λ times the next step's advantage (γ is 1 here).
02the codea readable version of what the real repos do+
openrlhf · reward shaping, GAE, and the two losses
# openrlhf/models/utils.py :: compute_reward
kl_reward = -kl_coef * kl                                     # per token, every token
eos_indices = action_mask.size(1) - 1 - action_mask.long().fliplr().argmax(dim=1, keepdim=True)
last_reward = torch.zeros_like(kl).scatter_(dim=1, index=eos_indices, src=r.unsqueeze(1))
reward = last_reward + kl_reward                              # one scalar at the end, KL everywhere

# get_advantages_and_returns  (GAE)
delta = rewards[:, t] + gamma * nextvalues - values[:, t]
lastgaelam = delta + gamma * lambd * lastgaelam

# models/loss.py
ratio = (log_probs - old_log_probs).clamp(-20, 20).exp()
loss = -torch.min(ratio * advantages, ratio.clamp(1 - eps_low, 1 + eps_high) * advantages)
values_clipped = old_values + (values - old_values).clamp(-clip_eps, clip_eps)
vloss = 0.5 * torch.max((values_clipped - returns) ** 2, (values - returns) ** 2)

verl's PPO step runs in this order, and reading it is the fastest way to see how many models are involved: generate_sequences → compute_log_prob → compute_ref_log_prob → compute_values → compute reward → compute_advantage → update_critic → update_actor.

03the scaffoldingwhat production adds, and where it breaks+

Why almost everyone switched

  • The critic is another full model. It is usually the same size as the policy, trained at the same time, and the hardest of the four to tune. DeepSeek-R1 describes GRPO as "foregoing the critic model that is typically the same size as the policy".
  • With a 0/1 reward there is little for it to learn. A value function is worth its cost when rewards are dense and shaped. When the only signal is "the final answer was right", a group average is a perfectly good baseline and needs no training.
  • Memory. Four transformer-sized networks sit in memory at once, and two of them carry optimizer state.
  • It did not go away. Tulu 3's RLVR was PPO as late as Nov 2024, and OpenRLHF, verl and NeMo-RL all still ship it. Where the reward is dense or the horizon is long, the critic is still the right tool.
the short historyRLHF with PPO made models pleasant to talk to. RLVR with GRPO made them correct on problems a program can check. The second became possible once people noticed that for math and code you do not need a reward model at all, because a program can check the answer. And once you drop the reward model, the critic has nothing left to do either.
09 · step 7 · the machinery

Generate in one engine, train in another.

Reinforcement learning asks the model to do two jobs with opposite needs. Generating needs a fast inference engine with a paged cache and continuous batching. Training needs optimizer state, gradients and sharded weights. So you run both systems, and the main engineering problem is keeping them in sync.

TRAINER · FSDP / MEGATRONgpu 0gpu 1gpu 2gpu 3holds optimizer state, computes log-probs,runs backward, updates the weightsmemory: params + grads + Adam = 16 bytes/paramROLLOUT ENGINE · VLLM / SGLANGgpu 4gpu 5gpu 6gpu 7paged KV cache, continuous batching,samples G answers per prompt, runs toolsmemory: params + KV cache · bf16new weights1T params in ~20 s (Kimi K2)rollouts + rewardstokens · log-probs · scoresTIME → SYNCHRONOUS (TOP) VS ONE-STEP-OFF-POLICY (BOTTOM)gentraingentraingentraingen (engine)gen (engine)gen (engine)gen (engine)traintraintrainSynchronous: half the cluster idles while the other half works. One-step-off: the engine generates batch k+1 with the weightsfrom step k while the trainer updates. That is worth 23 to 40 percent, and it is what makes the importance ratios in step 05necessary rather than optional.
01the ideawhat it does and why. read this first+

The split is the same everywhere: vLLM or SGLang generates, and FSDP or Megatron trains. verl calls this arrangement HybridFlow, with one controller process driving both. The two can share GPUs (colocated: every GPU is used, but the two phases take turns) or sit on separate GPUs (disaggregated: both stay busy, but you need more hardware).

After every step the new weights have to reach the engine. This sounds easy and turns out to be hard. Moonshot's checkpoint-engine moves a full one-trillion-parameter Kimi K2 update across thousands of GPUs in about twenty seconds, using a three-stage pipeline: copy from host to device, broadcast into CUDA IPC buffers, then have each engine reload its own shard. Qwen3-235B on eight H800s takes 6.2 seconds. When the trainer and engine share a machine, slime passes CUDA IPC handles instead. A handle is a reference to memory already on the GPU, so only the handle crosses the process boundary and the weights themselves do not move at all.

Going asynchronous

Synchronous RL wastes half the cluster. While the engine generates, the trainer sits idle, and then they swap. One-step-off-policy generation overlaps the two: the engine produces batch k+1 using the weights from step k while the trainer updates on batch k. verl measures this as 23 percent faster with FSDP2 and 40 percent faster with Megatron, with no loss in accuracy. Fully asynchronous designs go further. They add a separate rollout process (a rollouter), a message queue and a limit on how stale a rollout may be, and get 2.3 to 2.8 times the throughput.

But once generation and training are off by even one step, the answers you train on were produced by weights that no longer exist. The importance ratio from step 05 corrects for this. Asynchrony is what makes those ratios necessary rather than a formality.

02the codea readable version of what the real repos do+

Same weights, different probabilities

This one surprises people. Even with identical weights, the rollout engine and the trainer report different probabilities for the same token, because they use different kernels, different precision and different batch composition. Yao et al. found tokens where vLLM said probability 1 and FSDP said 0. If you do not correct for this, reward growth stalls or collapses.

worked examplethe same five tokens, as two engines see them
token           logp rollout   logp trainer    diff     ratio exp(diff)
t1                 -0.500        -0.520      -0.020       0.9802
t2                 -1.200        -1.150      +0.050       1.0513
t3                 -0.050        -0.080      -0.030       0.9704
t4 MISMATCH        -3.500        -2.600      +0.900       2.4596
t5                 -0.900        -1.100      -0.200       0.8187

sequence weight, raw product                              2.0138
sequence weight, each token truncated at 2.0              1.6375
no correction at all                                      1.0000
Three tokens agree to within 0.05 nats, and t5 is off by 0.2. The mismatch, t4, is a low-probability token that is off by 0.9, and it alone pushes the raw sequence weight to 2.01. The trainer would update as if the text came from a policy twice as confident as the one that wrote it. Truncating each token at 2.0 keeps the correction but stops one unstable token from dominating the batch.

Here are the fixes, from simplest to most thorough. Truncated importance sampling multiplies the loss by min(π_train/π_rollout, C) with C around 2. verl, TRL and open-instruct all do this. Masking drops tokens whose ratio falls outside a window, instead of clipping them. MiniMax traced their mismatch to the output head and ran it in FP32, which raised the correlation between the two engines from 0.987 to 0.997 and let training make progress again. Thinking Machines went to the root cause: the kernels are not batch-invariant. With batch-invariant implementations the sampler and trainer become bitwise identical, the KL between them is exactly zero, and the run trains fine with no correction.

one framework’s answer to the mismatch
# prime-rl · the most explicit version: mask the disagreeing tokens and penalise disagreement
abs_probs_diff = torch.abs(torch.exp(trainer_logprobs) - torch.exp(inference_logprobs))
keep_mask = loss_mask & ~(abs_probs_diff > loss_config.eps)          # eps = 0.1
pg_loss = keep_mask * loss_config.adv_tau * advantages * importance_ratio
kl_loss = loss_mask * log_importance_ratio ** 2
per_token_loss = -pg_loss + loss_config.kl_tau * kl_loss
03the scaffoldingwhat production adds, and where it breaks+

When the rollout uses tools

A multi-turn rollout interleaves text the model wrote with text a tool returned. Only the first kind should be trained on. This is the same loss-mask idea from step 02, now applied during generation. verl's agent loop returns a response_mask that is 1 for model tokens and 0 for tool output.

The documentation stresses one rule above the others: token in, token out. Never turn the conversation back into a string and re-tokenize it between turns. Tokenizing pieces separately does not always give the same tokens as tokenizing the joined text, so a re-tokenized transcript can differ from the one the model produced. That brings back the mismatch above, this time through your own code.

Budgets, and what they cost

Response length
2,048 for Tulu 3 in 2024 · 20,480 for DAPO · 32,768 for OLMo 3 Think · 40k then 80k staged for MiniMax-M1
Packing
open-instruct packs several rollouts into one training row of 35,840 tokens; the loss is normalized over tokens in the whole batch.
Engines
OLMo 3 32B: 28 nodes, 6 vLLM engines at tensor-parallel 8, plus the learners.
Cost
MiniMax-M1: three weeks on 512 H800s, about $535,000, for the RL stage alone.
Throughput wins
one-step-off 23 to 40 percent · fully async 2.35 to 2.67× · active sampling about 4× · all of them compounding.
what this step is forNothing here changes what the model learns. It changes how many experiments you can afford to run per day. At this scale that sets how fast research moves, which is why a third of every RL paper is systems work.
10 · step 8 · ship it

The last step: refusals, merging and evals.

The model is capable now. What remains is deciding what it should decline to do, recovering the things the last stage broke, and measuring all of it well enough to know whether to ship.

Basecompletes textpost-trainInstructanswers directlyThinking<think> … </think> then answershybridone model, a thinking switchSame weights family, different data at steps 02 and 05. Qwen3 shipped one hybrid model with a /think switch, then split it intoseparate Instruct and Thinking releases when the two modes turned out to want different training.
01the ideawhat it does and why. read this first+

Safety is mostly more data

Safety uses the same SFT and preference machinery as everything else, applied to refusal data. There is no separate safety algorithm. Tulu 3's mix is about 12 percent safety: 10,983 examples of when not to comply, 50,000 adversarial jailbreaks, and 50,000 prompt and response harm labels. Safety pairs also go into the preference mix. In RL, a judge penalizes both harmful compliance and over-refusal, because a model that refuses everything would score perfectly on the first and be useless.

Constitutional AI is the same loop, with the model generating its own labels. The model critiques and revises its answers against written principles, and you fine-tune on the revisions. Then a model picks the better of two answers according to a principle, and you train a preference model on those choices. The 2025 descendant of that idea is the rubric reward from step 03.

Merging, which sounds like cheating

Averaging the weights of several fine-tunes of the same base model works, and everyone does it. Llama 3 averaged checkpoints trained with different data and hyperparameters at every stage. SmolLM3 merged 0.9 of its preference-tuned "soup" (itself an average of several models) with 0.1 of a mid-training checkpoint, to recover the long-context ability that post-training had worn down. OLMo 3 32B averaged two midtraining runs and its last three long-context checkpoints.

There is a simple reason this works. The models being averaged are small changes to the same starting point, so their weights sit in the same basin, a low region of the loss. The average of two points in a basin is still in the basin, and often lower than either one.

02the codea readable version of what the real repos do+

What gets measured

Knowledge
MMLU-Pro, GPQA-Diamond, SimpleQA
Math
AIME 2024 and 2025, MATH-500, HMMT
Code
LiveCodeBench, CodeForces or CodeElo, SWE-bench Verified
Chat
AlpacaEval 2 length-controlled, Arena-Hard
Instruction following
IFEval
Agents
tau-bench, BFCL
Long context
RULER
Safety
WildGuard and HarmBench-style suites

The number that moves most visibly during reasoning RL is AIME. DeepSeek-R1-Zero went from 15.6 to 71.0 pass@1, and to 86.7 with majority voting over 16 samples. Qwen3's 235B went from 70.1 to 85.1 in 170 RL steps. Those are the numbers that made 2025 look like a step change.

a caveat for every one of those numbersAIME is only 30 problems a year. Contamination is widespread. Majority-vote numbers are not single-sample numbers. And the gap between a good benchmark score and a model that is useful to you is the main unsolved problem in evaluation.
03the scaffoldingwhat production adds, and where it breaks+

What gets released

Labs release a Base model, then an Instruct model, then a Thinking variant, and more and more often all three come from one family. Qwen3 first shipped a single hybrid model with a /think switch and later split it into separate Instruct and Thinking releases, because the two modes needed different training. OLMo 3 publishes Base, Instruct, Think and RL-Zero, plus every intermediate SFT and DPO checkpoint. It is the only place where you can see every stage on this page, one checkpoint at a time.

One last operational warning

open-instruct's OLMo 3 documentation has a warning worth ending on. The chat template used in training and the one shipped in the release had drifted apart: the system prompt was different, and <think> was handled differently in the generation prompt. The team now maintains separate -dev and -release tokenizer repositories. Every stage on this page works on a rendered string, so the template is the contract that all of them depend on, and it is the part most likely to be wrong without anyone noticing.

11 · closing

Post-training is cheap, and it decides how a model behaves.

Post-training uses about two tenths of a percent of the compute, and it decides everything you can name about a model: whether it answers or continues, whether it stops, whether it refuses, whether it checks its work. That imbalance has held since 2022, and it is the most useful thing to know about this field.

What did change is where the training signal comes from. RLHF learned from what people preferred. RLVR learns from what a program can check, and that turned out to be both cheaper and harder to fool. Every fix in step 07 came from someone discovering that an average was building in a preference nobody chose.

If you read one thing after this, read scripts/chat_rl.py in nanochat. It is GRPO in about three hundred lines, with a docstring that lists the four simplifications it makes. It will make everything above concrete faster than any paper.

Every worked example on this page was computed, not typed in by hand: the arithmetic was run and the output pasted in. The GRPO advantages use the population standard deviation, and the degenerate group reaches exactly zero because implementations add an epsilon to the denominator. Code excerpts were read from clones on September 17, 2026; where a report says something I could not verify in code, the page says so.