☰
深度强化学习核心:Actor-Critic框架原理与工程实现精讲
2026/10/5 6:25:32 网站建设 项目流程

做强化学习有一段时间的人,最后基本都会落到Actor-Critic这个框架上。不管是刚入门时照着教程敲的A2C,还是后来在机械臂、游戏AI、推荐系统里用得最多的PPO、SAC、TD3,底层全是Actor-Critic的影子。可以说,搞懂AC,就等于拿到了深度强化学习的主线钥匙。

这篇内容不是给你念教材,而是从“为什么要AC”、“AC到底在干什么”、“怎么把它写到能跑”三个层面拆开讲。我会先梳理AC从策略梯度与价值方法里各自继承了什么,再贴核心公式和PyTorch风格的实现思路,最后把训练里常见的坑和排查经验一并交代。适合刚看完DQN、想往连续控制或策略优化方向走的人,也适合项目里已经上了AC、但训练老不稳、想回头找原因的同学。

1. 为什么绕了一大圈,最后还是回到Actor-Critic

1.1 策略梯度的“高方差”痛点

先说最朴素的Policy Gradient方法,也就是REINFORCE。它的思路很直接:用一个策略网络直接输出动作概率分布,然后用整个回合的累计回报来评价这次动作好不好,好的动作加大概率,差的动作减小概率。

问题出在“用整个回合的累计回报来评价”这一步。想象一下,一个回合持续100步,第10步的动作其实是对的,但后面90步全是随机抖动,最后累计回报很低。REINFORCE会把这个“差评”平均分配到这100步里,第10步明明表现不错也跟着挨打。这就是所谓的高方差:每次更新的梯度方向噪声很大,同一个状态和动作,换个随机种子可能梯度方向完全不一样。

高方差带来的直接后果就是训练效率极低,往往要几百万步才能看到起色。我当时跑CartPole这种玩具环境还不觉得,一旦换到稍微复杂点的环境,比如HalfCheetah或机械臂仿真,你会发现loss曲线跟心电图似的,根本没法收敛。

1.2 价值方法的“连续动作”困境

另一条路线是Value-Based方法,最典型的就是DQN。DQN学习的是Q函数,也就是“在状态s下采取动作a,之后一直按照最优策略走,能拿到多少期望回报”。决策的时候贪心一下,选Q值最大的动作就行。

这个思路在离散动作空间里很优雅,但一碰到连续动作就麻烦了。机械臂的关节角度是连续值,无人车的方向盘转角也是连续值,你不可能把每一个可能的动作都枚举出来算Q值。就算用优化方法在线求解argmax,计算开销也大得离谱,根本满足不了实时控制的要求。

另外,Value-Based方法天然是确定性策略,对需要探索、需要随机性的场景也不够灵活。很多时候最优策略本身是带随机性的——比如扑克游戏里,你永远用同一个动作反而会被对手拿捏。

1.3 AC的切入点:让Critic来帮Actor“打分”

Actor-Critic的精髓,就是把这两条路线缝在一起:Actor负责输出策略(解决连续动作和随机策略的问题),Critic负责评价当前状态或动作的好坏(解决策略梯度高方差的问题)。

怎么评价?核心是用一个Critic网络来估计价值函数,然后拿它计算“优势”——也就是某个动作相比平均水平好多少。这个优势值替代了REINFORCE里那种粗糙的回合总回报,方差一下子小了很多。打个比方,REINFORCE像是让一个只看期末成绩的学生去复盘每一道题该不该错,而AC是让老师每节课都告诉你“这道题答得比班级平均分高,继续保持”——反馈粒度细了,学习自然快。

我在实际项目里的感受是,AC架构的收敛速度和稳定性,相对朴素策略梯度是肉眼可见的提升。早年机器人控制领域还在纠结怎么给策略梯度降方差,AC框架出来之后,大家终于有了一套标准打法,后面PPO、SAC这些工程上极其好用的算法,全都是在这个框架上长出来的。

2. Actor-Critic到底在更新什么:网络结构、TD误差与优势函数

2.1 Actor和Critic各自的职责分工

先明确两件事:Critic网络不看动作,只看状态,输出的是状态价值V(s)的估计;Actor网络只看状态,输出的是动作的策略分布π(a|s),在连续控制里通常是高斯分布的均值和方差。

为什么Critic不直接输出Q(s,a)而是输出V(s)?因为Actor的动作是采样出来的,如果你用Q函数做评价,每次更新Actor的时候还得再算一遍动作对应的Q值,多一层计算不说,在连续动作空间里还不好处理。用V函数配合TD误差,可以直接推导出优势估计,计算链路更顺畅。

在实际代码里,两个网络通常是分开定义的两套MLP,但也可以让它们共享前面的特征提取层(比如图像输入时的CNN部分),后面再分头输出策略参数和状态值。共享特征的好处是参数少、训练快,坏处是两个任务的梯度会互相干扰,尤其当环境复杂度上去之后,我更推荐两个网络完全独立,调参的时候也省心。

2.2 TD误差就是Critic的“学习信号”

Critic的更新方式用的是时序差分,也就是让V(s_t)去逼近r_t + γV(s_{t+1})。这里γ是折扣因子,表示未来的回报折算到当前值多少钱。两个目标之间的差δ_t = r_t + γV(s_{t+1}) - V(s_t),就是TD误差。

值得多讲两句的是,TD误差这个东西的意义被很多人低估了。表面上看,它只是Critic网络的损失函数来源,但实际上它同时是Actor更新的“打分凭证”。在标准的AC实现里,Actor的梯度方向是“增大当前动作概率”,乘上的系数就是这个TD误差。

为什么能用同一个δ来驱动两个网络?因为δ_t的期望值恰好等于优势函数A(s_t, a_t)的无偏估计。它表示“这一步走完拿到的实际回报比原先预期的好还是差”,比单纯用回报R_t作为信号要精准得多。Critic不断优化自己对这个值的预测,Actor则拿着这个反馈不断调整自己的动作分布,形成一套闭环。

2.3 优势函数与GAE:从单步到多步的平滑

单步TD误差的问题在于信息量太少,某些状态下奖励稀疏,单步看不出好坏。多步回报信息量大但方差高,单步回报方差低但有偏,怎么平衡是个经典问题。

实践里最常用的是Generalized Advantage Estimation,也就是广义优势估计。GAE的做法是,把1步、2步、3步……一直到整个回合的TD误差按指数衰减加权求和。权重系数λ控制着这个衰减速度:λ接近0,等价于只看单步TD,方差低但偏差大;λ接近1,等价于看完整回合,偏差小但方差大。

A_t = δ_t + (γλ)δ_{t+1} + (γλ)²δ_{t+2} + ... + (γλ)^{T-t+1}δ_{T-1}

调参的时候,λ在0.95到0.99之间是一个常见区间。控制类任务我一般从0.95起步,如果发现训练不稳定再往0.99方向调。GAE是整个AC体系里性价比最高的一行代码,几乎所有后续算法都用到了它,建议一定吃透。

3. 主流Actor-Critic变体与选型思路:A2C、PPO、DDPG、TD3、SAC

3.1 A2C/A3C:入门必写,多环境并行稳定训练

A2C(Advantage Actor-Critic)是最标准的AC实现,PyTorch官方强化学习教程里那个例子就是它。它同时开多个环境,每个环境各自跑一段轨迹,然后统一汇总数据更新网络。A3C是异步版本,每个环境配一个独立线程各自更新参数,但异步操作在工程实现上容易出bug,实际用A2C这种同步版本的人反而更多。

A2C适合做什么?适合验证一个环境的可行性,也适合作为后续一切算法的“洗碗工版本”。我带的实习生来项目组,第一周任务就是手写A2C,不调库,不抄完整开源实现,目的就是让TA把“采样、算优势、更新Actor、更新Critic”这条流水线彻底刻在脑子里。

3.2 PPO:工程上最省心的信赖域策略优化

PPO的全称是Proximal Policy Optimization,核心解决的是“策略更新太快导致崩溃”的问题。它给Actor更新加了一个裁剪项:如果新策略和旧策略的概率比超出[1-ε, 1+ε]的范围,就把梯度裁掉,保证一次更新不会让策略跑太远。

这个思想来自更早的TRPO(Trust Region Policy Optimization),但TRPO要用共轭梯度解约束优化,实现复杂,计算开销大。PPO用一行min操作就把同样的目的达到了,所以成了工业界的默认选择。我现在做机械臂操作任务,首选几乎都是PPO,仓储机器人仿真环境里做订单分拣策略,用的也是PPO。

PPO里那个裁剪系数ε,典型值是0.2。太大更新激进容易崩,太小更新太慢。如果你发现策略在某一步之后突然性能崩盘,多半是ε偏大,或者优势估计没做好,后面我会专门讲排查方法。

3.3 DDPG/TD3:确定性策略的连续控制利器

DDPG(Deep Deterministic Policy Gradient)走的是另一条路:Actor直接输出一个确定性的动作值,而不是动作分布。它结合了DQN的经验回放和目标网络,让Critic学Q(s,a),再用梯度上升直接调整Actor输出。

DDPG当年在连续控制上效果惊艳,但工程上有两个痛点:一是Q值容易被高估,导致策略飘到虚假的高奖励区域;二是超参数敏感,稍微没调好就发散。TD3在此基础上做了三项改进:用两个Critic取较小值来抑制过估计、延迟更新Actor网络、给目标动作加噪声实现平滑正则。就这三点改动,稳定性提升非常明显。

确定性策略的变体适合动作维度高、需要精准输出的任务,比如四足机器人腿部控制。但它对超参数和随机种子依然比较敏感,如果你项目周期紧,建议直接上TD3而不是DDPG。

3.4 SAC:最大熵与随机策略的优雅结合

SAC(Soft Actor-Critic)在AC框架里引入了“熵”这个正则项。熵代表策略的随机程度,SAC在优化累计回报的同时,还希望策略尽量随机。这么做的好处是探索更充分,不容易陷入局部最优,训练也更稳定。

SAC在输出动作分布时,通过重参数化技巧来保留梯度流,所以既支持连续动作的随机策略,又能在训练过程中自动调节熵的权重系数。在绝大多数连续控制benchmark上,SAC的样本效率和最终性能都优于DDPG和TD3。代价是实现复杂度高一些,需要维护两个Critic、一个Actor、一个温度系数以及各自的目标网络。

如果你做的是仿真环境且计算资源足够,我建议直接上SAC。它属于那种“你愿意多写几百行代码,就能换回大量调参时间”的算法。

3.5 变体之外的扩展方向:离线IQL与基于模型强化学习

除了在线交互式的AC算法,现在有两个方向在实际项目中越来越常见。一个是离线强化学习,典型代表是IQL(Implicit Q-Learning)。它的核心思路是:只用已有数据集训练,不与环境交互,通过expectile回归让Q函数只学到数据里“比较好的行为”的价值,从而避免分布外动作的过估计。做推荐系统、医疗策略这类没法在线试错的场景,IQL是AC框架在离线环境下的一个漂亮变种。

另一个是基于模型的强化学习。这类方法先用一个神经网络学习环境的状态转移模型,然后在这个“虚拟环境”里用AC算法训练策略。优势是样本效率可以提升一到两个数量级,非常适合真实机器人这类采样成本极高的场景。但世界模型学得不准的时候,策略会在误差累积中跑偏,实际应用需要配合模型不确定性估计来兜底。

这些扩展方向说明AC框架的生命力很强,换掉其中某个组件,就能适配完全不同的场景约束。你在选型的时候,核心对比维度就三件事:动作空间是离散还是连续、交互成本是便宜还是昂贵、策略是否允许较大的探索幅度。

4. 实操实现:从伪代码到可运行的A2C核心逻辑

4.1 主循环与数据流:采样、算优势、更新

前面讲了一堆理论,现在落到代码层面。下面是一个最简A2C的训练主循环伪代码,所有AC类算法基本都长这样:

# 伪代码,展示AC主循环数据流 for epoch in range(total_epochs): # 1. 并行环境采样 states, actions, rewards, next_states, dones = collect_rollout(envs, actor) # 2. 计算TD误差与GAE优势 values = critic(states) next_values = critic(next_states) td_errors = rewards + gamma * next_values * (1 - dones) - values advantages = compute_gae(td_errors, gamma, lam) returns = advantages + values # Critic的回归目标 # 3. 更新Critic:让V(s)逼近returns critic_loss = mse_loss(critic(states), returns) update(critic, critic_loss) # 4. 更新Actor:让好动作的概率变大 log_probs = actor.log_prob(states, actions) actor_loss = -(log_probs * advantages.detach()).mean() update(actor, actor_loss)

这个结构里有一个关键细节:Actor更新用的优势必须做detach,也就是不让梯度通过优势值反向传播到Critic或价值估计上。原因很直接——我们希望Actor只调整策略,不想让它莫名其妙去修改Critic网络的表征。很多新手写代码忽略了这一步,梯度流一起反传,训练必崩。

4.2 GAE的计算实现

GAE看起来有一串累加,实际实现用一个从后往前扫描的累积变量就能搞定:

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): """ 从后往前扫描计算GAE优势。 values: 每个时刻的V(s_t) dones: 回合结束标记,1表示终止 """ T = len(rewards) advantages = [0] * T gae = 0 next_value = 0 for t in reversed(range(T)): # 如果t是回合终止,则下一时刻价值视为0 delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages[t] = gae next_value = values[t] return advantages

这里有个细节:dones的处理非常重要。回合终止时,目标值应该是r_t,后面没有未来回报了,所以next_value必须归零,否则价值估计会把不存在的未来回报算进去,产生严重的偏置。我看到过不少开源代码在这个地方偷懒,导致算法在稀疏奖励环境里怎么调都学不会。

4.3 连续动作场景下Actor的输出层设计

如果动作空间是连续的,Actor输出层的设计很讲究。以机械臂关节控制为例,假设有6个关节,动作维度是6。通常做法是让Actor网络输出每个关节动作的均值和log标准差:

import torch import torch.nn as nn import torch.distributions as D class ContinuousActor(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim=256): super().__init__() self.trunk = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), ) self.mean_head = nn.Linear(hidden_dim, action_dim) self.log_std = nn.Parameter(torch.zeros(action_dim)) def forward(self, obs): feat = self.trunk(obs) mean = self.mean_head(feat) std = torch.exp(self.log_std.clamp(-20, 2)) return D.Normal(mean, std) def sample_action(self, obs): dist = self.forward(obs) action = dist.sample() return action, dist.log_prob(action).sum(dim=-1)

为什么要把log_std做成一个独立的可学习参数,而不是从网络里直接输出?因为均值的量级和方差量级差别很大,直接让网络同时输出两套参数,梯度容易失衡。把std设成可学习参数,让网络只去学均值,相当于告诉优化器“方差这种东西我自己慢慢调”,整个网络练起来会稳很多。采样动作之后,梯度不能通过随机采样节点反向传播,这是另一处新手高频踩坑点。连续控制任务中通常需要加tanh来限制动作范围,不过这里为了保持逻辑清晰先不给代码加料。

4.4 经验回放、目标网络与并行环境的取舍

A2C这类on-policy算法不开经验回放,因为旧策略采出来的数据对当前策略没意义。而DDPG、TD3、SAC这类off-policy算法必须开经验回放,因为它们复用一个固定大小的buffer,每次更新随机抽一批样本。

目标网络的作用是让价值更新的目标值相对稳定。如果不加目标网络,Critic一边更新自己、一边用更新后的自己作为目标,相当于“自己给自己出题”,容易数值震荡。DQN里这个思想已经成熟,DDPG/TD3/SAC全继承了这一设计。

并行环境方面,A2C一般开4到16个并行环境,每个环境算出来的梯度汇总后再更新,能显著降低训练方差。如果你机器资源充足,我建议并行数量往大里顶,CarPole这种轻量环境开8个都没问题,物理仿真器环境则要看CPU负载,一般4个比较稳妥。

5. 训练中那些让你怀疑人生的坑与排查技巧

5.1 奖励尺度过大导致梯度爆炸

AC算法对奖励的尺度非常敏感。如果奖励动辄上千,TD误差的量级就会非常大,梯度更新一步就可能把网络参数甩到天边去,训练曲线直接变野马。我接过一个项目,环境里给了一个“完成任务+1000”的稀疏大奖励,训练前500轮完全看不到任何有效学习,后来把奖励除以100放到一个相对小的量级,策略才开始正常上升。

处理思路主要有三种:一是对奖励做缩放/归一化,二是对优势值做标准归一化,三是在设计环境时就把奖励的尺度控制在合理范围。如果算法里已经用了GAE,优先对GAE输出做标准化,操作简单,作用明显。

5.2 熵坍缩:策略过早变“一根筋”

Actor输出的动作分布方差会随着训练逐渐缩小,如果缩得太快,策略就过早变成确定性策略,探索能力几乎归零,性能会卡在一个次优水平而不自知。排查方法很粗暴:把每轮动作分布的熵打印出来,如果熵值断崖式下降,说明策略很快就锁死在某条路上了。

解决手段:一是调大熵系数,直接给Actor损失里加正则项;二是换用SAC这种自带自适应熵权重的算法;三是审查奖励设计,看是不是存在特别容易被利用的捷径,让策略早早扎进去。机械臂抓取里很常见的一个现象是一开始策略老往某个固定方向偏,熵值掉到接近零,这个时候大多数情况是奖励信号给得太偏了。

5.3 Critic学偏了:价值估计不收敛

如果Actor学到的东西不错,但Critic的损失一直居高不下,常见原因是当前策略下价值函数的输入分布一直在变,Critic追不上。另一个原因是某些状态的价值在不同回合里差异太大,方差高的地方Casualty很难拟合。

遇到这种情况,我会先做一步“价值校准检查”:在训练早期,打印Critic对随机初始状态的输出,看量级是否合理。如果V(s)的输出动不动就上亿,那多半是TD误差累积导致的发散,需要减小学习率、增加归一化层,或者检查奖励是否被错误地重复累加。

5.4 不同随机种子下的方差问题

AC算法的运气成分比想象中大得多,同一个超参数、同一个环境,随机种子不同可能训练结果天差地别。做实验对比的时候,务必跑多个种子取平均,只跑一遍得出“我的算法比基线好”的结论,在AC领域没有任何说服力。

种子之外,PyTorch的浮点运算在GPU上有不确定性,同一个种子在不同硬件上也可能不完全一致。如果复现性对你的项目很重要,建议固定CPU线程数并关闭cudnn的自动tuning。但我也要说句实话:在真实项目里追求绝对复现意义不大,环境本身的随机性早把这点差异淹没掉了,把精力放在提升平均性能和稳定性上更划算。

5.5 超参调优优先级:先学习率,再GAE λ,最后熵系数

给还在调参泥潭里挣扎的朋友一份我自己的调参顺序,这是一份优先级清单,照着顺序试会少走很多弯路。第一优先看学习率。AC里Actor和Critic的学习率可以分开设,Critic通常略微慢一点,经验范围在3e-4到1e-3之间。如果训练直接NaN,先把学习率降到1e-4以下,排查代码里有没有除零或log(0)问题。第二优先调GAE的λ,它直接决定优势估计的平滑程度。第三才轮到熵系数,而且最好只在确定性策略导致探索不足时再动它。

调试的时候,别只看episode reward这一条曲线,至少要把actor_loss、critic_loss、entropy、advantage的均值这几条曲线一起打出来。有一次我调了半天reward上不去,翻曲线才发现critic_loss早就在起飞,问题根本不在策略更新,而在价值网络本身。

6. 写在最后的一点个人体会

做深度强化学习这几年,一个很深的感受是:算法框架的迭代替换永远在发生,但Actor-Critic的核心结构始终没变。从最开始的A2C到现在的PPO、SAC、TD3,本质上都是T接不同组件:价值函数、优势估计、置信域约束、熵正则项,但“Actor出策略、Critic做裁判”这套交互机制经久不衰。

对于刚入门的同学,我建议从手写一个极简A2C开始,在CartPole或Pendulum上跑通整套采样-更新流程,然后一步步往上叠加GAE、并行环境、裁剪目标。这个过程比直接调库学到的细节多得多,因为强化学习的坑几乎全藏在工程细节里:dones有没有正确处理、detach有没有加、梯度有没有被错误地截断,这些地方全靠手写踩过一遍才有体感。

如果你已经在项目里用AC算法跑了很久但效果不顺,不妨回头检查一下我在常见问题里列的那几条。很多时候困扰你三天的问题,就是某个细节没处理到位,跟算法本身的能力没关系。调策略优化算法,拼的就是谁更细心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询