--- Environment 与 TaoToken 统一 Key 通道的对接实践)
1. 从 OpenClaw-RL 的 Environment 说起为什么它没有 env.py如果你正在读 OpenClaw-RL 的源码大概率会经历一个困惑期翻遍整个仓库找不到environment.py、env.py或者任何继承自gym.Env的类。这跟教科书里RL Agent Environment 循环交互的图景对不上。我试过按 Gymnasium 的接口去找reset()和step()结果一个都没有。这不是代码写漏了而是 OpenClaw-RL 对环境这个概念的建模方式跟传统 RL 完全不同。在它的设计里环境不是一个被封装的对象而是由真实用户 OpenClaw App FastAPI Proxy三者共同构成的隐式实体。用户通过手机 App 发消息App 把请求打到 FastAPI ProxyProxy 再转发给 SGLang 推理服务生成回复——这一整条链路合起来才叫环境。理解这一点是读懂 OpenClaw-RL 源码的第一道门槛。而对我们做 Agentic RL 工程落地的人来说更实际的问题是这条链路里的模型请求也就是 Proxy 转发给推理服务的那一段怎么统一管理 Key 和 API 通道因为一旦你要把 OpenClaw-RL 从论文复现到自己的训练集群或者把它的 Environment 交互模式迁移到别的 Agent 场景模型请求的鉴权、路由、限流就会立刻变成绕不开的工程问题。这篇笔记分两条线走一条是源码阅读线拆解 Environment 模块的接口设计与调用链路另一条是工程落地线用 TaoToken 的统一 Key 通道把 Environment 里的模型请求接起来让你在读懂源码的同时完成一次端到端联调。适合正在做 Agentic RL 训练、需要管理多模型请求、或者单纯想搞明白 OpenClaw-RL 环境建模思路的读者。2. Environment 的接口设计与调用链路拆解2.1 单轮 RL 里环境退化成什么样先建立一个参照系。在标准 RL 里环境承担四类职责提供初始状态 s0、执行状态转移 s1→st、给出奖励 r0→rt、判断终止 done。完整循环是s0 → a0 → r0 → s1 → a1 → r1 → … → done。但 LLM 单轮 RL 把多步交互压扁成了一次性输出s0 → [a0, a1, …, at] → r。环境只负责提供 prompts0和终端奖励r中间所有 token 生成都在模型内部完成。这时候环境就退化成prompt 提供器 reward 打分器Transition 和 Termination 几乎归零。这个退化形态很重要因为它解释了为什么很多 LLM RL 框架里环境模块看起来没什么东西。但 OpenClaw-RL 是多轮对话场景四类职责会被重新拉满所以它的环境设计反而比单轮复杂得多。2.2 OpenClaw-RL 的四类职责落在哪结合源码OpenClaw-RL 的环境四类职责对应关系是这样的State 提供真实用户通过 App 发 HTTP POST 到/v1/chat/completions请求体里的messages数组就是环境提供的 state。代码位置在openclaw_api_server.pyapp.post(/v1/chat/completions) async def chat_completions(request): body await request.json() messages body[messages] # 这就是 environment 提供的 stateReward 打分由 PRMLLM Judge部署在 GPU 6-7异步完成。构建评分 prompt → SGLang 生成 → 解析\boxed{±1}内部还会调_majority_vote()做 m3 的多数投票聚合返回{-1, 0, 1}。Transition 驱动用户决定是否继续对话。用户发下一条消息就产生新的 state。实际用_pending_turn_data字典存储待评分 turn 数据收到 next_state 后才完成评分并提交为训练样本self._pending_turn_data.setdefault(session_id, {})[turn_num] turn_dataTermination 判断用户主动结束会话session_done请求头为 true或 Session 超时由客户端决定非服务端主动判定if session_done: self._flush_pending_record(session_id, None) self._maybe_submit_ready_samples(session_id, force_no_prmTrue)2.3 为什么没有 Environment 类关键差异在于标准 RL 环境是同步、可控、可重置的对象核心接口是step(action) → (observation, reward, done)。而 OpenClaw-RL 的环境是异步、不可控、不可重置的——用户什么时候回复不知道回复什么不知道让用户重来更不可能。调用方无法主动驱动环境只能被动等待用户发消息。所以env.step()接口根本不适用OpenClaw-RL 改用 event-driven 的 FastAPI 处理HTTP 请求到来时才有数据每个请求触发一次完整的接收 obs → 执行 action → 计算 reward → 判断 done流程。FastAPI Proxy 在这里同时扮演了两个角色environment 的接口层 rollout 的数据管道。这两个角色在标准 RL 里是分开的在 OpenClaw-RL 里被合并了。这也是为什么你找不到独立的 Environment 类——它的边界被隐式地画在了 Proxy 上。2.4 模型请求在链路里的位置现在把视角切到工程侧。上面这条链路里Proxy 收到用户消息后需要把请求转发给 SGLang 推理服务生成回复。这一步就是模型请求也是我们要用统一 Key 通道接管的地方。在原始设计里Proxy 直接调本地 SGLang 的 endpoint。但如果你要把 OpenClaw-RL 迁移到多机训练集群在 Environment 里混用不同模型比如 judge 用大模型、student 用小模型给模型请求加统一的鉴权、限流、日志那么硬编码本地 endpoint 就不够了。你需要一个统一的 API 通道把 Environment 里所有模型请求收敛到一个入口。这就是下一章要做的配置。3. 用统一 Key 通道接管 Environment 的模型请求3.1 为什么 Environment 需要统一 KeyOpenClaw-RL 的 Environment 里至少有两类模型请求一类是 student 模型生成回复Proxy → SGLang一类是 PRM judge 打分Proxy → SGLang judge 实例。如果再加上 OPD 模式里的 teacher 模型就是三类。这三类请求如果各自维护 endpoint 和 Key会有几个麻烦训练集群扩容时要改多处配置judge 模型换版本时要同步改 Proxy 代码多机部署时 Key 分发容易出错。统一 Key 通道的思路是所有模型请求都走同一个 Base URL用同一个 Key 鉴权通过 Model ID 区分具体调哪个模型。这样 Environment 里只需要维护一份配置。3.2 可复制的配置片段先拿 Key。访问https://taotoken.net/api-keys创建 API Key然后在控制台确认你的可用模型列表。接着把 Environment 的模型请求配置改成统一通道。如果你用环境变量管理推荐方便多机分发在 Proxy 启动脚本里加export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_API_KEYsk-你的key export STUDENT_MODEL_ID你的student模型ID export JUDGE_MODEL_ID你的judge模型ID然后在openclaw_api_server.py里把 SGLang 的调用改成走统一通道。假设原来是这样# 原来的硬编码方式 SGLANG_ENDPOINT http://localhost:30000/v1/chat/completions改成import os from openai import AsyncOpenAI client AsyncOpenAI( base_urlos.environ[TAOTOKEN_BASE_URL], api_keyos.environ[TAOTOKEN_API_KEY], ) STUDENT_MODEL os.environ[STUDENT_MODEL_ID] JUDGE_MODEL os.environ[JUDGE_MODEL_ID]如果你更喜欢用配置文件可以写一个env_config.toml[api] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY [models] student 你的student模型ID judge 你的judge模型ID teacher 你的teacher模型ID [prm] majority_vote_m 3 timeout_seconds 30然后在代码里读import tomllib with open(env_config.toml, rb) as f: cfg tomllib.load(f) client AsyncOpenAI( base_urlcfg[api][base_url], api_keyos.environ[cfg[api][api_key_env]], )3.3 把 student 生成和 judge 打分都接进来student 生成回复的部分原来可能是直接调 SGLang# 改前 resp await sglang_client.chat.completions.create( modellocal-student, messagesmessages, )改成走统一通道# 改后 resp await client.chat.completions.create( modelSTUDENT_MODEL, messagesmessages, temperature0.7, ) response_text resp.choices[0].message.contentjudge 打分部分原来调本地 PRM# 改前 score await self._prm_evaluate(session_id, turn_num, response_text, next_state)_prm_evaluate内部改成走统一通道async def _prm_evaluate(self, session_id, turn_num, response_text, next_state): judge_prompt self._build_judge_prompt(response_text, next_state) votes [] for _ in range(self.majority_vote_m): resp await client.chat.completions.create( modelJUDGE_MODEL, messages[{role: user, content: judge_prompt}], temperature0.3, ) votes.append(self._parse_boxed(resp.choices[0].message.content)) return self._majority_vote(votes)这样 student 和 judge 都走同一个 Base URL 和 Key只是 Model ID 不同。Environment 里只需要维护一份配置。3.4 三件套对照表不管你是接 Claude Code、Cline MCP 还是 Codex配置的核心都是三件套。这里列个对照配置项值说明Base URLhttps://taotoken.net/api统一入口不加 UTMAPI Keysk-...从控制台创建Model ID你的模型 ID区分 student/judge/teacher如果你用 Claude Code 做源码阅读辅助配置在~/.claude/settings.json{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的key, ANTHROPIC_MODEL: 你的模型ID } }如果你用 Codex配置在~/.codex/auth.json{ OPENAI_BASE_URL: https://taotoken.net/api, OPENAI_API_KEY: sk-你的key }Model ID 在~/.codex/config.toml里指定model 你的模型IDCline MCP 的配置在 Cline 设置里填 Base URL、API Key、Model ID 三项即可。4. 验证请求从单次调用到端到端联调4.1 先验证单次模型调用配置改完后别急着跑整个训练。先用一个最小脚本验证统一通道能通import asyncio import os from openai import AsyncOpenAI async def main(): client AsyncOpenAI( base_urlos.environ[TAOTOKEN_BASE_URL], api_keyos.environ[TAOTOKEN_API_KEY], ) resp await client.chat.completions.create( modelos.environ[STUDENT_MODEL_ID], messages[{role: user, content: 回复一个字好}], max_tokens10, ) print(student:, resp.choices[0].message.content) resp await client.chat.completions.create( modelos.environ[JUDGE_MODEL_ID], messages[{role: user, content: 判断这句话是否有帮助好。只回答 1 或 -1}], max_tokens10, ) print(judge:, resp.choices[0].message.content) asyncio.run(main())跑通的话你会看到 student 和 judge 各自返回内容。这一步确认了 Base URL、Key、Model ID 三件套都对。4.2 模拟一次 Environment 交互单次调用通了之后模拟一次完整的 Environment 交互。启动 Proxy然后用 curl 打一个请求curl -X POST http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -H X-Session-Id: test-session-001 \ -d { messages: [ {role: user, content: 帮我写一个 Python 快排} ] }Proxy 收到请求后会走完整链路提取 messages 作为 state → 调 student 模型生成回复 → 返回给客户端 → 异步调 judge 打分 → 构建 Sample 放入 queue。你可以在 Proxy 日志里看到类似输出[INFO] sessiontest-session-001 turn1 state received [INFO] student model call: model你的student模型ID [INFO] response generated, length256 [INFO] async judge call: model你的judge模型ID [INFO] judge score: 1 (votes: [1, 1, 0]) [INFO] sample submitted to queue4.3 验证多轮和 termination再发一条消息带上session_done头验证多轮和终止逻辑curl -X POST http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -H X-Session-Id: test-session-001 \ -H X-Session-Done: true \ -d { messages: [ {role: user, content: 帮我写一个 Python 快排}, {role: assistant, content: ...}, {role: user, content: 谢谢可以了} ] }这时候 Proxy 会触发_flush_pending_record和_maybe_submit_ready_samples把整个 session 的样本提交。日志里会看到[INFO] session_done received, flushing pending records [INFO] sessiontest-session-001 flushed, samples24.4 检查 queue 和训练样本最后确认样本真的进了训练 queue。如果你用的是 Slime 框架可以查 queue 状态# 在 Proxy 里加一个调试 endpoint app.get(/debug/queue) async def debug_queue(): return { queue_size: len(self._sample_queue), pending_sessions: list(self._pending_turn_data.keys()), }访问http://localhost:8000/debug/queue应该能看到 queue_size 增加了。到这一步Environment 的模型请求就完整走通了统一 Key 通道。5. 本篇常见报错排查5.1 401 Unauthorized最常见的报错。通常是 Key 没配对或者环境变量没生效。openai.AuthenticationError: Error code: 401 - {error: {message: Invalid API key}}排查步骤先确认echo $TAOTOKEN_API_KEY有值再确认 Key 没有多余空格复制时容易带上最后确认 Base URL 是https://taotoken.net/api不要漏掉/api或者多加/v1。5.2 local proxy failed / connection refusedhttpx.ConnectError: [Errno 111] Connection refused这个报错说明请求根本没发出去。如果你是从原来的本地 SGLang endpoint 迁移过来检查代码里是不是还有残留的localhost:30000硬编码。全局搜一下localhost和127.0.0.1把模型请求相关的都改成走统一通道。5.3 reading choices 报错KeyError: choices或者IndexError: list index out of range这个通常是响应格式跟预期不符。可能原因Model ID 填错了返回了错误信息而不是正常响应或者max_tokens设太小响应被截断。先打印完整响应看看resp await client.chat.completions.create(...) print(resp.model_dump())确认choices字段存在且有内容。5.4 OAuth 相关报错如果你用 Claude Code 或 Codex 接入可能遇到Error: OAuth token expired或者Error: Invalid authentication credentials这类报错说明工具在尝试用 OAuth 流程但你配的是 API Key 模式。检查配置文件里是不是同时存在 OAuth 相关字段和 API Key 字段把 OAuth 的删掉只保留 Base URL API Key Model ID 三件套。5.5 judge 打分一直返回 0如果 judge 总是返回 0无效评分检查评分 prompt 里的\boxed{±1}格式要求是否跟模型输出匹配。有些模型不按格式输出解析就会失败。可以在_parse_boxed里加个 fallbackdef _parse_boxed(self, text): import re match re.search(r\\boxed\{([-]?1)\}, text) if match: return int(match.group(1)) # fallback: 直接找 1 或 -1 if 1 in text: return 1 if -1 in text: return -1 return 05.6 多轮 session 样本丢失如果发现多轮对话只提交了部分样本检查_pending_turn_data的清理逻辑。常见问题是session_done请求头没传导致 session 一直挂着不 flush。可以在 Proxy 里加个超时清理async def _cleanup_stale_sessions(self, timeout300): now time.time() for sid, data in list(self._pending_turn_data.items()): if now - data.get(last_update, now) timeout: self._flush_pending_record(sid, None)6. 继续往下读源码和接入Environment 这条线走通之后你可以顺着几个方向继续深入。源码阅读方向_drain_output_queue的阻塞等待逻辑值得细看它处理的是数据断流问题——凌晨没用户时训练会饿死代码里用 while 循环加 timeout 日志兜底。另外_maybe_submit_ready_samples里的 at-least-one guarantee 也很有意思它防止整个 session 被软过滤掉。工程落地方向如果你要把 OpenClaw-RL 的 Environment 模式迁移到自己的 Agent 场景统一 Key 通道只是第一步。接下来要考虑的是多模型路由student/judge/teacher 分别走不同 Model ID、请求限流防止 judge 打分把配额打满、以及日志追踪每个 session 的模型调用链路。需要继续联调的话API Key 在https://taotoken.net/api-keys创建接入文档在https://taotoken.net/doc。如果你要验证不同模型在 Environment 里的表现可以直接用模型对话页面https://taotoken.net/chat快速试。长期跑 Agentic RL 训练、需要稳定 coding 通道的话Coding Plan 在https://taotoken.net/coding-plan。最后留一个我踩过的坑Environment 里的 judge 调用一定要设 timeout不然某个请求卡住会把整个_drain_output_queue阻塞住。我一开始没设结果训练卡了半小时才发现是 judge 那边超时没返回。加上timeout30之后就没再出现过。