
如何微调大模型,让它更聪明地使用工具? 精华
虽然大型语言模型(LLMs)已经重新定义了 AI 的可能性,但它们依然有个根本性的局限:无法与外部世界互动,知识停留在训练时的时间点,能力也局限于文本生成。在这篇文章中,我将探讨如何通过微调 LLMs 来让它们使用工具,突破这一局限。我会先从概念上讲解这是怎么实现的,然后通过一个具体的 Python 代码示例带你一步步操作。。
工具调用(Tool Calling)的现状
现在,像 GPT-4.1、Claude 和 Llama 3 这样的模型都已标配工具调用功能。你只需要通过一个 JSON 对象描述工具的用法,现代模型就能自己搞清楚啥时候用、怎么用。
虽然这种方式加上一些清晰的指令在很多场景下都挺好使,但有两种情况可能就不够用了:
1. 你用的是一个没被训练过工具调用的小型语言模型。
2. 你的应用场景需要复杂且特定领域的工具调用。
这时候,微调(fine-tuning)就派上用场了。下面我会先聊聊微调的一些核心概念,然后进入代码示例。
什么是微调?
微调就是通过额外的训练,让模型适配特定场景[1]。简单来说,“训练”模型就是通过例子教它新技能。
微调有几个关键好处:
• 赋予模型特定的“性格”
• 教会模型新行为(比如工具调用)
• 让模型做一些难以用语言描述的事情
• 降低模型的成本和延迟
训练数据
微调模型最重要的部分是你的训练数据集。这得包含能体现你想要模型模仿的行为的例子。
构建上下文
在教模型如何使用工具时,我们得先决定怎么组织模型的上下文。虽然方法很多,这里介绍一种 Llama 风格的做法[2,3]:
Llama 3 风格的工具调用和聊天模板 [3]
当然,如果需要多次调用工具或进行多轮对话,事情会变得更复杂。
整理训练示例
确定上下文格式后,我们可以用它来创建训练示例。理想情况下,示例来自你希望模型应用的真实场景(比如,拿用户真实对话改成上述格式)。
但很多时候,你还没部署应用,根本没这些数据。这时候可以借助公开数据集(比如 Hugging Face 上的数据集),或者用更大、更强的模型生成合成数据集。
在下面的例子中,我会展示如何用不同语言模型组合生成一个合成数据集。
示例:微调 Gemma 3 以使用工具
有了微调的基本概念,我们来看一个具体例子。我将训练 gemma-3-1b-it
来使用工具。虽然这个模型已经过指令调优(instruction-tuned,能进行对话),但它还没学会怎么用工具。
代码、数据集和最终模型都可以在下面链接找到:
🔗 GitHub Repo | Training Data | Fine-tuned Model
步骤 1:定义工具
第一步是定义一个多样化的工具集,加入训练数据。模型在训练中见过的工具越多,它泛化工具调用能力的机会就越大(而不是只会用训练中见过的几个工具)。
我跟 ChatGPT 和 Claude 聊了几轮后,定了 40 个不同类别的工具,涵盖数学、检索、生产力等。然后让 Claude(通过 Cursor)把工具名称、描述和参数写进一个 tools.yaml
文件。
# 示例工具元数据
- name: calculator
description: Perform basic arithmetic calculations.
parameters:
properties:
expression:
type: string
description: Arithmetic expression to evaluate.
工具(基本)定好后,我让 Claude 为每个工具写 Python 函数。过程中,我会时不时回过头改 tools.yaml
和 tools.py
文件,解决后续步骤中冒出的问题。
步骤 2:生成查询
接下来,我为微调数据集生成了三类查询:
- •no_tool:无需工具就能回答的查询
- •easy:需要工具调用,但直接调用即可
- •hard:需要工具调用,但可能得先“思考”一下
所有查询都通过 OpenAI 的 API 由 GPT-4.1 生成。对于 no_tool 查询,我通过硬编码参数(比如类别、种子、难度、拼写错误)增加多样性。对于 easy 和 hard 查询,我把每个工具的元数据传给 GPT-4.1,让它为每个工具生成 5 个查询。
最后,我生成了 600 个查询。生成查询的代码可以在这里找到。
步骤 3:生成对话记录(Traces)
有了工具和查询,我们可以开始构建完整的对话(即 traces)来训练 Gemma 3。第一个重要决定是系统消息和工具调用的结构。
Gemma 3 只有两种角色:user 和 model(不像 Llama 3.2 有 user、assistant、system 和 ipython 四种角色)[3,4]。所以,我得决定如何组织高级系统指令、工具调用和工具调用结果。以下是我用的格式示例:
<|begin_of_text|>
<|start_header_id|>user<|end_header_id>
<instructions>
# 系统指令放这里
</instructions>
<tools>
# 工具列表和元数据放这里
</tools>
<|start_header_id|>user<|end_header_id>
# 用户查询放这里
<|start_header_id|>assistant<|end_header_id>
# 助手回复放这里
<tool_call>
# 助手工具调用放这里
</tool_call>
<|start_header_id|>user<|end_header_id>
<tool_result>
# 工具调用结果放这里
</tool_result>
<|start_header_id|>assistant<|end_header_id>
<final_answer>
# 助手的最终回复放这里
</final_answer>
对于 no_tool 查询,我把系统提示和用户查询传给 google/gemma-3n-E4B-it
(通过 Together AI 的 API)生成回复。对于 easy 查询,我让 GPT-4.1 生成工具调用,执行后传给 google/gemma-3n-E4B-it
生成最终回复。hard 查询的流程类似,但我特别鼓励 GPT-4.1 在调用工具前写出思考过程。
最后,我手动检查并修复了示例中失败的工具调用或格式错误,然后将最终数据集推送到 Hugging Face hub。这个过程分为两个笔记本:2-gen_traces.ipynb
和 3-data_prep.ipynb
。
步骤 4:微调模型
600 个 traces 中,477 个用于训练,60 个用于开发,60 个用于测试(3 个被移除)。另外,我只用了 easy 查询和 20% 的 no_tool 查询(hard 查询因性能问题未用于训练)。
from datasets import load_dataset
# 加载数据集
ds = load_dataset("shawhin/tool-use-finetuning")
# 过滤数据集
import numpy as np
np.random.seed(42)
def filter_dataset(example):
# 保留所有需要工具的 easy 查询
if example['query_type'] == 'easy' and example['tool_needed'] == True:
return True
# 保留 20% 的 no_tool 查询
if example['query_type'] == 'no_tool':
return np.random.random() < 0.2
# 排除其他
return False
# 应用过滤
ds = ds.filter(filter_dataset)
我从 Hugging Face hub 用 transformers 库加载了 gemma-3-1b-it
。注意,这里必须用指令调优模型,因为训练基础 Gemma 模型需要更多训练数据和精力来学会工具调用。
from transformers import AutoTokenizer, AutoModelForCausalLM
# 加载模型
model_name = "google/gemma-3-1b-it"
model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map="mps", # 我用的是 Mac
attn_implementatinotallow='eager' # 模型卡推荐
)
# 加载 tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name)
接着,我预处理数据以使用合适的聊天模板。注意:apply_chat_template()
方法要求消息角色交替,所以我把第一条消息角色改成了“system”。
def preprocess(row):
# 将第一条 user 消息角色改为 system
messages = row['trace']
messages[0]['role'] = 'system'
# 向数据集添加 tokenized 文本
return {
"text": tokenizer.apply_chat_template(messages, tokenize=False,
add_generation_prompt=False, return_tensors="pt")
}
# 应用预处理函数
ds = ds.map(preprocess)
然后,我用 peft 库设置 LoRA 训练。这让可训练参数从 10 亿降到 1300 万。以下参考资料帮我选了超参数[5,6]。
from peft import LoraConfig
r = 16
lora_alpha = 32
lora_dropout = 0.05
target_modules = "all-linear"
peft_config = LoraConfig(r=r,
lora_alpha=lora_alpha,
lora_dropout=lora_dropout,
target_modules=target_modules,
bias="none",
task_type=TaskType.CAUSAL_LM)
最后,我用 trl 库进行监督微调(supervised fine-tuning)。超参数如下:
from trl import SFTConfig
# 超参数
lr = 2e-4
num_epochs = 3
batch_size = 1
finetuned_model_name = "gemma-3-1b-tool-use"
# 定义训练参数
training_args = SFTConfig(
output_dir=f"models/{finetuned_model_name}",
num_train_epochs=num_epochs,
learning_rate=lr,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
gradient_accumulation_steps=8,
warmup_ratio = 0.03,
max_grad_norm = 0.3,
eval_strategy="steps",
save_strategy="steps",
logging_steps=20,
eval_steps=20,
save_steps=20,
load_best_model_at_end=True,
bf16=False,
fp16=False,
metric_for_best_model="eval_loss",
greater_is_better=False,
)
from trl import SFTConfig, SFTTrainer
# 训练模型
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=ds["train"],
eval_dataset=ds["validation"],
processing_class=tokenizer,
peft_cnotallow=peft_config,
)
trainer.train()
训练结果如下,训练和验证损失都在下降,说明效果不错。
训练和验证损失图
步骤 5:评估模型
虽然训练结果看着不错,但我们还得验证模型在工具调用上是否真有提升。我检查了三个评估标准:
- •工具调用时机:模型是否在需要时调用工具,且在不需要时不调用。
- •正确工具选择:如果模型调用工具,是否选对了工具。
- •工具调用成功:即使选对了工具,格式可能出错,这个评估检查格式是否正确。
最终结果如下,数值表示每个模型在各项评估中的通过率:
令人惊讶的是,指令调优模型在工具调用时机上表现不错,但常选错工具或搞砸工具调用语法。
评估代码可以在这里(https://github.com/ShawhinT/llm-tool-use-ft/blob/main/5-eval_model.ipynb)找到。
总结
工具调用让语言模型从静态文本生成器变成了能与现实世界互动的动态系统。我们通过微调 gemma-3-1b-it
,让它更可靠地使用工具。
指令调优模型在知道何时调用工具上表现尚可,但选工具和调用工具的格式常出错。微调模型在后两项能力上提升了 15%。
本文转载自AI大模型观察站,作者:AI大模型观察站
