跳到主要内容

执行层:动作如何精确落到真实元素

30 秒导读: 上一章(决策层)里,模型吐出的是一句抽象指令——「在 element_id=42 上输入‘张三’」。 本章讲 Skyvern 的:这句话如何被翻译成一次真实的、会成功的浏览器操作。核心难点只有一个—— 模型只知道一个编号 42,执行层得把它反查回页面上那个具体的 DOM 节点,再想尽办法把动作落上去(普通点击不行就换坐标点击、再不行换 JS 点击……)。


1. 这是什么:从「一句指令」到「一次真实操作」

先接住上一章的输出。决策层交给执行层的,是一个 Action 对象——比如一个 ClickAction,里面 只有 action_type="click"element_id="42"、外加一点推理文本。它不含任何「这个元素在屏幕 哪个像素、用什么 CSS 选到它」的信息。

执行层要做三件事,缺一不可:

  1. 认领:根据 action_type 找到对应的处理函数(点击归点击、输入归输入)。
  2. 回查:把 element_id="42" 这个抽象编号,还原成一个能被 Playwright 操作的真实定位器(locator)。
  3. 落地:在真实元素上执行操作;一次不成,逐级降级重试,直到成功或彻底失败。

一句话直觉:执行层是「编号 → 真实元素 → 真实动作」的翻译器 + 一套不服输的容错重试机制。

为什么难?因为模型看到的页面(上一章「感知层」抓的那份快照)和此刻的真实页面几乎从不一字不差—— 按钮可能刚被 React 重渲染、下拉刚弹出把目标遮住、元素刚从 disabled 变成 enabled。执行层的全部 工程含量,都花在弥合「模型以为的页面」与「真实页面」之间的缝上。

本章不重复上一章「怎么决定要点这个按钮」的规划逻辑,只讲「决定之后,怎么把它稳稳点上」。


2. 动作类型全景:一张枚举定生死

所有动作类型集中定义在一个枚举里(webeye/actions/action_types.py:4 ActionType)。这不是 简单的字符串列表,它同时回答了三个问题,靠三处定义:

定义位置(action_types.py回答什么问题
ActionType 枚举成员:4-:36一共有哪些动作(click / input_text / select_option / scroll / keypress / terminate…)
is_web_action():38-:47这个动作需不需要一个真实 DOM 元素(要 element_id
POST_ACTION_EXECUTION_ACTION_TYPES:50-:62这个动作执行完要不要做后置处理(截图、抓页面变化等)

is_web_action() 是关键分水岭。 只有 7 种动作被算作「web action」——CLICKINPUT_TEXTUPLOAD_FILEDOWNLOAD_FILESELECT_OPTIONCHECKBOXHOVER。它们的共同点:必须落到一个 具体元素上,所以都要经过下一节讲的 element_id 反查。其余动作分两类:

  • 坐标/全局类(不绑元素):SCROLLKEYPRESSMOVEDRAGGOTO_URLSWITCH_TAB…… 它们作用于整页或某个屏幕坐标,无需回查元素。
  • 控制流类(不碰页面):TERMINATECOMPLETEWAITNULL_ACTION——它们改变的是 agent 循环的状态,不是网页。

记住这条线:web action 才需要「回查元素」这套重活;其余动作直接就能干。


3. 注册表式分发:一张字典,动态派活

3.1 思路:不用 if-else,用注册表

要把 30 种 action_type 各自派给对应函数,最朴素的写法是一坨 if action_type == CLICK: ... elif ...。 Skyvern 用了更干净的注册表模式:一张「动作类型 → 处理函数」的字典,运行时按 key 查表调用。

核心就是 ActionHandler 这个类,它其实只是三张字典的壳(webeye/actions/handler.py:1231 ActionHandler):

class ActionHandler:
_handled_action_types: dict[ActionType, Callable[...]] = {} # 主处理函数
_setup_action_types: dict[ActionType, Callable[...]] = {} # 前置钩子(当前为空)
_teardown_action_types: dict[ActionType, Callable[...]] = {} # 后置钩子(当前为空)

注册用一个类方法往字典里塞(handler.py:1248 register_action_type),本质是 cls._handled_action_types[action_type] = handler。 文件底部一口气把所有 handler 注册进去(handler.py:4015-4040),比如:

ActionHandler.register_action_type(ActionType.CLICK, handle_click_action)
ActionHandler.register_action_type(ActionType.INPUT_TEXT, handle_input_text_action)
ActionHandler.register_action_type(ActionType.SELECT_OPTION, handle_select_option_action)
# ……一直到 execute_js

一个可核实的细节: _setup_action_types / _teardown_action_types 两张钩子字典目前没有任何 注册(全仓没有一处调用 register_setup_for_action_type)。所以分发时的 setup/teardown 步骤实际 是空转——框架预留了扩展点,但当前动作都不用。

3.2 分发主线:两层包裹

真正的分发入口是 handle_actionhandler.py:1273),但它其实是个包裹层,干的是「下载感知」的脏活; 真正查表调用在它内部的 _handle_actionhandler.py:1710)。分两层的原因很实在——下载动作和非下载 动作的耗时是双峰分布:普通动作 ~1 秒结束,而会触发下载的动作要轮询等文件落盘,最长能耗到 120 秒 (handler.py:1291-1294 的注释直接点破了这个 p95 长尾)。

handle_action(外层包裹, :1273)
│ 判断这次动作会不会触发下载 (trigger_download_action)
├─ 不触发下载 ──► 直接调 _handle_action,落库,返回 (快路径, ~1s)
└─ 会触发下载 ──► 挂 download 监听 → 调 _handle_action
→ 轮询下载目录 / XHR 捕获 / 事件兜底 (慢路径, 最长 120s)
→ 处理 about:blank 回跳 → 落库

下载那套轮询逻辑(handler.py:1402-1570)很长但目标单一:多路信号抢答——浏览器 download 事件、 下载目录新文件、XHR 抓包、以及「一直没信号」的宽限超时,谁先命中就按谁的结果收尾。本章不逐行展开, 知道它是「为下载这种慢动作单开的一条等待路径」即可。

3.3 查表调用的五步(_handle_action

真正的注册表派活在 _handle_actionhandler.py:1710)。剥掉异常处理,主干是这样:

# 简化自 _handle_action,非逐字源码
if action.action_type in ActionHandler._handled_action_types:
# ① 前置校验:web action 但元素不在快照里 → 直接判失败
if invalid := check_for_invalid_web_action(action, page, scraped_page, task, step):
return invalid
# ② setup 钩子(当前恒空)
if setup := ActionHandler._setup_action_types.get(action.action_type):
...
# ③ 查表拿到 handler,执行
handler = ActionHandler._handled_action_types[action.action_type]
results = await handler(action, page, scraped_page, task, step)
# ④ teardown 钩子(当前恒空)
# ⑤ 返回结果
return results
else:
return [ActionFailure(...)] # 没注册的类型

两个值得记住的点:

  • check_for_invalid_web_actionhandler.py:1816)是元素回查前的第一道闸。 它的核心判断只有 一行:isinstance(action, WebAction) and action.element_id not in scraped_page.id_to_element_dict ——如果这是个需要元素的 web action,但 element_id 压根不在感知层抓的快照字典里,立刻返回 MissingElement 失败,连回查都不用做。(例外:带了 x/y 坐标的点击、以及 CUA 那种没有 element_id 的输入,直接放行。)

  • 所有 handler 签名统一(action, page, scraped_page, task, step) -> list[ActionResult]。返回的是 一个结果列表(不是单值),因为一次动作内部可能重试多次、每次留一条 ActionSuccess/ActionFailure 记录,列表最后一项才代表最终成败(见 _handle_actionfinallyhandler.py:1792:靠 actions_result[-1] 是不是 ActionSuccess 来定动作 status)。


4. 难点核心:element_id 如何反查回真实元素

这是整个执行层工程含量最高的一块。模型手里只有一个字符串编号,执行层要把它变成一个 Playwright Locator——一个能 .click().fill() 的真实句柄。

4.1 四张字典:编号的「户口本」

感知层(上一章)抓页面时,给每个可交互元素编了号,并留下四张以 element_id 为 key 的字典, 挂在 ScrapedPage 上(webeye/scraper/scraped_page.py:183-187):

字典key → value作用
id_to_element_dict编号 → 元素属性字典(含 tagName、xpath 等)元素长什么样
id_to_css_dict编号 → CSS 选择器首选怎么定位
id_to_frame_dict编号 → 所在 frame 的编号元素在哪个 iframe 里
id_to_element_hash编号 → 元素内容 hash跨快照识别「同一个元素」(缓存复用用,见 §6)

回查的总入口是 DomUtil.get_skyvern_element_by_idwebeye/utils/dom.py:1160)。它把这四张字典串起来, 产出一个 SkyvernElement(对真实 locator 的封装)。

4.2 回查五步 + 两级容错

element_id="42"

├─① id_to_element_dict[42]? 没有 → MissingElementDict(元素根本没抓到)
├─② id_to_frame_dict[42]? 没有 → MissingElementInIframe(不知道在哪个 frame)
├─③ id_to_css_dict[42]? 没有 → MissingElementInCSSMap(没有选择器)

├─④ resolve_locator(frame, css) ── 顺着 iframe 链逐层下钻,得到 locator

└─⑤ locator.count() == 1 ?
├─ ==0 ─► 【容错1】回退用 xpath 再定位一次;还是 0 → MissingElement
├─ >1 ─► MultipleElementsFound(选择器不唯一,宁可失败也不乱点)
└─ ==1 ─► ✅ 返回 SkyvernElement(locator, frame, element, hash)

这段逻辑在 dom.py:1160-1203。三个设计点值得单独讲:

(a) iframe 链下钻(resolve_locatordom.py:52)。 元素可能嵌在 iframe 里、甚至 iframe 套 iframe。 id_to_frame_dict 只记「我在哪个 frame」,resolve_locator 就顺着 frame_element.get("frame") 一路往上 爬到 main.frame,攒出一条 iframe 路径,再从主页面一层层 frame_locator(...) 钻回去。定位一个深层 iframe 里的元素,靠的就是这条链。注意它用的定位属性是 unique_idconstants.py:5 SKYVERN_ID_ATTR = "unique_id") ——感知层给每个元素注入的那个属性。

(b) CSS 优先、xpath 兜底(dom.py:1176-1190)。 首选 CSS 选择器;万一 CSS 选出 0 个元素(页面变了), 就回退到该元素记录里的 xpath。源码注释很诚实地承认这个 xpath 只按标签名记位置、不 100% 可靠dom.py:1182-1185),但「同位置有同标签元素」时够用了——这是典型的「差一点也比彻底失败强」的容错。

(c) 唯一性是硬约束(dom.py:1192)。 选择器命中多个元素时,直接抛 MultipleElementsFound不是 挑第一个。理由:点错元素的代价远高于失败重试。「宁可报错也不瞎点」是这里的安全底线。

还有一个「安全版」入口 safe_get_skyvern_element_by_iddom.py:1205):包一层 try/except,回查失败返回 None 而非抛异常——给那些「查不到就算了、别中断」的调用点用(比如 §5.5 的 scroll)。


5. 代表性 handler 逐个看

回查解决了「找到元素」,落地才是「把动作做成」。下面挑几个有代表性的 handler,看它们各自的容错花样。

5.1 点击:handle_click_action + chain_click 的容错梯

handle_click_actionhandler.py:1882)先分两条路:

  • x/y 坐标(CUA/视觉 agent 给的):用 document.elementFromPoint(x,y) 反查该像素处元素的 unique_id,能查到就先试「若是链接则直接导航」,否则退化成纯坐标 page.mouse.clickhandler.py:1894-1935)。repeat 决定单击/双击/三击。
  • element_id:走 §4 回查拿到 skyvern_element,然后做几件容错准备,最后交给 chain_click

回查后、点击前有三个巧妙的前置处理:

  1. disabled 重定向(handler.py:1943_retarget_disabled_element_for_click)。 元素若是 disabled 的 包装容器,尝试找它「唯一一条链上最深的可交互后代」改点那个(很多 UI 框架把真正可点的东西 包在禁用的外壳里)。找不到明确后代才判 InteractWithDisabledElement 失败。
  2. 动态复检 disabled(is_disabled(dynamic=True))。 因为前一个动作可能刚把它从禁用变成可用—— 静态快照会骗人,所以实时再查一次。
  3. 跳过 scroll_into_view 的信号(handler.py:1963)。 如果上一步是对同一元素的 SCROLL 动作 (比如把条款弹窗滚到底以激活「同意」按钮),这里就scrollIntoView(),否则会把刚滚好的 位置又顶回去。它靠 window.__skyvernScrolledElementId 这个页面变量在 scroll 和 click 之间传信号 (见 §5.5)。

真正落点的是 chain_clickhandler.py:4132)——一条逐级降级的容错梯。核心思路:一种点法失败, 就换下一种更「暴力」的点法,每种失败都记一条 ActionFailure 但不放弃,直到某一级成功。

chain_click 容错梯(从上到下,命中即停)
├─ 0. 若元素是 <a href> ─► 直接导航到 href(绕过点击)
├─ 1. 正常 Playwright 点击(走光标策略 EventStrategyFactory) ← 绝大多数在这一级成功
│ └─ 若「物理点击已派发、只是等导航超时」→ 也算成功(:4205)
├─ 2. 是 <label>? → 找 for= 绑定的控件点它 / 找 label 里的 <input> 点它
├─ 2'. 非 label? → 反查绑定它的 <label>(by attr id / by 直接父节点)点 label
├─ 3. 元素已不可见 → 直接放弃(返回累积的失败)
├─ 4. 找「挡在前面的元素」(find_blocking_element)
│ ├─ 没有遮挡但 Playwright 仍失败(React 重渲染/动画)→ 坐标点击 coordinate_click
│ │ └─ 坐标点击也失败 → JS 点击 click_in_javascript()
│ └─ 有遮挡且遮挡者是父/兄弟 → 改点那个遮挡元素
└─ 5. 遮挡者非父/兄弟 → JS 点击原元素,再用「增量抓取」验证页面真的响应了(_did_page_respond)

对应源码在 handler.py:4189-4392。这条梯子是 Skyvern「手」的精华:同一个「点击」意图,被翻译成 至多六七种不同的物理实现,逐级兜住真实页面的各种坑(label 关联、元素遮挡、框架重渲染、命中测试失败)。

顺带一提,chain_click 还兼管文件上传时的 filechooser:点击可能弹出系统文件选择框,它预挂一个 监听器自动 set_fileshandler.py:4169-4176),点完若没弹(被别的弹窗拦了)就把监听器「延迟挂起」 留给下次点击触发(handler.py:4407-4429)。

5.2 输入文本:handle_input_text_action 的「输入还是选择」纠结

handle_input_text_actionhandler.py:2415)远不止「往框里打字」。它要先判断这个「输入」到底是不是 真的输入。主流程(简化):

handle_input_text_action
├─ 没有 element_id → CUA 直接 type_text,结束
├─ 回查元素;若当前值已等于目标值 → 直接成功(幂等)
├─ 解析 secret:文本是密文占位符就换成真值;TOTP 特殊处理(:2441-2453)
├─ 元素本身可 select(<select>)→ 转成 SelectOptionAction 走选择逻辑(:2472)
├─ 疑似「自动补全输入框」? 按 ↓ 试探有没有下拉冒出来(:2492-2607)
│ 有下拉 → 转 sequentially_select_from_dropdown 当选择处理
│ 没下拉 → 回到纯输入
└─ 纯输入:focus → 清空 → 按类型特判 → input_sequentially 逐字输入
├─ type=tel → 电话号码格式校验/回读校验(:2628-2657, 2783-2816)
├─ type=date → 日期格式校验(:2745)
└─ 其它 → 逐字键入 + 监听增量 DOM,命中搜索下拉则选中(:2818-2857)

要抓住的直觉:「输入」和「选择」在真实网页里经常是同一个框。 一个看似普通的输入框,打字后可能 弹出自动补全下拉——那这次「输入」的正确落地其实是「选中下拉里的某项」。这个 handler 一大半代码在 处理这种输入/选择二义性,靠「按 ↓、监听 DOM 有没有新增元素」来现场判定(handler.py:2512-2542)。

底层真正打字的是 input_sequentiallyhandler_utils.py:32):超长文本先 fill 灌前半段、只对最后 TEXT_PRESS_MAX_LENGTH 个字符逐键输入(省时间又保留「像人一样键入」触发的事件)。

结尾还有个 HACK(handler.py:2897-2909):自动补全没选中时,兜底按一下 Tab 强制让浏览器选中一项。

5.3 选择下拉:handle_select_option_action 的 custom vs normal

下拉选择是最麻烦的一类,因为网页上「下拉」有两种完全不同的实现:

类型长什么样Skyvern 怎么选
normal-select原生 <select><option>normal_select:让 LLM 从 option 列表挑 index/value,再 locator.select_option
custom-select<div> 拼的假下拉(React 组件等)点开 → 监听弹出的选项 → LLM 匹配 → 点中那一项

handle_select_option_actionhandler.py:3145)先做一长串「这到底是什么控件」的分诊 (handler.py:3168-3301):

  • 是原生 <select>?→ 检查有没有被遮挡,没遮挡走 normal_selecthandler.py:3219-3260)。
  • <input> checkbox / radio / button?→ 分别转成 CheckboxActionClickAction
  • 不可选但子节点里有可选元素?→ 改选那个子节点(find_selectable_childhandler.py:3184)。
  • 都不是 → 走 custom selecthandler.py:3303 起)。

custom select 的容错handler.py:3316-3465)是本 handler 的重头:

custom select
├─ 挂 DOM 增量监听 → 点开下拉 → 等动画结束
├─ 抓到「点开后新增的元素」(就是选项们)
│ └─ 一个都没抓到? 且是 <input> → 按 ↓ 再试一次触发下拉(:3330)
│ └─ 还是没有 → 换「重新整页抓取找匹配元素」select_from_emerging_elements(:3349)
├─ 有选项 → sequentially_select_from_dropdown:LLM 看选项 HTML 挑一个去点(:3372)
└─ 按 label 没选中 → 拿 LLM 建议的 value 再走 select_from_dropdown_by_value 兜底(:3435)
finally: 失败就把下拉关掉(Escape + blur),别把页面卡在「下拉开着」的脏状态(:3397)

LLM 匹配选项的核心在 select_from_dropdownhandler.py:5474):把弹出的选项树清洗成 HTML,塞进 custom-select prompt 让模型选(handler.py:5537),必要时还会滚动下拉加载全部选项再匹配 (handler.py:5520-5528)。这里体现了执行层和决策层的边界:「选哪一项」仍要问模型,但「怎么把 下拉点开、怎么把选中动作可靠落上、失败怎么收场」全是执行层的活。

5.4 上传文件:handle_upload_file_action

handle_upload_file_actionhandler.py:2977)有两个亮点:

  1. 防幻觉 file_url(handler.py:2993-3016)。 模型给的下载 URL 若既不在 navigation goal 也不在 payload 里,先用有界编辑距离模糊搜索_find_similar_url_in_texthandler.py:2920)看是不是 模型把用户给的长 URL(含预签名 token)敲错了几个字符——能对上就用用户原文的那个 URL;对不上 就判 ImaginaryFileUrl 失败。这是防「模型编造一个下载地址」的安全阀。
  2. file input 反查(handler.py:3033-3068)。 目标若本身就是 <input type=file>,直接 set_input_files;若不是(比如是个「上传」按钮),就去子节点里找真正的 file input,找不到就退化成 一次点击chain_click 并把待传文件挂上),靠点击弹出的 filechooser 完成上传。

5.5 滚动:handle_scroll_action 与它和点击的暗号

handle_scroll_actionhandler.py:3668)分三种:元素级、坐标级、纯滚动。元素级最有意思 (handler.py:3677-3767):用 JS scrollNearestScrollableContainer 找到元素最近的可滚动容器;若只有 整页可滚,就用 mouse.wheel 分块滚 + 每块停 100mshandler.py:3741-3743),因为很多页面靠 真实 wheel 事件触发懒加载、或滚到底才启用按钮——window.scrollTo 这种编程式滚动它们不认。

滚完它会在页面上写一个变量 window.__skyvernScrolledElementId = idhandler.py:37523763)。这就是 §5.1 里点击 handler 读的那个「暗号」:「我刚故意把这个元素滚到位了,你点它时别再 scrollIntoView 把 它顶回去」——两个 handler 通过一个页面全局变量做隐式协作,解决「滚到底激活按钮 → 再点按钮」这类 连续动作的经典坑。

5.6 键盘:handle_keypress_action

最薄的一层(handler.py:3784):转手给 handler_utils.keypresshandler_utils.py:60)。后者干的主要是 按键名归一化——把模型可能说的 "enter"/"return" 都映射成 Playwright 的 "Enter""esc"→"Escape""ctrl"→"Control""f5"→"F5"handler_utils.py:62-102),再用 + 拼成组合键按下。支持 hold (按住 duration 秒)和 repeat(重复 n 次)。

5.7 收尾:handle_terminate_action / handle_complete_action

这两个是控制流动作,不碰页面:

  • terminate(handler.py:3549:任务失败退出。若配了 error_code_mapping,顺手让 LLM 从页面 抽取「用户自定义错误」填进 action.errors,然后返回成功(terminate 动作本身总是「成功地终止」)。
  • complete(handler.py:3573:任务完成退出,但要先核实。若尚未验证过(action.verified 为假) 且有 navigation goal,就调 complete_verify 让模型看着页面确认目标真达成了(handler.py:3594):
    • 验证不通过 → 返回 ActionFailure(IllegitComplete),任务继续(防模型过早宣布成功)。
    • 验证要求终止 → 转成 TerminateAction 执行(handler.py:3615-3629)。
    • 通过 → 标 verified=True,成功。

这里的精华是 complete 不是模型说完成就完成——执行层强制加了一道 LLM 复核,这是对抗「幻觉式提前 收工」的关键闸门。


6. 动作缓存与复用:caching.py

跑过一次的任务,Skyvern 能把「上次的动作序列」缓存下来,下次同 URL 同目标时直接重放,省掉再问 一遍大模型。难点是:上次记录的 element_id 这次已经失效了(每次抓取编号会变),怎么把老动作对回 到新页面的元素上?

答案是 element hash 反查webeye/actions/caching.py)。缓存的每个动作带一个 skyvern_element_hash (元素内容指纹,跨快照稳定),复用时拿它去新快照的 hash_to_element_ids 里查回新编号:

# 简化自 _retrieve_action_plan,caching.py:128-133
if cached_action.skyvern_element_hash:
matching = scraped_page.hash_to_element_ids.get(cached_action.skyvern_element_hash)
if matching and len(matching) == 1: # 必须唯一命中
updated_action.element_id = matching[0] # 用新编号替换老编号
updated_action.skyvern_element_data = scraped_page.id_to_element_dict.get(matching[0])

关键规则(caching.py:74-104):

  • hash 唯一命中才复用;命中 0 个或多个就停止匹配,剩下的动作退回「问模型」模式。这跟 §4.2 的 「唯一性是硬约束」一脉相承——对不准就别猜。
  • 无 hash 的动作(terminate/complete/wait/captcha/null)没有元素可对,规则是只能作为每步的第一个 动作执行,且执行完要重新抓页面(caching.py:78-87)。
  • 复用的动作还会按意图重新个性化personalize_actionscaching.py:154):比如缓存里的 INPUT_TEXT 会用当前上下文重新问 LLM 该填什么值(caching.py:215),而不是照搬上次的文本。
  • 只有部分动作类型支持缓存(check_for_unsupported_actionscaching.py:249:input_text/wait/click/ complete/download_file),碰到不支持的类型整体退回无缓存模式。

一句话:缓存复用 = 用稳定的 element hash 把老动作重新锚回新页面 + 把易变的值重新个性化。


7. 巧妙之处(值得带走的技术)

  • 注册表分发替代 if-else 巨兽。 30 种动作靠一张 dict[ActionType, handler] 派活,加动作只需 register_action_type 一行(handler.py:1248, :4015-4040)。还预留了 setup/teardown 钩子扩展点。
  • 一个抽象编号,四张字典还原真实元素。 element_id 不含任何定位信息,靠 element/css/frame/hash 四张字典 + iframe 链下钻还原成真实 locator(dom.py:1160, :52)。感知与执行彻底解耦。
  • 容错梯:一个意图,多种物理实现。 chain_click 把「点击」降级成 label 关联点击 / 坐标点击 / JS 点击 / 点遮挡元素等六七种(handler.py:4189-4392)——真实网页的坑,用「不服输地换招」兜住。
  • CSS 失败退 xpath、多命中即报错。 定位既有兜底(dom.py:1187)又有安全底线(多命中宁可失败不乱点, dom.py:1192)。
  • 点击与滚动用页面全局变量传暗号。 window.__skyvernScrolledElementId 让「滚到底激活按钮」和 「再点按钮」两个动作不互相拆台(handler.py:1963:3752)。
  • complete 强制 LLM 复核。 模型说完成不算数,执行层再问一遍模型「真的达成了吗」,对抗幻觉式收工 (handler.py:3585-3633)。
  • 防幻觉 file_url 的模糊搜索。 用有界编辑距离把模型敲错的下载 URL 拉回用户原文(handler.py:2920)。
  • element hash 让动作缓存跨快照复用。 编号会变、hash 不变,靠 hash 唯一命中把老动作重锚回新页面 (caching.py:128)。

8. 边界与局限(诚实说)

  • 定位不 100% 可靠。 xpath 兜底只按标签名记位置,源码自己承认「不 100% 可靠」(dom.py:1182-1185); 页面结构变动时可能定到「同位置但不同」的元素。
  • 多命中 = 直接失败,不重试选择。 MultipleElementsFound 一抛就是失败(dom.py:1199),选择器不唯一 的页面会卡在这里,交给上层重新规划。
  • 下载动作的长尾。 触发下载的动作最长要轮询 120 秒(handler.py:1291-1294)——慢是设计使然,不是 bug。
  • custom select 重度依赖 LLM 和 DOM 增量监听。 若下拉是异步渲染、或选项不通过 MutationObserver 冒出来, 匹配会退化到「整页重抓找匹配」这种更慢的路径(handler.py:3349)。
  • 缓存复用条件苛刻。 只支持少数动作类型、且 hash 必须唯一命中,任一环节对不上就整体退回问模型 (caching.py:90-104, :249);页面稍有结构变化,缓存就大概率失效。
  • setup/teardown 钩子当前是死代码。 框架留了口子但没接线(全仓无注册),别指望它现在做什么。

9. 代码地图(导航索引)

主题文件路径关键符号
动作类型枚举 / web-action 判定webeye/actions/action_types.pyActionTypeActionType.is_web_actionPOST_ACTION_EXECUTION_ACTION_TYPES
注册表 + 三张字典webeye/actions/handler.py:1231ActionHandlerregister_action_type
分发外层(含下载等待)webeye/actions/handler.py:1273ActionHandler.handle_action
分发内层(查表调用)webeye/actions/handler.py:1710ActionHandler._handle_action
回查前置闸webeye/actions/handler.py:1816check_for_invalid_web_action
element_id → 真实元素回查webeye/utils/dom.py:1160DomUtil.get_skyvern_element_by_idsafe_get_skyvern_element_by_id
iframe 链下钻定位webeye/utils/dom.py:52resolve_locatorSKYVERN_ID_ATTR
四张 id 字典webeye/scraper/scraped_page.py:183id_to_element_dictid_to_css_dictid_to_frame_dictid_to_element_hashhash_to_element_ids
点击 handlerwebeye/actions/handler.py:1882handle_click_action_retarget_disabled_element_for_click
点击容错梯webeye/actions/handler.py:4132chain_click_locator_click_get_click_count_did_page_respond
输入 handlerwebeye/actions/handler.py:2415handle_input_text_action_find_similar_url_in_text
选择 handler(分诊)webeye/actions/handler.py:3145handle_select_option_action
custom / normal 选择webeye/actions/handler.py:5474 / :5986select_from_dropdownselect_from_dropdown_by_valuenormal_select
上传 handlerwebeye/actions/handler.py:2977handle_upload_file_action
滚动 handler(含点击暗号)webeye/actions/handler.py:3668handle_scroll_action__skyvernScrolledElementId
键盘归一化webeye/actions/handler_utils.py:60keypressinput_sequentiallydrag
terminate / completewebeye/actions/handler.py:3549 / :3573handle_terminate_actionhandle_complete_action
动作缓存复用webeye/actions/caching.py:15retrieve_action_planpersonalize_actioncheck_for_unsupported_actions

相关章节:上游「模型怎么决定要做这个动作」见 决策层;动作要落的那份 「页面快照」怎么来的(四张 id 字典的源头)见 感知层;DOM 规划器与 计算机使用(CUA)两种引擎在动作上的差异见 引擎家族;全景与阅读顺序见 index