《AI Agent 高级编程》9.1 问题设定:轨迹、奖励与信用分配

Agent 的强化学习不是给分类任务换个 loss。本节把 Agent 的多步交互形式化成轨迹(状态、动作、奖励序列),讲清奖励设计与信用分配——到底是哪一步的功劳。配一段可运行的轨迹与奖励数据结构,以及一个按折扣回报做信用归因、再按总回报给轨迹排序的离线小例子,真跑贴输出;全程不训练模型,不编造任何实验曲线。

9.1 问题设定:轨迹、奖励与信用分配

前八章讲的都是「怎么把 Agent 跑起来」:框架选型、统一抽象、持久化、计算机使用、跨 Agent 协议。这些手段解决的是工程问题,模型的行为本身并没有被改变。到了本章,问题换了个方向:如果一套固定的提示词怎么写都差那么一口气,能不能让模型自己从「做对/做错」的结果里学出更好的行为?这就是 Agent 强化学习(RL)要回答的事。

在卷一 1.3 可编程假模型 FakeModel 里,我们靠 FakeModel 把行为钉死、把测试变确定;本章走的是相反的路——承认行为是不确定的,然后用奖励去塑形它。但要讲 RL,先得把「Agent 的一次任务」翻译成 RL 能处理的数学对象。这一步没做好,后面所有训练都是空中楼阁。

本节把「Agent 的一次任务」形式化成轨迹(状态、动作、奖励序列),讲清奖励设计与信用分配这两个 RL 的入口问题,并给出一段真跑的离线代码:用折扣回报做信用归因,再按总回报给多条轨迹排序。本节不训练任何模型,只用纯计算把概念落到可运行的代码上。

9.1.1 为什么 Agent 的 RL 不是「分类任务加个 loss」

普通的监督学习里,样本是「输入 → 正确标签」的静态对,损失函数度量的是「预测和标签差多远」。Agent 的场景有三个根本不同:

维度监督学习Agent 强化学习
数据形态静态 (输入, 标签) 对动态轨迹 (s₀,a₀,r₁,s₁,a₁,r₂,…)
反馈来源每个样本都有标签往往只有最终一个结果信号
反馈稀疏度稠密(每样本一个 loss)稀疏(整条轨迹一个回报)
环境的角色无工具返回值、用户、外部系统都会改变后续状态
错误代价预测错,重算即可一步走错,后面全歪,还可能产生副作用

最关键的一行是「反馈稀疏度」:一次研究任务可能有 8 步,模型在第 3 步选错了搜索词、第 6 步引用了不可靠来源,但最终只有「答案对不对」这一个信号。信号是有的,但落不到具体步骤上——这就是**信用分配(credit assignment)**问题:整条轨迹拿了高分,到底是哪一步的功劳?哪一步该背锅?

9.1.2 把一次任务形式化成轨迹

RL 的标准记法是马尔可夫决策过程(MDP):状态 s、动作 a、奖励 r、转移 P、折扣 γ。Agent 可以直接套这个框架,但每一项目标的含义要重新对齐:

  • 状态 s:当前上下文——用户问题 + 历史消息 + 已产生的工具结果。对 Agent 来说,状态就是「喂给模型的整段 prompt」。
  • 动作 a:模型这一轮输出——要么是一次工具调用(search、read、code),要么是最终回答(终止动作)。
  • 奖励 r:这一步行为的即时反馈。难点在于「一步的奖励从哪来」,见 9.1.3。
  • 折扣 γ:越靠后的奖励权重越高还是越低,由它决定。Agent 任务里通常 γ 接近 1,因为我们在乎最终结果,不希望早期正确探索被过度衰减。

先把这套结构写成可运行的数据类。下面这段是本机真跑的最小实现(Python 3.14.6,纯标准库):

"""轨迹与奖励的最小数据结构:把 Agent 的多步交互形式化成 (状态, 动作, 奖励) 序列。"""
from dataclasses import dataclass, field


@dataclass
class Turn:
    index: int
    state: str          # 观察:这一步看到的上下文摘要
    action: str         # 动作:一次工具调用,或最终回答
    reward: float       # 该步的即时奖励


@dataclass
class Trajectory:
    task_id: str
    turns: list[Turn] = field(default_factory=list)

    def returns(self, gamma: float = 0.9) -> list[float]:
        """折扣回报:G_t = r_t + gamma * G_{t+1},从最后一步往前累加。"""
        out = [0.0] * len(self.turns)
        g = 0.0
        for i in range(len(self.turns) - 1, -1, -1):
            g = self.turns[i].reward + gamma * g
            out[i] = round(g, 4)
        return out

    def total_return(self, gamma: float = 0.9) -> float:
        return self.returns(gamma)[0] if self.turns else 0.0


good = Trajectory("q-1", [
    Turn(0, "问题:PlumePHP 的 RSS 模板在哪", "search(query='rss template')", 0.0),
    Turn(1, "工具返回 3 个文件路径", "read(path='layouts/rss.xml')", 0.0),
    Turn(2, "读到模板内容", "final('layouts/_default/rss.xml 第 12 行')", 1.0),
])

bad = Trajectory("q-1", [
    Turn(0, "问题:PlumePHP 的 RSS 模板在哪", "search(query='rss')", 0.0),
    Turn(1, "工具返回 20 个结果", "final('大概是 layouts 目录下某个文件')", -1.0),
])

print("good 逐步回报:", good.returns())
print("good 总回报:", good.total_return())
print("bad  逐步回报:", bad.returns())
print("bad  总回报:", bad.total_return())

本机实测输出:

good 逐步回报: [0.81, 0.9, 1.0]
good 总回报: 0.81
bad  逐步回报: [-0.9, -1.0]
bad  总回报: -0.9

returns() 从后往前累加,得到的是每一步的折扣回报 G_t——它代表「从这一步往后,总共能拿多少分」。注意 good 的前两步即时奖励都是 0,但它们的 G_t 分别是 0.81 和 0.9,因为后面那 1.0 分被折扣着往回传递了。这正是 RL 里回报的定义:早期动作的价值,来自它对后续结果的影响。

9.1.3 奖励设计:稀疏、稠密与可验证

奖励是 RL 里最需要人来设计的部分,也是最容易设计错的部分。常见三档:

奖励类型例子优点风险
稀疏奖励只在最终答案正确时给 +1定义客观、难作弊信号太少,模型学得慢;信用分配极难
稠密奖励每一步工具调用成功 +0.1信号多、学得快容易「奖励错的东西」,模型钻空子刷分
可验证奖励单元测试通过、引用可回溯客观、可自动判定、难作弊只适用于「有客观对错」的任务

Agent 任务里,可验证奖励(verifiable reward) 是一个特别好的落点:工具调用成功与否、引用能否回溯到真实来源、代码能否通过测试、结构化输出能否被 schema 校验——这些都能用程序自动判分,不依赖昂贵且可能不一致的人类标注。9.2 会专门展开这条线。

这里有个反复踩的坑要提前警告:奖励函数一旦可被模型钻空子,模型就会去钻。如果你用「回答长度」当奖励,模型会学会写又长又水的答案;如果你用「调用了工具」当奖励,模型会学会无意义地狂调工具。9.1.2 那段代码里 bad 轨迹的 search(query='rss') 只给 0 分而不给负分,就是在刻意避免「不搜就扣分、乱搜反而加分」的扭曲激励。奖励设计的目标是让「人类想要的行为」和「最大化回报的行为」尽量重合,这两者永远不会完全一致,所以要盯着模型实际学出了什么。

9.1.4 信用分配:哪一步的功劳

有了轨迹和回报,信用分配要回答:G_0 = 0.81 这 0.81 分,应该归功于哪几步?两个最常用的离线视角:

  1. 折扣归因:把 G_0 按折扣拆开,看每一步的奖励对总回报的直接贡献是 γ^i · r_i。贡献最大的那步,就是这条轨迹的「关键步」。
  2. 优势(advantage):不看绝对回报,而看相对同题平均的差值 A = G − baseline。它回答的是「这条轨迹比平均水平好在哪」,这正是策略梯度类算法用来做梯度权重的量。

下面这段把两个视角都算出来(接上面的数据结构,真跑):

"""信用分配:哪一步的功劳?两种常见视角的离线演示(纯计算,无模型训练)。"""
from dataclasses import dataclass, field


@dataclass
class Turn:
    index: int
    state: str
    action: str
    reward: float


@dataclass
class Trajectory:
    task_id: str
    turns: list[Turn] = field(default_factory=list)

    def total_return(self, gamma: float = 0.9) -> float:
        g = 0.0
        for t in reversed(self.turns):
            g = t.reward + gamma * g
        return round(g, 4)


def discount_attribution(traj: Trajectory, gamma: float = 0.9) -> dict[str, float]:
    """视角一:把 G_0 按折扣拆到每一步,看每步奖励对总回报的直接贡献。"""
    return {t.action: round((gamma ** i) * t.reward, 4)
            for i, t in enumerate(traj.turns)}


def advantage(traj: Trajectory, group: list[Trajectory], gamma: float = 0.9) -> float:
    """视角二:相对同题平均回报的优势 A = G - baseline,做归一化便于比较。"""
    baseline = sum(x.total_return(gamma) for x in group) / len(group)
    return round(traj.total_return(gamma) - baseline, 4)


runs = [
    Trajectory("q-1", [Turn(0, "问题", "search", 0.0),
                       Turn(1, "结果", "read", 0.0),
                       Turn(2, "内容", "final(正确)", 1.0)]),
    Trajectory("q-1", [Turn(0, "问题", "search", 0.0),
                       Turn(1, "结果", "read", 0.0),
                       Turn(2, "内容", "final(部分对)", 0.3)]),
    Trajectory("q-1", [Turn(0, "问题", "search", 0.0),
                       Turn(1, "结果", "final(错误)", -1.0)]),
]

print("逐条总回报:", [r.total_return() for r in runs])
print("按总回报排序:", [r.turns[-1].action for r in sorted(runs, key=Trajectory.total_return, reverse=True)])

for r in runs:
    print(f"{r.turns[-1].action:12s} 折扣归因={discount_attribution(r)} 优势={advantage(r, runs)}")

本机实测输出:

逐条总回报: [0.81, 0.243, -0.9]
按总回报排序: ['final(正确)', 'final(部分对)', 'final(错误)']
final(正确)    折扣归因={'search': 0.0, 'read': 0.0, 'final(正确)': 0.81} 优势=0.759
final(部分对)   折扣归因={'search': 0.0, 'read': 0.0, 'final(部分对)': 0.243} 优势=0.192
final(错误)    折扣归因={'search': 0.0, 'final(错误)': -0.9} 优势=-0.951

这份输出暴露了稀疏奖励的核心困境:所有奖励都集中在最后一步,所以折扣归因里 search 和 read 的贡献都是 0.0。也就是说,只看这个奖励信号,模型无法区分「搜对了再答对」和「瞎搜也能答对」——前两步的正确性完全被淹没在最终信号里。真实训练要靠组内对比(优势那一列)和更多样的采样去补这个信息:同题采多条轨迹,好的那条整体被强化,坏的整体被抑制,模型在大量对比中统计地学到「哪种中间行为更容易通向好结果」。

最后一行也说明了相对信号为什么重要:final(错误) 的绝对回报是 −0.9,但它相对平均的优势是 −0.951——优势把「这题本身难不难」这个因素剥掉了,留下的是「这条轨迹比同题其它尝试差多少」。策略梯度算法正是用这个量来决定「往哪个方向推、推多少」。

9.1.5 折扣因子 γ:把「多远的未来」算进来

上一节反复出现的 γ 不是摆设,它是信用分配的旋钮:γ 越小,越近的奖励权重越高,早期步骤越拿不到功劳;γ 越接近 1,越把远期结果当回事。对 Agent 任务来说,这个选择直接决定「模型会不会为了最后那个正确结果,忍受前面几步的试错」。真跑看效果:

"""折扣因子 gamma 怎么影响早期步骤的回报权重:真跑对比。"""
from dataclasses import dataclass, field


@dataclass
class Trajectory:
    rewards: list[float] = field(default_factory=list)

    def returns(self, gamma: float) -> list[float]:
        g, out = 0.0, [0.0] * len(self.rewards)
        for i in range(len(self.rewards) - 1, -1, -1):
            g = self.rewards[i] + gamma * g
            out[i] = round(g, 3)
        return out


traj = Trajectory([0.0, 0.0, 0.0, 1.0])       # 只在最后一步拿到 1 分
for gamma in (0.5, 0.9, 0.99):
    print(f"gamma={gamma:<5} 逐步回报={traj.returns(gamma)}  首步权重={traj.returns(gamma)[0]}")

本机实测输出:

gamma=0.5   逐步回报=[0.125, 0.25, 0.5, 1.0]  首步权重=0.125
gamma=0.9   逐步回报=[0.729, 0.81, 0.9, 1.0]  首步权重=0.729
gamma=0.99  逐步回报=[0.97, 0.98, 0.99, 1.0]  首步权重=0.97

同样的「最后一步拿 1 分」,γ 从 0.5 提到 0.99,首步的回报权重从 0.125 涨到 0.97,差了近 8 倍。含义很直白:γ 小的时候,模型几乎只在乎眼前那一步,学不出「先探索、后收获」的多步策略;γ 接近 1 时,整条轨迹的每一步都被赋予接近同等的价值,多步推理才有可能被强化。Agent 的多步工具使用任务通常把 γ 设在 0.95~0.99 这一档,代价是回报的方差变大(远期奖励的不确定性被放大),训练更不稳。

9.1.6 按回报给轨迹排序:一个能跑的离线闭环

把 9.1.2 和 9.1.4 拼起来,就是一个不训练模型的离线闭环:给同题采多条轨迹 → 算每条的总回报 → 排序 → 取前若干条当作「正样本」。这正是 ReST(Reinforced Self-Training)这类迭代式自我训练的最小骨架——它不碰梯度,只用「采样 + 打分 + 筛选」就能改进数据分布。

关键在于:这套排序逻辑是纯计算的,可以离线复算、可以写单元测试。你不需要先跑一个训练集群,就能验证「我的奖励函数把对的行为排在前面了吗」。很多 RL 项目的失败,不是训练不行,而是奖励函数本身就把错的行为排在了前面——这一步离线验证能提前抓出来。

9.1.7 这套视角替你做了什么、你又让渡了什么

把 Agent 任务形式化成轨迹,替你做的:它把「模型行为好不好」这个模糊判断,变成了可计算、可排序、可离线复算的回报数值;它让「哪一步的功劳」从直觉变成折扣归因与优势两个具体量;它让奖励函数可以在不训练的前提下先做单元测试。

你让渡的:一旦用 RL 的框架思考,你就默认接受了「任务可被形式化成 MDP」这个前提——而有状态、有副作用、多轮交互的 Agent,往往并不严格满足 MDP 的马尔可夫性(当前状态未必包含所有历史信息)。此外,稀疏奖励下信用分配的困难是结构性的,不是调参能解决的:要么接受「整条轨迹一起奖惩」的粗粒度,要么花大力气设计稠密/可验证奖励,而后者又会引入被钻空子的新风险。

本节只做了「形式化」这一步。下一节 9.2 从 SFT 到 RL 会把这条链路接上:模型怎么先通过监督微调(SFT)学会基本格式,再用偏好或可验证奖励把行为推得更好,并给出一个真跑的离线策略打分例子。想先看框架侧「模型调用如何变成可重放步骤」的基础,见 6.2 确定性重放与副作用 ;通用微调与量化的站内背景见 /posts/ai-ml/ 。

阅读导航:上一节:8.3 跨语言 Agent:TS 实现 A2A 端与 Python 协作 · 下一节:9.2 从 SFT 到 RL:工具使用与多步推理训练流程 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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