0%

大模型应用系列(九) 对话模板对齐

解决在微调和部署时对话模板不统一的问题。

一. 发现问题

我们在大模型应用系列(七) LLama Factory和OpenWebui的安装和使用 | 乌漆嘛黑中学习了如何通过脚本或者LLama-Factory微调大模型,大模型应用系列(六) ollama,vllm,LMDeploy 部署大模型 | 乌漆嘛黑

中学习了如何使用Ollama, vllm, LMDeploy部署大模型,我们在使用的过程中就会发现,如果在LLamaFactory上微调到基本收敛了,此时在LLamaFactory上的对话框输出是正常的,符合预期的,但是使用其他部署工具部署的模型输出又不一样了。以下展示一些不同, 这里的模型是大模型应用系列(七) LLama Factory和OpenWebui的安装和使用 | 乌漆嘛黑训练的弱智吧对话AI,

1.1 LLamaFactory

image-20250328011605973

1.2 openWebui

image-20250328005447733

1.3 vllm

这里直接使用之前的vllm部署中多轮对话的代码

image-20250328010119136

可以看出,模型的输出都有区别,其中LLamaFactory的输出最符合数据集(和数据集一样,因为训练loss基本不下降了)。

二. 分析

发生上述原因主要是不同的框架使用的对话模板不同,首先所有大模型的文件中存在模型自定义的对话模板,但是不同的大模型的对话模板是不同的,下面分析不同模型和框架的对话模板:

2.1 Qwen 对话模板

大模型自带的对话模板可以从它文件中的tokenizer_config.json中查看,比如qwen2.5的对话模板为:

1
"chat_template": "{%- if tools %}\n    {{- '<|im_start|>system\\n' }}\n    {%- if messages[0]['role'] == 'system' %}\n        {{- messages[0]['content'] }}\n    {%- else %}\n        {{- 'You are Qwen, created by Alibaba Cloud. You are a helpful assistant.' }}\n    {%- endif %}\n    {{- \"\\n\\n# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n    {%- for tool in tools %}\n        {{- \"\\n\" }}\n        {{- tool | tojson }}\n    {%- endfor %}\n    {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n    {%- if messages[0]['role'] == 'system' %}\n        {{- '<|im_start|>system\\n' + messages[0]['content'] + '<|im_end|>\\n' }}\n    {%- else %}\n        {{- '<|im_start|>system\\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\\n' }}\n    {%- endif %}\n{%- endif %}\n{%- for message in messages %}\n    {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) or (message.role == \"assistant\" and not message.tool_calls) %}\n        {{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>' + '\\n' }}\n    {%- elif message.role == \"assistant\" %}\n        {{- '<|im_start|>' + message.role }}\n        {%- if message.content %}\n            {{- '\\n' + message.content }}\n        {%- endif %}\n        {%- for tool_call in message.tool_calls %}\n            {%- if tool_call.function is defined %}\n                {%- set tool_call = tool_call.function %}\n            {%- endif %}\n            {{- '\\n<tool_call>\\n{\"name\": \"' }}\n            {{- tool_call.name }}\n            {{- '\", \"arguments\": ' }}\n            {{- tool_call.arguments | tojson }}\n            {{- '}\\n</tool_call>' }}\n        {%- endfor %}\n        {{- '<|im_end|>\\n' }}\n    {%- elif message.role == \"tool\" %}\n        {%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != \"tool\") %}\n            {{- '<|im_start|>user' }}\n        {%- endif %}\n        {{- '\\n<tool_response>\\n' }}\n        {{- message.content }}\n        {{- '\\n</tool_response>' }}\n        {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n            {{- '<|im_end|>\\n' }}\n        {%- endif %}\n    {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n    {{- '<|im_start|>assistant\\n' }}\n{%- endif %}\n",

同样,可以从qwen1.5的tokenizer_config.json文件中查看qwen1.5的对话模板

1
"chat_template": "{% for message in messages %}{% if loop.first and messages[0]['role'] != 'system' %}{{ '<|im_start|>system\nYou are a helpful assistant<|im_end|>\n' }}{% endif %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}",

对比可以看出, qwen2.5和qwen1.5的对话模板是不一样的。同样,比如GPT的对话模板和Qwen更是不一样的,也就是说大模型的对话模板并没有统一标准,实际上也不能统一,因为不同大模型架构是不同的,qwen1.5和qwen2.5架构也是有区别的,所以对话模板也不同。但这样我们的输入都需要按对话模板转为后才输入到大模型,同样的输入经过不同的对话模板就会变得不同。

2.2 llamaFactory 对话模板

LLamaFactory中,我们在配置的时候,有个比较奇怪的地方,我们用的是自己的模型,按理来说只需要把模型路径传进去就行,为什么还要选择它的基座模型是什么。我们注意到选择不同系列的模型,变化的除了模型路径(可以使用我们自己的模型路径替换)还有对话模板。其实这个选项主要就是为了选择对话模板,也就是说LLamaFactory内置了很多对话模板,通过模型名称选择对应的对话模板,同时我们也注意到Qwen的qwen, qwen1.5,qwen2,qwen2.5系列对应的对话模板都是一样的。

image-20250328012417165

也就是说LLamaFactory的对话模板不是用大模型的模板,而是用自定义的模板。LLamaFactory的对话模板定义在在源码中的LLaMA-Factory/src/llamafactory/data/template.py文件中,比如Qwen的对话模板定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# copied from chatml template
register_template(
name="qwen",
format_user=StringFormatter(slots=["<|im_start|>user\n{{content}}<|im_end|>\n<|im_start|>assistant\n"]),
format_assistant=StringFormatter(slots=["{{content}}<|im_end|>\n"]),
format_system=StringFormatter(slots=["<|im_start|>system\n{{content}}<|im_end|>\n"]),
format_function=FunctionFormatter(slots=["{{content}}<|im_end|>\n"], tool_format="qwen"),
format_observation=StringFormatter(
slots=["<|im_start|>user\n<tool_response>\n{{content}}\n</tool_response><|im_end|>\n<|im_start|>assistant\n"]
),
format_tools=ToolFormatter(tool_format="qwen"),
default_system="You are a helpful assistant.",
stop_words=["<|im_end|>"],
)

所以当我们在前端选择的时候,就会注册一个对话模板,LLamaFactory的对话模板是参考官方的,但是还是不一样的,我们微调Qwen的时候,不管是qwen1.5还是qwen2.5,都是qwen的模板,微调完,我们用LLamaFactory的Chat功能,它也是用qwen的对话模板,这时候输出是正常的,但当我们使用vllm部署后,它默认使用的是模型自带的模板,就和我们微调时的模板不一样,就可能产生不一样的输出。

2.3 vllm对话模板

vllm对话模板的介绍可以从其官方文档中找到OpenAI 兼容服务器 | vLLM 中文站

文档中给出了自定义对话模板的办法,即通过--chat-template定义指明对话模板文件,对话模板的格式为Jinja2, 它是python的模板引擎,具体介绍可在其官网找到欢迎来到 Jinja2 — Jinja2 2.7 documentation

大部分大模型的对话模板都是用Jinjia2, 也有一些用json

2.3 OpenWebui对话模板

OpenWebui会覆盖掉我们定义的模板,并且没有提供自定义对话模板,所以还没找到解决办法。

三. 解决方案

3.1 统一LLamaFactory和vllm对话模板

做法:把vllm的对话模板替换为LLamaFactory的对话模板。

问题:为什么不把LLamaFactory的对话模板替换为模型自带模板?

解答:LLamaFactory把数据处理封装成服务,我们只需要按照LLama规定的格式(json),处理数据集,导入数据集后它后面还是会像之前微调Bert 大模型应用系列(三) Bert微调-评论情感分析 | 乌漆嘛黑那样封装成dataLoader,如果把对话模板改了,封装过程可能也得改变,就得改它的源码,是比较困难的 ,这也是LLamaFactory为什么qwen1.5和qwen2.5使用相同模板,因为如果每个系列的模型都用不同模板,那要定义很多封装过程,维护成本太高。vllm提供了修改对话模板的接口,我们目的是保持vllm和微调时对话模板一致,所以我们选择更改vllm对话模板。

具体做法:把2.2中的对话模板转化为Jinja2格式,启动vllm时传入参数。

通过阅读LLamaFactory源码,我发现它在文件LLaMA-Factory/src/llamafactory/data/template.py中提供了获取jinja2格式对话模板的函数。

这个方法的功能就是传入预训练模型的名称,返回jinja2格式的对话模板, 这个函数定义在类template中,但它私有化了,我们无法调用,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def _get_jinja_template(self, tokenizer: "PreTrainedTokenizer") -> str:
prefix = self._convert_slots_to_jinja(self.format_prefix.apply(), tokenizer)
system_message = self._convert_slots_to_jinja(
self.format_system.apply(), tokenizer, placeholder="system_message"
)
user_message = self._convert_slots_to_jinja(self.format_user.apply(), tokenizer)
assistant_message = self._convert_slots_to_jinja(self.format_assistant.apply(), tokenizer)
jinja_template = ""
if prefix:
jinja_template += "{{ " + prefix + " }}"

if self.default_system:
jinja_template += "{% set system_message = '" + self._jinja_escape(self.default_system) + "' %}"

jinja_template += (
"{% if messages[0]['role'] == 'system' %}{% set loop_messages = messages[1:] %}"
"{% set system_message = messages[0]['content'] %}{% else %}{% set loop_messages = messages %}{% endif %}"
"{% for message in loop_messages %}"
"{% if loop.index0 == 0 and system_message is defined %}"
"{% set content = " + system_message + " + message['content'] %}"
"{% else %}{% set content = message['content'] %}{% endif %}"
"{% if message['role'] == 'user' %}"
"{{ " + user_message + " }}"
"{% elif message['role'] == 'assistant' %}"
"{{ " + assistant_message + " }}"
"{% endif %}"
"{% endfor %}"
)
return jinja_template

它也没有提供调用这个函数的接口,但是提供了另一个把tokenizer中的对话模板转为LLamaFactory定义的jinja2格式模板的方法:

1
2
3
4
5
6
7
def fix_jinja_template(self, tokenizer: "PreTrainedTokenizer") -> None:
r"""Replace the jinja template in the tokenizer."""
if tokenizer.chat_template is None or self.replace_jinja_template:
try:
tokenizer.chat_template = self._get_jinja_template(tokenizer)
except ValueError as e:
logger.info_rank0(f"Cannot add this chat template to tokenizer: {e}.")

我们可以先定义一个Tokenizer(随便定义都可以),获取template对象,然后传入到fix_jinja_template函数中获取对话模板。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# mytest.py
import sys
import os

# 将项目根目录添加到 Python 路径
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
sys.path.append(root_dir)

from llamafactory.data.template import TEMPLATES
from transformers import AutoTokenizer

# 1. 初始化分词器(任意支持的分词器均可)只是为了获取一个tokenizer对象
tokenizer = AutoTokenizer.from_pretrained("/root/autodl-tmp/Qwen2.5-0.5B-Instruct")

# 2. 获取模板对象
template_name = "qwen" # 替换为你需要查看的模板名称,要跟LLamaFactory的名称一致
template = TEMPLATES[template_name]

# 3. 修复分词器的 Jinja 模板
template.fix_jinja_template(tokenizer)

# 4. 直接输出模板的 Jinja 格式
print("=" * 40)
print(f"Template [{template_name}] 的 Jinja 格式:")
print("=" * 40)
print(tokenizer.chat_template)

# 将模板内容保存到文件
with open("chat_template.jinja", "w", encoding="utf-8") as file:
file.write(tokenizer.chat_template)

print("模板已保存为 chat_template.jinja")

把这个文件放在template.py的同级目录下。最后在同级目录下生成的chat_template.jinja文件即为LLamaFacotory的Qwen对话模板jinja格式。

接着在启动vllm时通过--chat-template 指定对话模板:

1
vllm serve /root/autodl-tmp/Qwen2.5-0.5B-Instruct-ruozhiba --chat-template /root/autodl-tmp/LLaMA-Factory/src/llamafactory/data/chat_template.jinja

通过指定对话模板,vllm输出和原先训练数据一致。

image-20250330211852058

3.2 统一LMDeploy和LLamaFactory的对话模板

LMDeploy的对话模板是json格式,可以将上面得到的jinjia2格式转化为json格式,具体方法见官网自定义对话模板 — lmdeploy

这里我以使用Xtuner微调Qwen2.5-7B-Chat, 并且用LMDeploy部署为例。

在使用Xtuner微调时,需要修改配置文件,配置文件中指定了对话模板:

1
prompt_template = PROMPT_TEMPLATE.qwen_chat

然后到xtuner/xtuner/utils/templates.py 找到qwen_chat模板:

1
2
3
4
5
6
7
8
qwen_chat=dict(
SYSTEM=("<|im_start|>system\n{system}<|im_end|>\n"),
INSTRUCTION=("<|im_start|>user\n{input}<|im_end|>\n" "<|im_start|>assistant\n"),
SUFFIX="<|im_end|>",
SUFFIX_AS_EOS=True,
SEP="\n",
STOP_WORDS=["<|im_end|>", "<|endoftext|>"],
),

我们要将上述模板转化为json格式,可以用AI生成一个脚本即可(在官网找到目标模板,告诉AI现在模板和目标模板),生成如下脚本,根据对话模板不同需要调整,主要调整到和官方示例一致就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import re
import json
from typing import Dict, Any


def universal_converter(original_template: Dict[str, Any]) -> Dict[str, Any]:
"""将多种风格的原始模板转换为lmdeploy官方格式"""

# 字段映射关系(核心逻辑)
field_mapping = {
# 基础字段映射
"SYSTEM": "system",
"INSTRUCTION": ("user", "assistant"), # 需要拆分处理
"SUFFIX": "eoa",
"SEP": "separator",
"STOP_WORDS": "stop_words",

# 特殊处理字段
"SUFFIX_AS_EOS": None, # 该字段在官方模板中不需要
}

# 初始化目标模板(包含必填字段默认值)
converted = {
"meta_instruction": "You are a helpful assistant.", # 必填项
"capability": "chat", # 必填项
"eosys": "<|im_end|>\n", # 通常固定格式
"eoh": "<|im_end|>\n", # 通常固定格式
}

# 自动处理字段映射
for src_key, dest_key in field_mapping.items():
if src_key in original_template:
value = original_template[src_key]

# 处理需要拆分的字段(如INSTRUCTION)
if isinstance(dest_key, tuple) and src_key == "INSTRUCTION":
# 使用正则拆分user和assistant部分
parts = re.split(r'(<\|im_start\|>assistant\n?)', value)
converted["user"] = parts[0].strip()
if len(parts) > 1:
converted["assistant"] = parts[1] + parts[2] if len(parts) > 2 else parts[1]

# 处理直接映射字段
elif dest_key and not isinstance(dest_key, tuple):
converted[dest_key] = value

# 特殊处理system字段的占位符
if "system" in converted:
converted["system"] = converted["system"].replace("{system}", "{{ system }}")

# 处理用户输入占位符
if "user" in converted:
converted["user"] = converted["user"].replace("{input}", "{{ input }}")

# 自动处理停止词(兼容列表和字符串)
if "stop_words" in converted and isinstance(converted["stop_words"], str):
converted["stop_words"] = [converted["stop_words"]]

# 保留原始模板中的额外字段(带警告)
for key in original_template:
if key not in field_mapping:
print(f"Warning: 发现未映射字段 [{key}],已保留原样")
converted[key] = original_template[key]

return converted


# 示例用法
original_qwen_chat = dict(
SYSTEM=("<|im_start|>system\n{system}<|im_end|>\n"),
INSTRUCTION=("<|im_start|>user\n{input}<|im_end|>\n" "<|im_start|>assistant\n"),
SUFFIX="<|im_end|>",
SUFFIX_AS_EOS=True,
SEP="\n",
STOP_WORDS=["<|im_end|>", "<|endoftext|>"]
)

# 执行转换
converted_template = universal_converter(original_qwen_chat)

# 生成JSON文件
with open('chat_template.json', 'w') as f:
json.dump(converted_template, f,
indent=2,
ensure_ascii=False,
separators=(',', ': '))

运行后会生成json文件,在LMDeploy部署时选择对话模板即可

1
lmdeploy serve api_server internlm/internlm2_5-7b-chat --chat-template ${JSON_FILE} --server-port 8000

部署成功后另起终端运行下面推理代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1", # 模型部署在本地
api_key="token-abc123", # 随便写
)


completion = client.chat.completions.create(
model="/root/autodl-tmp/Qwen1.5-0.5B-boot", # 模型路径,要用绝对路径
messages=[
{"role": "user", "content": "今天好累啊"}
]
)


print(completion.choices[0].message.content)

运行后正常输出:

image-20250607162953165

四. 补充

微调时用LLamFactory的模板会导致模型变差吗?

不会,模板只改变输出格式,不改变模型能力,我们微调时使用LLamaFactory的chat方法测试,证明在这种模板下已经获得预期效果了,所以我们部署是要做到是还原模型的效果,所以部署工具用LLamaFactoryd 对话模板。

如果您读文章后有收获,可以打赏我喝咖啡哦~