AB
AiBoss站
教程

智能体工具调用微调实战:数据、QLoRA、运行时超参与偏好对齐

教程

智能体工具调用微调实战:数据、QLoRA、运行时超参与偏好对齐

面向需要让智能体稳定调用内部工具的开发者,本文把智能体微调拆成四个可独立调节的旋钮:训练数据、参数高效微调、运行时超参数与偏好对齐。以客服工单分流智能体为例,给出工具调用数据集的构造与校验代码、QLoRA 适配器配置、推理期温度与重试策略的设定方法,以及用 DPO 教会模型做判断题的思路,并说明何时该微调、何时该改用检索。

把一个通用大模型改造成能可靠调用内部工具的智能体,往往不是「训练一次就完事」的单点任务。模型权重调好了,推理时的温度设错,线上照样失败;温度调对了,训练数据里的工具调用格式不规范,模型照样会编造不存在的函数名。真正决定成败的是四个彼此独立的环节:训练数据、参数高效微调、运行时超参数、偏好对齐。跳过其中任何一个,项目表现都会低于预期。

这篇教程面向需要让智能体稳定调用自有工具的开发者和算法工程师,把上述四个环节按顺序讲清楚。全文围绕同一个例子展开:一个客服工单分流智能体,需要可靠地调用三个内部工具——lookup_order(按订单号查详情)、issue_refund(发起退款)、escalate_to_human(转人工),而不是凭「听起来对」的直觉作答。

准备工作

先确认环境与依赖。训练侧示例需要 Python 3.10 及以上版本,并安装以下库:

pip install peft transformers datasets accelerate

如果要做真实的训练运行,还需要额外安装 bitsandbytes,并准备一块 CUDA GPU。数据集构造、超参数设定和评估这三类示例不需要特殊硬件,普通机器即可运行。

在动手之前,先想清楚一件事:什么时候微调才是对的工具。当前的前沿基座模型本身就是很好的通用指令遵循者,微调真正能解决的问题集中在三处:

  • 精确的输出结构:要求模型每次都以完全一致的语法发出工具调用,参数名不能有偏差。
  • 狭窄的领域词汇:内部系统特有的字段名、状态码、业务术语。
  • 仅靠提示词无法稳定约束的行为一致性:同一类输入必须触发同一类动作。

微调不能解决知识缺失。如果智能体需要的是训练时点之后才出现的事实,那属于检索问题,不是微调问题;再怎么训练,也无法让模型可靠地知道它从未见过的东西。判断标准很简单:需要的是「换一种说法」还是「知道一件新事」——前者微调,后者上检索。

确认微调是正确路径之后,「微调智能体」这件事会自然拆成四个独立问题:

  1. 训练数据:它教的是不是真正需要的行为,格式是不是推理时会遇到的格式?
  2. 参数高效训练:如何在不依赖大规模算力集群的前提下更新权重?
  3. 运行时超参数:温度、迭代上限、重试策略,这些在训练之后、推理之时才决定,但足以毁掉一个训练良好的模型。
  4. 偏好对齐:教模型做那些单一「正确答案」标签无法表达的判断题。

操作步骤

第一步:构造工具调用微调数据集

对这类微调而言,格式比数据量重要得多。基座模型已经能流畅地写出关于退款政策的英文段落,但它不会每次都稳定地发出语法精确、参数名正确的工具调用。这是格式问题,几百条结构良好的样本比几千条松散样本有效得多。

下面这份脚本定义了三个工具的结构,并提供样本构造函数与校验函数:

# dataset.py
import json

TOOLS_SCHEMA = [
    {
        "name": "lookup_order",
        "description": "Retrieves order details by order ID.",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "string"}},
            "required": ["order_id"],
        },
    },
    {
        "name": "issue_refund",
        "description": "Issues a refund for an order. Only call this after confirming eligibility.",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string"},
                "amount": {"type": "number"},
            },
            "required": ["order_id", "amount"],
        },
    },
    {
        "name": "escalate_to_human",
        "description": "Hands the ticket to a human agent. Use for anything ambiguous, high-value, or policy-adjacent.",
        "parameters": {
            "type": "object",
            "properties": {"reason": {"type": "string"}},
            "required": ["reason"],
        },
    },
]


def make_example(user_message: str, tool_name: str, tool_args: dict) -> dict:
    return {
        "messages": [
            {"role": "system", "content": "You are a support triage agent with access to tools."},
            {"role": "user", "content": user_message},
            {
                "role": "assistant",
                "content": None,
                "tool_calls": [
                    {
                        "type": "function",
                        "function": {
                            "name": tool_name,
                            "arguments": json.dumps(tool_args),
                        },
                    }
                ],
            },
        ]
    }


def validate_examples(examples: list[dict]) -> list[str]:
    """Schema validation, before training starts, not after a wasted run."""
    valid_tool_names = {t["name"] for t in TOOLS_SCHEMA}
    tools_by_name = {t["name"]: t for t in TOOLS_SCHEMA}
    errors = []
    for i, example in enumerate(examples):
        for message in example["messages"]:
            if message["role"] != "assistant" or "tool_calls" not in message:
                continue
            for call in message["tool_calls"]:
                name = call["function"]["name"]
                if name not in valid_tool_names:
                    errors.append(f"Example {i}: unknown tool '{name}'")
                    continue
                required = set(tools_by_name[name]["parameters"].get("required", []))
                provided = set(json.loads(call["function"]["arguments"]).keys())
                missing = required - provided
                if missing:
                    errors.append(f"Example {i}: tool '{name}' missing required args {missing}")
    return errors

这段代码有两个设计要点。第一,每一行训练样本都采用当前主流 SFT 训练器原生支持的 role/content 对话格式,因此数据集可以直接接入训练器,不需要自己写并调试一个自定义的 collator。第二,validate_examples 是真正值得认真对待的部分:它在任何训练步执行之前,把数据集里的每一次工具调用拿去和真实的工具结构比对,能抓出未知的工具名和缺失的必填参数。

用一对故意写坏的样本测试这个校验器——一个调用了不存在的工具,一个漏掉了必填参数——两种情况都能被正确捕获。这是一个五分钟就能跑完的廉价检查,却能避免把「教模型编造参数」的数据集喂进训练流程;而后者要等到训练跑完才发现,代价高得多。

当样本规模超出人工手写的范围时,当前通行的做法不是靠人力堆量,而是合成生成加裁判过滤:先手写 150 到 200 条种子样本,再用一个更强的教师模型把它们扩展开,然后对每一条生成结果按指令遵循度和正确性打分,在进入训练器之前就丢弃得分最低的 10% 到 20%。

第二步:用 QLoRA 做参数高效微调

拿到经过校验的数据集之后,在单块高显存 GPU 上使用 QLoRA 是多数团队的默认起点。它的做法是把基座模型冻结在 4 位精度,在其上训练一小组低秩适配矩阵,从而让 70B 级别的模型能装进完整微调根本跑不动的硬件里。

from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model, TaskType

model = AutoModelForCausalLM.from_pretrained(
    "your-base-model",
    load_in_4bit=True,
    device_map="auto",
)

lora_config = LoraConfig(
    r=4,                 # rank of the adapter matrices, lower = fewer trainable params
    lora_alpha=32,       # scaling factor applied to the adapter's output
    lora_dropout=0.05,   # regularization on the adapter, helps on small datasets
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    task_type=TaskType.CAUSAL_LM,
)

peft_model = get_peft_model(model, lora_config)
peft_model.print_trainable_parameters()

几个参数值得逐个理解:

  • r 是秩,决定适配器的表达能力。它是最该优先理解的一个超参数,因为它直接决定容量与过拟合风险、适配器体积之间的取舍。值越小,可训练参数越少。
  • lora_alpha 是缩放系数,控制适配器输出相对于冻结基座权重的贡献比例。
  • lora_dropout 是施加在适配器上的正则化,在小数据集上有帮助。
  • target_modules 指定把适配器挂到哪些线性层上,这里覆盖了注意力机制的四个投影层。
  • load_in_4bit=True 是真正需要 CUDA GPU 的部分,这种级别的量化加载无法在纯 CPU 上有意义地运行。

上面给出的 r=4alpha=32dropout=0.05 这组组合并非随意选取,它来自一个经过同行评审的工具型智能体微调方案,是针对小型指令模型上的工具调用行为专门测试过的配置。实际使用时仍应结合自己的基座模型与数据规模做小范围对比。

第三步:把运行时超参数当作训练超参数来调

训练结束不等于工作完成。推理阶段的几个设置同样会决定线上表现,而且它们和训练超参数一样需要被认真对待、被记录、被版本化。

  • 温度:工具调用场景通常需要比自由文本生成更低的温度,因为需要的是稳定的结构输出而非多样性。温度过高会让模型在参数值上「发挥」,温度过低则可能让它在需要转人工的模糊场景里过度自信。
  • 迭代上限:智能体循环调用工具时必须有最大轮数限制,否则一次异常的工具返回就可能让它陷入无休止的调用链。
  • 重试策略:工具调用失败后是重试、换参数重试,还是直接转人工,需要在推理层明确定义,而不是留给模型临场决定。

这三项的共同点是:它们都在训练之后才生效,却足以让一个训练良好的模型在生产环境里失败。因此建议把它们和训练配置放在同一套配置管理里,一起做变更记录。

第四步:用 DPO 教模型做判断题

监督微调只能表达「唯一正确答案」,但智能体日常面对的很多情况并没有唯一正确答案。例如:一张工单金额不高但客户情绪激烈,应该直接退款还是转人工?两种做法都不算错,但其中一种更符合业务预期。这类判断无法用单一标签表达。

直接偏好优化(DPO)解决的正是这个问题:不再提供唯一正确输出,而是提供一对回答——一个被偏好的、一个被拒绝的——让模型学习两者之间的相对优劣。对于工单分流智能体,可以构造这样的偏好对:

  • 被偏好:在确认订单符合退款条件后调用 issue_refund,参数完整。
  • 被拒绝:未确认资格就直接调用 issue_refund,或对明显需要人工介入的高价值工单直接自动退款。

这样训练出来的模型学到的不是「遇到 X 就输出 Y」,而是「在这类边界情况下,哪种处理方式更可取」。

第五步:用结论驱动的框架做评估

评估不能只看损失曲线。一个结论驱动的评估框架应当对每个测试用例给出明确判定,而不是一个模糊的分数。至少需要覆盖以下几类检查:

  • 工具名正确性:模型是否调用了真实存在的工具,而不是编造一个名字相近的函数。
  • 参数完整性:必填参数是否齐全,类型是否正确。
  • 行为一致性:同类输入是否稳定触发同类动作。
  • 灾难性遗忘:微调之后,模型在原本擅长的通用任务上是否出现明显退化。这一项必须在发布之前检查,而不是等上线后由用户发现。

把每一项都写成可自动执行的判定,让评估结果直接给出通过或不通过的结论,而不是留给人工解读。

一个完整示例

把上述步骤串起来,针对工单分流智能体的最小可运行流程如下。

1. 定义工具结构并构造种子样本。使用第一步中的 TOOLS_SCHEMA,手写若干条覆盖三个工具的样本:

examples = [
    make_example("Where is my order #A1024?", "lookup_order", {"order_id": "A1024"}),
    make_example("Order A1024 arrived damaged, please refund it.",
                 "issue_refund", {"order_id": "A1024", "amount": 59.9}),
    make_example("This is the third time I'm contacting you about this.",
                 "escalate_to_human", {"reason": "repeated contact, customer frustrated"}),
]

2. 训练前先校验。在把数据交给训练器之前运行校验:

errors = validate_examples(examples)
if errors:
    raise SystemExit("\n".join(errors))
print(f"{len(examples)} examples passed schema validation")

如果这里抛出错误,说明数据集本身有问题,此时修正的成本远低于训练跑完之后再回头排查。

3. 配置 QLoRA 并查看可训练参数量。使用第二步中的 LoraConfig 配置,调用 get_peft_model 之后执行 print_trainable_parameters(),确认可训练参数占比处于预期范围。如果这个比例异常偏高,通常意味着 target_modules 配置过宽或 r 设得过大。

4. 训练完成后固定推理配置。为这个分流智能体设定一组明确的运行时参数:较低的温度以保证工具调用结构稳定,设定最大工具调用轮数,并定义失败后的重试与转人工规则。把这组配置与训练配置一起纳入版本管理。

5. 用偏好对做 DPO 补充训练。针对边界场景构造偏好对,例如「先确认资格再退款」优于「直接退款」,让模型学会在模糊情况下倾向于更稳妥的处理路径。

6. 跑结论驱动的评估。用一批覆盖正常路径与边界情况的测试用例,逐项检查工具名、参数、行为一致性和通用能力退化,得到明确的通过或不通过结论后再决定是否发布。

注意事项

以下几点在素材中被明确提及,值得在实施时留意:

  • 微调不解决知识缺失。如果智能体需要的是训练时点之后才存在的事实,正确做法是接入检索,而不是继续加训练数据。任何规模的训练都无法让模型可靠地知道它从未被展示过的东西。
  • 格式优先于数量。对工具调用这一特定类型的微调,几百条结构良好的样本比几千条格式松散的样本更可靠。
  • 校验必须在训练前执行。把工具调用与真实结构比对,抓出未知工具名和缺失必填参数,是一个成本极低但收益很高的步骤;等到训练跑完再发现数据集有问题,代价高得多。
  • 合成数据需要过滤。用教师模型扩展种子样本时,必须对每条生成结果按指令遵循度和正确性打分,丢弃得分最低的 10% 到 20%,否则噪声会直接进入训练。
  • 4 位量化加载需要 CUDA GPU。数据集构造、超参数设定和评估示例不需要特殊硬件,但真实的量化训练运行需要相应硬件支持。
  • 运行时超参数同样会毁掉模型。温度、迭代上限、重试策略都在训练之后生效,却足以让一个训练良好的模型在生产中失败,不能因为「训练已经做完了」就随意设置。
  • 灾难性遗忘要在发布前检查。微调之后模型可能在通用任务上退化,这一项必须纳入发布前的评估清单。
  • 参数取值需要自行验证。文中给出的 LoRA 配置来自特定的小型指令模型工具调用场景,换用其他基座模型或数据规模时,应做小范围对比后再确定。

涉及具体库版本、硬件要求、模型可用性与相关服务价格时,请以各项目官网当前公布的信息为准。