OpenAdapt 源码拆解:录制一次,如何实现确定性回放
OpenAdapt 是一款开源 RPA(Robotic Process Automation)工具,核心理念是"录制一次,确定性回放"。
本文基于 OpenAdapt v1.25.1 代码库进行源码级分析,涉及约 28,000 行 Python 代码,重点拆解它的使用流程、后端体系、录制---编译---回放机制,以及当前版本在动态页面、数据提取和调试方面的局限。
一、核心工作流:录制、编译、回放
OpenAdapt 的主流程可以概括为:
text
record → compile → replay
此外,项目还提供批量循环、故障修复、认证检查、性能测试和 Bundle 导出等命令。

1. 录制:record
录制阶段启动一个带浏览器或桌面环境的会话,自动捕获点击、输入、按键、滚动和拖拽等操作。
bash
# 最简方式:打开指定 URL 的浏览器并开始录制
python -m openadapt_flow record --url https://example.com
# 指定输出目录和名称
python -m openadapt_flow record --url https://example.com \
--out my_recordings/ --name "登录流程"
# 桌面端录制(Windows 原生应用)
python -m openadapt_flow record --backend windows --agent-url http://localhost:8080 \
--identifier 0,0,1920,1080
# RDP 远程桌面录制
python -m openadapt_flow record --backend rdp --rdp-host 192.168.1.100 \
--rdp-user admin --rdp-password pass123
# 参数化录制:将输入值声明为参数(回放时可替换)
# 在录制脚本中使用 recorder.type_text("张三", param="patient_name")
录制产物是一个包含原始证据的目录:
| 文件 | 内容 |
|---|---|
meta.json |
viewport、URL、参数列表、时间戳 |
events.jsonl |
每行一个事件:kind / x / y / text / url_before / url_after / structural_state ... |
frames/0000_before.png |
第 0 步执行前截图 |
frames/0000_after.png |
第 0 步执行后截图 |
frames/0001_before.png |
第 1 步执行前截图,即上一步的 after |
录制阶段不做 OCR、不做计算机视觉,也不调用模型。它只负责截图和 JSON 序列化,把"理解画面"的工作推迟到编译阶段。
这样做的直接好处是录制过程足够轻量,不会因为分析画面而明显干扰用户操作节奏。
2. 编译:compile
编译器读取录制目录,生成一个可独立回放的 Bundle。整个过程是纯本地计算,不联网。
bash
# 基本编译
python -m openadapt_flow compile recording_xxx/ --out bundle_xxx/
# 编译时确认参数(交互式)
python -m openadapt_flow compile recording_xxx/ --out bundle_xxx/ --accept-params patient_name
# 从文件读取参数确认
python -m openadapt_flow compile recording_xxx/ --out bundle_xxx/ --params-from decisions.json
编译产物包括:
| 文件 | 用途 |
|---|---|
workflow.json |
完整的 Workflow IR:步骤定义、锚点、后置条件、参数 |
manifest.json |
内容完整性校验清单(content_digest) |
templates/*.png |
每步的模板裁剪(160×64 px),回放时做模板匹配 |
workflow.py |
人类可读的 Python 伪代码,仅用于审查,不参与执行 |
3. 回放:replay
bash
# 无头回放(默认,不弹出浏览器窗口)
python -m openadapt_flow replay bundle_xxx/
# 有窗口回放(观察执行过程)
python -m openadapt_flow replay bundle_xxx/ --headed
# 指定部署配置(连接真实系统、启用效果验证)
python -m openadapt_flow replay bundle_xxx/ --config deployment.yaml
回放时,系统根据编译阶段生成的锚点定位目标,并在执行操作前验证当前画面是否仍然符合录制时的状态。
4. 批量循环:for-each
for-each 对预先定义的工作列表逐行执行同一个 Bundle 的循环体。输入格式可以是 CSV 或 JSON。
bash
# 准备 worklist.csv:
# patient_name
# 张三
# 李四
# 王五
python -m openadapt_flow compile recording_xxx/ --accept-params patient_name
python -m openadapt_flow for-each bundle_xxx/ --worklist worklist.csv
循环次数由 worklist 的行数预先决定,不能在运行时从页面动态提取目标。这是 OpenAdapt 与 Playwright 脚本之间的一个重要区别。
5. 其他命令
| 命令 | 功能 |
|---|---|
demo-record |
启动 MockMed 模拟医疗系统并录制标准演示流程 |
tutorial |
运行交互式教学 |
induce |
从多个录制中归纳参数化程序(多轨迹归纳) |
run |
完整录制 + 编译 + 回放,一站式执行 |
resume |
从上次中断处继续回放 |
repair |
对回放失败的 Bundle 进行故障修复 |
lint |
静态检查 Bundle 的覆盖率和风险等级 |
certify |
按策略(demo/standard/regulated)认证 Bundle 是否可安全无人值守运行 |
bench |
对 Bundle 重复回放 N 次并汇总成功率 |
visualize |
生成 Bundle 的可视化程序图 |
seal |
将 Bundle 密封打包,禁止修改 |
sanitize |
脱敏:擦除录制中的敏感数据(姓名、身份证号等) |
emit-skill |
将 Bundle 导出为 AI Agent 可调用的 Skill |
emit-mcp |
将 Bundle 导出为 MCP 工具 |
二、后端体系:同一套编译逻辑适配不同环境
OpenAdapt 通过 backend.py 中的 Protocol 体系支持多种驱动方式,具体后端由 --backend 参数选择。
后端只负责两件事:截图和执行操作,不参与编译逻辑。
| 后端 | 标记 | 底层驱动 | 画面来源 | 操作方式 | 适用场景 |
|---|---|---|---|---|---|
| Web | 默认 | Playwright / Chromium | 浏览器视口截图 | Playwright 鼠标/键盘 API | Web 应用自动化(最常见) |
| Windows | --backend windows |
WAA HTTP Agent | 桌面截图 API | 通过 agent HTTP 接口发送坐标操作 | Windows 原生桌面应用 |
| macOS | --backend macos |
macOS Accessibility API | 单应用窗口截图 | Accessibility 动作 + 坐标点击 | macOS 原生应用 |
| Linux | --backend linux |
AT-SPI | 单应用窗口截图 | AT-SPI 动作,可选全局指针/键盘兜底 | Linux 桌面应用 |
| RDP | --backend rdp |
FreeRDP / aardwolf | 远程桌面画面截图 | 坐标点击/输入(像素级) | 远程桌面、VDI、堡垒机 |
| Citrix | --backend citrix |
Citrix Workspace 窗口驱动 | Citrix 客户端窗口截图 | 本地窗口内坐标操作 | Citrix 虚拟桌面 |
Protocol 分层
OpenAdapt 的后端不是一个必须全部实现的单一接口,而是一组可选 Protocol。编译器根据后端具备的能力,决定最终能生成哪些锚点和验证信息。
| Protocol | 提供的能力 | 缺失的后果 |
|---|---|---|
StructuralBackend |
URL、page_title、page_count | 无法生成 structural postcondition |
IdentityBackend |
DOM/a11y 结构化文本 structured_text_at(x,y) |
只能靠 OCR 做身份验证,存在 O/0、l/1 字形混淆风险 |
StructuralActionBackend |
DOM 选择器 / UIA 标识符 | 只能用视觉模板匹配定位,无法使用 CSS selector |
FieldLabelBackend |
当前聚焦字段的标签文本 | 编译时只能用 OCR 推测字段标签 |
EffectBackend |
系统记录的写入痕迹,如数据库变更 | 无法验证操作是否真的写入系统 |
TextIntegrityBackend |
输入框的实际字符值,非 OCR 猜测 | 输入文本只能通过 OCR 读回验证 |
Web 后端的能力最完整。它基于 Playwright 实现了全部 Protocol,因此浏览器录制的 Bundle 能生成更丰富的锚点和验证信息,包括 DOM 选择器、结构化身份信息和视觉模板。
RDP、Citrix 等后端则更接近纯像素模式,主要依赖视觉模板匹配和 OCR。
三、源码级实现原理
OpenAdapt 的核心架构分成 Recorder、Compiler 和 Replayer 三个阶段。以下分析基于 v1.25.1 源码,相关代码位于 openadapt_flow 包内。

1. Recorder:只记录证据
Recorder 约有 484 行,核心逻辑位于 recorder.py:260 的 Recorder._record() 方法。
每次用户操作大致经历以下步骤:
python
# recorder.py:260 _record() 方法的执行流程(简化)
1. 截取 before_png = backend.screenshot()
2. 采集 structural_state(URL / title / page_count)
3. 执行操作:backend.click(x, y) / backend.type_text(text) / ...
4. 等待画面静止:_wait_settled()
└─ 轮询截图,直到连续 N 帧 phash 一致(默认超时 2s)
5. 截取 after_png = backend.screenshot()
6. 写入 events.jsonl 一行,保存 before.png / after.png
画面静止检测
_wait_settled() 位于 recorder.py:460,使用感知哈希(phash)判断页面是否完成渲染:
python
# recorder.py:460 _wait_settled() 核心逻辑
deadline = time.monotonic() + self._settle_timeout_s # 默认 2 秒
png = self._backend.screenshot()
prev = _phash(png)
consecutive = 1
while consecutive < SETTLE_CONSECUTIVE and time.monotonic() < deadline:
time.sleep(SETTLE_INTERVAL_S) # 默认 0.3 秒
png = self._backend.screenshot()
curr = _phash(png)
if phash_distance(prev, curr) == 0:
consecutive += 1
else:
consecutive = 1
prev = curr
return png # 最后一张稳定帧
结构化信息采集
录制器还会被动记录 structural_state,代码位于 recorder.py:433:
python
# recorder.py:433 _structural_state()
state = {}
for attr, key in (("url", "url"), ("page_title", "title"), ("page_count", "pages")):
value = getattr(self._backend, attr, None)
if value is not None:
state[key] = value
return state
这一步不负责理解页面,只是尽可能保留后续编译和回放可以利用的结构化信息。
2. Compiler:从截图和事件生成 Bundle
Compiler 约有 2,203 行,是整个系统最复杂的模块。它将录制目录转换为包含 Workflow IR、模板图片、后置条件和完整性清单的 Bundle。
锚点生成
锚点解决的是回放阶段的核心问题:如何在当前画面中找到录制时操作过的目标。
相关逻辑位于 compile.py:1581:
python
# compile.py:1581 锚点构建逻辑(简化)
# 1. 模板裁剪
crop_region = _discriminative_crop_region(frame, click_point)
# 默认 160×64,如像素方差不足则阶梯扩至 640×256
# CROP_GROWTH_LADDER = ((160,64), (240,96), (320,128), (480,192), (640,256))
# 2. OCR 文字
ocr_text = _best_crop_text(ocr(before_png, region=crop_region))
# 3. 上下文文字(用于身份验证)
context_text = identifier_text_from_lines(frame_lines, ...)
# 4. 地标(用于几何校准)
landmarks = _landmarks_for(frame_lines, crop_region, click_point)
# 取帧内 10~12 个最显著的 OCR 行作为锚点参照系
# 5. 结构化定位器(DOM / UIA)
structural = StructuralLocator.model_validate(event.get("structural"))
# 最终 Anchor 对象
anchor = Anchor(
template=template_rel, # 模板 PNG 路径
region=crop_region, # 裁剪区域坐标
click_point=click, # 点击坐标
ocr_text=ocr_text, # 锚点文字
context_text=context_text, # 身份上下文
structural=structural, # DOM 选择器
landmarks=landmarks, # 地标列表
)
一个 Anchor 可以同时包含模板、OCR 文本、上下文文字、结构化定位器和地标。回放时,系统可以根据当前后端能力选择其中的一部分。
后置条件挖掘
编译器会从每一步的 before/after 帧差异中自动推断操作完成后的验证条件。相关入口是 compile.py:656 的 _postconditions()。
REGION_STABLE
REGION_STABLE 用于验证某个区域在操作后达到预期稳定状态。
python
# compile.py:722 REGION_STABLE 生成逻辑
# 1. 像素差分找最大变化区域
changed = _largest_changed_region(before_png, after_png)
# 流程:cv2.absdiff → 阈值化(25) → cv2.findContours → 最大连通区域
# 2. 添加 24px 内边距(包裹结构边框)
padded = _pad_region(changed, frame_w, frame_h)
# 3. 自变异检测(过滤动画/时钟/toast)
if next_before_png is not None:
phash_now = phash_png(after_png, region=padded)
phash_next = phash_png(next_before_png, region=padded)
if phash_distance(phash_now, phash_next) > REGION_STABLE_TOLERANCE:
changed = None # 丢弃!该区域是自变异的
# 4. 参数隔离(如果区域含有参数文字则切掉参数行)
if exclude_texts and _param_text_in_region(padded, after_lines, exclude_texts):
band = _param_free_band(padded, carriers, after_lines)
if band is not None:
padded = band
# 5. 生成 postcondition
expect.append(Postcondition(
kind=PostconditionKind.REGION_STABLE,
region=padded,
phash=phash_png(after_png, region=padded),
phash_tolerance=REGION_STABLE_TOLERANCE, # 16
))
处理过程包括:
- 使用
cv2.absdiff、阈值化和轮廓分析寻找最大变化区域; - 在区域外扩 24px,尽量包含相关结构边框;
- 使用
next_before_png检测动画、时钟和 Toast 等自变异内容; - 如果区域包含参数文字,则尝试排除参数行;
- 用区域 phash 和容差
16生成后置条件。
TEXT_PRESENT
TEXT_PRESENT 用于检测操作后出现的新文本,核心逻辑位于 compile.py:536 的 _new_text_postcondition()。
编译器会比较 before 和 after 帧中的 OCR 文本,只保留 after 帧新增且满足条件的文本:
python
# compile.py:536 _new_text_postcondition() 核心逻辑
# 1. OCR after 帧,找出 before 帧中不存在的新文本行
new_lines = [line for line in after_lines if not seen_before(line)]
# 2. 过四道过滤器
for line in new_lines:
text = normalize_text(line.text)
# 过滤器 A:长度 ≥ MIN_TEXT_PRESENT_LEN (3)
if len(text) < 3: continue
# 过滤器 B:挥发性分类器
if volatility.is_volatile(text, reference_date): continue
# 拦截:时钟(18:38)、相对时间(3 min ago)、计数器(1-5 of 12)
# 过滤器 C:点击区域去重
if _matches_click_text(text, click_text): continue
# 过滤器 D:已在前一帧可见(模糊匹配)
if _was_visible_in_before(text, before_lines): continue
candidates.append((score, text))
# 3. 取得分最高的一个
return Postcondition(kind=PostconditionKind.TEXT_PRESENT, text=chosen)
它会过滤以下内容:
- 长度小于
MIN_TEXT_PRESENT_LEN (3)的文本; - 时钟、相对时间和分页计数器;
- 点击区域原本已经存在的文本;
- before 帧中已经可见的文本。
最后只选择得分最高的候选文本。
结构性兜底
当连续两步没有挖掘出视觉后置条件时,编译器会尝试使用结构信息生成兜底条件,代码位于 compile.py:880:
python
# compile.py:880 _structural_postconditions()
# 仅当前两步挖出零视觉后置条件时触发
pcs = []
if pages_after > pages_before:
pcs.append(Postcondition(kind=PostconditionKind.NEW_TAB_OPENED))
if url_before != url_after:
pcs.append(Postcondition(kind=PostconditionKind.URL_CHANGED))
elif title_before != title_after:
pcs.append(Postcondition(kind=PostconditionKind.TITLE_CHANGED))
return pcs
3. Volatility Classifier:过滤不可靠证据
volatility.py 共有 366 行,是编译器中用于降低误判的重要防御模块。它通过正则表达式识别不适合作为稳定证据的文本。
| 分类器 | 拦截模式 | 示例 | 原因 |
|---|---|---|---|
CLOCK_RE |
\d{1,2}:\d{2} 及 OCR 片段 :01 |
18:38、6:05、12:45:59 | 时钟分钟会走 |
DOT_CLOCK_RE |
\d+\.\d{2} 欧式时钟 |
18.38、updated 8.30 | 同上 |
RELATIVE_TIME_RE |
`\d+ (min | hour)s? ago` | 3 min ago、just now、moments ago |
COUNT_RE |
\d+ to \d+ of \d+ |
1 to 5 of 12 entries | 分页计数是瞬时状态 |
| 相对日期词 | 独立的 today / yesterday / now | Today、Yesterday | 作为消息列表分组头时是瞬时的 |
| 近日期 | 距离录制日期 < 7 天的日期 | 2026-08-06(录制日期附近) | 内容时间线而非身份数据 |
日期处理是这个分类器中比较细的一点:距离录制日期 ≤7 天的日期被视为内容时序,属于易变化信息;较远的日期,例如出生日期 DOB,则更可能被视为身份数据。
4. 内容完整性校验
编译完成后,manifest.json 会写入 content_digest。
该值由 compute_content_digest() 计算,先使用 model_dump(exclude_none=True) 规范化序列化,再计算 SHA256,而不是直接使用简单的 json.dumps。
因此,任何对 workflow.json 的修改都需要重新计算 digest,否则回放时可能触发完整性校验错误。
5. Replayer:从结构化定位逐级降级
回放引擎使用多级分辨率阶梯定位目标,相关 Resolution 枚举定义在 ir.py 中:
text
1. structural # DOM 选择器 / UIA 标识符 → 直接定位(毫秒级)
2. template_match # 模板 PNG 在当前帧上做 cv2.matchTemplate
3. ocr # OCR 当前帧,用锚点文本匹配
4. landmark # 用地标的 OCR 位置做几何变换推算目标位置
5. geometry # 纯坐标偏移(最后兜底)
某一级定位失败后,引擎会自动降级到下一级。历史 replay 日志中的 heal_events 字段会记录每次降级的详细信息。
这套机制的关键不在于某一种定位算法足够稳定,而在于把多种定位证据组合起来:Web 页面优先使用 DOM 结构,纯像素环境则更多依赖模板、OCR、地标和几何关系。
6. 身份验证:先确认目标,再执行操作
在点击或输入之前,回放引擎会先确认当前画面中的目标是否仍然是录制时的目标。
流程如下:
- 以点击点为中心,根据
crop_height和当前帧尺寸生成水平的身份带; - 对身份带执行 OCR;
- 将当前文本与录制时的
context_text比对; - 如果是参数化字段,则使用当前运行参数重新匹配;
- 匹配成功后执行操作,匹配失败则降级到下一级分辨率策略。

四、当前版本的局限
1. 后置条件无法在录制时控制
postcondition 完全由编译器自动生成,用户没有 CLI 参数或 API 可以直接干预。
对于搜索引擎、实时数据面板等动态页面,编译器生成的 region_stable 可能监控到持续变化的区域,导致 replay 必然 HALT。
当前的处理方式是编译后手动编辑 workflow.json,清空有问题的 expect 数组,再重算 content_digest。这个过程没有官方工具支持。
2. 运行时不能动态决策
OpenAdapt 的 loop authoring 要求预先声明 CSV/JSON worklist,运行时不能从页面动态提取目标。
因此,类似"搜索关键词 → 爬取前 N 个结果"的场景不适合直接用 OpenAdapt 实现。
根因在于 Workflow 是静态 IR:所有步骤在编译时确定,运行时只负责按图执行和自愈,不负责发现新目标。
3. 帧差分区域可能过大
_largest_changed_region() 在 URL 跳转或整页刷新时,可能返回接近全屏的区域。此时生成的 region_stable 会监控整个视口。
如果页面中存在时钟、广告轮播等动态元素,全屏 phash 几乎不可能保持稳定。
源码位置:compile.py 中的 _largest_changed_region() 只取最大连通区域,不做语义分割。
4. 自变异检测依赖录制时的帧间隔
编译器使用 next_before_png 与 after_png 比较,判断区域是否自变异。
如果录制时操作节奏太快,时钟还没有跳到下一分钟,或者动画还没有播放完,自变异检测就可能漏判。
源码位置:compile.py:722。当前逻辑只比较两帧 phash 距离,不做时序建模。
5. 不支持跨页爬取和数据提取
OpenAdapt 的目标是自动化操作,而不是数据抓取。它没有内置 DOM 提取、列表遍历和分页处理能力。
例如,Playwright 可以直接使用:
python
page.locator('.result-item')
来提取元素列表;OpenAdapt 只能识别录制时见过的那个特定坐标。
6. 纯像素后端的身份验证存在边界
在 RDP/Citrix 等纯像素后端中,没有 StructuralActionBackend 和 IdentityBackend,身份验证只能依赖 OCR。
OCR 无法可靠区分 O/0、l/1、I/l 等字形。在 backend.py:126 的文档中,源码明确承认:
"An adversarial review proved the OCR-only identity path cannot close the same-name / same-DOB glyph-collapse case: two DIFFERENT patients whose MRN differs only by an O/0 or l/1 glyph render to a byte-identical OCR band --- so no function downstream of OCR can distinguish them"
这意味着,在关键身份字段只存在字形差异时,后续代码无法从 OCR 结果中恢复丢失的信息。
7. 编译产物不便维护
编译后的 workflow.json 内部嵌入了 manifest,同时又有独立的 manifest.json。修改 workflow 后,需要同步更新两处的 content_digest。
常见错误是只修改了 manifest.json,没有同步更新 workflow.json 内嵌的 content_digest,最终触发 BundleIntegrityError。
更根本的问题是,项目没有提供官方的 post-hoc 编辑工具或 CLI。修改 Bundle 只能手工编辑 JSON,再调用库函数重算 digest。
8. 默认执行过程不可见
replay 默认使用 headless 模式。--headed 是 store_true,默认值为 False,因此:
text
headless = not headed = True
用户看不到浏览器窗口,只能等待 REPORT 输出,调试体验较差。
源码位置:__main__.py:3338 定义了 --headed action="store_true",与 record 的默认可见行为相反。
五、结论:OpenAdapt 适合什么场景
从 v1.25.1 的实现来看,OpenAdapt 更适合以下类型的自动化任务:
- 操作步骤相对固定;
- 页面或桌面环境变化有限;
- 任务目标可以在录制前明确;
- 需要在 Web、Windows、macOS、Linux、RDP 或 Citrix 等环境中复现操作;
- 希望通过截图、OCR、结构化定位器和后置条件提高回放可靠性。
它不适合直接替代 Playwright 等数据抓取工具,也不适合需要运行时探索、动态生成任务分支或复杂列表遍历的流程。
OpenAdapt 的核心价值不是"让自动化拥有无限适应能力",而是把一次人工操作转换成包含视觉证据、结构化锚点和执行后验证的静态 Bundle。它的可靠性来自录制证据和多级回放策略,同时也受静态 Workflow、自动生成后置条件和 OCR 能力边界的限制。
免责声明:本文档基于 OpenAdapt v1.25.1 源码逆向分析生成,所有代码片段均来自实际源码文件。分析日期:2026-08,源码总行数约 28,000 行 Python。