如果有人问我,强化学习入门最容易被低估的环节是什么,我的答案大概率是环境接口。你看论文的时候,注意力几乎都被算法吸走;真正动手跑代码之后,才会发现观察空间、动作空间、奖励信号这些边界问题才是消耗时间的大头,而 Gymnasium(原 Gym 库)就是把环境这层边界统一起来的标准层。这篇文章会围绕 Gymnasium 的接口设计、常见环境使用、自定义环境、以及调试技巧做一次完整梳理,附上可以直接复制运行的示例程序。无论你是刚开始接触强化学习,想搞明白环境层到底在做什么,还是写过一些算法但被新版 API 变动坑过,读这篇文章应该都会有用。
1. 环境库为什么反复改名:Gymnasium 的前世今生
1.1 强化学习为什么需要一张"标准插座"
先从一个大实话说起:强化学习算法本身是"环境无关"的。你写一个 DQN,它可以打游戏、也可以控制倒立摆,算法内部真正接触到的信息其实就四样——observation、action、reward,还有回合是否结束。只要这四样东西的格式约定好,算法代码基本不用动。但如果没有统一的约定,每个环境就要配一套自定义接口:这个环境返回字典,那个环境返回一维数组,动作映射五花八门,这会让你在写算法之前先花大量时间做"翻译"。
Gymnasium 做的就是这个标准化的活。它定义了一个Env基类,规定所有强化学习环境都实现几个固定的方法;同时提供Box、Discrete这类空间类型,用来描述观察和动作的合法范围。你可以把它想成强化学习世界的"标准插座"——算法是电器,环境是电器插头,插座规范统一了,随便哪个电器插上去都能通电。有了这一层,学术论文里的实验代码才能互相复现、互相比较,而不是每个团队各写一套私有框架。
1.2 从 gym 到 Gymnasium,改名背后是维护接力
最早的 gym 库大概是 2016 年前后出现的,准确时间不用太纠结,你只需要知道它在强化学习社区里几乎是"教科书级"的存在。大量公开项目、课程、论文代码都依赖它,一时间import gym成了固定写法。不过后续的事情很多人不清楚:这个老项目逐渐停止活跃维护,新需求和新环境接入的速度慢了下来。于是社区有人把它 fork 出来,重新组织维护,并改名为 Gymnasium,继续以更快节奏发布新版本。
现在 PyPI 上gym和gymnasium是两个完全独立的包,名字虽然只有一字之差,但版本线、API 风格都有区别。我见过不止一个人照着老教程写完代码,却先装了gymnasium,然后代码里依然写import gym,结果直接ModuleNotFoundError。所以如果你准备用新库,请记住:安装命令是pip install gymnasium,导入名也是gymnasium,不要再沿用import gym。除非你有特殊原因必须维护旧工程,否则新项目一律推荐用 Gymnasium。
1.3 迁移到 Gymnasium 时真正要紧的 API 差异
从我实际迁移的经验看,Gymnasium 对旧代码最大的"破坏"其实集中在几个点,下面这个表基本能覆盖 90% 的改动场景:
| 使用场景 | 旧版 gym | Gymnasium |
|---|---|---|
| 安装包名 | pip install gym | pip install gymnasium |
reset()返回值 | obs = env.reset() | obs, info = env.reset() |
step()返回值 | obs, reward, done, info | obs, reward, terminated, truncated, info |
| 设置随机种子 | env.seed(42) | obs, info = env.reset(seed=42) |
| 渲染启动 | env.render(mode="human") | 创建时传render_mode="human" |
reset()多返回一个info字典,看起来不痛不痒,但它给了环境一个向算法传递额外信息的通道,比如当前距离目标的曼哈顿距离、剩余步数、难度等级等等,后面做算法分析时会用得到。step()把原来的done拆成了terminated和truncated,这是整个 API 变动里最有意义的一处,我下一节展开细讲。seed()方法也被移除了,改成在reset()里传seed参数,好处是随机状态可以按回合精确控制,而不是污染全局的 numpy 随机数种子。
2. 把 Env 的四个核心方法啃透:reset、step、render、close
2.1 观察空间与动作空间:Space 不是花瓶
很多初学者第一次见到env.observation_space的时候,只把它当作一个"查看边界"的属性。实际上Space类是整个接口契约的核心:你定义环境的第一个任务不是写step(),而是想清楚观察和动作分别属于什么数学结构。Gymnasium 提供了几类常用空间,日常 95% 的情况都够用:
import numpy as np from gymnasium import spaces # 连续空间:3 维向量,每个分量都在 [-1, 1] 内,float32 cont_space = spaces.Box(low=-1.0, high=1.0, shape=(3,), dtype=np.float32) # 离散空间:动作编号为 0、1、2、3 disc_space = spaces.Discrete(n=4) # 多离散空间:两个维度,各自取值范围不同 mul_space = spaces.MultiDiscrete([5, 8]) # 组合空间:元组、字典都支持 tuple_space = spaces.Tuple((cont_space, disc_space)) dict_space = spaces.Dict({"obs": cont_space, "target": disc_space})Box描述连续范围,低和高可以传数组,表示每个维度边界不同;Discrete描述编号型动作,比如"向左推""向右推"就是Discrete(2);MultiDiscrete适合多关节、多通道的离散控制;Dict、Tuple则用来做结构化观察,比如同时观察 Agent 坐标和目标坐标。每个 Space 还有两个很实用的方法:sample()在空间内随机采样,正好可以作为随机策略的默认动作;contains()判断一个值是否合法。调试环境的时候用contains()自检一下,能快速发现类型或范围不匹配的问题。
2.2 done 拆成 terminated 和 truncated,到底好在哪
这一处改动我强烈建议所有写强化学习代码的人重视。旧版gym的step()返回一个done,语义是"这一回合结束了",但它把两种完全不同的结束混在了一起:一种是环境里的任务真正到了结局,比如倒立摆跌倒了、小车到达了终点,这种情况在马尔可夫决策过程里叫真正的终止状态;另一种是回合还没结束,只是到了最大步数被系统强行截断,比如让智能体持续不倒,它已经非常稳定,但程序限制最多跑 500 步,这时也会给你done=True。
两者对算法的影响完全不同。真正的终止状态意味着状态转移中止,时序差分目标不能再用下一步的 value 去续算;而截断只是时间预算用完,下一步观察其实仍然存在,训练时往往应该继续按正常状态转移处理。Gymnasium 把step()的返回值统一改成obs, reward, terminated, truncated, info之后,算法就能一眼看出到底属于哪种情况。许多框架如常见的基础强化学习库在处理done时,内部会做terminated or truncated的合并,但你写自定义训练循环时千万不要图省事直接合并,否则会把两种信息丢失。
2.3 reset 的 seed 和 options:不只是重置状态
reset()在 Gymnasium 里的签名是reset(seed=None, options=None),返回(observation, info)。这里有两个容易忽略的点。第一,种子不再通过全局方法设置,而是每次重置时通过seed参数传入,推荐写法是obs, info = env.reset(seed=42)。如果你在自己写的自定义环境里调用了super().reset(seed=seed),Gymnasium 会帮你准备好一个独立的随机数生成器self.np_random,这样环境的随机性和外部全局的 numpy 随机状态互不干扰,跑实验时能精确复现。第二,options是个字典,用来在重置时注入动态配置,比如让环境从某个指定起点开始、调整障碍物密度、设定难度级别。我的经验是,凡是需要做"环境参数扫描"的实验,options比修改类属性优雅得多。
render()和close()相对简单。render()根据创建环境时传入的render_mode返回不同形式的结果:"human"模式会弹出一个窗口,适合本地看效果;"rgb_array"模式返回当前画面的 numpy 数组,适合做图像观察或者录屏调试。close()用来释放窗口、渲染资源,在长时间跑实验时尤其重要,否则可能会积累出一堆没关掉的图形进程。
3. 从装环境到跑通 CartPole:一条可见的完整链路
3.1 安装选型:装错包是第一个坑
实战第一步。安装命令看起来很简单,但有几个细节值得注意。如果你想直接上手玩 CartPole 这类经典控制环境,建议至少安装带完整"附加依赖"的版本,我这里给出两个常用命令:
# 最小安装 pip install gymnasium # 推荐:包含 classic-control 系列环境(CartPole、MountainCar 等) pip install "gymnasium[classic-control]"经典控制这一组附加包其实依赖pygame来做渲染和物理示例,所以你在服务器环境里别忘了额外确认一下依赖是否装全。更通用的做法是直接装gymnasium[all],里面把多个常用环境类别需要的依赖都拉进来,适合不想逐个记 extras 的人;代价是安装体积大一些,而且个别环境的系统依赖在 Linux 服务器上可能要手动补。装完之后可以用一行命令验证版本:
python -c "import gymnasium; print(gymnasium.__version__)"如果打印出版本号,说明导入正常。另外提一句,别在没看完 docs 的情况下盲目装最新版本,Gymnasium 迭代挺快,跟不上小版本的代码偶尔会踩新 API 的坑,实际项目里建议把版本号锁住。
3.2 随机策略先跑一遍循环
环境装好了,先不要急着上算法。用随机策略把循环跑通,你才能确认接口调用、返回值、终止条件这些环节全部正常。这段代码是我最常贴给新人的"环境冒烟测试":
import gymnasium as gym env = gym.make("CartPole-v1", render_mode="rgb_array") obs, info = env.reset(seed=42) for step in range(500): action = env.action_space.sample() # 随机策略 obs, reward, terminated, truncated, info = env.step(action) if terminated or truncated: break print(f"回合在第 {step + 1} 步结束,总奖励约 {step + 1}") env.close()这段代码里最值得琢磨的是for step in range(500)。为什么一定要设一个步数上限?因为如果环境永远不会终止,或者你处理终止条件时忘了break,这会变成一个死循环。CartPole 本身自带 500 步的时间上限,所以这里直接取 500 作为兜底。如果是随机策略,这个回合大概率在第几步就结束?我实测通常也就是 10 到 30 步之间,因为杆子随机推力下会很快跌出角度限制。这个数字本身就是很直观的环境体检指标——如果一个环境为什么一直在 500 步退出,那可能说明策略已经很强,也可能说明终止条件没写对。
3.3 渲染与像素帧:脱离显示器也能可视化
render_mode需要你在创建环境时显式指定,这跟旧版gym里临时调用env.render(mode=...)的习惯很不一样。我在无显示器的远程服务器上跑实验时,一般用render_mode="rgb_array",然后通过env.render()拿到一帧图像数组,保存成图片序列再合成视频。示例:
import gymnasium as gym import numpy as np frames = [] env = gym.make("CartPole-v1", render_mode="rgb_array") obs, _ = env.reset(seed=0) for _ in range(20): obs, reward, terminated, truncated, info = env.step(env.action_space.sample()) frames.append(env.render()) if terminated or truncated: break env.close() # frames 是若干张 HxWxC 的 numpy 数组,可以逐帧保存为图片 print(np.array(frames).shape)需要注意,某些环境在render_mode="rgb_array"下返回的帧尺寸并不固定,当你需要把帧堆叠成一个批次去做神经网络输入时,通常要统一尺寸和通道格式。一个常见的做法是用gymnasium.wrappers里的ResizeObservation和GrayScaleObservation做预处理,后面说到 Wrapper 时我会提。
4. 手写一个自定义环境:SimpleGridWorld(完整可运行)
4.1 环境骨架:先定义空间,再写逻辑
很多时候内置环境不够用,你需要为具体任务写专属环境。比如我有一个简单的网格世界任务——Agent 从左上角出发,目标是走到右下角,每步可选上下左右四个方向。这个任务看起来很简单,但麻雀虽小五脏俱全,足够演示自定义环境的完整流程。第一步是继承gymnasium.Env,同时把观察空间、动作空间、渲染元数据这些"契约"定义清楚:
import numpy as np import gymnasium as gym from gymnasium import spaces class SimpleGridWorld(gym.Env): metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 4} def __init__(self, render_mode=None, size=5): super().__init__() self.size = size self.render_mode = render_mode self.observation_space = spaces.Dict( { "agent": spaces.Box(0, size - 1, shape=(2,), dtype=np.int32), "target": spaces.Box(0, size - 1, shape=(2,), dtype=np.int32), } ) self.action_space = spaces.Discrete(4) self._action_to_direction = { 0: np.array([1, 0]), # 右 1: np.array([0, 1]), # 下 2: np.array([-1, 0]), # 左 3: np.array([0, -1]), # 上 }观察空间用了Dict:Agent 坐标和目标坐标各占一个子空间。这样比把两个坐标拼成一维数组更清晰,算法处理时也能看出语义。动作空间是Discrete(4),对应上下左右。metadata里声明渲染模式,这个不是摆设,把render_mode="human"传进来时环境才知道要打开窗口。注意我用np.int32,因为坐标是整数,如果用默认的 float32,很多检查工具会提示观察类型不匹配。
4.2 核心方法实现:reset 和 step 的状态管理
自定义环境里最需要写好的两个方法是reset()和step(),它们管住了整个回合的状态流转。我给出完整实现,并逐段解释:
def _get_obs(self): return {"agent": self._agent_location, "target": self._target_location} def _get_info(self): return { "distance": int( np.linalg.norm( self._agent_location - self._target_location, ord=1 ) ) } def reset(self, seed=None, options=None): super().reset(seed=seed) self._agent_location = np.array([0, 0], dtype=np.int32) self._target_location = np.array( [self.size - 1, self.size - 1], dtype=np.int32 ) return self._get_obs(), self._get_info() def step(self, action): direction = self._action_to_direction[action] self._agent_location = np.clip( self._agent_location + direction, 0, self.size - 1, ).astype(np.int32) terminated = bool( np.array_equal(self._agent_location, self._target_location) ) reward = 1.0 if terminated else 0.0 return self._get_obs(), reward, terminated, False, self._get_info() def render(self): if self.render_mode is None: return None grid = np.zeros((self.size, self.size), dtype=np.uint8) grid[tuple(self._agent_location)] = 1 grid[tuple(self._target_location)] = 2 if self.render_mode == "human": print(grid) return None elif self.render_mode == "rgb_array": return grid return None第一行super().reset(seed=seed)非常重要,它让 Gymnasium 为环境实例创建一个独立的随机数发生器self.np_random。如果你在环境内部还用随机数(比如随机生成障碍物),后续都从self.np_random里取,这样reset(seed=42)才能真正保证复现。step()里我用np.clip把坐标限制在网格内,这样做的好处是环境永远安全——不会因为一个非法动作越界而崩溃。terminated用np.array_equal判断是否到达目标,奖励按"到达给 1,否则 0"的稀疏方式设置。False放在第四个位置,表示这个环境没有时间截断,是否截断完全由外部包装器决定。
4.3 注册环境并用 gym.make 加载
环境写好后,有两种使用方式。一种是直接实例化:
env = SimpleGridWorld(render_mode="rgb_array") obs, info = env.reset(seed=0)另一种是通过注册表统一管理,这也是后续跟算法库对接更推荐的方式。注册时用id命名环境,命名规范推荐名字-版本,并且entry_point指向"模块路径:类名"形式的字符串:
from gymnasium.envs.registration import register register( id="SimpleGridWorld-v0", entry_point="simple_grid_world:SimpleGridWorld", ) env2 = gym.make("SimpleGridWorld-v0")如果你的环境写在某个模块文件里,entry_point就写"模块文件名:SimpleGridWorld",Gymnasium 会在调用时自动导入对应模块。注册的好处是后续可以用gym.make统一加载,还能配合render_mode、max_episode_steps这些参数做统一配置。不过要注意,同一个进程里重复注册同一个 id 会报错,调试时可以先查一下gym.envs.registry或者直接在使用后删除注册项。
每个环境在团队里跑实验时,我都会先用check_env做一次体检,它会把新手最容易犯的接口错误直接暴露出来。这个方法在后面调试章节我会再详细展开。
5. 给环境套壳:Wrapper 的权限与 Vector 并行环境
5.1 三种 Wrapper 的职责分工
内置环境再强大,训练时也经常需要改动接口、奖励或观察。Gymnasium 不建议你直接改环境源码,而是用 Wrapper 包一层。
ObservationWrapper:只重写observation(),可以在这里做灰度化、缩放、归一化,把复杂观察变成算法想要的格式。ActionWrapper:只重写action(),负责修改最终传入环境的动作,比如把连续动作裁剪到合法范围。RewardWrapper:只重写reward(),用于改奖励信号,比如平滑、缩放、加历史平均。
这三种 Wrapper 的巧妙之处在于它们只干预自己的环节,其它环节仍然走原来的step(),不会破坏环境自身的逻辑。
比如我之前提到过的图像类观察预处理,Gymnasium 自带GrayScaleObservation和ResizeObservation,用wrappers组合起来非常方便:
from gymnasium.wrappers import GrayScaleObservation, ResizeObservation env = gym.make("PongNoFrameskip-v4", render_mode="rgb_array") env = GrayScaleObservation(env) env = ResizeObservation(env, shape=(84, 84))这样从env拿到的每一步观察就已经是 84x84 的单通道灰度图,算法侧完全不用管原始画面长什么样。
5.2 写一个自定义 ObservationWrapper:把 Dict 展平
回到刚才那个网格世界环境。它的观察是 Dict 结构,很多深度学习框架希望输入是一维向量。在不改环境代码的前提下,可以写一个观察包装器来转换:
import numpy as np from gymnasium import ObservationWrapper class FlattenObservationWrapper(ObservationWrapper): def __init__(self, env): super().__init__(env) def observation(self, observation): return np.concatenate( [ observation["agent"].astype(np.float32), observation["target"].astype(np.float32), ] )使用方式很简单:
base_env = SimpleGridWorld() wrapped_env = FlattenObservationWrapper(base_env) obs, _ = wrapped_env.reset(seed=1) print(obs) # 4 个 float32 组成的一维向量注意ObservationWrapper.observation()被调用时,输入就是环境原始观察,返回值会成为训练循环里所见的新观察。这种层层包壳的设计,让环境的"语义"和"预处理"解耦。我在实际项目里非常喜欢这种做法:核心环境保持最干净的状态,所有预处理、奖励塑造全部通过 Wrapper 叠加,实验时只要调整 Wrapper 组合就能快速对比不同配置。
5.3 Vector 并行环境:多进程采样提速
训练强化学习的瓶颈往往在环境采样。单环境串行 step 太慢,Gymnasium 的 Vector 环境就是为了解决这个问题:一次调用同时对多个环境副本执行动作,返回批量结果。最常用的创建方式是gym.vector.make:
import gymnasium as gym envs = gym.vector.make( "CartPole-v1", num_envs=4, asynchronous=True, ) obs, info = envs.reset(seed=42) actions = envs.action_space.sample() # 形状 (4,) obs, rewards, terminateds, truncateds, infos = envs.step(actions)这里的obs形状是(num_envs, *单环境观察形状),rewards是一维数组,terminateds、truncateds也都是布尔数组。asynchronous=True时,采样在独立进程中并行执行,数据汇总回来是同一个接口;同步模式asynchronous=False则在同一进程内顺序执行,适合简单调试。
使用 Vector 环境时有个容易踩的坑:自动包装的 VectorEnv 自带TimeLimit,每个子环境有自己的时间计数,所以不要只看truncateds数组忽略具体的子环境编号。批量 reset 时,所有子环境一起重置;如果你想让某个子环境提前重置,只能自己维护掩码。这个接口在实现 PPO 这类算法时非常有用,但一开始不需要过度深入,知道它存在、会用即可。
6. 用 Q-learning 驱动 CartPole:环境接口和算法怎么对接
6.1 为什么拿 CartPole 做算法演示
讲了这么多环境接口,是时候让算法真正跑一下了。CartPole 行动空间只有两个动作,观察空间是四维连续值,非常适合用最简单的 Q-learning 配合状态离散化来训练。我不准备把 Q-learning 本身讲成一篇算法论文,重点想展示的是:一套标准的环境接口,切换到具体算法时到底要如何衔接。
这个例子的精妙之处在于,它几乎只用到了reset、step、action_space、observation_space这几个接口,但已经足以训练出一个表现不错的策略。你不需要自己维护环境状态,环境内部自动推进;也不需要手工复位,reset()帮你回到回合起点。这正好说明环境标准化对算法侧有多么友好。
6.2 离散化与环境接口的完整对接代码
连续的观察空间不能直接塞进 Q 表,所以第一步是把每个维度划分成若干区间,再把连续值映射成区间编号。下面是一份可以直接运行的模块化代码:
import gymnasium as gym import numpy as np def make_bins(low, high, num_bins): low = np.maximum(low, [-2.0, -2.0, -0.5, -2.0]) high = np.minimum(high, [2.0, 2.0, 0.5, 2.0]) return [ np.linspace(low[i], high[i], num_bins + 1)[1:-1] for i in range(len(low)) ] def discretize(obs, bins): return tuple( int(np.clip(np.digitize(obs[i], bins[i]), 0, len(bins[i]))) for i in range(len(obs)) ) env = gym.make("CartPole-v1") low, high = env.observation_space.low, env.observation_space.high bins = make_bins(low, high, num_bins=10) q_table = np.zeros((11, 11, 11, 11, env.action_space.n)) alpha, gamma, epsilon = 0.1, 0.99, 0.1 rng = np.random.default_rng(42) for episode in range(2000): obs, _ = env.reset(seed=episode) state = discretize(obs, bins) terminated = truncated = False while not (terminated or truncated): if rng.random() < epsilon: action = env.action_space.sample() else: action = int(np.argmax(q_table[state])) next_obs, reward, terminated, truncated, _ = env.step(action) next_state = discretize(next_obs, bins) best_next = np.max(q_table[next_state]) q_table[state][action] += alpha * ( reward + gamma * best_next - q_table[state][action] ) state = next_state env.close()解释几个关键点。make_bins里我先把 CartPole 原始观察范围收敛到合理区间:位置和角度边界原本就比较松,速度两个维度在理论上无界,直接按原始范围切分会导致大部分样本落在极少数桶里。把范围裁剪成[-2, 2]和[-0.5, 0.5]是实践经验。np.digitize把连续值映射成"它在第几个区间",返回值是 0 到num_bins之间的整数,总共num_bins + 1个区间,所以 Q 表第一维大小是 11。discretize里再做一次np.clip,防止边界值映射成越界索引。
6.3 训练里真正值得注意的环境问题
这个例子里,训练循环把terminated or truncated当作回合结束条件,但只要稍加分析就会发现问题:CartPole 默认带 500 步的时间截断。当智能体已经在杆子稳定性上做得很好的时候,它会频繁碰到truncated=True而不是真正的terminated=True。如果在下一次更新时让best_next参与计算,你是把截断状态当成正常状态转移来用的,这对收敛方向会有细微影响。
一个更严谨的写法是:当terminated=True时,TD 目标里的best_next应为 0;当truncated=True时,则可以继续沿用q_table[next_state]的最大值,因为下一个状态仍然有效。这正好呼应了前面讲terminated和truncated拆分的意义。很多人在文档里读过这段差异,但只有自己写到一个训练循环,才会感受到它对算法行为的真实影响。
训练结束后,可以看一下效果:跑 100 个回合,统计平均奖励。随机策略一般只有十几分,Q-learning 训练几千回合后通常能稳定在 200 分上下。数字不是重点,重点是你可以用这个例子验证环境接口的调用链完全正确——如果环境接口有问题,Q 表根本不可能收敛。
7. 踩过这些坑之后,我总结的调试手段
7.1 用 PassiveEnvChecker 给自定义环境做体检
我见过最多的问题是:环境跑起来了,但step返回的奖励类型是 Pythonint而不是 float,或者观察维度类型不一致,或者reset忘了返回info。这些错误不会立刻导致崩溃,却会在算法里变成无声的 bug,非常难定位。Gymnasium 提供了一个很实用的体检工具:
from gymnasium.utils.env_checker import check_env env = SimpleGridWorld(render_mode="rgb_array") check_env(env)check_env会模拟调用reset和step,检查返回值的类型、形状、是否在合法空间内、以及 Wrapper 层的接口是否漏实现。只要有任何不合规,它会以AssertionError或 warnings 的形式明确指出来。我的建议是:每写完一个新环境,先跑一遍check_env再继续开发算法;每接入一个新 Wrapper,也顺手跑一遍,防止包装层把观察或动作格式改坏。
7.2 TimeLimit 截断:别忽略那个 False
自定义环境如果不做处理,truncated永远返回 False。但gym.make("CartPole-v1")创建出来的环境,实际是被一个隐藏的TimeLimitWrapper 包了一层,默认步数上限是 500。这是一个非常重要的设计:环境本身的语义是"不倒就继续",外部 Wrapper 负责给训练过程加上时间预算。
写训练循环时,很多人只判断terminated,然后继续step(),却不知道其实回合早已被强制截断。这导致的直接问题是:在截断状态之后继续step,环境内部可能已经不在新一轮回,训练数据变得毫无意义。正确做法永远是if terminated or truncated: break然后reset。另外,如果你希望改变时间上限,可以创建环境时传入max_episode_steps,或者自定义外层TimeLimit(env, max_episode_steps=100)。我建议在论文复现和实验比较时,把时间上限写进配置文件,因为不同上限会让同一个算法的收敛曲线差异很大。
7.3 seed 复现:环境、算法、预处理器要分开管
最后讲一个经常被忽略的复现问题。强化学习实验有三处随机源:环境内部的随机性(比如随机障碍、随机初始状态)、训练循环里的动作探索随机性、以及数据预处理或网络初始化的随机性。三者最好各自管理。
环境侧推荐在reset(seed=...)时指定种子,而不是调用全局np.random.seed;算法侧可以用np.random.default_rng(42)这样的独立生成器;如果用了自定义 Wrapper,也要确认 Wrapper 内部没有偷偷使用全局随机状态。这样配置的好处是,你能精确控制每一次实验的环境序列,从而判断策略改进究竟是算法贡献还是随机采样偏差。我在实际测试里复现过一个奇怪现象:两次训练代码完全一致,只因为一次少了reset(seed=...),结果平均奖励差出 30%。排查半天,才发现是环境每次随机起点不同造成的。
我自己做强化学习调试时最深的感触是:环境层越早标准化,后面的算法工作越省心。以前我写实验代码总是把环境逻辑和训练循环混在一起,改一个环境参数要翻半天代码,还容易把状态维度弄错;后来强迫自己按 Gymnasium 的 Env 规范把环境、Wrapper、算法训练循环拆开,很多问题反而一眼就能看出来。最后再分享一个小技巧:如果你在训练某个环境时表现忽高忽低,先用check_env体检一下,再排查是不是在truncated=True时漏了重置,这两个动作能挡掉相当大一部分隐性 bug。环境接口虽然看起来只是一堆规范,但它确实是强化学习工程里最容易埋雷、也最值得先打好的地基。