browser-use 的浏览器会话层:直连 CDP,三块各管什么

2026-07-30

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

这层最关键的判断是:browser-use 没有把浏览器抽象成”一个页面”,而是抽象成”一堆随时会出现和消失的 target”,会话层的绝大部分复杂度都是在替你兜住这个事实。 你在上层看到的是 agent_focus_target_id 这么一个字段,底下是一个靠 CDP 事件不断增删的会话池、一套焦点丢失后自动重建的恢复逻辑,以及一条把所有浏览器动作串起来的事件总线。理解这三块的分工,比记住有哪些 action 更能帮你判断线上问题出在哪。

这篇只谈会话这一层,进程怎么被拉起、日志怎么留痕、agent 之间怎么通信都不在范围内。想看另一种思路下服务端进程的托管方式,可以对照 pi 服务端包怎么把 agent 拉起并看住;想看运行记录该留成什么样,去 agent 跑完怎么回头看懂它的决策;协议层面 MCP、A2A 这类”连什么”的问题,在 Agent 侧协议生态的分层 里。本篇是它们下面一层:具体一个浏览器会话的内部结构。

一、这层到底要解决什么问题

让模型操作浏览器,听起来只需要”点这里、输入那个”。真正难的是中间那层状态。

一个 Chrome 实例里同时存在很多 target:普通标签页、跨域 iframe、Service Worker。CDP 的通信单位不是页面而是 session——你得先 attach 到某个 target 拿到一个 sessionId,之后所有命令都带着它发。麻烦在于这些东西的寿命完全不受你控制:点一个链接可能开出新标签页,页面自己会 detach 掉短命的 worker,一个崩掉的标签页会让你手里的 sessionId 立刻作废。

所以会话层要回答三个问题:现在有哪些 target 存在、每个 target 上有哪些通信通道可用、模型此刻”站在”哪个 target 上。browser_use/browser/session_manager.py 的类文档把职责说得很直接——SessionManager 是所有 target 和 session 的唯一事实来源(single source of truth);browser_use/browser/session.py 里凡是要拿页面列表的地方,注释都会补一句”以 SessionManager 为准”,BrowserSession 自己则是对外的门面加事件枢纽。

值得留意的是它对依赖的选择:这层直接用 cdp_use 提供的 CDPClient 说 CDP,事件总线用的是 bubusEventBus,中间没有再垫一层通用浏览器自动化库。这个取向决定了后面所有的能力和脆弱点。

二、三块各管什么

组成部分它负责什么对应仓库位置你什么时候会碰到它
BrowserSession对外门面:连接、启停、导航、切标签、挂 watchdog、维护 agent_focus_target_idbrowser_use/browser/session.py写集成代码、传浏览器配置、排查连不上时
Target / CDPSession两个数据模型:前者是被控实体(有 url、title、type),后者是通往它的一条通信通道(带 session_idbrowser_use/browser/session.py读源码时分清”实体”和”通道”,一个 target 可以挂多条 session
SessionManager会话池:监听 target 附加/分离事件增删条目,维护四张映射表,焦点失效时自动恢复browser_use/browser/session_manager.py标签页崩了、焦点丢了、导航一直等不到 ready 时
事件定义把每个浏览器动作和状态变化定义成一个事件类,带返回类型和超时browser_use/browser/events.py自己加动作、想调超时、想监听某个状态变化
ResilientEventBusbubusEventBus 做的一层包装,让已经拆掉的总线上再调 step()/wait_until_idle() 时安静返回而不是断言失败browser_use/browser/session.py会话被 close 后又被复用(注释里点名的是暖启动恢复场景)
watchdog 一族具体动作和副作用的执行者:点击输入、DOM、截图、下载、弹窗、权限、安全域名限制等browser_use/browser/watchdogs/(14 个)想知道某个动作到底怎么执行的,去对应 watchdog 里找

分工可以这么记:SessionManager 只关心”有什么、还活着没有”,BrowserSession 关心”现在对谁做事”,events.py 定义”能做什么事”,watchdog 才是”怎么做”。

三、会话池怎么跟浏览器保持同步

SessionManager.start_monitoring() 是这块的入口,它做的事很有代表性。

先开 Target.setDiscoverTargets,带上只关心 pageiframe 的过滤器,这样 target 的标题和 URL 变化会以 Target.targetInfoChanged 事件推过来,不必轮询 getTargetInfo()。接着注册四个处理器:target 附加、target 分离、target 信息变化、页面生命周期事件。最后调 _initialize_existing_targets() 把连上之前就存在的标签页补进池子——它对每个已有 target 调一次 Target.attachToTarget,然后等附加事件把活干完。

新 target 靠 Target.setAutoAttach 自动进来,参数是 autoAttach: TruewaitForDebuggerOnStart: Falseflatten: True。根客户端上开一次管顶层,每个 session 附加成功后还会在自己这条 session 上再开一次,用来接住它的子 target。

_handle_target_attached() 是唯一往池子里加东西的地方,它同时维护四张表:target 表、session 表、target 到 session 集合的映射、session 到 target 的反向映射。分离逻辑对称但多一层判断——只有当某个 target 上的最后一条 session 也断开时,这个 target 才被移除,中途少一条通道不算它死了。这一条如果不清楚,你读日志会以为池子在乱跳。

生命周期事件的处理方式藏了一个很实际的坑,源码里的注释直接把原因写出来了:

def on_lifecycle_event(event, session_id: SessionID | None = None):
    # ONE global handler for all targets: route by session_id -> target_id.
    # Registering per-session closures instead would clobber each other in
    # cdp-use's single-slot registry (one handler per CDP method).
    if not session_id:
        return
    target_id = self.get_target_id_from_session_id(session_id)

也就是说,cdp_use 的事件注册表对每个 CDP 方法只有一个槽位。如果按 session 各注册一个闭包,后注册的会顶掉先注册的,结果是除最近附加的那个标签页之外,其它页的生命周期事件全部收不到。它的解法是全局只注册一个处理器,靠 session_id 反查 target_id,再把事件塞进按 target 分开的环形缓冲区(每个 target 上限 50 条)。你如果打算给这套东西加自己的 CDP 事件监听,这个单槽位特性必须先知道。

焦点恢复是另一块。当持有焦点的 target 分离时,_handle_target_detached() 会立刻把 agent_focus_target_id 置空,再拉起一个恢复任务:优先切到还活着的页面里最新的那个,一个都没有就新建一个空白页,然后等附加事件把 session 建好(每 100 毫秒查一次,最多等 2 秒),成功后派发焦点变更事件;实在拿不到就再建一个应急标签页兜一次,全都失败会打一条 critical 日志。这里的锁和事件配合值得看一眼:恢复过程是排他的,同时来的其它调用不会各自建一个新标签页,而是等在同一个”恢复完成”事件上。

四、事件总线为什么被放在这一层

events.py 里每个动作都是一个事件类,携带自己的参数和返回类型:导航事件带 URL、wait_until、是否新标签页;输入事件带文本、是否清空原内容,还有 is_sensitivesensitive_key_name 两个字段专门标记敏感值;等待事件除了 seconds 还有一个 max_seconds 当安全上限。状态侧同样是事件:标签页创建与关闭、焦点变更、导航开始与完成、target 崩溃、WebSocket 重连中与重连成功、存储状态存取、下载的三个阶段、弹窗被处理。

两个细节能看出这套定义是被真实运维经验修过的。

一个是超时。每个事件类的 event_timeout 默认值都由 _get_timeout() 生成,先读形如 TIMEOUT_NavigateToUrlEventTIMEOUT_ClickElementEvent 的环境变量,解析失败或为负就打一行警告回落到默认值。这意味着你在慢环境里调超时不用改代码,但也意味着这些值散在环境里,排查时要顺手确认一遍。

另一个是命名。文件末尾有个 _check_event_names_dont_overlap(),在导入时就跑:所有事件名必须以 Event 结尾,且任何一个名字不能是另一个名字的子串。注释说得直白——名字互相包含会让 grep 和 sed 在重构时变成陷阱,所以 ClickEventFailedClickEvent 这种组合是不允许的,必须是 ClickEventClickFailedEvent。这条约束是给维护者定的纪律,不是给运行时的功能。

导航等待是事件设计落到实处的例子。_navigate_and_wait() 发出 Page.navigate 之后,去读那个按 target 分开的生命周期缓冲区,按 wait_until 决定哪些信号算达标;如果 wait_untilcommit 就直接返回,如果返回结果里没有 loaderId(同文档跳转,比如锚点和 History API)也直接返回,因为这种情况 Chrome 不会再发新的加载事件,继续等只是白耗超时。过滤旧事件的方式是比对 loaderId,实在没有 loaderId 的事件就看时间戳是否在本次导航开始之后。等超时了它不抛异常,而是把一段说明字符串塞进导航完成事件的 loading_status 字段,让下游知道这页可能没加载完。这种”降级但如实上报”的处理,比直接失败对 agent 循环更友好,代价是你必须真的去读这个字段。

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

直连 CDP 换来的是控制粒度和信息量,代价同样具体。

跨浏览器这件事直接放弃了。 会话层全程说的是 Chrome DevTools Protocol,连接时还有一段逻辑:如果给的不是 ws 开头的地址,就去 /json/version 端点取 WebSocket 调试地址。这套东西天然只对 Chromium 系生效,指望它去驱动别的内核不成立。

CDP 的版本与行为差异要你自己吃下。 没有中间层帮你抹平不同 Chrome 版本上事件时序的差别,前面那个单槽位事件注册表的坑就是这种代价的具体形态。

长连接是个真实的故障面。 整个会话建立在一条 WebSocket 上,源码里为此做了三件事:is_cdp_connected 会去看底层 WebSocket 是不是 OPEN 状态,免得往死连接上发命令干等超时;给消息处理任务挂一个完成回调,任务异常退出就当作连接掉了;掉线后触发自动重连,最多三次,退避间隔依次是 1、2、4 秒,每次尝试自身有 15 秒上限。但重连不是无损的——reconnect() 会把整个 SessionManager 清空重建,因为断线后所有 sessionId 都失效了;它会尽力把焦点还原到原来那个 target,还原不了就退到第一个页面,页面全没了就新建空白页。也就是说,重连能救回”还能继续操作浏览器”,救不回”agent 手里那批元素引用还有效”。

同一个 target 上取哪条 session 是不保证的。 内部方法从 session 集合里取第一个可用的,集合无序,多条通道并存时你拿到哪条不确定。这个方法在源码里被明确标为内部 API,上层应该走 get_or_create_cdp_session(),那条路上才有校验、焦点管理和恢复。

焦点只认页面类型。 get_or_create_cdp_session() 里带 focus=True 时,只有 target 类型是 page 才真的改焦点,iframe 和 worker 的焦点请求会被记一行日志然后忽略。理由写在注释里:这些东西随时会 detach,让焦点指向它们等于埋一个死指针。

它明确不管的部分:不负责替你判断目标站点允不允许自动化访问,不负责验证码,不负责账号风控。仓库里确实有和验证码相关的事件定义和一个对应的 watchdog,但那是用来接收浏览器代理侧的开始/结束通知、让 agent 知道该等一等,与”帮你绕过检测”不是一回事。目标站点的服务条款、反自动化机制、以及账号被判为异常的可能,全部落在使用者头上。 这类项目会驱动真实浏览器,如果你复用了带登录态的用户目录,它操作的就是你的真实账号。

顺带一个容易被忽略的外泄面:会话层提供了导出存储状态的能力,通过 CDP 直接拿到解密后的 cookie 并写成文件。这确实解决了钥匙串加密的麻烦,但那个文件等于一份可直接复用的登录凭据。配套的域名限制来自 profile 上的 allowed_domainsprohibited_domains,由 browser_use/browser/watchdogs/security_watchdog.py 落实。权限该怎么切,参考 给 agent 设计最小权限

六、上手与避坑清单

先分清 target 和 session,再看日志。 为什么会踩:日志里 target 和 session 的 ID 都被截成前 8 位混在一行里,不区分就会把”一条通道断了”读成”标签页关了”。怎么避:记住 target 只在最后一条 session 也断开时才消失,日志里那个 remaining= 才是关键数字。

不要直接调内部的取 session 方法。 为什么会踩:它名字看着就是你要的,还比公开方法短。怎么避:走 get_or_create_cdp_session()。内部那个方法没有校验、没有焦点管理、没有恢复,还会在集合里随机挑一条通道;它的 docstring 用警告符号明确要求你别用。

加 CDP 事件监听前先确认没人占着那个方法槽位。 为什么会踩:注册表每个 CDP 方法只有一个槽,你注册 Page.lifecycleEvent 的处理器会静默顶掉框架自己的那个,结果是导航等待再也拿不到信号,表现为”页面明明加载完了但一直超时”。怎么避:需要额外处理时,去读现有的全局处理器怎么按 session 路由,在它的基础上扩展,而不是另注册一个。

导航”成功”要看 loading_status 为什么会踩:等待超时不抛异常,导航完成事件照样发出来,你以为页面好了。怎么避:消费导航完成事件时把这个字段一起看,非空说明只是超时后放行。另外这个等待窗口在没显式指定时是按域名关系推的:目标 URL 与当前 URL 主机相同就给一个较短的窗口,跨域则宽松一些——同域跳到一个慢页面反而更容易撞上超时分支。

同文档跳转不会有加载事件。 为什么会踩:锚点跳转和前端路由切换后你去等 load,等到的只有超时。怎么避:这种场景下页面结构变化要靠 DOM 侧确认,别指望生命周期信号。源码里对没有 loaderId 的返回结果是直接放行的,行为上和你的预期未必一致。

重连之后别复用旧的元素索引。 为什么会踩:重连日志看着是恢复成功,代码也继续跑。怎么避:把重连事件当成一次状态失效信号处理——会话池是清空重建的,之前那批 backend node ID 和 session 绑定关系都不再成立,重新取一次浏览器状态再动手。重试和幂等这块的思路可以参考 agent 失败之后怎么重试

超时值可能不在代码里。 为什么会踩:你翻遍源码确认某个动作是 15 秒,线上却在 3 秒就断了。怎么避:先看部署环境有没有设 TIMEOUT_ 前缀的变量,这类值是运行时读的,会覆盖代码里的默认值。

用真实用户目录之前先想清楚。 为什么会踩:复用系统 Chrome 的 profile 最省事,能直接带着登录态干活。怎么避:先用干净的独立目录跑通,确认动作序列符合预期,再决定要不要接触真实账号;同时用域名白名单把可访问范围收窄,敏感输入走事件里那两个敏感标记字段而不是明文塞进普通文本。真实账号一旦被判异常,代价不在这个项目里体现。

接下来读哪个文件

如果你要判断这套结构值不值得借鉴,建议按这个顺序读:browser_use/browser/session_manager.py 从头到尾读一遍,它是这层的骨架且不长;然后回到 browser_use/browser/session.py 只看三个方法——connect()get_or_create_cdp_session()_navigate_and_wait(),这三处把连接、取通道、等就绪的完整链路走了一遍;最后翻 browser_use/browser/events.py,把事件清单当成这层的能力边界表来看。想知道某个具体动作怎么执行的,再去 browser_use/browser/watchdogs/ 里对应文件。

自检三条:你能说清一个 target 上有两条 session 时,池子的移除条件是什么;你能说清导航等待返回非空字符串代表什么;你能说清重连之后哪些状态还有效、哪些已经作废。这三条答得上来,线上出问题时你才知道该先看哪一行日志。项目采用 MIT 许可证,源码在 https://github.com/browser-use/browser-use ,本文的所有结论都可以回仓库逐行核对。

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

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