browser-use 用 14 个 watchdog 分管浏览器杂事

2026-07-30

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

把弹窗、下载、崩溃、权限拆成 14 个 watchdog,真正买到的不是目录整齐,而是每件杂事的失败被关在自己的处理器里,主循环连它们的名字都不用知道。 这句话在 browser-use 里有具体落点:browser_use/browser/watchdog_base.py 的基类统一处理了 CDP 断线、日志、重复注册和任务清理,browser_use/browser/watchdogs/ 下 14 个文件各写一件杂事的业务逻辑,BrowserSession 只负责把它们挂上事件总线。下面按「解决什么问题 → 怎么做的 → 对你意味着什么」的顺序走一遍。

本篇只谈 browser-use 这一层的拆分方式。失败之后怎么重试属于另一个话题,见失败重试的通用套路;链路变长之后怎么看清发生了什么,见Agent 可观察日志;模型把工具调错了怎么办,见工具调错的排查。这三篇讲通用方法,本篇只回答「一个真实项目是怎么切这刀的」。

一、Agent 想点按钮,浏览器满地是插曲

一次 agent 步进的理想形态很干净:拿浏览器状态、交给模型、执行一个动作。放到真实站点上,中间会插进来一堆和任务无关的事:

  • 页面弹了 alert,渲染卡住,后续 CDP 调用全排在它后面;
  • 点一下按钮,浏览器开始下载文件,动作的「结果」变成了一个文件;
  • 目标崩了,或者某个请求挂着不返回;
  • 站点要剪贴板、摄像头权限,不给就走不下去;
  • 页面 302 到了不该去的域名,或者自己开了个新标签跳走;
  • 站点弹出验证码,这一步该等还是该放弃。

这些事情的共同点是:出现时机随机,处理方式彼此无关,任何一件写进主循环都会变成一个 if,而且这些 if 会互相污染——弹窗分支里抛的异常会把下载分支的清理逻辑一起带走。

browser-use 的选择是按职责切文件。这个仓库整体都是这个取向:browser_use/llm/ 下 15 个 provider 目录、browser_use/agent/system_prompts/ 下 8 份系统提示词、examples/ 下 124 个文件,各自一格。浏览器杂事这一格就是 browser_use/browser/watchdogs/,14 个 watchdog 文件,全部继承 BaseWatchdog。项目采用 MIT 许可证,这些文件你可以直接打开对照着读。

二、拆得开,靠的是基类立的三条约定

约定一:方法名就是订阅声明

BaseWatchdog.attach_to_session() 不需要你手写注册表。它先把 browser_use.browser.events 里所有 BaseEvent 子类收集成字典,再扫 dir(self),凡是 on_ 开头的可调用方法,去掉 on_ 前缀后如果能在事件字典里找到同名类,就把这个方法注册上去。挂载时还有一层强制:

assert handler.__name__.startswith('on_'), f'Handler {handler.__name__} must start with "on_"'
assert handler.__name__.endswith(event_class.__name__), (
    f'Handler {handler.__name__} must end with event type {event_class.__name__}'
)

于是 PopupsWatchdog 里那个 async def on_TabCreatedEvent(self, event: TabCreatedEvent),方法签名本身就是全部的接线工作。

约定二:LISTENS_TO / EMITS 是可选的契约

每个 watchdog 顶上有两个 ClassVarLISTENS_TO 声明它听哪些事件,EMITS 声明它发哪些事件。基类注释写得很直白,这两个字段「不强制,只是让代码更好读、运行时更好调」。但一旦你填了 LISTENS_TO,它就会立刻变成两道检查:处理器订阅的事件不在声明里,断言直接失败;声明了事件却找不到对应的 on_ 方法,日志里打 warning。

反过来,default_action_watchdog.py 里搜不到 LISTENS_TO,也就不受这两道检查约束——点击、输入、滚动、返回、上传这些默认动作都由它接,事件面太宽,声明反而成了维护负担。同一套机制里两种用法并存,这个选择本身就说明约定的性质:契约是给读代码的人用的。

约定三:共享状态不许放在 watchdog 里

基类注释里有一条硬规矩:需要被别的 watchdog 看到的状态和 helper 方法,定义在 BrowserSession 上,不要定义在 watchdog 上;watchdog 自己的私有状态用 PrivateAttr。配合 model_config 里的 extra='forbid',任何没声明的隐式字段都会被拒掉。

这条约定决定了 14 个看护者之间不会互相调用。它们要么通过事件总线通信,要么通过 BrowserSession 上一格明确的状态交换信息。

基类还替所有人干了三件脏活

注册的时候,基类会把你的处理器包一层。这一层里有三件事是每个 watchdog 本来都要自己写的:

一是 CDP 断线熔断。包装器先看事件类型:

if event.event_type not in LIFECYCLE_EVENT_NAMES and not browser_session.is_cdp_connected:

LIFECYCLE_EVENT_NAMES 是一个 frozenset,里面是 BrowserStartEventBrowserStopEventBrowserReconnectingEvent 这类管生命周期的事件,它们即使连接断了也要跑。其余事件遇到 CDP 未连接,会分两种情况:如果 is_reconnecting 为真,就等 RECONNECT_WAIT_TIMEOUT 那么久,等不到或者等到了但仍未连上,抛 ConnectionError;如果不在重连,判定为主动停止,打条 debug 日志后 return None。这段逻辑写一次,14 个 watchdog 都不用再担心「连接已经死了,我的 CDP 调用要挂到超时」。

二是失败后尝试自救。处理器抛异常时,包装器会用 get_or_create_cdp_session 试着把可能崩掉的 CDP 会话恢复出来,然后不管修没修好,都把原始异常连带 traceback 原样抛出去。

三是生命周期清理。基类的 __del__ 会遍历自己的属性,凡是以 _task 结尾的私有属性,看到没结束的任务就 cancel();以 _tasks 结尾且可迭代的,逐个 cancel。这也是为什么这些 watchdog 里的后台任务都叫 _monitoring_task_cdp_event_tasks 这类名字。

还有一处细节值得记住:重复注册会直接 RuntimeError,错误信息明确指向「attach_to_session() 大概被调了多次」。

三、14 个看护者各盯什么

组成部分它负责什么仓库位置你什么时候会碰到它
BaseWatchdog命名即注册、断线熔断、失败自救、任务清理browser_use/browser/watchdog_base.py自己写 watchdog,或看不懂事件日志时
PopupsWatchdogJS 对话框自动处置并留痕browser_use/browser/watchdogs/popups_watchdog.py页面弹 alert/confirm 却没卡住流程
SecurityWatchdogURL 准入:导航前、导航后、新标签三处拦browser_use/browser/watchdogs/security_watchdog.py配了 allowed_domainsprohibited_domains
DOMWatchdog组装浏览器状态:DOM 树、截图、标签、页面尺寸browser_use/browser/watchdogs/dom_watchdog.py模型看到的元素列表不对时
ScreenshotWatchdog处理 ScreenshotEvent,用 CDP 截图browser_use/browser/watchdogs/screenshot_watchdog.py截图为空或超时
DefaultActionWatchdog点击、输入、滚动、返回、上传等默认动作browser_use/browser/watchdogs/default_action_watchdog.py动作执行结果和预期不符
DownloadsWatchdog下载开始、进度、完成,PDF 自动下载browser_use/browser/watchdogs/downloads_watchdog.py点击触发了文件下载
CrashWatchdog目标崩溃与网络超时监控browser_use/browser/watchdogs/crash_watchdog.py想查崩溃却发现它默认没挂上
PermissionsWatchdog连接后按配置授予浏览器权限browser_use/browser/watchdogs/permissions_watchdog.py站点索要剪贴板、摄像头等权限
CaptchaWatchdog监听验证码求解事件,让步循环阻塞等待browser_use/browser/watchdogs/captcha_watchdog.py打开了 captcha_solver 配置
StorageStateWatchdogcookie 与存储状态的加载和落盘browser_use/browser/watchdogs/storage_state_watchdog.py配了 storage_stateuser_data_dir
LocalBrowserWatchdog本地浏览器子进程的启动与结束browser_use/browser/watchdogs/local_browser_watchdog.py浏览器起不来、临时目录没清
AboutBlankWatchdog保证始终有一个 about:blank 标签browser_use/browser/watchdogs/aboutblank_watchdog.py关掉最后一个标签、会话该不该结束
RecordingWatchdog用 CDP 录屏保存视频browser_use/browser/watchdogs/recording_watchdog.py配了 record_video_dir
HarRecordingWatchdog抓 HAR 网络记录browser_use/browser/watchdogs/har_recording_watchdog.py配了 record_har_path

表里的 14 个 watchdog 加上基类,就是这个目录的全部。看名字你已经能感觉到一件事:这些职责之间几乎没有共同抽象,硬要塞进一个「浏览器事件处理器」里,只会得到一个谁都不敢改的巨型函数。

四、杂事被处置掉了,信息去哪了

拆开最容易招来的质疑是:弹窗被自动点掉了,模型岂不是不知道发生过什么。browser-use 这条链路可以完整追下来。

PopupsWatchdog 在新标签创建时先 Page.enable,再用 register.Page.javascriptDialogOpening(handle_dialog) 挂上回调。回调里的处置规则是硬编码的:

should_accept = dialog_type in ('alert', 'confirm', 'beforeunload')

alertconfirmbeforeunload 一律点确定,prompt 点取消——注释给的理由是没法凭空提供输入内容。处置动作有两条退路:先用检测到对话框的那个 session 调 Page.handleJavaScriptDialog,超时 0.5 秒;不成再用当前 agent 焦点所在的 session 试一次,同样 0.5 秒超时。

关键在处置之前那一步:如果对话框带消息,它会把 f'[{dialog_type}] {message}' 追加进 browser_session._closed_popup_messages。这个列表定义在 BrowserSession 上,正好对应基类那条「共享状态放 session」的约定。

接下来 DOMWatchdog 处理 BrowserStateRequestEvent 时,会把 self.browser_session._closed_popup_messages.copy() 填进 BrowserStateSummaryclosed_popup_messages 字段——空页面的快路径、正常路径、异常兜底这三个返回点都填了。最后在 browser_use/agent/prompts.py 里,它变成提示词的一段:

if self.browser_state.closed_popup_messages:
    closed_popups_text = 'Auto-closed JavaScript dialogs:\n'
    for popup_msg in self.browser_state.closed_popup_messages:
        closed_popups_text += f'  - {popup_msg}\n'

一件杂事被就地处置,同时留下一条痕迹,痕迹在下一次取状态时被统一收走,进到模型看到的上下文里。写状态的那个 watchdog 不用知道谁会来取,取状态的 DOMWatchdog 也不用知道消息是怎么来的。这是拆开之后真正省下来的东西:只有一个地方决定「浏览器状态长什么样」,其他人往里加一格就好。

顺手记一个读代码时的观察:我读到的这几处只有追加和拷贝,没有清空的路径,所以同一个会话里累积的对话框消息会一直跟着状态往后传。你如果在长任务里发现提示词里挂着一串早就过去的弹窗提示,先往这里看。

SecurityWatchdog 是另一种形态的示范——同一件事(URL 准入)在三个时机有三种处置:

  • NavigateToUrlEvent:导航之前判断,不通过就 dispatch 一个 BrowserErrorEvent(error_type='NavigationBlocked'),然后 raise ValueError 把事件链打断;
  • NavigationCompleteEvent:这一次是为了抓重定向,发现落在不允许的 URL 上不抛异常,而是导航到 about:blank,注释写明理由是保住会话,让 agent 看到错误后还能继续干别的;
  • TabCreatedEvent:新标签的 URL 不合规,走 _cdp_close_page 把这个标签关掉。

三种处置强度递进,写在一个类里一目了然,摊到主循环里就是三处相隔很远的分支。判断逻辑本身也比看起来讲究:about:blank 和几个 chrome://new-tab-page 变体直接放行,data: / blob: 放行,block_ip_addresses 打开时 _is_ip_address 会先做 unquote、NFKC 归一化、把 换成 .,再用 ipaddresssocket.inet_aton 双重判断,防的是十进制、十六进制这类非标准 IPv4 写法。allowed_domains 传 set 走精确匹配快路径(顺带比对 www 变体),传 list 走 fnmatch 慢路径。

五、边界与代价:它放弃了什么

链路变长,栈变浅。 一个动作的实际执行者常常在另一个文件里,异常栈里看到的是包装器。基类里那串带 emoji 的 debug 日志(⏳ Starting...Succeeded 后面跟本次耗时秒数、❌ Failed 后面跟异常类型与消息,还会顺着 event_parent_id 往上找父事件和祖父事件标出「由谁触发」)就是为了补这个洞而存在的。代价是你必须开 debug 级别日志才看得清链路。

依赖顺序靠人守。 BrowserSession.attach_all_watchdogs() 里是一段固定顺序的实例化加 attach_to_session()DOMWatchdog 那行注释写着「depends on ScreenshotWatchdog」——依赖关系写在注释里和代码顺序里,不是靠机制保证的。想调整顺序,得自己读懂这段。

默认降级不喊疼。 PopupsWatchdog 整个处理器外面套着一层 except,失败只打 warning;PermissionsWatchdog 授权失败后注释明确写「Don’t raise - permissions are not critical」;熔断分支在未连接时直接 return None。好处是单件杂事失败不会带崩整步,代价是不看日志你不知道这件事根本没做成。

文件在,不等于跑着。 这是最容易误判的一点。attach_all_watchdogs()CrashWatchdog 那几行是整段注释掉的,也就是说崩溃与网络超时监控默认不生效。另外三个是条件挂载:CaptchaWatchdogbrowser_profile.captcha_solver 为真,StorageStateWatchdog 要配了 storage_stateuser_data_dirHarRecordingWatchdog 要配了 record_har_path

明确不管的事。 这套机制管的是浏览器层面的意外,不管业务语义对不对——点错按钮、填错表单没有任何 watchdog 会拦。它也不判断目标站点是否愿意被自动化。CaptchaWatchdog 的文件注释自己就写清了边界:同一时刻只跟踪一个验证码求解,多个验证码重叠时只跟最新那个,早前的等待可能提前返回;它做的是监听 BrowserUse.captchaSolverStarted / captchaSolverFinished 这两个 CDP 事件,然后让 agent 步循环阻塞等待或超时,识别本身不在这个文件里。

风险要摊开说。 这些 watchdog 驱动的是真实浏览器:PermissionsWatchdogBrowser.grantPermissions,代码注释注明 origin 为 None 时对所有源生效;配 storage_stateuser_data_dir 意味着真实登录态会落到磁盘;录屏和 HAR 会把页面画面与网络请求写进文件,里面可能有 token 和个人信息。带着自己的登录态去操作第三方站点,要先看目标站点的使用条款,也要接受账号可能被判为异常访问的后果。验证码和各种反自动化机制是站点在表达「这里不欢迎自动化」,配置项能让流程不卡死,不代表它替你解决了合规问题。这类判断和Agent 权限设计的最小化原则是同一类问题:能力边界要在配置里写死,而不是靠运行时自觉。

六、上手与避坑清单

处理器名字写错,等于没订阅。 会踩是因为注册靠的是 on_ 加事件类名的精确匹配,写成 on_TabCreated 在事件字典里找不到,循环直接跳过,连断言都不触发——一个安静失效的处理器。避法:把 LISTENS_TO 填上,声明了却没找到对应方法时基类会打 warning,这是唯一会主动提醒你的地方。

LISTENS_TO 填了一半。 会踩是因为它是双向检查:处理器订阅了未声明的事件,断言当场失败;声明了事件却没写处理器,只有 warning。避法:改处理器和改声明当成一次改动,别只改一半就跑测试。

以为目录里有文件就等于在跑。 会踩是因为 watchdogs/ 目录里躺着 14 个文件,但真正挂载与否由 attach_all_watchdogs() 决定,CrashWatchdog 在那里被整段注释掉了。避法:判断某个能力在不在,去读 browser_use/browser/session.pyattach_all_watchdogs() 那一段,而不是去 ls 目录。

allowed_domains 用 list 加通配符,然后惊讶主域也被放进来。 会踩是因为 *.example.com 这种模式在代码里被特意实现成同时匹配子域和主域,首次使用还会打一条 warning 说明这件事。避法:能把域名列全就用 set,走精确匹配快路径;非要用通配,先接受主域也在允许范围内。

以为默认就有域名护栏。 会踩是因为 _is_url_allowed 里有一条早退:allowed_domainsprohibited_domains 都没配置时,直接 return True。避法:护栏是你自己配出来的,跑第三方站点之前先把清单写上,需要拦 IP 直连再打开 block_ip_addresses

指望对话框内容当场返回给模型。 会踩是因为对话框是被立即处置的,消息只作为下一次浏览器状态里的一段 Auto-closed JavaScript dialogs 出现;而且处置规则是固定的,prompt 一律取消,不会替你填内容。避法:别把流程设计成依赖 window.prompt 拿输入,这条路在当前实现里走不通。

重复挂载直接抛错。 会踩是因为基类发现同名处理器已注册就抛 RuntimeError,错误信息直接点名 attach_to_session() 被调了多次;BrowserSession 自己用 _watchdogs_attached 做了去重。避法:自定义 watchdog 只在自己的初始化路径里挂一次,不要在事件回调里补挂。

后台任务名字不合规范,析构时不会被取消。 会踩是因为基类 __del__ 只认以 _task_tasks 结尾的私有属性。避法:新写的后台任务照着 _monitoring_task_cdp_event_tasks 的命名来,别自创名字。

收束:接下来该读哪个文件

这套拆法可以浓缩成三个可验证的问题,你在自己的项目里也能拿去自查:一件杂事失败,会不会带崩整个动作?一件杂事被处置掉,模型有没有渠道知道它发生过?一个能力到底有没有被挂上,是读一行配置能确认,还是要靠猜?

对着 browser-use 继续读的顺序建议是:先 browser_use/browser/watchdog_base.py,把命名注册、熔断、清理这三条约定看明白;再跳到 browser_use/browser/session.pyattach_all_watchdogs(),确认自己这次运行里到底挂了哪几个;然后按你实际踩到的问题挑对应文件,域名被拦看 security_watchdog.py,模型看到的元素不对看 dom_watchdog.pyon_BrowserStateRequestEvent;想知道杂事怎么进到提示词,把 dom_watchdog.pybrowser_use/agent/prompts.py 对着看一遍就通了。想进一步区分「这次失败该重试还是该换路」,可以接着读失败分类的做法

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 的浏览器会话层browser-use 的 actor 层

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