After editing the Jinja chat template, your fine-tuned Llama 3 prints raw special token text. What broke?
Click any words you think contain an error. Click again to unmark.
Two bugs: template strings like '<|start_header_id|>' can tokenize as text without the special-token map, AND fine-tune data used a different template than deployed. Decode + diff against training.
Imagine you teach a child a code language for marking sections in their homework: 'BEGIN-MATH' means a math problem starts. Now you change the rule to 'BEGINMATH' (one word) without telling the child. The child keeps writing 'BEGIN-MATH' from old habit, because that is what they learned. The same happens with chat templates and fine-tuning. The model was trained on the original template. You modified the template later for inference. Some of the role-marker strings tokenize differently now, or they were stored as text instead of as the single special-id they should have been. The model occasionally outputs the raw text it learned, because that is what its training data showed. The fix is to keep the training-time and inference-time formats identical, byte by byte.
Concept explanation~2 min read
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
Concept explanation~2 min read
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
This bug is one of the most subtle in the fine-tune and deploy pipeline. The model trained correctly. The deploy succeeded. Inference returns plausible text. Then, occasionally, a response includes a raw <|start_header_id|> or similar special-token string in the output. The team blames sampling, blames temperature, blames the prompt, eventually arrives at the template, and only then realizes that template modifications interact with the special-token map and the training data in non-obvious ways.
The correct diagnosis names two coupled bugs. First, special-token handling: the string <|start_header_id|> becomes a single special-token id only when the tokenizer's special-token map is active during encoding. Without it, the string fragments into multiple text tokens, which the model can learn to emit as content. Second, training/inference template drift: modifying chat_template after fine-tuning means the model learned from one format and gets prompted in another. Both bugs compound; the model has learned to predict the literal special-token text in positions where the modified template did not match its training.
The rest of this explanation walks each bug in detail, names the decode and diff diagnostic that surfaces them, explains why both have to be fixed together, and closes on the production discipline of treating templates as part of the trained model artifact.
Bug 1: special-token handling and the string versus id distinction
A modern tokenizer like Llama 3's has two kinds of tokens: ordinary BPE tokens from the merge table, and special tokens that are registered separately and assigned dedicated ids. Llama 3 special tokens include <|begin_of_text|> (id 128000), <|start_header_id|> (128006), <|end_header_id|> (128007), and <|eot_id|> (128009), among others.
The critical mechanism: the tokenizer's encode method takes a flag that controls whether special-token strings in the input map to their dedicated ids or get tokenized as ordinary text. With add_special_tokens=True (or specific APIs like apply_chat_template that respect the special-token map), the string <|start_header_id|> in the input becomes the single id 128006. With add_special_tokens=False, the string tokenizes as multiple ordinary BPE tokens (something like <, |, start, _header_id, |, >).
This matters at training data preparation time. If the fine-tuning data preparation pipeline:
- Calls the Jinja template to render messages into a string.
- Writes the string to disk (parquet, jsonl).
- Loads the string and tokenizes with
add_special_tokens=False.
Then every <|start_header_id|> in the training data is stored as multiple ordinary text tokens. The model learns to emit those text tokens at the positions they appeared. At inference, the same text tokens occasionally come out as content, which is the symptom.
The fix at this layer: use apply_chat_template(..., tokenize=True) directly so the rendering and the special-token aware tokenization happen in one step. Or, if the pipeline must render then tokenize separately, ensure the tokenize step uses add_special_tokens=True and the tokenizer has the special tokens registered.
Situations where this technique stops working.
2–4 min · Everything important, quickly.
Real products, models, and research that use this idea.
- HuggingFace tokenizers expose added_tokens_encoder / added_tokens_decoder which control whether a registered string maps to a single id; apply_chat_template uses this map automatically.
- Llama 3's tokenizer registers <|begin_of_text|>, <|start_header_id|>, <|end_header_id|>, <|eot_id|> as special tokens with ids 128000, 128006, 128007, 128009 respectively.
What an interviewer would ask next. Try answering before peeking at the approach.
QHow would you write a CI test that catches this class of bug before deploying a fine-tune?
On training-data prep, assert that every rendered example contains the expected special-token ids (e.g., 128006 for <|start_header_id|> on Llama 3) at the expected positions. On deployment, render the inference template for a fixture messages list, tokenize, and assert the resulting id sequence matches a recorded golden from training. Any divergence is a deploy blocker. This catches both bugs at once.
Red flags & common mistakes
The phrases that signal junior thinking. Click to expand.
Red flags & common mistakes
The phrases that signal junior thinking. Click to expand.
Modifying the chat template after fine-tuning, so the training-time and inference-time formats diverge; or rendering templates to text without the special-token map active, so the role-marker strings tokenize as ordinary text.
60 second bullets to scan on the way to the call.
How a string like <|start_header_id|> becomes a single special-token id vs multiple text tokens.
Why add_special_tokens flag and the special-token map control the conversion.
Primary sources. Browse if you want the original framing.
Same topic, related formats. Practice these next.