1. 为什么非得自己写一个 gym 环境?——从“调包失败”到“动手造轮子”的真实动因
你是不是也经历过这样的时刻:在复现某篇强化学习论文时,兴冲冲地 pip install gym,import gym,然后 gym.make('CartPole-v1') —— 行,跑通了;接着想试试论文里那个叫 "CustomGridWorld-v0" 的环境,结果报错 ModuleNotFoundError: No module named 'custom_envs';再一查 GitHub,作者只放了 agent 的训练脚本,环境代码压根没开源,README 里轻描淡写一句 “environment implementation is straightforward”。这时候,你盯着终端里那行红色的 ImportError,手悬在键盘上,心里冒出三个字:我来写。
这根本不是“为了学而学”的学术演练。这是实打实的工程卡点。gym 本身只是一个接口规范(interface specification),它不提供“所有可能的环境”,它只定义了一套契约:你的环境必须有 reset()、step(action)、render() 这几个方法,返回的数据必须是特定类型(obs 是 numpy array,reward 是 float,done 是 bool)。就像 USB 接口标准规定了插头形状和电压,但不会替你生产鼠标或硬盘。当你需要的场景——比如一个带动态障碍物的无人机避障仿真、一个符合某工厂 PLC 通信协议的产线调度模拟器、甚至一个按真实物理参数建模的双足机器人步态控制器——在官方 gym 或 popular third-party 包(如 gym-electricity、gym-micropolis)里根本找不到时,“创建自己的 env” 就不是可选项,而是唯一路径。
更关键的是,环境才是强化学习问题的“问题本身”。算法(PPO、SAC、DQN)只是求解器,而环境定义了状态空间的维度与连续性、动作空间的离散/连续/混合结构、奖励函数的稀疏性与误导性、以及最重要的——马尔可夫性是否被严格满足。我曾在一个物流分拣模拟项目中,直接复用 gym-super-mario-bros 的框架改写环境,结果训练出的策略在仿真里成功率 92%,一上真机就频繁撞墙。排查三天才发现,原环境的 step() 函数里把“机械臂末端位置误差”作为观测的一部分,而我在移植时误把它当成了“目标位置”,导致 agent 学到的其实是“如何让误差变大”。这个坑,只有亲手写过 env 的人才会刻骨铭心:环境代码里的每一行,都在悄悄重写你的问题定义。
所以,这篇内容不讲“gym 是什么”的教科书定义,也不堆砌 API 文档。它聚焦于一个一线开发者的真实工作流:当你已经明确知道“我需要一个什么样的世界来训练我的 agent”,如何用最稳妥、最不易踩坑的方式,把它一行行敲出来,并确保它能被任何主流 RL 库(Stable-Baselines3、RLlib、Tianshou)无缝接入。接下来的所有步骤,都源于过去三年里我亲手写过的 17 个自定义环境——从最简陋的 20 行测试桩,到部署在 Kubernetes 集群上、每秒处理 5000 步仿真的工业级环境。
2. 从零开始的最小可行环境:一个 37 行代码的完整闭环
很多教程一上来就甩出 class CustomEnv(gym.Env),然后列一堆抽象方法,新手立刻懵:我连“状态长什么样”都没想清楚,怎么先写接口?这违背了工程直觉。我们反其道而行:先写一个能跑起来的、哪怕只有 1 个状态和 1 个动作的“玩具”,再逐步加肉。下面这个环境,我称之为DummyEnv,它只做一件事:每次 step() 都返回固定的状态 [0.5]、固定的奖励 1.0、固定的 done=False(除非你手动调用 reset() 后立即 step 超过 10 次)。但它能完美验证你的开发环境是否配置正确。
# dummy_env.py import gym from gym import spaces import numpy as np class DummyEnv(gym.Env): def __init__(self): super(DummyEnv, self).__init__() # 定义观测空间:1维连续值,范围 [-1.0, 1.0] self.observation_space = spaces.Box(low=-1.0, high=1.0, shape=(1,), dtype=np.float32) # 定义动作空间:1维离散动作,只有 0 和 1 两个选择 self.action_space = spaces.Discrete(2) self._step_count = 0 self._max_steps = 10 def reset(self): self._step_count = 0 # 返回初始观测:一个在 [-1,1] 内的随机数 return np.array([np.random.uniform(-1.0, 1.0)], dtype=np.float32) def step(self, action): self._step_count += 1 # 固定观测、奖励、done 标志 observation = np.array([0.5], dtype=np.float32) reward = 1.0 done = self._step_count >= self._max_steps info = {} # 必须返回,可以为空字典 return observation, reward, done, info def render(self, mode='human'): # 可视化是可选的,这里留空或打印日志 pass def close(self): # 清理资源,如关闭渲染窗口 pass现在,打开你的 Python 终端,执行以下命令:
# 确保当前目录下有 dummy_env.py python -c " import gym from dummy_env import DummyEnv env = DummyEnv() obs = env.reset() print('Initial obs:', obs) for i in range(12): # 故意多走两步,看 done 是否生效 obs, reward, done, info = env.step(0) print(f'Step {i+1}: obs={obs}, reward={reward}, done={done}') if done: print('Environment terminated!') break "你将看到输出类似:
Initial obs: [0.234] Step 1: obs=[0.5], reward=1.0, done=False ... Step 10: obs=[0.5], reward=1.0, done=True Environment terminated!为什么这 37 行比 370 行的“完整教程”更有价值?因为它强制你面对并解决四个核心契约:
observation_space和action_space的声明:这不是可有可无的装饰。Stable-Baselines3 在初始化 agent 时,会严格检查env.observation_space.shape来决定网络输入层的神经元数量。如果你写成spaces.Box(low=0, high=1, shape=(1,))却在reset()里返回np.array([0.5, 0.6])(二维数组),程序会在env.reset()时就抛出ValueError: Observation 0.5 0.6 is outside the observation space。spaces类型是 gym 的“类型系统”,它在运行时为你做静态检查。reset()的职责:它必须返回一个符合observation_space定义的观测值,并且重置所有内部状态。注意self._step_count = 0这行。如果漏掉,done判断就会失效。很多初学者的 bug 就藏在这里:reset()只重置了部分变量,导致环境状态“带记忆”。step(action)的原子性:它必须是一个不可分割的操作单元。传入一个动作,立刻返回(新状态、奖励、是否结束、额外信息)。不能在里面嵌套一个 while 循环去模拟“多步物理过程”,除非你明确设计成“动作代表一个宏观决策,step 内部执行微观仿真”。这是强化学习“单步交互”范式的铁律。info字典的强制存在:它必须是字典类型,哪怕为空{}。这是 gym 的硬性要求。有些第三方库(如 RLlib)会利用info传递调试信息(如 episode length、success flag),所以别偷懒写成return ..., ... , ..., None。
提示:把这个
DummyEnv当作你的“Hello World”测试桩。每次添加新功能(比如加入一个真实的物理模型、一个复杂的奖励计算),都先确保它还能通过上述终端测试。这是防止“改着改着,连基础功能都崩了”的最有效手段。
3. 状态与动作空间的设计哲学:别让数学定义毁掉你的训练
很多开发者栽在第一步:状态空间(observation space)和动作空间(action space)的定义上。他们照着论文公式,把所有能想到的变量都塞进去,结果 agent 训练几万步后 reward 曲线像心电图一样乱跳。问题往往不出在算法,而出在空间设计本身。我们来看两个真实案例。
3.1 案例一:被“标准化”反噬的传感器数据
某智能灌溉项目需要根据土壤湿度、光照强度、空气温湿度来决策浇水时长。原始数据范围如下:
- 土壤湿度:0% ~ 100% (传感器读数)
- 光照强度:0 ~ 10000 lux
- 空气温度:-20°C ~ 50°C
- 空气湿度:0% ~ 100%
新手直觉:“全量输入,越多信息越好”,于是定义:
# 错误示范! self.observation_space = spaces.Box( low=np.array([0.0, 0.0, -20.0, 0.0]), high=np.array([100.0, 10000.0, 50.0, 100.0]), dtype=np.float32 )结果 agent 学到的策略极度不稳定。原因在于:神经网络对输入特征的尺度极其敏感。光照强度(0~10000)的梯度更新量,天然就是温度(-20~50)的数百倍。网络权重在训练初期就被“大数字”主导,小数字特征(如温度)的微小变化完全被淹没。这不是算法缺陷,是数据预处理的缺失。
正确做法是“领域知识驱动的归一化”:
# 正确示范:基于物理意义的归一化 self.observation_space = spaces.Box( low=np.array([0.0, 0.0, 0.0, 0.0]), # 归一化后全部映射到 [0,1] high=np.array([1.0, 1.0, 1.0, 1.0]), dtype=np.float32 ) # 在 reset() 和 step() 中,对原始读数做如下转换: def _normalize_obs(self, raw_obs): # raw_obs = [soil_moisture, light_lux, temp_c, humidity_pct] norm_obs = np.zeros(4, dtype=np.float32) norm_obs[0] = raw_obs[0] / 100.0 # 湿度:0-100 -> 0-1 norm_obs[1] = np.clip(raw_obs[1] / 10000.0, 0, 1) # 光照:clip 防止异常值 norm_obs[2] = (raw_obs[2] + 20.0) / 70.0 # 温度:-20~50 -> 0~1 norm_obs[3] = raw_obs[3] / 100.0 # 湿度:0-100 -> 0-1 return norm_obs这个操作背后是两条经验:
- Clip 优于 Scale:对于可能有异常值的传感器(如光照突变),先
np.clip(value, min, max)再归一化,比直接除以理论最大值更鲁棒。 - 物理意义优先:温度归一化用
+20 / 70,而不是min-max scaling,因为-20°C和50°C是设备安全运行的物理边界,不是统计意义上的极值。这保证了归一化后的数值始终承载明确的物理含义。
3.2 案例二:动作空间的“离散陷阱”
另一个项目是控制一个三轴机械臂抓取物体。动作空间设计成:
spaces.Discrete(27):每个轴有 3 个档位(-1, 0, +1),3^3=27 种组合。
表面看很合理,但训练效果奇差。agent 总是在原地抖动,无法完成平滑移动。问题在于:离散动作空间强行割裂了动作的连续性本质。机械臂的电机控制信号本身就是连续的电压或 PWM 占空比。把一个连续的控制问题,硬编码成 27 个孤立的“按钮”,等于让 agent 在学一套毫无物理意义的“密码”。
正确解法是拥抱连续性:
# 正确示范:连续动作空间 self.action_space = spaces.Box( low=np.array([-1.0, -1.0, -1.0]), # 每个轴的最小控制量 high=np.array([1.0, 1.0, 1.0]), # 每个轴的最大控制量 dtype=np.float32 ) # 在 step() 中,将 [-1,1] 的网络输出,映射为实际电机指令 def _apply_action(self, action): # action 是一个长度为 3 的 numpy array,值在 [-1,1] # 假设电机接受 0-10V 信号,则映射为:voltage = (action + 1) * 5.0 voltage_cmd = (action + 1.0) * 5.0 # [-1,1] -> [0,10] # 发送 voltage_cmd 到硬件或仿真器 self._send_to_motor(voltage_cmd)选择Discrete还是Box,核心判断标准只有一个:你的执行器(actuator)在物理世界中,接收的是离散指令(如“开/关”、“左转/右转”),还是连续信号(如“转速 1200rpm”、“电压 3.2V”)?如果是前者,用Discrete;如果是后者,必须用Box。混淆这一点,是 80% 的“策略不收敛”问题的根源。
注意:
spaces.Tuple和spaces.Dict是进阶工具。当你需要混合不同类型的动作(比如同时发送一个连续的力矩和一个离散的模式切换指令)时才用它们。绝大多数场景,老老实实用Box或Discrete,别为了“炫技”增加复杂度。
4. 奖励函数:你给 agent 的“价值观说明书”
如果说状态空间定义了“世界是什么样”,动作空间定义了“我能做什么”,那么奖励函数(reward function)就定义了“什么叫做好”。它是整个强化学习任务中最主观、最易错、也最具杠杆效应的部分。一个设计精妙的奖励,能让简单算法(如 DQN)快速收敛;一个设计拙劣的奖励,能让最先进的 SAC 算法陷入局部最优,永远学不会核心技能。
我们拆解一个经典错误:“稀疏奖励陷阱”。假设你要训练一个 agent 学会开门。门的状态只有两种:closed和open。你设定:
reward = 100当门被打开;reward = 0其他所有时候。
这看起来天经地义。但 agent 在 99.9% 的时间里得到的都是 0。它就像一个在浓雾中摸索的人,完全不知道自己离目标是更近了还是更远了。训练几百万步后,reward 曲线可能还是一条平坦的直线。这就是“稀疏奖励”——信号太弱,无法提供有效的梯度指引。
破解之道是“奖励塑形”(Reward Shaping):在最终目标之外,为通往目标的每一个关键里程碑,设置一个较小的、即时的正向奖励。但这绝不是随意加分,它必须遵循一个黄金法则:塑形奖励不能改变最优策略(optimal policy)。否则,你就是在教 agent 一条“捷径”,而这条捷径在真实环境中可能根本不存在。
以开门为例,一个安全的塑形方案:
| 事件 | 奖励 | 设计理由 |
|---|---|---|
| Agent 的手成功触碰到门把手 | +5 | 触碰是开门的第一物理前提,且易于检测(距离阈值) |
| 门把手被成功旋转(角度 > 15°) | +10 | 旋转是核心动作,奖励应高于触碰 |
| 门缝开启宽度 > 5cm | +20 | 门开始移动,是显著进展 |
| 门完全打开(180°) | +100 | 终极目标 |
这个方案的关键在于:所有中间奖励都严格依赖于物理世界的可观测状态变化。它没有奖励“agent 朝门走”,因为“走”这个动作本身不保证触碰(可能走歪了);它只奖励“触碰”这个确定发生的事件。这样,agent 学到的策略,必然是“先走到门边,再伸手,再旋转”,这与人类开门的逻辑完全一致。
再看一个反面教材:“负向奖励滥用”。有开发者为了“防止 agent 撞墙”,在每次 step() 中,只要 agent 的位置离墙的距离 < 0.1m,就给reward = -50。结果 agent 学会了什么?它学会了永远待在房间中央,一动不动。因为任何移动都增加了“靠近墙”的风险,而静止是获得最高平均奖励(0)的最稳妥策略。这违背了任务初衷——你希望它“移动到目标点”,而不是“安全地发呆”。
正确的防撞策略是“约束”而非“惩罚”:
- 在
step()内部,当检测到即将碰撞时,不执行该动作,而是让 agent 保持原位,并返回reward = 0。 - 或者,将碰撞定义为
done=True,并给予一个较大的负向reward = -100,强制 episode 结束。这告诉 agent:“撞墙是致命错误,必须不惜一切代价避免”,而不是“撞墙很疼,但躺着更舒服”。
最后,一个硬性工程规范:所有 reward 值,必须落在一个合理的、有界的范围内。不要用reward = 1e6或reward = -1e9。Stable-Baselines3 的 PPO 算法默认使用clip_range=0.2,如果 reward 波动跨越 10 个数量级,clip 机制会彻底失效,导致训练崩溃。经验法则是:主任务 reward 控制在[-10, +10],塑形 reward 控制在[-1, +5]。你可以用np.clip(reward, -10, 10)做最后一道保险。
实操心得:把 reward 函数单独抽成一个私有方法
_calculate_reward(self, prev_state, action, next_state)。在开发阶段,在step()里打印出每一次计算的 reward 值和它的组成部分(如print(f"Touch: +5, Rotate: +10, Total: +15"))。这能让你在 5 分钟内定位 90% 的 reward 逻辑错误。我见过太多人因为 reward 打印被注释掉,花了两天时间 debug,最后发现是if door_angle > 15:写成了if door_angle < 15:。
5. 注册与封装:让自定义环境像官方环境一样被调用
写完一个功能完备的CustomEnv类,它还只是一个 Python 类,不能被gym.make("MyEnv-v0")这样调用。要让它融入 gym 的生态系统,必须完成注册(registration)和封装(packaging)两步。这一步看似繁琐,却是工程化的分水岭——它决定了你的环境能否被团队其他成员、CI/CD 流水线、甚至公开的 benchmark 平台所复用。
5.1 最小注册:两行代码搞定本地调用
最简单的注册方式,是在你的环境文件末尾,或者在一个独立的__init__.py文件里,调用gym.envs.registration.register:
# 在 custom_env.py 文件末尾添加 from gym.envs.registration import register register( id='MyCustomEnv-v0', # 环境ID,必须唯一,格式为 "Name-vVersion" entry_point='custom_env:MyCustomEnv', # 模块路径:类名 max_episode_steps=500, # 可选,超时自动结束 reward_threshold=100.0, # 可选,认为“解决”的奖励阈值 )然后,你就可以像使用官方环境一样调用了:
import gym env = gym.make('MyCustomEnv-v0') # 成功!为什么entry_point的写法如此重要?custom_env:MyCustomEnv中的custom_env是模块名(即.py文件名,不含.py后缀),MyCustomEnv是类名。如果你把文件命名为my_custom_env.py,但写成'my_custom_env:MyCustomEnv',它就能工作;但如果写成'custom_env:MyCustomEnv',Python 就会报ModuleNotFoundError。这是一个极易犯的拼写错误。
5.2 工程级封装:发布为可安装的 Python 包
当你的环境需要被多人协作、或集成到更大的项目中时,手动复制.py文件和register语句就不可持续了。你需要把它打包成一个标准的 Python 包。以下是经过验证的最小可行流程:
创建项目结构:
my_custom_env/ ├── setup.py # 包配置文件 ├── my_custom_env/ # 包源码目录(与包名同名) │ ├── __init__.py # 必须存在,可为空 │ └── envs/ # 环境实现目录 │ ├── __init__.py # 必须存在 │ └── my_custom_env.py # 你的环境类定义 └── README.md编写
setup.py:# setup.py from setuptools import setup, find_packages setup( name="my-custom-env", version="0.1.0", packages=find_packages(), # 自动发现 my_custom_env/ 目录 install_requires=[ "gym>=0.21.0", # 依赖 gym "numpy>=1.19.0", # 依赖 numpy ], # 关键:通过 entry_points 注册 gym 环境 entry_points={ 'gym.envs': [ 'my_custom_env = my_custom_env.envs.my_custom_env:MyCustomEnv', ] } )在
my_custom_env/envs/__init__.py中触发注册:# my_custom_env/envs/__init__.py # 这个文件会在包导入时自动执行 from gym.envs.registration import register register( id='MyCustomEnv-v0', entry_point='my_custom_env.envs.my_custom_env:MyCustomEnv', max_episode_steps=500, )安装与验证:
# 在 my_custom_env/ 目录下执行 pip install -e . # -e 表示“开发模式安装”,修改代码后无需重新安装 # 验证安装成功 python -c "import gym; env = gym.make('MyCustomEnv-v0'); print('Success!')"
这个封装流程的价值在于:它把环境的“定义”和“注册”完全解耦。setup.py负责声明“我提供了什么”,__init__.py负责“我如何被发现”,而你的核心环境类my_custom_env.py只专注于“我是什么”。这种清晰的分层,是大型项目可维护性的基石。
高级技巧:如果你想支持多个环境版本(如
MyCustomEnv-v0用于研究,MyCustomEnv-v1用于生产),可以在setup.py的entry_points里注册多个 ID,或者在__init__.py里根据环境变量动态注册。例如:# my_custom_env/envs/__init__.py import os from gym.envs.registration import register version = os.getenv("ENV_VERSION", "v0") register( id=f'MyCustomEnv-{version}', entry_point='my_custom_env.envs.my_custom_env:MyCustomEnv', )这样,
ENV_VERSION=v1 pip install -e .就能安装 v1 版本,无需修改代码。
6. 踩坑实录:那些让资深开发者也沉默的 5 个致命细节
即使你熟读 gym 文档,亲手写过十几个环境,依然会掉进一些文档里不会写、Stack Overflow 上找不到答案的“幽灵坑”。这些坑不报错,但会让训练结果诡异、不可复现,耗费数天时间排查。以下是我在工业项目中总结的 5 个最高频、最隐蔽的致命细节,每一个都附带了“如何一眼识别”和“如何永久规避”的实操方案。
6.1 坑一:seed()方法的“伪随机”陷阱
现象:你在训练脚本开头写了env.seed(42),但每次运行,agent 的训练曲线都不一样。
原因:gym.Env.seed()方法在较新版本的 gym(0.26+)中已被标记为 deprecated,且其行为与你的直觉相反。它只设置环境内部的随机数生成器(RNG),但不设置 NumPy 和 Python 的全局 RNG。而你的reset()和step()方法里,很可能直接调用了np.random.uniform()或random.choice(),这些调用使用的是全局 RNG,不受env.seed()控制。
识别方法:在reset()方法的第一行,插入print("NP Seed:", np.random.get_state()[1][0])。多次运行,如果这个数字每次都不同,说明全局 RNG 没被控制。
永久规避方案:彻底弃用env.seed(),改用gym.utils.seeding.np_random。这是 gym 官方推荐的、真正可控的 RNG 方式:
# 在你的环境类中 from gym.utils import seeding class MyCustomEnv(gym.Env): def __init__(self): ... self.np_random = None # 初始化 RNG 对象 self.seed() # 在 __init__ 中就调用 seed def seed(self, seed=None): # 使用 gym 的标准方式创建 RNG self.np_random, seed = seeding.np_random(seed) return [seed] def reset(self): # 所有随机操作,都用 self.np_random initial_x = self.np_random.uniform(-1.0, 1.0) initial_y = self.np_random.normal(0.0, 0.5) return np.array([initial_x, initial_y], dtype=np.float32) def step(self, action): # 同样,用 self.np_random noise = self.np_random.normal(0.0, 0.1, size=self.observation_space.shape) ...这个方案确保了:无论你调用多少次env.seed(42),self.np_random都会生成完全相同的随机序列。这是可复现性(reproducibility)的绝对保障。
6.2 坑二:render()的“阻塞式”假象
现象:你的环境有render()方法,调用env.render()后,程序卡住,画面一闪而过,或者干脆没反应。
原因:render()的mode参数有多种,最常用的是'human'(弹出 GUI 窗口)和'rgb_array'(返回一个 numpy array 图像)。但'human'模式在某些环境下(尤其是远程服务器、无图形界面的 Docker 容器)会直接失败或阻塞。而很多教程没告诉你,gym.make()默认并不会启用渲染,你必须显式调用env.render()。
识别方法:在render()方法里第一行加print(f"Rendering in mode: {mode}")。如果没看到打印,说明render()根本没被调用。
永久规避方案:永远为render()提供一个安全的 fallback,并明确区分用途:
def render(self, mode='human'): if mode == 'rgb_array': # 返回一个 (height, width, 3) 的 uint8 numpy array # 这是 Stable-Baselines3 的 Tensorboard 回调所需要的 return self._get_current_frame() # 你的图像生成逻辑 elif mode == 'human': # 尝试 GUI 渲染,但用 try-except 包裹 try: import pygame # 你的 pygame 渲染逻辑 except ImportError: print("Warning: pygame not installed. Skipping human render.") return else: # 对于未知 mode,抛出明确错误,而不是静默失败 raise NotImplementedError(f"Render mode '{mode}' is not supported.") # 在训练循环中,如果你想看画面,必须主动调用: # for _ in range(1000): # obs, reward, done, info = env.step(action) # env.render(mode='human') # 显式调用! # if done: break6.3 坑三:close()的“资源泄漏”黑洞
现象:你训练了 1000 个 episode,程序内存占用越来越高,最后 OOM(Out of Memory)崩溃。
原因:close()方法被严重低估。它不只是“关掉窗口”,而是负责释放所有由环境持有的外部资源:打开的文件句柄、网络 socket、GPU 显存、甚至启动的子进程(如一个外部物理引擎仿真器)。如果你的close()是空的,或者只写了pass,这些资源永远不会被回收。
识别方法:在__init__中记录资源创建,在close()中记录资源销毁,并用psutil库监控进程内存:
import psutil import os class MyCustomEnv(gym.Env): def __init__(self): self.process = psutil.Process(os.getpid()) print(f"Memory at init: {self.process.memory_info().rss / 1024 / 1024:.2f} MB") def close(self): # 在这里释放所有资源 if hasattr(self, 'simulator_process') and self.simulator_process.is_alive(): self.simulator_process.terminate() # ... 其他清理逻辑 print(f"Memory at close: {self.process.memory_info().rss / 1024 / 1024:.2f} MB")永久规避方案:把close()当作__del__的替代品来写,并用atexit做双重保险:
import atexit class MyCustomEnv(gym.Env): def __init__(self): ... # 注册一个退出钩子,确保即使用户忘记调用 close,也能清理 atexit.register(self._cleanup) def _cleanup(self): # 所有清理逻辑都放在这里 if hasattr(self, '_sim_handle') and self._sim_handle: self._sim_handle.close() if hasattr(self, '_log_file') and self._log_file: self._log_file.close() def close(self): self._cleanup() # 复用同一套逻辑6.4 坑四:observation_space的“dtype 不匹配”静默失败
现象:你的reset()返回np.array([0.5], dtype=np.float64),但observation_space.dtype是np.float32,程序不报错,但 agent 训练效果极差。
原因:gym 的spaces.Box在检查时,会对dtype进行宽松比较。float64和float32都是浮点数,所以np.array([0.5], dtype=np.float64)会被gym接受,但 PyTorch/TensorFlow 的神经网络期望输入是float32。当float64数组被送入网络时,会触发隐式类型转换,这个转换过程会引入微小的数值误差,并且极大地拖慢 GPU 计算速度(GPU 对 float32 优化最好)。
识别方法:在reset()和step()的返回前,加一行断言:
assert obs.dtype == self.observation_space.dtype, \ f"Observation dtype {obs.dtype} doesn't match space dtype {self.observation_space.dtype}"永久规避方案:在reset()和step()的返回语句中,强制指定dtype:
def reset(self): obs = np.array([0.5, 0.3], dtype=np.float32) # 显式指定 return obs def step(self, action): ... obs = np.array(new_state, dtype=self.observation_space.dtype) # 复用 space 的 dtype return obs, reward, done, info6.5 坑五:info字典的“键名冲突”灾难
现象:你用info={'success': True}来标记 episode 是否成功,但在用 RLlib 训练时,发现success字段被覆盖,永远是False。
原因:info字典是 gym 的“开放通道”,但很多高级 RL 库(如 RLlib、SB3 的EvalCallback)会往info里写入自己的字段,如'episode_reward','episode_len','is_success'。如果你也用'success'作为 key,就会发生覆盖。更糟的是,不同库使用的 key 名还不统一(SB3 用'is_success',RLlib 用'success'),导致跨框架迁移时出现诡异 bug。
识别方法:在 `step