← Back to Tutorials

Companion to the book The Chat Templates Handbook · this part maps to Book ch. 8

TutorialFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

How to Write a Chat Template for a Fine-Tuned Model and Test It

Write a chat template from the exact text your model was fine-tuned on, then prove it with string, token, mask, label and stop-token checks against a real TRL SFTTrainer batch.

#tutorial#intermediate#chat-templates#apply-chat-template#transformers#trl#fine-tuning#sft

What you'll build: a chat template for your fine-tuned model, checked token for token

This intermediate tutorial shows how to write the chat template for a fine-tuned model, and how to test it before you serve it. When you fine-tune a model on your own chat format, you also have to write the chat template it is served with. That template must produce the same tokens the trainer taught. Run against a saved model folder, the check you build in this tutorial prints one line for each of its six checks:

text
string   okmarkers  oktokens   okmask     oklabels   okstops    okpasses

By the end of this intermediate tutorial you will have templatestudio/authoring.py. It holds a chat template you transcribe from your training text, and it proves that template with six checks against a real TRL SFTTrainer batch on CPU, with no training run. The tutorial is for engineers who fine-tune a chat model with their own turn markers and then serve it with Transformers, vLLM or another engine that reads the template from the tokenizer.

Verified against Python 3.12.2, transformers==5.22.1, tokenizers==0.23.3, huggingface-hub==2.4.0, jinja2==3.1.6, torch==2.15.0+cpu, trl==1.17.1, datasets==5.2.0 and accelerate==1.15.0 on 2026-11-11.

Here is the failure this prevents. The story is a composite; no single public post-mortem matches it exactly. A team fine-tunes a model on support conversations, and its eval scores come out below the base model's. The serving template wrote an extra newline after each role header, so the model was graded on a format it had never seen. This is the silent format penalty from Part 1, caused this time by the template's own author. A template that nobody owns drifts this way, which is the argument of Chat Templates: The Last Unowned Layer in Your LLM Stack. The team fixes the template. The next day the model answers and then keeps writing: a blank line, an invented user turn, an answer to that. The template now matched the training text, but the engine did not stop on the token the model had learned to end with. That second half really happened at the Llama 3 release. The instruct models' generation_config.json did not list <|eot_id|> as a stop token, "so the model keeps writing" (discussion #4), until Meta fixed the file a day later. Step 3 catches the first failure and step 10 catches the second.

This is Part 8 of Chat Templates in Practice and continues the project from Part 7, How to Pass Images to apply_chat_template with a Processor. It takes about 30 minutes. It downloads a 2-layer test model of about 20 MB, measured in the Hugging Face cache, and the TRL, datasets and accelerate packages. It needs no GPU, no API key, no account and no Hugging Face token.

Prerequisites: install TRL 1.17.1 to test a chat template

You need the project from Parts 1 to 7: the chat-templates-part1/ folder with its virtual environment, the templatestudio/ package with messages.py, render.py and jinja_env.py, and families.py. This part adds one module, templatestudio/authoring.py, three helper files, support_data.py, broken_templates.py and trainer_batch.py, and seventeen scripts. The only earlier file it changes is families.py, which gets one constant at the end. jinja_env.py is Part 3's module. Parts 4 to 7 did not import it, so if you skipped Part 3, copy templatestudio/jinja_env.py from Part 3's complete code now. If you do not have the project at all, create the folder and the environment with Part 1's Prerequisites, add an empty templatestudio/__init__.py, copy messages.py and families.py from Part 7's complete code, and copy render.py from Part 6's complete code.

Go to the project folder, activate the environment, and turn off the Transformers advisory warnings. Run the cd from the folder that contains chat-templates-part1/:

bash
cd chat-templates-part1source .venv/bin/activateexport TRANSFORMERS_NO_ADVISORY_WARNINGS=1

On Windows, activate with .venv\Scripts\activate in PowerShell or source .venv/Scripts/activate in Git Bash, and set the variable in PowerShell with $env:TRANSFORMERS_NO_ADVISORY_WARNINGS = "1". Every script in this part runs from the project root.

The pins from Parts 1 to 7 are unchanged. This part adds three packages. TRL is the Hugging Face library whose SFTTrainer (SFT: supervised fine-tuning) turns chat conversations into training batches. Its release 1.17.1 (2026-10-30) declares transformers>=4.56.2 with no upper bound, so it runs on the series' 5.22.1. It needs datasets and accelerate, which are pinned here too. The install line repeats the Transformers, tokenizers and Hub pins from Part 1, so pip cannot move them while it resolves the new packages. In PowerShell, continue the line with a backtick instead of a backslash. PyTorch is already in the environment, because Part 7 made it required. If you skipped Part 7, install it first from the CPU wheel index with pip install "torch==2.15.0" --index-url https://download.pytorch.org/whl/cpu.

bash
pip install "trl==1.17.1" "datasets==5.2.0" "accelerate==1.15.0" \    "transformers==5.22.1" "tokenizers==0.23.3" "huggingface-hub==2.4.0"

The series stays on transformers 5.22.1. Part 7's Prerequisites explain why not to serve templates from untrusted repositories on that version with Python 3.12: 5.22.2 hardens the Jinja sandbox against a CPython bug that Python 3.12 does not fix. The template in this part is one you write yourself.

A block whose first line ends in (add at the end) goes after the last line of that file, separated from it by two blank lines. A block whose first line ends in (replace NAME) replaces that function in place. In both kinds of block the first line only says where the code goes: do not paste it. Add the test model that this part loads to the end of families.py:

python
# families.py (add at the end)# Part 8: a 2-layer test model from the TRL team that uses Qwen3's tokenizer.TINY_MODEL = "trl-internal-testing/tiny-Qwen3ForCausalLM"

Create check_trainer_versions.py, which prints the six versions this part depends on:

python
# check_trainer_versions.pyimport accelerateimport datasetsimport huggingface_hubimport tokenizersimport transformersimport trlprint(    transformers.__version__,    tokenizers.__version__,    huggingface_hub.__version__,    trl.__version__,    datasets.__version__,    accelerate.__version__,)

Create fetch_support_models.py. It downloads the Qwen3 tokenizer, which you already have from Part 1, and the test model. For each one it prints the number of tokens in the tokenizer and the number of rows in the model's embedding matrix. Only the test model's tokenizer and config download here; step 5 downloads its weights:

python
# fetch_support_models.pyfrom transformers import AutoConfigfrom families import FAMILIES, TINY_MODELfrom templatestudio.render import load_tokenizerfor model_id in (FAMILIES["qwen3"], TINY_MODEL):    tokenizer = load_tokenizer(model_id)    rows = AutoConfig.from_pretrained(model_id).vocab_size    print(f"{model_id:43} tokens {len(tokenizer)}  embedding rows {rows}")

Check the install and the downloads before you go on:

bash
python check_trainer_versions.pypython fetch_support_models.py

Expected output:

text
5.22.1 0.23.3 2.4.0 1.17.1 5.2.0 1.15.0Qwen/Qwen3-0.6B                             tokens 151669  embedding rows 151936trl-internal-testing/tiny-Qwen3ForCausalLM  tokens 151669  embedding rows 151936

The first run can print a warning starting Warning: You are sending unauthenticated requests to the HF Hub. and, on Windows, one starting UserWarning: `huggingface_hub` cache-system uses symlinks by default. Both are harmless. Note the second line of the output: Qwen3 ships 267 more embedding rows than it has tokens. Step 5 shows why that matters.

How to test a chat template for a fine-tuned model: the training-format contract

In Parts 1 to 7 you used templates other people wrote. A fine-tune makes you the author. You do not design this template to look right. You derive it from one fixed artifact: the token sequence each training example became when the model saw it. Rendering a conversation with your template must reproduce that sequence.

You do not produce those tokens yourself; the trainer does. TRL's SFTTrainer renders each conversation with the tokenizer's chat template and records two lists per example. input_ids holds the tokens the model reads. labels holds the same positions, with every token the loss must ignore set to -100. When you ask for assistant-only loss, the template's {% generation %} block decides which positions keep their label. At serving time the engine renders the same conversation with the same template, adds the generation prompt, and lets the model write until it emits a token in the stop list.

The Chat Templates Handbook, the book this series accompanies, names the agreement between those two sides the training-format contract. In its token form it says three things. First, take any assistant turn the model was trained on, including its end-of-turn marker: its tokens must be exactly what the model would write after the serving template renders the conversation up to that turn. Second, that end-of-turn token must be one the engine stops on. Third, any other difference between the two sides, such as how earlier turns are rendered, must be declared, never accidental. When one template does both jobs, as in this tutorial, the first clause becomes two token tests. The served tokens of the whole conversation must equal the trained tokens, and the generation prompt before each answer must be the start of the trained tokens, because the generation prompt is what the engine actually renders. The book also covers trainers whose training template legitimately differs from the serving one; this tutorial does not.

d2
grid-rows: 1
grid-gap: 40
train: "Training side: TRL SFTTrainer" {
  label.near: top-center
  style: {fill: "#EAF3FC"; stroke: "#2C6FB0"; font-color: "#2C2C2A"}
  grid-columns: 1
  conv: "conversation (messages)" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  tpl: "chat template\n{% generation %} marks the loss" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  rows: "input_ids + labels\n-100 outside each assistant turn" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
}
serve: "Serving side: your engine" {
  label.near: top-center
  style: {fill: "#EAF7EE"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  grid-columns: 1
  conv2: "conversation up to turn k" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  tpl2: "the same chat template\n+ generation prompt" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  gen: "the model writes turn k\nuntil a stop token" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}

Read the two columns row by row. The rows must agree in tokens, not only in text: the labels on the left are what the model learns to write, and the bottom box on the right is the model writing it. Each of the six checks you build tests one point where the columns can drift apart. Together they cover the first clause through the tokens, the mask and the labels, and the second clause against generation_config only. The third clause, declared differences, belongs to the book:

CheckComparesCatches
string (step 3)the template's render with the training textan extra newline or a changed marker
markers (step 4)each marker with the vocabularya marker that is several tokens of plain text
tokens (step 7)served input_ids and each generation prompt with the trainer'sequal text that tokenizes differently, a drifted generation prompt
mask (step 8)the assistant mask with each turna header inside the loss, the end marker outside it
labels (step 9)the trainer's real labelsa trained span that does not end on the end marker
stops (steps 10 to 12)the end marker with the stop list and the pad tokena model that never stops

Step 1: Save the exact fine-tuning text format as a reference string

Goal: store one training example twice, as messages and as the exact text the fine-tuning data was formatted into.

Why this step: every check in this tutorial compares against the training text. So the text has to exist in your repository as data, not in someone's memory of the format. This example uses four markers. <|system|>, <|user|> and <|assistant|> open a turn, and <|end|> closes every turn. <|end|> is the token the model learns to write when its answer is finished, and a format without such a marker trains a model that never stops. They play the roles that <|im_start|> and <|im_end|> play in ChatML. Axolotl's documentation uses the same four markers in its custom-template example (conversation format docs), so this format was not invented for the tutorial.

Create support_data.py:

python
# support_data.py# One training example for a support model, as messages and as the exact text# the fine-tuning data was formatted into. Every turn ends with <|end|>.SUPPORT_CONVERSATION = [    {"role": "system", "content": "You are a support agent."},    {"role": "user", "content": "Where is my order?"},    {"role": "assistant", "content": "Let me check."},    {"role": "user", "content": "It is order 1042."},    {"role": "assistant", "content": "Order 1042 ships today."},]SUPPORT_TEXT = (    "<|system|>\nYou are a support agent.<|end|>\n"    "<|user|>\nWhere is my order?<|end|>\n"    "<|assistant|>\nLet me check.<|end|>\n"    "<|user|>\nIt is order 1042.<|end|>\n"    "<|assistant|>\nOrder 1042 ships today.<|end|>\n")

Create show_training_format.py, which prints the text with Part 1's visible_whitespace so you can see every newline and space:

python
# show_training_format.pyimport sysfrom support_data import SUPPORT_CONVERSATION, SUPPORT_TEXTfrom templatestudio.render import visible_whitespacesys.stdout.reconfigure(encoding="utf-8")print(len(SUPPORT_CONVERSATION), "messages,", len(SUPPORT_TEXT), "characters")print(visible_whitespace(SUPPORT_TEXT), end="")

Run it:

bash
python show_training_format.py

Expected output:

text
5 messages, 192 characters<|system|>\nYou·are·a·support·agent.<|end|>\n<|user|>\nWhere·is·my·order?<|end|>\n<|assistant|>\nLet·me·check.<|end|>\n<|user|>\nIt·is·order·1042.<|end|>\n<|assistant|>\nOrder·1042·ships·today.<|end|>\n

What just happened: you now have the reference both sides must reproduce. Each turn is a header line, the content and <|end|>, followed by one newline. Remember that last newline. The model never writes it, and step 2 depends on that.

Step 2: Write a chat template with a {% generation %} block for assistant_only_loss

Goal: transcribe the training format into a Jinja chat template, with the assistant's answer marked for the loss.

Why this step: most of the template is a direct copy of step 1, written in the Jinja subset from Part 3. What the text does not show is which part of each assistant turn the model writes. A {% generation %} block marks that part. The block renders to nothing, so the string is the same with or without it, but it sets the assistant mask: a 0 or 1 for every token, 1 where the token came from inside a {% generation %} block. TRL turns that mask into labels in step 6. The real decision is where the block starts and ends. Each assistant turn has three pieces:

d2
grid-rows: 1
grid-gap: 16
header: "<|assistant|>\\n\nwritten by the prompt\noutside the block" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
body: "Let me check.<|end|>\nwritten by the model\ninside {% generation %}" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
nl: "\\n\nwritten by the template\noutside the block" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}

The model writes neither grey piece. The generation prompt writes the header before the model starts, and generation stops at <|end|>, so the model never writes the newline after it. Only the green piece is the model's own output, so only it carries a label. If the newline were inside the block, you would train a token the engine never lets the model emit.

Create templatestudio/authoring.py. The import block is complete for the whole tutorial, so later steps add functions at the end, and step 12 replaces one:

python
# templatestudio/authoring.pyfrom __future__ import annotationsfrom collections.abc import Sequencefrom transformers import GenerationConfig, PreTrainedModel, PreTrainedTokenizerBasefrom templatestudio.jinja_env import render_template_stringfrom templatestudio.messages import Conversationfrom templatestudio.render import first_divergence, load_tokenizer, render_idsEND_OF_TURN = "<|end|>"MARKERS = ("<|system|>", "<|user|>", "<|assistant|>", END_OF_TURN)# Transcribed from the training format. The assistant's words and its <|end|># sit inside {% generation %}: the model writes them, so the loss covers them.# The role header comes from the prompt and the newline after <|end|> comes# from the template, so both stay outside.SUPPORT_TEMPLATE = (    "{% for message in messages %}"    "{{- '<|' + message['role'] + '|>\n' }}"    "{%- if message['role'] == 'assistant' %}"    "{% generation %}{{- message['content'] + '<|end|>' }}{% endgeneration %}"    "{{- '\n' }}"    "{%- else %}"    "{{- message['content'] + '<|end|>\n' }}"    "{%- endif %}"    "{%- endfor %}"    "{%- if add_generation_prompt %}{{- '<|assistant|>\n' }}{%- endif %}")def install_template(    tokenizer: PreTrainedTokenizerBase, template_source: str) -> PreTrainedTokenizerBase:    """Make template_source the tokenizer's chat template.    apply_chat_template uses it from now on, and save_pretrained writes it to    chat_template.jinja beside the tokenizer files.    """    tokenizer.chat_template = template_source    return tokenizer

Setting tokenizer.chat_template is the whole installation. Step 13 saves the template with the tokenizer.

Create show_support_render.py, which renders step 1's conversation with Part 3's render_template_string. That function renders the way Transformers does, and it needs no tokenizer or model. SUPPORT_TEMPLATE reads no special-token variables, so you do not pass a special_tokens_map here:

python
# show_support_render.pyfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATION, SUPPORT_TEXTfrom templatestudio.authoring import SUPPORT_TEMPLATE, install_templatefrom templatestudio.jinja_env import render_template_stringfrom templatestudio.render import load_tokenizerserved = render_template_string(SUPPORT_TEMPLATE, SUPPORT_CONVERSATION)print("identical to the training text:", served == SUPPORT_TEXT)prompt = render_template_string(    SUPPORT_TEMPLATE, SUPPORT_CONVERSATION[:4], add_generation_prompt=True)print("generation prompt ends with:", repr(prompt[-28:]))tokenizer = install_template(load_tokenizer(FAMILIES["qwen3"]), SUPPORT_TEMPLATE)installed = tokenizer.apply_chat_template(SUPPORT_CONVERSATION, tokenize=False)print("installed on the tokenizer, same text:", installed == SUPPORT_TEXT)

Run it:

bash
python show_support_render.py

Expected output:

text
identical to the training text: Truegeneration prompt ends with: ' 1042.<|end|>\n<|assistant|>\n'installed on the tokenizer, same text: True

What just happened: the template reproduces the training text exactly. With add_generation_prompt=True it ends on the <|assistant|> header, where the model starts the next turn. After install_template, the Qwen3 tokenizer's own apply_chat_template renders the same text. Part 3's faithful environment accepts the {% generation %} tag through its GenerationExtension. A stock jinja2.Environment rejects the tag with a TemplateSyntaxError, quoted in the troubleshooting section.

Step 3: Check that the template reproduces the training text

Goal: turn step 2's comparison into a check that names the first character where the template and the training text differ.

Why this step: a printed True does not survive the next edit to the template. A check that returns problems can run in CI on every change. This one reports the position with Part 1's first_divergence, so a one-character drift such as the opening story's extra newline shows up at once:

d2
grid-rows: 2
grid-columns: 3
grid-gap: 12
l1: "trained" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
t1: "<|system|>\\n" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
t2: "You are a su..." {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
l2: "served" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
s1: "<|system|>\\n" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
s2: "\\nYou are a s...\ncharacter 11 differs" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}

The first eleven characters agree, so the check reports character 11, where the served copy has an extra newline.

Add the check to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_string_roundtrip(    template_source: str, conversation: Conversation, trained_text: str) -> list[str]:    """Compare the template's render with the exact text the model trained on."""    served = render_template_string(template_source, conversation)    at = first_divergence(served, trained_text)    if at is None:        return []    return [        f"text differs at character {at}: trained "        f"{trained_text[at:at + 12]!r}, served {served[at:at + 12]!r}"    ]

Create broken_templates.py to hold copies of your template with one mistake each. The first is the opening story's serving copy, with one extra newline after every role header:

python
# broken_templates.py# Copies of SUPPORT_TEMPLATE with one mistake each, for the failing cases.from templatestudio.authoring import SUPPORT_TEMPLATE# The serving copy someone edited: one extra newline after every role header.DRIFTED = SUPPORT_TEMPLATE.replace("'|>\n'", "'|>\n\n'")

Create show_string_check.py, which runs the check on both templates:

python
# show_string_check.pyfrom broken_templates import DRIFTEDfrom support_data import SUPPORT_CONVERSATION, SUPPORT_TEXTfrom templatestudio.authoring import SUPPORT_TEMPLATE, check_string_roundtripfor name, template in [("authored", SUPPORT_TEMPLATE), ("drifted", DRIFTED)]:    problems = check_string_roundtrip(template, SUPPORT_CONVERSATION, SUPPORT_TEXT)    print(f"{name}:", "ok" if not problems else "")    for problem in problems:        print("   ", problem)

Run it:

bash
python show_string_check.py

Expected output:

text
authored: okdrifted:     text differs at character 11: trained 'You are a su', served '\nYou are a s'

What just happened: the authored template passes. The drifted copy fails at character 11, the first character after <|system|>\n, and the '\n' in the output is repr showing the extra newline. This check needs no tokenizer, so it is the cheapest one to run on every commit. It cannot see the tokens under the string. Steps 7 to 9 check those.

Step 4: Add the turn markers to the tokenizer as single tokens

Goal: make each marker exactly one token, and check that it is.

Why this step: Qwen3 has never seen <|system|>, <|user|>, <|assistant|> or <|end|>. Its tokenizer encodes each one as five pieces of ordinary text. The text looks the same either way, but a marker in pieces is a weak boundary, because it can merge with the characters around it. A five-token <|end|> also cannot be a stop token, since the engine stops on one ID.

d2
grid-rows: 2
grid-gap: 18
before: "before add_tokens: 5 tokens" {
  label.near: top-center
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 8
  a: "<" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
  b: "|" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
  c: "end" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
  d: "|" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
  e: ">" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
}
after: "after add_tokens: 1 token" {
  label.near: top-center
  style: {fill: "#EAF7EE"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  one: "<|end|>  id 151672" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}

add_tokens(..., special_tokens=True) adds each marker to the vocabulary as a token that is never split.

Add the marker functions to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_marker_tokens(    tokenizer: PreTrainedTokenizerBase, markers: Sequence[str] = MARKERS) -> list[str]:    """Report every marker that is not exactly one token in the vocabulary."""    problems = []    for marker in markers:        ids = tokenizer.encode(marker, add_special_tokens=False)        if len(ids) != 1:            pieces = tokenizer.convert_ids_to_tokens(ids)            problems.append(f"{marker!r} is {len(ids)} tokens: {pieces}")    return problemsdef add_markers(    tokenizer: PreTrainedTokenizerBase, markers: Sequence[str] = MARKERS) -> int:    """Add the markers as special tokens, so each encodes to one ID."""    return tokenizer.add_tokens(list(markers), special_tokens=True)def authored_tokenizer(model_id: str) -> PreTrainedTokenizerBase:    """Load a base tokenizer, add the markers, and install the template."""    tokenizer = load_tokenizer(model_id)    add_markers(tokenizer)    return install_template(tokenizer, SUPPORT_TEMPLATE)

Every later step starts from authored_tokenizer, which joins step 2's template to the markers.

Create show_marker_split.py:

python
# show_marker_split.pyfrom families import FAMILIESfrom templatestudio.authoring import (    SUPPORT_TEMPLATE,    add_markers,    authored_tokenizer,    check_marker_tokens,)from templatestudio.render import load_tokenizertokenizer = load_tokenizer(FAMILIES["qwen3"])for problem in check_marker_tokens(tokenizer):    print(problem)print("added", add_markers(tokenizer), "tokens")print("after:", check_marker_tokens(tokenizer) or "every marker is one token")print("ids:", tokenizer.convert_tokens_to_ids(["<|system|>", "<|end|>"]))authored = authored_tokenizer(FAMILIES["qwen3"])print("authored_tokenizer:", check_marker_tokens(authored) or "markers ok", end=", ")print("template", "ok" if authored.chat_template == SUPPORT_TEMPLATE else "missing")

Run it:

bash
python show_marker_split.py

Expected output:

text
'<|system|>' is 5 tokens: ['<', '|', 'system', '|', '>']'<|user|>' is 5 tokens: ['<', '|', 'user', '|', '>']'<|assistant|>' is 5 tokens: ['<', '|', 'assistant', '|', '>']'<|end|>' is 5 tokens: ['<', '|', 'end', '|', '>']added 4 tokensafter: every marker is one tokenids: [151669, 151672]authored_tokenizer: markers ok, template ok

What just happened: before add_markers, every marker was five tokens. After it, each is one token, with the new IDs 151669 to 151672 placed right after Qwen3's last token, 151668. authored_tokenizer gives the same result in one call, with the template installed. The tokenizer now has 151,673 tokens, and step 5 checks that the model has an embedding row for each of them.

Step 5: Check resize_token_embeddings after add_tokens on Qwen3

Goal: make sure every new token ID has a row in the model's embedding matrix, without shrinking the matrix.

Why this step: a token ID is an index into the embedding matrix, so an ID past the last row fails as soon as the model reads it. The usual advice after add_tokens is model.resize_token_embeddings(len(tokenizer)). On Qwen3 that call is wrong. The Prerequisites output showed why: the matrix has 151,936 rows for 151,669 tokens. Qwen ships 267 spare rows, so the new IDs 151669 to 151672 already have rows, and resizing to len(tokenizer) would shrink the matrix. A shrunk matrix no longer has the base checkpoint's shape. Anything built against the base model's shapes, such as an adapter or a weight merge, then stops matching:

d2
grid-rows: 1
grid-gap: 24
tok: "tokenizer after add_markers\n151,673 tokens\nlast ID 151672" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
keep: "ensure_embedding_rows\n151,936 rows (unchanged)" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
shrink: "resize_token_embeddings(len)\n151,673 rows (263 cut)" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}

ensure_embedding_rows grows the matrix only when an ID would fall outside it, and never shrinks it. The model here is trl-internal-testing/tiny-Qwen3ForCausalLM, the 2-layer test model you added to families.py. It uses Qwen3's tokenizer and Qwen3's embedding size, so its rows match what a real Qwen3 fine-tune would have. It also loads in seconds on CPU.

Add the function to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def ensure_embedding_rows(    model: PreTrainedModel, tokenizer: PreTrainedTokenizerBase) -> int:    """Grow the embedding matrix only if a token ID would fall outside it.    Returns the number of rows. Never shrinks: some models ship spare rows.    """    rows = model.get_input_embeddings().weight.shape[0]    if len(tokenizer) > rows:        model.resize_token_embeddings(len(tokenizer))        rows = len(tokenizer)    return rows

Create show_embedding_rows.py. It counts the rows before and after ensure_embedding_rows, then shows what the usual resize call would do:

python
# show_embedding_rows.pyfrom transformers import AutoModelForCausalLMfrom families import FAMILIES, TINY_MODELfrom templatestudio.authoring import authored_tokenizer, ensure_embedding_rowstokenizer = authored_tokenizer(FAMILIES["qwen3"])model = AutoModelForCausalLM.from_pretrained(TINY_MODEL)print("tokens:", len(tokenizer))print("rows before:", model.get_input_embeddings().weight.shape[0])print("rows after ensure_embedding_rows:", ensure_embedding_rows(model, tokenizer))model.resize_token_embeddings(len(tokenizer))rows = model.get_input_embeddings().weight.shape[0]print("rows after resize_token_embeddings:", rows)

Run it:

bash
python show_embedding_rows.py

Expected output:

text
tokens: 151673rows before: 151936rows after ensure_embedding_rows: 151936rows after resize_token_embeddings: 151673

The first run downloads the test model's weights. Every run prints a Loading weights progress bar to stderr.

What just happened: ensure_embedding_rows left all 151,936 rows in place. The resize call cut the matrix to 151,673 rows, one per token. In neither version are the rows for IDs 151669 to 151672 trained, so the model cannot yet write <|end|> with any skill. Those rows are Qwen's spare padding rows, not rows that resize_token_embeddings added. That call fills any rows it adds with the average of the existing ones, and here it added none. A real fine-tune has to train these rows, which the book covers (see Next steps).

Step 6: Get SFTTrainer input_ids and labels without training

Goal: build a real TRL SFTTrainer on CPU and read the input_ids and labels it would train on, without training.

Why this step: every trainer has a setting that can replace your template. TRL's chat_template_path, Axolotl's chat_template and Unsloth's get_chat_template all do. So the reference for the token checks cannot be your guess at the trainer's data. It has to be a row from the trainer you will actually run. TRL builds that row when the trainer is constructed, before train() is ever called:

d2
direction: down
msgs: "train_dataset: {\"messages\": [...]}" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
tok: "1. apply_chat_template\nreturn_assistant_tokens_mask=True" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
lab: "2. labels = input_ids, with -100\nwherever the mask is 0" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
cut: "3. truncate to max_length\ndrop rows with nothing to train" {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
row: "trainer.train_dataset[0]\ninput_ids + labels" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
msgs -> tok
tok -> lab
lab -> cut
cut -> row

Each stage runs inside SFTTrainer.__init__ in TRL 1.17.1. Stages 1 and 2 raise an error for a template with no {% generation %} block and for a row with no assistant tokens; the troubleshooting section quotes both. Stage 3 is quieter. A row that truncation leaves with nothing to train is dropped without an error, so a dataset of long conversations can train on fewer examples than you gave it.

Create trainer_batch.py, the helper every later step uses to build a trainer:

python
# trainer_batch.pyfrom datasets import Dataset, disable_progress_barsfrom transformers import AutoModelForCausalLM, PreTrainedTokenizerBasefrom transformers.utils import logging as transformers_loggingfrom trl import SFTConfig, SFTTrainerfrom families import TINY_MODELfrom templatestudio.authoring import END_OF_TURN, ensure_embedding_rowsdisable_progress_bars()transformers_logging.disable_progress_bar()def build_trainer(    tokenizer: PreTrainedTokenizerBase, conversations: list[list[dict]]) -> SFTTrainer:    """Build an SFTTrainer the way a fine-tune would, without training."""    model = AutoModelForCausalLM.from_pretrained(TINY_MODEL)    ensure_embedding_rows(model, tokenizer)    config = SFTConfig(        output_dir="trainer-out",        assistant_only_loss=True,        eos_token=END_OF_TURN,        use_cpu=True,        bf16=False,        report_to="none",    )    dataset = Dataset.from_list([{"messages": c} for c in conversations])    return SFTTrainer(        model=model, args=config, train_dataset=dataset, processing_class=tokenizer    )

processing_class is the argument TRL takes the tokenizer from. assistant_only_loss=True trains only on the {% generation %} spans. eos_token names the token that ends a turn, and step 10 checks what TRL does with it. You need use_cpu=True and bf16=False on a machine without a GPU, because TRL turns bf16 on by default; the troubleshooting section quotes the error. The trainer writes nothing to trainer-out/ until it trains or saves, but it does create the folder.

Create show_trainer_row.py:

python
# show_trainer_row.pyfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import authored_tokenizerfrom trainer_batch import build_trainertokenizer = authored_tokenizer(FAMILIES["qwen3"])trainer = build_trainer(tokenizer, [SUPPORT_CONVERSATION])row = trainer.train_dataset[0]print("columns:", trainer.train_dataset.column_names)print("embedding rows:", trainer.model.get_input_embeddings().weight.shape[0])print("input_ids:", len(row["input_ids"]), "ids")trained = [t for t in row["labels"] if t != -100]print("trained ids:", len(trained))print("trained text:", repr(tokenizer.decode(trained)))

Run it:

bash
python show_trainer_row.py

Expected output:

text
columns: ['messages', 'input_ids', 'labels']embedding rows: 151936input_ids: 53 idstrained ids: 15trained text: 'Let me check.<|end|>Order 1042 ships today.<|end|>'

What just happened: TRL rendered step 1's conversation into 53 token IDs and kept a label on 15 of them, which are the two answers, each through its <|end|>. The messages column stays in the dataset. The Trainer drops it when it builds batches.

Step 7: Compare the served token IDs with the trainer's input_ids

Goal: check that the serving side produces exactly the IDs the trainer trained on.

Why this step: two equal strings can be different token sequences. Picture a serving setup that installs the template on the base tokenizer, without the added markers. It renders the same text that step 3 accepted, but it splits every marker into pieces, so the model sees a sequence it never trained on. Only a comparison of IDs sees that:

d2
grid-rows: 2
grid-gap: 18
text: "text: identical on both sides" {
  label.near: top-center
  style: {fill: "#EAF7EE"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  grid-rows: 1
  t: "<|system|>\\nYou are a support agent.<|end|>\\n..." {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
ids: "token IDs: different from position 0" {
  label.near: top-center
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 20
  tr: "trained: 151669, 198, 2610, ...\n53 ids" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  sv: "served: 27, 91, 8948, 91, 397, ...\n78 ids" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}

check_token_roundtrip renders with Part 1's render_ids, which calls apply_chat_template(..., tokenize=True). Do not tokenize the rendered string yourself instead. tokenizer(text) adds special tokens by default, and on a model with a beginning-of-sequence token that adds a second one the template already wrote. On failure the check prints the position and the tokens around it. It prints tokens rather than IDs, because '<' against '<|system|>' explains itself.

Add it to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_token_roundtrip(    tokenizer: PreTrainedTokenizerBase,    conversation: Conversation,    trained_ids: Sequence[int],) -> list[str]:    """Compare the served template's token IDs with the IDs the trainer used:    the whole conversation, then the generation prompt before each answer."""    served = render_ids(tokenizer, conversation, add_generation_prompt=False)    trained = [int(i) for i in trained_ids]    at = first_divergence(served, trained)    if at is not None:        window = slice(max(at - 2, 0), at + 3)        return [            f"tokens differ at position {at} "            f"(served {len(served)} ids, trained {len(trained)})",            f"served:  {tokenizer.convert_ids_to_tokens(served[window])}",            f"trained: {tokenizer.convert_ids_to_tokens(trained[window])}",        ]    problems = []    for k, message in enumerate(conversation):        if message["role"] != "assistant":            continue        prompt = render_ids(tokenizer, conversation[:k], add_generation_prompt=True)        at = first_divergence(prompt, trained[: len(prompt)])        if at is not None:            problems.append(                f"generation prompt before message {k} differs at position {at}: "                f"{tokenizer.convert_ids_to_tokens(prompt[at:at + 2])}"            )    return problems

The check has a second half. A serving engine never renders a finished conversation: it renders the conversation up to the next answer, with add_generation_prompt=True, and the model writes from there. So after the whole conversation matches, the check renders the generation prompt before each assistant turn and requires it to be the start of the trainer's IDs. A template whose generation prompt writes a different header than the trained turns passes every other check, because none of them renders a generation prompt. Add that case to the end of broken_templates.py, with one more newline in the generation prompt only:

python
# broken_templates.py (add at the end)# The generation prompt writes one more newline than the trained turns have.GEN_PROMPT_DRIFT = SUPPORT_TEMPLATE.replace(    "{{- '<|assistant|>\n' }}{%- endif %}",    "{{- '<|assistant|>\n\n' }}{%- endif %}",)

Create show_token_check.py. It compares the trainer's row from step 6 with three serving tokenizers: yours, the base tokenizer with only the template installed, and yours with GEN_PROMPT_DRIFT:

python
# show_token_check.pyimport sysfrom broken_templates import GEN_PROMPT_DRIFTfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATION, SUPPORT_TEXTfrom templatestudio.authoring import (    SUPPORT_TEMPLATE,    authored_tokenizer,    check_string_roundtrip,    check_token_roundtrip,    install_template,)from templatestudio.render import load_tokenizerfrom trainer_batch import build_trainersys.stdout.reconfigure(encoding="utf-8")tokenizer = authored_tokenizer(FAMILIES["qwen3"])trained_ids = build_trainer(tokenizer, [SUPPORT_CONVERSATION]).train_dataset[0][    "input_ids"]# The serving side loads the base tokenizer and installs the template only.served = install_template(load_tokenizer(FAMILIES["qwen3"]), SUPPORT_TEMPLATE)# A tokenizer with the markers, but a template whose generation prompt drifted.drifted = install_template(authored_tokenizer(FAMILIES["qwen3"]), GEN_PROMPT_DRIFT)cases = [("authored", tokenizer), ("template only", served), ("prompt drift", drifted)]for name, tok in cases:    text = check_string_roundtrip(tok.chat_template, SUPPORT_CONVERSATION, SUPPORT_TEXT)    tokens = check_token_roundtrip(tok, SUPPORT_CONVERSATION, trained_ids)    print(f"{name}: string", "ok" if not text else "differs")    print(f"{name}: tokens", "ok" if not tokens else "")    for line in tokens:        print("   ", line)

Run it:

bash
python show_token_check.py

Expected output:

text
authored: string okauthored: tokens oktemplate only: string oktemplate only: tokens     tokens differ at position 0 (served 78 ids, trained 53)    served:  ['<', '|', 'system']    trained: [None, 'Ċ', 'You']prompt drift: string okprompt drift: tokens     generation prompt before message 2 differs at position 20: ['ĊĊ']    generation prompt before message 4 differs at position 41: ['ĊĊ']

What just happened: all three tokenizers pass the string check, and only yours passes the token check. The template-only tokenizer writes <|system|> as '<', '|', 'system', which gives 78 IDs where the trainer had 53. Its trained side prints None first. The base tokenizer has no token with ID 151669 at all, because you added the markers only to the training tokenizer. 'Ċ' is how Qwen3's byte-level tokenizer writes a newline. The drifted template renders every trained turn correctly and fails only on its generation prompts, at positions 20 and 41. 'ĊĊ' is the single token for two newlines, where the trainer had 'Ċ'. In production that is a first generated token in a position the model never trained on. Of the six checks, this is the one that sees what the model actually read. It applies the same token-for-token rule as Part 6's prefix_break, but between training and serving instead of between two turns.

Step 8: Check which tokens the assistant mask covers

Goal: check that the mask covers each assistant turn plus <|end|>, and nothing else.

Why this step: apply_chat_template(..., return_assistant_tokens_mask=True) returns assistant_masks, one 0 or 1 per token, built from the character positions of each {% generation %} block. A template with no block gets a mask of all zeros and a warning, not an error, from apply_chat_template alone. TRL raises an error for the same template (step 6). That case is easy to catch. The subtle failures are masks that look plausible, because the block is in the wrong place or the tokens at its edge do not line up with its characters.

d2
grid-columns: 1
grid-gap: 16
ok: "right: the mask is the answer plus <|end|>" {
  label.near: top-center
  style: {fill: "#EAF7EE"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 8
  h: "<|assistant|> \\n" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
  c: "Let me check." {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  e: "<|end|>" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  n: "\\n" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
}
split: "markers not added: '>' and '\\n' merge into one token" {
  label.near: top-center
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 8
  h2: "<|assistant|> \\n" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
  c2: "Let me check" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  e2: ".<  |  end  |" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  n2: ">\\n  (masked)" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
out: "end outside the block: <|end|> is never trained" {
  label.near: top-center
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 8
  h3: "<|assistant|> \\n" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
  c3: "Let me check." {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  e3: "<|end|>  (not masked)" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  n3: "\\n" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
}

Green is masked, which means trained, and grey is not. Red marks the token that is wrong: masked when it should not be, or not masked when it should be. In the middle row the block ends after >. Qwen3's tokenizer merges > with the newline into one token, and the mask marks any token that touches the block. So the newline the model must never write becomes a trained token.

check_assistant_mask cuts the mask into runs of masked tokens and requires each run's IDs to equal the tokenized answer plus <|end|>. It compares IDs, not decoded text. A stray newline inside a span is a token the model will learn to write, and stripping decoded text would hide it.

Add it to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_assistant_mask(    tokenizer: PreTrainedTokenizerBase,    conversation: Conversation,    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check that each masked span is one assistant turn plus end_of_turn."""    encoded = tokenizer.apply_chat_template(        conversation, return_assistant_tokens_mask=True    )    ids, mask = encoded["input_ids"], encoded["assistant_masks"]    spans: list[list[int]] = []    current: list[int] = []    for token_id, flag in zip(ids, mask):        if flag:            current.append(token_id)        elif current:            spans.append(current)            current = []    if current:        spans.append(current)    turns = [m["content"] for m in conversation if m["role"] == "assistant"]    if not turns:        return ["the conversation has no assistant turn to mask"]    if not spans:        return ["the mask is all zeros: the template has no {% generation %}"]    if len(spans) != len(turns):        return [f"{len(spans)} masked spans for {len(turns)} assistant turns"]    problems = []    for i, (span, content) in enumerate(zip(spans, turns)):        expected = tokenizer.encode(content + end_of_turn, add_special_tokens=False)        if span != expected:            problems.append(                f"span {i} is {tokenizer.decode(span)!r}, "                f"not {content + end_of_turn!r}"            )    return problems

Add a third broken copy to the end of broken_templates.py, with <|end|> moved outside the block:

python
# broken_templates.py (add at the end)# <|end|> moved out of the generation block: the model never learns to stop.END_OUTSIDE = SUPPORT_TEMPLATE.replace(    "message['content'] + '<|end|>' }}{% endgeneration %}",    "message['content'] }}{% endgeneration %}{{- '<|end|>' }}",)

Create show_mask_check.py. It checks your tokenizer, the base tokenizer with only the template, and your tokenizer with END_OUTSIDE:

python
# show_mask_check.pyfrom broken_templates import END_OUTSIDEfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import (    SUPPORT_TEMPLATE,    authored_tokenizer,    check_assistant_mask,    install_template,)from templatestudio.render import load_tokenizercases = {    "authored": authored_tokenizer(FAMILIES["qwen3"]),    "markers not added": install_template(        load_tokenizer(FAMILIES["qwen3"]), SUPPORT_TEMPLATE    ),    "end outside": install_template(        authored_tokenizer(FAMILIES["qwen3"]), END_OUTSIDE    ),}for name, tokenizer in cases.items():    problems = check_assistant_mask(tokenizer, SUPPORT_CONVERSATION)    print(f"{name}:", "ok" if not problems else "")    for problem in problems:        print("   ", problem)

Run it:

bash
python show_mask_check.py

Expected output:

text
authored: okmarkers not added:     span 0 is 'Let me check.<|end|>\n', not 'Let me check.<|end|>'    span 1 is 'Order 1042 ships today.<|end|>\n', not 'Order 1042 ships today.<|end|>'end outside:     span 0 is 'Let me check.', not 'Let me check.<|end|>'    span 1 is 'Order 1042 ships today.', not 'Order 1042 ships today.<|end|>'

What just happened: your template's two spans are exactly the two answers with <|end|>. Without the added markers, both spans end in a newline, which is the merged-token leak from the diagram. With <|end|> outside the block, both spans stop before it, so the model would learn every answer and never the token that ends one. The string check from step 3 passes all three templates.

Step 9: Check that every trained span in the labels ends on <|end|>

Goal: check that every trained run in the trainer's real labels ends on <|end|>, whichever trainer and collator built them.

Why this step: step 8 tests the template. This step tests the trainer's output, which is what the loss sees. TRL builds labels from the mask. Other trainers and hand-written loops build them their own way, and a collator, the function that pads dataset rows into a batch, can rewrite them again. One rule holds for every correct setup: each run of trained positions is one answer, so it ends on <|end|>.

d2
grid-rows: 2
grid-columns: 7
grid-gap: 6
p1: "-100\n..." {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
p2: "Let" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
p3: "me" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
p4: "check" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
p5: "." {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
p6: "<|end|>\nrun ends here" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
p7: "-100\n(\\n)" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
q1: "-100\n..." {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
q2: "Let" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
q3: "me" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
q4: "check" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
q5: ".\nrun ends here" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
q6: "-100\n(<|end|>)" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
q7: "-100\n(\\n)" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}

The top row is your template's first answer in the labels. The bottom row is the same answer with <|end|> outside the block: the run ends on ., and <|end|> carries -100.

Add the check to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_trained_spans(    tokenizer: PreTrainedTokenizerBase,    labels: Sequence[int],    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check one row of real labels: every trained run ends on end_of_turn."""    end_id = tokenizer.convert_tokens_to_ids(end_of_turn)    problems = []    for i, label in enumerate(labels):        trained = label != -100        last_of_run = trained and (i + 1 == len(labels) or labels[i + 1] == -100)        if last_of_run and label != end_id:            text = tokenizer.decode([label])            problems.append(f"trained span ends at position {i} on {text!r}")    if all(label == -100 for label in labels):        problems.append("no position is trained")    return problems

Create show_label_check.py. It builds a trainer for your template and for END_OUTSIDE from step 8, then checks each trainer's first row:

python
# show_label_check.pyfrom broken_templates import END_OUTSIDEfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import (    SUPPORT_TEMPLATE,    authored_tokenizer,    check_trained_spans,    install_template,)from trainer_batch import build_trainerfor name, template in [("authored", SUPPORT_TEMPLATE), ("end outside", END_OUTSIDE)]:    tokenizer = install_template(authored_tokenizer(FAMILIES["qwen3"]), template)    trainer = build_trainer(tokenizer, [SUPPORT_CONVERSATION])    labels = trainer.train_dataset[0]["labels"]    problems = check_trained_spans(tokenizer, labels)    print(f"{name}:", "ok" if not problems else "")    for problem in problems:        print("   ", problem)

Run it:

bash
python show_label_check.py

Expected output:

text
authored: okend outside:     trained span ends at position 24 on '.'    trained span ends at position 50 on '.'

The second trainer also logs a warning to stderr, because TRL 1.17 added a check of its own. Its full text is: [RANK 0] The chat template does not include the assistant turn's end-of-turn token in the loss mask; the model may not learn to stop. The training loss still looks healthy, so this usually only surfaces at inference. Either set `assistant_only_loss=False` to train on the full sequence, or edit the chat template so the end-of-turn token falls inside `{% generation %}...{% endgeneration %}`.

What just happened: your template's labels end both runs on <|end|>. The broken copy ends both on ., at positions 24 and 50. TRL's warning catches this case too. But a warning is easy to miss in a long training log, and it covers only TRL. check_trained_spans reads labels from any source and returns a result you can assert on. It checks where each run ends, not where it starts or how many runs there are. With TRL the starts come from the mask step 8 already checked; Next steps extends the check for other trainers.

Step 10: Make a fine-tuned model stop on the end-of-turn token

Goal: check that <|end|> is in the model's stop list.

Why this step: generation stops when the model writes an ID listed in generation_config.eos_token_id, which can be one integer or a list. The base model's list cannot contain a token you added yourself. Until <|end|>'s ID is in that list, a model trained to end turns with <|end|> writes it and keeps going. Three places name the end-of-turn token, and all three must agree:

d2
grid-rows: 1
grid-gap: 24
t: "1. the template\nwrites <|end|> after\nevery turn" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
s: "2. the trainer\nSFTConfig(eos_token=\"<|end|>\")" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
g: "3. generation_config\neos_token_id includes 151672" {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}

If the stop list is the one that disagrees, the model trains correctly but does not stop when served. That is the opening story's second failure.

TRL's eos_token setting does part of the work for you. It sets tokenizer.eos_token and puts the new ID at the front of generation_config.eos_token_id, keeping the old IDs. Another TRL path behaves differently. When chat_template_path points at a tokenizer, TRL calls clone_chat_template, which replaces the list with the single new ID (TRL 1.17.1 chat_template_utils.py; not run in this tutorial). A flow that only swaps the template text does neither. So check the result, not the setting.

Add the check to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_stop_alignment(    tokenizer: PreTrainedTokenizerBase,    generation_config: GenerationConfig,    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check that generation stops on end_of_turn."""    ids = tokenizer.encode(end_of_turn, add_special_tokens=False)    if len(ids) != 1:        return [f"{end_of_turn!r} is {len(ids)} tokens, so it cannot be a stop ID"]    end_id = ids[0]    stops = generation_config.eos_token_id    stops = [stops] if isinstance(stops, int) else list(stops or [])    problems = []    if end_id not in stops:        problems.append(            f"{end_of_turn!r} (id {end_id}) is not in eos_token_id {stops}"        )    return problems

Create show_stop_check.py. It checks the generation config the test model shipped with, then the one the trainer left behind:

python
# show_stop_check.pyfrom transformers import GenerationConfigfrom families import FAMILIES, TINY_MODELfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import authored_tokenizer, check_stop_alignmentfrom trainer_batch import build_trainertokenizer = authored_tokenizer(FAMILIES["qwen3"])shipped = GenerationConfig.from_pretrained(TINY_MODEL)print("shipped eos_token_id:", shipped.eos_token_id)for problem in check_stop_alignment(tokenizer, shipped) or ["ok"]:    print("shipped:", problem)trainer = build_trainer(tokenizer, [SUPPORT_CONVERSATION])after = trainer.model.generation_configprint("after SFTTrainer eos_token_id:", after.eos_token_id)for problem in check_stop_alignment(tokenizer, after) or ["ok"]:    print("after:", problem)

Run it:

bash
python show_stop_check.py

Expected output:

text
shipped eos_token_id: [151645, 151643]shipped: '<|end|>' (id 151672) is not in eos_token_id [151645, 151643]after SFTTrainer eos_token_id: [151672, 151645, 151643]after: ok

What just happened: the shipped config stops on Qwen3's <|im_end|> (151645) and <|endoftext|> (151643), not on <|end|>. After SFTTrainer ran with eos_token="<|end|>", 151672 is first in the list. Step 13 saves that config with the model, and generate in Transformers reads it from there. Your serving engine may not. Check that it stops on the ID too, because an engine-level stop setting, or a converted checkpoint format with its own end-of-generation metadata, is one more place to keep in sync.

Step 11: Find which data collator masks <|end|> when pad_token = eos_token

Goal: see why pad_token = eos_token can stop a model from learning to stop, and which collator it affects.

Why this step: setting the pad token to the end-of-turn token is a common way to give a tokenizer a pad token. It is also a known cause of models that never stop. Transformers' DataCollatorForLanguageModeling builds labels from input_ids and sets every label equal to pad_token_id to -100. If the pad token is <|end|>, every <|end|> in the data leaves the loss along with the padding. TRL ships its own collator under the same name. It masks padding by position instead, so the same tokenizer is safe there:

d2
grid-rows: 1
grid-gap: 30
hf: "transformers DataCollatorForLanguageModeling" {
  label.near: top-center
  style: {fill: "#FDEDEC"; stroke: "#B03A2E"; font-color: "#2C2C2A"}
  grid-columns: 1
  a: "labels = input_ids" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  b: "labels[labels == pad_token_id] = -100" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  c: "every <|end|> is masked" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
trl: "trl DataCollatorForLanguageModeling" {
  label.near: top-center
  style: {fill: "#EAF7EE"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
  grid-columns: 1
  d: "labels from the dataset row" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  e: "pad the labels with -100" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  f: "<|end|> keeps its label" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}

Which collator you have depends on how you train. SFTTrainer uses TRL's collator by default. A plain Transformers Trainer, or a hand-written loop that copies the Transformers recipe, uses the pad-masking one.

No new code goes into authoring.py in this step. Create show_pad_trap.py. It sets the pad token to <|end|>, then runs the same trainer row through both collators and checks each result with check_trained_spans. mlm=False selects causal language modeling, where the labels are the input IDs, rather than masked language modeling:

python
# show_pad_trap.pyfrom transformers import DataCollatorForLanguageModelingfrom trl.trainer.sft_trainer import DataCollatorForLanguageModeling as TRLCollatorfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import authored_tokenizer, check_trained_spansfrom trainer_batch import build_trainertokenizer = authored_tokenizer(FAMILIES["qwen3"])row = build_trainer(tokenizer, [SUPPORT_CONVERSATION]).train_dataset[0]tokenizer.pad_token = "<|end|>"full = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)batch = full([{"input_ids": row["input_ids"]}])print("transformers collator:")for problem in check_trained_spans(tokenizer, batch["labels"][0].tolist()):    print("   ", problem)trl_batch = TRLCollator(pad_token_id=tokenizer.pad_token_id)([row])labels = trl_batch["labels"][0].tolist()print("TRL collator:", check_trained_spans(tokenizer, labels) or "ok")

Run it:

bash
python show_pad_trap.py

Expected output:

text
transformers collator:    trained span ends at position 7 on '.'    trained span ends at position 16 on '?'    trained span ends at position 24 on '.'    trained span ends at position 37 on '.'    trained span ends at position 50 on '.'    trained span ends at position 52 on '\n'TRL collator: ok

What just happened: the Transformers collator trains the whole sequence, as it always does, but with every <|end|> masked. Each of the six runs ends on the token before an <|end|>, or on the final newline. The TRL collator kept the row's labels, so both answers still end on <|end|>. You do not need to remember which collator masks how. Run check_trained_spans on one batch from your real trainer and collator, and the labels tell you.

Step 12: Flag a pad token that equals the end-of-turn token

Goal: make check_stop_alignment warn when the pad token is the end-of-turn token.

Why this step: check_trained_spans needs a real batch to see step 11's trap. The cause is visible much earlier, in the tokenizer alone, before any trainer exists. A second clause in check_stop_alignment reports it. The report is a warning, not a proven bug, because whether it hurts depends on the collator.

Replace the function in templatestudio/authoring.py:

python
# templatestudio/authoring.py (replace check_stop_alignment)def check_stop_alignment(    tokenizer: PreTrainedTokenizerBase,    generation_config: GenerationConfig,    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check that generation stops on end_of_turn, and that padding cannot    mask it out of the loss."""    ids = tokenizer.encode(end_of_turn, add_special_tokens=False)    if len(ids) != 1:        return [f"{end_of_turn!r} is {len(ids)} tokens, so it cannot be a stop ID"]    end_id = ids[0]    stops = generation_config.eos_token_id    stops = [stops] if isinstance(stops, int) else list(stops or [])    problems = []    if end_id not in stops:        problems.append(            f"{end_of_turn!r} (id {end_id}) is not in eos_token_id {stops}"        )    if tokenizer.pad_token_id == end_id:        problems.append(            f"pad_token is {end_of_turn!r}: a pad-ID collator masks it from the loss"        )    return problems

Create show_pad_stop_check.py. It runs the check with the tokenizer's own pad token, then with the pad token set to <|end|>:

python
# show_pad_stop_check.pyfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import authored_tokenizer, check_stop_alignmentfrom trainer_batch import build_trainertokenizer = authored_tokenizer(FAMILIES["qwen3"])config = build_trainer(tokenizer, [SUPPORT_CONVERSATION]).model.generation_configprint("pad <|endoftext|>:", check_stop_alignment(tokenizer, config) or "ok")tokenizer.pad_token = "<|end|>"for problem in check_stop_alignment(tokenizer, config):    print("pad <|end|>:", problem)

Run it:

bash
python show_pad_stop_check.py

Expected output:

text
pad <|endoftext|>: okpad <|end|>: pad_token is '<|end|>': a pad-ID collator masks it from the loss

What just happened: with Qwen3's own pad token, <|endoftext|>, which the tokenizer ships with and step 6's trainer kept, the check passes. With the pad token set to <|end|>, it reports the problem before any batch is built. check_stop_alignment now covers both causes of a model that never stops: a stop list without <|end|>, and a pad token that can mask <|end|> out of the loss.

Step 13: Save the template with the model and check the saved copy

Goal: save the model, tokenizer, template and stop list together, then reload the folder and run all six checks on it with one function.

Why this step: a template kept anywhere other than beside the model will drift from the weights it belongs to. Serving code, a wiki and a separate config are all such places, and that drift is the opening story's first failure. save_pretrained writes the template to chat_template.jinja next to the tokenizer files, so it travels with the model. The last check must run on what you ship, reloaded from disk. The objects in memory are not a fair test, because the trainer has already changed them:

d2
direction: down
mem: "authored tokenizer + trainer's generation_config" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
dir: "support-model/" {
  label.near: top-center
  style: {fill: "#EAF3FC"; stroke: "#2C6FB0"; font-color: "#2C2C2A"}
  grid-rows: 1
  grid-gap: 12
  j: "chat_template.jinja" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
  k: "tokenizer.json\ntokenizer_config.json" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
  g: "generation_config.json" {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
  r: "trained_row.json" {style: {fill: "#95A5A6"; stroke: "#6C7A7B"; font-color: "#2C2C2A"}}
}
chk: "check_authored_model: six checks" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
mem -> dir: "1. save_pretrained"
dir -> chk: "2. reload and check"

trained_row.json is the trainer's first row, stored as the reference the token and label checks compare against. Part 11 of this series turns stored sequences like it into golden-token tests.

Add the function that runs every check to the end of templatestudio/authoring.py:

python
# templatestudio/authoring.py (add at the end)def check_authored_model(    tokenizer: PreTrainedTokenizerBase,    generation_config: GenerationConfig,    conversation: Conversation,    trained_text: str,    trained_row: dict[str, list[int]],) -> dict[str, list[str]]:    """Run every check against one saved model. Empty lists mean it passes."""    template = tokenizer.chat_template    return {        "string": check_string_roundtrip(template, conversation, trained_text),        "markers": check_marker_tokens(tokenizer),        "tokens": check_token_roundtrip(            tokenizer, conversation, trained_row["input_ids"]        ),        "mask": check_assistant_mask(tokenizer, conversation),        "labels": check_trained_spans(tokenizer, trained_row["labels"]),        "stops": check_stop_alignment(tokenizer, generation_config),    }

Create save_support_model.py:

python
# save_support_model.pyimport jsonfrom pathlib import Pathfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATIONfrom templatestudio.authoring import authored_tokenizerfrom trainer_batch import build_trainertokenizer = authored_tokenizer(FAMILIES["qwen3"])trainer = build_trainer(tokenizer, [SUPPORT_CONVERSATION])row = trainer.train_dataset[0]out = Path("support-model")trainer.model.save_pretrained(out)tokenizer.save_pretrained(out)Path(out, "trained_row.json").write_text(    json.dumps({"input_ids": row["input_ids"], "labels": row["labels"]}))for path in sorted(out.iterdir()):    print(path.name)

Create check_support_model.py, which reloads the folder with Part 1's load_tokenizer and runs check_authored_model:

python
# check_support_model.pyimport jsonfrom pathlib import Pathfrom transformers import GenerationConfigfrom support_data import SUPPORT_CONVERSATION, SUPPORT_TEXTfrom templatestudio.authoring import check_authored_modelfrom templatestudio.render import load_tokenizermodel_dir = Path("support-model")tokenizer = load_tokenizer(str(model_dir))config = GenerationConfig.from_pretrained(model_dir)row = json.loads(Path(model_dir, "trained_row.json").read_text())report = check_authored_model(tokenizer, config, SUPPORT_CONVERSATION, SUPPORT_TEXT, row)for name, problems in report.items():    print(f"{name:8} {'ok' if not problems else problems}")print("passes" if not any(report.values()) else "FAILS")

Run it:

bash
python save_support_model.pypython check_support_model.py

Expected output:

text
chat_template.jinjaconfig.jsongeneration_config.jsonmodel.safetensorstokenizer.jsontokenizer_config.jsontrained_row.jsonstring   okmarkers  oktokens   okmask     oklabels   okstops    okpasses

What just happened: the folder holds everything a serving engine reads: the weights and config.json, the template, the tokenizer with its four markers, and generation_config.json with a stop list that includes <|end|>. model.save_pretrained wrote the generation config along with the weights. Reloaded from disk, the folder passes all six checks. Because the markers are in the saved tokenizer, nobody who loads this folder can hit the step 7 failure.

The folder only works as a whole. Create show_template_only_copy.py, which plays someone who copies the template and the weights but loads the base Qwen3 tokenizer, and runs the same six checks:

python
# show_template_only_copy.pyimport jsonfrom pathlib import Pathfrom transformers import GenerationConfigfrom families import FAMILIESfrom support_data import SUPPORT_CONVERSATION, SUPPORT_TEXTfrom templatestudio.authoring import check_authored_model, install_templatefrom templatestudio.render import load_tokenizer# Someone copies the template and the weights, but loads the base tokenizer.model_dir = Path("support-model")template = Path(model_dir, "chat_template.jinja").read_text(encoding="utf-8")tokenizer = install_template(load_tokenizer(FAMILIES["qwen3"]), template)config = GenerationConfig.from_pretrained(model_dir)row = json.loads(Path(model_dir, "trained_row.json").read_text())report = check_authored_model(tokenizer, config, SUPPORT_CONVERSATION, SUPPORT_TEXT, row)for name, problems in report.items():    print(f"{name:8} {'ok' if not problems else len(problems)}", end="")    print("" if not problems else f" problem(s): {problems[0][:52]}")

Run it:

bash
python show_template_only_copy.py

Expected output:

text
string   okmarkers  4 problem(s): '<|system|>' is 5 tokens: ['<', '|', 'system', '|', tokens   3 problem(s): tokens differ at position 0 (served 78 ids, trained mask     2 problem(s): span 0 is 'Let me check.<|end|>\n', not 'Let me checlabels   2 problem(s): trained span ends at position 25 on ''stops    1 problem(s): '<|end|>' is 5 tokens, so it cannot be a stop ID

The script prints the number of problems per check and the start of the first one. Only the string check passes, because the text is the same. Everything that depends on the four added tokens fails: the markers split, the token IDs differ from position 0, the mask leaks the newline, and <|end|> can no longer be a stop ID. The labels line prints '' because the base tokenizer has no token with ID 151672 to decode. This is why the template, the tokenizer and the weights ship as one folder.

Fix "The chat template is not training-compatible" and other chat template authoring errors

Each entry says where the string was seen: in a step, while preparing this tutorial, or in a named source.

ValueError: Your setup doesn't support bf16/gpu. You need to assign use_cpu if you want to train the model on CPU.

  • Cause: SFTConfig turns bf16 on by default, and the machine has no GPU (seen while preparing this tutorial).
  • Fix: pass use_cpu=True, bf16=False, as trainer_batch.py does in step 6.

ValueError: The chat template is not training-compatible (missing prefix-preservation or `{% generation %}` markers) and patching is not supported for this template. Please manually modify the chat template for training.

  • Cause: assistant_only_loss=True with a template that has no {% generation %} block, and TRL has no bundled training template to swap in for it (seen while preparing this tutorial).
  • Fix: put the answer and its end marker inside {% generation %}, as in step 2.

return_assistant_tokens_mask==True but chat template does not contain `{% generation %}` keyword.

  • Cause: the same template, rendered with a mask. Transformers 5.22.1 logs this once and returns a mask of all zeros instead of raising (chat_template_utils.py).
  • Fix: check_assistant_mask reports it (step 8). Add the block.

The specified `eos_token` ('<|end|>') is not found in the vocabulary of the given `processing_class` (Qwen2Tokenizer). Ensure that the `eos_token` exists in the vocabulary before using it as an EOS token.

  • Cause: SFTConfig(eos_token="<|end|>") on a tokenizer you never added the markers to (seen while preparing this tutorial).
  • Fix: call add_markers before building the trainer. authored_tokenizer does it for you (step 4).

The chat template does not include the assistant turn's end-of-turn token in the loss mask; the model may not learn to stop.

  • Cause: TRL 1.17 logs this when the end marker sits outside the block (step 9).
  • Fix: move the marker inside {% generation %}. check_trained_spans fails on the same template.

jinja2.exceptions.TemplateSyntaxError: Encountered unknown tag 'generation'.

  • Cause: the template was compiled by a plain jinja2.Environment, which does not know the tag (seen while preparing this tutorial; the message goes on to name the block Jinja was inside).
  • Fix: render with Part 3's render_template_string or build_template_env, whose GenerationExtension claims the tag.

RuntimeError: You're using `assistant_only_loss=True`, but at least one example has no assistant tokens.

  • Cause: a row whose render has no generation span, such as a conversation with no assistant turn (TRL 1.17.1 sft_trainer.py; also reported on the Hugging Face forum).
  • Fix: run check_assistant_mask on a sample of the dataset before training. For a conversation with no assistant turn it reports the conversation has no assistant turn to mask.

The served model answers, then keeps writing a new turn.

  • Cause: the end marker's ID is not in generation_config.eos_token_id (step 10), or the marker was never trained (steps 9 and 11).
  • Fix: run check_stop_alignment, then check_trained_spans on a real batch.

IndexError: index out of range in self after add_tokens.

  • Cause: a model whose embedding matrix has exactly one row per token, given an ID past the last row (forum thread). Qwen3 does not raise it, because it ships spare rows (step 5).
  • Fix: ensure_embedding_rows(model, tokenizer).

The token check reports generation prompt before message 2 differs at position 20.

  • Cause: the generation prompt writes a different header than the trained turns, here one extra newline (step 7, GEN_PROMPT_DRIFT). The model starts every answer one token away from what it learned.
  • Fix: make the add_generation_prompt branch write exactly the in-loop assistant header.

The token check prints None for a trained token.

  • Cause: the serving tokenizer does not have the added markers (step 7).
  • Fix: load the tokenizer saved with the model (step 13), not the base one.

UnicodeEncodeError: 'charmap' codec can't encode character '\u010a' in position 17: character maps to <undefined> when printing tokens on Windows.

  • Cause: Qwen3's byte-level token names, such as 'Ċ' for a newline, printed to a console that is not UTF-8 (seen while preparing this tutorial).
  • Fix: sys.stdout.reconfigure(encoding="utf-8"), as show_token_check.py does in step 7.

Where the chat template checks run in a fine-tuning pipeline

support_data.py holds the reference, TRL produces the trained row, and authoring.py checks both sides before anything is served. The numbers follow the order the pieces run in:

d2
direction: down
data: "support_data.py\nSUPPORT_CONVERSATION + SUPPORT_TEXT" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
tpl: "authoring.py: authored_tokenizer\nSUPPORT_TEMPLATE + MARKERS" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
trainer: "trainer_batch.py: build_trainer\nTRL SFTTrainer, tiny Qwen3 model" {style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}}
saved: "support-model/\nweights, template, tokenizer, generation_config" {style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}}
chk: "authoring.py: check_authored_model\nstring, markers, tokens, mask, labels, stops" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
serve: "your serving engine\nloads support-model/" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
data -> tpl: "1. reference"
tpl -> trainer: "2. tokenizer"
trainer -> saved: "3. save model, tokenizer, row"
saved -> chk: "4. reload and check"
chk -> serve: "5. ship if it passes"

check_authored_model runs on the saved folder, as in step 13, so a problem is caught before the engine ever reads it. authoring.py depends on render.py (Parts 1 to 6), messages.py (last changed in Part 7) and Part 3's jinja_env.py. It never imports TRL. Only trainer_batch.py does, so you can run the string and marker checks in an environment with no trainer installed.

Complete code: write and test a chat template for a fine-tuned model

The project after this part, with the files this part added marked:

text
chat-templates-part1/├── support-model/             (written by step 13)├── trainer-out/               (created by SFTTrainer, empty)├── templatestudio/│   ├── __init__.py│   ├── messages.py            (unchanged since Part 7)│   ├── render.py              (unchanged since Part 6)│   ├── jinja_env.py           (Part 3, unchanged)│   ├── authoring.py           (new)│   └── ... multimodal.py, tools.py, reasoning.py, anatomy.py if built├── families.py                (TINY_MODEL appended)├── support_data.py            (new)├── broken_templates.py        (new)├── trainer_batch.py           (new)├── check_trainer_versions.py  (new)├── fetch_support_models.py    (new)├── show_training_format.py    (new)├── show_support_render.py     (new)├── show_string_check.py       (new)├── show_marker_split.py       (new)├── show_embedding_rows.py     (new)├── show_trainer_row.py        (new)├── show_token_check.py        (new)├── show_mask_check.py         (new)├── show_label_check.py        (new)├── show_stop_check.py         (new)├── show_pad_trap.py           (new)├── show_pad_stop_check.py     (new)├── save_support_model.py      (new)├── check_support_model.py     (new)├── show_template_only_copy.py (new)└── ... the scripts from Parts 1 to 7

templatestudio/authoring.py:

python
# templatestudio/authoring.pyfrom __future__ import annotationsfrom collections.abc import Sequencefrom transformers import GenerationConfig, PreTrainedModel, PreTrainedTokenizerBasefrom templatestudio.jinja_env import render_template_stringfrom templatestudio.messages import Conversationfrom templatestudio.render import first_divergence, load_tokenizer, render_idsEND_OF_TURN = "<|end|>"MARKERS = ("<|system|>", "<|user|>", "<|assistant|>", END_OF_TURN)# Transcribed from the training format. The assistant's words and its <|end|># sit inside {% generation %}: the model writes them, so the loss covers them.# The role header comes from the prompt and the newline after <|end|> comes# from the template, so both stay outside.SUPPORT_TEMPLATE = (    "{% for message in messages %}"    "{{- '<|' + message['role'] + '|>\n' }}"    "{%- if message['role'] == 'assistant' %}"    "{% generation %}{{- message['content'] + '<|end|>' }}{% endgeneration %}"    "{{- '\n' }}"    "{%- else %}"    "{{- message['content'] + '<|end|>\n' }}"    "{%- endif %}"    "{%- endfor %}"    "{%- if add_generation_prompt %}{{- '<|assistant|>\n' }}{%- endif %}")def install_template(    tokenizer: PreTrainedTokenizerBase, template_source: str) -> PreTrainedTokenizerBase:    """Make template_source the tokenizer's chat template.    apply_chat_template uses it from now on, and save_pretrained writes it to    chat_template.jinja beside the tokenizer files.    """    tokenizer.chat_template = template_source    return tokenizerdef check_string_roundtrip(    template_source: str, conversation: Conversation, trained_text: str) -> list[str]:    """Compare the template's render with the exact text the model trained on."""    served = render_template_string(template_source, conversation)    at = first_divergence(served, trained_text)    if at is None:        return []    return [        f"text differs at character {at}: trained "        f"{trained_text[at:at + 12]!r}, served {served[at:at + 12]!r}"    ]def check_marker_tokens(    tokenizer: PreTrainedTokenizerBase, markers: Sequence[str] = MARKERS) -> list[str]:    """Report every marker that is not exactly one token in the vocabulary."""    problems = []    for marker in markers:        ids = tokenizer.encode(marker, add_special_tokens=False)        if len(ids) != 1:            pieces = tokenizer.convert_ids_to_tokens(ids)            problems.append(f"{marker!r} is {len(ids)} tokens: {pieces}")    return problemsdef add_markers(    tokenizer: PreTrainedTokenizerBase, markers: Sequence[str] = MARKERS) -> int:    """Add the markers as special tokens, so each encodes to one ID."""    return tokenizer.add_tokens(list(markers), special_tokens=True)def authored_tokenizer(model_id: str) -> PreTrainedTokenizerBase:    """Load a base tokenizer, add the markers, and install the template."""    tokenizer = load_tokenizer(model_id)    add_markers(tokenizer)    return install_template(tokenizer, SUPPORT_TEMPLATE)def ensure_embedding_rows(    model: PreTrainedModel, tokenizer: PreTrainedTokenizerBase) -> int:    """Grow the embedding matrix only if a token ID would fall outside it.    Returns the number of rows. Never shrinks: some models ship spare rows.    """    rows = model.get_input_embeddings().weight.shape[0]    if len(tokenizer) > rows:        model.resize_token_embeddings(len(tokenizer))        rows = len(tokenizer)    return rowsdef check_token_roundtrip(    tokenizer: PreTrainedTokenizerBase,    conversation: Conversation,    trained_ids: Sequence[int],) -> list[str]:    """Compare the served template's token IDs with the IDs the trainer used:    the whole conversation, then the generation prompt before each answer."""    served = render_ids(tokenizer, conversation, add_generation_prompt=False)    trained = [int(i) for i in trained_ids]    at = first_divergence(served, trained)    if at is not None:        window = slice(max(at - 2, 0), at + 3)        return [            f"tokens differ at position {at} "            f"(served {len(served)} ids, trained {len(trained)})",            f"served:  {tokenizer.convert_ids_to_tokens(served[window])}",            f"trained: {tokenizer.convert_ids_to_tokens(trained[window])}",        ]    problems = []    for k, message in enumerate(conversation):        if message["role"] != "assistant":            continue        prompt = render_ids(tokenizer, conversation[:k], add_generation_prompt=True)        at = first_divergence(prompt, trained[: len(prompt)])        if at is not None:            problems.append(                f"generation prompt before message {k} differs at position {at}: "                f"{tokenizer.convert_ids_to_tokens(prompt[at:at + 2])}"            )    return problemsdef check_assistant_mask(    tokenizer: PreTrainedTokenizerBase,    conversation: Conversation,    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check that each masked span is one assistant turn plus end_of_turn."""    encoded = tokenizer.apply_chat_template(        conversation, return_assistant_tokens_mask=True    )    ids, mask = encoded["input_ids"], encoded["assistant_masks"]    spans: list[list[int]] = []    current: list[int] = []    for token_id, flag in zip(ids, mask):        if flag:            current.append(token_id)        elif current:            spans.append(current)            current = []    if current:        spans.append(current)    turns = [m["content"] for m in conversation if m["role"] == "assistant"]    if not turns:        return ["the conversation has no assistant turn to mask"]    if not spans:        return ["the mask is all zeros: the template has no {% generation %}"]    if len(spans) != len(turns):        return [f"{len(spans)} masked spans for {len(turns)} assistant turns"]    problems = []    for i, (span, content) in enumerate(zip(spans, turns)):        expected = tokenizer.encode(content + end_of_turn, add_special_tokens=False)        if span != expected:            problems.append(                f"span {i} is {tokenizer.decode(span)!r}, "                f"not {content + end_of_turn!r}"            )    return problemsdef check_trained_spans(    tokenizer: PreTrainedTokenizerBase,    labels: Sequence[int],    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check one row of real labels: every trained run ends on end_of_turn."""    end_id = tokenizer.convert_tokens_to_ids(end_of_turn)    problems = []    for i, label in enumerate(labels):        trained = label != -100        last_of_run = trained and (i + 1 == len(labels) or labels[i + 1] == -100)        if last_of_run and label != end_id:            text = tokenizer.decode([label])            problems.append(f"trained span ends at position {i} on {text!r}")    if all(label == -100 for label in labels):        problems.append("no position is trained")    return problemsdef check_stop_alignment(    tokenizer: PreTrainedTokenizerBase,    generation_config: GenerationConfig,    end_of_turn: str = END_OF_TURN,) -> list[str]:    """Check that generation stops on end_of_turn, and that padding cannot    mask it out of the loss."""    ids = tokenizer.encode(end_of_turn, add_special_tokens=False)    if len(ids) != 1:        return [f"{end_of_turn!r} is {len(ids)} tokens, so it cannot be a stop ID"]    end_id = ids[0]    stops = generation_config.eos_token_id    stops = [stops] if isinstance(stops, int) else list(stops or [])    problems = []    if end_id not in stops:        problems.append(            f"{end_of_turn!r} (id {end_id}) is not in eos_token_id {stops}"        )    if tokenizer.pad_token_id == end_id:        problems.append(            f"pad_token is {end_of_turn!r}: a pad-ID collator masks it from the loss"        )    return problemsdef check_authored_model(    tokenizer: PreTrainedTokenizerBase,    generation_config: GenerationConfig,    conversation: Conversation,    trained_text: str,    trained_row: dict[str, list[int]],) -> dict[str, list[str]]:    """Run every check against one saved model. Empty lists mean it passes."""    template = tokenizer.chat_template    return {        "string": check_string_roundtrip(template, conversation, trained_text),        "markers": check_marker_tokens(tokenizer),        "tokens": check_token_roundtrip(            tokenizer, conversation, trained_row["input_ids"]        ),        "mask": check_assistant_mask(tokenizer, conversation),        "labels": check_trained_spans(tokenizer, trained_row["labels"]),        "stops": check_stop_alignment(tokenizer, generation_config),    }

support_data.py:

python
# support_data.py# One training example for a support model, as messages and as the exact text# the fine-tuning data was formatted into. Every turn ends with <|end|>.SUPPORT_CONVERSATION = [    {"role": "system", "content": "You are a support agent."},    {"role": "user", "content": "Where is my order?"},    {"role": "assistant", "content": "Let me check."},    {"role": "user", "content": "It is order 1042."},    {"role": "assistant", "content": "Order 1042 ships today."},]SUPPORT_TEXT = (    "<|system|>\nYou are a support agent.<|end|>\n"    "<|user|>\nWhere is my order?<|end|>\n"    "<|assistant|>\nLet me check.<|end|>\n"    "<|user|>\nIt is order 1042.<|end|>\n"    "<|assistant|>\nOrder 1042 ships today.<|end|>\n")

trainer_batch.py:

python
# trainer_batch.pyfrom datasets import Dataset, disable_progress_barsfrom transformers import AutoModelForCausalLM, PreTrainedTokenizerBasefrom transformers.utils import logging as transformers_loggingfrom trl import SFTConfig, SFTTrainerfrom families import TINY_MODELfrom templatestudio.authoring import END_OF_TURN, ensure_embedding_rowsdisable_progress_bars()transformers_logging.disable_progress_bar()def build_trainer(    tokenizer: PreTrainedTokenizerBase, conversations: list[list[dict]]) -> SFTTrainer:    """Build an SFTTrainer the way a fine-tune would, without training."""    model = AutoModelForCausalLM.from_pretrained(TINY_MODEL)    ensure_embedding_rows(model, tokenizer)    config = SFTConfig(        output_dir="trainer-out",        assistant_only_loss=True,        eos_token=END_OF_TURN,        use_cpu=True,        bf16=False,        report_to="none",    )    dataset = Dataset.from_list([{"messages": c} for c in conversations])    return SFTTrainer(        model=model, args=config, train_dataset=dataset, processing_class=tokenizer    )

broken_templates.py:

python
# broken_templates.py# Copies of SUPPORT_TEMPLATE with one mistake each, for the failing cases.from templatestudio.authoring import SUPPORT_TEMPLATE# The serving copy someone edited: one extra newline after every role header.DRIFTED = SUPPORT_TEMPLATE.replace("'|>\n'", "'|>\n\n'")# The generation prompt writes one more newline than the trained turns have.GEN_PROMPT_DRIFT = SUPPORT_TEMPLATE.replace(    "{{- '<|assistant|>\n' }}{%- endif %}",    "{{- '<|assistant|>\n\n' }}{%- endif %}",)# <|end|> moved out of the generation block: the model never learns to stop.END_OUTSIDE = SUPPORT_TEMPLATE.replace(    "message['content'] + '<|end|>' }}{% endgeneration %}",    "message['content'] }}{% endgeneration %}{{- '<|end|>' }}",)

The end of families.py, after Part 7's VISION_MODELS:

python
# Part 8: a 2-layer test model from the TRL team that uses Qwen3's tokenizer.TINY_MODEL = "trl-internal-testing/tiny-Qwen3ForCausalLM"

messages.py is unchanged and listed in Part 7's complete code, together with families.py, which this part extends only by the TINY_MODEL line in Prerequisites. render.py is listed in Part 6's complete code. jinja_env.py is listed in Part 3's complete code. The scripts are listed in full in the steps that create them.

Next steps: train the new rows, test the tool path, and check a real fine-tune

  • Check that the new rows train. The rows for IDs 151669 to 151672 are spare padding rows that nothing has trained. Under LoRA they stay that way for the whole run unless the embedding matrix and lm_head are trainable, and a model cannot learn to write a token whose output row never moves. Copy model.lm_head.weight before a short training run, compare it after, and fail if the <|end|> row did not change. PEFT's modules_to_save and trainable_token_indices are the two ways to make the rows trainable.
  • Test the tool path. Extend SUPPORT_TEMPLATE to render a tool call inside {% generation %} and a tool result outside it, using Part 5's message shapes. Then run all six checks on a conversation with a tool call. If you test only the text path, a broken tool path ships unnoticed.
  • Run the token and label checks on your real trainer. Take one batch's input_ids and labels from the trainer and collator you will run, whether that is TRL, Axolotl or Unsloth. Run check_token_roundtrip and check_trained_spans on them in CI before every training job. Axolotl's chat_template, chat_template_jinja and eot_tokens settings, and Unsloth's get_chat_template and train_on_responses_only, are the settings most likely to make its row differ from yours. For a trainer other than TRL, extend check_trained_spans to require one run per assistant turn, each starting where render_ids(..., add_generation_prompt=True) for that turn ends, so a trained header or a trained user turn fails too.

The Chat Templates Handbook, the book this series accompanies, covers authoring in chapter 8. That includes the training-format contract in full, for trainers whose training template legitimately differs from the serving one, how TRL, Axolotl, LLaMA-Factory and Unsloth each choose a template, and the untrained-row check under LoRA. The companion repository's v1.2/ch08-authoring tag holds the chapter's own authoring.py. Part 9 turns to the cross-engine problem: why a template that passes every check here can render differently on vLLM or llama.cpp.

References


Llms

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Federated AI Agents cover

Federated AI Agents

Right-Sized AI cover

Right-Sized AI

Harness Engineering for Production AI Systems cover

Harness Engineering

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments