聊天模板
介绍
LLMs 的一个越来越常见的用例是 聊天。在聊天环境中,模型不是继续一个单一的文本字符串(如标准语言模型的情况),而是继续一个由一个或多个 消息 组成的对话,每条消息包括一个 角色,如“用户”或“助手”,以及消息文本。
与分词类似,不同的模型对聊天输入格式的要求也大不相同。这就是我们添加聊天模板作为功能的原因。聊天模板是分词器的一部分。它们指定了如何将表示为消息列表的对话转换为模型期望的格式的单个可分词字符串。
让我们通过一个使用mistralai/Mistral-7B-Instruct-v0.1
模型的快速示例来具体说明:
>>> from transformers import AutoTokenizer
>>> tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.1")
>>> chat = [
... {"role": "user", "content": "Hello, how are you?"},
... {"role": "assistant", "content": "I'm doing great. How can I help you today?"},
... {"role": "user", "content": "I'd like to show off how chat templating works!"},
... ]
>>> tokenizer.apply_chat_template(chat, tokenize=False)
"<s>[INST] Hello, how are you? [/INST]I'm doing great. How can I help you today?</s> [INST] I'd like to show off how chat templating works! [/INST]"
注意分词器如何添加了控制标记 [INST] 和 [/INST] 来指示用户消息的开始和结束(但不是助手消息!),并且整个聊天被压缩成一个单一的字符串。如果我们使用 tokenize=True
,这是默认设置,该字符串也会被分词。
现在,尝试相同的代码,但换成HuggingFaceH4/zephyr-7b-beta
模型,你应该会得到:
<|user|> Hello, how are you?</s> <|assistant|> I'm doing great. How can I help you today?</s> <|user|> I'd like to show off how chat templating works!</s>
Zephyr 和 Mistral-Instruct 都是从同一个基础模型 Mistral-7B-v0.1
微调而来的。然而,它们使用了完全不同的聊天格式进行训练。如果没有聊天模板,您将不得不为每个模型编写手动格式化代码,这很容易出现小错误,从而影响性能!聊天模板为您处理格式化的细节,使您能够编写适用于任何模型的通用代码。
如何使用聊天模板?
正如你在上面的例子中看到的,聊天模板很容易使用。只需构建一个消息列表,包含role
和content
键,然后将其传递给apply_chat_template()方法。一旦你这样做了,你将得到准备就绪的输出!当使用聊天模板作为模型生成的输入时,使用add_generation_prompt=True
来添加生成提示也是一个好主意。
这是一个准备输入给 model.generate()
的示例,再次使用 Zephyr
:
from transformers import AutoModelForCausalLM, AutoTokenizer
checkpoint = "HuggingFaceH4/zephyr-7b-beta"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForCausalLM.from_pretrained(checkpoint) # You may want to use bfloat16 and/or move to GPU here
messages = [
{
"role": "system",
"content": "You are a friendly chatbot who always responds in the style of a pirate",
},
{"role": "user", "content": "How many helicopters can a human eat in one sitting?"},
]
tokenized_chat = tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=True, return_tensors="pt")
print(tokenizer.decode(tokenized_chat[0]))
这将生成一个符合Zephyr期望的输入格式的字符串。
<|system|> You are a friendly chatbot who always responds in the style of a pirate</s> <|user|> How many helicopters can a human eat in one sitting?</s> <|assistant|>
现在我们的输入已经为Zephyr正确格式化,我们可以使用模型来生成对用户问题的响应:
outputs = model.generate(tokenized_chat, max_new_tokens=128)
print(tokenizer.decode(outputs[0]))
这将产生:
<|system|> You are a friendly chatbot who always responds in the style of a pirate</s> <|user|> How many helicopters can a human eat in one sitting?</s> <|assistant|> Matey, I'm afraid I must inform ye that humans cannot eat helicopters. Helicopters are not food, they are flying machines. Food is meant to be eaten, like a hearty plate o' grog, a savory bowl o' stew, or a delicious loaf o' bread. But helicopters, they be for transportin' and movin' around, not for eatin'. So, I'd say none, me hearties. None at all.
哎呀,毕竟还是很容易的!
是否有自动化的聊天管道?
是的,有!我们的文本生成管道支持聊天输入,这使得使用聊天模型变得容易。过去,我们曾经使用一个专门的“ConversationalPipeline”类,但现在这个类已经被弃用,其功能已经合并到TextGenerationPipeline中。让我们再次尝试Zephyr
示例,但这次使用管道:
from transformers import pipeline
pipe = pipeline("text-generation", "HuggingFaceH4/zephyr-7b-beta")
messages = [
{
"role": "system",
"content": "You are a friendly chatbot who always responds in the style of a pirate",
},
{"role": "user", "content": "How many helicopters can a human eat in one sitting?"},
]
print(pipe(messages, max_new_tokens=128)[0]['generated_text'][-1]) # Print the assistant's response
{'role': 'assistant', 'content': "Matey, I'm afraid I must inform ye that humans cannot eat helicopters. Helicopters are not food, they are flying machines. Food is meant to be eaten, like a hearty plate o' grog, a savory bowl o' stew, or a delicious loaf o' bread. But helicopters, they be for transportin' and movin' around, not for eatin'. So, I'd say none, me hearties. None at all."}
管道将为您处理所有标记化和调用apply_chat_template
的细节 - 一旦模型有了聊天模板,您只需初始化管道并传递消息列表即可!
什么是“生成提示”?
你可能已经注意到apply_chat_template
方法有一个add_generation_prompt
参数。这个参数告诉模板添加表示机器人响应开始的标记。例如,考虑以下聊天:
messages = [
{"role": "user", "content": "Hi there!"},
{"role": "assistant", "content": "Nice to meet you!"},
{"role": "user", "content": "Can I ask a question?"}
]
这是一个没有生成提示的示例,适用于使用标准“ChatML”格式的模型:
tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
"""<|im_start|>user
Hi there!<|im_end|>
<|im_start|>assistant
Nice to meet you!<|im_end|>
<|im_start|>user
Can I ask a question?<|im_end|>
"""
这是带有生成提示的样子:
tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
"""<|im_start|>user
Hi there!<|im_end|>
<|im_start|>assistant
Nice to meet you!<|im_end|>
<|im_start|>user
Can I ask a question?<|im_end|>
<|im_start|>assistant
"""
请注意,这次我们添加了指示机器人响应开始的标记。这确保了当模型生成文本时,它会编写一个机器人响应,而不是做一些意外的事情,比如继续用户的消息。记住,聊天模型仍然只是语言模型——它们被训练来继续文本,而聊天对它们来说只是一种特殊的文本!你需要用适当的控制标记来引导它们,这样它们才知道它们应该做什么。
并非所有模型都需要生成提示。一些模型,如LLaMA,在机器人响应之前没有任何特殊标记。在这些情况下,add_generation_prompt
参数将不会产生任何效果。add_generation_prompt
的确切效果将取决于所使用的模板。
“continue_final_message”是做什么的?
当将消息列表传递给apply_chat_template
或TextGenerationPipeline
时,您可以选择格式化聊天,以便模型将继续聊天中的最后一条消息,而不是开始一条新的消息。这是通过删除任何指示最后一条消息结束的序列结束标记来实现的,这样模型在开始生成文本时就会简单地扩展最后一条消息。这对于“预填充”模型的响应非常有用。
这是一个例子:
chat = [
{"role": "user", "content": "Can you format the answer in JSON?"},
{"role": "assistant", "content": '{"name": "'},
]
formatted_chat = tokenizer.apply_chat_template(chat, tokenize=True, return_dict=True, continue_final_message=True)
model.generate(**formatted_chat)
模型将生成继续JSON字符串的文本,而不是开始一条新消息。当你知道如何希望模型开始其回复时,这种方法可以非常有效地提高模型遵循指令的准确性。
因为 add_generation_prompt
添加了开始新消息的标记,而 continue_final_message
移除了最终消息中的任何结束消息标记,所以同时使用它们是没有意义的。因此,如果你尝试这样做,你会得到一个错误!
TextGenerationPipeline
的默认行为是设置add_generation_prompt=True
,以便开始一个新的消息。然而,如果输入聊天中的最后一条消息具有“assistant”角色,它将假设此消息是预填充的,并切换到continue_final_message=True
,因为大多数模型不支持多个连续的助手消息。您可以通过在调用管道时显式传递continue_final_message
参数来覆盖此行为。
我可以在训练中使用聊天模板吗?
是的!这是确保聊天模板与模型在训练期间看到的标记相匹配的好方法。
我们建议您将聊天模板作为数据集的预处理步骤应用。之后,您可以像处理任何其他语言模型训练任务一样继续。在训练时,通常应设置
add_generation_prompt=False
,因为在训练期间添加的用于提示助手响应的标记不会有帮助。让我们看一个例子:
from transformers import AutoTokenizer
from datasets import Dataset
tokenizer = AutoTokenizer.from_pretrained("HuggingFaceH4/zephyr-7b-beta")
chat1 = [
{"role": "user", "content": "Which is bigger, the moon or the sun?"},
{"role": "assistant", "content": "The sun."}
]
chat2 = [
{"role": "user", "content": "Which is bigger, a virus or a bacterium?"},
{"role": "assistant", "content": "A bacterium."}
]
dataset = Dataset.from_dict({"chat": [chat1, chat2]})
dataset = dataset.map(lambda x: {"formatted_chat": tokenizer.apply_chat_template(x["chat"], tokenize=False, add_generation_prompt=False)})
print(dataset['formatted_chat'][0])
我们得到:
<|user|> Which is bigger, the moon or the sun?</s> <|assistant|> The sun.</s>
从这里开始,只需像处理标准语言建模任务一样继续训练,使用formatted_chat
列。
默认情况下,一些分词器会在它们分词的文本中添加特殊标记,如
和
。聊天模板应该已经包含了它们所需的所有特殊标记,因此额外的特殊标记通常是不正确或重复的,这会损害模型性能。
因此,如果您使用apply_chat_template(tokenize=False)
格式化文本,您应该在稍后对该文本进行标记化时设置参数add_special_tokens=False
。如果您使用apply_chat_template(tokenize=True)
,则无需担心这一点!
高级:聊天模板的额外输入
apply_chat_template
所需的唯一参数是 messages
。然而,你可以传递任何关键字参数给 apply_chat_template
,并且这些参数将在模板内部可访问。这为你提供了很大的自由度,可以使用聊天模板来实现许多功能。这些参数的名称或格式没有任何限制——你可以传递字符串、列表、字典或其他任何你想要的内容。
也就是说,这些额外参数有一些常见的用例,例如传递用于函数调用的工具,或用于检索增强生成的文档。在这些常见情况下,我们对这些参数的名称和格式有一些建议,这些建议在下面的部分中进行了描述。我们鼓励模型作者使他们的聊天模板与此格式兼容,以便在模型之间轻松转移工具调用代码。
高级:工具使用 / 函数调用
“工具使用” LLMs 可以在生成答案之前选择调用函数作为外部工具。当向工具使用模型传递工具时,您可以简单地将函数列表传递给 tools
参数:
import datetime
def current_time():
"""Get the current local time as a string."""
return str(datetime.now())
def multiply(a: float, b: float):
"""
A function that multiplies two numbers
Args:
a: The first number to multiply
b: The second number to multiply
"""
return a * b
tools = [current_time, multiply]
model_input = tokenizer.apply_chat_template(
messages,
tools=tools
)
为了使此功能正常工作,您应该按照上述格式编写您的函数,以便它们能够被正确解析为工具。具体来说,您应该遵循以下规则:
- 函数应该有一个描述性的名称
- 每个参数都必须有类型提示
- 函数必须有一个符合Google风格标准的文档字符串(换句话说,首先是函数的描述
接着是一个Args:
块,用于描述参数,除非函数没有任何参数。 - 不要在
Args:
块中包含类型。换句话说,写a: 要相乘的第一个数字
,而不是a (int): 要相乘的第一个数字
。类型提示应放在函数头中。 - 函数可以有一个返回类型和一个
Returns:
块在文档字符串中。然而,这些是可选的,因为大多数工具使用模型会忽略它们。
将工具结果传递给模型
上面的示例代码足以列出模型可用的工具,但如果它想要实际使用一个工具会发生什么呢?如果发生这种情况,你应该:
- 解析模型的输出以获取工具名称和参数。
- 将模型的工具调用添加到对话中。
- 使用这些参数调用相应的函数。
- 将结果添加到对话中
一个完整的工具使用示例
让我们一步一步地通过一个工具使用示例。在这个示例中,我们将使用一个8B的Hermes-2-Pro
模型,因为在撰写本文时,它是其尺寸类别中性能最高的工具使用模型之一。如果你有足够的内存,你可以考虑使用更大的模型,比如Command-R或Mixtral-8x22B,这两个模型也支持工具使用,并提供更强的性能。
首先,让我们加载我们的模型和分词器:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
checkpoint = "NousResearch/Hermes-2-Pro-Llama-3-8B"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForCausalLM.from_pretrained(checkpoint, torch_dtype=torch.bfloat16, device_map="auto")
接下来,让我们定义一个工具列表:
def get_current_temperature(location: str, unit: str) -> float:
"""
Get the current temperature at a location.
Args:
location: The location to get the temperature for, in the format "City, Country"
unit: The unit to return the temperature in. (choices: ["celsius", "fahrenheit"])
Returns:
The current temperature at the specified location in the specified units, as a float.
"""
return 22. # A real function should probably actually get the temperature!
def get_current_wind_speed(location: str) -> float:
"""
Get the current wind speed in km/h at a given location.
Args:
location: The location to get the temperature for, in the format "City, Country"
Returns:
The current wind speed at the given location in km/h, as a float.
"""
return 6. # A real function should probably actually get the wind speed!
tools = [get_current_temperature, get_current_wind_speed]
现在,让我们为我们的机器人设置一个对话:
messages = [
{"role": "system", "content": "You are a bot that responds to weather queries. You should reply with the unit used in the queried location."},
{"role": "user", "content": "Hey, what's the temperature in Paris right now?"}
]
现在,让我们应用聊天模板并生成响应:
inputs = tokenizer.apply_chat_template(messages, tools=tools, add_generation_prompt=True, return_dict=True, return_tensors="pt")
inputs = {k: v.to(model.device) for k, v in inputs.items()}
out = model.generate(**inputs, max_new_tokens=128)
print(tokenizer.decode(out[0][len(inputs["input_ids"][0]):]))
我们得到:
<tool_call> {"arguments": {"location": "Paris, France", "unit": "celsius"}, "name": "get_current_temperature"} </tool_call><|im_end|>
模型已使用有效参数调用了函数,格式符合函数文档字符串的要求。它推断我们最有可能指的是法国的巴黎,并且它记得,作为国际单位制的发源地,法国的温度当然应该以摄氏度显示。
上面的输出格式特定于我们在此示例中使用的Hermes-2-Pro
模型。其他模型可能会发出不同的工具调用格式,您可能需要在此时进行一些手动解析。例如,Llama-3.1
模型会发出稍微不同的JSON,使用parameters
而不是arguments
。无论模型输出的格式如何,您都应该以以下格式将工具调用添加到对话中,包含tool_calls
、function
和arguments
键。
接下来,让我们将模型的工具调用附加到对话中。
tool_call = {"name": "get_current_temperature", "arguments": {"location": "Paris, France", "unit": "celsius"}}
messages.append({"role": "assistant", "tool_calls": [{"type": "function", "function": tool_call}]})
如果你熟悉OpenAI API,你应该注意这里的一个重要区别 - tool_call
是一个字典,但在OpenAI API中它是一个JSON字符串。传递字符串可能会导致错误或奇怪的模型行为!
现在我们已经将工具调用添加到对话中,我们可以调用函数并将结果附加到对话中。由于我们在这个例子中只是使用了一个总是返回22.0的虚拟函数,我们可以直接附加该结果。
messages.append({"role": "tool", "name": "get_current_temperature", "content": "22.0"})
一些模型架构,特别是Mistral/Mixtral,还需要在这里提供一个tool_call_id
,它应该是9个随机生成的字母数字字符,并分配给工具调用字典的id
键。相同的键也应该分配给下面工具响应字典的tool_call_id
键,以便工具调用可以与工具响应匹配。因此,对于Mistral/Mixtral模型,上面的代码将是:
tool_call_id = "9Ae3bDc2F" # Random ID, 9 alphanumeric characters
tool_call = {"name": "get_current_temperature", "arguments": {"location": "Paris, France", "unit": "celsius"}}
messages.append({"role": "assistant", "tool_calls": [{"type": "function", "id": tool_call_id, "function": tool_call}]})
和
messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": "get_current_temperature", "content": "22.0"})
最后,让我们让助手读取函数输出并继续与用户聊天:
inputs = tokenizer.apply_chat_template(messages, tools=tools, add_generation_prompt=True, return_dict=True, return_tensors="pt")
inputs = {k: v.to(model.device) for k, v in inputs.items()}
out = model.generate(**inputs, max_new_tokens=128)
print(tokenizer.decode(out[0][len(inputs["input_ids"][0]):]))
我们得到:
The current temperature in Paris, France is 22.0 ° Celsius.<|im_end|>
尽管这是一个使用虚拟工具和单次调用的简单演示,但同样的技术适用于多个真实工具和更长的对话。这可以成为一种强大的方式,通过实时信息、计算工具(如计算器)或访问大型数据库来扩展对话代理的能力。
理解工具模式
你传递给tools
参数的每个函数都会被转换为一个JSON schema。这些模式随后会传递给模型聊天模板。换句话说,工具使用模型不会直接看到你的函数,它们也永远不会看到函数内部的实际代码。它们关心的是函数的定义以及需要传递给它们的参数——它们关心的是工具的功能以及如何使用它们,而不是它们如何工作!你需要读取它们的输出,检测它们是否请求使用工具,将它们的参数传递给工具函数,并在聊天中返回响应。
生成JSON模式以传递给模板应该是自动且不可见的,只要您的函数遵循上述规范,但如果您遇到问题,或者您只是希望更好地控制转换过程,您可以手动处理转换。以下是一个手动模式转换的示例。
from transformers.utils import get_json_schema
def multiply(a: float, b: float):
"""
A function that multiplies two numbers
Args:
a: The first number to multiply
b: The second number to multiply
"""
return a * b
schema = get_json_schema(multiply)
print(schema)
这将产生:
{
"type": "function",
"function": {
"name": "multiply",
"description": "A function that multiplies two numbers",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "The first number to multiply"
},
"b": {
"type": "number",
"description": "The second number to multiply"
}
},
"required": ["a", "b"]
}
}
}
如果你愿意,你可以编辑这些模式,甚至完全不使用get_json_schema
从头开始编写它们。JSON模式可以直接传递给apply_chat_template
的tools
参数——这为你提供了很大的能力来为更复杂的函数定义精确的模式。不过要小心——你的模式越复杂,模型在处理它们时越容易混淆!我们建议尽可能使用简单的函数签名,将参数(尤其是复杂的嵌套参数)保持在最低限度。
这是一个手动定义模式并将其直接传递给apply_chat_template
的示例:
# A simple function that takes no arguments
current_time = {
"type": "function",
"function": {
"name": "current_time",
"description": "Get the current local time as a string.",
"parameters": {
'type': 'object',
'properties': {}
}
}
}
# A more complete function that takes two numerical arguments
multiply = {
'type': 'function',
'function': {
'name': 'multiply',
'description': 'A function that multiplies two numbers',
'parameters': {
'type': 'object',
'properties': {
'a': {
'type': 'number',
'description': 'The first number to multiply'
},
'b': {
'type': 'number', 'description': 'The second number to multiply'
}
},
'required': ['a', 'b']
}
}
}
model_input = tokenizer.apply_chat_template(
messages,
tools = [current_time, multiply]
)
高级:检索增强生成
“检索增强生成”或“RAG”LLMs可以在响应查询之前搜索文档库中的信息。这使得模型能够大大扩展其知识库,超越其有限的上下文大小。我们建议RAG模型的模板应接受一个documents
参数。这应该是一个文档列表,其中每个“文档”是一个包含title
和contents
键的单个字典,两者都是字符串。由于这种格式比用于工具的JSON模式简单得多,因此不需要辅助函数。
这是一个实际操作的RAG模板示例:
from transformers import AutoTokenizer, AutoModelForCausalLM
# Load the model and tokenizer
model_id = "CohereForAI/c4ai-command-r-v01-4bit"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")
device = model.device # Get the device the model is loaded on
# Define conversation input
conversation = [
{"role": "user", "content": "What has Man always dreamed of?"}
]
# Define documents for retrieval-based generation
documents = [
{
"title": "The Moon: Our Age-Old Foe",
"text": "Man has always dreamed of destroying the moon. In this essay, I shall..."
},
{
"title": "The Sun: Our Age-Old Friend",
"text": "Although often underappreciated, the sun provides several notable benefits..."
}
]
# Tokenize conversation and documents using a RAG template, returning PyTorch tensors.
input_ids = tokenizer.apply_chat_template(
conversation=conversation,
documents=documents,
chat_template="rag",
tokenize=True,
add_generation_prompt=True,
return_tensors="pt").to(device)
# Generate a response
gen_tokens = model.generate(
input_ids,
max_new_tokens=100,
do_sample=True,
temperature=0.3,
)
# Decode and print the generated text along with generation prompt
gen_text = tokenizer.decode(gen_tokens[0])
print(gen_text)
用于检索增强生成的documents
输入并未得到广泛支持,许多模型的聊天模板会直接忽略此输入。
要验证模型是否支持documents
输入,您可以阅读其模型卡片,或者print(tokenizer.chat_template)
来查看documents
键是否在任何地方被使用。
不过,有一个模型类支持它,那就是Cohere的Command-R和Command-R+,通过它们的rag
聊天模板。你可以在它们的模型卡片中看到使用此功能的更多基于事实生成的示例。
高级:聊天模板如何工作?
模型的聊天模板存储在tokenizer.chat_template
属性中。如果没有设置聊天模板,则使用该模型类的默认模板。让我们来看一个Zephyr
聊天模板,不过请注意,这个模板比实际的稍微简化了一些!
{%- for message in messages %}
{{- '<|' + message['role'] + |>\n' }}
{{- message['content'] + eos_token }}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|assistant|>\n' }}
{%- endif %}
如果你以前从未见过这些,这是一个Jinja模板。 Jinja是一种模板语言,允许你编写生成文本的简单代码。在许多方面,代码和语法类似于Python。在纯Python中,这个模板看起来会像这样:
for message in messages:
print(f'<|{message["role"]}|>')
print(message['content'] + eos_token)
if add_generation_prompt:
print('<|assistant|>')
实际上,模板做了三件事:
- 对于每条消息,打印出用
<|
和|>
包围的角色,例如<|user|>
或<|assistant|>
。 - 接下来,打印消息的内容,然后打印序列结束标记。
- 最后,如果设置了
add_generation_prompt
,则打印助手令牌,以便模型知道开始生成助手响应。
这是一个相当简单的模板,但Jinja为您提供了很大的灵活性来做更复杂的事情!让我们看一个Jinja模板,它可以像LLaMA格式化输入的方式一样格式化输入(请注意,真正的LLaMA模板包括对默认系统消息的处理和一般稍微不同的系统消息处理 - 不要在实际代码中使用这个!)
{%- for message in messages %}
{%- if message['role'] == 'user' %}
{{- bos_token + '[INST] ' + message['content'] + ' [/INST]' }}
{%- elif message['role'] == 'system' %}
{{- '<<SYS>>\\n' + message['content'] + '\\n<</SYS>>\\n\\n' }}
{%- elif message['role'] == 'assistant' %}
{{- ' ' + message['content'] + ' ' + eos_token }}
{%- endif %}
{%- endfor %}
希望如果你盯着这个看一会儿,你就能明白这个模板在做什么——它根据每条消息的角色添加特定的标记,比如
[INST]
和 [/INST]
。用户、助手和系统消息因为被包裹在这些标记中,所以模型可以清楚地区分它们。
高级:添加和编辑聊天模板
如何创建聊天模板?
很简单,只需编写一个jinja模板并设置tokenizer.chat_template
。你可能会发现从另一个模型的现有模板开始并根据你的需求进行编辑会更容易!例如,我们可以采用上面的LLaMA模板,并在助手消息中添加“[ASST]”和“[/ASST]”:
{%- for message in messages %}
{%- if message['role'] == 'user' %}
{{- bos_token + '[INST] ' + message['content'].strip() + ' [/INST]' }}
{%- elif message['role'] == 'system' %}
{{- '<<SYS>>\\n' + message['content'].strip() + '\\n<</SYS>>\\n\\n' }}
{%- elif message['role'] == 'assistant' %}
{{- '[ASST] ' + message['content'] + ' [/ASST]' + eos_token }}
{%- endif %}
{%- endfor %}
现在,只需设置tokenizer.chat_template
属性。下次使用apply_chat_template()时,它将使用你的新模板!此属性将保存在tokenizer_config.json
文件中,因此你可以使用push_to_hub()将你的新模板上传到Hub,并确保每个人都在为你的模型使用正确的模板!
template = tokenizer.chat_template
template = template.replace("SYS", "SYSTEM") # Change the system token
tokenizer.chat_template = template # Set the new template
tokenizer.push_to_hub("model_name") # Upload your new template to the Hub!
方法 apply_chat_template() 使用您的聊天模板,由 TextGenerationPipeline 类调用,因此一旦您设置了正确的聊天模板,您的模型将自动与 TextGenerationPipeline 兼容。
为什么有些模型有多个模板?
一些模型针对不同的使用场景使用不同的模板。例如,它们可能使用一个模板进行正常聊天,另一个用于工具使用或检索增强生成。在这些情况下,tokenizer.chat_template
是一个字典。这可能会引起一些混淆,因此我们建议尽可能在所有使用场景中使用单一模板。你可以使用像 if tools is defined
和 {% macro %}
定义这样的 Jinja 语句,轻松地将多个代码路径包装在一个模板中。
当一个分词器有多个模板时,tokenizer.chat_template
将是一个 dict
,其中每个键是模板的名称。apply_chat_template
方法对某些模板名称有特殊处理:具体来说,在大多数情况下,它会寻找名为 default
的模板,如果找不到则会引发错误。然而,如果用户传递了 tools
参数且存在名为 tool_use
的模板,它将使用该模板。要访问其他名称的模板,请将你想要的模板名称传递给 apply_chat_template()
的 chat_template
参数。
我们发现这对用户来说可能有点令人困惑,因此如果您自己编写模板,我们建议尽可能将其全部放在一个模板中!
我应该使用什么模板?
在为已经训练用于聊天的模型设置模板时,您应确保模板与模型在训练期间看到的消息格式完全匹配,否则您可能会遇到性能下降的问题。即使您进一步训练模型,这一点也是正确的——如果您保持聊天标记不变,您可能会获得最佳性能。这与标记化非常相似——当您精确匹配训练期间使用的标记化时,通常会在推理或微调中获得最佳性能。
如果你是从头开始训练模型,或者为聊天微调基础语言模型,另一方面,你有很大的自由去选择一个合适的模板!大型语言模型足够聪明,能够学会处理许多不同的输入格式。一个流行的选择是ChatML
格式,这对于许多用例来说是一个良好且灵活的选择。它看起来像这样:
{%- for message in messages %}
{{- '<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n' }}
{%- endfor %}
如果你喜欢这个,这里有一个单行形式,可以直接复制到你的代码中。这个单行形式还包括了对生成提示的便捷支持,但请注意它不会添加BOS或EOS标记!如果你的模型需要这些标记,它们不会通过apply_chat_template
自动添加——换句话说,文本将使用add_special_tokens=False
进行标记化。这是为了避免模板和add_special_tokens
逻辑之间的潜在冲突。如果你的模型需要特殊标记,请确保将它们添加到模板中!
tokenizer.chat_template = "{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}"
此模板将每条消息包裹在<|im_start|>
和<|im_end|>
标记中,并简单地将角色写为字符串,这允许你在训练时灵活使用角色。输出如下所示:
<|im_start|>system You are a helpful chatbot that will do its best not to say anything so stupid that people tweet about it.<|im_end|> <|im_start|>user How are you?<|im_end|> <|im_start|>assistant I'm doing great!<|im_end|>
“user”、“system”和“assistant”角色是聊天的标准,我们建议在合适的情况下使用它们,特别是如果你希望你的模型能够与TextGenerationPipeline良好配合。然而,你并不局限于这些角色——模板非常灵活,任何字符串都可以作为角色。
我想添加一些聊天模板!我应该如何开始?
如果您有任何聊天模型,您应该设置它们的 tokenizer.chat_template
属性,并使用
apply_chat_template() 进行测试,然后将更新后的分词器推送到 Hub。即使您不是模型的所有者,这也适用 - 如果您使用的模型具有空的聊天模板,或者仍然使用默认的类模板,请向模型仓库提交一个 pull request,以便正确设置此属性!
一旦设置了属性,就完成了!tokenizer.apply_chat_template
现在将正确地为该模型工作,这意味着它在像TextGenerationPipeline
这样的地方也自动得到支持!
通过确保模型具有此属性,我们可以确保整个社区能够充分利用开源模型的力量。格式不匹配问题长期以来一直困扰着该领域,并默默地损害了性能——现在是时候结束它们了!
高级:模板编写技巧
开始编写Jinja模板的最简单方法是查看一些现有的模板。你可以使用
print(tokenizer.chat_template)
来查看任何聊天模型使用的模板。通常,支持工具使用的模型比其他模型有
更复杂的模板 - 所以当你刚开始学习时,它们可能不是一个好的学习例子!
你也可以查看
Jinja文档 以了解
一般的Jinja格式和语法细节。
transformers
中的 Jinja 模板与其他地方的 Jinja 模板相同。主要需要知道的是,对话历史将在您的模板中作为一个名为 messages
的变量访问。
您可以在模板中像在 Python 中一样访问 messages
,这意味着您可以使用 {% for message in messages %}
对其进行循环,或者使用 {{ messages[0] }}
访问单个消息,例如。
你也可以使用以下技巧来编写干净、高效的Jinja模板:
修剪空白
默认情况下,Jinja 会打印块前后所有的空白字符。这对于聊天模板来说可能是个问题,因为聊天模板通常需要对空白字符非常精确!为了避免这种情况,我们强烈建议像这样编写你的模板:
{%- for message in messages %}
{{- message['role'] + message['content'] }}
{%- endfor %}
而不是像这样:
{% for message in messages %}
{{ message['role'] + message['content'] }}
{% endfor %}
添加 -
将去除块之前的任何空白。第二个例子看起来无害,但换行和缩进可能会被包含在输出中,这可能不是你想要的!
特殊变量
在你的模板中,你将可以访问几个特殊的变量。其中最重要的是messages
,它包含了聊天历史记录,作为一个消息字典的列表。然而,还有其他几个变量。并不是每个变量都会在每个模板中使用。其他最常见的变量有:
tools
包含一个以JSON格式列出的工具列表。如果没有传递工具,则为None
或未定义。documents
包含一个文档列表,格式为{"title": "Title", "contents": "Contents"}
,用于检索增强生成。如果没有传递文档,则将为None
或未定义。add_generation_prompt
是一个布尔值,如果用户请求生成提示,则为True
,否则为False
。如果设置了此值,您的模板应在对话末尾添加助手消息的标题。如果您的模型没有特定的助手消息标题,您可以忽略此标志。- 特殊标记 如
bos_token
和eos_token
。这些是从tokenizer.special_tokens_map
中提取的。每个模板中可用的确切标记将根据父标记器的不同而有所差异。
实际上,你可以传递任何kwarg
给apply_chat_template
,并且它将在模板中作为一个变量可访问。一般来说,
我们建议尽量坚持使用上述核心变量,因为如果用户必须编写自定义代码来传递模型特定的kwargs
,这将使你的模型更难使用。然而,我们知道这个领域发展迅速,所以如果你
有一个不适合核心API的新用例,请随意使用一个新的kwarg
!如果一个新的kwarg
变得常见,我们可能会将其提升到核心API中,并为其创建一个标准的、有文档记录的格式。
可调用函数
在您的模板中,您还可以使用一些可调用的函数。这些函数包括:
raise_exception(msg)
: 引发一个TemplateException
。这对于调试非常有用,也可以用来告诉用户他们在做一些你的模板不支持的事情。strftime_now(format_str)
: 等同于 Python 中的datetime.now().strftime(format_str)
。用于获取特定格式的当前日期/时间,有时会包含在系统消息中。
与非Python Jinja的兼容性
Jinja 有多种语言的实现。它们通常具有相同的语法,但一个关键的区别是,当你在 Python 中编写模板时,你可以使用 Python 方法,例如在字符串上使用 .lower()
或在字典上使用 .items()
。如果有人尝试在非 Python 实现的 Jinja 上使用你的模板,这将会出现问题。非 Python 实现尤其在部署环境中非常常见,其中 JS 和 Rust 非常流行。
不过,别慌!你可以对你的模板做一些简单的更改,以确保它们在所有Jinja实现中兼容:
- 将Python方法替换为Jinja过滤器。这些通常具有相同的名称,例如
string.lower()
变为string|lower
,dict.items()
变为dict|items
。一个显著的变化是string.strip()
变为string|trim
。有关更多信息,请参阅Jinja文档中的内置过滤器列表。 - 将特定于Python的
True
、False
和None
替换为true
、false
和none
。 - 直接渲染字典或列表可能会在其他实现中产生不同的结果(例如,字符串条目可能会从单引号变为双引号)。添加
tojson
过滤器可以帮助确保一致性。
编写生成提示
我们上面提到,add_generation_prompt
是一个特殊变量,可以在你的模板中访问,并且由用户设置 add_generation_prompt
标志来控制。如果你的模型期望为助手消息添加一个头部,那么你的模板必须支持在 add_generation_prompt
被设置时添加该头部。
这是一个以ChatML风格格式化消息的模板示例,支持生成提示:
{{- bos_token }} {%- for message in messages %} {{- '<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n' }} {%- endfor %} {%- if add_generation_prompt %} {{- '<|im_start|>assistant\n' }} {%- endif %}
助手头的具体内容将取决于您的特定模型,但它应该始终是表示助手消息开始的字符串,以便如果用户使用add_generation_prompt=True
应用您的模板并生成文本,模型将编写助手响应。还要注意,有些模型不需要生成提示,因为助手消息总是在用户消息之后立即开始。这在LLaMA和Mistral模型中尤其常见,助手消息在结束用户消息的[/INST]
标记之后立即开始。在这些情况下,模板可以忽略add_generation_prompt
标志。
生成提示很重要!如果您的模型需要生成提示但未在模板中设置,那么模型生成可能会严重下降,或者模型可能会表现出异常行为,例如继续最终用户消息!
编写和调试较大的模板
当这个功能被引入时,大多数模板都非常小,相当于Jinja的“一行式”脚本。 然而,随着新模型和功能如工具使用和RAG的出现,一些模板可能长达100行或更多。在 编写这样的模板时,最好使用文本编辑器将它们写在单独的文件中。你可以轻松地 将聊天模板提取到一个文件中:
open("template.jinja", "w").write(tokenizer.chat_template)
或者将编辑后的模板加载回分词器:
tokenizer.chat_template = open("template.jinja").read()
作为额外的奖励,当你在一个单独的文件中编写一个长的多行模板时,该文件中的行号将完全对应于模板解析或执行错误中的行号。这将使识别问题的来源变得更加容易。
为工具编写模板
尽管聊天模板并不强制要求特定的API用于工具(或任何东西,真的),我们建议模板作者尽可能尝试遵循标准API。聊天模板的整个目的是允许代码在模型之间可转移,因此偏离标准工具API意味着用户将不得不编写自定义代码以使用您的模型的工具。有时这是不可避免的,但通常通过巧妙的模板设计,您可以使标准API工作!
下面,我们将列出标准API的元素,并提供编写与其良好配合的模板的技巧。
工具定义
您的模板应该预期变量tools
可能为空(如果没有传递工具),或者是一个JSON模式字典的列表。我们的聊天模板方法允许用户将工具作为JSON模式或Python函数传递,但当传递函数时,我们会自动生成JSON模式并将其传递给您的模板。因此,您的模板接收到的tools
变量将始终是一个JSON模式的列表。以下是一个示例工具的JSON模式:
{
"type": "function",
"function": {
"name": "multiply",
"description": "A function that multiplies two numbers",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "The first number to multiply"
},
"b": {
"type": "number",
"description": "The second number to multiply"
}
},
"required": ["a", "b"]
}
}
}
这里是一些用于处理聊天模板中工具的示例代码。请记住,这只是一个特定格式的示例 - 你的模型可能需要不同的格式!
{%- if tools %} {%- for tool in tools %} {{- '<tool>' + tool['function']['name'] + '\n' }} {%- for argument in tool['function']['parameters']['properties'] %} {{- argument + ': ' + tool['function']['parameters']['properties'][argument]['description'] + '\n' }} {%- endfor %} {{- '\n</tool>' }} {%- endif %} {%- endif %}
你的模板渲染的特定标记和工具描述当然应该选择与你模型训练时使用的相匹配。你的模型不需要理解JSON模式输入,只需要你的模板能够将JSON模式转换为你的模型格式。例如,Command-R是使用Python函数头定义的工具进行训练的,但Command-R工具模板接受JSON模式,内部转换类型并将输入工具渲染为Python头。你可以用模板做很多事情!
工具调用
如果存在工具调用,它们将作为列表附加到具有“assistant”角色的消息上。请注意,tool_calls
始终是一个列表,即使大多数工具调用模型一次只支持单个工具调用,这意味着列表通常只包含一个元素。以下是一个包含工具调用的消息字典示例:
{
"role": "assistant",
"tool_calls": [
{
"type": "function",
"function": {
"name": "multiply",
"arguments": {
"a": 5,
"b": 6
}
}
}
]
}
处理它们的常见模式通常是这样的:
{%- if message['role'] == 'assistant' and 'tool_calls' in message %} {%- for tool_call in message['tool_calls'] %} {{- '<tool_call>' + tool_call['function']['name'] + '\n' + tool_call['function']['arguments']|tojson + '\n</tool_call>' }} {%- endif %} {%- endfor %} {%- endif %}
再次,你应该使用模型期望的格式和特殊标记来渲染工具调用。
工具响应
工具响应有一个简单的格式:它们是一个带有“tool”角色的消息字典,一个“name”键提供被调用函数的名称,以及一个“content”键包含工具调用的结果。以下是一个工具响应的示例:
{
"role": "tool",
"name": "multiply",
"content": "30"
}
你不需要使用工具响应中的所有键。例如,如果你的模型不期望在工具响应中包含函数名称,那么渲染它可以像这样简单:
{%- if message['role'] == 'tool' %} {{- "<tool_result>" + message['content'] + "</tool_result>" }} {%- endif %}
再次提醒,实际的格式和特殊标记是模型特定的 - 你应该非常小心,确保标记、空格和其他所有内容与你模型训练时的格式完全匹配!
< > Update on GitHub