OpenAdapt 源码拆解:录制一次,如何实现确定性回放 OpenAdapt 源码拆解录制一次如何实现确定性回放OpenAdapt 是一款开源 RPARobotic Process Automation工具核心理念是“录制一次确定性回放”。本文基于 OpenAdapt v1.25.1 代码库进行源码级分析涉及约 28,000 行 Python 代码重点拆解它的使用流程、后端体系、录制—编译—回放机制以及当前版本在动态页面、数据提取和调试方面的局限。一、核心工作流录制、编译、回放OpenAdapt 的主流程可以概括为record → compile → replay此外项目还提供批量循环、故障修复、认证检查、性能测试和 Bundle 导出等命令。1. 录制record录制阶段启动一个带浏览器或桌面环境的会话自动捕获点击、输入、按键、滚动和拖拽等操作。# 最简方式打开指定 URL 的浏览器并开始录制python-mopenadapt_flow record--urlhttps://example.com# 指定输出目录和名称python-mopenadapt_flow record--urlhttps://example.com\--outmy_recordings/--name登录流程# 桌面端录制Windows 原生应用python-mopenadapt_flow record--backendwindows --agent-url http://localhost:8080\--identifier0,0,1920,1080# RDP 远程桌面录制python-mopenadapt_flow record--backendrdp --rdp-host192.168.1.100\--rdp-user admin --rdp-password pass123# 参数化录制将输入值声明为参数回放时可替换# 在录制脚本中使用 recorder.type_text(张三, parampatient_name)录制产物是一个包含原始证据的目录文件内容meta.jsonviewport、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。整个过程是纯本地计算不联网。# 基本编译python-mopenadapt_flow compile recording_xxx/--outbundle_xxx/# 编译时确认参数交互式python-mopenadapt_flow compile recording_xxx/--outbundle_xxx/ --accept-params patient_name# 从文件读取参数确认python-mopenadapt_flow compile recording_xxx/--outbundle_xxx/ --params-from decisions.json编译产物包括文件用途workflow.json完整的 Workflow IR步骤定义、锚点、后置条件、参数manifest.json内容完整性校验清单content_digesttemplates/*.png每步的模板裁剪160×64 px回放时做模板匹配workflow.py人类可读的 Python 伪代码仅用于审查不参与执行3. 回放replay# 无头回放默认不弹出浏览器窗口python-mopenadapt_flow replay bundle_xxx/# 有窗口回放观察执行过程python-mopenadapt_flow replay bundle_xxx/--headed# 指定部署配置连接真实系统、启用效果验证python-mopenadapt_flow replay bundle_xxx/--configdeployment.yaml回放时系统根据编译阶段生成的锚点定位目标并在执行操作前验证当前画面是否仍然符合录制时的状态。4. 批量循环for-eachfor-each对预先定义的工作列表逐行执行同一个 Bundle 的循环体。输入格式可以是 CSV 或 JSON。# 准备 worklist.csv:# patient_name# 张三# 李四# 王五python-mopenadapt_flow compile recording_xxx/ --accept-params patient_name python-mopenadapt_flow for-each bundle_xxx/--worklistworklist.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 可调用的 Skillemit-mcp将 Bundle 导出为 MCP 工具二、后端体系同一套编译逻辑适配不同环境OpenAdapt 通过backend.py中的 Protocol 体系支持多种驱动方式具体后端由--backend参数选择。后端只负责两件事截图和执行操作不参与编译逻辑。后端标记底层驱动画面来源操作方式适用场景Web默认Playwright / Chromium浏览器视口截图Playwright 鼠标/键盘 APIWeb 应用自动化最常见Windows--backend windowsWAA HTTP Agent桌面截图 API通过 agent HTTP 接口发送坐标操作Windows 原生桌面应用macOS--backend macosmacOS Accessibility API单应用窗口截图Accessibility 动作 坐标点击macOS 原生应用Linux--backend linuxAT-SPI单应用窗口截图AT-SPI 动作可选全局指针/键盘兜底Linux 桌面应用RDP--backend rdpFreeRDP / aardwolf远程桌面画面截图坐标点击/输入像素级远程桌面、VDI、堡垒机Citrix--backend citrixCitrix Workspace 窗口驱动Citrix 客户端窗口截图本地窗口内坐标操作Citrix 虚拟桌面Protocol 分层OpenAdapt 的后端不是一个必须全部实现的单一接口而是一组可选 Protocol。编译器根据后端具备的能力决定最终能生成哪些锚点和验证信息。Protocol提供的能力缺失的后果StructuralBackendURL、page_title、page_count无法生成 structural postconditionIdentityBackendDOM/a11y 结构化文本structured_text_at(x,y)只能靠 OCR 做身份验证存在 O/0、l/1 字形混淆风险StructuralActionBackendDOM 选择器 / UIA 标识符只能用视觉模板匹配定位无法使用 CSS selectorFieldLabelBackend当前聚焦字段的标签文本编译时只能用 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()方法。每次用户操作大致经历以下步骤# recorder.py:260 _record() 方法的执行流程简化1.截取 before_pngbackend.screenshot()2.采集 structural_stateURL/title/page_count3.执行操作backend.click(x,y)/backend.type_text(text)/...4.等待画面静止_wait_settled()└─ 轮询截图直到连续 N 帧 phash 一致默认超时 2s5.截取 after_pngbackend.screenshot()6.写入 events.jsonl 一行保存 before.png/after.png画面静止检测_wait_settled()位于recorder.py:460使用感知哈希phash判断页面是否完成渲染# recorder.py:460 _wait_settled() 核心逻辑deadlinetime.monotonic()self._settle_timeout_s# 默认 2 秒pngself._backend.screenshot()prev_phash(png)consecutive1whileconsecutiveSETTLE_CONSECUTIVEandtime.monotonic()deadline:time.sleep(SETTLE_INTERVAL_S)# 默认 0.3 秒pngself._backend.screenshot()curr_phash(png)ifphash_distance(prev,curr)0:consecutive1else:consecutive1prevcurrreturnpng# 最后一张稳定帧结构化信息采集录制器还会被动记录structural_state代码位于recorder.py:433# recorder.py:433 _structural_state()state{}forattr,keyin((url,url),(page_title,title),(page_count,pages)):valuegetattr(self._backend,attr,None)ifvalueisnotNone:state[key]valuereturnstate这一步不负责理解页面只是尽可能保留后续编译和回放可以利用的结构化信息。2. Compiler从截图和事件生成 BundleCompiler 约有 2,203 行是整个系统最复杂的模块。它将录制目录转换为包含 Workflow IR、模板图片、后置条件和完整性清单的 Bundle。锚点生成锚点解决的是回放阶段的核心问题如何在当前画面中找到录制时操作过的目标。相关逻辑位于compile.py:1581# 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,regioncrop_region))# 3. 上下文文字用于身份验证context_textidentifier_text_from_lines(frame_lines,...)# 4. 地标用于几何校准landmarks_landmarks_for(frame_lines,crop_region,click_point)# 取帧内 10~12 个最显著的 OCR 行作为锚点参照系# 5. 结构化定位器DOM / UIAstructuralStructuralLocator.model_validate(event.get(structural))# 最终 Anchor 对象anchorAnchor(templatetemplate_rel,# 模板 PNG 路径regioncrop_region,# 裁剪区域坐标click_pointclick,# 点击坐标ocr_textocr_text,# 锚点文字context_textcontext_text,# 身份上下文structuralstructural,# DOM 选择器landmarkslandmarks,# 地标列表)一个 Anchor 可以同时包含模板、OCR 文本、上下文文字、结构化定位器和地标。回放时系统可以根据当前后端能力选择其中的一部分。后置条件挖掘编译器会从每一步的 before/after 帧差异中自动推断操作完成后的验证条件。相关入口是compile.py:656的_postconditions()。REGION_STABLEREGION_STABLE用于验证某个区域在操作后达到预期稳定状态。# 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. 自变异检测过滤动画/时钟/toastifnext_before_pngisnotNone:phash_nowphash_png(after_png,regionpadded)phash_nextphash_png(next_before_png,regionpadded)ifphash_distance(phash_now,phash_next)REGION_STABLE_TOLERANCE:changedNone# 丢弃该区域是自变异的# 4. 参数隔离如果区域含有参数文字则切掉参数行ifexclude_textsand_param_text_in_region(padded,after_lines,exclude_texts):band_param_free_band(padded,carriers,after_lines)ifbandisnotNone:paddedband# 5. 生成 postconditionexpect.append(Postcondition(kindPostconditionKind.REGION_STABLE,regionpadded,phashphash_png(after_png,regionpadded),phash_toleranceREGION_STABLE_TOLERANCE,# 16))处理过程包括使用cv2.absdiff、阈值化和轮廓分析寻找最大变化区域在区域外扩 24px尽量包含相关结构边框使用next_before_png检测动画、时钟和 Toast 等自变异内容如果区域包含参数文字则尝试排除参数行用区域 phash 和容差16生成后置条件。TEXT_PRESENTTEXT_PRESENT用于检测操作后出现的新文本核心逻辑位于compile.py:536的_new_text_postcondition()。编译器会比较 before 和 after 帧中的 OCR 文本只保留 after 帧新增且满足条件的文本# compile.py:536 _new_text_postcondition() 核心逻辑# 1. OCR after 帧找出 before 帧中不存在的新文本行new_lines[lineforlineinafter_linesifnotseen_before(line)]# 2. 过四道过滤器forlineinnew_lines:textnormalize_text(line.text)# 过滤器 A长度 ≥ MIN_TEXT_PRESENT_LEN (3)iflen(text)3:continue# 过滤器 B挥发性分类器ifvolatility.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):continuecandidates.append((score,text))# 3. 取得分最高的一个returnPostcondition(kindPostconditionKind.TEXT_PRESENT,textchosen)它会过滤以下内容长度小于MIN_TEXT_PRESENT_LEN (3)的文本时钟、相对时间和分页计数器点击区域原本已经存在的文本before 帧中已经可见的文本。最后只选择得分最高的候选文本。结构性兜底当连续两步没有挖掘出视觉后置条件时编译器会尝试使用结构信息生成兜底条件代码位于compile.py:880# compile.py:880 _structural_postconditions()# 仅当前两步挖出零视觉后置条件时触发pcs[]ifpages_afterpages_before:pcs.append(Postcondition(kindPostconditionKind.NEW_TAB_OPENED))ifurl_before!url_after:pcs.append(Postcondition(kindPostconditionKind.URL_CHANGED))eliftitle_before!title_after:pcs.append(Postcondition(kindPostconditionKind.TITLE_CHANGED))returnpcs3. Volatility Classifier过滤不可靠证据volatility.py共有 366 行是编译器中用于降低误判的重要防御模块。它通过正则表达式识别不适合作为稳定证据的文本。分类器拦截模式示例原因CLOCK_RE\d{1,2}:\d{2}及 OCR 片段:0118:38、6:05、12:45:59时钟分钟会走DOT_CLOCK_RE\d\.\d{2}欧式时钟18.38、updated 8.30同上RELATIVE_TIME_RE\d (minhour)s? ago3 min ago、just now、moments agoCOUNT_RE\d to \d of \d1 to 5 of 12 entries分页计数是瞬时状态相对日期词独立的 today / yesterday / nowToday、Yesterday作为消息列表分组头时是瞬时的近日期距离录制日期 7 天的日期2026-08-06录制日期附近内容时间线而非身份数据日期处理是这个分类器中比较细的一点距离录制日期 ≤7 天的日期被视为内容时序属于易变化信息较远的日期例如出生日期 DOB则更可能被视为身份数据。4. 内容完整性校验编译完成后manifest.json会写入content_digest。该值由compute_content_digest()计算先使用model_dump(exclude_noneTrue)规范化序列化再计算 SHA256而不是直接使用简单的json.dumps。因此任何对workflow.json的修改都需要重新计算 digest否则回放时可能触发完整性校验错误。5. Replayer从结构化定位逐级降级回放引擎使用多级分辨率阶梯定位目标相关Resolution枚举定义在ir.py中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 可以直接使用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因此headless not headed True用户看不到浏览器窗口只能等待REPORT输出调试体验较差。源码位置__main__.py:3338定义了--headed actionstore_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。