大模型做 Agent 时,最让人头疼的不是推理能力不够,而是工具调用不准——该调 API 时不调,不该调时瞎调,参数拼错、格式乱写,整个工作流直接断掉。Amazon SageMaker AI 最近的一篇实践文章给出了一个清晰的解法:先用监督微调(SFT)教会模型"怎么正确调用工具",再用直接偏好优化(DPO)让它学会"哪种调用方式更好",两步叠加,小语言模型(SLM)的工具调用准确率可以显著提升。
下面拆解这套方法的核心思路、数据构造方式、训练配置,以及如何量化评估效果。
工具调用为什么容易翻车
Agent 的工具调用本质上是一个结构化输出任务:模型需要在对话中判断是否需要调用工具、选择哪个工具、按 schema 填写参数。对大模型来说这已经很吃力,对 7B–14B 的 SLM 更是如此——训练语料里工具调用的分布本来就少,模型经常出现三类典型错误:
- 漏调:用户明确要求查天气,模型直接用内部知识编一个回答,不调 weather API。
- 错调:用户问"帮我订明天上午的会议室",模型调了日历查询 API 而不是预订 API。
- 参数错:调对了 API,但日期格式写成
2024-5-1而不是 schema 要求的YYYY-MM-DD。
SFT 解决的是"会不会"的问题——让模型见过足够多的正确调用样本,学会格式和时机。DPO 解决的是"好不好"的问题——让模型在多个候选回答中偏好更精确、更简洁的调用方式,减少冗余描述和参数歧义。
SFT 阶段:先教会正确的调用姿势
SFT 的数据构造是整个流程中最耗精力的环节。每条样本是一个多轮对话,包含用户输入、模型判断(是否调工具)、工具执行结果、模型最终回复。关键是格式要和推理时用的 tool schema 完全一致。
下面是一个典型的 SFT 训练样本,采用 JSON Lines 格式,兼容 HuggingFace trl 库的 SFTTrainer:
{"messages":[{"role":"system","content":"You are a helpful assistant with access to the following tools. Use them when needed:\n\n[{\"name\": \"get_weather\", \"description\": \"Get current weather for a city\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\", \"description\": \"City name\"}, \"unit\": {\"type\": \"string\", \"enum\": [\"celsius\", \"fahrenheit\"]}}, \"required\": [\"city\"]}}]"},{"role":"user","content":"北京今天天气怎么样?"},{"role":"assistant","content":"<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Beijing\", \"unit\": \"celsius\"}}\n</tool_call>"},{"role":"tool","content":"{\"temperature\": 28, \"condition\": \"sunny\", \"humidity\": 45}"},{"role":"assistant","content":"北京今天天气晴朗,气温 28°C,湿度 45%。"}]}
几个要点:
- system prompt 里嵌入完整的工具 schema,用 JSON 描述,和 OpenAI tool calling 格式对齐。
- assistant 的工具调用用特殊标记
<tool_call>包裹,推理时可以可靠地解析出来。 - tool 角色返回执行结果,assistant 再基于结果生成自然语言回复——这教会模型"调完工具要读结果再回答",而不是编造。
在 SageMaker AI 上启动 SFT 训练,可以用 PyTorch 训练脚本配合 trl:
from trl import SFTTrainer
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "meta-llama/Llama-3.1-8B-Instruct"
model = AutoModelForCausalLM.from_pretrained(model_id)
tokenizer = AutoTokenizer.from_pretrained(model_id)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=sft_dataset, # 上面格式的 JSONL 加载后的 Dataset
max_seq_length=2048,
args={
"num_train_epochs": 3,
"per_device_train_batch_size": 4,
"learning_rate": 2e-5,
"lr_scheduler_type": "cosine",
"warmup_ratio": 0.1,
"logging_steps": 10,
},
)
trainer.train()
trainer.save_model("/opt/ml/model") # SageMaker 挂载的输出路径
SageMaker AI 的好处是不需要自己管 GPU 集群——你只需要写训练脚本,打包成 Docker 镜像或用预置容器,通过 Estimator 提交即可:
import sagemaker
from sagemaker.pytorch import PyTorch
estimator = PyTorch(
entry_point="train_sft.py",
role=sagemaker.get_execution_role(),
instance_type="ml.g5.12xlarge", # 4xA100
instance_count=1,
framework_version="2.3",
py_version="py311",
hyperparameters={
"model_id": "meta-llama/Llama-3.1-8B-Instruct",
"epochs": 3,
"lr": 2e-5,
},
output_path=f"s3://{bucket}/sft-output/",
)
estimator.fit({"train": s3_train_data_path})
DPO 阶段:让模型偏好更精准的调用
SFT 之后模型基本能调工具了,但还有灰色地带:同一个问题可能有多种调用方式,有的参数更精确、有的更冗余。DPO 通过"偏好对"来让模型学会选更好的那个。
DPO 数据格式需要 chosen 和 rejected 两条响应:
{"prompt":"上海明天会下雨吗?","chosen":"<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Shanghai\", \"unit\": \"celsius\"}}\n</tool_call>","rejected":"我来帮你查一下上海的天气。<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"shanghai\"}}\n</tool_call>\n根据我的了解,上海明天可能有小雨。"}
这条偏好对教了三件事:
- chosen 直接调用,不废话;rejected 先说"我来帮你查",冗余。
- chosen 参数带
unit,更完整;rejected 缺了可选但有用的参数。 - chosen 不在调用前编造答案;rejected 调完工具还加了一句"根据我的了解…",容易和工具结果矛盾。
rejected 样本的来源可以是:SFT 模型的错误输出、参数缺失的调用、格式不规范的调用,或者故意制造的常见错误模式。
DPO 训练同样用 trl:
from trl import DPOTrainer
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(sft_model_path) # SFT 产出的模型
tokenizer = AutoTokenizer.from_pretrained(sft_model_path)
# DPO 需要一个 reference model(冻结,不训练)
ref_model = AutoModelForCausalLM.from_pretrained(sft_model_path)
trainer = DPOTrainer(
model=model,
ref_model=ref_model,
tokenizer=tokenizer,
train_dataset=dpo_dataset,
args={
"num_train_epochs": 1,
"per_device_train_batch_size": 2,
"learning_rate": 5e-7, # DPO 学习率要低很多
"beta": 0.1, # DPO KL 散度惩罚系数
"max_length": 1024,
"logging_steps": 10,
},
)
trainer.train()
trainer.save_model("/opt/ml/model")
注意 beta 参数:值越大,模型越不愿意偏离 reference 模型的分布,训练更保守但更稳定;值越小,偏好信号更强但可能过拟合。0.1 是一个常见起点,建议从 0.05–0.2 之间网格搜索。
怎么评估工具调用准确率
训练完了不能只看 loss 曲线,工具调用需要专门的评估指标。原文推荐拆成三个维度分别打分:
| 维度 | 评估内容 | 计算方式 |
|---|---|---|
| 意图判断 | 该调工具时是否调了,不该调时是否没调 | 准确率(二分类) |
| 工具选择 | 调了正确的工具还是错误的工具 | 选择准确率 |
| 参数填充 | 参数名和值是否符合 schema | 字段级 F1 |
一个可改造的评估脚本骨架:
import json
from collections import defaultdict
def evaluate_tool_call(predictions, references):
"""predictions / references: list of dicts with keys
'should_call' (bool), 'tool_name' (str|None), 'arguments' (dict|None)"""
intent_correct = 0
tool_correct = 0
param_f1_scores = []
for pred, ref in zip(predictions, references):
# 意图判断
pred_calls = pred.get("should_call", False)
if pred_calls == ref["should_call"]:
intent_correct += 1
# 工具选择(仅在该调工具的样本上评估)
if ref["should_call"]:
if pred.get("tool_name") == ref["tool_name"]:
tool_correct += 1
# 参数填充 F1
pred_args = pred.get("arguments", {})
ref_args = ref.get("arguments", {})
required_keys = set(ref_args.keys())
pred_keys = set(pred_args.keys())
if not required_keys:
continue
tp = len(required_keys & pred_keys & {k for k in required_keys if pred_args.get(k) == ref_args.get(k)})
precision = tp / len(pred_keys) if pred_keys else 0
recall = tp / len(required_keys) if required_keys else 0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0
param_f1_scores.append(f1)
n = len(predictions)
n_callable = sum(1 for r in references if r["should_call"])
return {
"intent_accuracy": intent_correct / n,
"tool_accuracy": tool_correct / n_callable if n_callable else 0,
"param_f1": sum(param_f1_scores) / len(param_f1_scores) if param_f1_scores else 0,
}
# --- 使用示例 ---
predictions = [
{"should_call": True, "tool_name": "get_weather", "arguments": {"city": "Beijing", "unit": "celsius"}},
{"should_call": False, "tool_name": None, "arguments": None},
{"should_call": True, "tool_name": "get_weather", "arguments": {"city": "shanghai"}}, # 缺 unit
]
references = [
{"should_call": True, "tool_name": "get_weather", "arguments": {"city": "Beijing", "unit": "celsius"}},
{"should_call": False, "tool_name": None, "arguments": None},
{"should_call": True, "tool_name": "get_weather", "arguments": {"city": "Shanghai", "unit": "celsius"}},
]
results = evaluate_tool_call(predictions, references)
print(json.dumps(results, indent=2))
# 预期输出:
# {
# "intent_accuracy": 1.0,
# "tool_accuracy": 1.0,
# "param_f1": 0.833...
# }
实际评估时,predictions 来自模型推理输出,需要先解析 <tool_call> 标记提取结构化信息,再和 ground truth 对比。原文的做法是对比 base 模型、SFT 模型、SFT+DPO 模型在同一个测试集上的三组指标,用数据驱动决策——如果 DPO 只提升了参数 F1 但意图准确率没变,说明偏好数据需要更多意图层面的负例。
落地时的取舍和检查清单
这套方法不是万能的,几个实际边界需要提前想清楚:
- 数据量:SFT 至少需要几百条高质量多轮对话样本(覆盖不同工具组合和边界 case),DPO 偏好对数量可以少一些但质量要高——rejected 不能是随机噪声,必须是"看起来还行但有具体缺陷"的输出。
- 工具数量:如果 Agent 接入的工具超过 10 个,SFT 数据要确保每个工具都有足够样本,否则模型会偏向高频工具而忽略低频工具。可以考虑按工具分组采样再混合。
- 推理成本:SFT+DPO 后的小模型工具调用准确率可能接近大模型,但复杂推理能力仍有差距。如果你的 Agent 需要多步规划+工具调用混合,可能需要外挂一个规划模块,小模型只负责调用执行。
- SageMaker 费用:
ml.g5.12xlarge按需实例约 $12/小时,SFT 3 个 epoch + DPO 1 个 epoch 在几百条数据上大概 2–4 小时,总费用 $25–50。数据量大时需要估算时间再选实例类型。
上线前的快速检查清单:
- [ ] SFT 数据的 tool schema 和推理时 system prompt 里的 schema 是否完全一致?
- [ ] DPO 的 rejected 样本是否覆盖了"漏调、错调、参数错"三类典型错误?
- [ ] 评估测试集是否包含"不该调工具"的负例(防止模型过度调用)?
- [ ] 是否对比了 base → SFT → SFT+DPO 三阶段的指标变化,确认每一步都有提升?
- [ ] 推理时的
<tool_call>解析逻辑是否和训练时的格式对齐? - [ ] DPO 的
beta值是否做了至少 2–3 个值的对比实验?
SFT 教规则,DPO 教品味,两步叠加让小模型在工具调用上从"勉强能用"变成"可靠可用"。如果你的 Agent 正在为调用不准而频繁兜底,这套流程值得花一个周末试一轮。