跳到主要内容

数据截至 (上游 commit 2689884a6257)

回放策略

这一章讲什么: 录下来的动作是「在 (832, 419) 点一下」。可是回放时窗口挪了、分辨率变了、列表多了一行。怎么还能点对?这是 computer-use 的核心难题,OpenAdapt 的答案是换锚点

前置提醒: 第 6 节会用到 03 章的一条结论——归并不删事件,而是把被吃掉的事件挂成 children。按 index 的建议顺序跳过了 03 的读者,读到那里之前请先回看 03 章第 2 节的父子树。


1. 骨架:所有策略共用的一个循环

先看框架,再看具体策略。BaseReplayStrategy.run(legacy/openadapt/strategies/base.py:56-115)的循环极其简单:

┌──────────────────────────────────────┐
│ 截当前屏 │
│ 取当前活动窗口(含 a11y 数据) │
│ action = get_next_action_event(...) │ ← 唯一的抽象方法,策略在这里分岔
│ playback.play_action_event(action) │ ← 注入鼠标/键盘
└───────────────┬──────────────────────┘
│ StopIteration → 结束

框架只管「看 → 想 → 做」的节奏,想什么完全交给子类。 这是这份代码里最干净的一处抽象。

可选的一层:CHECK_ACTION_COMPLETE(base.py:13,默认 False)开启后,每轮先问模型「上一个动作完成了吗」,没完成就 continue 重来(prompt_is_action_complete,base.py:140-176)。类定义在 :128 结束,而在它和 prompt_is_action_complete 之间的空隙里(base.py:130-135),留着一段这个判定失败时的真实输出记录,开头挂着 # TODO XXX handle failure mode:——模型说「按了 cmd-tab 应该看到应用切换器」,却仍然判定 is_complete=False


2. 六种策略,一条演化线

legacy/openadapt/strategies/__init__.py 导出一个抽象基类加六种具体策略。按「模型介入程度」从低到高排:

策略锚点是什么模型用在哪文件(行数)
NaiveReplayStrategy原始坐标完全不用strategies/naive.py(99)
StatefulReplayStrategy窗口状态描述每步生成动作strategies/stateful.py(197)
VanillaReplayStrategy无(全交给模型)描述录制 + 每步生成动作strategies/vanilla.py(211)
SegmentReplayStrategy分割出的片段分割 + 定位strategies/segment.py(343)
VisualReplayStrategy元素的自然语言描述(片段来自图像分割)描述元素 + 改写流程strategies/visual.py(534)
VisualBrowserReplayStrategy同上,但片段来自浏览器 DOM描述元素 + 改写流程strategies/visual_browser.py(671)

另有一个 DemoReplayStrategy 写在 strategies/demo.py 里,但它的导入被注释掉了,理由是「importing is expensive」(strategies/__init__.py:11-12),所以不算在导出清单里。

两支 Visual 的关系

VisualBrowserReplayStrategy 是所有策略文件里最长的一个(671 行),而它的定位在文件第一行就说尽了(strategies/visual_browser.py:1):

Like visual.py but using instrumented DOM to generate segments instead of FastSAM.

两者的算法骨架完全一样——连函数名都同名(add_active_segment_descriptionsapply_replay_instructionsget_active_segmentget_window_segmentationprompt_for_descriptions)。唯一的区别是「片段从哪来」:

片段来源代价
VisualReplayStrategy图像分割服务返回的分割图,按颜色反推掩膜每张新截图都要调一次分割服务
VisualBrowserReplayStrategy浏览器扩展注入进 DOM 的 data-tlbr-screen 属性,直接算出每个元素的掩膜(get_dom_masks,visual_browser.py:463)只在浏览器里有效,依赖扩展

一句话:DOM 里本来就写着每个元素的屏幕坐标,那就不必再让模型看图猜。 代价是这条路出了浏览器就不成立。

下面重点讲两端:最偷懒的 Vanilla 和最讲究的 Visual


3. VanillaReplayStrategy:把一切交给模型

思路

文件开头引了一句合作者的话,把这个策略的哲学说透了(strategies/vanilla.py:29-30):

如果哪天真出了 AGI 或者 GPT-6,这个脚本应该突然就能干活了。

做法分两步:

  1. 构造期:把整个录制(每个动作的字典 + 每个窗口的字典 + 每张截图)一次性喂给模型,让它写一段「这次演示发生了什么」的自然语言描述(describe_recording,:106-154)。
  2. 每一步:把当前截图、当前窗口、原始动作列表、已回放动作列表、用户的修改指令一起给模型,让它吐出下一个动作的 JSON(generate_action_event,:157-211)。

关键实现细节

模型输出的 JSON 直接反序列化成 ActionEvent:

# 真实源码节选,legacy/openadapt/strategies/vanilla.py:204-209
action_dict = utils.parse_code_snippet(content)
if not action_dict:
# allow early stopping
return None
action = models.ActionEvent.from_dict(action_dict)

空输出 = 提前停止,这是一个很轻的收尾协议。

ActionEvent.from_dict(models.py:383-463)负责把模型给的松散字典变回合法事件,包括从文本重建按键子事件(_create_key_events,models.py:465-489)。

边界

每一步都要发一次多模态请求,慢且贵,而且同一个输入两次可能给出不同动作。这正是今天的产品线要摆脱的东西——README 反复强调「健康路径上零模型调用」。


4. VisualReplayStrategy:把坐标换成描述

这是 legacy 里最值得学的一支。文件头的三步说明(strategies/visual.py:1-30)就是完整算法。

它要解决的小问题

坐标是脆的:窗口挪一下就废。要找一个换了环境还成立的锚点。

OpenAdapt 选的锚点是:「这个元素叫什么」的自然语言描述,比如「标着 Save 的蓝色按钮」。

三步流程图

怎么读这张图:上半段发生在回放开始前(离线),下半段发生在每一步回放时(在线)。

【离线,构造期】
录制里的每个鼠标动作

├─→ 分割它当时的窗口截图 ──→ 一堆掩膜
├─→ 每个掩膜让模型起个名字 ──→ descriptions[]
└─→ 动作坐标落在哪个框里 ──→ 这个动作的 active_segment_description

用户的自然语言指令 ──────┤

模型改写整条动作序列(坐标已被抹掉)

【在线,每一步】
截当前屏 ──→ 分割当前窗口 ──→ descriptions[]

要找的 active_segment_description ── 精确匹配 ──→ 命中的下标
│ │
│ 没命中 ▼
└─→ 把异常写进 prompt 取该片段质心
重新分割再试 │

质心 ÷ 缩放比 + 窗口左上角 = 屏幕坐标

第一步:给每个动作打上「你点的是什么」

add_active_segment_descriptions(visual.py:92-111)遍历所有鼠标事件,对每个:分割窗口、找出坐标落在哪个包围盒里、把那个片段的描述写进 action.active_segment_description

找包围盒的函数是 get_active_segment(visual.py:257-333)。它必须先做坐标换算:

# 真实源码节选,legacy/openadapt/strategies/visual.py:280-281
adjusted_mouse_x = (action.mouse_x - action.window_event.left) * width_ratio
adjusted_mouse_y = (action.mouse_y - action.window_event.top) * height_ratio

屏幕坐标 → 减去窗口左上角 → 乘以缩放比 = 裁剪图里的坐标。缩放比来自 utils.get_scale_ratios(legacy/openadapt/utils.py:325),存在的原因是 Retina/HiDPI 屏上截图像素和逻辑坐标不是 1:1

第二步:抹掉坐标,让模型改写流程

这一步的精髓藏在序列化函数里。ActionEvent.to_prompt_dict(models.py:500-540):

# 真实源码节选,legacy/openadapt/models.py:517-520
if self.active_segment_description:
for key in ("mouse_x", "mouse_y", "mouse_dx", "mouse_dy"):
if key in action_dict:
del action_dict[key]

只要这个动作有了描述,坐标就从给模型看的字典里删掉。

妙在哪: 不给模型看坐标,模型就没法「照抄坐标」,只能在描述层面思考。这是用接口设计约束模型行为,而不是靠 prompt 里写「请不要输出坐标」。

apply_replay_instructions(visual.py:114-155)带 @utils.retry_with_exceptions() 装饰器——失败时把上次的异常一起放进下次的 prompt,让模型看到自己上次错在哪。

第三步:回放时按描述重新定位

get_next_action_event(visual.py:187-247)的核心是一个「不匹配就重来」的循环:

# 真实源码节选,legacy/openadapt/strategies/visual.py:223-236
while True:
active_window_segmentation = get_window_segmentation(
modified_reference_action, exceptions=exceptions,
)
try:
target_segment_idx = active_window_segmentation.descriptions.index(
modified_reference_action.active_segment_description
)
except ValueError as exc:
exceptions.append(exc)
else:
break

匹配用的是 list.index(),也就是字符串精确相等。匹配不上就把 ValueError 塞进 exceptions,重新分割、重新让模型命名,再试。

这是这个策略最脆的一环。 精确字符串匹配依赖模型两次对同一个元素给出一模一样的描述。而且这个 while True 没有次数上限——模型如果始终给不出那个字符串,循环不会退出。

匹配上之后换回坐标(visual.py:237-245):

target_mouse_x = target_centroid[0] / width_ratio + active_window.left
target_mouse_y = target_centroid[1] / height_ratio + active_window.top

和第一步的换算正好互逆:质心 ÷ 缩放比 + 窗口左上角


5. 分割是怎么做的

get_window_segmentation(visual.py:373-456)是 Visual 策略的引擎室。

流水线

截图裁到活动窗口(Screenshot.cropped_image)

├─→ 先查缓存:和历史图 SSIM > 0.9 ? ── 命中 ──→ 直接复用旧分割


分割适配器 fetch_segmented_image ← Set-of-Mark 服务


get_masks_from_segmented_image ← 按「颜色 → 掩膜」还原


refine_masks ← 去边框 / 去毛刺 / 填洞 / 去太小 / 去被包含

├─→ extract_masked_images ──→ 每个片段一张小图
│ └─→ prompt_for_descriptions ──→ 每张小图一句话描述
└─→ calculate_bounding_boxes ──→ 包围盒 + 质心

图里有两个术语要先说清:

  • SSIM(结构相似性指数,Structural Similarity Index):一个 0 到 1 的图像相似度分数,1 表示两张图结构完全一致。这里的门槛写死在 MIN_SCREENSHOT_SSIM = 0.9(visual.py:60),意思是「当前截图和缓存里某张够像,就别再花钱分割一次」。
  • Set-of-Mark(SoM):一种把界面元素逐个标注出来(编号或上色)、再让多模态模型按标记指认元素的提示方法。这条流水线只用它的分割输出——一张「每个元素一种纯色」的图,后面自己按颜色还原掩膜。

两个值得记的实现点

掩膜是从颜色反推的。 get_masks_from_segmented_image(legacy/openadapt/vision.py:16-56)的做法是:找出分割图里所有唯一颜色,每种颜色生成一个布尔掩膜。这意味着分割服务必须返回无损图像——一旦被压缩,颜色数暴增,掩膜就废了。

这个隐患是真实存在的:SoM 适配器 fetch_segmented_image(legacy/openadapt/adapters/som.py:78-96)第一行就是 raise NotImplementedError,理由写得很清楚:

SoM 服务器会压缩分割后的图,导致颜色数远多于掩膜数。

所以默认走的是别的适配器(adapters/__init__.py:21,get_default_segmentation_adapter)。

掩膜清洗是一串朴素但有效的形态学操作。 refine_masks(vision.py:88-128)依次:去掉贴边的掩膜(多半是背景)、去掉细长毛刺、binary_fill_holes 填洞、按包围盒尺寸过滤太小的、最后去掉完全被另一个掩膜包含的(用 np.array_equal(mask_i & mask_j, mask_i) 判断)。这些函数都带 @cache.cache() 装饰器。

描述生成的两层容错

prompt_for_descriptions(visual.py:459-534)有两层递归:

  1. 图太多就分批。 驱动有 MAX_IMAGES 限制时,把掩膜图切成若干批,每批递归调用一次再拼起来(:479-493)。注意 MAX_IMAGES - 1——要给原图留一个位置。
  2. 数量对不上就重试。 断言描述数 == 掩膜数,失败就把异常追加进 exceptions 再递归重来(:516-530)。

模型驱动本身也有 fallback 链:DRIVER_ORDER = [openai, google, anthropic](adapters/prompt.py:11),挨个试到成功为止。

VisualBrowserReplayStrategy 里有一份几乎逐行相同的 prompt_for_descriptions(visual_browser.py:596-)——两支策略共用同一套容错逻辑,只是各自复制了一份。


6. 最后一步:注入

legacy/openadapt/playback.py 只有 119 行,是全流程最薄的一环。

  • play_mouse_event(:10-51):先设 mouse_controller.position,再按事件名分派 press/release/click(1)/click(2)/scroll
  • play_key_event(:54-78):优先用 canonical_key(:69),这样跨键盘布局仍然对。
  • play_action_event(:81-119):有子事件的键盘事件递归回放子事件——这就是 03 章父子树设计的兑现点(「输入 hello」这个父事件好读,真正要注入的是它的五个子按键)。

7. 边界与已知弱点

问题在哪后果
描述用精确字符串匹配visual.py:229-231模型措辞一变就找不到,循环重试
重试循环无上限visual.py:223极端情况下不退出
分割缓存直接整份复用visual.py:398-402相似但不同的界面会拿到过时的分割(TODO XXX 承认了)
长得一样的片段分不清visual.py:33-37表格单元格这类界面失效,handle_similar_image_groups 直接 raise ValueError(:426)
分割结果不落库visual.py:59(SEGMENTATIONS = [])进程一退全丢,重启要重新花钱调模型
异常时进交互式调试器base.py:112-114无人值守环境挂死
DOM 分支的缓存明知有问题visual_browser.py:3(# TODO XXX: fix caching)浏览器策略的缓存路径默认关着(return_similar_segmentation=False,:351)

8. 代码地图

主题文件路径符号名
回放主循环骨架legacy/openadapt/strategies/base.pyBaseReplayStrategyrun
动作完成度判定legacy/openadapt/strategies/base.pyprompt_is_action_complete
策略导出清单legacy/openadapt/strategies/__init__.py六种具体策略 + BaseReplayStrategy
策略选择与运行legacy/openadapt/replay.pyreplay
全模型驱动策略legacy/openadapt/strategies/vanilla.pyVanillaReplayStrategydescribe_recordinggenerate_action_event
描述锚点策略legacy/openadapt/strategies/visual.pyVisualReplayStrategyadd_active_segment_descriptionsapply_replay_instructions
窗口分割legacy/openadapt/strategies/visual.pyget_window_segmentationfind_similar_image_segmentationprompt_for_descriptions
坐标↔片段换算legacy/openadapt/strategies/visual.pyget_active_segment
DOM 版描述锚点策略legacy/openadapt/strategies/visual_browser.pyVisualBrowserReplayStrategyget_dom_masksget_tlbr
缩放比legacy/openadapt/utils.pyget_scale_ratios
掩膜提取与清洗legacy/openadapt/vision.pyget_masks_from_segmented_imagerefine_maskscalculate_bounding_boxes
分割适配器legacy/openadapt/adapters/som.pyfetch_segmented_imagepredict
模型驱动 fallbacklegacy/openadapt/adapters/prompt.pyDRIVER_ORDERprompt
坐标抹除legacy/openadapt/models.pyActionEvent.to_prompt_dictActionEvent.from_dict
输入注入legacy/openadapt/playback.pyplay_action_eventplay_mouse_eventplay_key_event