Hugging Face 推出跨模型统一工具调用 API
Tool Use, Unified
Hugging Face 在 Transformers 中推出统一的工具调用 API,同一套代码可在 Mistral、Cohere、NousResearch 和 Llama 等模型上使用工具,无需或只需少量模型专属改动。工具以 JSON schema 定义,Python 函数传入后会自动转换为 schema,并配有完整文档和示例。目前模型响应格式仍需手动解析,这部分尚未统一。
原文给出了统一工具调用 API 的跨模型设计与 Transformers 代码示例,读者可据此判断现有工具调用代码的迁移成本。
现在,多个流行模型系列之间有了一个统一的工具使用 API。这个 API 意味着相同的代码具有可移植性——在与 Mistral、Cohere、NousResearch 或 Llama 模型的聊天中使用工具时,几乎不需要或完全不需要针对特定模型进行修改。此外,Transformers 现在还包含了辅助功能,使工具调用更加简便,并为整个工具使用流程提供了 完整的文档和示例。未来不久将支持更多模型。
引言
工具使用是一个奇特的功能——大家都觉得它很棒,但大多数人自己并没有尝试过。从概念上讲,它非常简单:你给 LLM 一些工具(可调用函数),它就可以决定调用这些工具来帮助回应用户的查询。也许你给它一个计算器,这样它就不必依赖其内部不可靠的算术能力。也许你让它搜索网络或查看你的日历,或者你给它(只读!)访问公司数据库的权限,以便它可以调取信息或搜索技术文档。
工具使用克服了 LLM 的许多核心局限。许多 LLM 表达流畅、滔滔不绝,但在计算和事实方面往往不精确,对更小众话题的具体细节也模糊不清。它们不知道训练截止日期之后发生的任何事情。它们是通才;进入对话时,除了你在系统消息中提供的信息之外,它们对你自己或你的工作场所一无所知。工具为它们提供了获取结构化、具体、相关且最新信息的途径,这能极大地帮助它们成为真正有用的伙伴,而不仅仅是有趣的新奇事物。
然而,当你真正尝试实现工具使用时,问题就出现了。文档往往稀少、不一致,甚至相互矛盾——无论是闭源 API 还是开放访问模型都是如此!尽管工具使用在理论上很简单,但在实践中常常变成一场噩梦:如何将工具传递给模型?如何确保工具提示与其训练时使用的格式匹配?当模型调用工具时,如何将其纳入聊天中?如果你以前尝试过实现工具使用,你可能会发现这些问题出乎意料地棘手,而且文档并不总是完整和有用的。
更糟糕的是,不同模型对工具使用的实现可能大相径庭。即使在定义可用工具的最基本层面上,一些提供商期望 JSON 模式,而另一些则期望 Python 函数头。即使在那些期望 JSON 模式的提供商中,细微差别也常常不同,并造成严重的 API 不兼容。这造成了很多摩擦,总体上只会加深用户的困惑。那么,我们能对此做些什么呢?
聊天模板
HF Mirror 电影宇宙的忠实粉丝会记得,开源社区过去在聊天模型方面也面临过类似的挑战。聊天模型使用诸如 <|start_of_user_turn|> 或 <|end_of_message|> 这样的控制标记来让模型了解聊天中正在发生什么,但不同模型训练时使用的控制标记完全不同,这意味着用户需要为他们想要使用的每个模型编写特定的格式化代码。这在当时是一个巨大的头痛问题。
对此,我们的解决方案是聊天模板——本质上,模型会附带一个微小的 Jinja 模板,为每个模型以正确的格式和控制标记渲染聊天内容。聊天模板意味着用户可以用一种通用的、与模型无关的格式编写聊天内容,并信赖 Jinja 模板来处理所需的任何模型特定格式。
那么,支持工具使用的显而易见的做法,就是将聊天模板扩展为也支持工具。这正是我们所做的,但工具给模板系统带来了许多新挑战。让我们逐一梳理这些挑战以及我们是如何解决它们的。在此过程中,希望你能更深入地理解这个系统是如何运作的,以及如何让它为你所用。
将工具传递给聊天模板
我们在设计工具使用 API 时的首要标准是,定义工具并将它们传递给聊天模板应该直观易懂。我们发现,大多数用户会先编写工具函数,然后再想办法从中生成工具定义并将其传递给模型。这引出了一个显而易见的做法:如果用户可以直接把函数传递给聊天模板,并让它为他们生成工具定义,会怎样?
不过,问题在于,“传递函数”是一件非常依赖具体语言的事情,而很多人是通过 JavaScript 或 Rust 而不是 Python 来访问聊天模型的。因此,我们找到了一个我们认为能两全其美的折中方案:聊天模板期望工具以 JSON schema 的形式定义,但如果你改为将 Python 函数传递给模板,它们会自动为你转换为 JSON schema。这带来了一个简洁优雅的 API:
def get_current_temperature(location: str):
"""
Gets the temperature at a given location.
Args:
location: The location to get the temperature for
"""
return 22.0 # bug: Sometimes the temperature is not 22. low priority
tools = [get_current_temperature]
chat = [
{"role": "user", "content": "Hey, what's the weather like in Paris right now?"}
]
tool_prompt = tokenizer.apply_chat_template(
chat,
tools=tools,
add_generation_prompt=True,
return_tensors="pt"
)
在内部,get_current_temperature 函数会被展开为完整的 JSON schema。如果你想查看生成的 schema,可以使用 get_json_schema 函数:
>>> from transformers.utils import get_json_schema
>>> get_json_schema(get_current_weather)
{
"type": "function",
"function": {
"name": "get_current_temperature",
"description": "Gets the temperature at a given location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The location to get the temperature for"
}
},
"required": [
"location"
]
}
}
}
如果你更喜欢手动控制,或者你使用的是 Python 以外的语言,你可以像这样直接将 JSON schema 传递给模板。不过,当你在 Python 中工作时,你可以避免直接处理 JSON schema。你只需要用清晰的名称、准确的类型提示和完整的文档字符串(包括参数文档字符串)来定义你的工具函数,因为所有这些都会被用来生成模板所读取的 JSON schema。其中很多本来就是良好的 Python 实践,如果你遵循这些做法,你就会发现无需额外工作——你的函数已经可以作为工具使用了!
请记住:准确的 JSON schema,无论是从文档字符串和类型提示生成的,还是手动指定的,对于模型理解如何使用你的工具都至关重要。模型永远不会看到你函数内部的代码,但它会看到 JSON schema。它们越清晰、越准确,效果就越好!
将工具调用添加到聊天中
一个常被用户(以及模型文档 😬)忽视的细节是,当模型调用工具时,实际上需要向聊天历史中添加两条消息。第一条是助手调用工具的消息,第二条是工具响应,即被调用函数的输出。
工具调用和工具响应都是必需的——请记住,模型只知道聊天历史中的内容,如果模型无法同时看到它所做的调用以及为获得该响应而传递的参数,它就无法理解工具响应。单独的“22”信息量不大,但如果你知道它前面的消息是 get_current_temperature("Paris, France"),那就非常有帮助了。
这是不同提供商之间可能差异极大的领域之一,但我们确定的标准是工具调用是助手消息的一个字段,如下所示:
message = {
"role": "assistant",
"tool_calls": [
{
"type": "function",
"function": {
"name": "get_current_temperature",
"arguments": {
"location": "Paris, France"
}
}
}
]
}
chat.append(message)
向聊天中添加工具响应
工具响应要简单得多,尤其是当工具只返回单个字符串或数字时。
message = {
"role": "tool",
"name": "get_current_temperature",
"content": "22.0"
}
chat.append(message)
工具使用实战
让我们把目前已有的代码拿来,构建一个完整的工具调用示例。如果你想在自己的项目中使用工具,我们建议你在这里的代码上多试试——自己运行一下,添加或删除工具,更换模型,调整细节,以感受这个系统。当你需要在软件中实现工具使用时,这种熟悉程度会让事情容易得多!为了方便起见,这个示例也以 notebook 形式提供。
首先,让我们设置模型。我们将使用 Hermes-2-Pro-Llama-3-8B,因为它小、能力强、无需门控,并且支持工具调用。不过,如果你使用更大的模型,在复杂任务上可能会得到更好的结果!
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
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")
接下来,我们将设置工具和我们想要使用的聊天。让我们使用上面的 get_current_temperature 示例:
def get_current_temperature(location: str):
"""
Gets the temperature at a given location.
Args:
location: The location to get the temperature for, in the format "city, country"
"""
return 22.0 # bug: Sometimes the temperature is not 22. low priority to fix tho
tools = [get_current_temperature]
chat = [
{"role": "user", "content": "Hey, what's the weather like in Paris right now?"}
]
tool_prompt = tokenizer.apply_chat_template(
chat,
tools=tools,
return_tensors="pt",
return_dict=True,
add_generation_prompt=True,
)
tool_prompt = tool_prompt.to(model.device)
现在我们准备好根据模型可用的工具,生成模型对用户查询的响应:
out = model.generate(**tool_prompt, max_new_tokens=128)
generated_text = out[0, tool_prompt['input_ids'].shape[1]:]
print(tokenizer.decode(generated_text))
然后我们得到:
<tool_call>
{"arguments": {"location": "Paris, France"}, "name": "get_current_temperature"}
</tool_call><|im_end|>
模型请求了一个工具!请注意它如何正确推断出应该传递参数“Paris, France”而不仅仅是“Paris”,因为这是函数 docstring 推荐的格式。
不过,模型实际上并不能以编程方式访问这些工具——像所有语言模型一样,它只是生成文本。作为程序员,你需要拿着模型的请求去调用函数。不过首先,让我们把模型的工具请求添加到聊天中。
请注意,这一步可能需要一点手动处理——尽管你应该始终按下面的格式将请求添加到聊天中,但工具调用请求的文本,例如 <tool_call> 标签,可能因模型而异。通常,它相当直观,但请记住,在你自己的代码中尝试时,可能需要一些针对特定模型的 json.loads() 或 re.search()!
message = {
"role": "assistant",
"tool_calls": [
{
"type": "function",
"function": {
"name": "get_current_temperature",
"arguments": {"location": "Paris, France"}
}
}
]
}
chat.append(message)
现在,我们实际上在 Python 代码中调用该工具,并将其响应添加到聊天中:
message = {
"role": "tool",
"name": "get_current_temperature",
"content": "22.0"
}
chat.append(message)
最后,就像我们之前所做的那样,我们格式化更新后的聊天并将其传递给模型,以便它可以在对话中使用工具响应:
tool_prompt = tokenizer.apply_chat_template(
chat,
tools=tools,
return_tensors="pt",
return_dict=True,
add_generation_prompt=True,
)
tool_prompt = tool_prompt.to(model.device)
out = model.generate(**tool_prompt, max_new_tokens=128)
generated_text = out[0, tool_prompt['input_ids'].shape[1]:]
print(tokenizer.decode(generated_text))
然后我们得到给用户的最终响应,它是使用中间工具调用步骤中的信息构建的:
The current temperature in Paris is 22.0 degrees Celsius. Enjoy your day!<|im_end|>
令人遗憾的响应格式不统一
在阅读这个示例时,你可能已经注意到,尽管聊天模板可以在将聊天和工具定义转换为格式化文本时隐藏模型特定的差异,但反过来却并非如此。当模型发出工具调用时,它会以自己的格式发出,因此你目前需要手动解析它,然后才能以通用格式将其添加到聊天中。幸运的是,大多数格式都相当直观,所以这应该只需要几行 json.loads(),或者最坏情况下,一个简单的 re.search() 就能创建你需要的工具调用字典。
不过,这仍是整个过程中最未“统一”的部分。我们有一些解决办法的想法,但尚未准备好进入主流阶段。正如孩子们所说:“让我们慢慢来。”
结论
尽管存在上述小瑕疵,我们认为这与之前工具使用分散、混乱且文档不足的情况相比,已是巨大改进。我们希望这能大大降低开源开发者在项目中集成工具使用的难度,通过一系列工具增强强大的LLM,增添令人惊叹的新功能。从小型模型如Hermes-2-Pro-8B到像Mistral-Large、Command-R-Plus或Llama-3.1-405B这样的顶尖巨型模型,许多前沿的LLM现在都支持工具使用。我们认为工具将成为下一代LLM产品不可或缺的部分,希望这些变化能让您在自己的项目中更轻松地使用它们。祝你好运!
来源:Hugging Face Blog · hf-mirror.com