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 | |
一轮对话通常包含三类角色:
system:规定助手的身份、边界和整体行为。user:用户本轮输入。assistant:模型已经生成的历史回答。
这些角色并不会直接送入 Transformer。它们首先经过模型自己的 Chat Template,被序列化为训练时使用的文本格式,然后再 Tokenize。
以本次使用的 Qwen2.5 Instruct 模型为例:
1 | |
完整数据流是:
1 | |
因此,Chat Template 的输出仍然是字符串。它只负责把结构化消息转换成模型熟悉的格式,并不执行分词或推理。
二、add_ass 到底添加了什么
生成前调用模板时使用 add_ass = true:
1 | |
对于当前 Qwen 模板,它会在末尾补上:
1 | |
这个标记的作用不是替模型生成一个回答,而是告诉模型:前面的 System 和 User 消息已经结束,接下来应该继续 Assistant 的内容。
模型回答完成后,计算历史格式化长度时则使用 add_ass = false。此时 Assistant 消息已经在历史中,不应该再额外增加下一轮 Assistant 的起始标记,否则 previous_length 会多算一段尚未进入 KV Cache 的文本。
三、为什么 Instruct 模型不能只输入裸 Prompt
我使用同一条指令进行了对比:
1 | |
直接把裸文本送入模型时,输出很快偏离指令,继续扩展出大量不需要的项目设计内容,最终耗尽 Context。
加入 Chat Template 后,回答恢复为正常的对话形式:
1 | |
第二轮询问“项目代号是什么?”,模型能够回答“Falcon”;第三轮要求定义 C++ constexpr 字符串,也能够沿用前文信息。
原因不是 Chat Template 提升了模型能力,而是 Instruct 模型在训练和微调时已经学习了特定的消息边界与角色格式。缺少这些控制标记,相当于部署输入与训练分布不一致,模型无法可靠判断当前文本是指令、历史回答,还是需要继续补全的普通内容。
四、多轮对话为什么只 Decode 增量 Prompt
程序同时维护两份状态:
1 | |
每次加入新的 User 消息后,程序先格式化完整消息历史,再通过上一次的格式化长度截取新增部分:
1 | |
这里的 previous_length 是字符串的字节位置,而不是 Token 位置。原因很直接:llama_chat_apply_template() 的输入和输出都是字符数据,Tokenize 尚未发生。
第一次输入时,KV Cache 为空,增量 Prompt 包含 System、User 和 Assistant 起始标记:
1 | |
第二次输入时,之前的消息已经完成 Decode,因此只需要处理新追加的 User 消息和新的 Assistant 起始标记:
1 | |
第二轮不能把完整历史再次追加给同一个 Context。KV Cache 已经占用了前面 Token 的位置,若把完整历史重新 Decode,它们会被当成新的 Token 继续追加,造成位置重复、上下文浪费,并使实际缓存序列与消息历史不再一一对应。
当然,也可以清空 KV Cache 后重新 Decode 完整历史,但那会丢失复用带来的性能收益。
五、消息历史与 KV Cache 不是同一种“记忆”
消息历史保存的是业务层数据,例如:
1 | |
它可以被保存到文件、裁剪、摘要、搜索或重新组织。
KV Cache 保存的不是文本、Token ID,也不是事实数据库,而是 Transformer 每层对已处理 Token 计算出的 Key 和 Value 张量。它的价值是避免每生成一个新 Token 都重新计算完整前缀。
两者的关系可以概括为:
1 | |
消息历史负责“以后还能怎样组织这段会话”,KV Cache 负责“当前 Context 中哪些 Attention 计算可以直接复用”。
六、采样 Token 何时进入 KV Cache
Sampler 选出 Token 时,KV Cache 还没有这个 Token 的 K/V。它要先作为下一批输入,再执行一次 Decode:
1 | |
也就是说:
1 | |
这也引出一个容易忽略的同步问题。模型采样到 EOG 后,如果立即 break,结束 Token 就没有经过 Decode;但 Chat Template 格式化历史时已经把结束标记算入 previous_length。下一轮再按这个长度截取,文本历史会比 KV Cache 多一个结束 Token。
本次代码让 EOG 也完成一次 Decode,再结束本轮生成:
1 | |
这样 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 | |
这里需要保证临时视图只在原始 messages 未发生增删和扩容时使用。当前代码每次修改消息后都会重新创建视图,因此指针生命周期是明确的。
同时补齐了 Model、Context、Sampler 和 Chat Template 创建失败的返回路径,Context 超限也改为返回错误并统一释放资源,不再通过 exit() 跳过清理。
九、Context 满了以后怎么办
KV Cache 容量由 Context 大小限制,不能无限追加。当前学习程序检测到:
1 | |
就结束会话并返回错误。这是正确的保护行为,但还不是完整的长对话方案。
实际系统通常组合使用以下策略:
- 丢弃较早的普通对话,只保留 System Prompt 和最近若干轮。
- 将较早内容压缩成摘要,再作为新消息放回 Context。
- 把用户资料和历史事实保存在数据库或向量库中,按当前问题检索相关片段。
- 清理或移动相应的 KV Cache 区间,使缓存与重新组织后的 Token 序列重新对齐。
所谓“长期记忆”主要是模型外部的工程系统。模型在一次推理中只能直接利用当前 Context 内的信息,KV Cache 提升的是这段 Context 的增量计算效率,并不会把聊天内容永久写入模型权重。
十、本次结论
本次从单轮生成推进到了可连续交互的本地对话程序,并完成了几项关键验证:
- Chat Template 将结构化角色消息转换为模型训练时熟悉的文本格式,之后才进入 Tokenize 和 Decode。
add_ass = true用于生成前追加 Assistant 起始标记;回答进入历史后,用false计算已完成历史长度。previous_length是格式化字符串的字节偏移,第二轮只截取并 Decode 新增文本。- 消息历史是可管理的业务文本,KV Cache 是 Attention 的中间计算状态,两者必须保持 Token 序列一致。
- 采样出的 Token 要到下一次 Decode 才进入 KV Cache,结束 Token 也需要考虑同步。
std::string可以明确消息内存所有权,避免strdup/free带来的泄漏风险。
到这里,模型已经可以在同一个 Context 中完成多轮对话。下一步将继续研究结构化输出和 Context 管理,把“会聊天”推进到“能以稳定格式执行任务”,为后续本地 Agent 和模型部署自动化打基础。
参考
- 上一篇:《AI 大模型模型部署系列(二):从 Logits 到 Token,理解 llama.cpp 采样链》
- llama.cpp 仓库
examples/simple-chat/simple-chat.cppinclude/llama.h
本文基于源码阅读和本地实验记录整理,使用 AI 辅助检查文章结构与技术表述,文中的代码、实验结果和结论均由本人复核。