Prhub

#2479 examples: computer-use RL on HUD v6 environments

原始 PR 作者 nblintao 合并时间 2026-08-13 10:38 文件变更 13 提交数 1 评论 0 代码增减 +1679 / -1

执行摘要

HUD v6 计算机使用 RL 训练示例

PR body 明确说明:Trains a VLM to operate a real GUI through HUD v6 environments. HUD owns the episode — it boots the environment (one Daytona sandbox per episode), runs the agent loop, and grades — while Miles owns training. 同时指出 HUD 自带的 computer-use 工具仅覆盖 Claude/Gemini/OpenAI 原生协议,其 OpenAI-compatible harness 缺少自托管模型(sglang/vLLM)所需的纯 function-calling 屏幕工具;sglang 也缺少 HUD 要求的 return_token_ids 扩展。本 PR 在示例层补齐这些缺口,并验证 reward 塑形、write-off 处理等可学习性设计。

值得精读。该 PR 最有价值的设计决策包括:① 用 httpx 传输层而非改 sglang 源码来补齐 token-id 契约,思路可复用甚至上游化;② 拼接前逐轮验证前缀属性,只容忍空白规范化的 resync,避免静默训练损坏;③ 失败按 write-off 而非异常处理,并保证样本字段完整(含 dummy image)以维持 FSDP collective 同步;④ “机制与数字分离”的分层设计,reward shape 通过数据而非代码决定。建议阅读 rollout.py_stitch/_resync_whitespacesglang_compat.py 的传输重写。

讨论亮点

该 PR 的 review 记录仅包含 Shi-Dong 的 'LGTM!' 批准,没有实质代码讨论。设计权衡主要在 PR body 中阐述,例如 whitespace resync 只接受“去空白后文本相等且后续精确对齐”的规范化差异、拒绝真正的历史改写;FSDP 纯文本微批的 dummy image 处理;Daytona 凭证以路径传输而非值(Ray 会原样记录 runtime_env 到日志和 job metadata)。

实现拆解

  1. 数据生成(make_hud_data.py):从 HUD v6 env 包的 tasks.py 读取任务模板,生成 Miles 的 prompt jsonl;prompt 列只作样本分组用(真实 prompt 在环境内由模板 first yield 提供),通过 --repeat 复制行来满足 GRPO 组大小,--args-json 支持在不改动 env 包的情况下重调任务参数(如 target_score)。
  2. 推理兼容层(sglang_compat.py):用 httpx 自定义传输拦截 /chat/completions 请求,把 HUD 的 return_token_ids/prompt_token_ids 改写为 sglang 的 return_prompt_token_ids/return_meta_info,并在响应侧从 meta_info.output_token_logprobs 三元组合成 choice.token_ids,实现 sglang 与 HUD 双方零改动桥接。
  3. 屏幕工具(computer_tool.py):继承 HUD 的 RFBTool,暴露 computer 工具的 OpenAI function-calling schema;实现截图降采样与坐标回缩放、按键序列(逐个按键)与组合键(+ 连接)区分、首次键盘输入前的聚焦点击(_ensure_focus),并保证异常不会杀死 episode(返回 tool_err)。
  4. 回放拼接与奖励(rollout.py):作为 --custom-generate-function-path 接入,逐轮收集 HUD Trace 中 token 级 Sample,验证每轮 prompt 前缀与已拼接序列一致后拼接 token,并为 policy 输出置 loss mask 1、脚手架置 0;仅容忍空白规范化差异(_resync_whitespace),其余分歧按 turn 截断;视觉输入从工具返回的截图重建,并与 ids 中 image-token 数量按前缀校验,不一致则响亮失败;所有失败 episode 生成带 dummy image 的 remove_sample 样本,规避 FSDP 纯文本微批导致的 collective 不同步(#2406)。
  5. 启动与配置(run_hud2048.py + hud2048_config.yaml):组装 FSDP + GRPO + sglang 参数,含 preflight 检查(SDK、Daytona 凭证路径)与 smoke/rollout-only 模式;配置文件注释每个 trade-off 参数。
    测试配套tests/ 下 28 个离线用例,通过 fakes 覆盖拼接、mask、resync 边界、视觉输入校验、工具 dispatch、sglang 重写和数据行,另外 MODE=smoke 提供 2 个真实 episode 的冒烟验证。
文件 模块 状态 重要度
examples/experimental/hud/rollout.py 回放拼接 added 8.89
examples/experimental/hud/computer_tool.py 屏幕工具 added 8.95
examples/experimental/hud/sglang_compat.py 兼容层 added 8.15
examples/experimental/hud/run_hud2048.py 启动器 added 8.12
examples/experimental/hud/make_hud_data.py 数据生成 added 7.8
examples/experimental/hud/agent.py 智能体 added 7.22
examples/experimental/hud/tests/test_rollout.py 回放测试 added 7.45
examples/experimental/hud/tests/test_sglang_compat.py 兼容测试 added 6.68
examples/experimental/hud/hud2048_config.yaml 配方配置 added 4.68
examples/experimental/hud/README.md 示例文档 added 3.81

关键符号

_stitch _resync_whitespace _vision_inputs _failed ChatComputerTool.execute ChatComputerTool._ensure_focus _SglangTokenIds.handle_async_request sglang_client ComputerChatAgent.for_shot_width make_hud_data.main run_hud2048.preflight run_hud2048.execute

关键源码片段

examples/experimental/hud/rollout.py core-logic

示例的核心拼接逻辑:将 HUD 每轮 token 级 sample 拼接成单条训练序列并计算 loss mask,含空白规范化 resync、视觉输入校验与失败样本兜底。

def _stitch(turns, tokenizer=None) -> tuple[list[int], list[int], list[float], int] | None:
    """将每轮 (prompt_ids, output_ids) 拼接成一条训练序列。    返回 (tokens, loss_mask, rollout_log_probs, response_start):
    mask 和 logprobs 覆盖响应区域(首轮 prompt 之后的所有位置):
    策略真正输出的 token 为 1/真实 logprob,重渲染进后续 prompt 的脚手架
    token 为 0/0.0。
    """
    tokens: list[int] = []
    loss_mask: list[int] = []
    logprobs: list[float] = []
    response_start = 0
    for k, s in enumerate(turns):
        prompt = list(s.prompt_token_ids)
        output = list(s.output_token_ids)
        if k == 0:
            # 第一轮的 prompt 就是序列前缀
            tokens = prompt
            response_start = len(prompt)
        else:
            # 服务器每轮重新渲染模板,因此第 k+1 轮 prompt 必须以已拼接
            # 的 tokens 为前缀;不一致时尝试仅空白差异的 resync,否则截断。
            if prompt[: len(tokens)] != tokens:
                resynced = (
                    _resync_whitespace(tokens, loss_mask, logprobs, prompt, response_start, tokenizer)
                    if tokenizer is not None
                    else None
                )
                if resynced is None:
                    # 真正的历史改写:保留该 turn 之前的精确序列,结束拼接
                    break
                tokens, loss_mask, logprobs = resynced
            # 新 prompt 中多出的部分(脚手架 / 截图 tokens)mask 为 0
            delta = prompt[len(tokens):]
            tokens += delta
            loss_mask += [0] * len(delta)
            logprobs += [0.0] * len(delta)
        out_lp = list(s.output_logprobs)
        # 长度不匹配时兜底为 0,防止训练器索引越界
        if len(out_lp) != len(output):
            out_lp = [0.0] * len(output)
        tokens += output
        loss_mask += [1] * len(output) # 策略输出参与 loss
        logprobs += out_lp
    if not loss_mask:
        return None # 连一个有效 turn 都没有,直接判失败
    return tokens, loss_mask, logprobs, response_start
examples/experimental/hud/computer_tool.py core-logic

为自托管策略补齐 OpenAI function-calling 屏幕工具,处理截图降采样、坐标缩放、按键序列 / 组合键区分与键盘聚焦。

async def execute(self, arguments: dict[str, Any]) -> MCPToolResult:
    action = str(arguments.get("action", ""))
    # 以 INFO 记录每个动作,便于事后诊断 reward 为零的批量运行
    logger.info("[hud act] %s %s", action, {k: v for k, v in arguments.items() if k != "action"})
    try:
        if action == "screenshot":
            pass # 直接返回下方 _observation()
        elif action in ("click", "double_click"):
            if arguments.get("x") is None or arguments.get("y") is None:
                return tool_err("click needs x and y")
            # 模型在降采样帧里给出坐标,需换算回真实屏幕
            await self.click(
                self._to_screen(arguments["x"]),
                self._to_screen(arguments["y"]),
                button=arguments.get("button") or "left",
                count=2 if action == "double_click" else 1,
            )
            self._pointer_used = True
        elif action in ("type", "press"):
            await self._ensure_focus() # 首次键盘输入前确保焦点
            if action == "type":
                await self.type_text(str(arguments.get("text") or ""))
            else:
                keys = arguments.get("keys") or []
                if isinstance(keys, str):
                    keys = [keys]
                if not keys:
                    return tool_err("press needs keys, e.g. {'keys': ['Left', 'Down']}")
                # keys 数组中的每个元素是独立的按键序列;包含 '+' 的
                # 元素是组合键(hotkey),如 'ctrl+c'。逐次发送。
                for element in keys:
                    chord = [to_keysym(k) for k in str(element).split("+") if k.strip()]
                    if chord:
                        await self.press_keys(chord)
        elif action == "scroll":
            await self.scroll(
                self._to_screen(arguments["x"]) if arguments.get("x") is not None else None,
                self._to_screen(arguments["y"]) if arguments.get("y") is not None else None,
                scroll_y=int(arguments.get("scroll_y") or 0),
            )
        elif action == "wait":
            await self.wait(min(int(arguments.get("ms") or 500), 5000))
        else:
            return tool_err(f"unknown action {action!r}")
        # 等待 UI 稳定后再截图给模型看
        await self.wait(300)
        return await self._observation()
    except Exception as e: # 坏动作不能杀死 episode
        return tool_err(f"{action} failed: {e}")
examples/experimental/hud/sglang_compat.py core-logic

httpx 传输层重写,将 HUD 的 return_token_ids 契约翻译为 sglang 的 return_prompt_token_ids/return_meta_info 并合成 token_ids,是示例能拿到 token 级样本的基础。

class _SglangTokenIds(httpx.AsyncBaseTransport):
    """把 HUD 的 token-id 契约翻译成原生 sglang 字段的传输层。"""
​
    def __init__(self) -> None:
        self._inner = httpx.AsyncHTTPTransport()
​
    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        is_chat = request.url.path.endswith("/chat/completions")
        if is_chat:
            body = json.loads(request.content or b"{}")
            # HUD 请求里带 return_token_ids,sglang 不认识;
            # 改为 sglang 自己的两个标志。
            if body.pop("return_token_ids", None):
                body["return_prompt_token_ids"] = True
                body["return_meta_info"] = True
            # HUD 的 KV-continuation 字段,sglang 改为重渲染模板,丢弃之
            body.pop("prompt_token_ids", None)
            content = json.dumps(body).encode()
            headers = {k: v for k, v in request.headers.items() if k.lower() != "content-length"}
            request = httpx.Request(request.method, request.url, headers=headers, content=content)
​
        response = await self._inner.handle_async_request(request)
​
        if is_chat and response.status_code == 200:
            data = json.loads(await response.aread())
            for choice in data.get("choices", []):
                # sglang 把输出 token 及对数概率放在 meta_info.output_token_logprobs
                # 三元组里,这里合成 HUD 期望的 choice.token_ids。
                triples = (choice.get("meta_info") or {}).get("output_token_logprobs") or []
                if triples and "token_ids" not in choice:
                    choice["token_ids"] = [t[1] for t in triples]
            content = json.dumps(data).encode()
            headers = {
                k: v
                for k, v in response.headers.items()
                if k.lower() not in ("content-length", "content-encoding", "transfer-encoding")
            }
            response = httpx.Response(response.status_code, headers=headers, content=content, request=request)
        return response

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  • 拼接正确性依赖服务端行为:rollout.py 假设 sglang 每轮重渲染 chat template,且第 k+1 轮 prompt 以第 k 轮 prompt+output 为前缀。若 sglang 或 HUD 升级后改变渲染方式,可能大量截断 episode 或产生静默错位序列(虽有校验,但属强假设)。
  • sglang_compat.py 重写响应的潜在开销:重构响应时移除 content-length/content-encoding/transfer-encoding 头,对超大响应或压缩流可能造成解析变化;示例规模下可接受,但未来若用于大 batch 需关注。
  • computer_tool.py 坐标与聚焦风险:坐标缩放依赖 display_width/shot_width 的准确性;_ensure_focus 只点击一次,若聚焦失败或界面将焦点移走,后续按键可能无效(仅日志可见,不报错)。
  • 外部 SDK 版本未锁定:hud/daytona SDK 未固定版本,上游 API 变化可能导致示例失效。
  • FSDP 纯文本微批陷阱:通过 dummy image 规避 collective 不同步,但这只是本示例的本地保护,通用修复仍需 #2406 落到 trainer。

影响范围限于 examples/experimental/hud 目录和 docs/user-guide/environments.md 文档,miles/ 核心代码零改动,不影响现有训练、推理或 dashboard 功能。对用户/研究团队而言,提供了一个可直接复现的 computer-use GRPO 训练配方(含启动脚本、数据生成、离线测试),显著降低多轮 VLM 实验门槛;对团队而言,新增示例代码的维护成本主要由上游 SDK 的兼容性决定,但分层清晰(transport / computer use / HUD / recipe),便于后续扩展其他 HUD 任务。

拼接逻辑对服务端行为敏感 FSDP 纯文本微批需 dummy image 外部 SDK 未锁版本 测试覆盖离线为主

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论