数据截至 (上游 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_descriptions、apply_replay_instructions、get_active_segment、get_window_segmentation、prompt_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,这个脚本应该突然就能干活了。
做法分两步:
- 构造期:把整个录制(每个动作的字典 + 每个窗口的字典 + 每张截图)一次性喂给模型,让它写一段「这次演示发生了什么」的自然语言描述(
describe_recording,:106-154)。 - 每一步:把当前截图、当前窗口、原始动作列表、已回放动作列表、用户的修改指令一起给模型,让它吐出下一个动作的 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)有两层递归:
- 图太多就分批。 驱动有
MAX_IMAGES限制时,把掩膜图切成若干批,每批递归调用一次再拼起来(:479-493)。注意MAX_IMAGES - 1——要给原图留一个位置。 - 数量对不上就重试。 断言描述数 == 掩膜数,失败就把异常追加进
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.py | BaseReplayStrategy、run |
| 动作完成度判定 | legacy/openadapt/strategies/base.py | prompt_is_action_complete |
| 策略导出清单 | legacy/openadapt/strategies/__init__.py | 六种具体策略 + BaseReplayStrategy |
| 策略选择与运行 | legacy/openadapt/replay.py | replay |
| 全模型驱动策略 | legacy/openadapt/strategies/vanilla.py | VanillaReplayStrategy、describe_recording、generate_action_event |
| 描述锚点策略 | legacy/openadapt/strategies/visual.py | VisualReplayStrategy、add_active_segment_descriptions、apply_replay_instructions |
| 窗口分割 | legacy/openadapt/strategies/visual.py | get_window_segmentation、find_similar_image_segmentation、prompt_for_descriptions |
| 坐标↔片段换算 | legacy/openadapt/strategies/visual.py | get_active_segment |
| DOM 版描述锚点策略 | legacy/openadapt/strategies/visual_browser.py | VisualBrowserReplayStrategy、get_dom_masks、get_tlbr |
| 缩放比 | legacy/openadapt/utils.py | get_scale_ratios |
| 掩膜提取与清洗 | legacy/openadapt/vision.py | get_masks_from_segmented_image、refine_masks、calculate_bounding_boxes |
| 分割适配器 | legacy/openadapt/adapters/som.py | fetch_segmented_image、predict |
| 模型驱动 fallback | legacy/openadapt/adapters/prompt.py | DRIVER_ORDER、prompt |
| 坐标抹除 | legacy/openadapt/models.py | ActionEvent.to_prompt_dict、ActionEvent.from_dict |
| 输入注入 | legacy/openadapt/playback.py | play_action_event、play_mouse_event、play_key_event |