数据截至 (上游 commit 5359534c6f00)
第 1 章 · 核心契约与双 API 边界
本章讲 OpenEnv 到底「规定」了什么。读完你能自己 写出一个合法环境的骨架。
1.1 全部契约就这么点东西
OpenEnv 的标准面出奇地小:三个数据类型 + 两个抽象基类。
数据(线上传的) 行为(你要实现的)
┌──────────────┐ ┌──────────────────┐
│ Action │ ───── 送入 ────▶ │ Environment │
└──────────────┘ │ reset() │
┌──────────────┐ │ step(action) │
│ Observation │ ◀──── 产出 ───── │ state │
└──────────────┘ └──────────────────┘
┌──────────────┐ ┌──────────────────┐
│ State │ ◀──── 查询 ───── │ EnvClient │
└──────────────┘ │ (客户端一侧) │
└──────────────────┘
下面逐个拆。
1.2 三个数据类型
三者全是 Pydantic v2 的 BaseModel,定义在 src/openenv/core/env_server/types.py。用 Pydantic 不是随手选的:它同时解决了「校验」和「自动生成 JSON Schema」两件事——服务端的 /schema 端点直接吐 model_json_schema()。
Action — 智能体做的事
定义在 types.py:50。基类只有一个 metadata 字段,真正的内容由子类加。关键在它的 model_config(types.py:57-61):
| 配置 | 值 | 含义 |
|---|---|---|
extra | "forbid" | 多余字段直接报错,不静默吞掉 |
validate_assignment | True | 赋值时也校验,不只构造时 |
arbitrary_types_allowed | True | 允许 numpy 数组、torch 张量这类非 Pydantic 类型 |
extra="forbid" 这条在 RL 场景里很值钱:模型生成的动作 JSON 里多了个拼错的键,你希望立刻炸掉而不是让环境按默认值默默跑下去。
Observation — 环境回的话
定义在 types.py:68。基类自带三个字段:
| 字段 | 类型 | 干什么 |
|---|---|---|
done | bool | 这一局是不是结束了 |
reward | bool | int | float | None | 奖励信号,默认 None |
metadata | Dict[str, Any] | 兜底的附加信息 |
注意 reward 长在 Observation 上,不是独立返回值。这跟 Gymnasium 的 (obs, reward, terminated, truncated, info) 五元组不同。原因在序列化那一步能看出来:serialize_observation() 会把 reward/done 从观察体里抠出来,平铺到线格式的顶层(src/openenv/core/env_server/serialization.py:155-174)。
所以线上传的其实长这样:
{
"observation": { "...环境自定义字段..." },
"reward": 1.0,
"done": false,
"metadata": { "..." }
}
环境作者写的是「一个带 reward 的观察对象」,协议看到的是「观察 + 奖励 + 终止标志」。两种视角的转换全在 serialize_observation 一个函数里,这是个值得记住的小巧思。
State — 环境的内部账本
定义在 types.py:180。自带 episode_id 和 step_count,extra 配的是 "allow"(而非 Observation 的 forbid)——因为 State 是给基础设施看的,不同环境爱加什么字段就加。
State 和 Observation 的区别是本章最容易搞混的一点:
| 给谁看 | 内容 | |
|---|---|---|
Observation | 智能体 | 智能体应该感知到的东西 |
State | 训 练/编排基础设施 | 局号、步数、内部账本,智能体不该看到的 |
1.3 Environment:环境作者的作业本
定义在 src/openenv/core/env_server/interfaces.py:137,泛型签名是 Environment[ActT, ObsT, StateT]。
必须实现的三件事
| 成员 | 签名 | 位置 |
|---|---|---|
reset | (seed=None, episode_id=None, **kwargs) -> ObsT | interfaces.py:186-194 |
step | (action, timeout_s=None, **kwargs) -> ObsT | interfaces.py:208-216 |
state | @property -> StateT | interfaces.py:230-234 |
可选覆盖的异步版
基类给了 reset_async 和 step_async 的默认实现,直接转调同步版(interfaces.py:196-206、183-193)。你需要真异步时才覆盖它们。
服务端用一个很直接的手法判断你有没有覆盖:
is_async = _env.step_async.__func__ is not Environment.step_async
真实源码见 src/openenv/core/env_server/http_server.py:688。它比较绑定方法的底层函数对象是不是还等于基类那个——是就说明没覆盖,走线程池;不是就直接 await。这是整个服务端调度策略的分岔点,细节在 02-server.md。
两个类级开关
| 类属性 | 默认 | 含义 |
|---|---|---|
SUPPORTS_CONCURRENT_SESSIONS | False | 声明本环境可以多会话并存(interfaces.py:171) |
REQUIRES_SINGLE_THREAD_EXECUTOR | False | 声明本环境所有会话必须共用同一个线程(interfaces.py:173) |
第一个开关不是文档级的建议,是硬闸门:服务端启动时如果 max_concurrent_envs > 1 而环境没打开这个开关,直接抛 ConcurrencyConfigurationError(http_server.py:301-305)。仓库自带的 35 个环境里,有 13 个打开了这个开关。
一个合法环境的最小骨架
# 示意,非源码
from openenv.core.env_server import Environment, Observation, State
class CounterEnvironment(Environment):
SUPPORTS_CONCURRENT_SESSIONS = True # 声明:多会话安全
def __init__(self):
super().__init__()
self._state = State(episode_id="ep-0", step_count=0)
def reset(self, seed=None, episode_id=None, **kwargs):
self._state = State(episode_id=episode_id or "ep-0", step_count=0)
return Observation(done=False, reward=0.0)
def step(self, action, timeout_s=None, **kwargs):
self._state.step_count += 1 # 记账
done = self._state.step_count >= 10
return Observation(done=done, reward=1.0 if done else 0.0)
@property
def state(self):
return self._state
重点看:环境自己不管序列化、不管 HTTP、不管并发,只管「给动作、还观察」。上面这些全由 HTTPEnvServer 兜住。
真实的最小环境是 envs/echo_env/server/echo_environment.py 的 EchoEnvironment——213 行,其中一半是文档字符串。
1.4 Transform:服务端的观察后处理管道
定义在 interfaces.py:115,是个只有 __call__(observation) -> observation 的抽象类。文档字符串明说它借鉴 TorchRL 的 transform 模式。
基类给了两个现成的(src/openenv/core/env_server/base_transforms.py):
| 类 | 行为 |
|---|---|
CompositeTransform | 串联多个 transform,依次施加 |
NullTransform | 原样返回,占位用 |
真实用法可以看 envs/coding_env/server/transforms.py 的 CodeSafetyTransform——它用正则扫代码里的 import os、eval(、open( 等模式,命中就把 observation.reward 改成惩罚值(transforms.py:33-48)。
关键约束:Transform 是服务端的。基类 只提供 _apply_transform() 这个受保护的钩子(interfaces.py:252-256),要不要调、什么时候调,由环境作者在 step() 里自己决定。奖励逻辑因此始终待在环境边界内——这是仓库自己列的架构不变量之一。
1.5 EnvClient:客户端的作业本
定义在 src/openenv/core/env_client.py:238,同样是三泛型的 EnvClient[ActT, ObsT, StateT]。
环境作者要写客户端时,只需实现三个纯翻译函数:
| 抽象方法 | 干什么 | 位置 |
|---|---|---|
_step_payload(action) | 把动作对象压成 dict | env_client.py:813-816 |
_parse_result(payload) | 把服务端 JSON 还原成 StepResult[ObsT] | env_client.py:818-821 |
_parse_state(payload) | 把 JSON 还原成 State 对象 | env_client.py:823-826 |
连接、重连、超时、心跳、容器生命周期全在基类里。一个完整的真实客户端可以短到 30 行——见 envs/coding_env/client.py 的 CodingEnv(整个文件 56 行,三个方法各不到 10 行)。
更极端的例子:envs/echo_env/client.py 的 EchoEnv 类体只有一句 pass,因为它继承的 MCPToolClient 已经把三个方法实现完了。
StepResult:客户端侧的返回值
定义在 src/openenv/core/client_types.py:11,是个 dataclass(不是 Pydantic):
@dataclass
class StepResult(Generic[ObsT]):
observation: ObsT
reward: Optional[float] = None
done: bool = False
metadata: Optional[Dict[str, Any]] = None
注意这里和服务端的不对称:服务端环境返回「带 reward 的 Observation」,客户端拿到的是「Observation + 平级的 reward/done」。中间那次形状变换,就是上一节说的 serialize_observation。