Add chat template

#11
by qgallouedec HF Staff - opened

Adds chat_template.jinja, so apply_chat_template renders the same prompts as encoding/encoding.py (asked in #10).

It is the DeepSeek-V4.1-Flash template with the 16 October encoder sync from #74 there, plus four fixes:

  • thinking=true/false is accepted like enable_thinking. vLLM only forwards kwargs a template references, so {"thinking": false} was silently ignored.
  • OpenAI reasoning_effort values map like deepseek-recipe's Chat Completions adapter: none turns thinking off, minimal/low give 50, medium/high/xhigh give 75, max gives 100. The V4.1-Flash template maps medium to 62 and raises on the others.
  • Tool call arguments passed as a JSON string raise instead of rendering a single bogus arguments parameter (huggingface/transformers#45419).
  • A tool message with content=None renders like a missing content key, not as None (huggingface/transformers#45422).

Tests

Setup for every snippet (transformers 5.20.1):

import importlib.util, json, random, sys
from huggingface_hub import hf_hub_download
from jinja2 import TemplateError
from transformers import AutoTokenizer

REPO = "deepseek-ai/DeepSeek-V4.1-Flash-1017"
tok = AutoTokenizer.from_pretrained(REPO, revision="refs/pr/11")
spec = importlib.util.spec_from_file_location("encoding", hf_hub_download(REPO, "encoding/encoding.py"))
enc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(enc)

TOOL = {"type": "function", "function": {"name": "lookup", "description": "Look up a value", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}}}}
CALL = {"type": "function", "function": {"name": "lookup", "arguments": {"query": "value", "limit": 2}}}


def render(messages, tools=None, **kwargs):
    gen = messages[-1]["role"] != "assistant"
    return tok.apply_chat_template(messages, tools=tools, tokenize=False, add_generation_prompt=gen, **kwargs)


def check(messages, tools=None, thinking_mode="chat", **kwargs):
    ref = [dict(m) for m in messages]
    if tools:  # request tools go on the leading system message, as in deepseek-recipe
        if ref[0]["role"] != "system":
            ref.insert(0, {"role": "system", "content": ""})
        ref[0]["tools"] = tools
    expected = enc.encode_messages(ref, thinking_mode=thinking_mode, **kwargs)
    assert render(messages, tools, thinking_mode=thinking_mode, **kwargs) == expected


def fails(messages, tools=None, **kwargs):
    try:
        render(messages, tools, **kwargs)
    except TemplateError:
        return True
    return False

Golden outputs (encoding/tests)

for i in range(1, 6):
    case = enc.load_cases(hf_hub_download(REPO, f"encoding/tests/test_input_{i}.json"))[0]
    for m in case["messages"]:
        for call in m.get("tool_calls") or []:
            call["function"]["arguments"] = json.loads(call["function"]["arguments"])
    out = render(case["messages"], thinking_mode=case["thinking_mode"] or "chat", reasoning_effort=case["reasoning_effort"])
    assert out == open(hf_hub_download(REPO, f"encoding/tests/test_output_{i}.txt")).read()

inference/examples

for case in json.load(open(hf_hub_download(REPO, "inference/examples/example_harmony.json"))):
    for mode in ["chat", "thinking"]:
        check(case["messages"], case.get("tools"), thinking_mode=mode)

Images

check([{"role": "user", "content": [{"type": "text", "text": "inspect"}, {"type": "image_url", "image_url": {"url": "a.png"}}]}])
for role in ["user", "tool"]:
    assert fails([{"role": role, "content": "hi <|deepseek_image|>"}])

Reasoning effort

q = [{"role": "user", "content": "question"}]
for effort in [None, "low", "high", "max", 1, 42, 100]:
    check(q, thinking_mode="thinking", reasoning_effort=effort)
check(q, thinking_mode="chat", reasoning_effort="max")
for effort in [-1, 0, 101, True, False, 1.5]:
    assert fails(q, thinking_mode="thinking", reasoning_effort=effort)

System messages

check([{"role": "system", "content": "sys"}, {"role": "user", "content": "q1"}, {"role": "assistant", "content": "a1", "reasoning_content": "r1"}, {"role": "system", "content": "mid sys"}], thinking_mode="thinking", reasoning_effort=88)
check([{"role": "system", "content": "sys"}, {"role": "latest_reminder", "content": "2026-10-17"}, {"role": "user", "content": "q"}])

Tool calls

messages = [{"role": "user", "content": "question"}, {"role": "assistant", "reasoning_content": "  reason  ", "content": "summary", "tool_calls": [CALL, CALL]}]
for mode in ["chat", "thinking"]:
    check(messages, [TOOL], thinking_mode=mode)
    check(messages, thinking_mode=mode)

Tool namespaces

for ns in ["search", {"name": "search", "description": "Search tools."}]:
    check([{"role": "system", "content": "sys"}], [{**TOOL, "namespace": ns}])
    check([{"role": "system", "content": "sys"}], [{**TOOL, "function": {**TOOL["function"], "namespace": ns}}])
for call in [{**CALL, "namespace": "search"}, {**CALL, "function": {"name": "search::lookup", "arguments": {}}}]:
    check([{"role": "user", "content": "q"}, {"role": "assistant", "content": "", "tool_calls": [call]}], thinking_mode="thinking")
assert fails([{"role": "user", "content": "q"}], [{**TOOL, "namespace": "a::b"}])
assert fails([{"role": "user", "content": "q"}, {"role": "assistant", "content": "", "tool_calls": [{**CALL, "namespace": "files", "function": {"name": "search::lookup", "arguments": {}}}]}])

drop_thinking

messages = [
    {"role": "user", "content": "q1"},
    {"role": "assistant", "content": "a1", "reasoning_content": "r1"},
    {"role": "user", "content": "q2"},
    {"role": "assistant", "reasoning_content": "r2", "tool_calls": [{**CALL, "id": "c1"}]},
    {"role": "tool", "tool_call_id": "c1", "content": "value"},
]
for drop in [True, False]:
    check(messages, thinking_mode="thinking", drop_thinking=drop)
    check(messages + [{"role": "user", "content": "q3"}], thinking_mode="thinking", drop_thinking=drop)
    check(messages, [TOOL], thinking_mode="thinking", drop_thinking=drop)

Tool results in call order

calls = [{**CALL, "id": i} for i in "ab"]
results = [{"role": "tool", "tool_call_id": i, "content": [{"type": "text", "text": f"result {i}"}, {"type": "image_url", "image_url": {"url": f"{i}.png"}}]} for i in "ba"]
check([{"role": "user", "content": "q"}, {"role": "assistant", "content": "", "tool_calls": calls}] + results)

Tasks

check([{"role": "user", "content": "classify me", "task": "query"}])
check([{"role": "user", "content": "q", "task": "action"}, {"role": "assistant", "content": "Search"}])

Random conversations

rng = random.Random(0)
texts = ["Hi", "", "多行\n文本", 'quote " \\ ', "</think>", "{{ x }}"]
values = ["Paris", "", 0, 2.5, True, None, [1, "a"], {"k": [False]}]
for _ in range(5000):
    messages, n = [], 0
    if rng.random() < 0.6:
        messages.append({"role": "system", "content": rng.choice(texts)})
        if rng.random() < 0.2:
            messages.append({"role": "latest_reminder", "content": "2026-10-17"})
    for _ in range(rng.randint(1, 3)):
        content = rng.choice(texts) if rng.random() < 0.8 else [{"type": "text", "text": rng.choice(texts)}, {"type": "image_url", "image_url": {"url": "a.png"}}]
        messages.append({"role": "system" if messages and rng.random() < 0.1 else "user", "content": content})
        for _ in range(rng.randint(0, 2)):
            calls = [{"id": f"c{n + i}", "type": "function", "function": {"name": "lookup", "arguments": {"query": rng.choice(values)}}} for i in range(rng.randint(1, 3))]
            n += len(calls)
            messages.append({"role": "assistant", "content": rng.choice(texts), "reasoning_content": rng.choice(texts), "tool_calls": calls})
            messages += rng.sample([{"role": "tool", "tool_call_id": c["id"], "content": rng.choice(texts)} for c in calls], len(calls))
        messages.append({"role": "assistant", "content": rng.choice(texts), "reasoning_content": rng.choice(texts)})
    messages = messages[: rng.randint(1, len(messages))]
    check(messages, [TOOL] if rng.random() < 0.4 else None, thinking_mode=rng.choice(["chat", "thinking"]), reasoning_effort=rng.choice([None, "low", "max", rng.randint(1, 100)]), drop_thinking=rng.random() < 0.5)

Request kwargs

q = [{"role": "user", "content": "q"}]
assert render(q).endswith("<|Assistant|><think>")
for kwargs in [{"thinking": False}, {"enable_thinking": False}, {"thinking_mode": "chat"}, {"reasoning_effort": "none"}]:
    assert render(q, **kwargs).endswith("<|Assistant|></think>")
for effort, budget in [("minimal", 50), ("low", 50), ("medium", 75), ("high", 75), ("xhigh", 75), ("max", 100)]:
    assert f"Reasoning Effort: {budget} " in render(q, reasoning_effort=effort)

vLLM forwards thinking (vllm 0.31.0)

from vllm.renderers.hf import resolve_chat_template_kwargs

assert "thinking" in resolve_chat_template_kwargs(tok, tok.chat_template, {"thinking": False})

huggingface/transformers#45419

base = [{"role": "user", "content": "hi"}]
call = {"type": "function", "function": {"name": "f", "arguments": {"a": 1}}}
assert len({render(base + [{"role": "assistant", "tool_calls": [call], **extra}]) for extra in [{}, {"content": None}, {"content": ""}]}) == 1
assert fails(base + [{"role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": {"name": "f", "arguments": '{"a": 1}'}}]}])

huggingface/transformers#45422

prefix = [{"role": "user", "content": "q"}, {"role": "assistant", "content": "", "tool_calls": [{**CALL, "id": "c"}]}]
assert render(prefix + [{"role": "tool", "tool_call_id": "c", "content": None}]) == render(prefix + [{"role": "tool", "tool_call_id": "c"}])
assert render([{"role": "system", "content": None}, {"role": "user", "content": "q"}]) == render([{"role": "system"}, {"role": "user", "content": "q"}])
assert render([{"role": "user", "content": None}]) == render([{"role": "user"}])

Tokenizer

markers = ["<|begin▁of▁sentence|>", "<|end▁of▁sentence|>", "<|System|>", "<|User|>", "<|Assistant|>", "<|latest_reminder|>", "<think>", "</think>", "|DSML|", "<|deepseek_image|>", "<|action|>", "<|query|>", "<|authority|>", "<|domain|>", "<|title|>", "<|read_url|>"]
assert all(len(tok(m, add_special_tokens=False).input_ids) == 1 for m in markers)
text = render([{"role": "user", "content": "q"}, {"role": "assistant", "reasoning_content": "r", "content": "", "tool_calls": [CALL]}], [TOOL], thinking_mode="thinking")
assert tok.decode(tok(text, add_special_tokens=False).input_ids) == text
assert tok(text).input_ids.count(tok.bos_token_id) == 1

Chat Template Inspector

spec = importlib.util.spec_from_file_location("checks", hf_hub_download("trl-lib/chat-template-inspector", "inspector/checks.py", repo_type="space"))
checks = importlib.util.module_from_spec(spec)
sys.modules["checks"] = checks
spec.loader.exec_module(checks)
ctx = checks.Context(obj=tok, is_processor=False, template_source=tok.chat_template, model_id=REPO)
not_passing = {r.name for r in (fn(ctx) for fn in checks.ALL_CHECKS) if r.status != "pass"}
assert not_passing == {"Assistant masking", "Accepts `arguments` as JSON string"}  # no {% generation %} markers; string arguments raise

TRL (trl 1.16.0)

from trl.chat_template_utils import is_chat_template_prefix_preserving, supports_tool_calling
from trl.data_utils import apply_chat_template

assert supports_tool_calling(tok) and is_chat_template_prefix_preserving(tok)
prompt = [{"role": "user", "content": "Weather?"}]
completion = [{"role": "assistant", "reasoning_content": "r", "content": "", "tool_calls": [CALL]}, {"role": "tool", "content": "sunny"}, {"role": "assistant", "reasoning_content": "r", "content": "Sunny."}]
for kwargs in [{}, {"thinking": False}, {"tools": [TOOL]}]:
    out = apply_chat_template({"prompt": prompt, "completion": completion}, tok, **kwargs)
    assert out["prompt"] == tok.apply_chat_template(prompt, add_generation_prompt=True, tokenize=False, **kwargs)
    prompt_ids = tok(out["prompt"], add_special_tokens=False).input_ids
    assert tok(out["prompt"] + out["completion"], add_special_tokens=False).input_ids[: len(prompt_ids)] == prompt_ids

Thanks for this! One issue: it doesn't load in llama.cpp or transformers.js yet, because their Jinja engines don't support dict() or chained comparisons like 1 <= x <= 100 (and llama.cpp also lacks ** unpacking). The patch below fixes that without changing the output in Transformers. We'll add a response template in a separate PR.

Patch
--- a/chat_template.jinja
+++ b/chat_template.jinja
@@ -27,7 +27,7 @@
 {%- set effort_map = {"none": 75, "minimal": 50, "low": 50, "medium": 75, "high": 75, "xhigh": 75, "max": 100} -%}
 {%- if reasoning_effort is string and reasoning_effort in effort_map -%}
     {%- set budget = effort_map[reasoning_effort] -%}
-{%- elif reasoning_effort is integer and 1 <= reasoning_effort <= 100 -%}
+{%- elif reasoning_effort is integer and 1 <= reasoning_effort and reasoning_effort <= 100 -%}
     {%- set budget = reasoning_effort -%}
 {%- else -%}
     {{- raise_exception("Invalid reasoning effort: " ~ reasoning_effort ~ ", should be int within [1,100] or one of \"low\", \"high\", \"max\"") -}}
@@ -66,13 +66,25 @@
     {%- for tool in tool_list -%}
         {%- set fn = tool.function if tool.function is defined else tool -%}
         {%- set ns = tool.namespace if tool.namespace is defined and tool.namespace is not none else fn.get("namespace") -%}
-        {%- set schema = namespace(d={}) -%}
-        {%- for k, v in fn.items() if k != "namespace" -%}{%- set schema.d = dict(schema.d, **{k: v}) -%}{%- endfor -%}
-        {%- set schema.d = dict(schema.d, name=qualified_name(fn.name, ns)) -%}
-        {%- if ns is mapping and ns.description -%}
-            {%- set schema.d = dict(schema.d, description=ns.description ~ "\n" ~ (fn.description or "")) -%}
+        {%- set qname = qualified_name(fn.name, ns) -%}
+        {%- set ns_description = ns.description ~ "\n" ~ (fn.description or "") if ns is mapping and ns.description else none -%}
+        {#- JSON built field by field (same separators as json.dumps) instead of with the dict builtin, which llama.cpp lacks -#}
+        {%- set schema = namespace(fields=[], has_name=false, has_description=false) -%}
+        {%- for k, v in fn.items() if k != "namespace" -%}
+            {%- if k == "name" -%}
+                {%- set value = qname -%}{%- set schema.has_name = true -%}
+            {%- elif k == "description" and ns_description is not none -%}
+                {%- set value = ns_description -%}{%- set schema.has_description = true -%}
+            {%- else -%}
+                {%- set value = v -%}
+            {%- endif -%}
+            {%- set schema.fields = schema.fields + [(k | tojson) ~ ": " ~ (value | tojson)] -%}
+        {%- endfor -%}
+        {%- if not schema.has_name -%}{%- set schema.fields = schema.fields + ['"name": ' ~ (qname | tojson)] -%}{%- endif -%}
+        {%- if ns_description is not none and not schema.has_description -%}
+            {%- set schema.fields = schema.fields + ['"description": ' ~ (ns_description | tojson)] -%}
         {%- endif -%}
-        {%- set ts.schemas = ts.schemas + [schema.d | tojson] -%}
+        {%- set ts.schemas = ts.schemas + ["{" ~ (schema.fields | join(", ")) ~ "}"] -%}
     {%- endfor -%}
     {{- "## Tools\n\nYou have access to a set of tools to help answer the user's question. You can invoke tools by writing a \"<|DSML| calls>\" block like the following:\n\n<|DSML| calls>\n<|DSML| invoke name=\"$TOOL_NAME\">\n<|DSML| parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</|DSML| parameter>\n...\n</|DSML| invoke>\n<|DSML| invoke name=\"$TOOL_NAME2\">\n...\n</|DSML| invoke>\n</|DSML| calls>\n\nString parameters should be specified as is and set `string=\"true\"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string=\"false\"`.\n\nIf thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.\n\nOtherwise, output directly after </think> with tool calls or final response.\n\n### Available Tool Schemas\n\n" -}}
     {{- ts.schemas | join("\n") -}}
@@ -80,6 +92,8 @@
 {%- endmacro -%}
 
 {#- 1. Merge tool results (and consecutive user turns) into user messages made of content blocks. -#}
+{#- Copies of user/system messages list the fields read below explicitly (rather than copying them with the dict builtin, which llama.cpp
+    lacks); absent fields become none, which every later .get() treats the same as missing. -#}
 {%- set mns = namespace(list=[]) -%}
 {%- for msg in messages -%}
     {%- set content = msg.get("content") -%}
@@ -100,16 +114,16 @@
     {%- if msg.role == "tool" -%}
         {%- set tblock = {"type": "tool_result", "tool_use_id": msg.get("tool_call_id", ""), "content": joined_text(content) if is_blocks else (content or "")} -%}
         {%- if prev is not none and prev.role == "user" -%}
-            {%- set mns.list = mns.list[:-1] + [dict(prev, content_blocks=prev.content_blocks + [tblock])] -%}
+            {%- set mns.list = mns.list[:-1] + [{"role": prev.role, "content": prev.get("content"), "content_blocks": prev.content_blocks + [tblock], "task": prev.get("task"), "tools": prev.get("tools"), "response_format": prev.get("response_format"), "wo_eos": prev.get("wo_eos")}] -%}
         {%- else -%}
             {%- set mns.list = mns.list + [{"role": "user", "content_blocks": [tblock]}] -%}
         {%- endif -%}
     {%- elif msg.role == "user" -%}
         {%- set blocks = content if is_blocks else [{"type": "text", "text": content or ""}] -%}
         {%- if prev is not none and prev.role == "user" and prev.get("task") is none -%}
-            {%- set mns.list = mns.list[:-1] + [dict(prev, content_blocks=prev.content_blocks + blocks)] -%}
+            {%- set mns.list = mns.list[:-1] + [{"role": prev.role, "content": prev.get("content"), "content_blocks": prev.content_blocks + blocks, "task": prev.get("task"), "tools": prev.get("tools"), "response_format": prev.get("response_format"), "wo_eos": prev.get("wo_eos")}] -%}
         {%- else -%}
-            {%- set mns.list = mns.list + [dict(msg, content_blocks=blocks)] -%}
+            {%- set mns.list = mns.list + [{"role": msg.role, "content": msg.get("content"), "content_blocks": blocks, "task": msg.get("task"), "tools": msg.get("tools"), "response_format": msg.get("response_format"), "wo_eos": msg.get("wo_eos")}] -%}
         {%- endif -%}
     {%- else -%}
         {%- set mns.list = mns.list + [msg] -%}
@@ -119,7 +133,7 @@
 {#- Top-level tools attach to a leading system message, or to a new empty one. -#}
 {%- if tools -%}
     {%- if mns.list and mns.list[0].role == "system" -%}
-        {%- if not mns.list[0].get("tools") -%}{%- set mns.list = [dict(mns.list[0], tools=tools)] + mns.list[1:] -%}{%- endif -%}
+        {%- if not mns.list[0].get("tools") -%}{%- set mns.list = [{"role": mns.list[0].role, "content": mns.list[0].get("content"), "content_blocks": mns.list[0].get("content_blocks"), "task": mns.list[0].get("task"), "tools": tools, "response_format": mns.list[0].get("response_format"), "wo_eos": mns.list[0].get("wo_eos")}] + mns.list[1:] -%}{%- endif -%}
     {%- else -%}
         {%- set mns.list = [{"role": "system", "content": "", "tools": tools}] + mns.list -%}
     {%- endif -%}
@@ -141,7 +155,7 @@
             {%- for tc_id in sns.ids -%}
                 {%- if tc_id and tc_id == b.get("tool_use_id", "") -%}{%- set order.idx = loop.index0 -%}{%- endif -%}
             {%- endfor -%}
-            {%- set tb.blocks = tb.blocks + [dict(b, _order=order.idx)] -%}
+            {%- set tb.blocks = tb.blocks + [{"type": b.type, "tool_use_id": b.get("tool_use_id", ""), "content": b.content, "_order": order.idx}] -%}
         {%- endfor -%}
         {%- if tb.blocks | length > 1 -%}
             {%- set sorted_blocks = tb.blocks | sort(attribute="_order") -%}
@@ -154,7 +168,7 @@
                     {%- set rb.blocks = rb.blocks + [b] -%}
                 {%- endif -%}
             {%- endfor -%}
-            {%- set sns.list = sns.list + [dict(m, content_blocks=rb.blocks)] -%}
+            {%- set sns.list = sns.list + [{"role": m.role, "content": m.get("content"), "content_blocks": rb.blocks, "task": m.get("task"), "tools": m.get("tools"), "response_format": m.get("response_format"), "wo_eos": m.get("wo_eos")}] -%}
         {%- else -%}
             {%- set sns.list = sns.list + [m] -%}
         {%- endif -%}
GeeeekExplorer changed pull request status to merged

Accelerate

Accelerate is a library that enables the same PyTorch code to be run across any distributed configuration by adding just four lines of code! In short, training and inference at scale made simple, efficient and adaptable.

+ from accelerate import Accelerator
+ accelerator = Accelerator()

+ model, optimizer, training_dataloader, scheduler = accelerator.prepare(
+     model, optimizer, training_dataloader, scheduler
+ )

  for batch in training_dataloader:
      optimizer.zero_grad()
      inputs, targets = batch
      inputs = inputs.to(device)
      targets = targets.to(device)
      outputs = model(inputs)
      loss = loss_function(outputs, targets)
+     accelerator.backward(loss)
      optimizer.step()
      scheduler.step()

Built on torch_xla and torch.distributed, Accelerate takes care of the heavy lifting, so you don't have to write any custom code to adapt to these platforms.
Convert existing codebases to utilize DeepSpeed, perform fully sharded data parallelism, and have automatic support for mixed-precision training!

To get a better idea of this process, make sure to check out the Tutorials!

This code can then be launched on any system through Accelerate's CLI interface:

accelerate launch {my_script.py}
<a class="!no-underline border dark:border-gray-700 p-5 rounded-lg shadow hover:shadow-lg" href="./basic_tutorials/overview"
  >Tutorials
  Learn the basics and become familiar with using Accelerate. Start here if you are using Accelerate for the first time!

<a class="!no-underline border dark:border-gray-700 p-5 rounded-lg shadow hover:shadow-lg" href="./usage_guides/explore"
  >How-to guides
  Practical guides to help you achieve a specific goal. Take a look at these guides to learn how to use Accelerate to solve real-world problems.

<a class="!no-underline border dark:border-gray-700 p-5 rounded-lg shadow hover:shadow-lg" href="./concept_guides/gradient_synchronization"
  >Conceptual guides
  High-level explanations for building a better understanding of important topics such as avoiding subtle nuances and pitfalls in distributed training and DeepSpeed.

<a class="!no-underline border dark:border-gray-700 p-5 rounded-lg shadow hover:shadow-lg" href="./package_reference/accelerator"
  >Reference
  Technical descriptions of how Accelerate classes and methods work.

Sign up or log in to comment