02 · Agent 侧:观测、动作空间与解析
这一章讲 agent 那一半:
PromptAgent怎么把obs(截图 + 无障碍树)拼成大模型能读的消息,模型吐回的一段自然语言/代码又怎么被解析回DesktopEnv.step能执行的动作。注意:OSWorld 是「环境」,agent 只是可替换的插件——mm_agents/下有约 40 个不同 agent 实现,本章讲最基准的PromptAgent(mm_agents/agent.py:226),其余都是它的变体。
2.1 两个正交的旋钮:看什么 × 说什么
PromptAgent 的行为由两个参数决定(agent.py:227 的 __init__):
| 旋钮 | 取值 | 含义 |
|---|---|---|
observation_type(看什么) | screenshot / a11y_tree / screenshot_a11y_tree / som | 喂给模型的是纯截图、纯无障碍树、两者都给、还是「带标记的截图」 |
action_space(说什么) | pyautogui / computer_13 | 模型该吐 Python 代码,还是吐结构化的动作字典 |
构造时按这两个旋钮的组合,选定一份系统提示词(agent.py:256-281),六份 prompt 常量都在 mm_agents/prompts.py:
observation × action → system_message
screenshot + code → SYS_PROMPT_IN_SCREENSHOT_OUT_CODE
screenshot + action → SYS_PROMPT_IN_SCREENSHOT_OUT_ACTION
a11y_tree + code → SYS_PROMPT_IN_A11Y_OUT_CODE
a11y_tree + action → SYS_PROMPT_IN_A11Y_OUT_ACTION
both + code → SYS_PROMPT_IN_BOTH_OUT_CODE
both + action → SYS_PROMPT_IN_BOTH_OUT_ACTION
som → SYS_PROMPT_IN_SOM_OUT_TAG
两种动作空间的差别,用一个「点 (500,300)」来对照最直观:
| 动作空间 | 模型输出长这样 | step 怎么执行 |
|---|---|---|
pyautogui | 一段代码块 ```python\npyautogui.click(500,300)\n``` | 直接 execute_python_command 跑(desktop_env.py:448) |
computer_13 | JSON {"action_type":"CLICK","parameters":{"x":500,"y":300}} | execute_action 翻译成对应 pyautogui 调用(python.py:301) |
pyautogui 表达力最强(能写任意 Python),computer_13 是一套受控的 13 类动作枚举(MOVE_TO/CLICK/SCROLL/TYPING/HOTKEY…),由 PythonController.execute_action 一个大 if/elif 分派(controllers/python.py:301)。合法动作空间的白名单在 desktop_env.py:183(还含 claude_computer_use / gemini_computer_use 等给专用 agent 的口子)。
2.2 predict:把观测拼成一通对话
核心方法是 predict(instruction, obs)(agent.py:289)。它做的事,本质是把「系统提示 + 历史几轮 + 当前这帧」拼成一个 messages 列表发给模型:
messages = [
system: 系统提示词 + "你要完成的任务是:{instruction}"
user: [历史第 t-k 帧观测] assistant:[当时模型的回答]
... (最多回看 max_trajectory_length 轮)
user: [当前这一帧观测:文本(无障碍树) + 截图]
]
为什么要截断历史。 每帧都可能带一张高清截图,token 极贵。所以只保留最近 max_trajectory_length 轮(agent.py:313-325);设为 0 则完全不给历史。这是「让模型有短期记忆」与「别把上下文撑爆」之间的直接权衡。
当前帧怎么拼,取决于 observation_type(agent.py:416-514):
- 含截图的模式:把 PNG base64 成
data:image/png;base64,...塞进image_url,detail: high(agent.py:447-452)。 - 含无障碍树的模式:把 XML 线性化成一张表(下一节),当文本塞进 user 消息。
2.3 无障碍树:从 XML 到一张制表符表格
模型读不动原始 XML,linearize_accessibility_tree(agent.py:71)把它压成一张 TSV 风格的表,每行一个可交互元素:
tag name text class description position(top-left x&y) size(w&h)
(表头见 agent.py:87。)它按平台选命名空间(ubuntu/windows),从每个节点里抠出 screencoord(坐标)和 size(尺寸)等属性——这些正是 01 章 里 guest 端 _create_atspi_node 写进 XML 的字段。坐标很关键:pyautogui 要靠它知道「点哪」。
线性化之后还要按 token 数截断:trim_accessibility_tree(agent.py:217)用 tiktoken 编码,超过 a11y_tree_max_tokens(默认 10000)就截掉尾部并补 [...]。真实桌面的无障碍树可以有几万个节点,不截会瞬间爆预算。
2.4 Set-of-Marks:给截图打上可点的编号
som(set-of-marks)模式是给「视觉定位不准」开的一条捷径。tag_screenshot(agent.py:120)先用无障碍树筛出可交互元素,再在截图上画出带编号的框(draw_bounding_boxes),模型只需说「点 tag_5」而不必自己估像素坐标。
解析时,parse_code_from_som_string(agent.py:197)把每个 tag 的编号翻译回框中心的真实坐标,拼在模型代码前面:
# 示意:som 解析把编号还原成坐标
tag_5 = (612, 344) # 由第 5 个框的中心算出
pyautogui.click(tag_5) # 模型原文里写的就是 click(tag_5)
这把「精确视觉定位」这个 GUI agent 的老大难,从模型身上卸给了无障碍树。
2.5 从模型文本到可执行动作:解析
模型回的是一段自然语言 + 代码/JSON。parse_actions(agent.py:1113)按 action_space 分流:
| action_space | 解析函数 | 怎么抠 |
|---|---|---|
pyautogui | parse_code_from_string (agent.py:162) | 正则 r"```(?:\w+\s+)?(.*?)```" 抠出所有代码块 |
computer_13 | parse_actions_from_string (agent.py:128) | 抠 ```json ... ``` 再 json.loads |
两条路都特判三个「元动作」WAIT/DONE/FAIL——模型可以只回一个 ```WAIT```,解析出的就是字符串 "WAIT",交给 step 走特殊分支(agent.py:129,164)。parse_code_from_string 还处理「代码块最后一行是 DONE」这种混合情况:把代码和 DONE 拆成两个动作依次执行(agent.py:187-190)。