60 minutes · the capstone of Pillar 1 — the last gate between "clean data" and "correct tokens"
Pillar 1 — Data
The data pipeline is not complete when the JSONL is clean. It is complete when the tokens are correct.
The optimizer faithfully minimizes loss on the WRONG token stream. The loss curve gives no signal for these bugs — by design, they live below its resolution.
| # | Bug | Symptom |
|---|---|---|
| 1 | Cross-family template misuse Llama-3 template on Qwen, or vice versa | Role boundaries fuzzy; assistant continues user turn; format drifts |
| 2 | EOS mishandling missing / wrong end-of-sequence token | Run-on generation to max length; loss explodes / NaN (Qwen SFT thread) |
| 3 | Packing without attention mask examples attend across boundaries | Trains fine, quality subtly degraded vs baseline — the quietest |
Llama-3 convention
<|begin_of_text|>
<|start_header_id|>system<|end_header_id|>
...
<|eot_id|>
Qwen (ChatML) convention
<|im_start|>system
...
<|im_end|>
<|im_start|>. Persona & format understanding degrades. Diagnosis: decode an example and read it.
The EOS token tells the model when to stop. Every assistant turn during SFT must end with the family EOS.
| Break | What happens |
|---|---|
| Missing | Stripped/dropped → model never learns to stop |
| Wrong | Generic </s> on a model whose EOS is <|im_end|> |
| Wrong place | Appended once per packed seq, not per turn |
apply_chat_template() appends the correct EOS; verify tokenizer.eos_token_id == config.eos_token_id. For ChatML models, <|im_end|> is the EOS.
Packing multiple examples into one sequence is correct & standard — if and only if you do both:
3a — Mask cross-boundary attention
Example B must not attend to example A. Use document / variable-length attention (FlashAttention cu_seqlens / TRL position_ids).
3b — Mask loss on non-assistant tokens
System/user/role-scaffold are context, not targets. Set labels=-100 via return_assistant_tokens_mask=True.
apply_chat_template(). Prevents ~80% of bugs 1 & 2.
The inspection loop (2 minutes, before every run)
ids = apply_chat_template(conv, tokenize=True, return_assistant_tokens_mask=True)print(tokenizer.decode(ids)) — read itThe number of teams that skip this step and ship a mis-tokenized model is the reason this module exists.
tokenizer.chat_template regardless of checkpoint.
The four canonical "train your own" cases
Symptom in every case: tokens-per-character is abnormally high. Measure before deciding. Training a new tokenizer commits you to continued pretraining, not just SFT.
Middle path (2025): efficient tokenizer adaptation (arXiv:2512.03989) — extend the merge table additively, respecting SentencePiece rules, preserving base embeddings.
When you need a handful of domain tokens (a citation marker, a tool-result tag) — not a new tokenizer:
tokenizer.add_tokens([...])model.resize_token_embeddings(len(tokenizer)) — input emb and lm_headSymptom → bug → fix
| Symptom | Bug | Fix |
|---|---|---|
| Won't stop generating; runs to max len | 2 (EOS) | apply_chat_template (append family EOS) |
| Loss explodes / NaN (Qwen SFT) | 2 (EOS/template) | verify eos_token_id == config.eos_token_id |
| Role boundaries fuzzy; asst continues user | 1 (cross-family) | use the family's native template |
| Trains fine, subtly degraded vs baseline | 3 (packing) | document attn + mask non-asst loss |
| New domain tokens behave erratically | vocab | warm up new embeddings |
| Tool calls malformed / truncated | tool tpl | override chat_template explicitly |
CPU-only. Three deliberately-broken scripts — one per top bug.
Three broken scripts
Your task
For each: decode the tokens, diagnose the bug, write the fix with apply_chat_template, confirm by re-inspecting.
apply_chat_template() is mandatory, and distinguish Llama-3 vs Qwen role-token conventions.Next: FT08 — LoRA & QLoRA (Pillar 2 — PEFT)
The steering wheel is built. The tokens are verified. Now we build the adapter — the cheap, swappable steer.