跳到主要内容

数据截至 (上游 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_assignmentTrue赋值时也校验,不只构造时
arbitrary_types_allowedTrue允许 numpy 数组、torch 张量这类非 Pydantic 类型

extra="forbid" 这条在 RL 场景里很值钱:模型生成的动作 JSON 里多了个拼错的键,你希望立刻炸掉而不是让环境按默认值默默跑下去。

Observation — 环境回的话

定义在 types.py:68。基类自带三个字段:

字段类型干什么
donebool这一局是不是结束了
rewardbool | int | float | None奖励信号,默认 None
metadataDict[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_idstep_count,extra 配的是 "allow"(而非 Observationforbid)——因为 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) -> ObsTinterfaces.py:186-194
step(action, timeout_s=None, **kwargs) -> ObsTinterfaces.py:208-216
state@property -> StateTinterfaces.py:230-234

可选覆盖的异步版

基类给了 reset_asyncstep_async默认实现,直接转调同步版(interfaces.py:196-206183-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_SESSIONSFalse声明本环境可以多会话并存(interfaces.py:171)
REQUIRES_SINGLE_THREAD_EXECUTORFalse声明本环境所有会话必须共用同一个线程(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.pyEchoEnvironment——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.pyCodeSafetyTransform——它用正则扫代码里的 import oseval(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)把动作对象压成 dictenv_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.pyCodingEnv(整个文件 56 行,三个方法各不到 10 行)。

更极端的例子:envs/echo_env/client.pyEchoEnv 类体只有一句 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


1.6 双 API 边界:本章的重点

边界是什么

OpenEnv 对外有两套接口,面向两类完全不同的调用者:

边界接口调用者能力
控制面Gym 式 reset / step / state训练编排、基础设施开局、推进、看内部状态
智能体面MCP 式 tools/list / tools/call被训练/被评测的模型只能调环境摆出来的那些工具

为什么必须隔开

仓库 RFC 001 和内部不变量文档给的理由很好懂:如果智能体能自己调 reset(),它就学到了「后果可以撤销」。开车撞了树、按一下重来——这会直接毁掉训练范式本身。从智能体的视角,一局就是结束了,重开是基础设施背着它做的事。

边界在代码里怎么落实

不是靠文档约定,是三处实打实的机制:

第一,保留字表。 RESERVED_TOOL_NAMES = frozenset(["reset", "step", "state", "close"])(src/openenv/core/env_server/mcp_types.py:316)。

第二,注册时校验。 MCPEnvironment 在构造函数里就扫一遍 MCP 服务器上的工具名,撞上保留字直接 ValueError——见 mcp_environment.py:320-341_validate_tool_names。用 @self.tool() 装饰器注册的也会被单独查一遍(mcp_environment.py:364-369)。

第三,生产模式砍路由。 服务端有 ServerMode.SIMULATIONServerMode.PRODUCTION 两档(types.py:18-22)。生产模式下,/reset/step/state 这三个 HTTP 端点根本不注册——见 http_server.py:1259(if mode == ServerMode.SIMULATION:)和 http_server.py:1386(/state 的条件插入)。剩下的只有 /health/schema/metadata/mcp/ws

第三条是最硬的:同一份环境代码,部署成推理服务时,控制面在 URL 层面就不存在了。

一张图收尾

训练编排 被训练的模型
│ │
│ reset / step / state │ tools/list
│ (WebSocket /ws) │ tools/call
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ 环境容器 │
│ 控制面 ── 仿真模式才注册 ──┐ ┌── MCP 工具面 │
│ ▼ ▼ │
│ Environment 实例(共享同一份状态) │
└─────────────────────────────────────────────────────────┘
↑ 保留字 reset/step/state/close 禁止出现在工具名里

1.7 关键细节与坑

  • Action.extra = "forbid" 会咬人。 你给动作 JSON 加了个服务端不认识的字段,服务端会回 422 而不是忽略。HTTP /step 路径上这个错误被显式转成 HTTPException(422)(http_server.py:704-707);WebSocket 路径上则变成一个 code: VALIDATION_ERROR 的错误帧(http_server.py:1646-1654)。
  • reset() 的 kwargs 是按签名过滤后传的。 服务端用 inspect.signature 取出你的参数名,只把匹配得上的传进去(_get_valid_kwargs,http_server.py:336-357)。所以客户端多传参数不会炸,只是被丢掉——除非你的 reset 带了 **kwargs
  • Stateextra="allow" 是有意的。 环境可以往 State 里塞任意字段(比如 envs/coding_env/models.py:31CodeState.last_exit_code),/state 端点会原样吐出来。
  • Observation.reward 的类型是 bool | int | float | None 允许 bool 看起来奇怪,但对二元奖励环境很方便;下游 StepResult.reward 声明的是 Optional[float],这里存在一处宽松的类型不一致(types.py:82 vs client_types.py:27)。

1.8 代码地图

主题文件符号
动作/观察/状态定义src/openenv/core/env_server/types.pyActionObservationState
服务端模式枚举src/openenv/core/env_server/types.pyServerModeHealthStatusWSErrorCode
环境抽象基类src/openenv/core/env_server/interfaces.pyEnvironmentTransform
并发声明开关src/openenv/core/env_server/interfaces.pySUPPORTS_CONCURRENT_SESSIONSREQUIRES_SINGLE_THREAD_EXECUTOR
线格式转换src/openenv/core/env_server/serialization.pyserialize_observationdeserialize_action
客户端抽象基类src/openenv/core/env_client.pyEnvClient_step_payload_parse_result
客户端返回值src/openenv/core/client_types.pyStepResult
保留字边界src/openenv/core/env_server/mcp_types.pyRESERVED_TOOL_NAMES
边界校验src/openenv/core/env_server/mcp_environment.py_validate_tool_names
现成 transformsrc/openenv/core/env_server/base_transforms.pyCompositeTransformNullTransform
最小真实环境envs/echo_env/server/echo_environment.pyEchoEnvironment
最小真实客户端envs/coding_env/client.pyCodingEnv