Chat Templates, Masking and Stop Tokens

By Syncfusion®, Inc. · Last updated

The formatting bugs that break real fine-tunes: chat templates, loss masking, end-of-turn tokens and max sequence length, with real outputs from a tiny model.

Chat Templates, Masking and Stop Tokens

The team's first real fine-tune finishes. The loss curve looks perfect. They send it one ticket, and the answer is the right JSON, followed by a second copy, then half a third, until it hits the token limit.

Nothing was wrong with the learning rate or the data. The bug was in how the examples were formatted for the model.

Here is the course's tiny model, trained five different ways on the same data. Every output below is real.

The correct run has a validation loss of 0.066. The run without an end token has 0.085, almost the same, yet it never stops. The template-mismatch run has exactly 0.066, and its answers are garbled.

Simple lesson: a good loss curve does not prove the model works. Formatting bugs hide from it.

A Real-World Example

Think about training someone to fill in a form, where every practice form you give them has the last line torn off.

They learn the fields perfectly. But they have never seen where a form ends, so they keep writing past the bottom of the page.

Or you train them on blue forms and then hand them a green one with the boxes in different places. They knew the job, just not that layout.

Simple lesson: practise on exactly the layout you will use for real, including the ending.

What You Will Learn

In this lesson, you will learn:

  1. What a chat template is, and why you should never write one by hand.
  2. What loss masking is, and why you train on the answer only.
  3. Why every answer needs an end-of-turn token.
  4. How max_seq_length can cut answers in training.
  5. Which of these bugs the loss curve can and cannot show you.

Before you start

  • Token = a small piece of text the model reads and writes. Special tokens like <|im_end|> mark structure, such as the end of a turn.
  • The outputs in this lesson come from the course's tiny model, trained each wrong way by a build script. You can repeat every one in the Fine-tune Lab's Live fine-tune tab with the Formatting trap setting.

TL;DR

Chat template = the exact way a model family wraps system, user and assistant messages in special tokens. Use the tokenizer's apply_chat_template.

Loss masking = compute the loss on the assistant's answer only, not on the prompt.

End-of-turn token = the token after each answer that teaches the model to stop.

max_seq_length = the longest example, in tokens, the trainer keeps. Anything longer is cut.

Check all four with check_template.py before you train.

What the Model Really Sees

Here is the same idea in code. The template wraps each message, and only the part after assistant is trained on.

Render a chat template and find the trained part

type Msg = { role: "system" | "user" | "assistant"; content: string };

// A ChatML-style template, the kind some model families use.
// Real code: tokenizer.apply_chat_template(messages). Never hand-write it.
function render(messages: Msg[]): { text: string; trainedFrom: number } {
  let text = "";
  let trainedFrom = -1;
  for (const m of messages) {
    text += `<|im_start|>${m.role}\n`;
    if (m.role === "assistant") trainedFrom = text.length;
    text += `${m.content}<|im_end|>\n`;
  }
  return { text, trainedFrom };
}

const { text, trainedFrom } = render([
  { role: "system", content: "Reply with JSON only." },
  { role: "user", content: "Cancel order 2518" },
  { role: "assistant", content: '{"intent":"cancel","order":"2518"}' },
]);
console.log(text.trimEnd());
console.log("---");
console.log("context (masked):", trainedFrom, "characters");
console.log("trained on:", JSON.stringify(text.slice(trainedFrom).trimEnd()));

Render a chat template and find the trained part

using System.Text.Json;
using System.Text.Encodings.Web;

// A ChatML-style template, the kind some model families use.
// Real code: tokenizer.apply_chat_template(messages). Never hand-write it.
(string text, int trainedFrom) Render(List<Msg> messages)
{
    var text = "";
    var trainedFrom = -1;
    foreach (var m in messages)
    {
        text += $"<|im_start|>{m.Role}\n";
        if (m.Role == "assistant") trainedFrom = text.Length;
        text += $"{m.Content}<|im_end|>\n";
    }
    return (text, trainedFrom);
}

var (text, trainedFrom) = Render(new List<Msg>
{
    new("system", "Reply with JSON only."),
    new("user", "Cancel order 2518"),
    new("assistant", "{\"intent\":\"cancel\",\"order\":\"2518\"}"),
});
Console.WriteLine(text.TrimEnd());
Console.WriteLine("---");
Console.WriteLine($"context (masked): {trainedFrom} characters");
var json = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
Console.WriteLine("trained on: " + JsonSerializer.Serialize(text[trainedFrom..].TrimEnd(), json));

record Msg(string Role, string Content);

Expected output:

<|im_start|>system
Reply with JSON only.<|im_end|>
<|im_start|>user
Cancel order 2518<|im_end|>
<|im_start|>assistant
{"intent":"cancel","order":"2518"}<|im_end|>
---
context (masked): 118 characters
trained on: "{\"intent\":\"cancel\",\"order\":\"2518\"}<|im_end|>"

The model trains on the JSON and the <|im_end|> after it. That end token is what makes generation stop.

Prompt/completion format

  • Many trainers accept examples as a prompt (the messages before the answer) and a completion (the answer).
  • With that format the loss is computed on the completion only, which is the masking you want.
  • The starter kit's train_qlora.py converts each chat line to prompt/completion for exactly this reason.

The Four Traps

TrapWhat you seeLoss curve warns you?Fix
No end tokenCorrect JSON, then it keeps goingNoTemplate adds the end-of-turn token after every answer
No loss maskingWandering answers, prompt pieces in the outputYes, loss is highTrain on the completion only
Template mismatchGarbled answersNoServe with the tokenizer's chat template
max_seq_length too shortEvery answer stops earlyYes, loss is highMeasure token lengths, raise the limit

Measure Token Lengths First

Answers get cut when examples are longer than max_seq_length. The fix is to measure before training.

How many examples a limit would cut

// Token lengths of 20 training examples (check_template.py prints these for real).
const lengths = [212, 240, 188, 301, 655, 274, 199, 530, 233, 260, 1210, 245, 318, 287, 702, 221, 264, 296, 241, 512];

for (const limit of [512, 1024, 2048]) {
  const cut = lengths.filter((n) => n > limit).length;
  console.log(`max_seq_length ${String(limit).padStart(4)}: ${cut} example(s) cut`);
}
const sorted = [...lengths].sort((a, b) => a - b);
console.log(`median ${sorted[Math.floor(sorted.length / 2)]}, longest ${sorted[sorted.length - 1]}`);

How many examples a limit would cut

// Token lengths of 20 training examples (check_template.py prints these for real).
var lengths = new[] { 212, 240, 188, 301, 655, 274, 199, 530, 233, 260, 1210, 245, 318, 287, 702, 221, 264, 296, 241, 512 };

foreach (var limit in new[] { 512, 1024, 2048 })
{
    var cut = lengths.Count(n => n > limit);
    Console.WriteLine($"max_seq_length {limit,4}: {cut} example(s) cut");
}
var sorted = lengths.OrderBy(n => n).ToArray();
Console.WriteLine($"median {sorted[sorted.Length / 2]}, longest {sorted[^1]}");

Expected output:

max_seq_length  512: 4 example(s) cut
max_seq_length 1024: 1 example(s) cut
max_seq_length 2048: 0 example(s) cut
median 274, longest 1210

With a limit of 512, 4 of 20 examples would be cut. A cut answer teaches the model to stop mid-JSON. In the gallery, the run trained on cut answers ended with a validation loss of 2.516, because the full validation answers kept going where it had learned to stop.

Run check_template.py before every first run.

It prints one example exactly as the model sees it, shows the token after the answer, checks that the serving prompt is a prefix of the training text, and counts examples over the limit. Ten seconds of checking saves a wasted GPU run.

python check_template.py

What to ask before the first GPU run

  • Did someone print one formatted example and look at it?
  • Is the loss on the answer only?
  • Does every answer end with the end-of-turn token?
  • How many examples exceed max_seq_length? The answer should be zero, or a known few.
  • Is the serving code using the same template? Ask to see the line that builds the prompt.

Do's and Don'ts

  • Do use the tokenizer's chat template for training and serving.
  • Do train on the answer only.
  • Do check the end-of-turn token after every answer.
  • Do measure token lengths before choosing max_seq_length.
  • Don't hand-write templates with string concatenation.
  • Don't trust a low loss as proof the outputs are right.
  • Don't change the serving prompt without re-running the eval.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Hand-written template"It is just some tags around the text."Use the tokenizer's chat template.Training and serving disagree by one token, and outputs break.
No end token"The model knows when the JSON closes."Train the end-of-turn token after each answer.The model writes until max_new_tokens runs out.
Loss on the prompt"More tokens to learn from."Mask the prompt; learn the answer.Capacity wasted on copying customer messages.
Guessed max length"512 is a common default."Measure lengths first.Long examples are cut and teach early stopping.
Trusting the loss"Loss is 0.07, ship it."Read real outputs too.A model that never stops reaches production.

Check Your Understanding

Exercises

What to Remember

  • Use the tokenizer's chat template, the same for training and serving.
  • Train on the answer only, and always include the end-of-turn token.
  • Measure token lengths before setting max_seq_length.
  • Two of these bugs hide from the loss curve: read real outputs.
  • Run check_template.py before every first GPU run.

Next: Run a Real Fine-tune, the starter kit from data to score, on a free GPU.