
1. 先聊聊我为什么在DDPG之后还要啃SAC连续控制这个赛道能用的算法其实就那么几个DDPG、TD3、PPO、SAC。前两年我用DDPG做机械臂的位置跟踪调了两周好不容易收敛了换个种子重跑又崩了那种感觉就像你熬夜写完的作业第二天发现打印机没墨了。后来换TD3双Q网络确实压住了一部分过估计鲁棒性上来了但探索策略还是靠外部加的高斯噪声随机性来自“外加”的噪声而不是策略本身的内在性质这一点让我一直觉得别扭。真正让我下决心把SAC啃透的是它把“探索”这件事直接塞进了优化目标里。这不是技巧堆叠而是目标函数层面的重新设计。SAC全名Soft Actor-Critic核心卖点就三条最大化熵的目标函数、双Q网络抑制过估计、温度系数自动调节。这套组合下来我在几个不同尺度的连续控制任务上基本能做到一次调参多个任务通用换种子重跑也很少翻车。如果你已经懂DQN和DDPG想找一个“调参不那么玄学”的连续控制baselineSAC值得花时间。这篇笔记就把我理解SAC的完整思路、代码骨架、踩过的坑和排查方法一次讲清楚代码用的是PyTorch。下面所有实现细节凡是原论文没写死、我根据自己的实际运行经验补全的部分我都会明确标出来是“常见实践补全”。2. SAC的数学骨架到底在拆什么2.1 从标准RL目标到最大熵目标的跳跃标准RL的目标是最大化累计期望回报写成式子就是 $\sum_t \mathbb{E}{(s_t,a_t)\sim\rho\pi}[r(s_t,a_t)]$。这个目标有个隐含假设只要期望回报最大策略多确定都无所谓。结果就是策略很快塌缩到一个确定性动作上探索全靠外部噪声撑着。SAC改的地方很巧妙它在每一步的回报里额外塞进一个熵项$J(\pi)\sum_t \mathbb{E}{(s_t,a_t)\sim\rho\pi}\left[r(s_t,a_t)\alpha,\mathcal{H}\big(\pi(\cdot|s_t)\big)\right]$这里的 $\mathcal{H}$ 是策略分布的微分熵$\alpha$ 叫温度系数控制“回报”和“随机性”两件事谁更重要。熵越大策略在动作上的分布越平越愿意尝试不同动作。这个改动看着只是一项但传播开来会让整套Bellman方程都变软。所谓“软”指的就是价值函数的更新里也带上熵的贡献。我一开始不理解为什么要在回报里加熵后来想明白了它相当于给策略装了一个正则项防止策略过早自信。就像你带孩子学走路不逼他每步都走最优路径而是允许他多试几个方向试错空间大了最终找到的路反而更稳。2.2 软价值函数、软Q函数和软策略的关系链定义软Q函数 $Q(s,a)$ 和软价值函数 $V(s)$$V(s)\mathbb{E}{a\sim\pi}\big[Q(s,a)-\alpha\log\pi(a|s)\big]$ $Q(s,a)r(s,a)\gamma,\mathbb{E}{s}[V(s)]$把第一式代进第二式就得到软Q的Bellman备份$Q(s,a)r\gamma,\mathbb{E}_{s}\big[Q(s,a)-\alpha\log\pi(a|s)\big]$。注意这个备份目标里多了一项 $-\alpha\log\pi(a|s)$。这就是“软”的来源。它意味着Q函数学的不是纯回报而是“回报减去策略的不确定性代价”。策略越确定log π 的绝对值大这一项惩罚越重。反过来策略在某个状态下如果分布很平惩罚就小。最终平衡出来的策略既追求回报又保持一定随机度。至于策略本身怎么更新SAC不去直接最大化 $V$而是最小化策略和玻尔兹曼分布之间的KL散度。展开后策略loss就是 $\mathbb{E}_{s}\big[\alpha\log\pi(a|s)-Q(s,a)\big]$其中动作 $a$ 是从当前策略里采样出来的。这一项的直觉是让策略倾向高Q值的动作第二项同时别让分布塌得太窄第一项。我第一次看这个loss觉得是不是少了个负号对着论文推了两遍才确认是因为做的是最小化KL展开后符号就长这样。2.3 三个网络各自在优化什么SAC实际跑起来会有五组参数策略网络 $\pi_\phi$、两个Q网络 $Q_{\theta_1},Q_{\theta_2}$、以及两个Q网络各自的目标网络 $\bar Q_{\bar\theta_1},\bar Q_{\bar\theta_2}$。它们各自的loss是这样的Q网络loss$L_Q\mathbb{E}\big[\big(Q_\theta(s,a)-(r\gamma,(Q_{\bar\theta}(s,a)-\alpha\log\pi_\phi(a|s)))\big)^2\big]$。两个Q网络各算一份分别更新防止同一个网络既当选手又当裁判。策略loss$L_\pi\mathbb{E}{s}\big[\alpha\log\pi\phi(a|s)-Q_\theta(s,a)\big]$其中 $a$ 由重参数化采样得到$Q$ 取两个Q网络的最小值。温度loss$L_\alpha\mathbb{E}\big[-\alpha(\log\pi_\phi(a|s)\bar{\mathcal H})\big]$$\bar{\mathcal H}$ 是目标熵通常取动作维度的负数。为什么要用两个Q网络取min这是从TD3借来的过估计抑制技巧。单Q网络在off-policy训练里容易把某些动作的价值估高策略就会一头扎进去然后数据一变Q值又崩。取min相当于悲观估计宁可低估也不高估训练稳很多。目标网络用软更新$\bar\theta\leftarrow\tau\theta(1-\tau)\bar\theta$$\tau$ 一般取0.005。3. 参数里的门道动作压缩与重参数化3.1 高斯策略为什么要套一个tanh连续动作空间里SAC的策略输出是一个对角高斯分布均值 $\mu$ 和标准差 $\sigma$ 由网络给出。但动作有边界比如机械臂力矩限制在[-1,1]直接用高斯分布会有无界问题。标准做法是采样一个无界值 $u\sim\mathcal N(\mu,\sigma^2)$再过一层 $\tanh$ 压到(-1,1)$a\tanh(u)$。代价是概率密度变了因为 $\tanh$ 是双射得按换元公式修正log概率$\log\pi(a|s)\log\mathcal N(u;\mu,\sigma^2)-\sum_i\log\big(1-\tanh^2(u_i)\big)$第二项就是雅可比行列式的对数。少写这一项策略更新会偏而且偏得不容易察觉因为训练还能跑就是收敛质量下降。我早期复现时忽略了这一项任务奖励卡在一个不上不下的水平排查了两天。数值上建议用 $\log(1-\tanh^2(u))$ 的等价稳定形式直接算会在 $u$ 很大时溢出我一般写成2*(np.log(2) - u - F.softplus(-2*u))。这是常见实践补全原论文没强调数值细节但工程上必须处理。3.2 重参数化技巧带来的梯度通路策略loss里的第二项 $Q(s,a)$ 需要动作 $a$ 对 $\phi$ 可导否则梯度断在采样这一步。重参数化就是解决这个的把随机性拆成独立的外生噪声 $\epsilon$写成 $u\mu_\phi(s)\sigma_\phi(s)\odot\epsilon$。这样 $u$ 对 $\phi$ 有确定性的可导路径梯度能穿过采样回传。$\epsilon$ 从标准正态里抽本身不依赖参数。如果没有这一步就只能用似然比REINFORCE那套估计梯度方差会大得离谱在连续控制里几乎不可用。所以SAC能同时用到Q函数的低方差梯度和策略的随机性重参数化是中间那块拼图。3.3 温度系数到底要不要手动调$\alpha$ 手动设是能跑但不同任务奖励尺度差很多手动调特别费时间。SAC的自动温度调节把 $\alpha$ 也当成一个可学习参数目标熵设为 $-\dim(\mathcal A)$。这个设定的直觉是动作维度越高想要的探索量越大因为高维空间里需要更多样本才能覆盖。比如一个7自由度的机械臂目标熵就是-7。$\alpha$ 的loss是 $L_\alpha-\alpha(\log\pi\bar{\mathcal H})$ 用梯度下降更新。注意更新 $\alpha$ 时要把策略的loss项detach掉否则梯度会串。如果训练中发现 $\alpha$ 一直往上涨多半是策略熵远低于目标可能奖励尺度太大或者动作范围太窄如果 $\alpha$ 一路跌到接近0说明策略已经够随机任务可能太简单。我通常把 $\alpha$ 的初始值设0.2、学习率跟Q网络一致3e-4然后观察它的走势判断训练健康度。4. 从零搭一版能跑通的SAC4.1 网络结构与环境接口我用一个高斯策略网络和两个Q网络。策略网络输出均值和log标准差Q网络输入状态和动作输出标量。代码基于PyTorch。环境接口沿用Gym风格env.reset()返回状态env.step(a)返回(s, r, done, info)。为了让读者能直接复现我用一个通用的连续控制环境作为例子具体环境名不写死任何动作连续、维度固定的任务都能套。import torch import torch.nn as nn import torch.nn.functional as F import numpy as np LOG_STD_MIN, LOG_STD_MAX -20, 2 class GaussianPolicy(nn.Module): def __init__(self, obs_dim, act_dim, hidden256): super().__init__() self.fc1 nn.Linear(obs_dim, hidden) self.fc2 nn.Linear(hidden, hidden) self.mu_head nn.Linear(hidden, act_dim) self.log_std_head nn.Linear(hidden, act_dim) def forward(self, obs): h F.relu(self.fc1(obs)) h F.relu(self.fc2(h)) mu self.mu_head(h) log_std self.log_std_head(h) log_std torch.clamp(log_std, LOG_STD_MIN, LOG_STD_MAX) return mu, log_std def sample(self, obs): mu, log_std self.forward(obs) std log_std.exp() dist torch.distributions.Normal(mu, std) u dist.rsample() # 重参数化采样 a torch.tanh(u) log_prob dist.log_prob(u) - torch.log(1 - a.pow(2) 1e-6) log_prob log_prob.sum(dim-1, keepdimTrue) return a, log_probQ网络就是一个普通的MLP把状态和动作拼一起输入class QNet(nn.Module): def __init__(self, obs_dim, act_dim, hidden256): super().__init__() self.fc1 nn.Linear(obs_dim act_dim, hidden) self.fc2 nn.Linear(hidden, hidden) self.fc3 nn.Linear(hidden, 1) def forward(self, obs, act): x torch.cat([obs, act], dim-1) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.fc3(x)4.2 关键超参数取值和我推荐的区间下面这张表是我在多个连续控制任务上试出来的常用配置不是唯一解但作为起点很稳参数常用取值说明学习率3e-4策略和Q都用Adam温度也可以一样批大小256内存够可以上512太小梯度噪声大折扣因子0.99长周期任务可用0.995软更新系数0.005过大会抖过小收敛慢回放池1e6小任务1e5也够随机预热步数1e4前1万步纯随机填充每步更新次数1样本效率吃紧时可选1目标熵-动作维度自动调温必须设隐藏层宽度2562层MLP3层有时过拟合隐藏层宽度不是越大越好。我在一个状态维度只有十几的任务上试过512×512结果过拟合严重评估奖励比训练奖励低一大截。256×2基本够用除非状态是图像那才需要卷积编码器。4.3 训练循环里最容易写错的几行训练主循环里几个容易翻车的地方我单独拎出来说。第一处是Q目标的计算with torch.no_grad(): next_act, next_logp policy.sample(next_obs) q1_next q1_target(next_obs, next_act) q2_next q2_target(next_obs, next_act) q_next torch.min(q1_next, q2_next) - alpha * next_logp target_q reward (1 - done) * gamma * q_next注意alpha * next_logp这一项别漏。done用来处理终止状态终止时不算未来价值。第二处是策略更新的梯度通路act, logp policy.sample(obs) q1_pi q1(obs, act) q2_pi q2(obs, act) q_pi torch.min(q1_pi, q2_pi) policy_loss (alpha.detach() * logp - q_pi).mean()这里alpha.detach()很关键防止策略loss的梯度流进温度参数。第三处是温度更新_, logp policy.sample(obs) alpha_loss -(alpha * (logp target_entropy).detach()).mean()logp要detach否则策略网络也会被温度loss带着走训练会乱。这三处我踩过至少两次一次是漏了熵项导致Q值虚高一次是忘了detach导致alpha爆炸。5. 踩坑排查SAC训练不收敛的典型症状5.1 症状对照表与快速定位训练出问题时第一步别乱调参先看曲线。下面这张表是我的排查顺序从最常见的往上排症状最可能原因处理方式Q值持续攀升不下降目标网络更新太慢或没同步检查tau和target update调用策略塌缩到确定性熵项权重太小或漏写检查logp是否正确计算alpha一路涨到很大奖励尺度过大奖励除以尺度因子或降低lr奖励震荡后崩盘学习率过大或batch太小降到1e-4batch调大早期奖励不涨随机预热步数不够预热加到2e4评估远差于训练过拟合或状态未归一化加正则或做obs标准化我遇到最多的是“策略塌缩到确定性”。表现是动作方差越来越小最后策略几乎输出固定动作reward卡住。根因通常是logp的雅可比修正项写错导致熵项估计偏差。用前文那个稳定的等价写法能规避大部分数值问题。还有一种情况是目标熵设得不对比如动作维度是6但目标熵写成-1那策略会觉得随机度已经够高不再探索。5.2 状态归一化和奖励缩放的必要性很多人复现SAC失败问题不在算法在数据预处理。状态各维度量纲差很多时网络前几层会被大量纲维度主导。我的做法是跑几百步随机策略统计状态均值和标准差做在线归一化。注意评估时也要用同一套统计量否则训练和评估分布不一致。奖励缩放同理如果奖励在几百量级Q值会很大温度系数和梯度都会受影响。一个常用技巧是把奖励除以一个固定尺度或者用运行均值做动态缩放。这些都是常见实践补全论文里没细说但不做的话复现成功率会低不少。5.3 回放池采样与更新频率的讲究SAC是off-policy样本效率和回放池设计强相关。回放池太小新旧数据相关性高等效于on-policy双Q抑制过估计的效果打折。我一般最少放到1e5复杂任务1e6。每步更新多少次也有讲究环境交互慢、计算资源多的时候可以每步更新多次提高样本效率但这会放大数据相关性带来的偏差需要配合更大的batch。我实测每步更新1次、batch 256是个稳妥组合追求样本效率可以试每步2次更新。另外终止状态的处理要一致如果环境用时间截断返回done那这个done不该当作真正的终止(1-done)会错误地把未来价值砍掉。稳妥做法是让环境区分终止和截断或者对时间截断的情形仍然计算bootstrap。这一点在很多教程里被忽略实际影响不小。6. 和其他算法的取舍什么场景我会选SAC连续控制里SAC不是唯一选择我把常用几个放在一起对比方便你决定什么时候用它算法是否off-policy探索方式调参难度适用场景DDPG是外加噪声高简单连续任务已不推荐新项目TD3是外加噪声中确定性策略够用的任务PPO否策略分布采样低并行采样方便、奖励稳定的任务SAC是内在最大熵中低需要样本效率且探索要求高的任务我的实际选择逻辑是这样如果仿真环境跑一次很便宜、能开大量并行采样PPO省心调参少如果环境交互昂贵比如真实机械臂或者复杂物理仿真SAC的样本效率优势明显如果任务奖励信号稀疏SAC的最大熵探索比TD3的外加噪声更容易找到有效路径。机械臂抓取这类任务我基本首选SAC。还有一个场景值得提就是状态维度高、动作维度也高的任务SAC自动调温给的适应性比手动设噪声好得多。6.1 从SAC延伸到离线强化学习后来越来越多的实战场景拿不到在线交互只能吃现成数据集这就牵扯到离线强化学习。SAC本身不能直接用于离线因为它对分布外动作的Q值过估计严重策略会跑到数据集没覆盖的动作上。有一类离线算法就是在SAC框架上加保守惩罚把分布外动作的Q值压低。我理解这条线的思路时一直把它当作“给SAC的Q目标加一个分布约束”这样看比从零理解一堆新公式容易。如果你已经把在线SAC跑通再去看这些离线变体会顺很多。6.2 我在调参顺序上的习惯最后分享我调SAC的顺序这个顺序帮我省了很多时间。第一步先把随机预热拉满确认回放池有足够多样数据第二步关掉自动调温手动固定一个alpha看能不能收敛验证算法实现没问题第三步打开自动调温观察alpha是否稳定在合理区间第四步再调网络宽度和学习率。很多人一上来就同时调一堆参数出了问题根本不知道是哪一项造成的。分步验证听着慢实际比反复试错快得多。另外提醒一句换随机种子重跑至少三次再下结论单次训练结果在RL里参考价值很低我见过太多“调好了”其实是撞上了好种子。