搞机器人强化学习的人,尤其是做导航、避障、多车调度这类任务的,基本都经历过同一个阶段:在真实小车上调算法调到怀疑人生。我现在的做法是,先在一套叫 Microduck 的仿真强化学习流程里把算法验证完,再往实体车上迁移。Microduck 这个名字看起来像“小鸭子”,其实它对应的是 Duckietown 这种微缩自动驾驶平台,但真正有价值的不是那辆模型车,而是从仿真环境、传感器模拟、奖励函数到强化学习算法的一条完整链路。这篇文章就把这条链路拆开讲:环境怎么搭、算法怎么接、训练时哪些坑必须绕开。适合刚接触强化学习、又急着跑仿真实验的工程师和学生,也适合准备做 sim-to-real 迁移的团队。
1. 为什么仿真强化学习流程要先搭好
1.1 真实机器人试错成本太高,仿真是第一个缓冲层
真实小车跑强化学习,大多数时候不是在调算法,而是在调环境。电池电压掉一点,策略行为就变了;撞一次墙角,要手动把车放回起点;训练一晚上想攒经验数据,结果发现车早就卡在桌椅腿旁边空转了。更麻烦的是,强化学习天然需要探索,而探索意味着要试错。在真实环境里试错,轻则原地打滑,重则电机过热、结构件断裂,这些都是钱和时间。
所以我在做这类项目时,坚持先用仿真兜底。仿真给了一个低成本试错的缓冲层:跑坏了直接 reset,状态可以恢复,物理参数可以任意改。Microduck 就是从这个需求出发的项目。它不追求照片级真实感,重点是把动力学反馈、传感器噪声和任务逻辑做对。一个 episode 训练样本在仿真里按分钟计算,在真车上可能要人工干预半天,这个差距决定了强化学习项目能不能快速迭代。
1.2 Microduck 的定位:轻量、够用、可复现
Microduck 不是一个新算法,而是一套流程。它参考了 Duckietown 里“微缩城镇 + 自动驾驶小车”的思路,但把场景极简化:一辆差速驱动小车、一张栅格地图、若干动态障碍物,外加一个多目标导航任务。我当初给自己定了三个原则,分别是轻量、够用、可复现。
轻量是指仿真器不能吃满显卡和内存。我自己默认用 PyBullet 做物理引擎,只加载小车和地面模型,一张地图几十 MB 足够,训练时 CPU 占用也不会太高。够用是指环境必须能表达移动机器人决策的核心问题:车能不能走、会不会撞、能不能到目标点。我加了碰撞检测、激光雷达、里程计和差速运动模型,这些在真实小车里都是标配。可复现则是每次实验必须能固定随机种子,保证不同算法之间比较结果是有意义的。没有这一步,后面所有指标都是噪声。
1.3 先想清楚要解决什么决策任务
搭环境之前,一定要先问自己一个问题:你到底想让小车学会什么决策?Microduck 一开始锁定的任务就是点目标导航:给定起点、终点和静态地图,小车需要学会避开障碍物到达目标。这个任务看起来简单,但它覆盖了强化学习动作空间设计、奖励塑造成本、状态观测设计的大部分关键问题。
选这个任务还有一个好处:它容易扩展。把静态地图改成带动态障碍物的地图,就是动态避障;把一台车改成多台车,就是多 AGV 协调;把目标改成一条路径轨迹,就能做车道跟踪。如果一开始就贪心,想把所有场景都做进去,环境代码会复杂到连调试都没法下手。先从最简单的版跑通,再逐步叠加,这个顺序我建议不要反过来。
2. 环境搭建时最容易出错的几个细节
2.1 环境依赖与目录结构
Microduck 的仿真环境我采用 PyBullet 作为底层物理引擎。选它的原因很简单:Python 接口干净,安装起来不用处理一堆 ROS 中间件。如果你已经非常熟悉 Gazebo 和 ROS,完全可以用 gym-gazebo 替换底层,但只要保持了 gymnasium 风格接口,上层算法代码就不需要改。
一个比较典型的安装流程是这样的:
git clone https://github.com/your-project/microduck.git cd microduck conda create -n microduck python=3.9 -y conda activate microduck pip install pybullet gymnasium numpy matplotlib stable-baselines3装完之后,目录结构我建议按这样分:
microduck/ ├── envs/ │ ├── microduck_env.py │ └── map_loader.py ├── configs/ │ ├── default.yaml │ └── reward.yaml ├── agents/ │ ├── ppo_train.py │ └── evaluate.py └── scripts/ └── run_tensorboard.sh你可能觉得多建几个文件夹浪费几分钟,但后面训练不同算法、对比不同 reward 配置时,这套结构能帮你省下大量找文件的时间。我见过太多项目,训练脚本和回报函数混在一个文件里,最后改了一行 reward 又开始全量重训,根本不知道改的是哪份代码。
2.2 地图、传感器和动态障碍物怎么建模才合理
地图方面,我用一张 128x128 的二值 PNG 当输入:白色像素是可通行区域,黑色像素是障碍物边界。加载之后转成占据栅格,小车初始位置和目标点都从地图参数里读取。你用灰度图甚至矢量图也可以,只要在 map_loader 里统一转成矩阵就行。
传感器方面,我给虚拟小车配置了三个核心感知模块:2D 激光雷达、里程计和碰撞检测器。激光雷达射线数量设 64 根,探测距离 5 米。射线太多会让训练变慢,太少则小车会漏掉侧面障碍物。里程计返回车体坐标系下的线速度和角速度。碰撞检测器返回布尔值和小车与障碍物的最近距离。这三个信息拼在一起,就是强化学习算法的观测输入。还有一个容易被忽略的点:每次 reset 时,车的位置和朝向都要加一点随机扰动,否则策略会记住“从同一个起点出发”这件事,学不到泛化能力。
动态障碍物也不是随便放的。我让障碍物沿固定路线移动,但初始相位和移动速度在每次 reset 时随机化。这样做的好处是,小车在训练初期能看到一个“会动的世界”,但又不至于难度爆炸。如果一开始就让所有障碍物乱跑,很多随机种子下小车根本活不过前几个 episode,reward 曲线全是零,你很难判断是算法问题还是环境问题。
2.3 奖励函数不是拍脑袋,是先定行为边界
奖励函数是强化学习里最玄学的地方,也是 Microduck 项目里我调得最久的部分。我的经验是不要一开始就设计一堆复杂规则,先用“稀疏奖励 + 少量引导”跑通,再逐步加东西。具体来说,我用的奖励形式是这样的:
- 到达目标点:
+10 - 碰撞障碍物:
-10 - 与上一步相比,离目标点更近:
+0.2 * distance_change - 每个 step 附加存活惩罚:
-0.01
这个设计的关键在于引导项。distance_change 如果乘的系数太大,小车会为了多拿引导奖励而贴着墙走;乘得太小,引导效果不明显,训练初期小车到处乱窜。我最终把系数固定在 0.2,配合碰撞惩罚 -10,才得到比较稳定的行为。
还有一个经常被人忽略的点:一定要区分 episode 结束原因。在 gymnasium 里,terminated 表示真实终止,比如到达目标或碰撞;truncated 表示超时或步数上限导致截断。强化学习算法在计算收益时,对这两种结束的处理方式不同。我在 Microduck 里默认设置单 episode 步数上限为 300,如果 300 步还没到目标,就按 truncated 处理,并把这一步的 value 用当前价值估计 bootstrap。很多新手在这里直接把 both 当成 done,结果小车永远学不会长距离导航。
3. 训练流程里的关键实现
3.1 算法从 PPO 开始,别一上来就 SAC
Microduck 的算法接入我默认从 PPO(近端策略优化)开始。原因是 PPO 对超参数不那么敏感,训练曲线相对平滑,而且 2048 步一个 batch 的更新方式很适合在小规模环境上反复实验。如果你直接上 SAC,可能样本效率更高,但 reward scale、学习率、自动温度系数这些参数会引入一堆额外变量,出了问题你很难定位是环境还是算法的问题。
我把环境包装成 gymnasium 的 Env 类,实现 reset、step、render 三个方法。动作空间是二维连续动作:线速度[-0.5, 0.5]m/s,角速度[-1.0, 1.0]rad/s。观测空间则拼接了激光雷达数据、当前速度、目标相对坐标和上一时刻动作。封装好之后,接 Stable-Baselines3 的 PPO 只需要几行配置。如果你不想依赖现成库,也可以自己写 PPO,但前期我不建议重复造轮子,先把端到端链路跑通更重要。
3.2 训练主循环,别把 terminated 和 truncated 混在一起
训练主循环本身不复杂,但数据流的细节会直接影响收敛效果。我常用的伪代码如下:
obs, _ = env.reset() buffer = RolloutBuffer(capacity=2048) while step < total_steps: action, _ = policy(obs) obs_next, reward, terminated, truncated, info = env.step(action) buffer.store(obs, action, reward, terminated, truncated) obs = obs_next if buffer.full: policy.update(buffer) buffer.clear() if terminated or truncated: obs, _ = env.reset()代码看起来简单,核心问题在 buffer 里每一项要同时记录 terminated 和 truncated。PPO 计算 GAE 时,只有 terminated 为 True,目标价值才设为 0;truncated 为 True 时,应该用当前 critic 网络输出的价值估计继续往后推。如果你把两个标志合并成一个 done,那么小车在到达 300 步上限被截断时,会被误判为失败,value target 被压到 0,策略就会越来越不敢往外走,训练最后变成原地踏步。
另一个容易踩的坑是采样和更新频率。PPO 不是每个 step 都更新,而是收集满一个 rollout buffer 再统一更新。我把 buffer 容量设成 2048,也就是小车跑约 6 到 7 个 episode 收集一次数据。更新时采用 mini-batch 方式和 GAE,GAE lambda 设 0.95。如果你每个 step 都更新,不仅慢,而且策略会被单条轨迹的噪声带偏。
3.3 超参数和观测归一化
超参数我建议先固定一组比较稳的 baseline,而不是一上来就调参。Microduck 里我常用的初始配置如下表所示:
| 参数 | 取值 | 说明 |
|---|---|---|
| 学习率 | 3e-4 | 对 PPO 来说比较安全 |
| batch_size | 2048 | 采样缓冲区容量 |
| minibatch_size | 64 | 每次梯度更新的样本数 |
| GAE lambda | 0.95 | 优势估计平滑程度 |
| 折扣因子 | 0.99 | 长距离目标依赖未来奖励 |
| clip 系数 | 0.2 | PPO 的裁剪范围 |
| 更新轮数 | 10 | 每个 buffer 更新次数 |
| 控制频率 | 10-15 Hz | 与真车保持一致 |
这套参数跑 300 万步左右,Microduck 里的智能车基本能从随机乱撞变成会找目标。如果完全不上涨,问题多半不在超参数,而在 reward 设计和状态归一化。
观测归一化是我特别想强调的一点。速度、激光雷达距离、目标坐标这三类数据量纲完全不一样。激光雷达距离是 0 到 5 米,速度是 -0.5 到 0.5,目标坐标可能是几十像素级别。如果不做归一化,PPO 的策略头会被量级大的维度带偏。我在 environments 里维护了一个 running mean 和 running std,在训练过程中逐步更新,推理时固定统计量。做完这一步,reward 曲线稳定性提升非常明显。
4. 调试实录:常见问题与排查方法
4.1 训练发散、NaN 和虚假碰撞
训练过程中最让人头疼的就是 reward 突然变成 NaN。我遇到过的 NaN 基本来自三类:reward 计算分母为 0、激光雷达某帧为空、策略输出的 action 超出仿真器允许范围。排查办法是在 env 里的 step 函数加上断言,把所有 NaN 出现时的 obs、action、reward 打印出来,再回放对应 frame。
有一个具体例子:我在计算 distance_change 时,取的是“目标点当前距离减上一时刻距离”,如果小车正好在某一步到了目标点,目标距离变成 0,此时再除以上一步距离就可能产生除零。把公式改成差值而不是比值,问题就消失了。
虚假碰撞也很常见。PyBullet 里如果碰撞体尺寸比视觉模型大,小车明明离障碍物还有一段距离,碰撞检测就被触发。我的处理是把碰撞体缩小 10%,并且增加一个条件:只有当小车速度低于 0.05m/s 且碰撞体持续接触障碍物时才判定为真实碰撞。这个缓冲逻辑看起来有些放宽碰撞标准,但实际训练出来的策略更加稳定,不会因为碰撞检测抖动而变得畏手畏脚。
4.2 小车不走、原地转圈、只走直线
这几个现象几乎每个做过 RL 导航的人都会遇到。我先说小车不走。如果每一步存活扣 -0.01,而前进没有明显的正向反馈,策略最优解就是站在原地不动,因为这样扣分最少。解决办法很简单,把存活惩罚去掉,或者给“向目标靠近”一个更明显的正奖励,让走动比不走更划算。
原地转圈通常是观测里缺少目标方向信息。激光雷达只能感知局部障碍物,小车并不知道目标点在自己前方还是后方,它当然只能靠旋转碰运气。我把目标点在车体坐标系下的坐标归一化后拼进观测,问题立刻改善。只走直线则说明碰撞惩罚权重不够,小车觉得撞上也没关系,因为离目标更近的引导奖励盖过了碰撞惩罚。我当时的做法是把碰撞惩罚从 -10 调到 -20,并对连续靠近障碍物的行为追加一个小惩罚,让小车学会留出安全距离。
4.3 仿真到实物的迁移误差怎么缩小
仿真练出来的策略直接搬到真车上,成功率通常惨不忍睹。Microduck 在这方面做了三件事:随机化动力学、动作低通滤波、限制最大速度。
动力学随机化是在每次 reset 时,给轮子的半径、摩擦系数增加 5% 到 10% 的随机扰动。这样训练出来的策略不会依赖精确的物理参数,到了真车上能更快适应。动作低通滤波是给 policy 输出的动作加一个一阶惯性环节,把突变的控制量平滑掉,这在真车上能明显减少电机抖动。限制最大速度则更关键:我把仿真中的最大速度设为真车的 60% 到 70%。前期我试过把仿真速度设成真车两倍来训练,策略学会了一路全速冲,真车上根本刹不住。后来把仿真速度限制在真车能力范围内,迁移效果一下子好了很多。
5. 从 Microduck 延伸出去的强化学习方向
5.1 离线强化学习:用沉淀数据训练 IQL
Microduck 运行一段时间后,你会积累大量历史轨迹。这些数据如果直接扔掉非常可惜。离线强化学习可以只靠历史数据训练策略,不需要和仿真环境实时交互。IQL(Implicit Q-Learning)是我试过比较稳的离线算法。它不依赖动作选择网络,通过分位数回归避免分布外动作的价值估计过高。
我做过对比:用随机策略和 PPO 策略各跑 500 个 episode,混合成一个离线数据集。IQL 在这个数据集上训练出来的策略,其导航成功率不输于在线 PPO,而且策略的方差更小。这说明只要数据覆盖足够广,离线训练可以作为在线训练的替代或预热阶段。
5.2 多 AGV 路径规划和交通协调
Microduck 的场景稍加修改,就能变成多 AGV 路径规划问题。把一张地图上放三到五辆虚拟小车,每辆车有独立起点和目标点,共享全局地图,但只使用自己的局部观测。这个场景下,单智能体强化学习很容易出现“抢路”行为:两辆车同时挤在一个路口,谁都不让谁。
我的建议是不要急着上多智能体强化学习,先试试分层方案:全局路径规划用 RRT 或者 A* 算出一条参考路径,再用 Microduck 里的单智能体 RL 做局部避障和路径跟踪。只有在多个 AGV 频繁交互的动态场景里,才需要考虑集中训练、分布执行的多智能体框架。入门阶段把一个智能体的问题做好,比追多智能体算法更稳妥。
5.3 基于模型的强化学习与因果强化学习
如果你希望减少与仿真环境的在线交互次数,可以考虑基于模型的强化学习。它的核心是先学一个动力学模型,用小车的当前状态和动作预测下一时刻状态,再基于这个模型做规划或价值估计。Microduck 的仿真器足够轻量,刚好适合做这种实验。但要注意,动力学模型本身也会有误差,误差累积后策略可能在仿真里表现不错,换到真车立刻失效。
因果强化学习是更前沿的方向。它把因果推断工具嵌入强化学习流程,核心能力是识别变量之间的因果关系,而不是简单的相关性。放到 Microduck 里,一个最简单的因果干预是多了一个环境开关:某个障碍物是否出现、地面摩擦系数是高是低,训练时随机切换这个因素,观察策略行为是否跟着改变。这样可以检验策略到底依赖了哪个真正的因果变量。这个方向更适合做研究型项目,离工程落地还有段距离,但如果想了解最新的强化学习趋势,值得留个心眼。
调试强化学习流程时,我自己的习惯是先把仿真速度调到实时以上,确保一天能看到足够多的轨迹。TensorBoard 里只盯三个指标:平均回报、episode 长度、碰撞率。还有一个常被忽略的小技巧:每次训练完,用固定的 10 个随机种子各评估 20 次,记录成功率和平均到达时间。这一步相当于给策略建立基准线,下一次算法改了什么、效果是否变好,一眼就能看明白。希望这套 Microduck 流程能帮你少踩几个坑,快速跑出自己的第一个仿真强化学习 demo。