Published Build3 min read
The chat-template one-liner is load-bearing, and your loss mask depends on it
A dev.to teardown of apply_chat_template argues training and inference must render byte-identical prefixes, or the tokens you mask and the tokens you serve quietly diverge.
Written for builders.See today for builders

What happened
- The line tok.apply_chat_template(msgs, add_generation_prompt=True) is the form written in official examples and widely copied without examination.
- Once you build your own SFT data and compute your own loss mask, every piece of that line is load-bearing.
- Training renders the full conversation with add_generation_prompt=False; inference renders msgs[:-1] with add_generation_prompt=True.
- The prefixes produced by the training render and the inference render must be identical down to the byte; one assert catches a mismatch.
- The model only ever sees the rendered string; the messages list is for Python.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A write-up on dev.to pulls apart the single line most teams copy out of the docs, `tok.apply_chat_template(msgs, add_generation_prompt=True)`, and argues that every argument in it carries weight once you start building your own supervised fine-tuning data and computing your own loss mask [1][2]. The consequence is unglamorous and expensive: training and inference must produce identical prefixes down to the byte, or the span you masked during training is not the span you serve [3][4].
Start with the part that breaks most mental models. The model never sees your `messages` list; that list exists only in your Python process, and what reaches the model is a rendered string [5][6]. Roles are tokens in the vocabulary, not structured metadata read by some component inside the network [7][11]. In the author's GLM-4 example, the rendered prefix is `[gMASK]<sop><|user|>` followed by the content and then `<|assistant|>`, which tokenizes to ids beginning 151331, 151333, 151336, 198 [8][9]. The model knows it is its turn because it sees id 151337, learned as any other pattern is learned [10]. Those ids are family-specific and should not be hardcoded across models [12].
The asymmetry is where the mask goes wrong. Training renders the full conversation with `add_generation_prompt=False`; inference renders `msgs[:-1]` with `add_generation_prompt=True` [3]. Two different code paths, two different call sites, one string that has to match. The author's recommendation is a single assert comparing the two prefixes [4]. That is cheap insurance against a failure mode that produces no error, only worse outputs.
The template itself is a Jinja2 program, stored inline in `tokenizer_config.json` or as a standalone `chat_template.jinja`, and when both are present the file wins [13]. Jinja was built to render HTML for Flask, and HuggingFace uses it to flatten messages into the exact string the model was trained on [14]. The whitespace-control tags `{%-` and `-%}` are not a style choice: templates are indented for human readability, and without stripping, every newline and indent in the source lands verbatim in the prompt, at which point the token sequence no longer matches training [15][16]. Print with `repr()` rather than trusting your eyes [17]. Rendering happens inside `jinja2.sandbox.ImmutableSandboxedEnvironment`, so arbitrary attribute access and side effects are blocked; the template gets `messages`, `tools` when passed, `add_generation_prompt`, the tokenizer's `bos_token` and `eos_token`, plus `raise_exception()` and `strftime_now()` [18][19].
Two smaller traps. Do not let the tokenizer add special tokens after templating, or you end up with two BOS [20]. And `added_tokens_decoder` in `tokenizer_config.json` decides whether `<|user|>` is one atomic token or gets shredded into BPE pieces; shredded, the role marker stops being a clean signal and the model has to infer the boundary from the characters [21][22]. It can learn that, the author notes, but there is no reason to spend the capacity [23]. Note also that `tokenizer_config.json` does not hold the vocabulary; that lives in `tokenizer.json`, or `vocab.json` plus `merges.txt`, or a SentencePiece model file [24]. And `padding_side` should be right for training and left for batched generation [25].
What to watch: whether your stack has a test that renders the training prefix and the serving prefix and compares them byte for byte, and whether that test runs when you swap model families, since the special-token ids and the template both change [4][12][13].
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The line tok.apply_chat_template(msgs, add_generation_prompt=True) is the form written in official examples and widely copied without examination.
- [2]
Once you build your own SFT data and compute your own loss mask, every piece of that line is load-bearing.
ReportedView cited source - [3]
Training renders the full conversation with add_generation_prompt=False; inference renders msgs[:-1] with add_generation_prompt=True.
ReportedView cited source - [4]
The prefixes produced by the training render and the inference render must be identical down to the byte; one assert catches a mismatch.
ReportedView cited source - [5]
The model only ever sees the rendered string; the messages list is for Python.
ReportedView cited source - [6]
The messages list, e.g. [{"role": "user", "content": "What is the Young's modulus of graphene?"}], lives in the Python process and nowhere else.
ReportedView cited source
Sources & coverage · 1 publisher
The reporting this story was synthesized from, earliest first. Every link goes to the original.
- dev.toJessie JiaAug 13What Actually Gets Tokenized in SFT
Cited in this coverage: dev.to post 'What Actually Gets Tokenized in SFT'

