跳到主要内容

01 · 顶层全景与主循环

这一章讲清两件事:(1) DesktopEnv 的三个核心方法 reset / step / evaluate 各做什么;(2) 一条动作命令怎么从你的 Python 代码,穿过「起 VM 的 provider」「发 HTTP 的 controller」「VM 里的 Flask 服务」三层,最终落到真机上。

1.1 DesktopEnv:一个伪装成 Gym 的真机

OSWorld 把「一台受控的真电脑」包成了一个标准 gym 环境(desktop_env/desktop_env.py:85class DesktopEnv(gym.Env))。这样做的好处是:任何熟悉 RL 训练循环的人,都能直接用 reset → step → step → … 的老套路来驱动它。

三个方法的分工:

方法职责关键源码
reset(task_config)回滚到干净快照 → 按任务 config 布置初始状态 → 返回首帧观测desktop_env.py:273
step(action)在真机执行一个动作 → 睡 pause 秒 → 返回新观测desktop_env.py:416
evaluate()跑 postconfig → 进真机取证 → 按 metric 打分desktop_env.py:458

注意一个和普通 gym 的关键差异:step 返回的 reward 恒为 0、done 恒为 False(除非 agent 主动发 DONE/FAIL)。真正的「得分」不在每一步,而在最后单独的 evaluate()。源码里 reward = 0 / done = False 旁边直接写着 tododesktop_env.py:423-424)——OSWorld 是稀疏奖励、终局判分的基准,不是逐步给 reward 的 RL 环境。

1.2 reset:怎么把机器「洗回」初始状态

一次 reset 要保证:无论上一题把机器弄多脏,这一题都从一个确定、可复现的初始状态开始。它的逻辑(desktop_env.py:273 reset)分三步:

reset(task_config)

├─① 若环境「用过」→ _revert_to_snapshot():回滚 VM 快照,再 _start_emulator 重连
│ (is_environment_used 标志见 desktop_env.py:298)

├─② _set_task_info:把任务的 instruction / config / evaluator 装进 env

└─③ setup_controller.setup(self.config):按 config 逐条布置初始状态
成功 → break;失败 → sleep 5s 重试,最多 MAX_RETRIES=5 次

「用过才回滚」是一个刻意的性能优化。 不同 provider 的初始洁净度不一样:docker/aws/gcp/azure 每次都从干净镜像起,天生是「没用过」;vmware/virtualbox 从上次的脏盘起,天生是「用过」(desktop_env.py:155-160)。云上回滚快照又慢又贵,所以只有真正被 step/setup 弄脏过,才付回滚的代价(is_environment_usedstep() 里被置真,见 desktop_env.py:421)。

布置初始状态由 SetupController.setup 完成,它是声明式的:config 是一串 {"type": ..., "parameters": ...}setuptype 拼出方法名 _{type}_setup 再反射调用(desktop_env/controllers/setup.py:92)。这套「type→方法」的约定和具体有哪些 setup 动作,放在 03 章 细讲。

1.3 观测长什么样

每次 reset/step 结束都调 _get_obs()desktop_env.py:337),返回一个字典:

字段内容何时有
screenshot当前屏幕 PNG 字节(带光标)总是有
accessibility_tree无障碍树 XML(AT-SPI/UIA 抓的界面结构)require_a11y_tree=True
terminal终端输出require_terminal=True
instruction本任务的自然语言指令总是有

这四样就是 agent 能「看到」的全部。截图给视觉模型看,无障碍树给纯文本模型或做视觉定位用——两者怎么被消费,是 02 章 的主题。

1.4 一条命令怎么落到真机:三层穿透

这是理解 OSWorld 的关键。你在 host 写 env.step("pyautogui.click(500, 300)"),这串字符要跑到虚拟机里真的点一下。中间穿过三层:

① DesktopEnv.step(action) desktop_env.py:416
│ 普通动作 → controller.execute_python_command(command)

② PythonController.execute_python_command controllers/python.py:195
│ 给命令加「导库前缀」,POST http://<vm_ip>:5000/execute
▼ ─────────────────────── HTTP ───────────────────────▶
③ Flask 服务 execute_command() server/main.py:78
subprocess.run(["python","-c", ...]) ← 在真机里真的跑 pyautogui

第 ① 层——特殊动作 vs 普通动作。 step 先拦截三个「元动作」:WAIT(睡一会)、FAIL(判定不可行、结束)、DONE(判定完成、结束)(desktop_env.py:428)。其余都是要执行的动作,pyautogui 动作空间下就是一串 Python 代码,直接交给 controller(desktop_env.py:448)。

第 ② 层——为什么要「导库前缀」。 agent 只吐 pyautogui.click(...),但真机上那条 python -c 需要先 import pyautoguiPythonControllerPYAUTOGUI_PKGS_PREFIXcontrollers/python.py:29)把导库、关失效保护、以及一段「shift 字符」补丁塞在命令前面再发出去(controllers/python.py:201self.pkgs_prefix.format(command=command))。这段前缀还顺手处理了 Linux 与其他平台在 |:">? 这些需 shift 的字符上的差异——一个很实在的跨平台坑。

第 ③ 层——真机里其实毫无防护地执行。 guest 端的 /execute 就是 subprocess.run(command, ...),注释直白写着 “Execute the command without any safety checks.”(server/main.py:92-106)。这在「一次性、可回滚的隔离虚拟机」语境下是合理的:机器本就是用完即弃的靶场。

guest 服务还提供更多端点,host 侧的 PythonController 一一对应封装:

host 方法guest 端点作用
get_screenshot (python.py:101)GET /screenshot (main.py:264)抓带光标的屏幕,含 PNG/JPEG 魔数校验
get_accessibility_tree (python.py:129)GET /accessibility (main.py:903)抓无障碍树 XML
execute_python_command (python.py:195)POST /execute (main.py:78)跑一行 python(pyautogui 动作走这里)
run_python_script (python.py:224)POST /run_python (main.py:1581)跑整段 python 脚本
run_bash_script (python.py:249)POST /run_bash_script (main.py:1676)跑 bash 脚本
start_recording/end_recording (python.py:475,496)/start_recording,/end_recording录屏,供事后回看

每个 host 方法都带重试 + 超时retry_times=3controllers/python.py:80),因为真机 + 网络这条链天然不稳。

1.5 截图取回时的一个细节:魔数校验

看似简单的「取截图」也有工程含量。get_screenshot 不只看 HTTP 200,还检查返回字节的文件头魔数是不是真的 PNG(\x89PNG…)或 JPEG(\xff\xd8\xff)(controllers/python.py:83 _is_valid_image_response)。因为真机可能返回一个 200 但内容是坏图/半张图,靠魔数能挡掉这类脏数据再触发重试。Content-Type 只作为「弱兜底」——注释明说 “Content-Type is advisory”。

1.6 无障碍树在 guest 端怎么抓(跨三大平台)

GET /accessibilityserver/main.py:903)按操作系统分三路抓界面结构树,统一序列化成一棵带命名空间的 XML:

平台用什么抓构树函数
LinuxAT-SPI(pyatspi_create_atspi_node (main.py:423)
Windowspywinauto(uia 后端)_create_pywinauto_node (main.py:580)
macOSQuartz / AXUIElement_create_axui_node (main.py:744)

三者都用线程池并发遍历各应用窗口再拼树。抓出来的 XML 用了一组形如 https://accessibility.ubuntu.example.org/ns/... 的命名空间来标 attributes/state/component/value——agent 侧解析无障碍树时正是按这些命名空间取坐标和文本(见 02 章linearize_accessibility_tree)。

1.7 Provider:把「起停虚拟机」的差异藏起来

DesktopEnv 本身不关心 VM 是 VMware 还是 AWS 实例——它只依赖两个抽象基类(desktop_env/providers/base.py):

抽象关键方法意义
Provider (base.py:4)start_emulator / get_ip_address / save_state / revert_to_snapshot / stop_emulator一台机器的生命周期
VMManager (base.py:47)get_vm_path / add_vm / occupy_vm / list_free_vms / check_and_clean一池机器的登记与分配

工厂函数 create_vm_manager_and_providerproviders/__init__.py:4)按 provider_name 惰性 import 对应实现,覆盖 vmware / virtualbox / aws / azure / docker / gcp / aliyun / volcengine / fastvm。_start_emulatordesktop_env.py:198)起机后向 provider 要 IP,并用 rsplit(':', 4) 从右往左切出 host:server:chromium:vnc:vlc 五段端口——特意从右切,是为了兼容 IPv6 provider(如 fastvm)把 host 写成 [2001:db8::1] 时 host 里自带的冒号(desktop_env.py:206-216 的注释)。这类「一个 rsplit 里的边界考量」正是 OSWorld 支持多后端时反复出现的细节。

1.8 单题主循环与并行

把上面这些串起来的是 lib_run_single.run_single_examplelib_run_single.py:14):

run_single_example(agent, env, example, max_steps, ...)
env.reset(task_config=example) # 布置初始状态
agent.reset(vm_ip=env.vm_ip) # 重置 agent 历史
sleep(60) # 等 VM 真正就绪(真机很慢)
start_recording()
while not done and step_idx < max_steps:
response, actions = agent.predict(instruction, obs)
for action in actions:
obs, reward, done, info = env.step(action) # 逐个动作落真机
存 step_N.png + 追加 traj.jsonl
if done: break
sleep(20); result = env.evaluate() # 终局判分
写 result.txt;end_recording()

单进程版入口是 run.pyrun.py:125 test),它按 domain/example 遍历 test_all.json,并用 get_unfinishedrun.py:234)跳过已有 result.txt 的题——天然支持断点续跑。

真正跑榜用的是并行版 scripts/python/run_multienv.py:用 multiprocessing--num_envs 个进程(run_multienv.py:86),把所有任务塞进一个共享 Queuedistribute_tasksrun_multienv.py:145),每个进程 run_env_tasks 各占一台 VM 从队列取题跑(run_multienv.py:175),进程死了还会重启。这就是 README 说的「AWS 并行可把评测压到 1 小时内」的实现底座。

下一章:agent 拿到 obs 后,怎么把它拼成大模型能读的 prompt,模型的自然语言回复又怎么变回 actions。见 02-agent-and-actions.md