What you'll build
Chat templates match roles case-sensitively, and Qwen3's silently drops a message whose role is "User" instead of "user", with no error or warning. That is why you validate chat messages before apply_chat_template renders them. Here is the drop, on one message in a four-turn conversation:
clean: 292 characters, question present: Truedrifted: 230 characters, question present: Falsefirst divergence at character 220--- last 3 lines, clean:<|im_start|>userCan I change the delivery address?<|im_end|><|im_start|>assistant--- last 3 lines, drifted:<|im_start|>assistantIt shipped Monday and arrives Thursday.<|im_end|><|im_start|>assistantThe user's question is gone. The model is asked to speak again straight after its own last reply, and no exception, warning or log line says so. The module you build next catches this, and the same conversation then fails loudly:
clean conversation, same string as render(): Truedrifted conversation refused: message 3: unknown role 'User'By the end you will have a safe_render that refuses a malformed conversation at your own call, with the message index and the bad value in the error, before it can reach the model. It runs validate_conversation from a new templatestudio/messages.py, a typed message model with a validator for the rules the chat template assumes and never checks. The rules cover known roles, the shape of content, the position of the system message, and the shape of tool calls.
This is an intermediate tutorial for Python engineers who already call apply_chat_template. It is Part 2 of a series and continues the project from Part 1, How to See What apply_chat_template Sends Your Model. It takes about 20 minutes, needs no GPU and no API key, and runs nothing but 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 on 2026-11-09.
Prerequisites
You need the project you built in Part 1: the chat-templates-part1/ folder with its virtual environment, naive.py, and templatestudio/render.py. This part adds files to it, replaces the two type lines at the top of render.py with an import, and adds one function at its end. If you do not have that project, follow Part 1's Prerequisites to create the folder and the environment. Then copy naive.py, templatestudio/render.py and show_wire.py from its complete code section, and create an empty templatestudio/__init__.py if Part 1's Prerequisites did not already create it. Step 7 runs show_wire.py to confirm that nothing from Part 1 broke. Part 1's other scripts are not needed here.
Before you install anything, go to the project folder, activate the environment, and turn off the advisory that Transformers prints when PyTorch is not installed. Run the cd from the folder that contains chat-templates-part1/, and skip it if you are already inside the project, as you are right after Part 1's Prerequisites:
cd chat-templates-part1source .venv/bin/activateexport TRANSFORMERS_NO_ADVISORY_WARNINGS=1On Windows, activate with .venv\Scripts\activate in PowerShell or source .venv/Scripts/activate in Git Bash. In PowerShell, set the variable with $env:TRANSFORMERS_NO_ADVISORY_WARNINGS = "1". Every script in this part runs from the project root. If you start from a fresh folder, the first tokenizer download prints the same one-time Hub and Windows symlink warnings that Part 1 lists; they need no action.
You need Python 3.11 or later, because step 3 imports typing.NotRequired, which Python added in 3.11 (PEP 655). This tutorial was verified on Python 3.12.2, the same as Part 1. The package versions are also unchanged from Part 1, so if you are continuing from there they are already installed and you can skip this command. Otherwise run it now, inside the activated environment:
pip install "transformers==5.22.1" "tokenizers==0.23.3" \ "huggingface-hub==2.4.0" "jinja2==3.1.6"In PowerShell, put the install on one line, because PowerShell does not continue a line with \.
Transformers 5.22.2 came out on 2026-11-06, after Part 1 was verified. It is a security patch that makes the sandbox stricter, the restricted Jinja environment that chat templates run inside. The pull request behind it explains why: a CPython bug can be reached from inside the sandbox, and it is fixed only in Python 3.13 and later. This series keeps 5.22.1 so that both parts run on one verified stack, and this part was verified on 5.22.1 only. The 5.22.2 source adds one new check that matters here, shown in the troubleshooting table.
Check the Python version, the install, and the Part 1 module before you start:
python -c "import sys; print(sys.version_info >= (3, 11))"python -c "import transformers as t, jinja2 as j; print(t.__version__, j.__version__)"python -c "from templatestudio.render import first_divergence as f; print(f('ab', 'ac'))"Expected output:
True5.22.1 3.1.61The first line must be True; on Python 3.10 step 3 fails. The last line comes from Part 1's first_divergence: the strings first differ at index 1. If it prints ModuleNotFoundError: No module named 'templatestudio', you are not in the project root.
Why apply_chat_template does not validate chat messages
Part 1 described the render contract: your list of messages on one side, the model's training format on the other, and the chat template as the only thing allowed to translate between them. Part 1 spent its time on the output side of that contract, the wire format, which for Qwen3 is ChatML. This part is about the input side. No one else in a typical stack checks it either, which is why chat templates are the last unowned layer in your LLM stack.
The template is a formatter, and it assumes its input is correct. It matches each role against a string with == and copies content into the output. When the role does not match, it does whatever its author wrote for that case, which is often nothing at all. The Chat Templates Handbook, the book this series accompanies, calls the rules a conversation must meet before rendering the input contract. You do not need the book to follow along. Every role is known and spelled exactly. Every content is a string or a list of parts. The system message, if there is one, comes first. Tool calls have the shape the template reads. The template assumes all of these and checks none of them, so your code has to.
direction: down
source: "Your code\nhistory store · client request · other services" {
style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
contract: "Input contract (your code checks it)\nknown roles · content is str or list\nsystem first · tool_calls shape" {
style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}
}
reject: "InvalidConversation\nevery violation listed" {
style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
template: "Chat template (Jinja)\nmatches roles with ==\nchecks nothing" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
wire: "Wire format\nthe tokens the model reads" {
style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
source -> contract: "1. list of messages"
contract -> reject: "malformed"
contract -> template: "2. only a well-formed list"
template -> wire: "3. render contract (Part 1)"
Part 1 built the bottom edge of this diagram. The yellow box is what you build now: one check at the boundary, before the template runs. If the check fails, you get an exception with a message. Without the check, the same mistake becomes a worse answer that nobody can trace.
Step 1: Reproduce a chat turn that apply_chat_template silently drops
Goal. Make a copy of Part 1's conversation with one role miscased, and compare its render with the clean one.
Why this step. A fix means little until you have watched the failure it prevents. Roles reach your code from many places: a database column, a message queue, another team's API, a client library. Any of them can hand you "User", "Assistant" or "assistant " with a trailing space. Picture a history store that saved the last turn's role as "User". Qwen3's template checks the role with a chain of if and elif tests and has no else, so a role that matches none of them produces no output at all.
direction: down
msg: "message 3\nrole: 'User'\ncontent: the delivery question" {
style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
q1: "role == 'user'?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
q2: "role == 'assistant'?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
q3: "role == 'tool'?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
none: "no else branch\nnothing is written for this turn" {
style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
msg -> q1
q1 -> q2: "no"
q2 -> q3: "no"
q3 -> none: "no"
The diagram leaves out system: the template writes a first system message before this loop starts, and step 4 shows what it does with a later one. The comparison is case-sensitive, so "User" fails all three tests. The red box is not an error, only the absence of output, and nothing reports an absence.
Create drift.py in the project root:
# drift.pyfrom naive import CONVERSATION# The same conversation after a round trip through a history store# that saved the last role as "User". Copy each message first, so# CONVERSATION itself is never changed.DRIFTED = [dict(message) for message in CONVERSATION]DRIFTED[-1]["role"] = "User"The copy matters. CONVERSATION is a list of dicts, and list(CONVERSATION) would copy the list but share the dicts, so changing a role through the copy would change the original too. Then create show_drop.py, which renders both conversations with Part 1's render and compares them with first_divergence:
# show_drop.pyfrom drift import DRIFTEDfrom naive import CONVERSATIONfrom templatestudio.render import first_divergence, load_tokenizer, rendertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")clean = render(tokenizer, CONVERSATION)drifted = render(tokenizer, DRIFTED)question = CONVERSATION[-1]["content"]print("clean: ", len(clean), "characters, question present:", question in clean)print("drifted:", len(drifted), "characters, question present:", question in drifted)print("first divergence at character", first_divergence(clean, drifted))for name, wire_format in [("clean", clean), ("drifted", drifted)]: print(f"--- last 3 lines, {name}:") print("".join(wire_format.splitlines(keepends=True)[-3:]), end="")Run it.
python show_drop.pyExpected output.
clean: 292 characters, question present: Truedrifted: 230 characters, question present: Falsefirst divergence at character 220--- last 3 lines, clean:<|im_start|>userCan I change the delivery address?<|im_end|><|im_start|>assistant--- last 3 lines, drifted:<|im_start|>assistantIt shipped Monday and arrives Thursday.<|im_end|><|im_start|>assistantWhat just happened. The clean render is the 292-character string from Part 1. The drifted render is 62 characters shorter, and those 62 characters are exactly the user turn: its opening marker and the word user, a newline, the 34-character question, and the closing marker with its newline. The two strings agree up to character 220. The 12 characters before that point are an <|im_start|> marker in both renders. They are not the same marker. In the clean render it opens the user turn, and in the drifted render it starts the generation prompt. So at character 220 the clean render writes user, and the drifted one writes assistant. The model would receive its own previous answer followed by a request to answer again, with no question in between. apply_chat_template returned normally, and nothing in your logs would show the difference.
Step 2: Check which roles and content shapes Qwen3's template ignores
Goal. Probe the template with several role spellings and two content shapes, and record what reaches the wire format.
Why this step. One miscased role could be an accident of this template. Before you decide what the validator must check, find out how many inputs the template ignores. The probe puts a marker string, PROBE, in a message and asks whether it appears in the render. It also tries content as None and as a list of parts, the shape multimodal messages use, because the template copies content as blindly as it matches roles.
Create probe_template.py:
# probe_template.pyfrom templatestudio.render import load_tokenizer, rendertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")MARKER = "PROBE"print("role spelling -> did the text reach the wire format?")for role in ["user", "User", "assistant ", "ai", "developer", None]: conversation = [ {"role": "user", "content": "Hi."}, {"role": role, "content": MARKER}, ] print(f" {role!r:13} {MARKER in render(tokenizer, conversation)}")print("content shape -> what the template wrote for the turn")for content in [None, [{"type": "text", "text": MARKER}]]: wire_format = render(tokenizer, [{"role": "user", "content": content}]) print(f" {type(content).__name__:13} {wire_format!r}")Run it.
python probe_template.pyExpected output.
role spelling -> did the text reach the wire format? 'user' True 'User' False 'assistant ' False 'ai' False 'developer' False None Falsecontent shape -> what the template wrote for the turn NoneType '<|im_start|>user\n<|im_end|>\n<|im_start|>assistant\n' list '<|im_start|>user\n<|im_end|>\n<|im_start|>assistant\n'What just happened. Only the exact string "user" got through. A capital letter, a trailing space, a synonym like "ai", and a missing role (None) all vanish the same silent way. So does "developer", a real role that some templates know and Qwen3-0.6B's does not. The content probes fail differently: the turn markers are written, with nothing between them. The template keeps content only when it is a string and replaces anything else with '', so a list of parts sent to this text-only model becomes an empty user turn.
Other templates fail on the same inputs in different ways. The Transformers issue #45419 and pull request #45422 record templates that write the literal text None into the prompt, and others that raise TypeError, when content is None. The pull request, merged on 2026-11-09 after 5.22.2 shipped, drops None-valued keys before rendering, so templates in a later Transformers release receive no content key at all; 5.22.1, the version this series pins, still passes None through. Because the failure depends on the template, the check belongs in your code, where it behaves the same for every model.
Step 3: Define a TypedDict chat message type in Python
Goal. Create templatestudio/messages.py with a typed model of a message and a conversation.
Why this step. Part 1 typed a message as dict[str, str], which was enough to pass text in and get text back. That type is now wrong in three ways. role is not any string: it is one of a small, closed set. content is not always a string, because multimodal messages carry a list of parts. And an assistant message that calls a tool may have no content at all, only a tool_calls list. A precise type documents the contract for every reader of your code. It does not enforce the contract, and this step shows that too.
grid-rows: 1
grid-gap: 30
static: "What the annotation says" {
label.near: top-center
style: {fill: "#F4F6F6"; stroke: "#95A5A6"; font-color: "#2C2C2A"}
grid-columns: 1
role: "Role = Literal[\n'system', 'developer',\n'user', 'assistant', 'tool']" {
style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
msg: "Message(TypedDict)\nrole: Role\ncontent: optional, str or list of parts\ntool_calls: optional list" {
style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
}
runtime: "What Python runs" {
label.near: top-center
style: {fill: "#F4F6F6"; stroke: "#95A5A6"; font-color: "#2C2C2A"}
grid-columns: 1
plain: "Message(role='User', ...)\nreturns a plain dict" {
style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
check: "role in VALID_ROLES\nthe only check that runs" {
style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
}
}
The left side is what a type checker and a human reader see. The right side is what happens when the program runs: the annotation is gone, and only an explicit set membership test can reject "User".
Create templatestudio/messages.py:
# templatestudio/messages.pyfrom __future__ import annotationsfrom typing import Literal, NotRequired, TypedDict, UnionRole = Literal["system", "developer", "user", "assistant", "tool"]VALID_ROLES: frozenset[str] = frozenset( {"system", "developer", "user", "assistant", "tool"})# A content part is one item in a multimodal or tool message.ContentPart = dict[str, object]Content = Union[str, list[ContentPart]]# One entry in an assistant message's tool_calls list (step 6).ToolCall = dict[str, object]class Message(TypedDict): role: Role content: NotRequired[Content | None] tool_calls: NotRequired[list[ToolCall]]Conversation = list[Message]NotRequired marks a key that a message may leave out. content is optional here because the tool-call message has none, and step 5 makes the validator agree with that. developer is in the set because some formats use it: gpt-oss's template reads a developer message as the application's instructions. Qwen3-0.6B drops it, as step 2 showed, and step 4 comes back to that. Then create check_types.py, which builds a Message with the bad role:
# check_types.pyfrom templatestudio.messages import VALID_ROLES, Messagemessage = Message(role="User", content="Can I change the delivery address?")print(type(message).__name__, message)print("role in VALID_ROLES:", message["role"] in VALID_ROLES)Run it.
python check_types.pyExpected output.
dict {'role': 'User', 'content': 'Can I change the delivery address?'}role in VALID_ROLES: FalseWhat just happened. Message(...) built an ordinary dict and accepted "User" without complaint. The Python documentation states it plainly: "The Python runtime does not enforce function and variable type annotations." PEP 589 adds that a TypedDict is always just a dict at runtime. That is also why adopting the type is free: the runtime object is the same plain dict that Part 1 passed to apply_chat_template, so nothing that already works breaks. The second line is the check that does run. VALID_ROLES holds the same five names as Role in a form Python can test at runtime, and the next step builds the validator on it.
Step 4: Validate chat messages before apply_chat_template renders them
Goal. Add validate_conversation, which returns every input-contract violation in a conversation as a list of strings.
Why this step. The template will not check its input, so your code checks it once, at the boundary, before render. The validator collects every problem instead of stopping at the first one. A caller who is debugging a bad conversation then sees all of its problems in one run, instead of fixing one, re-running, and finding the next.
Each rule stops a specific failure. An unknown role is the dropped turn from step 1. content that is None, or not a string or a list, is the empty turn from step 2, or the literal None or TypeError that other templates produce. A blank string is a turn with nothing in it, which usually means an earlier step failed, such as an empty form field or a speech-to-text call that returned nothing. A list of parts must hold dicts with a type key, because that key is how templates that read parts tell text from images. The system message goes first because templates only treat a system message at index 0 as the standing instruction. Qwen3's template does not stop a late one: its user branch also matches message.role == "system" and not loop.first (the template source), so it writes the late system message in place, in the middle of the history, with no error. The contract requires the system message first. Without that rule, you would have to test every model to see whether it follows an instruction that arrives mid-history.
The checks run in two passes. The first pass looks at each message on its own: is it a dict, is its role known, and is its content allowed. The second pass looks at the order of the list: a system message must be at index 0.
direction: down
input: "conversation: list of messages" {
style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
empty: "empty list?\nreturn ['conversation is empty']" {
style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}
}
each: "Pass 1: for each message i" {
label.near: top-center
style: {fill: "#F4F6F6"; stroke: "#95A5A6"; font-color: "#2C2C2A"}
grid-rows: 1
grid-gap: 16
dict: "is it\na dict?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
role: "role in\nVALID_ROLES" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
content: "content is a\nnon-blank str\nor typed parts" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
}
order: "Pass 2: system only at index 0" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
out: "problems: list[str]\n[] means well-formed" {
style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
input -> empty
empty -> each: "not empty"
each -> order
order -> out
Every check appends a message that names the index of the bad message. The index is what makes the output useful, because it points at one item in a list you can print.
Add this at the end of templatestudio/messages.py. Skip the first comment line; it only tells you where the code goes:
# templatestudio/messages.py (add at the end)def validate_conversation(conversation: Conversation) -> list[str]: """Return every input-contract violation. An empty list means well-formed.""" if not conversation: return ["conversation is empty"] problems: list[str] = [] for i, message in enumerate(conversation): if not isinstance(message, dict): kind = type(message).__name__ problems.append(f"message {i}: must be a dict, got {kind}") continue role = message.get("role") if role not in VALID_ROLES: problems.append(f"message {i}: unknown role {role!r}") content = message.get("content") if content is None: problems.append(f"message {i}: content is None") elif not isinstance(content, (str, list)): kind = type(content).__name__ problems.append(f"message {i}: content must be str or list, got {kind}") elif isinstance(content, str) and not content.strip(): problems.append(f"message {i}: content is empty or whitespace") elif isinstance(content, list): for j, part in enumerate(content): if not isinstance(part, dict) or "type" not in part: problems.append(f"message {i}: content[{j}] is not a typed part") for i, message in enumerate(conversation): if isinstance(message, dict) and message.get("role") == "system" and i != 0: problems.append(f"message {i}: system message must be first") return problemsTwo details keep the validator itself from crashing on bad input. It checks that each item is a dict before reading it, and skips to the next item with continue when it is not. It also reads every field with message.get(...), never message["role"], so a missing key is reported as a problem instead of raising KeyError halfway through the list.
Now create validate_examples.py. It runs the validator on the clean and drifted conversations, an empty list, one conversation with six problems in it, and two cases the validator lets through on purpose:
# validate_examples.pyfrom drift import DRIFTEDfrom naive import CONVERSATIONfrom templatestudio.messages import validate_conversationBROKEN = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "system", "content": "You are a terse support agent."}, {"role": "assistant", "content": None}, {"role": "ai", "content": " "}, "Can I change the delivery address?", {"role": "user", "content": ["Can I change the delivery address?"]},]DEVELOPER_LATE = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "developer", "content": "Never promise a delivery date."},]LIST_CONTENT = [ {"role": "user", "content": [{"type": "text", "text": "Where is my order?"}]},]print("clean: ", validate_conversation(CONVERSATION))print("drifted: ", validate_conversation(DRIFTED))print("empty: ", validate_conversation([]))print("developer: ", validate_conversation(DEVELOPER_LATE))print("list content:", validate_conversation(LIST_CONTENT))print("broken:")for problem in validate_conversation(BROKEN): print(" ", problem)Run it.
python validate_examples.pyExpected output.
clean: []drifted: ["message 3: unknown role 'User'"]empty: ['conversation is empty']developer: []list content: []broken: message 2: content is None message 3: unknown role 'ai' message 3: content is empty or whitespace message 4: must be a dict, got str message 5: content[0] is not a typed part message 1: system message must be firstWhat just happened. One call now returns a list for any conversation, and each entry names a message index and the rule it broke. That list is your debugging interface. The clean conversation returns an empty list. Where step 1 got only a missing turn, the drifted one now returns one line that names the message, the field and the bad value. The broken conversation reports all six of its problems in one run, including the bare string at index 4, which is reported rather than crashing the check. The system-position problem comes last because it comes from the second loop, after every per-message check. Step 8 turns this same list into an exception message.
Look at the developer and list content lines. Both return [], and both describe input that step 2 showed Qwen3-0.6B silently loses. developer is a known role, and a list of typed parts is a legitimate shape for content, so the shared contract has to accept them. What the shared contract cannot know is whether one particular model's template handles them. This text-only template turns the list into an empty turn and drops developer entirely. This validator checks only the part of the input contract that every template shares. The rest is model-specific. Gemma 3 has no standalone system turn; its template puts the system text at the start of the first user turn. It also requires user and assistant turns to alternate. A validator tuned to one family is not automatically right for another. The book makes that a per-model policy, and Part 4 of this series takes templates apart and builds a probe that reports how each one handles roles.
Step 5: Validate tool_calls messages when content is None
Goal. Change the content rule so that a correct tool-call round trip passes.
Why this step. A check at the boundary is only useful if it lets correct input through. An assistant turn that calls a tool has no text to say, so it often has no content at all. The Transformers tool-use guide builds exactly that message: a tool_calls list and no content key. Other clients send None or "" in the same place. A tool that returned nothing still answered its call, so an empty content on a tool turn is legitimate too. Step 4's rule rejects all of these, and a rule that rejects correct input teaches callers to switch the check off. So test the legitimate shapes first, and only then the broken ones.
grid-rows: 3
grid-columns: 2
grid-gap: 26
q1: "1. content is None?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
a1: "OK only on an assistant turn\nwith a non-empty tool_calls list" {
style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
}
q2: "2. not a str or a list?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
a2: "always a violation" {
style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
q3: "3. blank string?" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
a3: "OK on a tool turn or a tool-call turn;\na violation anywhere else" {
style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
q1 -> q2
q2 -> q3
The left column is the order the content checks run in, top to bottom. The right column is what each check allows after this step. Both exceptions are tied to tool calls, and the list-of-parts rule from step 4 is unchanged.
Create tool_turns.py with one correct round trip and two broken assistant turns. The tool call uses the shape the Transformers guide documents: a function object with a name and the arguments as a dict.
# tool_turns.pyCALL = {"name": "track_order", "arguments": {"order_id": "4471"}}# A correct tool round trip: the assistant turn has tool_calls and no content.ROUND_TRIP = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "assistant", "tool_calls": [{"type": "function", "function": CALL}]}, {"role": "tool", "content": "in transit, arrives Thursday"},]# Broken: an empty tool_calls list and no content.NO_CALLS = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "assistant", "tool_calls": []},]# Broken: a call whose function has no arguments.BAD_CALL = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "assistant", "tool_calls": [{"function": {"name": "track_order"}}]},]First confirm that the round trip is a shape the model was trained on. Create show_tool_render.py:
# show_tool_render.pyfrom templatestudio.render import load_tokenizer, renderfrom tool_turns import ROUND_TRIPtokenizer = load_tokenizer("Qwen/Qwen3-0.6B")print(render(tokenizer, ROUND_TRIP), end="")Then create validate_tools.py, which runs the validator on all three:
# validate_tools.pyfrom templatestudio.messages import validate_conversationfrom tool_turns import BAD_CALL, NO_CALLS, ROUND_TRIPprint("round trip:", validate_conversation(ROUND_TRIP))print("no calls: ", validate_conversation(NO_CALLS))print("bad call: ", validate_conversation(BAD_CALL))Run it before you change the validator. Start with the render:
python show_tool_render.pyExpected output.
<|im_start|>userWhere is my order #4471?<|im_end|><|im_start|>assistant<tool_call>{"name": "track_order", "arguments": {"order_id": "4471"}}</tool_call><|im_end|><|im_start|>user<tool_response>in transit, arrives Thursday</tool_response><|im_end|><|im_start|>assistantThen run the validator on the same three conversations:
python validate_tools.pyExpected output.
round trip: ['message 1: content is None']no calls: ['message 1: content is None']bad call: ['message 1: content is None']Qwen3's own template turns the assistant message, which has no content, into a <tool_call> block, and wraps the tool's result in <tool_response> inside a user turn. The round trip is correct, and the validator rejects it anyway, with the same verdict it gives the two broken turns.
Now fix the rule. In validate_conversation, replace the start of the content block in the first loop: everything from content = message.get("content") down to the line that appends content is empty or whitespace. Leave the elif isinstance(content, list): branch below it as it is. The replacement:
tool_calls = message.get("tool_calls") content = message.get("content") # An assistant turn that calls a tool may carry no content at all. # An empty tool_calls list calls nothing, so it gets no exception. calls_only = role == "assistant" and bool(tool_calls) if content is None: if not calls_only: problems.append(f"message {i}: content is None") elif not isinstance(content, (str, list)): kind = type(content).__name__ problems.append(f"message {i}: content must be str or list, got {kind}") elif isinstance(content, str) and not content.strip(): # A tool that returned nothing still answered its call. if not calls_only and role != "tool": problems.append(f"message {i}: content is empty or whitespace")Keep the indentation exactly as shown: the block sits inside the for loop, at the same level as role = message.get("role").
Run it again:
python validate_tools.pyExpected output.
round trip: []no calls: ['message 1: content is None']bad call: []Then re-run step 4's examples, to confirm that the replacement left the other rules alone:
python validate_examples.pyExpected output, the same as in step 4:
clean: []drifted: ["message 3: unknown role 'User'"]empty: ['conversation is empty']developer: []list content: []broken: message 2: content is None message 3: unknown role 'ai' message 3: content is empty or whitespace message 4: must be a dict, got str message 5: content[0] is not a typed part message 1: system message must be firstWhat just happened. The round trip passes. NO_CALLS is still reported, because an empty tool_calls list calls nothing and the exception does not apply to it. BAD_CALL now passes too, and it should not. Its assistant turn has a non-empty tool_calls list, so it qualifies for the exception, and nothing checks what is inside that list. A call with no arguments can never become a complete call in any template, so the validator now has a hole, which the next step closes. validate_examples.py printed the same output as in step 4, because none of its messages carry tool calls.
Step 6: Check the shape of tool_calls
Goal. Add a check on the structure of tool_calls, so that a turn that skips content is checked for something else instead.
Why this step. Step 5 dropped the content check for tool-call turns without adding the check that should replace it. An assistant turn without content is only correct if its tool_calls list holds real calls. The Transformers guide and the OpenAI message format both put each call inside a function object that carries a name and its arguments. The OpenAI Python SDK's assistant message type says content is "Required unless tool_calls or function_call is specified", which is the rule step 5 just wrote. The new helper checks for the object and those two keys, and nothing more.
In validate_conversation, replace the line tool_calls = message.get("tool_calls") with these lines, which keep it and pass the list to a new helper:
tool_calls = message.get("tool_calls") if tool_calls is not None: problems.extend( f"message {i}: {p}" for p in _validate_tool_calls(tool_calls) )Then add the helper at the end of templatestudio/messages.py, skipping the first comment line:
# templatestudio/messages.py (add at the end)def _validate_tool_calls(tool_calls: object) -> list[str]: """Check the shape of an assistant message's tool_calls.""" if not isinstance(tool_calls, list): return ["tool_calls must be a list"] problems: list[str] = [] for j, call in enumerate(tool_calls): function = call.get("function") if isinstance(call, dict) else None if not isinstance(function, dict): problems.append(f"tool_calls[{j}]: missing function object") elif "name" not in function or "arguments" not in function: problems.append( f"tool_calls[{j}]: function needs name and arguments" ) return problemsvalidate_conversation can call _validate_tool_calls even though the helper is defined further down the file.
Run it.
python validate_tools.pyExpected output.
round trip: []no calls: ['message 1: content is None']bad call: ['message 1: tool_calls[0]: function needs name and arguments']What just happened. An assistant turn without content now passes only when every call in its tool_calls list has a function object with a name and arguments. The step 5 exception no longer opens a hole, and each of the three conversations gets the right verdict. The helper reports the call's index inside the list, tool_calls[0], and validate_conversation adds the message index in front, so the line points at one call inside one message. The content rules for every other message are unchanged.
Step 7: Import the Conversation type into render.py from messages.py
Goal. Make render.py use the Conversation type from messages.py instead of defining its own.
Why this step. render.py still defines Message = dict[str, str] and Conversation = list[Message] from Part 1. Two definitions of one type drift apart, and the one in render.py already says that content is always a string, which steps 3 to 6 showed is false. One import fixes it. It is also the last change you make before render.py starts depending on the validator in step 8.
Open templatestudio/render.py and replace these two lines near the top:
Message = dict[str, str]Conversation = list[Message]with this import:
from templatestudio.messages import ConversationThe import is safe in this direction: messages.py imports nothing from render.py, so there is no import cycle.
Run it. Run Part 1's show_wire.py, the first script that uses render:
python show_wire.pyExpected output.
<|im_start|>systemYou are a terse support agent. Answer in one sentence.<|im_end|><|im_start|>userWhere is my order #4471?<|im_end|><|im_start|>assistantIt shipped Monday and arrives Thursday.<|im_end|><|im_start|>userCan I change the delivery address?<|im_end|><|im_start|>assistant--- ends with a newline: TrueWhat just happened. This is the same render Part 1 printed in its step 2, so the edit changed nothing that already worked. There is now one definition of a conversation in the project, and render.py takes it from messages.py. Part 1's other scripts keep working, because they import only functions from render.py, and none of those functions changed. Any code of your own that imports Message from templatestudio.render must now import it from templatestudio.messages; the troubleshooting table shows the error.
Step 8: Refuse malformed conversations with safe_render
Goal. Add assert_valid and an InvalidConversation exception, and wire them in front of render as safe_render.
Why this step. A list of problems suits debugging, but production code has to stop with an exception. The function production code calls should make it impossible for a malformed conversation to reach a template. One function that checks and then renders does that, and no caller can forget the check. render stays, for the case where you want to see what a bad conversation produces, as you did in steps 1 and 2.
direction: down
caller: "your code\nsafe_render(tokenizer, conversation)" {
style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
check: "assert_valid\nvalidate_conversation" {
style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}
}
fail: "raise InvalidConversation\nmessage 3: unknown role 'User'" {
style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
render: "render\napply_chat_template" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
wire: "wire format" {
style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
caller -> check: "1. conversation"
check -> fail: "problems found"
check -> render: "2. no problems"
render -> wire: "3. string"
In the diagram, no path from the caller to the template skips the yellow box.
Add the exception and assert_valid at the end of templatestudio/messages.py. Again, skip the first comment line:
# templatestudio/messages.py (add at the end)class InvalidConversation(ValueError): """Raised when a conversation violates the input contract."""def assert_valid(conversation: Conversation) -> None: """Raise InvalidConversation listing every violation, or return cleanly.""" problems = validate_conversation(conversation) if problems: raise InvalidConversation("; ".join(problems))InvalidConversation subclasses ValueError, so existing code that already catches ValueError around a render keeps working. Next, in templatestudio/render.py, extend the import from step 7 so it also brings in the check. Replace this line:
from templatestudio.messages import Conversationwith:
from templatestudio.messages import Conversation, assert_validThen add safe_render at the end of templatestudio/render.py:
# templatestudio/render.py (add at the end)def safe_render( tokenizer: PreTrainedTokenizerBase, conversation: Conversation, *, add_generation_prompt: bool = True,) -> str: """Check the input contract, then render. Use this in production.""" assert_valid(conversation) return render( tokenizer, conversation, add_generation_prompt=add_generation_prompt, )Last, create show_safe_render.py:
# show_safe_render.pyfrom drift import DRIFTEDfrom naive import CONVERSATIONfrom templatestudio.messages import InvalidConversationfrom templatestudio.render import load_tokenizer, render, safe_rendertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")same = safe_render(tokenizer, CONVERSATION) == render(tokenizer, CONVERSATION)print("clean conversation, same string as render():", same)try: safe_render(tokenizer, DRIFTED)except InvalidConversation as error: print("drifted conversation refused:", error)Run it.
python show_safe_render.pyExpected output.
clean conversation, same string as render(): Truedrifted conversation refused: message 3: unknown role 'User'What just happened. For a well-formed conversation, safe_render returns exactly the string render returns, so switching to it changes nothing that already works. The drifted conversation raised before the template ran, with the same line the validator printed in step 4. Without the try, this is the last line of the traceback a caller would see. The script catches the exception, so you will not see this line when you run it:
templatestudio.messages.InvalidConversation: message 3: unknown role 'User'Compare it with step 1: the same bug, in the same template. What changed is when you find out. With the check, you learn it at the call, from an error that names the message index and the bad value. Without it, you would learn it from a conversation transcript days later.
Fix common chat message validation errors
Every row below was reproduced on the versions this tutorial pins, with three exceptions. The Python 3.10 row was reproduced on Python 3.10.11. The two rows marked as quoted come from a template file and a release this tutorial does not run, and each one names its source.
| Error or symptom | Root cause | Fix |
|---|---|---|
templatestudio.messages.InvalidConversation: message 3: unknown role 'User' | A role spelled differently from the five known roles, often after a round trip through a database or another service | Fix the role where it is written, or map known variants to canonical roles before validation. Decide which one you do, and write it down |
TypeError: can only concatenate str (not "NoneType") to str from render | A system message at index 0 with content set to None. Qwen3's template adds strings to build the system turn | Use safe_render, which reports message 0: content is None instead |
ValueError: Cannot apply chat template to an empty conversation. Provide at least one message. | render called with []. This is the only check Transformers 5.22.1 runs on the conversation | safe_render reports conversation is empty before the call |
NameError: name '_validate_tool_calls' is not defined from validate_tools.py in step 6 | You added the call to _validate_tool_calls but not the helper itself at the end of messages.py | Add the helper, then run the script again |
IndentationError: unexpected indent, or SyntaxError: invalid syntax at the elif isinstance(content, list): line, in messages.py after step 5 or 6 | The replacement block lost its indentation. It must sit inside the for loop, eight spaces in, at the same level as role = message.get("role") | Re-paste the block with its indentation exactly as shown. A paste that is off by four spaces may raise no error at all: the content checks then run only for the last message, so compare the indentation with the complete code listing |
ImportError: cannot import name 'NotRequired' from 'typing' | Python 3.10 or older. typing.NotRequired arrived in Python 3.11 | Create the virtual environment with Python 3.11 or later; this tutorial uses 3.12 |
message 4: must be a dict, got str from the validator | An item in the list is not a message dict, for example raw text that a history store saved without its role | Store and load whole message dicts. The index tells you which item to trace back to its source |
ImportError: cannot import name 'Message' from 'templatestudio.render', followed by the file's path | Your own code still imports the old type from render.py, which step 7 removed | Import Message and Conversation from templatestudio.messages |
NameError: name 'assert_valid' is not defined from show_safe_render.py | You added safe_render in step 8 but did not extend the import line at the top of render.py | Change the import to from templatestudio.messages import Conversation, assert_valid |
can't open file '<path>/show_drop.py': [Errno 2] No such file or directory, with your interpreter and the full path around it | The terminal is not in the project root, so Python cannot find the script | cd chat-templates-part1 and run it again |
ModuleNotFoundError: No module named 'drift' or No module named 'naive' | The script was copied out of the project folder, or drift.py or naive.py is missing. Scripts that import templatestudio first fail with No module named 'templatestudio' instead, as Prerequisites describes | Keep the scripts in the project root; copy naive.py from Part 1 if needed |
message 1: content is None on a tool-call turn | The assistant turn has tool_calls: []. An empty list calls nothing, so the tool-call exception does not apply | Remove the empty tool_calls key, or put the text in content |
| The validator passes and the turn still disappears | A known role the model's template does not handle, such as developer on Qwen3-0.6B, or content as a list of parts sent to a text-only template, which renders it as an empty turn (step 2) | Run step 2's probe_template.py with your model ID and the role or content shape in question before you rely on it. Part 4 builds a fuller probe |
Quoted: jinja2.exceptions.TemplateError: Conversation roles must alternate user/assistant/user/assistant/... | A Gemma 3 template rule your validator does not check. Source: the raise_exception call in the gemma-3-1b-it chat template (an ungated copy) | The conversation has two turns in a row with the same role, or starts with an assistant turn. Merge or reorder the turns, and add a per-model alternation rule for families that need it |
Quoted: TypeError: Chat messages should be dicts, dataclasses or pydantic models, but got 'hello'. | On Transformers 5.22.2 only, a message that is not a dict. On 5.22.1 the same message is passed to the template, which drops it without an error. Source: the v5.22.2 chat_template_utils.py | safe_render reports message 0: must be a dict, got str first, on either version |
How the chat message validator fits around apply_chat_template
The project now has two modules of your own in templatestudio/, messages.py and render.py, plus three data files, naive.py, drift.py and tool_turns.py, and a set of scripts, each of which shows one behavior.
direction: down
scripts: "scripts: show_drop · probe_template · check_types\nvalidate_examples · show_tool_render\nvalidate_tools · show_safe_render" {
style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
data: "naive.py: CONVERSATION (Part 1)\ndrift.py: DRIFTED\ntool_turns.py: ROUND_TRIP, NO_CALLS, BAD_CALL" {
style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
messages: "templatestudio/messages.py\nRole · Message · Conversation · VALID_ROLES\nvalidate_conversation · assert_valid · InvalidConversation" {
style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}
}
render: "templatestudio/render.py\nload_tokenizer · render · render_ids\nvisible_whitespace · first_divergence · safe_render" {
style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
hub: "Hub: Qwen/Qwen3-0.6B\ntokenizer files + chat_template" {
style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
scripts -> data: "conversations"
scripts -> messages: "validate_conversation"
scripts -> render: "render / safe_render"
render -> messages: "Conversation, assert_valid"
render -> hub: "apply_chat_template"
The arrow from render.py to messages.py is the new rule in this part. render.py is still the only file that calls apply_chat_template, and it now depends on the module that owns the input contract. Nothing depends in the other direction, which keeps messages.py free of Transformers, so you can import it in a service that never loads a tokenizer. Use safe_render in production and keep render for inspection.
Complete code: validate_conversation and safe_render in Python
These are the final versions of every file this part creates or changes, without the # filename comment lines. Part 1's other scripts are unchanged and not repeated here. The files marked (Part 1, unchanged), apart from naive.py and show_wire.py, exist only if you completed Part 1.
chat-templates-part1/├── naive.py (Part 1, unchanged)├── drift.py├── templatestudio/│ ├── __init__.py (empty)│ ├── messages.py│ └── render.py├── tool_turns.py├── show_drop.py├── probe_template.py├── check_types.py├── validate_examples.py├── show_tool_render.py├── validate_tools.py├── show_safe_render.py├── show_wire.py (Part 1, unchanged)├── show_whitespace.py (Part 1, unchanged)├── diff_prompts.py (Part 1, unchanged)├── show_tokens.py (Part 1, unchanged)├── diff_tokens.py (Part 1, unchanged)├── no_gen_prompt.py (Part 1, unchanged)└── generate.py (Part 1, unchanged; optional, needs torch)templatestudio/messages.py:
from __future__ import annotationsfrom typing import Literal, NotRequired, TypedDict, UnionRole = Literal["system", "developer", "user", "assistant", "tool"]VALID_ROLES: frozenset[str] = frozenset( {"system", "developer", "user", "assistant", "tool"})# A content part is one item in a multimodal or tool message.ContentPart = dict[str, object]Content = Union[str, list[ContentPart]]# One entry in an assistant message's tool_calls list (step 6).ToolCall = dict[str, object]class Message(TypedDict): role: Role content: NotRequired[Content | None] tool_calls: NotRequired[list[ToolCall]]Conversation = list[Message]def validate_conversation(conversation: Conversation) -> list[str]: """Return every input-contract violation. An empty list means well-formed.""" if not conversation: return ["conversation is empty"] problems: list[str] = [] for i, message in enumerate(conversation): if not isinstance(message, dict): kind = type(message).__name__ problems.append(f"message {i}: must be a dict, got {kind}") continue role = message.get("role") if role not in VALID_ROLES: problems.append(f"message {i}: unknown role {role!r}") tool_calls = message.get("tool_calls") if tool_calls is not None: problems.extend( f"message {i}: {p}" for p in _validate_tool_calls(tool_calls) ) content = message.get("content") # An assistant turn that calls a tool may carry no content at all. # An empty tool_calls list calls nothing, so it gets no exception. calls_only = role == "assistant" and bool(tool_calls) if content is None: if not calls_only: problems.append(f"message {i}: content is None") elif not isinstance(content, (str, list)): kind = type(content).__name__ problems.append(f"message {i}: content must be str or list, got {kind}") elif isinstance(content, str) and not content.strip(): # A tool that returned nothing still answered its call. if not calls_only and role != "tool": problems.append(f"message {i}: content is empty or whitespace") elif isinstance(content, list): for j, part in enumerate(content): if not isinstance(part, dict) or "type" not in part: problems.append(f"message {i}: content[{j}] is not a typed part") for i, message in enumerate(conversation): if isinstance(message, dict) and message.get("role") == "system" and i != 0: problems.append(f"message {i}: system message must be first") return problemsdef _validate_tool_calls(tool_calls: object) -> list[str]: """Check the shape of an assistant message's tool_calls.""" if not isinstance(tool_calls, list): return ["tool_calls must be a list"] problems: list[str] = [] for j, call in enumerate(tool_calls): function = call.get("function") if isinstance(call, dict) else None if not isinstance(function, dict): problems.append(f"tool_calls[{j}]: missing function object") elif "name" not in function or "arguments" not in function: problems.append( f"tool_calls[{j}]: function needs name and arguments" ) return problemsclass InvalidConversation(ValueError): """Raised when a conversation violates the input contract."""def assert_valid(conversation: Conversation) -> None: """Raise InvalidConversation listing every violation, or return cleanly.""" problems = validate_conversation(conversation) if problems: raise InvalidConversation("; ".join(problems))templatestudio/render.py:
from __future__ import annotationsfrom transformers import AutoTokenizer, PreTrainedTokenizerBasefrom templatestudio.messages import Conversation, assert_validdef load_tokenizer(model_id: str) -> PreTrainedTokenizerBase: """Load a tokenizer together with the chat template it ships with.""" return AutoTokenizer.from_pretrained(model_id)def render( tokenizer: PreTrainedTokenizerBase, conversation: Conversation, *, add_generation_prompt: bool = True,) -> str: """Render a conversation into the model's wire format, using its own template.""" return tokenizer.apply_chat_template( conversation, tokenize=False, add_generation_prompt=add_generation_prompt, )def visible_whitespace(text: str) -> str: """Show spaces, tabs and newlines so two renders can be compared by eye.""" return ( text.replace("\t", "[TAB]") .replace(" ", "·") .replace("\n", "\\n\n") )def first_divergence(a: str | list[int], b: str | list[int]) -> int | None: """Return the first index where a and b differ, or None if they are equal. Works on strings (characters) and on lists of token IDs alike. """ for index, (item_a, item_b) in enumerate(zip(a, b)): if item_a != item_b: return index if len(a) != len(b): return min(len(a), len(b)) return Nonedef render_ids( tokenizer: PreTrainedTokenizerBase, conversation: Conversation, *, add_generation_prompt: bool = True,) -> list[int]: """Render a conversation straight to the token IDs the model receives.""" encoded = tokenizer.apply_chat_template( conversation, tokenize=True, add_generation_prompt=add_generation_prompt, ) return encoded["input_ids"]def safe_render( tokenizer: PreTrainedTokenizerBase, conversation: Conversation, *, add_generation_prompt: bool = True,) -> str: """Check the input contract, then render. Use this in production.""" assert_valid(conversation) return render( tokenizer, conversation, add_generation_prompt=add_generation_prompt, )drift.py:
from naive import CONVERSATION# The same conversation after a round trip through a history store# that saved the last role as "User". Copy each message first, so# CONVERSATION itself is never changed.DRIFTED = [dict(message) for message in CONVERSATION]DRIFTED[-1]["role"] = "User"show_drop.py (step 1):
from drift import DRIFTEDfrom naive import CONVERSATIONfrom templatestudio.render import first_divergence, load_tokenizer, rendertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")clean = render(tokenizer, CONVERSATION)drifted = render(tokenizer, DRIFTED)question = CONVERSATION[-1]["content"]print("clean: ", len(clean), "characters, question present:", question in clean)print("drifted:", len(drifted), "characters, question present:", question in drifted)print("first divergence at character", first_divergence(clean, drifted))for name, wire_format in [("clean", clean), ("drifted", drifted)]: print(f"--- last 3 lines, {name}:") print("".join(wire_format.splitlines(keepends=True)[-3:]), end="")probe_template.py (step 2):
from templatestudio.render import load_tokenizer, rendertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")MARKER = "PROBE"print("role spelling -> did the text reach the wire format?")for role in ["user", "User", "assistant ", "ai", "developer", None]: conversation = [ {"role": "user", "content": "Hi."}, {"role": role, "content": MARKER}, ] print(f" {role!r:13} {MARKER in render(tokenizer, conversation)}")print("content shape -> what the template wrote for the turn")for content in [None, [{"type": "text", "text": MARKER}]]: wire_format = render(tokenizer, [{"role": "user", "content": content}]) print(f" {type(content).__name__:13} {wire_format!r}")check_types.py (step 3):
from templatestudio.messages import VALID_ROLES, Messagemessage = Message(role="User", content="Can I change the delivery address?")print(type(message).__name__, message)print("role in VALID_ROLES:", message["role"] in VALID_ROLES)validate_examples.py (step 4):
from drift import DRIFTEDfrom naive import CONVERSATIONfrom templatestudio.messages import validate_conversationBROKEN = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "system", "content": "You are a terse support agent."}, {"role": "assistant", "content": None}, {"role": "ai", "content": " "}, "Can I change the delivery address?", {"role": "user", "content": ["Can I change the delivery address?"]},]DEVELOPER_LATE = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "developer", "content": "Never promise a delivery date."},]LIST_CONTENT = [ {"role": "user", "content": [{"type": "text", "text": "Where is my order?"}]},]print("clean: ", validate_conversation(CONVERSATION))print("drifted: ", validate_conversation(DRIFTED))print("empty: ", validate_conversation([]))print("developer: ", validate_conversation(DEVELOPER_LATE))print("list content:", validate_conversation(LIST_CONTENT))print("broken:")for problem in validate_conversation(BROKEN): print(" ", problem)tool_turns.py (step 5):
CALL = {"name": "track_order", "arguments": {"order_id": "4471"}}# A correct tool round trip: the assistant turn has tool_calls and no content.ROUND_TRIP = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "assistant", "tool_calls": [{"type": "function", "function": CALL}]}, {"role": "tool", "content": "in transit, arrives Thursday"},]# Broken: an empty tool_calls list and no content.NO_CALLS = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "assistant", "tool_calls": []},]# Broken: a call whose function has no arguments.BAD_CALL = [ {"role": "user", "content": "Where is my order #4471?"}, {"role": "assistant", "tool_calls": [{"function": {"name": "track_order"}}]},]show_tool_render.py (step 5):
from templatestudio.render import load_tokenizer, renderfrom tool_turns import ROUND_TRIPtokenizer = load_tokenizer("Qwen/Qwen3-0.6B")print(render(tokenizer, ROUND_TRIP), end="")validate_tools.py (steps 5 and 6):
from templatestudio.messages import validate_conversationfrom tool_turns import BAD_CALL, NO_CALLS, ROUND_TRIPprint("round trip:", validate_conversation(ROUND_TRIP))print("no calls: ", validate_conversation(NO_CALLS))print("bad call: ", validate_conversation(BAD_CALL))show_safe_render.py (step 8):
from drift import DRIFTEDfrom naive import CONVERSATIONfrom templatestudio.messages import InvalidConversationfrom templatestudio.render import load_tokenizer, render, safe_rendertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")same = safe_render(tokenizer, CONVERSATION) == render(tokenizer, CONVERSATION)print("clean conversation, same string as render():", same)try: safe_render(tokenizer, DRIFTED)except InvalidConversation as error: print("drifted conversation refused:", error)Next steps: per-model rules, role normalization, and immutable history
- Make alternation a per-model rule. Add a
require_alternation: bool = Falseparameter tovalidate_conversation, then a small policy object per model family, so a Gemma 3 conversation is checked for strict user and assistant alternation and a Qwen3 one is not. Test it with the two-user-turns case that Gemma's template rejects. - Normalize or reject, on purpose. Write
normalize_roles, which maps known drift such as"User","assistant "and"ai"to canonical roles, and givesafe_rendera flag that chooses between normalizing and rejecting. Normalizing heals bad data and also hides where it came from. Write one paragraph on which default you would pick for roles that arrive from an external API. - Stop mutating history. Write
next_turn(conversation, reply), which returns a new list with the reply appended, and a test that fails if the caller's original list changed.drift.pyalready copies each message for the same reason. A shared, mutated history is a common source of turns leaking between sessions in a multi-user server.
Part 3 of this series opens the template itself: just enough Jinja to read one, and a Jinja environment that renders it exactly as Transformers does.
This tutorial is the runnable slice of chapter 2 of The Chat Templates Handbook. The chapter adds roles as authority boundaries, the per-family differences in the input contract, and the production failures around role drift and shared history. The chapter's code is the v1.2/ch02-message-model tag of Template Studio. This tutorial's validate_conversation is a little stricter than the tagged version: it reports a conversation item that is not a dict, where the tagged version raises AttributeError, and it rejects list content whose parts have no type.
References
- Hugging Face. Chat templates (Transformers v5.22.1 documentation).
- Hugging Face. Tool use (Transformers v5.22.1 documentation, the
tool_callsmessage shape). - Hugging Face. PreTrainedTokenizerBase.apply_chat_template (Transformers v5.22.1 API reference).
- Hugging Face. transformers v5.22.1 source: tokenization_utils_base.py (the empty-conversation check).
- Hugging Face. transformers 5.22.1 on PyPI.
- Hugging Face. Patch release v5.22.2 (2026-11-06).
- Hugging Face. transformers v5.22.2 source: chat_template_utils.py (the non-dict message check).
- Hugging Face. Harden our Jinja sandboxing (pull request #50431, the change behind v5.22.2 and the CPython bug it works around).
- Hugging Face. Chat template inconsistencies in tool-calling support (issue #45419).
- Hugging Face. Drop None-valued keys from messages in apply_chat_template (pull request #45422, merged 2026-11-09).
- Hugging Face. tokenizers 0.23.3 on PyPI.
- Hugging Face. huggingface-hub 2.4.0 on PyPI.
- Kumar, R. How to See What apply_chat_template Sends Your Model (Part 1 of this series). ranjankumar.in.
- Kumar, R. The Chat Templates Handbook, Edition 1.2, chapter 2. Book site: chattemplates.ranjankumar.in.
- Kumar, R. Template Studio, tag
v1.2/ch02-message-model. - Lehtosalo, J. et al. PEP 589: TypedDict.
- Foster, D. PEP 655: Marking individual TypedDict items as required or potentially-missing.
- OpenAI. openai-python: ChatCompletionAssistantMessageParam (the
contentfield withtool_calls, at commitf348ec8). - OpenAI. gpt-oss-20b chat template (the
developerrole, at commit6cee5e8). - Pallets. Jinja2 3.1.6 on PyPI.
- Python Software Foundation. Python 3.12.2 release.
- Python Software Foundation. typing.NotRequired (Python 3.12).
- Python Software Foundation. typing: Support for type hints (Python 3.12).
- Qwen Team. Qwen3-0.6B tokenizer_config.json (the chat template source, at commit
c1899de). - Unsloth. gemma-3-1b-it chat_template.jinja (an ungated copy of Google's Gemma 3 template, at commit
5b11413).
Related Articles
- How to Pass Tools to apply_chat_template and Parse the Calls
- How to Render a Chat Template with Jinja Like Transformers
- How to See What apply_chat_template Sends Your Model
- How to Pass reasoning_content Back Through a Chat Template






