Files
wehub-resource-sync a203934033
Lint test / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:34:58 +08:00

361 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GYM环境训练
GYM 风格的环境训练把"模型 → 环境 → 奖励"这条链路封装成一个抽象接口,让 LLM 像 Agent 一样与环境进行多轮交互,每一步的奖励直接由环境给出,无需再单独写 reward 函数从轨迹里反推。本文先介绍接口,再用一个完整的自定义示例(FrozenLake)说明如何接入训练。
## Gym 接口
GYM 源自 [Gymnasium库](https://github.com/Farama-Foundation/Gymnasium)。在 ms-swift 中我们定义了如下接口:
```python
class Env(ABC):
def __init__(self, env_config):
"""env_config 来自数据集每行的 env_config 列,可承载初始化参数"""
self.env_config = env_config
@abstractmethod
async def reset(self, config: RolloutInferRequest) -> Tuple[str, Dict[str, Any], str]:
"""
Returns:
- observation: 作为首轮 user 消息发送给模型
- info: 调试/日志信息,记录到 completions.jsonl
- system_message: 本条轨迹的 system prompt
"""
pass
@abstractmethod
async def step(self, action: Messages) -> Tuple[str, float, bool, Dict[str, Any]]:
"""
Args:
action: 截止当前的完整对话消息,最后一条即模型最新回复
Returns:
- next_observation: 下一轮 user 消息
- reward: 当前 step 奖励
- done: 轨迹是否结束
- info: 调试/日志信息
"""
pass
@abstractmethod
async def close(self):
"""释放资源"""
pass
```
`reset` 接收到的 `RolloutInferRequest` 包含数据集行的 `messages``data_dict`(额外列,包括 `env_config`)等。完整示例参见 [入参示例](./multi_turn.md#多轮规划器-multiturnscheduler)。
> 如果需要在每轮 rollout 之间额外控制对话历史(例如动态压缩、注入额外提示),推荐直接继承 `MultiTurnScheduler` 并实现 `on_trajectory_start` / `on_turn_end` hook,或重写 `step` / `run` 方法,详见[多轮训练文档](./multi_turn.md#自定义多轮交互逻辑)。
## 启动训练
使用内置的 [gym_scheduler](https://github.com/modelscope/ms-swift/blob/main/swift/rollout/multi_turn.py) 把 env 串到多轮 rollout 中。
`GYMScheduler` 基于通用 hook 协议实现:
- 继承 `MultiTurnScheduler`,无需自定义 `run` 方法
- 实现 `on_trajectory_start`(调用 `env.reset`)和 `on_turn_end`(调用 `env.step`
- 同时适用于 server mode`run()`)和 colocate mode`run_multi_turn()`
用户自定义的 env 通过 `--external_plugins your_plugin.py` 加载,plugin 里执行 `envs['my_env'] = MyEnv` 完成注册(下文 FrozenLake 示例完整演示)。
**Colocate 模式**:
```bash
megatron rlhf \
--rlhf_type grpo \
--vllm_mode colocate \
--external_plugins examples/megatron/grpo/multi_turn/frozen_lake_plugin.py \
--multi_turn_scheduler gym_scheduler \
--gym_env frozen_lake \
--use_gym_env true \
--max_turns 10 \
...
# swift rlhf 同理
```
**Server 模式**
```bash
swift rollout \
--model xxx \
--use_gym_env true \
--external_plugins examples/megatron/grpo/multi_turn/frozen_lake_plugin.py \
--multi_turn_scheduler gym_scheduler \
--gym_env frozen_lake \
--max_turns 10
# trainer 侧需要加 --vllm_server_pass_dataset true,把 env_config 等额外列透传给 rollout 端
megatron rlhf --vllm_mode server --vllm_server_pass_dataset true ...
# or swift rlhf --vllm_mode server --vllm_server_pass_dataset true ...
```
环境选择有两种方式:
- 通过 `--gym_env env_name` 全局指定(同一脚本里所有 prompt 共用一个 env);
- 在每行数据的 `env_config.name` 中指定(适用于多环境混合场景,每条数据可指向不同 env,会覆盖 `--gym_env`)。
## 示例:从零写一个 FrozenLake 环境
<img src="https://gymnasium.farama.org/_images/frozen_lake.gif" width="220" alt="FrozenLake 环境示意图(来源:Gymnasium 官方文档)" />
[FrozenLake](https://gymnasium.farama.org/environments/toy_text/frozen_lake/) 是 OpenAI Gym 中的经典任务:智能体从起点出发,需要穿过一片冰湖到达终点,途中要避开冰窟。原始环境如上图所示。下面以纯文本版本(把上图网格直接渲染成 ASCII 字符)为例。
以下完整代码参考完整代码:[frozen_lake_plugin](https://github.com/modelscope/ms-swift/blob/main/examples/megatron/grpo/multi_turn/frozen_lake_plugin.py)。
**1. 定义 Env**
每条数据派生一张随机 4x4 地图(随机洞 + 随机 S/G 位置,BFS 校验保证可解)。单元含义:`S` 起点 / `G` 终点 / `H` 冰窟(踩到=失败)/ `F` 安全冰面 / `P` 玩家当前位置。
```python
class FrozenLakeEnv(Env):
def __init__(self, env_config):
super().__init__(env_config)
self.size = int(env_config.get('size', 4))
self.p = float(env_config.get('p', 0.8))
seed = env_config.get('seed')
self.seed = int(seed) if seed is not None else None
async def reset(self, config: RolloutInferRequest):
self.grid = generate_random_map(size=self.size, p=self.p, seed=self.seed)
...
return observation, {'seed': self.seed}, SYSTEM_PROMPT
async def step(self, action: Messages):
move = _parse_action(action[-1]['content']) # <action>up|down|left|right</action>
# 推进一格、判断 G / H;外层 max_turns 由 scheduler 兜底
if cell == 'G': return obs, 1.0, True, {'status': 'goal'}
if cell == 'H': return obs, 0.0, True, {'status': 'hole'}
...
```
**2. GYMScheduler 的 hook 实现**
框架内置的 `GYMScheduler` 基于多轮 hook 完成了控制逻辑:
```python
class GYMScheduler(MultiTurnScheduler):
def on_trajectory_start(self, requests):
# 为每个请求创建 env,调用 env.reset,注入初始 observation
for req in requests:
env = self._create_env(req.data_dict.get('env_config', {}))
observation, info, system_message = env.reset(req)
req.messages = [system_msg, user_msg(observation)]
self._envs[req.uuid] = env
def on_turn_end(self, req, response_choice, current_turn):
# 调用 env.step,累积 reward,返回 done + rollout_infos
next_obs, reward, done, info = env.step(deepcopy(req.messages))
self._total_rewards[req.uuid] += reward
return {
'done': done,
'rollout_infos': {
'total_reward': self._total_rewards[req.uuid],
'step_rewards': [...],
}
}
def step(self, req, response_choice, current_turn):
# 注入下一帧 observation 到 user message
if self._pending_obs.get(req.uuid):
req.messages.append({'role': 'user', 'content': next_obs})
return {'infer_request': req}
```
用户只需实现 Env 接口,无需关心多轮控制细节。
**3. 注册**
将 env 类挂到 swift 的 `envs` 注册表里。`--external_plugins` 在训练启动时会 import 该文件,注册随之生效:
```python
# examples/megatron/grpo/multi_turn/frozen_lake_plugin.py
from swift.rollout.gym_env import Env, envs
class FrozenLakeEnv(Env):
...
envs['frozen_lake'] = FrozenLakeEnv
```
**4. 准备数据集**
数据集在这里仅作占位符处理,数据构造由环境生成,和 `env_config.seed`来控制地图生成的随机性:
```json
{"messages":[{"role":"user","content":"<placeholder>"}],"env_config":{"seed":0}}
{"messages":[{"role":"user","content":"<placeholder>"}],"env_config":{"seed":1}}
...
{"messages":[{"role":"user","content":"<placeholder>"}],"env_config":{"seed":127}}
```
**5. (可选)叠加自定义 reward**
设置 `--use_gym_env true` 后,env 给出的 `total_reward` 会自动作为一路奖励参与训练,无需再写 reward 函数。如果想在此之外再叠加自定义信号(如格式/长度等),通过 `--reward_funcs` 传入即可,gym 奖励会作为额外一列与 reward_funcs 拼在一起,由 `--reward_weights` 统一加权。例如同时启用一个格式校验 reward:
```bash
megatron rlhf ... --use_gym_env true --reward_funcs format --reward_weights 0.2 1.0
# reward_weights 末位对应 gym 的 total_reward
```
**6. 训练**
运行脚本参考:[`examples/megatron/grpo/multi_turn/frozen_lake.sh`](https://github.com/modelscope/ms-swift/blob/main/examples/megatron/grpo/multi_turn/frozen_lake.sh)
## OpenEnv 环境训练
[OpenEnv](https://github.com/huggingface/openenv) 是 HuggingFace 开源的 Agentic RL 环境框架,通过 WebSocket 与环境服务器交互。与上文 FrozenLake 的本地 `Env` 接口不同,OpenEnv 将环境逻辑放在独立的服务进程中,swift 通过 `OpenEnvScheduler` + `OpenEnvWrapper` 与之通信。
### 架构对比
| 特性 | 内置 Gym (`GYMScheduler`) | OpenEnv (`OpenEnvScheduler`) |
|------|--------------------------|------------------------------|
| 环境运行位置 | 训练进程内(Python 对象) | 独立服务器(WebSocket 通信) |
| 环境接口 | 继承 `Env`,实现 `reset/step/close` | 服务器提供 HTTP/WebSocket API |
| 注册方式 | `--external_plugins` + `envs` 注册表 | `--external_plugins` + `multi_turns` 注册表 |
| 适用场景 | 轻量本地环境(FrozenLake 等) | 复杂服务端环境(TextArena、CARLA 等) |
| 并发控制 | 无需 | 内置 Semaphore 限制并发连接 |
### OpenEnvScheduler
`OpenEnvScheduler` 继承 `GYMScheduler`,将本地 `Env` 替换为 `OpenEnvWrapper`(WebSocket 客户端)。核心设计:
- **`_create_env`**:创建 `OpenEnvWrapper`,连接 OpenEnv 服务器
- **`on_trajectory_start`**:为每个请求创建 wrapper,调用 `reset()`,用 Semaphore 限制并发(默认 4
- **`on_turn_end`**:解析模型输出,调用 `wrapper.step()`,累积奖励
- **`parse_action`**(可覆盖):将模型文本解析为 action dict,默认 `json.loads`
- **`format_observation`**(可覆盖):将服务器返回的 observation 格式化为字符串,默认 `json.dumps`
用户通过继承 `OpenEnvScheduler` 并覆盖 `parse_action``format_observation``on_trajectory_start``on_turn_end` 来适配具体环境。
### 示例:Sudoku 环境
以 TextArena Sudoku 为例,模型需要通过 `[row col number]` 格式下棋,在 9x9 数独棋盘上填入数字。完整代码参考:[sudoku_scheduler.py](https://github.com/modelscope/ms-swift/blob/main/examples/train/grpo/plugin/openenv/sudoku_scheduler.py)。
**1. 启动 OpenEnv 服务器**
安装 OpenEnv 和 Sudoku 环境包(textarena 和 nltk 会作为依赖自动安装):
```bash
pip install openenv
pip install git+https://huggingface.co/spaces/openenv/sudoku
```
使用提供的启动脚本启动本地服务器(默认端口 8000)。`MAX_CONCURRENT_ENVS` 需 ≥ 训练时的 `num_generations`
```bash
TEXTARENA_ENV_ID=Sudoku-v0 MAX_CONCURRENT_ENVS=8 python examples/train/grpo/plugin/openenv/start_sudoku_server.py
```
数据集中将 `base_url` 指向本地服务器地址:
```json
{"messages":[{"role":"user","content":"Play"}],"env_config":{"name":"openenv","base_url":"http://127.0.0.1:8000"}}
```
**2. 自定义 Scheduler**
继承 `OpenEnvScheduler`,实现 Sudoku 专用的动作解析、观察格式化和多组件奖励:
```python
from swift.rollout.multi_turn import OpenEnvScheduler
class SudokuScheduler(OpenEnvScheduler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_content_len = {} # 内容差分跟踪
async def on_trajectory_start(self, requests):
# 创建环境、解析棋盘、生成 hints
# hints 包括「保证正确的走法」和候选数字列表
...
async def on_turn_end(self, infer_request, response_choice, current_turn):
# 解析 [row col number]step 环境
# 计算 5 路奖励:空格选择 / 合法移动 / 重复惩罚 / 进度 / 正确
# 返回更新后的棋盘 + hints 作为下一轮观察
...
def parse_action(self, text):
import re
match = re.search(r'\[\s*(\d+)\s+(\d+)\s+(\d+)\s*\]', text)
if match:
row, col, num = match.groups()
return {"message": f"[{row} {col} {num}]"}
return {"message": "[1 1 1]"}
```
**多组件奖励系统**(参考 [TRL Sudoku 示例](https://github.com/huggingface/trl/blob/main/examples/notebooks/openenv_sudoku_grpo.ipynb)):
| 奖励组件 | 计算方式 | 作用 |
|---------|---------|------|
| `empty_cell_reward` | 目标是空格 +1 / 覆盖已有 -1 | 引导模型选择合法位置 |
| `valid_move_reward` | 合法新走法 +1 / 警告 -0.5 / 无效 0 | 鼓励合法操作 |
| `repetition_reward` | 重复走法指数惩罚(-2^n,上限 -10) | 避免重复 |
| `progress_reward` | (已填充 - 初始) / (81 - 初始) | 衡量解题进度 |
| `correct_reward` | 环境返回的二值奖励 | 完全解出 |
组合奖励 = 各组件均值之和,提供比单一二值奖励更密集的学习信号。
**3. Hints 系统**
每轮交互中,scheduler 解析当前棋盘状态,为模型提供提示:
- **GUARANTEED MOVES**:只有一个候选数字的空格(可直接填入)
- **Other options**2-3 个候选数字的空格
- **MOVES ALREADY TRIED**:已尝试过的走法(避免重复)
**4. 准备数据集**
数据集仅作占位符,实际棋盘由环境服务器生成。`base_url` 指向 OpenEnv 托管地址:
```json
{"messages":[{"role":"user","content":"Play"}],"env_config":{"name":"openenv","base_url":"http://127.0.0.1:8000"}}
```
**5. 注册 Scheduler**
`sudoku_scheduler.py` 末尾已包含注册代码,通过 `--external_plugins` 加载即可:
```python
# sudoku_scheduler.py 末尾
from swift.rollout.multi_turn import multi_turns
multi_turns['sudoku_scheduler'] = SudokuScheduler
```
**6. 启动训练**
```bash
swift rlhf \
--rlhf_type grpo \
--model Qwen/Qwen3.5-4B \
--dataset examples/train/grpo/plugin/openenv/sudoku.jsonl \
--external_plugins examples/train/grpo/plugin/openenv/sudoku_scheduler.py \
--enable_thinking false \
--max_completion_length 256 \
--use_gym_env true \
--multi_turn_scheduler sudoku_scheduler \
--max_turns 20 \
--use_vllm true \
--vllm_mode colocate \
...
```
运行脚本参考:[`examples/train/grpo/plugin/openenv/run_grpo_sudoku.sh`](https://github.com/modelscope/ms-swift/blob/main/examples/train/grpo/plugin/openenv/run_grpo_sudoku.sh)
### 注意事项
- **vLLM 模式**:以上示例使用 `--vllm_mode colocate`,vLLM 与训练共享 GPU。若使用 `--vllm_mode server`,需额外启动 `swift rollout` 作为 vLLM 服务器,且 `--multi_turn_scheduler``--max_turns` 参数应传给 `swift rlhf` 而非 `swift rollout`
- **并发会话数**`start_sudoku_server.py``MAX_CONCURRENT_ENVS` 需 ≥ 训练时的 `num_generations`。默认的 `python -m textarena_env.server.app` 只支持 1 个并发会话。
- **enable_thinking**Sudoku 等环境不需要 CoT 推理,建议设置 `--enable_thinking false` 以减少 token 消耗。
- **同步 I/O**`OpenEnvWrapper``reset()`/`step()` 是同步 WebSocket 调用。`OpenEnvScheduler` 的子类应使用 `asyncio.to_thread()` 包装这些调用以避免阻塞事件循环。
参考资料:
- https://gymnasium.farama.org/environments/toy_text/frozen_lake/
- https://github.com/alibaba/ROLL/tree/main/roll/pipeline/agentic/env/frozen_lake
- https://github.com/huggingface/openenv
- https://github.com/huggingface/trl/blob/main/examples/notebooks/openenv_sudoku_grpo.ipynb