🦙 llama.cpp 图解教程llama.cpp Visual Guide 第四部分 · llama 推理内部Part 4 · Inside llama inference 22 / 40
第四部分 · llama 推理内部Part 4 · Inside llama inference

对话模板Chat templates

M4a 让模型能算,L20 把文字切成 token,L21 教它怎么选下一个词。可还有个关键问题没解决:你在聊天框里一句一句地说,模型怎么知道"哪句是你说的、哪句是它说的、一轮从哪到哪"?这一课讲对话模板——把一串带角色的消息(system/user/assistant),按这个模型认得的格式,拼成一段带特殊标记的提示词字符串。

这步看似不起眼,却极其关键:每个模型在训练时,对话都是按某种固定格式喂进去的(ChatML、Llama-2 各不相同)。推理时你必须用同一种格式,模型才认得出"轮次"和"角色"。格式拼错,模型轻则答非所问,重则完全不在状态。对话模板就负责把消息正确装进"这个模型的信封"。

🔌 生活类比
对话模板像公文的信封格式:同样一句话,不同机构有不同的抬头和落款。ChatML 把每条消息裹成 <|im_start|>角色 ... <|im_end|>,Llama-2 用 [INST] ... [/INST]。模板做的,就是把你的消息装进这个模型训练时认得的那种信封——装错了信封,收信人就读不懂。

为什么需要模板

消息列表
[{system},{user}]
->
apply_template
套模板
->
提示词串
"<|im_start|>..."
->
tokenize
L20
->
model
L17

先看清这步在整条链路里的位置。你的对话是一串结构化消息:每条有个角色(system/user/assistant)和一段内容。但模型吃的不是这种结构,而是一长串 token(L20)。中间必须有一步把"消息列表"压平成"一段字符串",这一步就是对话模板。

拼出来的字符串里,除了各条消息的正文,还插了一堆特殊标记:标明每条消息从哪开始、到哪结束、是谁说的。这些标记对应词表里的特殊 token(L20),模型正是靠它们识别"现在轮到 assistant 说话了""这一轮用户说完了"。

顺序是:消息列表 -> 套模板拼成字符串 -> 交给词表 tokenize -> 进模型。模板负责"结构到文本",tokenize 负责"文本到 token",两步接力、缺一不可。这也是为什么这一课紧跟在词表(L20)后面——它的产物正是 tokenize 的输入。

🔬 细节 / 源码对应
模板拼出来的是纯文本,模型并不知道什么"角色""轮次"的高级概念,它只是学会了"看到 <|im_start|> 这种标记,就该切换说话人"。模板只是忠实地复刻那个格式。

反过来想,如果不套模板、直接把用户的话 tokenize 进去会怎样?模型会以为这是一段普通文本的续写,而不是"一轮对话求回应"。它可能继续替用户往下编,而不是作为助手来回答——因为少了那些界定角色和轮次的标记,它根本不知道"该自己说话了"。模板的有无,直接决定模型是"补全"还是"对话"。

🌍 宏观理解
模型本身并不"理解"对话,它只是个超级强大的文本续写器。是对话模板和训练数据一起,把"续写"这件事伪装成了"对话"。你看到的一问一答,在模型眼里始终是"给定前文、预测下一个 token"。这个认识很重要——它能帮你理解后面很多看似神奇的行为,其实都只是续写规律的体现。

正因为对话被编码成了纯文本,很多有趣的事才成为可能:你可以把"系统提示"写进 system 消息里,给模型定个人设;可以把前几轮对话原样拼进去,让它"记住"上下文(其实是每次都把历史重新喂一遍);甚至可以伪造一段助手的话塞进去,引导它往某个方向接。这些灵活玩法,全建立在"对话不过是一段精心格式化的文本"这个事实上。

⚠ 注意
模板里的特殊标记,必须是这个模型词表(L20)里真实存在的 token,模型才认得。所以模板和词表是配套的——ChatML 的 <|im_start|> 之所以好使,是因为对应模型的词表里就有这么一个专门的 token。换个不认识这标记的模型硬套 ChatML,反而会把标记拆成一堆碎字节,适得其反。

内建模板表

// 简化自 src/llama-chat.h
enum llm_chat_template {
    LLM_CHAT_TEMPLATE_CHATML,
    LLM_CHAT_TEMPLATE_LLAMA_2,
    LLM_CHAT_TEMPLATE_LLAMA_3,
    LLM_CHAT_TEMPLATE_GEMMA,
    /* ... 五十多种 ... */
    LLM_CHAT_TEMPLATE_UNKNOWN,
};

llama.cpp 内置了一大批常见模型的模板,全列在枚举 llm_chat_template 里——CHATML、LLAMA_2(还有若干变体)、LLAMA_3、GEMMA、MISTRAL、PHI 等等,加起来五十多种。每一种对应一套具体的"标记 + 拼法"。

为什么要硬编码这么多?因为不同模型家族的对话格式是它们训练时定死的,五花八门。把常见的都内置进来,用户拿到一个主流模型,引擎多半能自动认出它该用哪套格式,开箱即用,不用手动指定。

模板消息标记
ChatML<|im_start|>role ... <|im_end|>
Llama-2[INST] ... [/INST]
Llama-3<|start_header_id|>role<|end_header_id|>
Gemma<start_of_turn>role ... <end_of_turn>

看几个代表就懂了:ChatML(很多模型用)拿 <|im_start|>/<|im_end|> 包消息;Llama-2 用 [INST]/[/INST] 框用户指令;Gemma 用 <start_of_turn>;Llama-3 用 <|start_header_id|> 标角色。标记不同,但意图一样:界定角色和轮次边界。

这套"把每个模型的格式收进一张枚举表"的做法,你应该眼熟——和 L15 把架构收进 LLM_ARCH 表、L20 把分词器类型收进 vocab_type 是同一种思路:把"会变的差异"集中成数据,让通用代码照表办事。

你可能会问:模型自己不知道该用哪套格式吗,还要引擎来猜?还真不一定知道。GGUF 文件里可能带一个模板字段(很多新模型会写),但也有不少模型没写、或写得不规范。于是 llama.cpp 一边支持读取模型自带的模板,一边内置这几十种常见格式兜底——两手准备,尽量让用户不必手动操心。

🌍 宏观理解
这几十种模板看着多,其实大同小异,无非是"用什么符号标角色、用什么符号断轮次、system 消息放哪"几个维度的排列组合。把它们一一编码进枚举,是一种"用工程量换通用性"的取舍:写的时候累一点,换来的是"一个引擎通吃主流模型"的便利。这种"宁可自己多写、也要让用户省心"的态度,贯穿了 llama.cpp 的很多设计。

顺便说,枚举里那个 LLM_CHAT_TEMPLATE_UNKNOWN 哨兵也有讲究:当检测既匹配不上名字、又认不出特征时,就落到它。这时引擎会提示"没认出模板",提醒用户手动指定一个,而不是默默用错格式蒙混过去。给"认不出"留一个明确的出口,是健壮设计的常见手法。

一个常被忽略的细节是 system 消息的处理。不同模板对"系统提示"放哪、怎么标记,分歧最大:有的像 ChatML 一样单列一条 system 消息,有的(如某些 Llama-2 变体)要把它揉进第一条 user 消息里,还有的根本不支持独立的 system。所以同一段系统提示,套不同模板出来的位置可能差很远——这也是为什么换模型时,光改提示词内容还不够,得让模板替你摆对位置。

检测与应用

# 伪代码: 套用对话模板
tmpl = llm_chat_detect_template(template_str)   # 先按名, 再按内容特征猜
dest = ""
llm_chat_apply_template(tmpl, messages, dest, add_ass=True)
# add_ass: 末尾追加 assistant 起始标记, 让模型接着写回答
1

模板字符串/名字

来自模型 GGUF 元数据或用户指定。

2

detect_template

先按名精确匹配,认不出就看是否含 <|im_start|>/[INST] 等特征子串。

3

apply_template

按选定模板,把消息逐条裹上标记、首尾拼成一段字符串。

4

add_ass 递话筒

为真则末尾追加 assistant 起始标记,让模型接着写回答。

有了这张表,剩下两件事:一是认出该用哪套模板,二是套用它把消息拼出来。

认出靠 llm_chat_detect_template:它先按名字精确匹配(模型 GGUF 里常自带一个模板名/模板串),认不出就退而看模板内容里有没有 <|im_start|>[INST] 这类特征子串,按特征猜。套用靠 llm_chat_apply_template:给它模板枚举、消息列表、一个输出字符串,它就按这套格式拼好。

💡 实战
这里有个参数值得专门说:add_ass(add assistant)。为真时,拼完所有消息后,会在末尾再追加 assistant 的起始标记(但不含内容)——相当于把话筒递给模型,让它从"该 assistant 说话"的位置开始生成回答。要模型续写回答时打开它;只想补全已有文本时关掉。

消息本身的结构很简单:llama_chat_message 就两个字段,role(角色字符串,如 "user")和 content(内容)。一串这样的消息,就是 apply_template 的输入;它在内部按选定模板,把每条消息裹上对应标记、首尾拼接,吐出最终那段提示词。拿一组具体消息走一遍就清楚了:

追踪模板拼接:结构化消息怎么被压平成一串带特殊标记的纯文本(送进 tokenize 之前的最后一步;内容为示意)。
① 消息列表
system: "You are helpful."user: "Hi"
2 条带角色的结构化消息
套 ChatML
② 压平 + 加标记
<|im_start|>systemYou are helpful.<|im_end|><|im_start|>userHi<|im_end|><|im_start|>assistant
标记包住每条正文,结尾把话筒递给 assistant
→ tokenize
③ 交给 L20
tokenize
这串纯文本再切成 token id

检测这一步其实暗藏玄机。最理想的情况是模型 GGUF 里写明了模板名,一查便知;但现实里常常只给出一段模板内容(Jinja 文本),没有名字。这时只能靠"内容里有没有某些特征标记"来反推——看到 [INST] 就猜 Llama-2、看到 <|im_start|> 就猜 ChatML。这种基于特征的启发式不是百分百可靠,但覆盖了绝大多数情况。

套用这一步也比看上去讲究。同一套格式,system 消息有的拼在最前、有的并进第一条 user 消息、有的干脆不支持;多轮对话里,历史消息要不要重复加标记、最后一轮怎么收尾,每种模板都有自己的规矩。apply_template 把这些细节按模板类型一一处理妥当,你只管递进去一个消息列表,它还你一段格式严丝合缝的提示词。

💡 实战
调试对话效果时,不妨把 apply_template 拼出来的那段字符串原样打印出来看看。很多"模型不好好回答"的问题,根子就在拼出来的提示词格式不对——少了个标记、system 放错了位置、add_ass 忘了开。先看清喂进去的到底长什么样,往往比反复调参数更快定位问题。

两条路:内建 vs Jinja

内建(llama-chat.cpp)

固定枚举、纯字符串拼接、零依赖、快;只认预定义的几十种。C API llama_chat_apply_template 走这条。

Jinja(common/jinja)

渲染任意模板:模型自带的 Jinja chat_template 原样执行,最忠实。common/chat.cpp 封装,还支持工具调用。

内建模板只覆盖"已知的那些模型"。要是来了个全新模型、带着自己独特的模板呢?这就引出第二条路:Jinja

内建这条路(src/llama-chat.cpp)是纯 C++ 字符串拼接,固定枚举、零依赖、快,但只认预定义的那几十种。C API llama_chat_apply_template 走的就是这条。

⚠ 注意
C API llama_chat_apply_template 只收模板字符串、不带模型参数(旧版带 llama_model * 的重载已移除),注释也明说"不用 jinja,只支持预定义列表"。

Jinja 这条路(vendored 在 common/jinja/)能渲染任意模板:模型在 GGUF 里自带的 chat_template(往往是一段 Jinja 文本)可以被原样执行,最忠实。上层 common/chat.cppcommon_chat_templates 封装了它,还顺带支持工具调用、输出解析等高级花样。

🌍 宏观理解
两条路分工很清楚:内建管"已知模型、要快要稳",Jinja 管"任意模型、要忠实"。衔接上,无论哪条路,拼好的提示词都要再经 L20 的 tokenize、进 L17 的 decode——对话模板只是把"消息"变成"字符串"的那一棒。

为什么不干脆用 Jinja,省得维护几十种内建模板?因为 Jinja 是一套完整的模板语言,要带一个解释器、要解析执行任意逻辑,开销和复杂度都不小。对那些格式早已固定的常见模型,用几行 C++ 直接拼,又快又稳、还没有解析任意模板带来的安全顾虑。所以内建这条"快路"有它不可替代的价值。

反过来,为什么又非要有 Jinja 不可?因为模型层出不穷,总有内建表里没有的新格式、或带着复杂条件逻辑的模板(比如"有 system 就这样拼、没有就那样拼""带工具定义时再加一段")。这些用固定枚举根本表达不了,只能靠一个真正的模板引擎去执行。两条路各补各的短,合起来才既覆盖广、又跑得快。

🌍 宏观理解
对话模板这一层,本质上是在弥合"人类的对话观"和"模型的文本观"之间的鸿沟。人觉得对话是你一言我一语的结构,模型只认一条连续的 token 流。模板就是这两种世界观之间的翻译协议——而它居然能用"一张枚举表 + 一个可选的 Jinja 引擎"就基本搞定,足见把复杂性收进数据是多么有力的一招。

还值得一提的是工具调用(function calling)这类高级用法。当你想让模型调用外部工具时,工具的定义、调用的格式、返回的拼接,都要按特定约定塞进提示词——这远超内建模板"拼几条消息"的能力,正是 common 层结合 Jinja 与语法约束(L23)来做的。所以对话模板不只是"聊天",它还是更复杂的"结构化交互"的地基。

1 add_ass(add assistant)这个开关到底干嘛? 点击展开

它决定要不要在拼好的提示词末尾,补上 assistant 角色的起始标记(如 ChatML 的 <|im_start|>assistant),但不含任何内容。等于在纸上写好"助手:"然后把笔递过去——模型自然会从这个位置接着往下写回答。

为真是聊天的常态:你想要模型作为助手回应,就得把"该它说话"的起点标出来。为假则用于补全:你只想让模型接着某段已有文本往下写,不需要切换到 assistant 身份。一个开关,区分了"对话"和"续写"两种用法。

这也解释了一个常见现象:如果忘了开 add_ass,模型有时会"自言自语"地替用户多说几句,而不是直接回答——因为提示词停在了用户那一轮,没给它"轮到你了"的信号。理解这个开关,能省掉不少"模型怎么不好好回答"的困惑。

2 为什么 C API 的 apply_template 不再带 model 参数? 点击展开

因为套模板这件事,需要的只是"模板字符串 + 消息列表",跟整个模型没关系。模板串本身可以来自 GGUF 元数据、也可以由用户直接指定;把 model 从参数里拿掉,函数职责更单一、更好测试、也更灵活。

这是个典型的解耦动作,和 L20 把 llama_token_bos 等改成 llama_vocab_* 一个道理:让每个 API 只依赖它真正需要的东西。旧版带 llama_model * 的重载已经移除,看老代码时别再按那个签名调用。

注释里还点明:这个 C API 不走 jinja,只支持内建的预定义模板列表。换句话说,它是"快而专"的那条路;要 jinja 的全部灵活性,得上 common/chat.cpp 那层。API 的边界划得很清楚。

3 内建模板 vs Jinja,何时用哪个? 点击展开

原则上:模型在 GGUF 里自带了 chat_template(一段 Jinja 文本)、或你需要工具调用这类高级特性时,走 Jinja(common/chat.cppcommon_chat_templates,开 use_jinja),最忠实于模型作者的意图。

反过来,模型是已知的主流款、或你想要零依赖、要快要稳时,用内建枚举即可——几十种常见格式都覆盖了,纯 C++ 拼接没有额外开销。命令行小工具、嵌入式场景,往往选这条。

两者不是对立,而是分层覆盖:"已知模型"由内建快速搞定,"任意模型"由 Jinja 兜底。这种"常见走快路、罕见走通路"的设计,和 L20 词表的"高频片段直接收、罕见字符靠字节回退"是同一种务实智慧。

✅ 关键要点
  • 对话模板把带角色的消息列表 -> 该模型约定格式的提示词字符串,再交给 L20 tokenize。
  • 内建模板枚举 llm_chat_template(五十多种);llm_chat_detect_template(先按名、再按特征子串)+ llm_chat_apply_template(渲染,add_ass 控制是否递话筒)。
  • llama_chat_message 只有 role + content 两个字段。
  • C API llama_chat_apply_template 只收模板字符串、无 model 参数,且只走内建、不用 jinja。
  • 两条路:内建(llama-chat.cpp,快/已知模型)vs Jinja(common/jinja + common/chat.cpp,任意模型/工具调用)。
💡 设计洞察
对话模板把"模型的对话方言"收进一张表——同样的消息,换个模型就换个信封,引擎主干不必关心。它和 L15 的"表驱动架构"、L20 的"表驱动分词"是同一种智慧:把"每个模型各不相同的部分"沉淀成数据/模板,让通用代码照着办。而内建与 Jinja 两条路,又是"常见走快路、罕见走通路"的经典分层。读懂它,你就明白为什么同一个 llama.cpp 能流利地说几十种模型的"话"。

🧪 自测 · 想一想为什么这么设计

1. 对话模板(chat template)做什么?
  1. 把带角色的消息列表拼成该模型约定格式的提示词字符串
  2. 把文本切成 token
  3. 压缩对话历史
  4. 给回答打分
看答案与解析 点击展开
答案:A。模板负责把 system/user/assistant 的消息列表,按这个模型训练时的格式(插入特殊标记)拼成一段字符串,再交给词表 tokenize。切 token 是 L20、打分是 L21 的事。
2. ChatML 模板用哪对标记包裹每条消息?
  1. <|im_start|> 和 <|im_end|>
  2. {{ 和 }}
  3. <s> 和 </s>
  4. [INST] 和 [/INST]
看答案与解析 点击展开
答案:A。ChatML 用 <|im_start|>role ... <|im_end|> 包每条消息;[INST]/[/INST] 是 Llama-2 的;<s>/</s> 是序列起止符;{{ }} 是 Jinja 语法。
3. add_ass(add assistant)为真时会做什么?
  1. 把回答翻译成英文
  2. 在末尾追加 assistant 起始标记,让模型接着生成回答
  3. 关闭采样
  4. 删除 system 消息
看答案与解析 点击展开
答案:B。add_ass 在拼好的提示词末尾补上 assistant 的起始标记(不含内容),相当于把话筒递给模型,让它从"该助手说话"处续写。聊天时开、纯补全时关。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 结合 L20,说说为什么要"先套对话模板、再 tokenize",如果把顺序反过来会出什么问题。

M4a let the model compute, L20 cut text into tokens, L21 taught it to pick the next word. But one key question remains: you speak sentence by sentence in a chat box, so how does the model know "which line is yours, which is its own, where one turn starts and ends"? This lesson covers chat templates - assembling a list of role-tagged messages (system/user/assistant), in the format this model recognizes, into a prompt string with special markers.

This step looks minor but is crucial: every model was trained with conversations fed in some fixed format (ChatML, Llama-2 differ). At inference you must use the same format for the model to recognize "turns" and "roles". Get the format wrong and the model is, at best, off-topic; at worst, completely out of character. The chat template is what packs messages correctly into "this model's envelope".

🔌 Analogy
A chat template is like an official envelope format: the same words get different headers and sign-offs at different institutions. ChatML wraps each message as <|im_start|>role ... <|im_end|>, Llama-2 uses [INST] ... [/INST]. What the template does is pack your message into the envelope this model learned to recognize at training - wrong envelope, and the recipient cannot read it.

Why a template is needed

message list
[{system},{user}]
->
apply_template
apply template
->
prompt string
"<|im_start|>..."
->
tokenize
L20
->
model
L17

First see where this step sits in the whole pipeline. Your conversation is a list of structured messages: each has a role (system/user/assistant) and some content. But the model doesn't eat this structure - it eats a long string of tokens (L20). There must be a step that flattens "the message list" into "one string", and that step is the chat template.

The assembled string, besides each message's body, inserts a pile of special markers: marking where each message starts, ends, and who spoke. These markers correspond to special tokens in the vocab (L20), and the model relies on them to recognize "it is the assistant's turn now", "the user is done with this turn".

The order is: message list -> apply the template into a string -> hand to the vocab to tokenize -> into the model. The template handles "structure to text", tokenize handles "text to tokens" - two relays, neither dispensable. This is also why this lesson follows the vocab (L20) closely - its product is exactly tokenize's input.

🔬 Details / source
What the template assembles is plain text; the model has no high-level notion of "role" or "turn", it merely learned that "seeing a marker like <|im_start|> means switch speakers". The template just faithfully reproduces that format.

Conversely, what if you skip the template and tokenize the user's words directly? The model would think it is the continuation of some ordinary text, not "a turn of dialogue asking for a reply". It might keep writing on the user's behalf rather than answer as the assistant - because without the markers delimiting role and turn, it has no idea "it is its turn to speak". The presence or absence of a template directly decides whether the model "completes" or "converses".

🌍 Big picture
The model itself does not "understand" conversation; it is just an extremely powerful text continuer. It is the chat template together with the training data that disguises "continuation" as "conversation". The question-and-answer you see is, to the model, always "given the prefix, predict the next token". This realization matters - it helps you understand why many seemingly magical behaviors later are just continuation patterns at work.

Precisely because conversation is encoded as plain text, many interesting things become possible: you can write a "system prompt" into the system message to give the model a persona; you can splice prior turns verbatim so it "remembers" context (really, the history is re-fed each time); you can even insert a fabricated assistant line to steer where it continues. These flexible tricks all rest on the fact that "a conversation is just carefully formatted text".

⚠ Heads-up
The template's special markers must be tokens that truly exist in this model's vocab (L20) for the model to recognize them. So template and vocab are a matched set - ChatML's <|im_start|> works because that model's vocab has a dedicated token for it. Force ChatML onto a model that does not know the marker and it gets split into a pile of byte fragments, backfiring.

The built-in template table

// simplified from src/llama-chat.h
enum llm_chat_template {
    LLM_CHAT_TEMPLATE_CHATML,
    LLM_CHAT_TEMPLATE_LLAMA_2,
    LLM_CHAT_TEMPLATE_LLAMA_3,
    LLM_CHAT_TEMPLATE_GEMMA,
    /* ... fifty-odd of them ... */
    LLM_CHAT_TEMPLATE_UNKNOWN,
};

llama.cpp ships a big batch of common models' templates, all listed in the enum llm_chat_template - CHATML, LLAMA_2 (plus several variants), LLAMA_3, GEMMA, MISTRAL, PHI, and so on, fifty-odd in total. Each corresponds to a concrete "markers + assembly rule".

Why hardcode so many? Because each model family's chat format is fixed at its training time, and they vary widely. Building the common ones in means that, given a mainstream model, the engine can mostly auto-detect which format to use, working out of the box without manual specification.

TemplateMessage markers
ChatML<|im_start|>role ... <|im_end|>
Llama-2[INST] ... [/INST]
Llama-3<|start_header_id|>role<|end_header_id|>
Gemma<start_of_turn>role ... <end_of_turn>

A few representatives make it clear: ChatML (used by many models) wraps messages with <|im_start|>/<|im_end|>; Llama-2 frames user instructions with [INST]/[/INST]; Gemma uses <start_of_turn>; Llama-3 marks roles with <|start_header_id|>. Different markers, same intent: delimit role and turn boundaries.

This "gather every model's format into one enum table" should look familiar - the same idea as L15 gathering architectures into the LLM_ARCH table and L20 gathering tokenizer types into vocab_type: concentrate "the differences that vary" into data, and let generic code act by the table.

You might ask: does the model not know which format to use, needing the engine to guess? Not necessarily. The GGUF file may carry a template field (many new models write one), but plenty of models omit it or write it loosely. So llama.cpp both supports reading the model's own template and builds in these dozens of common formats as a backstop - a two-pronged setup to spare the user manual fuss.

🌍 Big picture
These dozens of templates look many but are largely alike, just permutations of a few dimensions: "which symbol marks the role, which breaks the turn, where the system message goes". Encoding them one by one into an enum is an "engineering effort for generality" trade-off: a bit more work to write, in exchange for "one engine handling mainstream models". This "rather write more ourselves than burden the user" attitude runs through much of llama.cpp's design.

By the way, the enum's LLM_CHAT_TEMPLATE_UNKNOWN sentinel has a purpose too: when detection matches neither a name nor a feature, it lands here. The engine then signals "template not recognized", prompting the user to specify one manually rather than silently using a wrong format. Leaving a clear exit for "cannot recognize" is a common robust-design technique.

An often-overlooked detail is how the system message is handled. Templates diverge most on where the "system prompt" goes and how it is marked: some, like ChatML, list a separate system message; some (like certain Llama-2 variants) fold it into the first user message; some support no standalone system at all. So the same system prompt can land in very different places under different templates - which is also why, switching models, changing the prompt text alone is not enough; the template must place it correctly for you.

Detection and application

# pseudocode: apply a chat template
tmpl = llm_chat_detect_template(template_str)   # by name first, then guess by content
dest = ""
llm_chat_apply_template(tmpl, messages, dest, add_ass=True)
# add_ass: append the assistant start marker so the model writes the reply
1

template string/name

From the model's GGUF metadata or user-specified.

2

detect_template

Match by name first; failing that, look for feature substrings like <|im_start|>/[INST].

3

apply_template

By the chosen template, wrap each message in markers and concatenate into one string.

4

add_ass hands the mic

If true, append the assistant start marker so the model continues the reply.

With this table, two things remain: recognize which template to use, and apply it to assemble the messages.

Recognizing uses llm_chat_detect_template: it first matches by name exactly (the model's GGUF often carries a template name/string), and failing that, looks for feature substrings like <|im_start|> or [INST] in the template body and guesses by feature. Applying uses llm_chat_apply_template: give it the template enum, the message list, and an output string, and it assembles per that format.

💡 Tip
One parameter deserves a special mention: add_ass (add assistant). When true, after all messages are assembled it appends the assistant's start marker at the end (but no content) - like handing the mic to the model, letting it start generating the reply from where "the assistant should speak". Turn it on when you want the model to continue a reply; off when you only want to complete existing text.

The message's own structure is simple: llama_chat_message has just two fields, role (a role string, e.g. "user") and content (the content). A list of such messages is the input to apply_template; internally, per the chosen template, it wraps each message in its markers, concatenates head to tail, and emits that final prompt. Walk a concrete message set through it to see:

Tracing template assembly: how structured messages flatten into one marked-up plain-text string (the last step before tokenize; content illustrative).
(1) message list
system: "You are helpful."user: "Hi"
2 structured role-tagged messages
apply ChatML
(2) flatten + mark
<|im_start|>systemYou are helpful.<|im_end|><|im_start|>userHi<|im_end|><|im_start|>assistant
markers wrap each body; ends handing the mic to assistant
-> tokenize
(3) hand to L20
tokenize
this plain text then splits into token ids

Detection actually hides subtlety. Ideally the GGUF states the template name and a lookup settles it; but in reality it often gives only a template body (Jinja text) with no name. Then one can only infer from "whether certain feature markers appear in the body" - see [INST] and guess Llama-2, see <|im_start|> and guess ChatML. This feature-based heuristic is not 100% reliable but covers the vast majority.

Application is also fussier than it looks. For the same format, the system message may go at the very front, be merged into the first user message, or not be supported at all; in multi-turn dialogue, whether history repeats the markers and how the last turn closes - each template has its own rules. apply_template handles these details per template type, so you just hand in a message list and it returns a precisely formatted prompt.

💡 Tip
When debugging chat behavior, print the string apply_template produces verbatim and look at it. Many "the model won't answer properly" issues are rooted in a wrong assembled prompt - a missing marker, the system placed wrong, add_ass forgotten. Seeing exactly what is fed in often locates the problem faster than repeatedly tuning parameters.

Two paths: built-in vs Jinja

Built-in (llama-chat.cpp)

fixed enum, pure string assembly, zero deps, fast; recognizes only the predefined few dozen. The C API llama_chat_apply_template takes this path.

Jinja (common/jinja)

renders any template: the model's own Jinja chat_template runs as-is, most faithful. common/chat.cpp wraps it and even supports tool calls.

The built-in templates cover only "the known models". What about a brand-new model bringing its own unique template? That leads to the second path: Jinja.

The built-in path (src/llama-chat.cpp) is pure C++ string assembly - fixed enum, zero deps, fast, but recognizes only the predefined few dozen. The C API llama_chat_apply_template takes this path.

⚠ Heads-up
The C API llama_chat_apply_template only takes a template string, no model parameter (the old llama_model * overload is removed), and its comment plainly says "no jinja, only the predefined list".

The Jinja path (vendored in common/jinja/) can render any template: the chat_template a model carries in GGUF (often a piece of Jinja text) runs as-is, most faithful. The upper layer common/chat.cpp's common_chat_templates wraps it and also supports tool calls, output parsing, and other advanced tricks.

🌍 Big picture
The two paths divide cleanly: built-in for "known models, fast and steady", Jinja for "any model, faithful". For the hand-off, whichever path, the assembled prompt still goes through L20's tokenize and into L17's decode - the chat template is merely the leg that turns "messages" into "a string".

Why not just use Jinja for everything and skip maintaining dozens of built-in templates? Because Jinja is a full template language, requiring an interpreter and executing arbitrary logic, with non-trivial cost and complexity. For common models whose format is long fixed, a few lines of C++ assembling directly is faster, steadier, and free of the security concerns of running arbitrary templates. So the built-in "fast path" has irreplaceable value.

Conversely, why is Jinja indispensable? Because models keep appearing, and there are always new formats absent from the built-in table, or templates with complex conditional logic ("assemble this way if there is a system message, that way if not", "add a section when tool definitions are present"). A fixed enum simply cannot express these; only a real template engine can execute them. Each path covers the other's weakness; together they are both broad and fast.

🌍 Big picture
The chat-template layer essentially bridges the gap between "the human view of conversation" and "the model's view of text". People see dialogue as a back-and-forth structure; the model knows only one continuous token stream. The template is the translation protocol between these worldviews - and that it largely manages with "one enum table + an optional Jinja engine" shows how powerful settling complexity into data really is.

Also worth mentioning is the advanced use of tool calling (function calling). When you want the model to call an external tool, the tool definitions, the call format, and the splicing of returns must all be packed into the prompt per a specific convention - far beyond the built-in template's "stitch a few messages", and exactly what the common layer does by combining Jinja with grammar constraints (L23). So chat templates are not only "chat"; they are the foundation of more complex "structured interaction" too.

1 What does the add_ass (add assistant) switch actually do? Click to expand

It decides whether to append, at the end of the assembled prompt, the assistant role's start marker (like ChatML's <|im_start|>assistant) with no content. It is like writing "Assistant:" on the page and handing over the pen - the model naturally continues writing the reply from that spot.

True is the norm for chat: if you want the model to respond as the assistant, you must mark the start of "its turn to speak". False is for completion: when you only want the model to continue some existing text, with no switch to the assistant identity. One switch separates "converse" from "continue".

This also explains a common phenomenon: forget add_ass and the model sometimes "talks to itself", adding a few more lines on the user's behalf instead of answering directly - because the prompt stopped at the user's turn, with no "your turn" signal. Understanding this switch saves much "why won't the model answer properly" confusion.

2 Why does the C API apply_template no longer take a model parameter? Click to expand

Because applying a template needs only "a template string + a message list", nothing to do with the whole model. The template string can come from GGUF metadata or be given by the user directly; dropping model from the parameters makes the function's job more single-purpose, easier to test, and more flexible.

This is a classic decoupling move, the same idea as L20 renaming llama_token_bos etc. to llama_vocab_*: let each API depend only on what it truly needs. The old overload taking llama_model * is removed, so do not call it by that signature when reading old code.

The comment also makes plain: this C API does not use jinja, only the built-in predefined template list. In other words, it is the "fast and specialized" path; for jinja's full flexibility you go to the common/chat.cpp layer. The API draws its boundary clearly.

3 Built-in vs Jinja, when to use which? Click to expand

In principle: when the model carries its own chat_template in GGUF (a piece of Jinja text), or you need advanced features like tool calls, go Jinja (common/chat.cpp's common_chat_templates with use_jinja), most faithful to the model author's intent.

Conversely, when the model is a known mainstream one, or you want zero deps, fast and steady, the built-in enum suffices - it covers dozens of common formats, with no extra cost of pure C++ assembly. Command-line tools and embedded scenarios often pick this path.

The two are not opposed but a layered coverage: "known models" handled fast by built-in, "any model" backstopped by Jinja. This "common takes the fast path, rare takes the full path" is the same pragmatic wisdom as L20's vocab "keep high-frequency pieces directly, rare chars via byte fallback".

✅ Key points
  • A chat template turns a role-tagged message list -> a prompt string in the model's agreed format, then handed to L20 tokenize.
  • Built-in template enum llm_chat_template (fifty-odd); llm_chat_detect_template (by name, then feature substring) + llm_chat_apply_template (render, add_ass controls whether to hand over the mic).
  • llama_chat_message has just two fields, role + content.
  • The C API llama_chat_apply_template takes only a template string, no model parameter, and goes built-in only, not jinja.
  • Two paths: built-in (llama-chat.cpp, fast / known models) vs Jinja (common/jinja + common/chat.cpp, any model / tool calls).
💡 Design insight
The chat template gathers "a model's conversational dialect" into a table - the same messages, a different model, a different envelope, with the engine trunk none the wiser. It is the same wisdom as L15's "table-driven architecture" and L20's "table-driven tokenization": settle "the part each model differs in" into data/templates and let generic code act by it. And the built-in vs Jinja two paths are the classic "common takes the fast path, rare takes the full path" layering. Understand it, and you see why one llama.cpp can fluently speak the "tongue" of dozens of models.

🧪 Self-test - think about the design

1. What does a chat template do?
  1. assemble a role-tagged message list into a prompt string in the model's agreed format
  2. cut text into tokens
  3. compress the conversation history
  4. score the reply
Show answer & explanation click to expand
Answer: A. The template assembles the system/user/assistant message list into a string per the model's training format (inserting special markers), then hands it to the vocab to tokenize. Cutting tokens is L20, scoring is L21.
2. Which pair of markers does ChatML use to wrap each message?
  1. <|im_start|> and <|im_end|>
  2. {{ and }}
  3. <s> and </s>
  4. [INST] and [/INST]
Show answer & explanation click to expand
Answer: A. ChatML wraps each message as <|im_start|>role ... <|im_end|>; [INST]/[/INST] is Llama-2's; <s>/</s> are sequence delimiters; {{ }} is Jinja syntax.
3. What does add_ass (add assistant) do when true?
  1. translate the reply into English
  2. append the assistant start marker at the end so the model continues generating the reply
  3. turn off sampling
  4. remove the system message
Show answer & explanation click to expand
Answer: B. add_ass appends the assistant's start marker (no content) at the end of the assembled prompt, like handing over the mic so the model continues from 'the assistant's turn'. On for chat, off for plain completion.
💭 Open questions (no single right answer - just think or try)
  • Drawing on L20, explain why you 'apply the chat template first, then tokenize', and what goes wrong if you reverse the order.