browser-use 的 actor 层:哪些点击不必交给模型决定

2026-07-30

本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。

**actor 层不是给模型用的,是给你用的。**它存在的意义是:一次浏览器任务里,那些你早就知道该怎么走的步骤,不必再花一轮模型调用去”决定”——直接写代码点掉。模型只负责它真正不可替代的部分:页面长得跟上次不一样时,判断该点哪个。

这个判断听起来平淡,但它决定了你怎么组织一次任务。把整条流程都丢给模型循环,每一步都要截图、序列化 DOM、发一轮请求、等结构化输出返回;而登录页那个提交按钮的选择器三年没变过,你为它付的这一轮代价是纯浪费,而且引入了本可以避免的不确定性。

站内另外三篇是从别的角度切这件事:Agent 工具设计 讲的是你自己设计工具接口时边界该划在哪;pi 的工具层 拆的是另一个项目里工具层的实现取向;硬编码与模型决策的取舍 讨论的是”什么时候该写死”这个通用原则。这篇不重复它们,只做一件具体的事:把 browser-use 的 actor 目录读一遍,看它把哪些能力做成了可直接调用的对象,以及代价在哪。

一、这层在整个项目里的位置

先说清楚它不是什么。actor 目录下的 README 第一句就把定位写死了:这是一个建立在 CDP(Chrome DevTools Protocol)之上的 web 自动化库,提供 browser-use 生态内的低层浏览器自动化能力。README 后面还专门有一句提醒:这是 browser-use actor,不是 Playwright 也不是 Selenium,只用文档里列出的方法。

这句提醒不是客套。你会在下面看到,很多签名和习惯用法跟 Playwright 只是”看着像”,行为并不一致——凭手感调用就会掉坑。

再看它和模型侧的分工。browser-use 里模型驱动的那条路,动作是在 browser_use/tools/service.py 注册的一套带索引的动作:clickinputscrollextractfind_elementssend_keysselect_dropdowndone 之类,模型看到的是序列化后带编号的可交互元素清单,它输出编号,执行侧再把编号翻回真实节点。actor 层在这条链路下面:它不认编号,它认对象。

browser_use/actor/__init__.py 只导出四个名字:PageElementMouseUtils。整层的心智模型就这么大。

二、四个对象、一张表

组成部分它负责什么对应仓库位置你什么时候会碰到它
Page一个标签页或 iframe 的页面级操作:导航、求值、按键、截图、视口、查元素browser_use/actor/page.py拿到 page 之后的绝大部分调用
Element单个 DOM 元素的交互与属性读取,内部以 backend node id 定位browser_use/actor/element.py点击、填表、读属性、元素级截图
Mouse页面内基于坐标的鼠标操作:点击、移动、按下抬起、滚动browser_use/actor/mouse.pycanvas、地图、拖拽这类没有可靠元素可抓的场景
按键映射工具EnterArrowUp 这类键名翻成 CDP 需要的 code 与 Windows 虚拟键码browser_use/actor/utils.py间接碰到,press 内部在用
标签页生命周期new_pageget_pagesget_current_pageclose_pagebrowser_use/browser/session.py每次开始一段自动化
用法说明全部公开方法的签名清单与限制说明browser_use/actor/README.md写代码前对签名
可跑的样例确定性调用与模型接管混着用的两个脚本browser_use/actor/playground/想看真实组合方式

README 里推荐的入口是从会话拿 page,也允许直接构造:

from browser_use.actor import Page, Element, Mouse

# Create page with existing browser session
page = Page(browser_session, target_id, session_id)

会话侧的 new_page 走的是 CDP 的 Target.createTarget,不传 url 就开 about:blank,然后用 target id 构造出一个 Page 返回。Page 自己是懒的:构造时可以不给 session id,第一次真正需要时才 attach 到 target,并把 Page、DOM、Runtime、Network 四个域并发打开。这意味着你手里的 page 对象在第一次调用之前基本没有代价。

有意思的是 get_urlget_title 走的是 Target.getTargetInfo,不经过页面会话;而 gotoreloadevaluate 这些都要先确保会话存在。读 URL 比你想的便宜。

三、一次点击背后有四级兜底

Element.click 是这层里最值得读的一段,因为它暴露了”可靠点击”到底难在哪。它的顺序是这样的:

先用 Page.getLayoutMetrics 拿到布局视口的宽高。然后开始找元素几何形状,依次尝试 DOM.getContentQuads(对行内元素和复杂布局最准)、DOM.getBoxModel、以及注入 JavaScript 读 getBoundingClientRect。三条路都拿不到形状,就直接退到最后一招:

'functionDeclaration': 'function() { this.click(); }',

拿到了形状也还没完。一个元素可能有多个 quad(比如换行的行内链接),代码会遍历所有 quad,算出各自与视口的交集面积,挑面积最大的那个;如果一个都不在视口里,就退回用第一个。然后取这个 quad 四个角的平均值作为点击点,再把坐标夹回视口范围内,接着调 DOM.scrollIntoViewIfNeeded 滚一下,等 50 毫秒。

最后才是真正的输入事件:mouseMoved 移过去,mousePressedmouseReleased,修饰键按 Alt/Control/Meta/Shift 折成位掩码传进去。按下和抬起都套了超时保护,超时了就当没发生继续往下走,不抛错。整个事件路径再出异常,还有一次回退到 JavaScript 点击。

填表 fill 的层次一样厚。它默认先清空:清空的第一策略是注入 JS 执行 select()、把 value 设空、然后手动派发冒泡的 inputchange 事件——这一步是为了让 React 这类框架感知到变化;然后再读一次 value 验证是否真清掉了,没清掉就退到三击选中加 Delete。聚焦也有三级:CDP 的 DOM.focus、JS 的 focus()、按坐标点一下。

清完才开始打字,而且是逐字符发事件:keyDown 不带 text,char 带 text,keyUp 不带 text,每个字符之间睡 18 毫秒。换行字符单独按 Enter 处理。日志里被填入的内容是脱敏的,只记字符数。

读这段代码的收获不是”哦它很健壮”,而是:**你以为的一次点击,是十几次 CDP 往返。**下一节讲这件事的代价。

四、混合模式:把方向盘在两条路之间交接

仓库里 browser_use/actor/playground/ 下有两个脚本,正好演示了两种混法。

mixed_automation.py 是纯确定性加一点模型:开会话、拿当前页或新开页、goto 到一个测试页、按提示词找一个元素、点掉。这里模型只被用来做元素定位——page.get_element_by_prompt 会把 DOM 序列化成带编号的可交互元素表交给模型,让它回一个编号,然后把编号映射回节点。定位交给模型,动作由你写死。

flights.py 更能说明分工。它前半段的动作序列是你写死的——goto 到目标页、按顺序切两个选项、每次点击都是显式的一行代码,只在”这个按钮长什么样”这一步借了一次模型;把该切的选项切完之后,才把浏览器会话整个交给 Agent:

round_trip_button = await page.must_get_element_by_prompt('round trip button', llm)
await round_trip_button.click()

one_way_button = await page.must_get_element_by_prompt('one way button', llm)
await one_way_button.click()

await asyncio.sleep(1)

agent = Agent(task='Find the cheapest flight from London to Paris on 2025-10-15', llm=llm, browser_session=browser)
await agent.run()

同一个会话,前半段你控,后半段模型控。这是 actor 层最实际的用法:把任务里”路径固定、失败会很贵”的那一段(登录、切换到正确的表单模式、进到正确的列表页)写成代码,把”页面形态不可预知”的那一段留给模型循环。

Page 上还有一个偏模型侧的方法 extract_content:它先把页面抽成清洗过的 markdown,再连同你的查询一起发给模型,按你给的 Pydantic 模型返回结构化结果,整个调用套了 120 秒超时。它和纯抓取的区别在于,你不需要为每个页面写解析器——代价是每次调用都把页面正文交给了模型服务方。

extract_contentget_element_by_prompt 这两个方法内部都要用模型,这跟开头那句”这层不是给模型用的”并不冲突:区别在调用方向。模型循环那条路上,是模型决定下一步做什么、你的代码只负责执行;而这两个方法是你的代码决定”此刻需要模型帮我认一下这个按钮”或者”帮我把这页正文结构化”,模型只是被借来完成一个受限的子问题,做完就把控制权交回给你的下一行代码。判断一个接口属于哪一侧,看的不是它内部有没有调模型,而是流程的下一步由谁决定。

顺带一句关于成本:把确定性的步骤从模型循环里摘出来,省下的不只是钱,还有可复现性。同一段代码跑十次走的是同一条路,出问题能对着日志复盘;模型循环跑十次可能走十条路,这就是 复现与回放 那篇要解决的问题的来源之一。

五、边界与代价:它明确不管的事

它不等元素。 README 特意标了一句:get_elements_by_css_selector 立即返回,不等可见性。实现上就是 DOM.getDocumentDOM.querySelectorAll,再逐个节点转成 backend node id。没有自动重试、没有可见性轮询、没有超时参数。README 给的建议是自己用 asyncio.sleep 处理导航时序。这是整层最容易让人栽跟头的设计取向:所有等待都是你的责任。

它不做断言。 get_bounding_box 在任何异常下返回 None,不抛错;get_basic_info 出错时返回的对象里带一个 error 字段,也不抛。这意味着你不能靠”没报错”判断成功。README 的建议是用 get_urlget_titleget_attribute 自己验证状态变化。

它不保证语义结果,只保证动作发出去了。 check() 的实现就是调 click() ——它是切换,不是”设为选中”。元素本来已经勾上了,你再 check 一次就取消了。

方法面比你想的窄。 README 明确列出不存在的方法:element.submit()element.dispatch_event()element.get_property()。下拉框必须用 select_option() 而不是 fill();提交表单要么点提交按钮,要么 page.press("Enter")

有些能力是占位的。 Mouse.move 的 steps 参数在实现里被显式丢掉了,源码里留着 TODO 说平滑移动待实现。Element.select_option 的注释也自认是简化实现,单选和多选没有区别对待——它的做法是取 select 的子节点,匹配 option 的 value 或文本,然后点那个 option。自定义下拉(用 div 模拟的那种)它管不到。drag_to 只发一次 mouseMoved 就抬起,中间没有插值轨迹,依赖连续移动事件的拖拽实现可能不响应。

成本是 CDP 往返。 上一节数过点击的层数。actor 层的每个调用都可能是多次协议往返加固定 sleep,比”注入一段 JS 一次搞定”慢得多。它换来的是更接近真实用户的输入事件。这个取舍在大部分场景是对的,但如果你的任务是纯读取,page.evaluate 会便宜很多。

它是在真实浏览器上真实操作。 这条不是技术边界,是使用边界。这类工具通常带着你的登录态跑,动作落在真实账号上:填的表会真的提交,点的按钮会真的下单。你要自己确认目标站点的使用条款是否允许自动化访问,自己评估账号被判定为异常行为的可能,也要清楚验证码和反自动化机制的存在是站点方的明确意愿——遇到就该停下来换方案或转人工,不是想办法绕过去。另外 extract_content 会把页面正文送出到模型服务方,如果页面上有客户资料、内部数据或个人信息,这就是一条实打实的外泄面;各家模型服务商对数据留存与训练使用的规则不同且会调整,以官方最新说明为准。

六、上手清单:每条都是会踩的

别拿 Playwright 的手感写 actor 调用。 会踩是因为类名方法名太像了,page.clickpage.wait_for_selector 这些肌肉记忆会直接写出来。怎么避:写之前把 browser_use/actor/README.md 的 API Reference 那一节对一遍,那份清单就是全集。

evaluate 必须写成箭头函数。 会踩是因为习惯性写 'document.title' 或者 'function() { ... }'Page.evaluate 里有硬校验:不以 ( 开头且不含 => 就抛 ValueError。Element.evaluate 额外允许 async 前缀,内部会用正则把箭头函数改写成函数声明再交给 CDP 的 callFunctionOn。怎么避:统一写 '() => ...',参数从 *args 传,别在字符串里拼值。

别指望 evaluate 的返回类型。 会踩是因为你写了 if await page.evaluate('() => 1 > 0'): 这种判断。它总是返回字符串,字典和列表被 JSON 序列化,其他类型走 str()None 变成空字符串——空字符串是假值,字符串 'False' 是真值。怎么避:拿到结果自己解析,布尔判断不要直接用返回值。

元素级 evaluate 里的 this 是元素,不是 window。 会踩是因为跟 Page.evaluate 长得一样。README 的例子是 element.evaluate("() => this.textContent")。怎么避:记住两个 evaluate 的上下文不同,元素那个的实现走的是 callFunctionOn 加 objectId。

元素句柄会失效。 会踩是因为 Element 内部存的是 backend node id,页面重新渲染后节点没了。代码里的错误信息说得很直白:找不到节点,可能是页面内容变了。怎么避:导航或大范围重渲染之后重新查元素,不要跨页面复用句柄;单页应用局部刷新后同样要重取。

给 png 传 quality 是无效的。 会踩是因为 README 的示例里就写了 page.screenshot(format="png", quality=90),看着像能用。实现里 quality 只在格式是 jpeg 时才写进参数。怎么避:想控制体积就用 jpeg。

Mouse.downMouse.up 不带坐标。 会踩是因为你以为它会在你上次 move 的位置按下。实现里传的是 x=0、y=0,注释说会用最后的鼠标位置。怎么避:需要精确位置的按下抬起,先 move 过去,并且把这条当成”依赖浏览器端状态”的调用来对待,做拖拽优先考虑 Element.drag_to

滚动的坐标语义有个拐点。 会踩是因为你传 x=0, y=0 想在左上角滚。Mouse.scroll 的实现里,x 或 y 不大于 0 时会被换成视口中心。它还有三级降级:先发 mouseWheel 事件,失败退 synthesizeScrollGesture,再失败退注入 window.scrollBy。怎么避:明确要在某处滚就传正的坐标;滚不动时先确认走到了哪一级。

别把”没抛错”当成”做成了”。 会踩是因为点击内部对超时和异常吞得很多,抬起事件超时都不算失败。怎么避:关键步骤后面接一次显式验证,比如读 URL、读标题、读目标元素的属性。

登录态和会话生命周期要分开想。 会踩是因为 stop()kill() 不一样:README 写明 stop() 停会话但浏览器还活着,kill() 才结束进程并重置状态。怎么避:调试期用 keep_alive 保持浏览器,收工时明确调哪个;带着真实登录态的浏览器别长期挂着无人看管。

接下来读哪个文件

如果你要判断 actor 层够不够用,读的顺序建议是:browser_use/actor/README.md 对齐能力边界,browser_use/actor/element.py 看点击和填表的兜底层数(这段最能说明它对”真实输入”的执着),browser_use/actor/mouse.py 确认坐标类操作的实现程度,最后 browser_use/actor/playground/flights.py 看确定性段落和模型接管怎么在一个会话里交接。

自检三问:这一步的目标元素定位在过去三个月变过吗?变过就交给模型,没变过就写死。这一步失败的代价是什么?代价高的步骤后面必须补显式验证。这一步会不会把页面内容送出去?会的话先确认页面上没有不该出去的东西。

这个项目是 MIT 许可证,代码在 https://github.com/browser-use/browser-use 。它迭代很快,actor 目录里的注释、TODO 和”简化实现”的自述都还留着,直接读源码比读任何二手总结都准。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 用 14 个 watchdog 分管浏览器杂事拆解 browser-use 的动作注册表

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。