AI 大模型模型部署系列(三):从 Chat Template 到多轮对话,理解 KV Cache 复用

上一篇文章《AI 大模型模型部署系列(二):从 Logits 到 Token,理解 llama.cpp 采样链》中,我们从 Logits 出发,验证了 Top-K、Top-P、Temperature、Seed 和最终选择器如何共同决定下一个 Token。

但是,能够连续生成 Token 还不等于能够正确对话。Instruct 模型需要知道哪段内容来自 System、哪段来自 User、什么时候轮到 Assistant 回答;进入第二轮后,程序还需要让消息历史与 KV Cache 保持同步,避免重复计算已经处理过的 Token。

本次以 examples/simple-chat 为入口,将单轮生成循环扩展为多轮对话程序,并重点处理三个问题:Chat Template 如何组织消息、第二轮为什么只 Decode 增量 Prompt、文本历史与 KV Cache 如何对应。

llama.cpp 的接口变化较快。本文对应本地学习分支的基础版本 bdbfb4c79,其他版本的接口名称和实现细节可能不同。

一、从 Prompt 字符串到结构化消息

普通补全接口接收一段字符串,而聊天程序维护的是一组有角色的消息:

1
2
3
4
struct llama_chat_message {
const char * role;
const char * content;
};

一轮对话通常包含三类角色:

  • system:规定助手的身份、边界和整体行为。
  • user:用户本轮输入。
  • assistant:模型已经生成的历史回答。

这些角色并不会直接送入 Transformer。它们首先经过模型自己的 Chat Template,被序列化为训练时使用的文本格式,然后再 Tokenize。

以本次使用的 Qwen2.5 Instruct 模型为例:

1
2
3
4
5
<|im_start|>system
你是一名专业的 C++ 开发助手。<|im_end|>
<|im_start|>user
记住数字9527<|im_end|>
<|im_start|>assistant

完整数据流是:

1
2
3
4
5
6
7
8
messages(role, content)
→ llama_chat_apply_template()
→ formatted string
→ llama_tokenize()
→ llama_decode()
→ KV Cache + Logits
→ Sampler
→ response tokens

因此,Chat Template 的输出仍然是字符串。它只负责把结构化消息转换成模型熟悉的格式,并不执行分词或推理。

二、add_ass 到底添加了什么

生成前调用模板时使用 add_ass = true

1
2
3
4
5
6
7
llama_chat_apply_template(
chat_template,
messages.data(),
messages.size(),
true,
formatted.data(),
formatted.size());

对于当前 Qwen 模板,它会在末尾补上:

1
<|im_start|>assistant

这个标记的作用不是替模型生成一个回答,而是告诉模型:前面的 System 和 User 消息已经结束,接下来应该继续 Assistant 的内容。

模型回答完成后,计算历史格式化长度时则使用 add_ass = false。此时 Assistant 消息已经在历史中,不应该再额外增加下一轮 Assistant 的起始标记,否则 previous_length 会多算一段尚未进入 KV Cache 的文本。

三、为什么 Instruct 模型不能只输入裸 Prompt

我使用同一条指令进行了对比:

1
记住项目代号是 Falcon,不要解释。

直接把裸文本送入模型时,输出很快偏离指令,继续扩展出大量不需要的项目设计内容,最终耗尽 Context。

加入 Chat Template 后,回答恢复为正常的对话形式:

1
好的,我会记住项目代号是 Falcon。

第二轮询问“项目代号是什么?”,模型能够回答“Falcon”;第三轮要求定义 C++ constexpr 字符串,也能够沿用前文信息。

原因不是 Chat Template 提升了模型能力,而是 Instruct 模型在训练和微调时已经学习了特定的消息边界与角色格式。缺少这些控制标记,相当于部署输入与训练分布不一致,模型无法可靠判断当前文本是指令、历史回答,还是需要继续补全的普通内容。

四、多轮对话为什么只 Decode 增量 Prompt

程序同时维护两份状态:

1
2
消息历史:可读、可编辑的 role/content 文本
KV Cache:各层 Attention 已计算出的 Key/Value 张量

每次加入新的 User 消息后,程序先格式化完整消息历史,再通过上一次的格式化长度截取新增部分:

1
2
3
const std::string incremental_prompt(
formatted.begin() + previous_length,
formatted.begin() + formatted_length);

这里的 previous_length 是字符串的字节位置,而不是 Token 位置。原因很直接:llama_chat_apply_template() 的输入和输出都是字符数据,Tokenize 尚未发生。

第一次输入时,KV Cache 为空,增量 Prompt 包含 System、User 和 Assistant 起始标记:

1
2
3
4
完整 formatted 长度 : 127
previous_length : 0
本轮增量 Prompt : System + User + Assistant start
Prompt Token 数 : 23

第二次输入时,之前的消息已经完成 Decode,因此只需要处理新追加的 User 消息和新的 Assistant 起始标记:

1
2
3
4
完整 formatted 长度 : 278
previous_length : 212
本轮增量 Prompt : User + Assistant start
Prompt Token 数 : 14

第二轮不能把完整历史再次追加给同一个 Context。KV Cache 已经占用了前面 Token 的位置,若把完整历史重新 Decode,它们会被当成新的 Token 继续追加,造成位置重复、上下文浪费,并使实际缓存序列与消息历史不再一一对应。

当然,也可以清空 KV Cache 后重新 Decode 完整历史,但那会丢失复用带来的性能收益。

五、消息历史与 KV Cache 不是同一种“记忆”

消息历史保存的是业务层数据,例如:

1
2
3
system: 你是一名专业的 C++ 开发助手。
user: 记住项目代号是 Falcon。
assistant: 好的,我会记住项目代号是 Falcon。

它可以被保存到文件、裁剪、摘要、搜索或重新组织。

KV Cache 保存的不是文本、Token ID,也不是事实数据库,而是 Transformer 每层对已处理 Token 计算出的 Key 和 Value 张量。它的价值是避免每生成一个新 Token 都重新计算完整前缀。

两者的关系可以概括为:

1
2
3
消息历史 --Chat Template/Tokenize/Decode--> KV Cache
↑ |
└--------- Assistant 文本解码 ----------┘

消息历史负责“以后还能怎样组织这段会话”,KV Cache 负责“当前 Context 中哪些 Attention 计算可以直接复用”。

六、采样 Token 何时进入 KV Cache

Sampler 选出 Token 时,KV Cache 还没有这个 Token 的 K/V。它要先作为下一批输入,再执行一次 Decode:

1
2
3
llama_token token = llama_sampler_sample(sampler, ctx, -1);
llama_batch batch = llama_batch_get_one(&token, 1);
llama_decode(ctx, batch);

也就是说:

1
2
3
4
第 N 次 Decode 产生 Logits
→ Sampler 选出 Token N
→ 第 N+1 次 Decode 处理 Token N
→ Token N 的 K/V 进入 KV Cache

这也引出一个容易忽略的同步问题。模型采样到 EOG 后,如果立即 break,结束 Token 就没有经过 Decode;但 Chat Template 格式化历史时已经把结束标记算入 previous_length。下一轮再按这个长度截取,文本历史会比 KV Cache 多一个结束 Token。

本次代码让 EOG 也完成一次 Decode,再结束本轮生成:

1
2
3
4
5
6
7
if (llama_vocab_is_eog(vocab, token)) {
llama_batch eog_batch = llama_batch_get_one(&token, 1);
if (!decode_batch(eog_batch)) {
return false;
}
break;
}

这样 previous_length、已 Decode 的 Token 序列和 KV Cache 占用位置可以保持一致。

七、System Prompt 改变了什么

本次对照了两种 Chat Template 输入:

  • 只有 User/Assistant:模型能够正确记住 Falcon,并完成连续问答。
  • 增加 C++ 开发助手的 System Prompt:记忆结果相同,但后续代码回答更主动地采用 C++ 语境。

System Prompt 不是独立的权限系统,也不会在模型外部强制执行规则。它仍然只是上下文中的一段高优先级消息,通过训练阶段学到的角色行为影响后续 Token 概率。

因此,对比实验应该关注它对回答风格、约束遵循和任务稳定性的影响,而不能把一次输出差异直接解释成模型能力变化。

八、C++ 消息生命周期修正

llama_chat_message 只保存两个 const char *,不拥有内存。示例中 role 使用字符串字面量,不需要释放;content 通过 strdup() 创建,所以原示例只释放 content

为了避免手动 strdup/free 和异常路径泄漏,本次用 std::string 保存真实消息,再在调用模板前创建临时视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct ChatMessage {
std::string role;
std::string content;
};

static std::vector<llama_chat_message> to_llama_messages(
const std::vector<ChatMessage> & messages) {
std::vector<llama_chat_message> result;
result.reserve(messages.size());

for (const auto & message : messages) {
result.push_back({
message.role.c_str(),
message.content.c_str()
});
}
return result;
}

这里需要保证临时视图只在原始 messages 未发生增删和扩容时使用。当前代码每次修改消息后都会重新创建视图,因此指针生命周期是明确的。

同时补齐了 Model、Context、Sampler 和 Chat Template 创建失败的返回路径,Context 超限也改为返回错误并统一释放资源,不再通过 exit() 跳过清理。

九、Context 满了以后怎么办

KV Cache 容量由 Context 大小限制,不能无限追加。当前学习程序检测到:

1
used_positions + incoming_tokens > llama_n_ctx(ctx)

就结束会话并返回错误。这是正确的保护行为,但还不是完整的长对话方案。

实际系统通常组合使用以下策略:

  1. 丢弃较早的普通对话,只保留 System Prompt 和最近若干轮。
  2. 将较早内容压缩成摘要,再作为新消息放回 Context。
  3. 把用户资料和历史事实保存在数据库或向量库中,按当前问题检索相关片段。
  4. 清理或移动相应的 KV Cache 区间,使缓存与重新组织后的 Token 序列重新对齐。

所谓“长期记忆”主要是模型外部的工程系统。模型在一次推理中只能直接利用当前 Context 内的信息,KV Cache 提升的是这段 Context 的增量计算效率,并不会把聊天内容永久写入模型权重。

十、本次结论

本次从单轮生成推进到了可连续交互的本地对话程序,并完成了几项关键验证:

  1. Chat Template 将结构化角色消息转换为模型训练时熟悉的文本格式,之后才进入 Tokenize 和 Decode。
  2. add_ass = true 用于生成前追加 Assistant 起始标记;回答进入历史后,用 false 计算已完成历史长度。
  3. previous_length 是格式化字符串的字节偏移,第二轮只截取并 Decode 新增文本。
  4. 消息历史是可管理的业务文本,KV Cache 是 Attention 的中间计算状态,两者必须保持 Token 序列一致。
  5. 采样出的 Token 要到下一次 Decode 才进入 KV Cache,结束 Token 也需要考虑同步。
  6. std::string 可以明确消息内存所有权,避免 strdup/free 带来的泄漏风险。

到这里,模型已经可以在同一个 Context 中完成多轮对话。下一步将继续研究结构化输出和 Context 管理,把“会聊天”推进到“能以稳定格式执行任务”,为后续本地 Agent 和模型部署自动化打基础。

参考

本文基于源码阅读和本地实验记录整理,使用 AI 辅助检查文章结构与技术表述,文中的代码、实验结果和结论均由本人复核。


AI 大模型模型部署系列(三):从 Chat Template 到多轮对话,理解 KV Cache 复用
https://suntfly.github.io/2026/08/29/AI大模型模型部署系列(三):从Chat-Template到多轮对话/
作者
sunTFly
发布于
2026年8月29日
许可协议