<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jessie Jia</title>
    <description>The latest articles on DEV Community by Jessie Jia (@jessiejia11).</description>
    <link>https://dev.to/jessiejia11</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4075567%2Fba4a6e29-2db3-4397-8fe6-722c8b27109e.jpg</url>
      <title>DEV Community: Jessie Jia</title>
      <link>https://dev.to/jessiejia11</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jessiejia11"/>
    <language>en</language>
    <item>
      <title>Speculative Decoding and MTP: Why Guessing Is Free</title>
      <dc:creator>Jessie Jia</dc:creator>
      <pubDate>Thu, 20 Aug 2026 15:32:51 +0000</pubDate>
      <link>https://dev.to/jessiejia11/speculative-decoding-and-mtp-why-guessing-is-free-4p36</link>
      <guid>https://dev.to/jessiejia11/speculative-decoding-and-mtp-why-guessing-is-free-4p36</guid>
      <description>&lt;p&gt;I saw "MTP round-trip" on a checklist for a Megatron conversion pipeline and had no idea what it meant. Two acronyms, one hyphen, apparently important enough that someone had listed it as a thing to verify.&lt;/p&gt;

&lt;p&gt;Working out what it meant took me somewhere I didn't expect. The interesting part turned out not to be MTP at all — it was the reason speculative decoding works in the first place, which rests on a fact about hardware that I had backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generating text is slow because it's sequential: one full forward pass per token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But a forward pass over five tokens costs about the same as over one.&lt;/strong&gt; Generation is bottlenecked by moving weights, not by arithmetic.&lt;/li&gt;
&lt;li&gt;Speculative decoding exploits that: something cheap drafts &lt;em&gt;k&lt;/em&gt; tokens, the big model verifies all of them in one pass.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;exact&lt;/strong&gt;, not an approximation. Same output distribution as normal decoding.&lt;/li&gt;
&lt;li&gt;MTP (Multi-Token Prediction) is one way to produce those drafts — a small module trained into the model itself.&lt;/li&gt;
&lt;li&gt;MTP has two separate lives: a training-time auxiliary loss you can throw away, and an inference-time draft head you can't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why generating text is slow
&lt;/h2&gt;

&lt;p&gt;To produce token N+1, the model needs token N. There's no way around that ordering — it's what "language model" means.&lt;/p&gt;

&lt;p&gt;So generating 100 tokens means 100 full passes through the network. For a model like GLM-5.2, that's 78 layers, 100 times over.&lt;/p&gt;

&lt;p&gt;The obvious conclusion is that generation is 100 times as expensive as reading the prompt. The obvious conclusion is wrong, and the way it's wrong is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that got me
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A forward pass processing &lt;strong&gt;one&lt;/strong&gt; token and a forward pass processing &lt;strong&gt;five&lt;/strong&gt; tokens take roughly the same wall-clock time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had assumed compute scaled with tokens. It doesn't, because compute isn't the bottleneck.&lt;/p&gt;

&lt;p&gt;Every forward pass has to read the model's weights out of memory and into the compute units. That's hundreds of gigabytes moving across a memory bus, and it happens &lt;em&gt;whether you're processing one token or fifty&lt;/em&gt;. The actual arithmetic on a handful of tokens is rounding error next to the cost of fetching the weights to do it with.&lt;/p&gt;

&lt;p&gt;So the accounting looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;weight reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 tokens, one at a time&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 tokens, in a single pass&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same tokens, same math, five times the memory traffic — purely because of the ordering.&lt;/p&gt;

&lt;p&gt;That ratio is the prize. If you could somehow process five tokens at once, you'd get them roughly five times faster. You can't, because token 3 depends on token 2.&lt;/p&gt;

&lt;p&gt;Unless you guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speculative decoding: draft, then verify
&lt;/h2&gt;

&lt;p&gt;The trick is to split generation into two roles.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Something &lt;strong&gt;cheap&lt;/strong&gt; drafts &lt;em&gt;k&lt;/em&gt; tokens ahead. These are guesses.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;big model&lt;/strong&gt; does one forward pass over all &lt;em&gt;k&lt;/em&gt; positions at once, checking each guess.&lt;/li&gt;
&lt;li&gt;Accept the longest correct prefix. Throw away the rest.&lt;/li&gt;
&lt;li&gt;Repeat.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is the whole thing, and it hinges on a property that's easy to miss: &lt;strong&gt;verification is parallel even though generation isn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The sequential dependency exists because you don't know token 3 until you've produced token 2. But in verification you already &lt;em&gt;have&lt;/em&gt; candidate tokens 1 through &lt;em&gt;k&lt;/em&gt; — the drafter handed them over. So you can lay them all out and check them in a single pass. There's nothing left to wait for.&lt;/p&gt;

&lt;p&gt;Guess three tokens correctly and you've produced four tokens for the price of one weight read. Guess wrong on the first one and you fall back to producing one token, which is what you'd have done anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's exact, not approximate
&lt;/h2&gt;

&lt;p&gt;This is the part I assumed had a catch, and it doesn't.&lt;/p&gt;

&lt;p&gt;Speculative decoding produces &lt;strong&gt;the same output distribution as ordinary decoding&lt;/strong&gt;. It is not a quality-for-speed trade. Every token in the final output is one the big model itself endorsed — the drafter's guesses are only ever suggestions, and any suggestion the big model wouldn't have made is rejected.&lt;/p&gt;

&lt;p&gt;The acceptance test is designed so that the surviving tokens are distributed exactly as if the big model had generated them alone. So there's no accuracy knob to tune and no quality regression to monitor. Either it's faster or it isn't.&lt;/p&gt;

&lt;p&gt;The only cost of a bad drafter is wasted drafting work. Which means the metric that matters is &lt;strong&gt;acceptance rate&lt;/strong&gt;: what fraction of guesses survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the drafts come from
&lt;/h2&gt;

&lt;p&gt;Several options, and this is where MTP finally enters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Draft source&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A separate small model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A 1B model drafts for a 70B one&lt;/td&gt;
&lt;td&gt;Train, ship and serve a second model; tokenizers must match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;N-gram / lookup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Copy candidates straight from the context&lt;/td&gt;
&lt;td&gt;Free, but only helps when output repeats input — code editing, summarisation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MTP head&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A small module trained into the main model&lt;/td&gt;
&lt;td&gt;One extra layer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The separate-model approach came first and has an awkward property: the two models were trained independently, so they don't necessarily think alike. When the drafter's instincts diverge from the big model's, acceptance rate falls, and a drafter whose guesses get rejected is pure overhead.&lt;/p&gt;

&lt;p&gt;Which motivates building the drafter &lt;em&gt;into&lt;/em&gt; the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MTP actually is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multi-Token Prediction.&lt;/strong&gt; GLM-5.2's config says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"num_hidden_layers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"num_nextn_predict_layers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;78 layers of main model, plus &lt;strong&gt;one extra module&lt;/strong&gt; whose job is to predict the token after next. That module is MTP.&lt;/p&gt;

&lt;p&gt;Two properties make it a good drafter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It agrees with the main model,&lt;/strong&gt; because it was trained alongside it and sits on top of the same internal representations. Its guesses are the guesses the main model would plausibly make, which is exactly what acceptance rate rewards. &lt;a href="https://arxiv.org/abs/2412.19437" rel="noopener noreferrer"&gt;DeepSeek-V3&lt;/a&gt;, which uses the same design, reports 85–90% acceptance on the next token and around 1.8× end-to-end throughput.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's cheap,&lt;/strong&gt; because it reuses the main model's already-computed hidden states. Drafting costs one extra layer, not a second model's worth of forward pass.&lt;/p&gt;

&lt;p&gt;And there's no second checkpoint to version, deploy or keep in sync. The drafter ships inside the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  MTP has two separate lives
&lt;/h2&gt;

&lt;p&gt;This is the distinction I'd have got wrong if I hadn't looked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At training time&lt;/strong&gt;, MTP is an auxiliary loss. Forcing the model to predict two tokens ahead rather than one pushes it toward representations that carry more forward-looking information, which improves the main model. Used this way, &lt;strong&gt;MTP is disposable&lt;/strong&gt; — you can drop the module when training finishes and keep the benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At inference time&lt;/strong&gt;, MTP is the draft head. Used this way, &lt;strong&gt;you have to keep it&lt;/strong&gt;, or you lose the speedup.&lt;/p&gt;

&lt;p&gt;Same weights, two unrelated reasons to care about them. Whether you need MTP to survive your pipeline depends entirely on which of these you're after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the checklist
&lt;/h2&gt;

&lt;p&gt;Which finally explains why "MTP round-trip" is its own line item.&lt;/p&gt;

&lt;p&gt;A training pipeline converts the model between formats: HuggingFace to Megatron to train, Megatron back to HuggingFace to serve. The round-trip is that loop, and the question is whether the MTP module comes out the other side intact.&lt;/p&gt;

&lt;p&gt;The reason it needs its own check is that &lt;strong&gt;losing it is silent&lt;/strong&gt;. MTP isn't on the main forward path — it doesn't affect what the model says, only how fast it says it. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Conversion completes without error&lt;/li&gt;
&lt;li&gt;Forward-parity tests pass&lt;/li&gt;
&lt;li&gt;Training runs, loss curve looks normal&lt;/li&gt;
&lt;li&gt;Export succeeds&lt;/li&gt;
&lt;li&gt;Serving works, at half the throughput, with nothing in the logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I looked at the verification script in the pipeline that prompted all this. Step 5 checks the HuggingFace to Megatron weight mapping by running both implementations on the same input and comparing outputs — cosine 0.9936, a clean pass. It also contains this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_nextn_predict_layers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;hc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_nextn_predict_layers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MTP is switched off for the test. Which is the right call for what that test is &lt;em&gt;for&lt;/em&gt; — it isolates the main trunk so a mismatch points somewhere specific. But it means the verified mapping says nothing about MTP, and a forward-parity check couldn't have caught the omission anyway, because MTP doesn't touch the forward path being compared.&lt;/p&gt;

&lt;p&gt;The verification you'd actually want is different in kind: not "does the model still behave correctly" but "is every weight still here". Enumerate the tensors before and after, compare names and shapes, and require the numbers to match &lt;strong&gt;exactly&lt;/strong&gt; — a format conversion does no arithmetic, so there's no floating-point slack to allow for. Closer to an inventory than a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;I went in thinking MTP was the topic and speculative decoding was context. It's the reverse: speculative decoding is the idea, and MTP is one implementation of one of its parts.&lt;/p&gt;

&lt;p&gt;The thing worth carrying, though, is the hardware fact underneath. &lt;strong&gt;The cost of a forward pass barely depends on how many tokens are in it.&lt;/strong&gt; Once that lands, speculative decoding stops looking like a clever trick and starts looking obvious — you have four free seats in the car, so you may as well guess who's coming.&lt;/p&gt;

&lt;p&gt;It also rhymes with something I ran into &lt;a href="https://jessie-jia.com/article/littles-law-vllm-autoscaling/2026/08/07/" rel="noopener noreferrer"&gt;autoscaling vLLM replicas&lt;/a&gt;: the intuitive cost model was wrong in a way that only shows up when you check what the hardware is actually doing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;中文版：&lt;a href="https://jessie-jia.com/article/speculative-decoding-and-mtp-zh/2026/08/19/" rel="noopener noreferrer"&gt;投机解码与 MTP：为什么"猜"是免费的&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>technical</category>
      <category>inference</category>
      <category>mtp</category>
    </item>
    <item>
      <title>What Actually Gets Tokenized in SFT</title>
      <dc:creator>Jessie Jia</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:17:00 +0000</pubDate>
      <link>https://dev.to/jessiejia11/what-actually-gets-tokenized-in-sft-jbo</link>
      <guid>https://dev.to/jessiejia11/what-actually-gets-tokenized-in-sft-jbo</guid>
      <description>&lt;p&gt;&lt;code&gt;tok.apply_chat_template(msgs, add_generation_prompt=True)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I have copied that line more times than I can count without thinking about what any of it does. The official example writes it that way, the output looks right, you move on.&lt;/p&gt;

&lt;p&gt;Then you have to build your own SFT data and compute your own loss mask, and it turns out every piece of that line is load-bearing. I spent today pulling it apart. Notes below.&lt;/p&gt;

&lt;p&gt;The counterintuitive part first: the model has no idea &lt;code&gt;messages&lt;/code&gt; exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The model only ever sees the rendered string. &lt;code&gt;messages&lt;/code&gt; is for Python. Roles are real tokens in the vocabulary.&lt;/li&gt;
&lt;li&gt;A chat template is a Jinja2 program, stored in &lt;code&gt;tokenizer_config.json&lt;/code&gt; or as a standalone &lt;code&gt;chat_template.jinja&lt;/code&gt;. When both exist the file wins.&lt;/li&gt;
&lt;li&gt;Training renders the full conversation with &lt;code&gt;add_generation_prompt=False&lt;/code&gt;. Inference renders &lt;code&gt;msgs[:-1]&lt;/code&gt; with &lt;code&gt;add_generation_prompt=True&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The prefixes those two produce must be identical down to the byte. One assert catches it.&lt;/li&gt;
&lt;li&gt;Don't let the tokenizer add special tokens after templating, or you get two BOS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The model has no idea &lt;code&gt;messages&lt;/code&gt; exists
&lt;/h2&gt;

&lt;p&gt;I had some vague picture in my head where the role was structured metadata and something inside the model read it. Nope.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What is the Young&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s modulus of graphene?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That list lives in your Python process and nowhere else. What reaches the model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[gMASK]&amp;lt;sop&amp;gt;&amp;lt;|user|&amp;gt;
What is the Young's modulus of graphene?&amp;lt;|assistant|&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and after tokenization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;151331&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;151333&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;151336&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;198&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ids...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;151337&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;198&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those leading numbers are GLM-4's ids for &lt;code&gt;[gMASK]&lt;/code&gt;, &lt;code&gt;&amp;lt;sop&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;|user|&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;|assistant|&amp;gt;&lt;/code&gt;. Every model family has its own, so don't hardcode them across models.&lt;/p&gt;

&lt;p&gt;The role is a token, not a field. The model knows it is its turn to talk because it sees id &lt;code&gt;151337&lt;/code&gt;, the same way it learns any other pattern. No JSON at inference time, no key lookup, no schema. One sequence.&lt;/p&gt;

&lt;p&gt;Nearly everything below follows from that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jinja2, the rendering layer
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://jinja.palletsprojects.com/" rel="noopener noreferrer"&gt;Jinja2&lt;/a&gt; is a text templating engine originally built to render HTML for Flask. Three constructs, that's it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ expr }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;print the value of an expression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{% ... %}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;control flow: &lt;code&gt;for&lt;/code&gt;, &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;set&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{# ... #}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;comment, produces no output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HuggingFace borrowed it for one job: flatten structured messages into the string the model was trained on, and flatten it the same way every time. The minimal shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;messages&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;|'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'|&amp;gt;\n'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;|endoftext|&amp;gt;\n'&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;add_generation_prompt&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;|assistant|&amp;gt;\n'&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The dashes aren't style
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;{%-&lt;/code&gt; and &lt;code&gt;-%}&lt;/code&gt; strip whitespace around the tag. Real templates are covered in them.&lt;/p&gt;

&lt;p&gt;I assumed this was a formatting preference. It isn't. Templates get indented so humans can read them, and without stripping, every newline and indent in the source lands verbatim in the prompt. One extra &lt;code&gt;\n&lt;/code&gt; and the token sequence no longer matches what training saw.&lt;/p&gt;

&lt;p&gt;If you write your own, print the result with &lt;code&gt;repr()&lt;/code&gt; rather than trusting your eyes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's in scope inside a template
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;transformers&lt;/code&gt; renders in a &lt;code&gt;jinja2.sandbox.ImmutableSandboxedEnvironment&lt;/code&gt;, so arbitrary attribute access and side effects are blocked. You can't call random Python from in there. What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;messages&lt;/code&gt;, plus &lt;code&gt;tools&lt;/code&gt; when tool definitions are passed&lt;/li&gt;
&lt;li&gt;&lt;code&gt;add_generation_prompt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bos_token&lt;/code&gt;, &lt;code&gt;eos_token&lt;/code&gt; and friends, from the tokenizer&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;raise_exception()&lt;/code&gt;, for templates that reject malformed conversations, like a system message in the wrong slot&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;strftime_now()&lt;/code&gt;, for templates that inject the current date&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the template lives
&lt;/h2&gt;

&lt;p&gt;Two places, same string.&lt;/p&gt;

&lt;p&gt;The old way is inline in &lt;code&gt;tokenizer_config.json&lt;/code&gt;. That's the file &lt;code&gt;AutoTokenizer.from_pretrained()&lt;/code&gt; reads, and note it does not hold the vocabulary. That's in &lt;code&gt;tokenizer.json&lt;/code&gt;, or &lt;code&gt;vocab.json&lt;/code&gt; plus &lt;code&gt;merges.txt&lt;/code&gt;, or a SentencePiece &lt;code&gt;*.model&lt;/code&gt;. It only records how to construct the tokenizer object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tokenizer_class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PreTrainedTokenizerFast"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model_max_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;131072&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"padding_side"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bos_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[gMASK]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eos_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;|endoftext|&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pad_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;|endoftext|&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"clean_up_tokenization_spaces"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"added_tokens_decoder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"151329"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;|endoftext|&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"special"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"normalized"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"151336"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;|user|&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"special"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"normalized"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chat_template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{%- for message in messages %}..."&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// ← inline, one giant line&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For SFT work, a few fields are worth a look.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;added_tokens_decoder&lt;/code&gt; decides whether &lt;code&gt;&amp;lt;|user|&amp;gt;&lt;/code&gt; is one atomic token or gets shredded into BPE pieces. Shredded, the role marker stops being a clean signal and the model has to infer the boundary from "&lt;code&gt;&amp;lt;&lt;/code&gt; then &lt;code&gt;|&lt;/code&gt; then &lt;code&gt;user&lt;/code&gt;". It can learn that. No reason to spend the capacity.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;padding_side&lt;/code&gt; is right for training and must be left for batched generation. In a right-padded generation batch every sequence shorter than the longest one comes out garbage.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eos_token&lt;/code&gt; and &lt;code&gt;pad_token&lt;/code&gt; govern both where generation stops and which positions get carved out of the loss.&lt;/p&gt;

&lt;p&gt;The new way is a standalone &lt;code&gt;chat_template.jinja&lt;/code&gt;, which newer &lt;code&gt;transformers&lt;/code&gt; versions write by default from &lt;code&gt;save_pretrained()&lt;/code&gt;. The reason is mundane: inside JSON every newline is &lt;code&gt;\n&lt;/code&gt; and every quote is escaped, so a 200-line template becomes one unreadable line with no diff and no highlighting.&lt;/p&gt;

&lt;p&gt;The file wins on precedence. Plenty of repos ship both for backward compatibility, which leaves a trap. Once the two drift apart, behavior depends on your &lt;code&gt;transformers&lt;/code&gt; version, and that is a miserable thing to track down. Check they agree before you debug anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  add_generation_prompt: training vs inference
&lt;/h2&gt;

&lt;p&gt;This is where I actually got stuck today. One SFT sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What is the Young&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s modulus of graphene?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;About 1 TPa.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;At training time you have the answer.&lt;/strong&gt; That's the supervision signal, so you render the whole thing with no generation prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_generation_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;|user|&amp;gt;
What is the Young's modulus of graphene?&amp;lt;|assistant|&amp;gt;
About 1 TPa.&amp;lt;|endoftext|&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting it to &lt;code&gt;True&lt;/code&gt; would append a second, empty &lt;code&gt;&amp;lt;|assistant|&amp;gt;\n&lt;/code&gt; after the answer. A dangling role marker, which the model would learn to emit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At inference you don't have the answer.&lt;/strong&gt; Producing it is the point, so you pass &lt;code&gt;msgs[:-1]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;add_generation_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;|user|&amp;gt;
What is the Young's modulus of graphene?&amp;lt;|assistant|&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;add_generation_prompt=True&lt;/code&gt; is what appends that trailing &lt;code&gt;&amp;lt;|assistant|&amp;gt;\n&lt;/code&gt;. Without it the last token the model sees is &lt;code&gt;?&lt;/code&gt;, and it will cheerfully continue the &lt;em&gt;user's&lt;/em&gt; turn, inventing a follow-up question instead of answering. With it, the model is standing exactly where, during training, the next token was the start of an answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  The prefixes have to match exactly
&lt;/h3&gt;

&lt;p&gt;Line the two up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;training&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;&amp;lt;|user|&amp;gt;\nWhat is ... graphene?&amp;lt;|assistant|&amp;gt;\n | About 1 TPa.&amp;lt;|endoftext|&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;inference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;&amp;lt;|user|&amp;gt;\nWhat is ... graphene?&amp;lt;|assistant|&amp;gt;\n | ← generation starts here&lt;/span&gt;
            &lt;span class="s"&gt;└──────────── must match exactly ───────────┘&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything left of the bar is the model's conditioning context. Training taught it "given this prefix, emit &lt;code&gt;About&lt;/code&gt;". If inference rebuilds that prefix with one extra newline, or a space the training path didn't have, the model is conditioning on something outside its training distribution.&lt;/p&gt;

&lt;p&gt;What makes it nasty is that it degrades invisibly. Print both strings and they look identical. Nothing in the logs.&lt;/p&gt;

&lt;p&gt;So just assert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;full&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokenize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_generation_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;tokenize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_generation_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines, and it hands you the loss-mask boundary for free: &lt;code&gt;len(prefix)&lt;/code&gt; is where the answer starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cutting the loss mask
&lt;/h2&gt;

&lt;p&gt;Roles are tokens, not fields, so there are no field boundaries to slice on. You locate the assistant span yourself.&lt;/p&gt;

&lt;p&gt;The direct approach is the length difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary lands naturally after &lt;code&gt;&amp;lt;|assistant|&amp;gt;\n&lt;/code&gt;. Clean.&lt;/p&gt;

&lt;p&gt;The cleverer-looking alternative is to regex the rendered text for &lt;code&gt;&amp;lt;|assistant|&amp;gt;&lt;/code&gt; and map character offsets back to token indices. Don't. The moment a message's &lt;code&gt;content&lt;/code&gt; legitimately contains that string it's wrong, and wrong silently.&lt;/p&gt;

&lt;p&gt;The length-diff trick has one real limit: it only handles the last turn. For multi-turn data where you want loss on every assistant reply, you'd render incrementally turn by turn, which gets ugly fast.&lt;/p&gt;

&lt;p&gt;The proper fix is to mark the assistant span in the template itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'assistant'&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="nv"&gt;generation&lt;/span&gt; &lt;span class="cp"&gt;%}{{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;|endoftext|&amp;gt;'&lt;/span&gt; &lt;span class="cp"&gt;}}{%&lt;/span&gt; &lt;span class="nv"&gt;endgeneration&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask for the mask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokenize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_dict&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;return_assistant_tokens_mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant_masks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# 1 on assistant tokens
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct for any number of turns. The cost is that the template has to contain &lt;code&gt;{% generation %}&lt;/code&gt; blocks, and a lot of published templates don't, which is why I haven't switched to it yet. Check first, or add the blocks to your own copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Double BOS.&lt;/strong&gt; The template already emitted BOS. Call &lt;code&gt;tok(text)&lt;/code&gt; after it and the default &lt;code&gt;add_special_tokens=True&lt;/code&gt; adds another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokenize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ids&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_special_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# ← required
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or skip the round trip with &lt;code&gt;tokenize=True&lt;/code&gt;, which uses &lt;code&gt;add_special_tokens=False&lt;/code&gt; internally anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hand-built strings.&lt;/strong&gt; Writing &lt;code&gt;f"&amp;lt;|user|&amp;gt;\n{q}&amp;lt;|assistant|&amp;gt;\n"&lt;/code&gt; in your data pipeline works, right up until the official template changes, or you swap base models, or someone adds a system prompt. Let the template be the single source of truth and stop thinking about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inconsistent template branches.&lt;/strong&gt; Some hand-edited templates emit &lt;code&gt;&amp;lt;|assistant|&amp;gt;\n&lt;/code&gt; inside the loop and &lt;code&gt;&amp;lt;|assistant|&amp;gt;&lt;/code&gt; in the &lt;code&gt;add_generation_prompt&lt;/code&gt; branch, one newline short. The official GLM and Qwen templates are fine, but if you've touched a template, or added tool definitions or a system prompt, that assert is the only thing that will tell you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special tokens in user content.&lt;/strong&gt; Jinja does plain string concatenation with no escaping. A &lt;code&gt;content&lt;/code&gt; field containing the literal text &lt;code&gt;&amp;lt;|assistant|&amp;gt;&lt;/code&gt; tokenizes into the real special token, and the model reads a genuine turn boundary. Prompt injection, training-data edition. If your corpus is scraped or model-generated, scan it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;It comes down to one thing: the byte string you train on and the byte string you infer on have to share an identical prefix. Jinja2 produces that string, &lt;code&gt;tokenizer_config.json&lt;/code&gt; and &lt;code&gt;chat_template.jinja&lt;/code&gt; hold the recipe, and &lt;code&gt;add_generation_prompt&lt;/code&gt; is the only difference the two paths should have.&lt;/p&gt;

&lt;p&gt;Honestly, none of this comes up most of the time. Use a stock model on a standard pipeline and &lt;code&gt;apply_chat_template&lt;/code&gt; handles everything; you never need to know what's underneath. It only surfaces when you start building your own data, computing your own masks, or swapping base models. And when it does surface it doesn't throw. It just makes things slightly worse, which is exactly why it's worth knowing in advance.&lt;/p&gt;

&lt;p&gt;There's an isomorphic problem on the serving side. The QPS-as-load-signal mistake in &lt;a href="https://jessie-jia.com/article/littles-law-vllm-autoscaling/2026/08/07/" rel="noopener noreferrer"&gt;Little's Law and vLLM autoscaling&lt;/a&gt; has the same shape: a metric that looks reasonable and is quietly measuring something else.&lt;/p&gt;

&lt;p&gt;Further reading: the &lt;a href="https://huggingface.co/docs/transformers/main/en/chat_templating" rel="noopener noreferrer"&gt;chat templating guide&lt;/a&gt; in the &lt;code&gt;transformers&lt;/code&gt; docs, and the whitespace-control section of the &lt;a href="https://jinja.palletsprojects.com/en/stable/templates/" rel="noopener noreferrer"&gt;Jinja2 template designer reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;中文版：&lt;a href="https://jessie-jia.com/article/chat-template-add-generation-prompt-zh/2026/08/12/" rel="noopener noreferrer"&gt;Chat Template 到底 tokenize 了什么&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>technical</category>
      <category>sft</category>
      <category>tokenizer</category>
    </item>
  </channel>
</rss>
