《AI Agent 高级编程》9.2 从 SFT 到 RL:工具使用与多步推理训练流程

SFT 教会模型「格式」,RL 才教会它「取舍」。本节讲清从监督微调到强化学习的完整流程,聚焦工具使用与多步推理:对比偏好奖励与可验证奖励,说明为什么「工具调用成功、引用可回溯」这类可自动判分的信号特别适合 Agent,并给出一个真跑的离线策略打分例子——给定若干轨迹与奖励函数,算出每条的分数并选出最优。

9.2 从 SFT 到 RL:工具使用与多步推理训练流程

上一节把 Agent 任务形式化成了轨迹与回报,但没回答「怎么用这些回报去改模型」。本节接上这条链路。要讲清它,得先分清两件事:**监督微调(SFT)和强化学习(RL)**各自能解决什么。一句话概括——SFT 教会模型「格式」,RL 教会模型「取舍」。格式是「工具调用要写成什么样、推理链要分几步」;取舍是「同样都能写出合法格式,哪一种走法更容易通向正确结果」。

本节聚焦 Agent 场景下工具使用与多步推理的 SFT→RL 流程:对比偏好奖励与可验证奖励两类反馈,说明为什么「工具调用成功、引用可回溯」这类可自动判分的信号特别适合 Agent,并用一个真跑的离线策略打分例子,把「给定若干轨迹与奖励函数,算出每条的分数并选出最优」这一步完整走一遍。本节不训练任何模型,所有数字都来自本机可复算的纯计算脚本。

9.2.1 SFT 教会了什么、教不会什么

SFT 的做法很直接:拿一批「输入 → 期望输出」的示范数据,用交叉熵把模型往示范上拉。对 Agent 来说,这批示范长这样:

用户:PlumePHP 的 RSS 模板在哪?
助手:<tool_call>search(query="rss template")</tool_call>
工具:layouts/rss.xml, layouts/_default/rss.xml
助手:<tool_call>read(path="layouts/_default/rss.xml")</tool_call>
工具:<?xml version="1.0" ...> ...
助手:RSS 模板在 layouts/_default/rss.xml,第 12 行起是模板主体。

SFT 在这件事上非常有效,因为它直接教会模型:

  • 格式:工具调用怎么写、参数怎么填、多轮消息怎么组织;
  • 流程模式:先搜再读、先规划再执行这类常见套路;
  • 风格:引用怎么标注、答案怎么组织。

但 SFT 有三个结构性教不会的东西:

  1. 它只学「示范里有的走法」。示范里没出现过的工具组合、没遇到过的错误恢复,模型学不到。而 Agent 任务的分支空间极大,穷举示范不现实。
  2. 它无法区分「对」和「看起来像对」。如果示范数据里混入了不准确的引用、绕远的路径,SFT 会照单全收——它优化的是「和示范的相似度」,不是「结果的好坏」。
  3. 它不能从失败中学习。SFT 只用正样本;模型自己采出来的一条错误轨迹,对 SFT 毫无价值(顶多作为负样本,但交叉熵不天然支持「远离」某个输出)。

第 2 条是致命的:SFT 的收益上限,就是示范数据的质量上限。示范数据里没有的「更好的走法」,SFT 永远学不出来。要突破这个上限,就需要 RL——让模型自己采样、按结果好坏来调整,而不是照着别人的答案抄。

9.2.2 两种反馈来源:偏好奖励 vs 可验证奖励

RL 需要一个「打分器」。按打分器的来源,分两大类:

反馈来源打分器数据成本适合的任务主要风险
偏好奖励(RLHF/RLAIF)训练一个人/模型偏好模型当代理奖励高(要人类排序,或昂贵模型标注)写作、对话、主观质量奖励模型可被钻空子(reward hacking);标注不一致
可验证奖励(RLVR)程序化判分:测试、schema、引用回溯低(一次写判分器,无限复用)有客观对错的任务只覆盖「可验证」的子集;判分器本身可能有 bug

偏好奖励的代表是 RLHF:先让人对多个回答排序,训一个奖励模型,再用 PPO(Schulman 等,Proximal Policy Optimization Algorithms,2017;arXiv:1707.06347)优化策略(Ouyang 等,Training language models to follow instructions with human feedback,2022;arXiv:2203.02155)。后来 DPO(Rafailov 等,Direct Preference Optimization,2023;arXiv:2305.18290)把「奖励模型 + PPO」这步绕开,直接用偏好对做对比损失,省掉了奖励模型这一层。

可验证奖励(RLVR,这个术语由 Lambert 等,Tulu 3: Pushing Frontiers in Open Language Model Post-Training,2024 提出)走的是另一条路:不训练打分器,直接用程序判定对错。数学题用标准答案核对,代码用单元测试跑,结构化输出用 schema 校验——判分是确定性的、可复算的、不会随时间漂移。DeepSeek-R1(DeepSeek-AI,DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning,2025;arXiv:2501.12948)正是用「答案对不对」这类可验证信号,把长链推理训出来的;它用的 GRPO(组相对策略优化,出自 Shao 等,DeepSeekMath,2024;arXiv:2402.03300)在同题一组轨迹内部做相对比较,恰好对应 9.1.4 讲的「优势」视角。

9.2.3 为什么可验证奖励特别适合 Agent

Agent 任务天然长在「有客观对错」的区域里,这给了可验证奖励一个极其顺手的落点:

Agent 行为可验证信号判定方式
工具调用调用是否成功返回、参数是否合法工具返回值 / schema 校验
引用溯源结论里的引用能否回溯到真实来源检索原文比对
代码执行代码能否跑通、测试是否通过单元测试
结构化输出输出是否符合目标 schemaJSON schema 校验
多步任务终态最终状态是否满足目标(如文件已创建、字段已填)断言 / 环境检查

这带来三个实实在在的好处:

  1. 信号客观:不需要昂贵且可能不一致的人类标注;同一份数据重跑判分器结果一样,训练可复现。
  2. 难被钻空子:偏好奖励模型会被模型学会「说好听的话」骗过;而「单元测试通过」这种信号,模型只能真的把测试跑过才能拿到——它逼着模型学真本事。
  3. 可离线验证:判分器是纯函数,可以在不训练的前提下先跑一遍,检查「它有没有把对的行为排在前面」。这正是本节 9.2.5 要做的。

代价也要讲清:可验证奖励只覆盖任务里「能客观判定」的那部分。像「这个答案读起来是不是自然」「解释是否清晰」这类主观维度,程序判不了,得靠偏好奖励或人工兜底。所以生产上的常见做法是混合:可验证信号当主干(保证正确性),偏好信号补主观维度(保体验)。

9.2.4 从 SFT 到 RL 的四条常见路径

把「反馈来源」和「优化方式」两个维度交叉,得到几条主流路线:

路线反馈优化方式代表工作适合 Agent 的点
SFT → RLHF (PPO)人类偏好训奖励模型 + PPOInstructGPT主观质量、通用对齐
SFT → DPO偏好对直接偏好优化DPO无需奖励模型,实现简单
SFT → RLVR (GRPO)可验证组内相对比较DeepSeek-R1 / Tulu 3工具调用、代码、推理
SFT → ReST / STaR自我采样 + 筛选迭代式自我训练STaR、ReST用「采样-打分-筛选」造更好的数据

其中第四类特别值得 Agent 关注:STaR(Zelikman 等,STaR: Bootstrapping Reasoning With Reasoning,2022;arXiv:2203.14465)和 ReST(Gulcehre 等,Reinforced Self-Training for Language Modeling,2023)的核心是「采样 → 用奖励筛选 → 用筛选出的好样本再 SFT」的循环。它不碰策略梯度,只做「生成 + 打分 + 过滤 + 重训」,实现门槛比 PPO/GRPO 低得多,而效果在「有可靠判分器」的任务上相当可观。对 Agent 来说,只要你的判分器(工具成功、引用可回溯)足够可靠,这条路线就是性价比最高的 RL 入门路径。

要理解它的第一步,就是把「打分 + 筛选」跑通——这正是下一小节。

9.2.5 离线策略打分:给定轨迹与奖励函数,选最优

下面这段是本节的可运行核心:定义三个可验证奖励项(答案正确、工具调用成功率、引用可回溯)加一个软长度约束,给一组候选轨迹逐条打分,选出最优。全部是纯函数,可离线复算:

"""离线策略打分:给一组轨迹套多个奖励函数,算总分并选最优(不训练模型)。"""
from dataclasses import dataclass, field
from typing import Callable


@dataclass
class Turn:
    action: str
    ok: bool = True          # 工具是否成功返回
    cited: bool = False      # 这一步的结论是否带可回溯引用


@dataclass
class Trajectory:
    task_id: str
    final: str
    turns: list[Turn] = field(default_factory=list)
    answer_ok: bool = False  # 最终答案是否与可验证标准一致

    @property
    def tool_calls(self) -> int:
        return sum(1 for t in self.turns if t.action.startswith("tool:"))


# 三个「可验证」奖励项 + 一个软约束:都是纯函数,可离线复算
def r_answer(t: Trajectory) -> float:
    return 1.0 if t.answer_ok else 0.0

def r_tools(t: Trajectory) -> float:
    calls = [x for x in t.turns if x.action.startswith("tool:")]
    return sum(1.0 for x in calls if x.ok) / len(calls) if calls else 0.0

def r_cite(t: Trajectory) -> float:
    if not t.turns:
        return 0.0
    return sum(1.0 for x in t.turns if x.cited) / len(t.turns)

def r_len(t: Trajectory, budget: int = 4) -> float:
    return -0.2 * max(0, len(t.turns) - budget)


REWARDS: dict[str, tuple[Callable[[Trajectory], float], float]] = {
    "answer": (r_answer, 1.0),
    "tool_ok": (r_tools, 0.5),
    "cited": (r_cite, 0.5),
    "length": (r_len, 1.0),
}


def score(t: Trajectory) -> float:
    return round(sum(w * fn(t) for fn, w in REWARDS.values()), 4)


cands = [
    Trajectory("q-1", "rss.xml 第 12 行", [Turn("tool:search"), Turn("tool:read", cited=True)], True),
    Trajectory("q-1", "大概是 layouts 目录", [Turn("tool:search")], False),
    Trajectory("q-1", "layouts/_default/rss.xml", [Turn("tool:search", ok=False),
                                                    Turn("tool:read", ok=False), Turn("tool:grep"),
                                                    Turn("tool:read"), Turn("tool:read", cited=True)], True),
]

for t in cands:
    print(f"final={t.final!r:32s} score={score(t)}  "
          f"answer={r_answer(t)} tools={r_tools(t):.2f} cite={r_cite(t):.2f} len={r_len(t):.2f}")

best = max(cands, key=score)
print("最优:", best.final, "->", score(best))

本机实测输出(Python 3.14.6,纯标准库):

final='rss.xml 第 12 行'                 score=1.75  answer=1.0 tools=1.00 cite=0.50 len=-0.00
final='大概是 layouts 目录'                 score=0.5  answer=0.0 tools=1.00 cite=0.00 len=-0.00
final='layouts/_default/rss.xml'       score=1.2  answer=1.0 tools=0.60 cite=0.20 len=-0.20
最优: rss.xml 第 12 行 -> 1.75

这份输出把「可验证奖励」的取舍讲得很清楚:

  • 第二条(含糊答案)拿了 0.5:它工具调用全成功(tools=1.00),但答案不对(answer=0)、没有引用(cite=0)。它不是 0 分——因为工具成功本身是「对的过程信号」,值得保留一点分数,避免模型学会「因为怕扣分而不敢调工具」。
  • 第三条(绕远路但答案对)拿了 1.2:它答案正确、有引用,但工具失败了两次、多调了两轮,被 tool_ok(0.60)和 length(−0.20)扣分。这说明奖励设计能同时约束「结果对不对」和「过程省不省」——这正是 Agent 奖励比纯答案判定更有表达力的地方。
  • 第一条最优(1.75):答案对、工具全成功、有引用、步数不超预算。它是「又快又准」的轨迹。

再强调一遍:这三个分数不是模型训练出来的,是奖励函数算出来的。这段代码的价值不在「选出最优」,而在暴露奖励函数的偏好:你只要改一下权重(比如把 tool_ok 从 0.5 提到 2.0),排序就可能翻转。上线前一定要先用这类离线脚本,把「你希望的排序」和「奖励函数实际给出的排序」对齐——否则模型会忠实地优化出一个你不想要的行为。

9.2.6 把打分接进训练环:ReST 的筛选一步

上一小节的打分脚本不只用来「选最优」,它正是 ReST/STaR 循环里的筛选器。把「采样 → 打分 → 按阈值筛选 → 用留存样本再 SFT」串起来,就是一条完整的自我训练回路。真跑这一小步(接上面的奖励口径):

"""ReST / STaR 的第一步:采样一批轨迹,按打分筛出高分样本,作为下一轮 SFT 数据。"""
from dataclasses import dataclass


@dataclass
class Traj:
    answer_ok: bool
    tools_ok: float      # 工具成功率 0~1
    cited: float         # 引用覆盖率 0~1


def score(t: Traj) -> float:
    return round(1.0 * t.answer_ok + 0.5 * t.tools_ok + 0.5 * t.cited, 4)


pool = [
    Traj(True, 1.00, 0.80),
    Traj(True, 0.60, 0.20),
    Traj(False, 1.00, 0.00),
    Traj(True, 1.00, 1.00),
    Traj(False, 0.50, 0.00),
]

scored = sorted(pool, key=score, reverse=True)
print("全部打分:", [score(t) for t in scored])
threshold = 1.0                       # 只留「答案对且过程达标」的样本
kept = [t for t in scored if score(t) >= threshold]
print(f"阈值 {threshold} 下保留 {len(kept)}/{len(pool)} 条作为下一轮 SFT 数据")

本机实测输出:

全部打分: [2.0, 1.9, 1.4, 0.5, 0.25]
阈值 1.0 下保留 3/5 条作为下一轮 SFT 数据

这就是 ReST 的全部精髓:用奖励当筛子,把模型自己采出来的好轨迹变成下一轮的监督数据。注意阈值这一行的微妙之处——两条被挡掉的(0.5、0.25)都是答案错的,因为 answer_ok 权重最高、答案错就封顶 0.5 分;而三条留下的(2.0、1.9、1.4)答案都对,但过程质量不同,1.4 那条工具只成功 60%、引用只有 20%,却依然越过了阈值。阈值定在哪里,决定了「模型下一轮向哪种行为靠拢」:定太高(比如 1.8),只剩过程最干净的一条,样本不够、训练停摆;定太低(比如 0.4),答案错的轨迹也混进来,把模型带偏。这是 ReST 类方法唯一但最关键的调参点。

9.2.7 Agent RL 特有的三个坑

把上面这套流程用到真实 Agent 上,有三个RL 通用流程里没有、Agent 特有的坑:

  1. 环境不可复现。通用 RL 的环境(游戏、仿真器)是确定性的;Agent 的环境是工具 + 外部系统——同一个 search 调用两次返回可能不同,网页会变、数据库会改。这意味着「同一条轨迹重放」在 Agent 里往往不成立,确定性重放(本卷 6.2 )在这里从「可选优化」变成「训练前提」。没有可靠的环境复现,回报的方差会大到淹没信号。
  2. 多轮、长程、稀疏。一次任务几十轮工具调用,奖励只在最后给一次——这就是 9.1.4 讲的信用分配困境被放大到极致。要么设计稠密的可验证中间信号(每步工具成功、每步引用有效),要么接受「整条轨迹一起奖惩」的粗粒度。
  3. 非平稳。模型在训练中行为会变,而它操作的工具、依赖的检索库、面对的用户分布也在变。通用 RL 假设环境平稳,Agent 场景不满足。所以 Agent RL 通常是小步迭代:训一轮、评测、看行为漂移、再调奖励,而不是一次性跑几十万步。

这三个坑指向同一个结论:Agent 的 RL 不是「把通用 RL 搬过来」,而是「在不可复现、稀疏、非平稳的环境里做小步迭代」。这也是为什么 9.3 会把「到底值不值得训」当成一个成本收益问题来认真算——很多时候,调提示或加工具比训模型更划算。

9.2.8 这套流程替你做了什么、你又让渡了什么

SFT→RL 这条流程替你做的:它让模型突破示范数据的上限——从「照着抄」变成「自己试、按结果好坏调整」。可验证奖励尤其适合 Agent,因为工具成功、引用回溯这些信号客观、可复算、难作弊,能用程序无限复用,不依赖昂贵标注。而 ReST/STaR 这类「采样-打分-筛选」路线,把 RL 的门槛降到了「先写一个可靠的判分器」这一步。

你让渡的:你要维护一套奖励函数 + 判分器,而它们本身可能有 bug、可能被钻空子——奖励错了,模型会忠实地把错的行为学到极致。你要接受 Agent 环境不可完全复现、回报方差大、训练要小步迭代的现实。你还得承担训练基础设施的成本(采样要跑真实工具、要存大量轨迹、要反复评测),这往往比「调提示」贵一个数量级。

判断「该不该走到这一步」,正是下一节 9.3 何时该训、何时调提示 的主题。想先看 SFT 之前的「通用微调与量化」站内背景,见 /posts/ai-ml/ ;模型对齐与推理增强的更大背景见 /posts/llm/ 。

阅读导航:上一节:9.1 问题设定:轨迹、奖励与信用分配 · 下一节:9.3 何时该训、何时调提示:成本收益决策清单 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距