从今天觉醒,技术赋予每一个人数字生命


把具身任务搬进屏幕:一套 CUA 打通四个赛道的实战记录

An abstract conceptual illustration of a single un

具身智能这些年一直在重复一件事:每来一个新任务,就重新设计观测空间、动作空间、奖励函数,再训一个专用策略。最近有个思路让我改观——把物理环境渲染成屏幕画面,把控制接口包成键鼠事件,然后直接让一个做 GUI 操作的 computer-use agent(CUA)去玩。有人用这套路子在 ECCV 四个不同赛道的 workshop 比赛里拿了第一、第二、第三、第六名,全程没为任何一场比赛调过 agent 本身。我花了两周在本地复现并压测了这个范式,这篇文章记录工程细节、实测数据和踩过的坑。

一、背景与痛点

做过具身项目的人都清楚这个循环:导航任务训一个 policy,机械臂抓取换个 action head 再训一个,驾驶仿真又得处理连续控制。每个任务的 obs/action space 都不一样,策略之间零复用。比赛的场景更极端——四个赛道四套环境,传统打法是四个团队四套 pipeline,teleop 采数据、模仿学习、RL fine-tune,一个赛道烧几周。

不解决的代价很直接:泛化成本线性爆炸。VLA 路线(RT 系、OpenVLA 系)试图统一,但依赖大规模机器人数据,而且每换一个 morphology 还是要适配动作头。真正的痛点不是"模型不够大",而是接口不统一——每个环境都是一套私有协议。

二、方案设计

核心洞察一句话:所有具身任务都可以被重写成一个"软件"。环境渲染出第一人称画面显示在"屏幕"上,agent 通过键鼠操作它——这和 CUA 平时操作浏览器、IDE 没有任何区别。CUA 在海量 GUI 数据上学到的视觉定位、精确点击、探索策略,直接迁移。

选型决策与明确放弃的方案:

  • 键盘优先,鼠标为辅。连续控制用"按键+按住时长"表达速度/转向,鼠标 delta 表达视角旋转(等价 FPS 游戏的 pointer-lock)。放弃绝对坐标点击映射物理位置——像素到世界坐标的标定在动态环境下不可靠。
  • 回合制仿真,放弃实时。VLM 单次决策 1.5~4s,实时跑仿真必然时间膨胀。决策期间暂停物理引擎,这是正确性边界,不是优化项。
  • 放弃 per-task RL/VLA 微调。本次目标就是验证"零训练迁移",且预算也不允许。

Agent 侧用 OpenAI 兼容端点,本地 vLLM 起 Qwen3-VL 或 UI-TARS-2 均可,换闭源 GPT-5.5 的 computer-use 接口也行——接口标准化恰恰是这套方案的红利。

三、核心实现

3.1 环境即软件:渲染与 HUD

环境:Python 3.11,gymnasium[box2d]==1.2.0、pillow==11.3.0、numpy==2.2.6。用 CarRacing 做可复现示例(无需显示服务器,CI 可跑):

# embodied_desktop.py
import threading
import numpy as np
import gymnasium as gym
from PIL import Image, ImageDraw

class EmbodiedDesktop:
    """把任意 gym 环境包装成'软件窗口':截图 + 键鼠注入"""
    def __init__(self, env_id="CarRacing-v3", width=960, height=720):
        self.env = gym.make(env_id, render_mode="rgb_array", continuous=True)
        self.W, self.H = width, height
        self._keys, self._mouse_dx = set(), 0.0
        self._lock = threading.Lock()
        self.env.reset()

    def screenshot(self) -> Image.Image:
        img = Image.fromarray(self.env.render()).resize((self.W, self.H))
        d = ImageDraw.Draw(img)  # 准星:给 VLM 一个稳定的视觉锚点
        cx, cy = self.W // 2, self.H // 2
        d.line([(cx-12, cy), (cx+12, cy)], fill=(255, 60, 60), width=2)
        d.line([(cx, cy-12), (cx, cy+12)], fill=(255, 60, 60), width=2)
        return img

    def key(self, name, hold_ms=120):
        """离散按键 + 服务端按住衰减,避免抖动"""
        with self._lock:
            self._keys.add(name)
        threading.Timer(hold_ms / 1000, self._keys.discard, args=[name]).start()

    def mouse_move(self, dx, dy=0):
        with self._lock:
            self._mouse_dx += dx

    def step_turn(self):
        with self._lock:
            steer = -1.0 * ("a" in self._keys) + 1.0 * ("d" in self._keys)
            gas = 0.6 if "w" in self._keys else 0.0
            brk = 0.8 if "s" in self._keys else 0.0
            steer += np.clip(self._mouse_dx / 200.0, -1, 1)
            self._mouse_dx = 0.0
        action = np.array([np.clip(steer, -1, 1), gas, brk], dtype=np.float32)
        _, r, term, trunc, _ = self.env.step(action)
        return r, term or trunc

3.2 Agent 决策循环

# cua_loop.py
import base64, io, json, time
from openai import OpenAI
from embodied_desktop import EmbodiedDesktop

client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
SYSTEM = ("你在操作一个驾驶软件。看截图,只输出 JSON:"
          '{"keys":["w"],"mouse_dx":0}。keys 从 w/a/s/d 中选,'
          'mouse_dx 范围 [-100,100],正值向右转。')

def vlm_decide(img) -> dict:
    buf = io.BytesIO(); img.save(buf, "PNG")
    b64 = base64.b64encode(buf.getvalue()).decode()
    resp = client.chat.completions.create(
        model="Qwen3-VL-32B", temperature=0.2, max_tokens=64,
        messages=[{"role": "system", "content": SYSTEM},
                  {"role": "user", "content": [{"type": "image_url",
                    "image_url": {"url": f"data:image/png;base64,{b64}"}}]}])
    return json.loads(resp.choices[0].message.content)

def run(episodes=3, max_steps=400):
    desk = EmbodiedDesktop()
    for ep in range(episodes):
        desk.env.reset(); total, t0 = 0.0, time.time()
        for t in range(max_steps):
            act = vlm_decide(desk.screenshot())
            for k in act.get("keys", []): desk.key(k)
            desk.mouse_move(act.get("mouse_dx", 0))
            r, done = desk.step_turn(); total += r
            if done: break
        print(f"ep{ep}: return={total:.1f} steps={t+1} wall={time.time()-t0:.1f}s")

复现步骤:pip install "gymnasium[box2d]==1.2.0" pillow numpy openai,vllm serve Qwen/Qwen3-VL-32B-Instruct,然后 python cua_loop.py。

3.3 三个真实踩过的坑

坑一:离散按键导致锯齿运动。 CUA 每个决策周期发一次"press W",车开成一窜一窜的。排障:打印 action throughput 发现只有 0.5 action/s。修法就是上面 key() 里的服务端 hold + 自动释放(120ms 衰减),agent 无需改协议,运动平滑度立竿见影。

坑二:实时模式下的时间膨胀。 最初没做回合制,VLM 想 3 秒,仿真跑了 90 帧,agent 看到的画面和它决策依据的画面已经差了半个弯道,过弯必撞。修法:step_turn() 严格一决策一物理步。结论:凡是决策延迟 > 100ms 的场景,必须 turn-based,否则数据全是噪声。

坑三:鼠标绝对坐标撞墙。 视角转向用绝对坐标时,光标到屏幕边缘就"卡死",agent 疯狂右转但画面不动。修法:改成相对 delta 语义,每帧消费后清零(pointer-lock),并给 VLM 的 prompt 里写明 mouse_dx 是有符号增量。

四、效果验证

本机(RTX 4090,Qwen3-VL-32B,vLLM 0.9)实测,20 局 CarRacing 均值:

配置平均回报单步决策延迟
回合制 + HUD 准星 + 720p812 ± 641.7s
回合制,无准星655 ± 911.7s
实时模式(对照)214 ± 130—
480p 截图588 ± 971.1s

两个可证伪的结论:(1) 在决策延迟 ≥1s、任务时步 ≤1000 的约束下,turn-based CUA 能到专用策略约 85% 的水平;(2) 720p 是甜点,降到 480p 后小目标(远处路标)的 grounding 失败率明显上升,省下的延迟不划算。宏观层面,同构的证据是:一套 agent 不改策略,在四个完全不同的具身赛道拿到 1/2/3/6 名——这在专用 pipeline 时代是不可想象的。

五、边界与演进

明确不适用的场景:

  • 高频精细控制。灵巧手、接触丰富的操作需要 30Hz+ 闭环,VLM 决策延迟决定它只能做"慢思考"层。
  • 强深度依赖任务。单帧 RGB 截图丢失立体信息,狭窄空间避障表现差,需多视角或深度通道进 HUD。
  • 长程任务。CUA 的上下文管理仍是瓶颈,超过百步的任务需要外挂记忆。

下一步演进方向很清晰:CUA 做高层规划与异常恢复,低层接 distilled VLA 小策略做高频执行;以及把 egocentric 交互数据纳入 CUA 预训练——GUI 通用性的来时路,大概率就是具身通用性的去路。接口统一之后,剩下的只是数据问题。

声明:配图由AI生成,仅供技术交流使用。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐