用 SFT + DPO 双阶段训练,让小模型的工具调用不再"胡说八道"

2026-06-03 26 预计阅读时间:1 分钟
来源:aws.amazon.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:14 分钟

大模型做 Agent 时,最让人头疼的不是推理能力不够,而是工具调用不准——该调 API 时不调,不该调时瞎调,参数拼错、格式乱写,整个工作流直接断掉。Amazon SageMaker AI 最近的一篇实践文章给出了一个清晰的解法:先用监督微调(SFT)教会模型"怎么正确调用工具",再用直接偏好优化(DPO)让它学会"哪种调用方式更好",两步叠加,小语言模型(SLM)的工具调用准确率可以显著提升。

下面拆解这套方法的核心思路、数据构造方式、训练配置,以及如何量化评估效果。

工具调用为什么容易翻车

Agent 的工具调用本质上是一个结构化输出任务:模型需要在对话中判断是否需要调用工具、选择哪个工具、按 schema 填写参数。对大模型来说这已经很吃力,对 7B–14B 的 SLM 更是如此——训练语料里工具调用的分布本来就少,模型经常出现三类典型错误:

  1. 漏调:用户明确要求查天气,模型直接用内部知识编一个回答,不调 weather API。
  2. 错调:用户问"帮我订明天上午的会议室",模型调了日历查询 API 而不是预订 API。
  3. 参数错:调对了 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 数据格式需要 chosenrejected 两条响应:

{"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根据我的了解,上海明天可能有小雨。"}

这条偏好对教了三件事:

  1. chosen 直接调用,不废话;rejected 先说"我来帮你查",冗余。
  2. chosen 参数带 unit,更完整;rejected 缺了可选但有用的参数。
  3. 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 正在为调用不准而频繁兜底,这套流程值得花一个周末试一轮。


相关推荐