


























SAC 是SERL 算法底座,是整个系统的"引擎"。SAC(Soft Actor-Critic)之所以在机器人领域(如 SERL 论文中)如此强大,是因为它解决了强化学习中最头疼的问题之一:如何在探索(寻找新方案)和利用(优化已知方案)之间取得完美平衡。
注:
SAC 的"Soft"之源:传统强化学习目标是最大化累积奖励 \(\sum \gamma^\wedge t r_t\),SAC 在这个目标函数中额外增加了一项。SAC 解决了连续空间下的动作控制问题,它的"灵魂" 就是在于熵(Entropy)的引入。
SAC 的论文如下:Soft Actor-Critic: Off-Policy Maximum Entropy Deep Reinforcement Learning with a Stochastic Actor
SAC 的核心思想:奖励与"快乐"并存。
传统的 RL 让智能体像个"做题家",只盯着分数(奖励 R)看。SAC 则引入了熵(Entropy)的概念。简单来说,熵代表了智能体动作的随机性或多样性。SAC 的目标不只是最大化奖励,而是:Maximize E [ 奖励 + α × 熵 ]
SAC 引入熵(Entropy)解决的是强化学习里最核心的矛盾:探索(Exploration)与利用(Exploitation)的矛盾。
直观理解:如果智能体发现有两条路都能到达终点,传统 RL 可能会死磕其中一条;而 SAC 尽量保持一种"两条路都能走"的状态,这让它在环境发生变化时更具鲁棒性。
我们可以把强化学习看作是"教一个小孩(智能体)在不同的房间(状态)里做不同的动作,最终为了拿到最多的糖果(奖励)"。接下来我们看看算法如何演进。
核心思想:我不教小孩怎么走,我只告诉他每个房间里每个动作值多少分。
Q 值不是"当前"的分数,也不是"结束"时的总分,而是从现在开始到未来的累积预期。
在 Q-Learning 中,我们需要找到让 Q 最大的动作 。
具体而言,使用 Q 网络:当你真的要决定"该做什么动作"时,问题来了。Q 网络就像一个黑盒函数 。
核心思想:与其让小孩自己记 Q 值,不如给小孩配一个教练。
在机器人控制中,动作通常是连续的(比如电机的电压、关节的角度)。Actor 输出一个概率分布(如正态分布)而不是单一数值。输出分布对机器人的意义很大,想象你要控制机械臂抓一个杯子,左边抓可以,右边抓也可以。
Actor 为什么可以解决 Q-Learning 的问题?
Actor 的思路是:"我不用临时去找最佳动作,我直接养一个专门输出'最佳动作'的函数。"
\[\frac{\partial Q}{\partial(\text{Actor参数})} = \underbrace{(\frac{\partial Q}{\partial(\text{动作 }a)})}_{\text{Critic告诉Actor动作往哪改}} \times \underbrace{(\frac{\partial \text{Actor}}{\partial(\text{Actor参数})})}_{\text{Actor自己调整内部参数}} \]
想象你在漆黑的深夜,要在山上找最高点:
SERL 框架的底层动力引擎是 SAC。之所以选择 SAC,是因为它是处理连续动作空间(机器人关节或末端位移)最稳定、性能最强的算法之一。
传统的强化学习算法只追求"分数最高"。但 SAC 多了一个追求:最大化熵(Entropy Maximization)。它的公式可以表示为:目标 = 奖励 (Reward) + α × 熵 (Entropy)。
直白地说,SAC 不仅想拿高分,它还希望自己的动作尽可能地多样化、不呆板。这对于真实机器人非常重要:如果策略过早变得确定,一旦陷入错误的动作模式,就很难恢复;而熵正则让机器人保留了探索能力。
好处有两点:
具体算法如下:

SERL 里的 SAC 实际上训练了三种网络:
Actor(策略网络 π):负责出动作。它输出的是一个概率分布(比如:均值和方差),这意味着机器人每次做动作都会带有一点点随机性。
Critic(两个 Q 网络 Q₁、Q₂):负责打分。为了解决"过估计"问题,SAC 永远训练两个 Q 函数,并取其中的最小值。
SAC 要求 Actor 的动作既要让 Q 值大,又要保持随机(熵大),这就像是要求一个短跑运动员:"你要跑得尽量快(Q值),但跑姿还要尽量花哨多变(熵)。"
Critic就是在做一个带熵修正的Q-learning。
SAC 的 Critic 在算未来的价值时,不只看 \(\max Q\),还要加上一句:"而且我希望未来的动作选择越丰富越好"。
我们可以把这三者的演进看作是:
用一个类比总结:
本节,我们来看看SAC 的特色细节。
在一个标准的 SAC 实现中,通常有以下几个神经网络:
在 RL 中,我们确实希望最大化 \(J(\pi)\)(即累积奖励)。但神经网络优化工具(如 PyTorch/TensorFlow)通常只能最小化一个损失函数(Loss)。因此,会设置 Loss \(= -J(\pi)\)。
Critic 的目标是预测未来的总收益。在 SAC 的实现中,通常会维护两个 Q 网络(Clipped Double-Q)。
SAC 的 Critic:
\(Q = R + \gamma \mathbb{E}[\text{未来能拿的分} + \text{未来动作的熵}], Q(s,a) \leftarrow r + \gamma \mathbb{E}_{a'\sim\pi(\cdot|s')} [ Q(s',a') - \alpha \log \pi(a'|s') ]\)
\([ Q(s', a') - \alpha \log \pi(a'|s') ]\) 这个括号里的东西,我们称之为 Soft Value(软价值)。
既然提到了熵,我们就看看,Critic 为何要考虑熵。
如果 Critic 只预测奖励,而 Actor 却在追求"奖励+熵"。这会导致什么结果?
这会导致"驴唇不对马嘴",两者无法协作。所以,SAC 的 Critic 更新公式是 \(Q(s,a) \leftarrow r + \gamma \mathbb{E}_{a'\sim\pi(\cdot|s')} [ Q(s',a') - \alpha \log \pi(a'|s') ]\)。这里的 \(-\alpha \log \pi(a'|s')\) 其实就是熵的体现。如果智能体在下一步动作 \(a'\) 的概率非常高(非常确定),\(-\log \pi\) 会变得很小;如果动作很随机,\(-\log \pi\) 会变大。
这意味着,SAC 的 Critic 实际上是在评估:"这步动作不仅现在好,而且能保证以后有更多的选择余地"。
实际上在代码实现中:训练 Critic 时,Actor 是禁止动弹的。
在每一轮训练中,我们其实是分两步走的:
actor.requires_grad = False 或者只是不把 Actor 的参数放进优化器。Actor Loss 如下:$$\text{Loss}{{\text{Actor}}} = \mathbb{E} \left[ \alpha \log \pi(a|s) - Q(s, a) \right]$$
这个 Loss 的两部分为:
这就好比:一个教练(Loss)同时盯着运动员的「速度」和「花哨程度」。如果速度慢了,教练扣分;如果动作单一了,教练也扣分。
loss.backward() 时,梯度会穿过 Q 网络(但 Q 的参数被冻结,不更新),一直回传到 Actor 网络输出 \(\mu\) 和 \(\sigma\) 的那一层。\(Loss_{a} = E [ \underbrace{-a}_{变量} · (\underbrace{log π(a|s) + \bar{H}}_{误差}) ]\)
这是一个标量损失函数(Scalar Loss)。\(\bar{H}\) 是你的目标。比如你希望动作保持一定的随机性。
目标函数:Maximize E [ 奖励 + α × 熵 ]。如果 α=0,智能体会陷入"死磕一条路"的死胡同。熵确保了智能体在追求高分的同时,保持"条条大路通罗马"的鲁棒性。
为什么 \(\sigma \to 0\),熵就没了?
为什么 log π 越大, 熵就越小? 我们要先搞清楚概率 π 的范围: 它在 [0, 1] 之间。
结论: 在负数世界里, 0 是最大的。所以 log π 越接近 0 (越大), 说明概率越集中, 熵 (即 -log π 的平均值) 就越小。
Actor和Critic使用高度相似但不完全相同的网络架构。主要区别在于Critic需要额外输入动作信息,这符合Actor-Critic算法的理论设计。
相似点:
关键差异:
| 特性 | Actor | Critic |
|---|---|---|
| 输入 | 仅观测 observations |
观测 + 动作 [obs_enc, actions] |
| 输出 | 动作分布参数 mean, std |
标量Q值 |
| 网络结构 | 独立输出均值和标准差 | 单一输出层 |
| 激活函数 | 最后一层通常激活 | 通常线性输出 |
SERL 的网络设计选择如下:

Actor和Critic使用高度相似但不完全相同的网络架构。主要区别在于Critic需要额外输入动作信息,这符合Actor-Critic算法的理论设计。
相似点:
关键差异:
| 特性 | Actor | Critic |
|---|---|---|
| 输入 | 仅观测 observations |
观测 + 动作 [obs_enc, actions] |
| 输出 | 动作分布参数 mean, std |
标量Q值 |
| 网络结构 | 独立输出均值和标准差 | 单一输出层 |
| 激活函数 | 最后一层通常激活 | 通常线性输出 |
Actor 网络如下:
class Policy(nn.Module):
encoder: Optional[nn.Module] # 视觉编码器
network: nn.Module # MLP主干网络
action_dim: int
def __call__(self, observations, temperature=1.0):
if self.encoder is None:
obs_enc = observations
else:
obs_enc = self.encoder(observations, train=train, stop_gradient=True)
outputs = self.network(obs_enc, train=train)
means = nn.Dense(self.action_dim)(outputs)
stds = nn.Dense(self.action_dim)(outputs) # 标准差参数
return TanhMultivariateNormalDiag(loc=means, scale_diag=stds)
Critic 网络定义如下:
class Critic(nn.Module):
encoder: Optional[nn.Module] # 视觉编码器
network: nn.Module # MLP主干网络
def __call__(self, observations, actions, train=False):
if self.encoder is None:
obs_enc = observations
else:
obs_enc = self.encoder(observations)
inputs = jnp.concatenate([obs_enc, actions], -1) # 关键差异
outputs = self.network(inputs, train=train)
value = nn.Dense(1)(outputs) # 输出Q值
return jnp.squeeze(value, -1)
SACAgent 算是 SERL Agent 系统的基础,所以我们从它看起。
class SACAgent(flax.struct.PyTreeNode):
其总体信息如下:
| 组件 | 输入 | 网络结构 | 输出 | 参数共享 |
|---|---|---|---|---|
| Actor | 图像观测 | 编码器+MLP[256,256] | 动作分布(μ,σ) | 编码器可共享 |
| Critic | 图像+动作 | 编码器+Ensemble MLP[256,256]×2 | Q值 | 编码器可共享 |
| Temperature | 无 | Lagrange乘数 | 标量温度 | 独立参数 |
Actor (Policy) 网络内部结构:
policy_def = Policy(
encoder=encoders["actor"], # 视觉编码器
network=MLP(**policy_network_kwargs), # 默认 [256, 256]
action_dim=actions.shape[-1],
tanh_squash_distribution=True,
std_parameterization="uniform",
)
Critic内部结构:
[obs_enc, actions]critic_backbone = partial(MLP, **critic_network_kwargs) # [256, 256]
critic_backbone = ensemblize(critic_backbone, critic_ensemble_size)(
name="critic_ensemble"
)
critic_def = partial(
Critic,
encoder=encoders["critic"], # 可与Actor共享编码器
network=critic_backbone
)
Temperature (自动熵调节) 网络:
temperature_def = GeqLagrangeMultiplier(
init_value=temperature_init, # 默认1.0
constraint_shape=(),
constraint_type="geq",
)
"small" 编码器:
encoders = {
image_key: SmallEncoder(
features=(32, 64, 128, 256),
kernel_sizes=(3, 3, 3, 3),
strides=(2, 2, 2, 2),
padding="VALID",
pool_method="avg",
bottleneck_dim=256,
spatial_block_size=8,
)
}
"resnet" 编码器:
encoders = {
image_key: resnetv1_configs["resnetv1-10"](
pooling_method="spatial_learned_embeddings",
num_spatial_blocks=8,
bottleneck_dim=256,
)
}
"resnet-pretrained" 编码器:
pretrained_encoder = resnetv1_configs["resnetv1-10-frozen"](
pre_pooling=True,
)
encoders = {
image_key: PreTrainedResNetEncoder(
pooling_method="spatial_learned_embeddings",
num_spatial_blocks=8,
bottleneck_dim=256,
pretrained_encoder=pretrained_encoder, # 冻结的预训练权重
)
}
SAC 的熵正则化保证了探索性,双Critic的ensemble提供了稳定的价值估计,自动温度调节实现了探索-利用的平衡。
Critic损失:
def critic_loss_fn(self, batch, params, rng):
# 计算目标Q值
target_next_qs = self.forward_target_critic(batch["next_observations"], next_actions, rng)
target_next_min_q = target_next_qs.min(axis=0) # 最小Q值(保守估计)
# TD误差
predicted_qs = self.forward_critic(batch["observations"], batch["actions"], rng, grad_params=params)
critic_loss = jnp.mean((predicted_qs - target_qs) ** 2)
Actor损失:
def policy_loss_fn(self, batch, params, rng):
# 最大化Q值-熵
predicted_q = predicted_qs.mean(axis=0)
actor_objective = predicted_q - temperature * log_probs
actor_loss = -jnp.mean(actor_objective)
重参数化(Reparameterization Trick):直接从分布采样是不可导的。通过 a = μ + σ · ε(ε 是固定噪声),我们把随机性剥离出来,让梯度能顺着"加法和乘法"回传。
在 SAC 中,Actor 输出的是一个概率分布(通常是高斯分布)。
由于我们需要对这个分布进行采样才能得到动作 a,但"采样"这个动作是不可导的,这就导致梯度无法直接回传给生成分布的神经网络。
为什么采样不能传导梯度?这是深度学习中最经典的问题之一。
random()。随机数发生器就像一个黑盒,梯度传到这里就断了。那么在复现 Actor 的更新过程时,我们该如何让梯度通过这个"采样"步骤传回神经网络的参数中?重参数化(Reparameterization Trick)其实就是为了解决 Actor 怎么根据这个带熵的 Q 值更新梯度的问题。
目前问题就是:我们该怎么把"抽样"这个动作变成一个"加减乘除"的公式?
SERL 不直接采样 \(a \sim \mathcal{N}(\mu, \sigma)\),而是写成:$$a = \mu + \sigma \cdot \varepsilon, \quad \varepsilon \sim \mathcal{N}(0, 1)$$
重参数化使用了 \(a = \mu + \sigma \varepsilon\)。但在机器人控制中,动作通常是有范围的(比如 -1 到 1)。直接加减可能会超出范围。SAC 论文里用了Tanh 激活函数来把这个 a 限制在 \((-1,1)\)。
当你把一个高斯分布的 u 通过 a = tanh(u) 映射到 (-1, 1) 时, 概率密度会发生变化。
如果我们不使用 Tanh 修正,直接强制把超出范围的动作 clip 掉,这会给"梯度回传"带来什么灾难?
如果用 clip(a, -1, 1):
极简版工作流程如下:
下面是 SAC 算法的高层结构伪代码。它清晰地展示了 数据流 是如何在 Actor (演员)、Critic (评论家) 和 Buffer (经验池) 之间流动的。
class SACAgent:
def __init__(self):
# 1. 初始化 5 个核心网络
self.actor = ActorNetwork() # 策略函数: s -> (mu, sigma)
self.critic1 = CriticNetwork() # Q1函数: (s, a) -> q1
self.critic2 = CriticNetwork() # Q2函数: (s, a) -> q2
self.target_critic1 = Target() # Q1的稳定副本
self.target_critic2 = Target() # Q2的稳定副本
# 2. 熵自动调节参数 (Temperature)
self.log_alpha = log(initial_alpha)
# 3. 经验回放池
self.replay_buffer = ReplayBuffer(capacity=1000000)
def step(self, state):
"""与环境交互: 根据当前状态, 喷出一个动作"""
action = self.actor.sample(state)
return action
def train_step(self):
"""核心训练逻辑: SAC 的三步走"""
# 从池子里抓一把数据
batch = self.replay_buffer.sample(batch_size=256)
# --- 第一步: 更新 Critic (练地图) ---
self.update_critic(batch)
# --- 第二步: 更新 Actor (练向导) ---
# 顺着 Critic 指出的梯度方向, 让 Actor 变得更好
self.update_actor(batch)
# --- 第三步: 自动调节 Alpha (练灵魂) ---
# 如果熵太小, 调大 Alpha 增加探索; 反之调小
self.update_alpha(batch)
# --- 最后: 平滑更新 Target 网络 ---
self.soft_update_targets()
def update_critic(self, batch):
"""计算带熵的 Bellman 目标"""
# 核心公式: Target = R + gamma * (min(Q1_target, Q2_target) - alpha * log_prob)
target_q = self.calculate_target_q(batch)
# 最小化 MSE 误差
loss1 = MeanSquaredError(self.critic1(s, a), target_q)
loss2 = MeanSquaredError(self.critic2(s, a), target_q)
# 执行梯度下降...
我们接下来看看 SERL 开源代码的实现,看看其对 SAC 做了什么改变。
在 SERL 的代码库中,sac.py 扮演的是"通用底座"的角色。原生 SAC 在真机上其实很慢。为了让它起飞,SERL 做了若干增强,sac.py 其实是一个"全能型 SAC"。虽然这个文件叫 sac.py,但它已经为 RLPD 做好了全部基础准备:
High UTD 支持:update_high_utd 函数把一个大的 Batch 拆成 20 份,连续更新 20 次 Critic,这是 RLPD 能跑通的前提。
LayerNorm 的隐形支持:它调用了 MLP 网络。只要在创建时传入 value_layer_norm=True,它就会自动在内部插入归一化层。
Ensemble Q:它支持 critic_ensemble_size=10,这是 RLPD 抑制 Q 值发散的手段。即,在计算 Target 时, 它不是取最小值, 而是计算这 10 个 Q 的均值减去标准差:\(Target Q = mean(Q_{1...10}) - std(Q_{1...10}) × ρ\)
这叫"悲观备份"。在不确定的地方, Q 值会因为标准差大而被拉低。这强迫智能体只信任那些所有 Q 网络都达成共识的高分区域。
自动调节 Alpha(Lagrange):它使用了拉格朗日乘子法(GeqLagrangeMultiplier)来自动调节熵,比我们手写的手动更新公式更数学化、更稳定。
JAX 异步更新:利用 JAX,SAC 的 10 个 Critic 可以在不同显卡上并行更新,极大地提升了训练吞吐量。
缺少的内容如下:
特色功能 (Special Features)如下:

极致的集成(Ensemble)与向量化。sac.py 使用了 ensemblize 技巧。
"重 Critic、轻 Actor" 的高 UTD 架构。SERL 中有一个非常显著的策略:在 update_high_utd 里,Critic 更新 20 次,Actor 才更新 1 次。
灵活的视觉编码器架构(create_pixels)。源码中通过 shared_encoder 参数决定了 Actor 和 Critic 是否共用一个视觉大脑。
拉格朗日温度控制(GeqLagrangeMultiplier)。源码中引入了拉格朗日约束(并非简单的梯度下降来更新 α)。
在 SAC 中,训练目标通常拆成三个部分:
| 损失函数 | 更新对象 | 核心目标 |
|---|---|---|
critic_loss_fn |
critic / Q 网络 | 学习 Bellman backup,让 Q 逼近 TD target |
policy_loss_fn |
actor / policy 网络 | 最大化 Q,同时最大化熵 |
temperature_loss_fn |
temperature / α | 自动调节熵权重,使策略熵接近目标熵 |
在这份代码里,这三个 loss 会被包装成一个字典:
def loss_fns(self, batch):
return {
"critic": partial(self.critic_loss_fn, batch),
"actor": partial(self.policy_loss_fn, batch),
"temperature": partial(self.temperature_loss_fn, batch),
}
这意味着:
critic_loss_fn → 更新 params["critic"]
policy_loss_fn → 更新 params["actor"]
temperature_loss_fn → 更新 params["temperature"]
不过从实现上看,apply_loss_fns 会对 全量 self.params 求梯度,然后通过不同 optimizer 分支把对应梯度应用到参数树上。
update 则是训练调度器:它先整理 batch,再构造三个 loss,按 networks_to_update 决定本轮更新哪些网络,最后统一调用 apply_loss_fns 计算梯度并应用 optimizer。
可以把整个 update 理解成一个三方协作系统:
critic_loss_fn:
学会评价 replay buffer 中的动作。
目标来自 r + γ * target_Q(s', π(s'))。
policy_loss_fn:
利用 critic 的评价来改进 actor。
让 actor 选择 Q 更高且保持一定熵的动作。
temperature_loss_fn:
自动调节 α。
如果策略太确定,就提高熵权重;
如果策略太随机,就降低熵权重。
对应到真实机器人训练场景中,我们可以这样理解:


SERL——针对真机高效采样的RL系统:基于图像观测和RLPD算法等,开启少量演示下的RL精密插拔之路(含插入基准FMB的详解)
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。