browser-use 用 14 个 watchdog 分管浏览器杂事
本文基于 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 顶上有两个 ClassVar:LISTENS_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,里面是 BrowserStartEvent、BrowserStopEvent、BrowserReconnectingEvent 这类管生命周期的事件,它们即使连接断了也要跑。其余事件遇到 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,或看不懂事件日志时 |
| PopupsWatchdog | JS 对话框自动处置并留痕 | browser_use/browser/watchdogs/popups_watchdog.py | 页面弹 alert/confirm 却没卡住流程 |
| SecurityWatchdog | URL 准入:导航前、导航后、新标签三处拦 | browser_use/browser/watchdogs/security_watchdog.py | 配了 allowed_domains 或 prohibited_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 配置 |
| StorageStateWatchdog | cookie 与存储状态的加载和落盘 | browser_use/browser/watchdogs/storage_state_watchdog.py | 配了 storage_state 或 user_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')
alert、confirm、beforeunload 一律点确定,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() 填进 BrowserStateSummary 的 closed_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 归一化、把 。 。 换成 .,再用 ipaddress 和 socket.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 那几行是整段注释掉的,也就是说崩溃与网络超时监控默认不生效。另外三个是条件挂载:CaptchaWatchdog 要 browser_profile.captcha_solver 为真,StorageStateWatchdog 要配了 storage_state 或 user_data_dir,HarRecordingWatchdog 要配了 record_har_path。
明确不管的事。 这套机制管的是浏览器层面的意外,不管业务语义对不对——点错按钮、填错表单没有任何 watchdog 会拦。它也不判断目标站点是否愿意被自动化。CaptchaWatchdog 的文件注释自己就写清了边界:同一时刻只跟踪一个验证码求解,多个验证码重叠时只跟最新那个,早前的等待可能提前返回;它做的是监听 BrowserUse.captchaSolverStarted / captchaSolverFinished 这两个 CDP 事件,然后让 agent 步循环阻塞等待或超时,识别本身不在这个文件里。
风险要摊开说。 这些 watchdog 驱动的是真实浏览器:PermissionsWatchdog 调 Browser.grantPermissions,代码注释注明 origin 为 None 时对所有源生效;配 storage_state 或 user_data_dir 意味着真实登录态会落到磁盘;录屏和 HAR 会把页面画面与网络请求写进文件,里面可能有 token 和个人信息。带着自己的登录态去操作第三方站点,要先看目标站点的使用条款,也要接受账号可能被判为异常访问的后果。验证码和各种反自动化机制是站点在表达「这里不欢迎自动化」,配置项能让流程不卡死,不代表它替你解决了合规问题。这类判断和Agent 权限设计的最小化原则是同一类问题:能力边界要在配置里写死,而不是靠运行时自觉。
六、上手与避坑清单
处理器名字写错,等于没订阅。 会踩是因为注册靠的是 on_ 加事件类名的精确匹配,写成 on_TabCreated 在事件字典里找不到,循环直接跳过,连断言都不触发——一个安静失效的处理器。避法:把 LISTENS_TO 填上,声明了却没找到对应方法时基类会打 warning,这是唯一会主动提醒你的地方。
LISTENS_TO 填了一半。 会踩是因为它是双向检查:处理器订阅了未声明的事件,断言当场失败;声明了事件却没写处理器,只有 warning。避法:改处理器和改声明当成一次改动,别只改一半就跑测试。
以为目录里有文件就等于在跑。 会踩是因为 watchdogs/ 目录里躺着 14 个文件,但真正挂载与否由 attach_all_watchdogs() 决定,CrashWatchdog 在那里被整段注释掉了。避法:判断某个能力在不在,去读 browser_use/browser/session.py 里 attach_all_watchdogs() 那一段,而不是去 ls 目录。
allowed_domains 用 list 加通配符,然后惊讶主域也被放进来。 会踩是因为 *.example.com 这种模式在代码里被特意实现成同时匹配子域和主域,首次使用还会打一条 warning 说明这件事。避法:能把域名列全就用 set,走精确匹配快路径;非要用通配,先接受主域也在允许范围内。
以为默认就有域名护栏。 会踩是因为 _is_url_allowed 里有一条早退:allowed_domains 和 prohibited_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.py 的 attach_all_watchdogs(),确认自己这次运行里到底挂了哪几个;然后按你实际踩到的问题挑对应文件,域名被拦看 security_watchdog.py,模型看到的元素不对看 dom_watchdog.py 的 on_BrowserStateRequestEvent;想知道杂事怎么进到提示词,把 dom_watchdog.py 和 browser_use/agent/prompts.py 对着看一遍就通了。想进一步区分「这次失败该重试还是该换路」,可以接着读失败分类的做法。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 的浏览器会话层 和 browser-use 的 actor 层。