微调一个函数调用小模型
近年来,人工智能(AI)和机器学习在金融、医学和娱乐等多个领域取得了显著的进步。其中,自然语言处理(NLP)是AI中最具有变革性的领域之一,推动了大型语言模型(LLMs)的发展。这些模型经过大量文本数据的训练,能够识别单词和短语之间的统计模式和关系,从而提高其理解和生成人类语言的能力。
一些最著名的LLMs包括OpenAI的GPT模型、Meta的Llama模型以及Google的Gemini和Gemma模型。这些模型支持广泛的NLP任务,如文本摘要、分类、翻译和情感分析——您可能已经在与AI驱动的助手互动时体验过这些功能。在这篇博客中,我们将重点关注一个特定的功能:函数调用。
1、什么是函数调用?
函数调用是一种让LLMs与外部工具和系统交互的方式。除了简单地以文本形式响应外,LLM可以根据用户的请求选择一组预定义的编程函数(也称为API,即应用程序编程接口)中的最佳匹配函数。
一旦选择了正确的函数,模型会生成一个结构化的答案,并通常以JSON、XML或YAML格式包含必要的参数。这使得LLM能够获取实时数据、自动化任务或以更有用的方式与软件应用程序交互。
2、为什么创建一个本地的小模型?
在RidgeRun.ai,我们开始开发自己的小规模、本地运行的函数调用模型,有几个重要的原因。
首先,依赖第三方模型时,数据隐私和安全是一个主要问题。当我们使用外部函数调用服务时,我们必须问:用户数据是否真正受到保护?谁可以访问它?此外,供应商锁定是另一个风险——如果一家公司决定停止提供其服务,任何围绕其构建的项目可能会陷入困境。
首先,依赖第三方模型时,数据隐私和安全是一个主要问题。当我们使用外部函数调用服务时,我们必须问:用户数据是否真正受到保护?谁可以访问它?此外,供应商锁定是另一个风险——如果一家公司决定停止提供其服务,任何围绕其构建的项目可能会陷入困境。
最后,为什么要构建另一个函数调用模型?在RidgeRun.ai,我们不仅仅是构建机器学习解决方案——我们优化它们以便在边缘部署。你会发现现有的函数调用模型通常大约有80亿个参数,这对资源受限的系统来说已经开始变得有点太大了。我们的模型只有20亿个参数。一个小而高效、本地运行的模型非常适合资源有限的环境,例如嵌入式系统,在这种环境中计算能力和内存受到限制。
3、制作数据集
在训练我们的模型之前,我们首先需要定义数据集规范——本质上是我们将用于函数调用的结构。
我们选择了JSON格式进行函数调用。我们的决策基于两个关键因素:
- 效率:尽管文献有限,但我们的研究表明JSON和YAML是两种最高效的格式。
- 可靠性:虽然YAML稍微更高效,但它容易出现缩进错误,这些错误可能是由于不一致的空白引起的。JSON则避免了这些问题。
为了进一步优化效率,我们决定使用精简版的JSON,它去除了不必要的空格和换行符,帮助我们节省令牌。
接下来,我们定义了模型能力的范围,指定了它将支持的函数类型:
- 命名约定:函数和参数名称遵循snake_case
- 每个函数的参数数量:0到5个
- 支持的参数类型:整数、浮点数、布尔值和字符串
- 默认或必需字段:无。
- 每个API的函数数量:1到5个,这意味着模型可以从最多5个函数的组中选择一个。
随后,我们定义了每个数据集样本的外观。记住LLMs是如何在大量文本数据上进行训练的吗?我们的数据集正是由这样的文本组成——关键区别在于我们的文本数据需要以考虑函数调用的方式来结构化。
归根结底,我们的函数调用模型实际上并不知道如何编程或执行函数。相反,它只是接收API定义和用户请求作为结构化文本。然后,根据其训练,它输出一个函数调用,同样也是结构化文本。
考虑到这一点,我们为每个数据集条目设计了以下格式。为了保持简单,下面的例子只包含两个函数。
### PROMPT:
Reset the device.
### API:
[
{
"name": "toggle_lights",
"description": "Toggles the lights on/off.",
"parameters": {
"enable": {
"type": "boolean",
"description": "Whether to turn the lights on.",
}
}
},
{
"name": "reset_device",
"description": "Resets the device to factory settings.",
"parameters": {}
}
]
### OUTPUT:
{
"function": "reset_device",
"parameters": {}
}
为了帮助模型清楚地理解结构,我们引入了三个关键部分,每个部分都标有一个标志:
- ### PROMPT: 表示用户的请求。
- ### API: 定义模型必须从中选择的函数集合。每个函数及其参数都包括描述,帮助模型理解其用途以及它与请求的关系。
- ### OUTPUT: 包含所选函数名称和必要的参数。
此外,我们还定义了一种特殊情况,即当没有适合用户请求的可用函数时。在这种情况下,模型不应强行匹配或猜测,而是应该避免调用任何函数。这有助于防止幻觉,并确保模型仅在它确信存在有效函数时才作出回应。
考虑到这一点,我们为每个样本定义了两种可能的结果:
- 有效的函数调用——当从API中选择了一个函数时。
- 空函数调用——当不存在合适的函数时,模型返回空值。
以下是空情况下的相应输出:
### OUTPUT:
{
"function": null
}
3.1 数据集生成
一旦我们知道需要什么样的数据集,下一步就是实际创建它。为此,我们得到了OpenAI的一个模型——GPT-4o的巨大帮助。当时,它是我们用例的最佳选择,因为它能紧密遵循指示并减少响应中的错误。
使用语言模型生成数据非常有用,但也带来了一些挑战:
- 提示是一门艺术。 让模型给出你想要的内容往往需要大量的试验和错误。
- 模型会出错。 即使是最好的模型有时也会产生不正确或不一致的示例,所以我们必须仔细审查生成的数据。
- 数据集需要平衡。 我们还进行了分析,以确保数据涵盖了我们关心的所有类别,而不会有任何一组过度或不足表示。
让我们从提示开始。这可能是与LLMs合作中最乏味(而且奇怪的艺术性)的部分。如果你曾经尝试为LLM编写提示,你会知道一个词——甚至一个标点符号——的影响有多大。语气或结构的轻微变化完全会改变模型的输出。某些提示对一个模型效果很好,但对另一个却不起作用,这就是为什么需要大量的实验。
现在,这是我们项目中使用的系统提示。它看起来可能有点老派,因为我们是在OpenAI发布其结构化输出功能之前构建的——这个功能会让获取干净的JSON变得更加容易。
You are a helpful assistant that generates JSON objects for a function calling dataset.
Your answers must be a JSON object of the following format:
{
"api": API_JSON,
"prompt": USER_PROMPT,
"output": OUTPUT_JSON
}
The API_JSON structure must be:
[{
"name":"<name>",
"description":"<description>",
"parameters":{
"<param_name>":{
"type":"<type>",
"description":"<param_description>"
}
}
}]
The function and parameter names should follow only the snake_case naming convention.
The USER_PROMPT refers to the user request, in human language, that potentially refers to one of the functions in the API and its respective parameters.
The OUTPUT_JSON describes the selected function and its parameters that fulfill the user prompt. It must follow the next structure:
{
"function":"<name>",
"parameters":{
"<param_name>":<value>,
"<param2_name>":<value>
}
}
Here's an example of a JSON object that follows the required format:
{
"api": [
{
"name": "set_light_status",
"description": "Set the dim value of the lights",
"parameters": {
"value": {
"description": "The value to set to the light",
"type": "integer"
}
}
},
{
"name": "play_music",
"description": "Play a song",
"parameters": {
"song": {
"description": "The song to play",
"type": "string"
},
"volume": {
"description": "The volume at which to play the song",
"type": "float"
}
}
}
],
"prompt": "Play the Espresso song at an 80.5 volume",
"output": {
"function": "set_light_status",
"parameters": {
"song": "Espresso"
}
}
}
The JSON objects must comply with these rules:
- All functions and parameters must have a description.
- Repeating functions within the same API or across different APIs is forbidden.
如您所见,这个提示告诉模型我们想要什么类型的输出。它定义了结构,设定了期望,并且还包括示例(出于简洁起见这里未显示)。我们在最后添加了一些严格的规则。这些规则来自我们通过经验教训学到的东西——比如当模型开始跳过函数或参数描述时。添加这些规则有助于减少这类错误。
总而言之,提示并不是一门简单的科学。要正确完成它需要时间、创造力和大量的耐心——但一旦成功,它会使其他一切变得更容易。
有了我们的系统提示准备就绪,我们终于可以开始生成数据集样本了。我们使用OpenAI的Python API编写了一个脚本,该脚本向GPT-4o提供了我们的系统提示,同时还通过用户提示给出了如何创建每个样本的具体说明(稍后我们会详细讨论这一点)。脚本重复此过程,直到达到1000个样本的目标。
有些输出有JSON语法错误。当我们无法修复它们时,我们简单地丢弃那些样本并生成新的样本来弥补损失——确保我们仍然达到了目标。
这里有一个快速图表展示了我们遵循的数据集生成过程:
一旦我们有了所有1000个样本,我们将数据集分为训练集、验证集和测试集。然后是时候进行一些探索性数据分析(EDA),更好地了解我们正在处理的内容。
3.2 数据集分析
在使用任何数据训练模型之前,了解数据的样子是很重要的。这个步骤——称为探索性数据分析(EDA)——帮助我们发现统计模式、捕捉错误并更清晰地了解数据集的结构。
为什么这很重要?假设您正在训练一个模型来检测图像中的猫。如果您的数据集中大部分图片都是黑色的猫,而只有少数是其他类型的猫,那么模型可能会难以识别非黑色的猫。这是因为数据是不平衡的,模型最终会学习到一种扭曲的现实版本。
这种偏见正是我们想要避免的。
在我们的案例中,我们并没有处理猫的照片——但我们的函数调用数据集也有自己的多样性需要跟踪。例如,如果数据集中大多数函数使用布尔参数,而只有少数使用浮点数,那么模型在处理这些罕见的浮点数情况时可能会表现不佳。
因此,为了确保数据集平衡且具有代表性,我们专注于跟踪几个关键模式:
- 每个API在每个数据集样本中的函数数量均衡。
- 所有函数的参数数量均衡。
- 参数类型的分布均衡(例如,字符串、布尔值、浮点数)
让我们回到数据集生成阶段。为了确保最终数据集在我们提到的所有关键模式上都平衡,我们设计了一个简单的算法,从一开始就指导数据的创建。
让我们分解一下:
1. 平衡每个API的函数数量
我们已经定义数据集中的每个API可以有1到5个函数。由于我们想要总共1000个样本,我们只是将它们平均分配给这些API大小。这意味着生成200个每个可能大小的样本:200个有1个函数,200个有2个函数,依此类推,直到5个。够简单吧?
2. 平衡每个函数的参数数量
一旦我们确切知道数据集中有多少个函数(基于我们的API分布),我们就继续平衡每个函数的参数数量。就像API大小一样,我们希望均匀分布——所以我们要确保在所有函数中,有均衡的数量的函数分别有0、1、2、3、4和5个参数。
3. 平衡参数类型
最后,在所有函数的总参数数量已知的情况下,我们解决了最后一个部分:参数类型。我们的目标是确保像字符串、布尔值和浮点数这样的类型在数据集中都被充分代表。因此,我们均匀分布在全部参数中,避免类型相关的偏差。
这种逐步的方法帮助我们创建了一个不仅大而且精心设计的数据集。虽然听起来可能有点机械,但它在最终模型训练数据的质量上产生了巨大影响。
现在我们已经定义了每个单独的数据集样本应该是什么样子——API包含多少个函数,每个函数有多少个参数,这些参数的类型是什么。每次我们从OpenAI API请求新样本时,用户提示都会明确指示GPT-4o生成什么:函数的数量、每个函数应有多少个参数以及这些参数的类型。
最后,我们使用条形图对训练、验证和测试分割进行了彻底分析。这里的目的是确保每个分割——而不仅仅是整个数据集——在所有关键指标上都平衡。这给了我们信心,每一步模型训练和评估都将基于分布良好的数据。
看看我们针对训练数据集分割的EDA图表:
4、模型训练
接下来到了实际训练我们模型的时候了。为此,我们选择了Gemma-2–2B作为我们的基础模型。为了减少内存需求并加快训练速度,我们使用了它的4位量化版本。
我们使用Unsloth对模型进行了微调,这是一个专为通过聪明的数学优化实现快速微调而设计的框架。
对于这篇博客的目的,我们将保持高层次的讨论。
想象一下一个已经在广泛主题上预训练过的LLM。微调是将该模型适应于更具体任务的过程——在我们的例子中是函数调用。这是通过继续训练过程实现的,但这次使用的是更小的数据集,专注于我们的特定领域。模型的参数被调整以更好地处理这个更窄的范围。
我们还使用了LoRA(低秩适应)优化,特别是QLoRA变体,它允许在量化模型上进行高效的微调。如果您想了解更多细节,请查看我们的微调教程博客!
下面,您可以看到我们模型的训练和验证损失曲线。我们总共训练了三个周期。注意训练和验证损失是如何开始偏离的——这是过拟合的经典迹象,模型开始记忆训练数据而不是从中泛化。这就是为什么我们选择使用第二周期结束时的模型权重,此时验证损失最低。
5、模型测试
我们旅程的最后一站是评估新微调的Juniper模型。对于像函数调用这样的任务,仅仅依靠训练和验证损失是不够的。我们需要更稳健的指标来真正了解模型的表现如何。
因此,我们定义了明确的标准,用于真阳性、真阴性、假阳性和假阴性——专门针对我们的用例。在这个上下文中,标签指的是每个数据集样本中的真实输出。
- 真阳性: 标签是一个有效的函数调用,预测也是一个有效的函数调用,函数名称匹配,所有参数名称和值均正确。
- 真阴性: 标签是一个空函数调用,预测也是一个空函数调用。
- 假阳性: 有两种情况,情况1是标签是一个空函数调用,但模型预测了一个有效的函数调用。情况2发生在标签是一个有效的函数调用,但预测有任何不匹配,例如函数名称错误、参数名称错误或参数值错误。
- 假阴性: 标签是一个有效的函数调用,但模型预测了一个空函数调用。
有了我们的评估定义,我们计算了经典的机器学习性能指标:准确率、精确度、召回率和F1分数。
我们使用这些指标将微调后的Juniper模型与另外三个模型进行了基准测试:Qwen 2.5–3B、Llama 3.1–8B和GPT-4o。为了公平比较,Qwen和Llama模型使用了它们的4位量化版本,就像基于4位量化Gemma-2–2B模型的Juniper一样。
以下是使用我们的测试集对四个模型进行评估的结果。尽管尺寸较小,Juniper在所有指标上的得分等于或高于较大的模型——这表明精心设计的数据集和微调可以走得很远。
测试数据集分割
伯克利简单与多重数据集*
*简单和多重数据集经过修改,移除了参数类型不符合Juniper支持的类型(整数、浮点数、字符串和布尔值)的样本。此外,函数和参数名称已调整为遵循snake case命名约定,与Juniper的训练一致。
为了进一步验证我们的结果,我们使用来自伯克利函数调用排行榜的外部基准数据集对所有模型进行了测试,该数据集通过一系列测试评估不同LLMs正确执行函数调用的能力。具体来说,我们结合了简单的和多重函数调用数据集,并使用它们来基准测试模型。
再一次,Juniper表现出色,与更大的模型并肩而立。这证实了我们的管道——从数据集生成到微调——成功地产生了一个真正理解函数调用的模型。
最终,这个项目证明了有针对性的微调和精心设计的数据集如何将通用LLM转变为特定领域的专家。
原文链接:Introducing Juniper: How We Fine-Tuned a Small and Local Model for Function Calling
汇智网翻译整理,转载请标明出处