其实这事情说起来有点滑稽:我把一个搁置了半年的强化学习项目捡起来,想换一下 Python 版本和依赖,结果一启动就报错。仔细一看,是环境对象没有按新规范返回接口。后来我花了一整个下午把代码里的 gym 全部换成 gymnasium,才意识到这次“换代”比想象中动得多,根本不只是 pip install 一个新包那么简单。
如果你也在维护 RL 相关代码,大概率会遇到同样的问题:老的 gym 包从 2022 年底基本停止实质更新,社区把维护工作迁到了 Gymnasium 上。表面上是换了包名,实际上是接口规范的一轮收口。这篇文章会把“gym 更新到 Gymnasium 后需要修改哪些位置”拆得很细,按安装依赖、import 语句、自定义环境、训练循环、Wrapper、spaces、注册表、常见报错这几个维度逐一说明,适合正在翻新旧代码的强化学习开发者,也适合第一次写自定义环境的新手。
1. 为什么需要迁移,Gymnasium 到底改了什么
1.1 从 gym 到 Gymnasium 的背景
gym 是早期强化学习生态中事实上的环境接口标准。OpenAI 发布它之后,大量算法实现、教程、开源项目都基于它构建。2022 年以后,gym 的核心维护者没有继续投入太多精力,新环境、新接口特性基本停止,社区逐渐转向由 Farama Foundation 维护的 Gymnasium。于是“更新”这件事不再是 pip 里升一个大版本,而是切换到新的包名和新的代码规范。
这一背景决定了后续所有修改位置:只要你想在较新版本的 Python 上跑实验、想用较新的加速库,或者想继续获取环境套件的新特性,就需要把依赖从 gym 换到 gymnasium,并检查所有引用 gym 命名空间的代码。
1.2 接口层面的系统性变化
Gymnasium 相对老 gym 最大的变化,是整理了 Env 接口的返回约定。老的 gym 在较新版本里已经要求 reset() 返回 (obs, info) 元组,Gymnasium 继承了这一点并强化了检查。step() 则正式把 done 拆分成 terminated 和 truncated 两个标志:terminated 表示环境到达终止状态,比如游戏角色死亡、小车冲出边界;truncated 表示回合因为时间上限、步数上限或外部截断条件而提前结束。
这个拆分不只是关于多一个返回值的语义变化,还会影响训练逻辑。比如 PPO 这类算法在处理终止状态时,价值估计通常不需要 bootstrap,而截断状态却往往需要继续估计后续收益。如果只是简单地把 terminated 和 truncated 又或在一起,迁移的意义就失去了一大半。所以我建议迁移后顺手检查一下算法框架是否分别处理了这两个标志。
2. 第一步改动:安装依赖与 import 语句
2.1 安装 gymnasium 和对应环境套件
迁移从依赖声明开始。最简单的是直接替换包名安装:
pip install gymnasium不过一个裸包往往不够。如果你平时用 CartPole、MountainCar、Pendulum 这类经典控制环境,需要额外安装扩展项:
pip install gymnasium[classic-control]Atari、Box2D、MuJoCo 等套件也类似,分别对应 gymnasium[atari]、gymnasium[box2d]、gymnasium[mujoco]。老 gym 的安装方式里,这些环境可能被附带打包,或者需要单独从 pip 里装。Gymnasium 把这些都变成显式扩展项,安装声明更清楚,但也意味着迁移时不能只看主包版本。
这里给一个经验型建议:在 requirements.txt 里最好加上版本约束:
gymnasium[classic-control]>=0.29锁定大版本能防止后续 API 再次变动时踩坑。如果你用 Poetry、conda 这类环境管理工具,同样要把依赖声明里的 gym 替换成 gymnasium。
2.2 import 全量替换
代码层最明显的改动是 import 语句。把所有 import gym 改成 import gymnasium 是最基础的操作,但别天真地以为搜一遍 import gym 就够了。代码里会出现很多子模块引用,例如:
# 修改前 import gym from gym import spaces from gym.wrappers import TimeLimit, FlattenObservation # 修改后 import gymnasium as gym from gymnasium import spaces from gymnasium.wrappers import TimeLimit, FlattenObservation有一种取巧写法是在文件开头保留 import gymnasium as gym,这样能减少一部分算法代码改动。不过这种写法有隐患,因为代码里如果还有 gym.spaces、gym.wrappers、gym.utils、gym.error 这类子模块引用,依然会因为找不到对应名字而报错。所以我更建议对每个文件单独处理,把引用来源统一改成 gymnasium。
另一个容易漏的地方是类型判断。用过 isinstance(env, gym.Env) 这类判断的代码也要同步换掉。迁移时最好用编辑器全局搜索“gym”,把搜索结果列成清单,逐个确认改法,而不是只替换一种模式。
提示:只要代码里有残留的 import gym,就会同时存在两个不同的环境体系。环境注册表、版本号、Wrapper 名字都可能互相干扰,调试时非常难受,所以迁移要一次做到位。
2.3 依赖冲突与虚拟环境隔离
实际项目中经常出现的情况是项目 A 还在用 gym,项目 B 想迁移到 gymnasium。此时不要把两个包强行塞到同一个 Python 环境里。虽然 gym 和 gymnasium 的包名不同可以共存,但环境注册表、包装器、底层依赖经常打架,典型表现是 gym.make 能创建环境而 gymnasium.make 找不到同一个 ID 的环境。
我在迁移某个模拟实验环境时遇到过这种情况。最终只有一个笨办法:把项目统一到 gymnasium,或者让旧项目继续留在专门的虚拟环境里。分环境管理虽然麻烦,但能避免大量隐性 bug。
2.4 环境验证工具的路径变化
老代码里常用的环境自检工具:
from gym.utils.env_checker import check_env check_env(env)迁移后要改成:
from gymnasium.utils.env_checker import check_env check_env(env)check_env 在 Gymnasium 里会做更严格的一致性检查,例如检查 reset 返回结构、step 返回结构、空间类型匹配等。迁移后跑一次 check_env,能快速暴露旧环境类的兼容性问题。我实际用下来的感受是,这一步比省掉之后的排错成本低很多,强烈建议当成迁移标准动作。
3. 环境类与训练循环的适配
3.1 make 与环境创建方式
最基础的环境创建方式改动不大:
# 修改前 env = gym.make("CartPole-v1") # 修改后 env = gymnasium.make("CartPole-v1")但 Gymnasium 更推荐在创建时显式传入 render_mode:
env = gymnasium.make("CartPole-v1", render_mode="human")这背后有个设计意图:把渲染模式视为环境构造参数的一部分,而不是调用期临时指定。老代码里常见的 env.render(mode="human") 用法仍然能跑,但新的自定义环境规范里,render() 方法不再接收 mode 参数,渲染方式在init时确定。如果你想让自己的环境同时给别人用,最好遵循这个约定。
创建环境时还有个细节:make 方法传入的 kwargs 会传给环境的构造函数。因此自定义环境如果支持某些配置项,比如 max_steps、观察尺寸、难度等级,都要在构造函数里用参数接住。老代码里常见的做法是创建后再修改环境属性进行配置,这在 Gymnasium 里依然可行,但不太推荐。
3.2 自定义环境类怎么改
几乎所有自定义环境类都要调整。先看一个典型的迁移对照。修改之前,旧代码通常是:
import gym from gym import spaces class MyEnv(gym.Env): metadata = {"render.modes": ["human"]} def reset(self): self.state = self.np_random.integers(0, 10) return self.state def step(self, action): obs = self.state reward = 1.0 done = False info = {} return obs, reward, done, info修改后:
import gymnasium as gym from gymnasium import spaces class MyEnv(gym.Env): metadata = {"render_modes": ["human"]} def __init__(self, render_mode=None): super().__init__() self.observation_space = spaces.Box(low=0, high=10, shape=(1,), dtype=float) self.action_space = spaces.Discrete(2) self.render_mode = render_mode def reset(self, *, seed=None, options=None): super().reset(seed=seed) # 自动处理 self.np_random self.state = self.np_random.integers(0, 10) info = {} return self.state, info def step(self, action): obs = self.state reward = 1.0 terminated = False truncated = False info = {} return obs, reward, terminated, truncated, info这里面有几个关键点必须讲清楚。
第一,reset 的签名要改成 reset(self, *, seed=None, options=None)。seed 必须作为关键字参数接收,并调用 super().reset(seed=seed),这样 Env 基类才会自动初始化 self.np_random。如果跳过这步,你会发现自己手动创建的随机数生成器没有正确跟随种子变化,复现实验时很不稳定。
第二,reset 必须返回 (obs, info)。info 可以是一个空字典,但返回结构不能省。Gymnasium 的检验机制会检查这一步,一旦少了 info,训练主循环会解构错误。
第三,step 必须返回 5 元组。这里最容易犯的错误是写成 obs, reward, truncated, terminated, info 之类的顺序。正确顺序是 obs, reward, terminated, truncated, info。顺序写错的情况下,训练循环看起来不会崩,但终止标志判断就乱了,最后训练出来的策略也会很怪。
第四,metadata 里键名从 "render.modes" 变成了 "render_modes"。这个键名变化在渲染功能里影响最直接,如果你的环境是给图像渲染用的,迁移时尤其要注意。
3.3 训练循环中的 reset 和 step 改法
训练循环的改动点在 reset 和 step 两个位置。先说 reset,旧代码普遍是这样:
obs = env.reset()迁移后应改为:
obs, info = env.reset()再看 step,如果代码里原来用 done 作为回合结束标志,迁移后需要把逻辑拆开,或者至少用 terminated 和 truncated 两个值分别处理。
# 修改前 obs, reward, done, info = env.step(action) if done: obs = env.reset() # 修改后 obs, reward, terminated, truncated, info = env.step(action) if terminated or truncated: obs, info = env.reset()如果你用的是现成的算法库,比如 Stable-Baselines3,要注意它的版本是否已经支持 Gymnasium。较新版本的 SB3 已经支持 gymnasium.make 出来的环境,但旧版本仍按 gym 老接口运行。此时最简单的方案是升级 SB3,或者在安装时保证“训练库依赖的 gym 版本”和“你写的环境代码”保持一致。
3.4 种子的设置方式变了
旧式代码经常这样设置随机种子:
env.seed(42) env.action_space.seed(42)这个 env.seed() 方法在 Gymnasium 里已经移除,统一改为在 reset 时传入种子:
obs, info = env.reset(seed=42)如果想在不同 rollout 间延续某个随机序列,需要在 reset 后不再手动调用 seed。Gymnasium 推荐的思路是:在训练开始时调用一次 env.reset(seed=SEED),之后每次 reset 如果不传 seed,内部 RNG 会按之前的随机序列继续推进。
如果需要复现实验,你还可以在创建环境后使用 gymnasium.utils.seeding 模块来手动构造 np_random,再传给自定义环境。但常规场景下,直接在 reset(seed=...) 中传入种子足够了。
3.5 渲染调用方式调整
渲染相关的改动容易被忽略。原来很多代码写成:
env = gym.make("CartPole-v1") env.render(mode="human")新写法是创建环境时指定渲染模式:
env = gymnasium.make("CartPole-v1", render_mode="human") env.reset() env.render()如果你的代码里有很多 env.render(mode="rgb_array") 的调用,建议统一改成创建环境时传 render_mode="rgb_array",然后直接 env.render()。这一改动的意义在于:render_mode 成为环境构造参数的一部分,逻辑更清晰,也便于在训练时用 rgb_array 做录像,在评估时用 human。
4. Wrapper、Space、Registry 等其他改动点
4.1 Wrapper 类从哪导入
老代码可能这样用:
from gym.wrappers import TimeLimit, FrameStack, RecordVideo env = gym.make("CartPole-v1") env = TimeLimit(env, max_episode_steps=500)Gymnasium 对应改成:
from gymnasium.wrappers import TimeLimit, FrameStack, RecordVideo env = gymnasium.make("CartPole-v1") env = TimeLimit(env, max_episode_steps=500)如果你在自定义代码里自己实现了继承自 gym.Wrapper 的包装器,也需要把基类改成 gymnasium.Wrapper。这一步不会报特别诡异的错误,但如果旧包装器和新环境混用,很可能出现返回值结构不匹配的问题,比如包装器内部把 reset 的结果只解构成一个值,然后在外面再取 info 就会报错。
4.2 spaces 相关改动
gym.spaces 和 gymnasium.spaces 的类基本相同,Box、Discrete、Tuple、Dict 等命名一致。迁移时把 import 来源改掉即可。不过有些细节值得注意:新版里空间对象的 dtype 检查更严格,Box 构造时如果不传 dtype,某些情况下会被警告或按默认 float32 处理。如果旧代码写的是:
from gym import spaces self.observation_space = spaces.Box(low=0, high=255, shape=(84, 84, 3))最好显式补上 dtype:
from gymnasium import spaces self.observation_space = spaces.Box(low=0, high=255, shape=(84, 84, 3), dtype=int)否则在后面处理图像像素时可能遇到类型断言问题,尤其是配合神经网络输入时,会有一堆 float 和 int 的兼容性小毛病。
4.3 注册表和 EnvSpec
如果你用过 gym.register 或 gym.envs.registration 来注册自定义环境,现在要改成 gymnasium.register。老代码像是:
from gym.envs.registration import register register(id="MyEnv-v0", entry_point="my_module:MyEnv")新代码:
from gymnasium.envs.registration import register register(id="MyEnv-v0", entry_point="my_module:MyEnv")Gymnasium 还引入了 EnvSpec 的概念,注册表里每个环境都对应一份规格信息,包含了环境的 id、entry_point、reward_threshold、kwargs 等信息。当你通过 gymnasium.make("MyEnv-v0") 创建环境时,如果找不到对应注册项,会看到比老 gym 更明确的错误提示。迁移自定义环境注册时,顺手在 register 里补上 reward_threshold 等参数,有助于后续做 benchmark。
4.4 版本字符串与检查工具
如果你的代码里会读取 gym.version来做版本分支判断,迁移后需要改成读 gymnasium.version。另外,环境有效性检查工具也变了:
# 修改前 from gym.utils.env_checker import check_env check_env(env) # 修改后 from gymnasium.utils.env_checker import check_env check_env(env)check_env 在 Gymnasium 里会做更严格的一致性检查,例如检查 reset 返回值结构、step 返回值结构、空间类型匹配等。迁移后跑一次 check_env,能快速暴露旧环境类的兼容性问题。
4.5 向量环境与并行采样
如果项目用了向量环境,导入路径也要换:
# 修改前 from gym.vector import SyncVectorEnv, AsyncVectorEnv # 修改后 from gymnasium.vector import SyncVectorEnv, AsyncVectorEnv此外,向量环境的 reset 返回值在 Gymnasium 中是 (obs, info),其中 obs 的形状是第一维为 num_envs 的批量观测,info 是包含各环境独立信息的字典。这个结构变化同样会影响 batch 采样代码。迁移时建议写一个小函数做统一封装,把批量 reset 和 step 的结果整理成算法需要的格式,避免在整套代码里到处修改。
5. 常见报错与排查清单
5.1 ModuleNotFoundError 与安装问题
出现 No module named 'gymnasium' 时,直接安装即可。但要注意运行环境和安装环境是否一致。很多项目是在 Docker 里跑训练,代码却在宿主机上装包,这种错位不排查清楚会一直抓狂。
类似地,如果报错说找不到某个环境 id,比如 gymnasium.make('CartPole-v1') 失败,多半是没装对应环境套件,先确认 pip list 里有没有 gymnasium[classic-control]。
5.2 reset 相关报错
TypeError: reset() got an unexpected keyword argument 'seed' 是迁移自定义环境时的高频报错。原因很简单:旧环境类的 reset 方法不接收 seed 参数。解法是改用 reset(self, *, seed=None, options=None),并在方法内部调用 super().reset(seed=seed)。
还有个常见提示是 reset() 应该返回两个值。这是 Gymnasium 在抛出检查警告,说明你的 reset 只返回了 obs。把返回结构改成 (obs, info)。
5.3 step 返回值不匹配
Python 报错 ValueError: too many values to unpack (expected 4),对应的是你的代码还在用 4 元组接 step 返回值。快速修改方案是:
obs, reward, terminated, truncated, info = env.step(action) done = terminated or truncated如果第三方算法库内部还在用旧接口,你会看到它在调用 env.step 时返回结构不匹配。此时不要试图在业务代码里兜底,应该直接升级该库到兼容 Gymnasium 的版本。
5.4 渲染与显示问题
迁移后常见的一个坑是 env.render(mode="human") 在无头环境下报错。因为 Gymnasium 在构造环境时就确定渲染模式,如果创建环境时没有传 render_mode="human",后续渲染就没法正确触发。在服务器上做实验时,我通常会结合环境变量配置图形渲染方式,避免直接依赖本机显示窗口。
5.5 gym 与 gymnasium 混装的隐性冲突
最麻烦的问题不是单一报错,而是两种包共存带来的混乱。具体表现很多,例如 gym.make 能创建自定义环境而 gymnasium.make 却找不到;或者同一个环境在两个体系里注册信息不一致。我见过有项目两套环境 ID 混着用,在训练代码里创建 gymnasium 环境,在评估脚本里又去 import 老 gym,结果跑出来的结果毫无规律。
排查方法很简单:检查 pip list 里是否同时存在 gym 和 gymnasium;再全局搜索代码里是否有残留的 import gym。一旦发现混装,立即统一依赖,尽量不要靠兼容写法蒙混过关。
6. 迁移检查清单速查
把这次迁移的核心改动点整理成一份清单,可以让实操时少走弯路。我按修改优先级排序:
| 位置 | 修改前 | 修改后 |
|---|---|---|
| 安装依赖 | pip install gym | pip install gymnasium[classic-control] |
| import 主模块 | import gym | import gymnasium as gym |
| import spaces | from gym import spaces | from gymnasium import spaces |
| import wrappers | from gym.wrappers import ... | from gymnasium.wrappers import ... |
| 创建环境 | gym.make("CartPole-v1") | gymnasium.make("CartPole-v1", render_mode=...) |
| reset 调用 | obs = env.reset() | obs, info = env.reset(seed=42) |
| step 调用 | obs, reward, done, info = env.step(a) | obs, reward, terminated, truncated, info = env.step(a) |
| 回合结束判断 | if done: | if terminated or truncated: |
| 设置种子 | env.seed(42) | env.reset(seed=42) |
| 渲染 | env.render(mode="human") | make 时传 render_mode |
| 注册表 | gym.envs.registration.register | gymnasium.envs.registration.register |
| 环境检查 | gym.utils.env_checker | gymnasium.utils.env_checker |
| vector 环境 | gym.vector | gymnasium.vector |
| 版本号 | gym.version | gymnasium.version |
实际操作时,我建议按“安装依赖-import 替换-环境类适配-训练循环适配-其他模块适配”的顺序来做。每一步做完都可以先跑一下环境自检,确认没有报错再进入下一步。
注意:迁移做完后不要急着训练完整实验。先跑一个几百步的 rollout 小脚本,打印每一步的 obs、reward、terminated、truncated、info,肉眼确认这些值符合预期,再进入正式训练。这个习惯能避免很多迁移残留问题带来的时间浪费。
我个人体会最深的一次迁移,是一个自定义多智能体环境。那个项目里把 agent 的回合结束条件写在 step 内部,同时又把整局游戏的最大步数判断也混在同一个 done 标志里。迁移到 Gymnasium 后,我花了不少时间把 terminated 和 truncated 拆开,才在训练指标上看出明显区别。
后面我做任何 RL 项目,都会默认按 Gymnasium 的规范来写环境接口,不再为老 gym 留兼容层。如果你手头还有旧代码,建议早点迁移,越晚改动面积越大。迁移过程本身并不神秘,只要照着接口返回结构逐项改,跑一轮 check_env 校验,基本上就能平稳落地。