M4a 让模型能算,L20 把文字切成 token,L21 教它怎么选下一个词。可还有个关键问题没解决:你在聊天框里一句一句地说,模型怎么知道"哪句是你说的、哪句是它说的、一轮从哪到哪"?这一课讲对话模板——把一串带角色的消息(system/user/assistant),按这个模型认得的格式,拼成一段带特殊标记的提示词字符串。
这步看似不起眼,却极其关键:每个模型在训练时,对话都是按某种固定格式喂进去的(ChatML、Llama-2 各不相同)。推理时你必须用同一种格式,模型才认得出"轮次"和"角色"。格式拼错,模型轻则答非所问,重则完全不在状态。对话模板就负责把消息正确装进"这个模型的信封"。
先看清这步在整条链路里的位置。你的对话是一串结构化消息:每条有个角色(system/user/assistant)和一段内容。但模型吃的不是这种结构,而是一长串 token(L20)。中间必须有一步把"消息列表"压平成"一段字符串",这一步就是对话模板。
拼出来的字符串里,除了各条消息的正文,还插了一堆特殊标记:标明每条消息从哪开始、到哪结束、是谁说的。这些标记对应词表里的特殊 token(L20),模型正是靠它们识别"现在轮到 assistant 说话了""这一轮用户说完了"。
顺序是:消息列表 -> 套模板拼成字符串 -> 交给词表 tokenize -> 进模型。模板负责"结构到文本",tokenize 负责"文本到 token",两步接力、缺一不可。这也是为什么这一课紧跟在词表(L20)后面——它的产物正是 tokenize 的输入。
反过来想,如果不套模板、直接把用户的话 tokenize 进去会怎样?模型会以为这是一段普通文本的续写,而不是"一轮对话求回应"。它可能继续替用户往下编,而不是作为助手来回答——因为少了那些界定角色和轮次的标记,它根本不知道"该自己说话了"。模板的有无,直接决定模型是"补全"还是"对话"。
正因为对话被编码成了纯文本,很多有趣的事才成为可能:你可以把"系统提示"写进 system 消息里,给模型定个人设;可以把前几轮对话原样拼进去,让它"记住"上下文(其实是每次都把历史重新喂一遍);甚至可以伪造一段助手的话塞进去,引导它往某个方向接。这些灵活玩法,全建立在"对话不过是一段精心格式化的文本"这个事实上。
// 简化自 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 一边支持读取模型自带的模板,一边内置这几十种常见格式兜底——两手准备,尽量让用户不必手动操心。
顺便说,枚举里那个 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 起始标记, 让模型接着写回答
来自模型 GGUF 元数据或用户指定。
先按名精确匹配,认不出就看是否含 <|im_start|>/[INST] 等特征子串。
按选定模板,把消息逐条裹上标记、首尾拼成一段字符串。
为真则末尾追加 assistant 起始标记,让模型接着写回答。
有了这张表,剩下两件事:一是认出该用哪套模板,二是套用它把消息拼出来。
认出靠 llm_chat_detect_template:它先按名字精确匹配(模型 GGUF 里常自带一个模板名/模板串),认不出就退而看模板内容里有没有 <|im_start|>、[INST] 这类特征子串,按特征猜。套用靠 llm_chat_apply_template:给它模板枚举、消息列表、一个输出字符串,它就按这套格式拼好。
消息本身的结构很简单:llama_chat_message 就两个字段,role(角色字符串,如 "user")和 content(内容)。一串这样的消息,就是 apply_template 的输入;它在内部按选定模板,把每条消息裹上对应标记、首尾拼接,吐出最终那段提示词。拿一组具体消息走一遍就清楚了:
检测这一步其实暗藏玄机。最理想的情况是模型 GGUF 里写明了模板名,一查便知;但现实里常常只给出一段模板内容(Jinja 文本),没有名字。这时只能靠"内容里有没有某些特征标记"来反推——看到 [INST] 就猜 Llama-2、看到 <|im_start|> 就猜 ChatML。这种基于特征的启发式不是百分百可靠,但覆盖了绝大多数情况。
套用这一步也比看上去讲究。同一套格式,system 消息有的拼在最前、有的并进第一条 user 消息、有的干脆不支持;多轮对话里,历史消息要不要重复加标记、最后一轮怎么收尾,每种模板都有自己的规矩。apply_template 把这些细节按模板类型一一处理妥当,你只管递进去一个消息列表,它还你一段格式严丝合缝的提示词。
固定枚举、纯字符串拼接、零依赖、快;只认预定义的几十种。C API llama_chat_apply_template 走这条。
渲染任意模板:模型自带的 Jinja chat_template 原样执行,最忠实。common/chat.cpp 封装,还支持工具调用。
内建模板只覆盖"已知的那些模型"。要是来了个全新模型、带着自己独特的模板呢?这就引出第二条路:Jinja。
内建这条路(src/llama-chat.cpp)是纯 C++ 字符串拼接,固定枚举、零依赖、快,但只认预定义的那几十种。C API llama_chat_apply_template 走的就是这条。
Jinja 这条路(vendored 在 common/jinja/)能渲染任意模板:模型在 GGUF 里自带的 chat_template(往往是一段 Jinja 文本)可以被原样执行,最忠实。上层 common/chat.cpp 的 common_chat_templates 封装了它,还顺带支持工具调用、输出解析等高级花样。
为什么不干脆全用 Jinja,省得维护几十种内建模板?因为 Jinja 是一套完整的模板语言,要带一个解释器、要解析执行任意逻辑,开销和复杂度都不小。对那些格式早已固定的常见模型,用几行 C++ 直接拼,又快又稳、还没有解析任意模板带来的安全顾虑。所以内建这条"快路"有它不可替代的价值。
反过来,为什么又非要有 Jinja 不可?因为模型层出不穷,总有内建表里没有的新格式、或带着复杂条件逻辑的模板(比如"有 system 就这样拼、没有就那样拼""带工具定义时再加一段")。这些用固定枚举根本表达不了,只能靠一个真正的模板引擎去执行。两条路各补各的短,合起来才既覆盖广、又跑得快。
还值得一提的是工具调用(function calling)这类高级用法。当你想让模型调用外部工具时,工具的定义、调用的格式、返回的拼接,都要按特定约定塞进提示词——这远超内建模板"拼几条消息"的能力,正是 common 层结合 Jinja 与语法约束(L23)来做的。所以对话模板不只是"聊天",它还是更复杂的"结构化交互"的地基。
它决定要不要在拼好的提示词末尾,补上 assistant 角色的起始标记(如 ChatML 的 <|im_start|>assistant),但不含任何内容。等于在纸上写好"助手:"然后把笔递过去——模型自然会从这个位置接着往下写回答。
为真是聊天的常态:你想要模型作为助手回应,就得把"该它说话"的起点标出来。为假则用于补全:你只想让模型接着某段已有文本往下写,不需要切换到 assistant 身份。一个开关,区分了"对话"和"续写"两种用法。
这也解释了一个常见现象:如果忘了开 add_ass,模型有时会"自言自语"地替用户多说几句,而不是直接回答——因为提示词停在了用户那一轮,没给它"轮到你了"的信号。理解这个开关,能省掉不少"模型怎么不好好回答"的困惑。
因为套模板这件事,需要的只是"模板字符串 + 消息列表",跟整个模型没关系。模板串本身可以来自 GGUF 元数据、也可以由用户直接指定;把 model 从参数里拿掉,函数职责更单一、更好测试、也更灵活。
这是个典型的解耦动作,和 L20 把 llama_token_bos 等改成 llama_vocab_* 一个道理:让每个 API 只依赖它真正需要的东西。旧版带 llama_model * 的重载已经移除,看老代码时别再按那个签名调用。
注释里还点明:这个 C API 不走 jinja,只支持内建的预定义模板列表。换句话说,它是"快而专"的那条路;要 jinja 的全部灵活性,得上 common/chat.cpp 那层。API 的边界划得很清楚。
原则上:模型在 GGUF 里自带了 chat_template(一段 Jinja 文本)、或你需要工具调用这类高级特性时,走 Jinja(common/chat.cpp 的 common_chat_templates,开 use_jinja),最忠实于模型作者的意图。
反过来,模型是已知的主流款、或你想要零依赖、要快要稳时,用内建枚举即可——几十种常见格式都覆盖了,纯 C++ 拼接没有额外开销。命令行小工具、嵌入式场景,往往选这条。
两者不是对立,而是分层覆盖:"已知模型"由内建快速搞定,"任意模型"由 Jinja 兜底。这种"常见走快路、罕见走通路"的设计,和 L20 词表的"高频片段直接收、罕见字符靠字节回退"是同一种务实智慧。
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".
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.
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".
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".
// 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.
| Template | Message 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.
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.
# 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
From the model's GGUF metadata or user-specified.
Match by name first; failing that, look for feature substrings like <|im_start|>/[INST].
By the chosen template, wrap each message in markers and concatenate into one string.
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.
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:
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.
fixed enum, pure string assembly, zero deps, fast; recognizes only the predefined few dozen. The C API llama_chat_apply_template takes this path.
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.
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.
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.
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.
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.
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.
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".