browser-use 源码梳理:专职浏览器 Agent 与顺手开浏览器的工具差在哪
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
browser-use 真正花力气的地方不是「点击」,而是把一个随时变化的网页压缩成模型能稳定填的一张表,并且用一组常驻监听器兜住浏览器自己会闯出来的祸。 你如果只看它的入口示例,会觉得这就是几行 Agent(task=..., llm=...);但把 browser_use/browser/session.py 打开翻到 attach_all_watchdogs(),你会看到十几个监听器依次挂到同一条事件总线上——下载、弹窗、权限、录制、越界导航各有专人。这个结构差异,决定了它和「在通用 Agent 里顺手挂两个浏览器工具」不是同一类东西。
站内已经有三篇横向对照:开源 Agent 项目三体对比 比的是项目定位与取舍,Agent 扩展与跨平台三体对比 比的是扩展机制,Agent 框架对比 比的是框架层选型;这一篇只钻一个项目的浏览器控制链路,从事件总线一直看到动作参数。
一、它先解决的问题:让模型能指认页面上的某个东西
模型看不见 DOM,也不该看见完整 DOM。browser-use 的做法是每一步把页面重建成一棵带编号的树,编号才是模型唯一能引用的东西。看 browser_use/agent/system_prompts/system_prompt.md 里对 <browser_state> 的描述就很直白:交互元素以 [index]<tagname attribute=value /> 的形式给出,文本内容作为子节点单独一行,缩进代表父子关系。它还给了三个额外标记:*[ 前缀表示这是上一步之后新出现的交互元素(URL 未变的前提下),|SCROLL| 前缀表示可滚动容器并附带滚动位置,|SHADOW(open)| 或 |SHADOW(closed)| 表示 shadow DOM。
同一份提示词里写死了配套约束:只与带数字 [index] 的元素交互,只使用明确给出的编号,默认只列出当前可视区内的元素。
编号从哪来?browser_use/browser/watchdogs/dom_watchdog.py 里 DOMWatchdog 维护三个公开属性:selector_map(类型是 dict[int, EnhancedDOMTreeNode])、current_dom_state、enhanced_dom_tree。也就是说编号到真实节点的映射是每一步重建、缓存在会话里的,AgentFocusChangedEvent 到来时 session.py 会调用 self._dom_watchdog.clear_cache()。
对你的意义很实际:编号是一次性的坐标,不是稳定选择器。 你不能把某次跑出来的 index=38 写进脚本重放,页面结构一动它就指向别的东西。想要稳定引用,你要么走 browser_use/actor/ 那层用 CSS 选择器,要么自己在动作外面做校验。
二、主干四层与周边四块:它们在仓库里的位置
下面这张表里,Agent 循环、动作层与注册表、浏览器会话、actor 是主干四层;系统提示词、watchdog 群、LLM 适配层、示例集是围着主干长出来的周边四块。分开看比混成一锅好排查——出问题时你至少知道该翻哪一列。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| Agent 循环 | 组织每一步的输入、调用模型、执行动作、判断收尾 | browser_use/agent/service.py | 想接回调、想管 sensitive_data 时 |
| 系统提示词 | 规定页面表示法、浏览器规则、收尾校验 | browser_use/agent/system_prompts/(8 份) | 模型行为不对味、想改默认策略时 |
| 动作层与注册表 | 定义模型能调的动作及参数模型 | browser_use/tools/service.py、browser_use/tools/registry/ | 加自定义动作、想禁掉某个动作时 |
| 浏览器会话 | CDP 连接、标签页管理、事件总线、状态汇总 | browser_use/browser/session.py | 配置 profile、排查连不上时 |
| watchdog 群 | 各自订阅事件,兜住下载、弹窗、权限、越界等 | browser_use/browser/watchdogs/(14 个文件) | 某类浏览器行为没生效时 |
| actor 低层 API | 不经过模型的页面/元素/鼠标操作 | browser_use/actor/(page.py、element.py、mouse.py) | 需要确定性操作、写夹具时 |
| LLM 适配层 | 各家模型的 chat 封装 | browser_use/llm/(15 个 provider 目录) | 换模型、接自建服务时 |
| 示例集 | 可跑的用法样本 | examples/(124 个文件) | 找现成写法时 |
这张表里最容易被忽略的是 actor 层。browser_use/actor/README.md 把它定位成建立在 CDP 之上的低层自动化库,提供 BrowserSession(别名 Browser)、Page、Element、Mouse 四个核心类。它同时也提供两个需要 LLM 的方法:get_element_by_prompt() 用自然语言找元素,extract_content() 按你给的 Pydantic 模型抽结构化数据。换句话说,同一个仓库里既有「全交给模型」的路,也有「我自己写死流程」的路。
模型能调的动作在 browser_use/tools/service.py 里逐个注册,名字都很短:search、navigate、click、input、scroll、send_keys、go_back、wait、switch、close、extract、search_page、find_elements、find_text、screenshot、save_as_pdf、dropdown_options、select_dropdown、upload_file、write_file、read_file、replace_file、evaluate、done。参数模型在 browser_use/tools/views.py:ClickElementAction 允许 index,也允许 coordinate_x / coordinate_y 这对坐标;InputTextAction 除了 index 和 text 还有一个 clear 开关,默认 True,注释里写明 clear=True 配空字符串就是清空字段而不输入。
自定义动作的写法在仓库 README 里给了:
from browser_use import Tools
tools = Tools()
@tools.action(description='Description of what this tool does.')
def custom_tool(param: str) -> str:
return f"Result: {param}"
三、事件总线加 watchdog:把意外从主流程里挪出去
session.py 顶部导入的是 bubus 的 EventBus 和 cdp_use 的 CDPClient。会话里定义了 Target、CDPSession 两个数据模型,以及一个 ResilientEventBus——它的 step() 和 wait_until_idle() 在总线已被拆掉时直接返回 None,而不是断言失败,注释里说明这是为了应对会话关闭后又被 step() 的场景。
连接过程在 connect() 里:先起 TimeoutWrappedCDPClient,然后启动 SessionManager 开始监听,再调 Target.setAutoAttach,参数是 autoAttach: True、waitForDebuggerOnStart: False、flatten: True。这里有个很实在的细节——WebSocket 帧上限被设成 200MB,注释写的原因是「处理 DOM 极大的页面」。
真正把行为分出去的是 watchdog。attach_all_watchdogs() 里逐个 model_rebuild() 再 attach_to_session():下载、本地浏览器进程、安全策略、about:blank、弹窗、权限、默认动作、截图、DOM、录制。其中三个是条件挂载:StorageStateWatchdog 只在配了 storage_state 或 user_data_dir 时挂(日志里会打出这两个布尔值),HarRecordingWatchdog 只在配了 record_har_path 时挂,CaptchaWatchdog 只在 captcha_solver 打开时挂。还有一个更值得记住的现状:CrashWatchdog 那段初始化代码在当前提交里是被注释掉的,目录里有文件不等于它在跑。
越界拦截是这套机制最好的例子。browser_use/browser/watchdogs/security_watchdog.py 里 SecurityWatchdog 声明自己 LISTENS_TO 三个事件、EMITS 一个 BrowserErrorEvent,然后在导航发生前就把不合规的地址掐掉:
async def on_NavigateToUrlEvent(self, event: NavigateToUrlEvent) -> None:
"""Check if navigation URL is allowed before navigation starts."""
if not self._is_url_allowed(event.url):
self.logger.warning(f'⛔️ Blocking navigation to disallowed URL: {event.url}')
# 此处省略:向总线 dispatch 一个 error_type='NavigationBlocked' 的 BrowserErrorEvent
raise ValueError(f'Navigation to {event.url} blocked by security policy')
raise 这一步是关键:它不是记一条日志就放行,而是让整个事件处理链断掉,导航请求根本不会发到 CDP。
它还堵了另外两个口子:on_NavigationCompleteEvent 里如果发现落地地址不合规(重定向绕过),会把这个标签页导到 about:blank 保住会话;on_TabCreatedEvent 里如果新标签页地址不合规,直接把标签页关掉。判定逻辑 _is_url_allowed() 读的是 profile 上的 allowed_domains 和 prohibited_domains,两个都没配就一律放行——换句话说默认配置下这道闸是敞开的,它是你主动锁上才生效的锁,不是开箱即用的保护。这个函数里还有几处先于域名判断的短路:about:blank 与几个 chrome://new-tab-page 变体永远允许,data: 和 blob: 这类没有主机名的地址也直接放过,profile 上的 block_ip_addresses 打开时会先把 IP 直连挡掉。读这段的顺序很重要,否则你会误以为只要写了 allowed_domains 就万事俱备。
配套的还有凭据处理。browser_use/tools/registry/service.py 里 _replace_sensitive_data() 负责在动作真正执行前把 <secret> 占位符换成真值——只要配了 sensitive_data,任何动作的参数都会走这一遍替换,并不只限于填表;真正被收窄的是那个 has_sensitive_data 标记,它只在动作名恰好是 input 时才为真,也只有 input 的实现会读它来决定日志与回读怎么处理。browser_use/agent/service.py 里则做了一件更该被看见的事——你给了 sensitive_data 却没锁 allowed_domains,它会直接告警,原话是「如果 agent 访问了恶意网站并遭遇提示注入攻击,你的 sensitive_data 可能被暴露」。如果你用的是按域名分组的凭据格式,它还会逐个检查这些域名模式是否被 allowed_domains 覆盖,没覆盖就再警告一次。这条链路和 提示注入防御 讲的是同一个威胁面,只是它把防线放在了导航和参数替换两处。
四、边界与代价:它明确不管的事
第一,actor 层不是 Playwright 的替代品。browser_use/actor/README.md 里有一句加粗声明:这是 browser-use actor,不是 Playwright 或 Selenium,只用文档里列出的方法。它甚至点名了几个不存在的方法:element.submit()、element.dispatch_event()、element.get_property()。page.evaluate() 和 element.evaluate() 必须传 (...args) => {} 形式的箭头函数字符串,返回值永远是字符串(对象会被自动 JSON 序列化)。
第二,时序要你自己处理。README 明确写了 get_elements_by_css_selector() 立即返回、不等待可见性,并且在防错建议里让你「用合适的 asyncio.sleep() 处理导航时序」,以及用 page.get_url()、page.get_title() 校验页面状态变化。这是一个坦诚的表态:它没打算给你一套自动等待语义。
第三,抽取动作是有代价的。系统提示词里对 extract 的措辞是「调用它很贵」,并要求不要用同一个 query 反复查同一页;相对地它推荐先用 search_page 找文本、用 find_elements 看 DOM 结构,因为这两个免费且即时。这实际上是在提示词层面做预算控制。
第四,也是最需要写清楚的:反自动化机制不在开源侧的承诺范围内。仓库 README 在验证码那条 FAQ 里说得很直接——处理验证码需要更好的浏览器指纹和代理,并把你指向它的云端服务;在上生产那条里说 Chrome 会吃掉大量内存,大量并发不好管。系统提示词也让模型不要手动去解验证码,遇到 403、bot 检测或限流时不要反复重试同一个 URL,而是换路子或者直接报告限制。
把这几条合起来看,边界就清楚了:这套东西擅长的是「你本来有权限、只是手工点太慢」的流程。反过来说,如果目标站点的使用条款不允许自动化访问、或者它靠验证码与风控刻意挡自动流量,那正确的动作是去看条款、找官方 API、或者放弃,而不是想办法绕。绕过反爬与规避风控这件事本文不提供任何做法。
还有一层容易被低估的风险面。README 的认证 FAQ 推荐了复用真实 Chrome 配置文件的例子,BrowserSession 也支持 user_data_dir、storage_state、profile_directory 这些参数。一旦你这么配,模型就是带着你的登录态在操作真实账号:它可能发出不可逆的操作,页面上的文字也可能是攻击者写的指令,目标平台也完全可以把这种访问模式判为异常。凭据、cookie、页面正文都会进入模型上下文,这是实打实的数据外泄面。
五、和「顺手挂个浏览器工具」的做法差在哪
拿 hermes-agent 这个通用 Agent 做对照最合适,因为它同时做了两件事:自己实现了一套浏览器工具,又把 browser-use 当成一种可插拔后端。plugins/browser/browser_use/provider.py 里 BrowserUseBrowserProvider 继承 BrowserProvider,name 返回 browser-use,create_session() 向 https://api.browser-use.com/api/v3 的 /browsers 发 POST 建会话,取回的 cdpUrl 或 connectUrl 作为 cdp_url 返回,close_session() 用 PATCH 带 {"action": "stop"} 收尾。也就是说它要的只是一个云浏览器的 CDP 地址,并不使用 browser-use 自己的 Agent 循环。两者不是互斥关系,这里比的是设计取向,不排座次。
寻址方式不同。 browser-use 用数字编号(selector_map 是 dict[int, ...],提示词里是 [index]<tag />);hermes-agent 的 tools/browser_tool.py 走可访问性树快照,元素引用形如 @e1、@e5,browser_click 的参数直接叫 ref。前者把「哪些元素可交互」的判断放在自己的 DOM 序列化里,后者借的是可访问性树。
工具粒度不同。 browser-use 把二十多个动作注册进一个注册表,由 Agent 循环统一调度;hermes-agent 在 browser_tool.py 末尾注册的是十个扁平工具:browser_navigate、browser_snapshot、browser_click、browser_type、browser_scroll、browser_back、browser_press、browser_get_images、browser_vision、browser_console,它们和这个 Agent 的其它工具平级共存。它的工具描述里甚至主动劝退:简单取信息优先用 web_search 或 web_extract,纯文本端点(.md、.txt、.json 之类)优先用 curl,因为「浏览器栈对这些场景过重且慢得多」。
状态归属不同。 browser-use 在进程内长期持有一条 CDP 连接,由 SessionManager 加 setAutoAttach 跟踪所有 target;hermes-agent 的 tools/browser_cdp_tool.py 走的是另一条路,它的 schema 里明说每个无状态调用彼此独立、会话与事件订阅不跨调用保留,只有传 frame_id 走 supervisor 常连接时才复用会话。
兜的东西不同。 browser-use 的 watchdog 兜浏览器自身行为;hermes-agent 兜的是把浏览器接进一个通用 Agent 之后新出现的问题:browser_cdp_tool.py 里有一份 _CDP_PRIVATE_PAGE_ALLOWED_METHODS 白名单,配合 _eval_ssrf_guard_active()、_is_safe_url()、_is_always_blocked_url()、_current_page_private_url() 做私网与内网地址拦截,并用 _redact_cdp_output() 对返回内容脱敏;browser_tool.py 里 SNAPSHOT_SUMMARIZE_THRESHOLD 定在 15000,超了就截断或让模型摘要,全量快照落盘并把路径给回模型。
边界的落点不同。 browser-use 按域名清单在导航事件上拦;hermes-agent 按「是不是私网地址」在工具层拦,而且明确把原始 CDP 逃生舱纳入同一条边界,注释里的理由是不能让 Runtime.evaluate 变成绕过守卫的兄弟通道。这是两种权限模型,各自服务于各自的部署形态,思路可以对照 最小权限设计。
六、上手与避坑清单
别把 Playwright 的肌肉记忆带进 actor 层。 会踩是因为方法名太像,element.get_property() 这种写法脑子里会自动补出来。怎么避:写之前把 browser_use/actor/README.md 的 API Reference 那节过一遍,只用上面列出的方法;evaluate 一律写成箭头函数字符串。
别假设选择器查询会等元素出现。 会踩是因为大多数自动化库默认帮你等。怎么避:README 已经说了它立即返回,异步渲染的页面上你会拿到空列表;先用 get_url() / get_title() 确认页面已换,再查元素。
给了凭据就必须锁域名。 会踩是因为 sensitive_data 单独配也能跑通,警告只是一行日志。怎么避:sensitive_data 和 allowed_domains 成对出现;用按域名分组的格式时,确认每个域名模式都被 allowed_domains 覆盖,否则凭据可能被用在你没打算给的站点上。
别以为 watchdog 目录里的文件都在跑。 会踩是因为「有文件」和「被挂载」是两件事。怎么避:某类行为不生效时先读 attach_all_watchdogs(),确认它是无条件挂载还是要靠 storage_state、record_har_path、captcha_solver 触发,也留意当前提交里 CrashWatchdog 那段是注释状态。
别拿一次跑出来的 index 当稳定选择器。 会踩是因为日志里编号看着很像 ID。怎么避:编号来自每步重建的 selector_map,提示词里 *[ 标记本身就是在说「这些是新冒出来的元素」;要复现流程就走 actor 层的 CSS 选择器,或者在动作前后加校验。
别顺手复用你日常那个 Chrome 配置文件。 会踩是因为这是最省事的登录方式,官方例子也这么演示。怎么避:给自动化单独准备 user_data_dir 或 storage_state,敏感账号一律隔离;跑之前想清楚这个任务允不允许出现不可逆操作,涉及别人的账号或数据时先取得授权。
别把它扔到明显不欢迎自动化的站点上。 会踩是因为技术上「点得动」不代表允许。怎么避:先读目标站点的使用条款与 robots 说明,优先找官方 API;系统提示词自己都要求遇到 403、bot 检测、限流时换路子而不是硬刚,你也别在外面加重试把它逼回去。
别一上手就开高并发。 会踩是因为单个任务跑得挺顺。怎么避:README 在生产那条 FAQ 里已经点了 Chrome 的内存开销和并发管理成本,先拿单实例把流程跑稳,再考虑并发编排。
收尾:接下来读哪几个文件
如果你打算真上手,按这个顺序读四个文件效率最高:先 browser_use/actor/README.md 建立对低层能力边界的认知,再 browser_use/browser/session.py 的 attach_all_watchdogs() 看清哪些行为是自动兜住的,然后 browser_use/tools/service.py 把模型能调的动作清单过一遍,最后 browser_use/agent/system_prompts/system_prompt.md 看它对模型下了哪些硬约束——很多你以为要自己写的规则,那里已经写了。
跑之前的自检:目标站点的条款允许自动化吗;allowed_domains 锁了吗;这次用的是隔离配置还是你自己的登录态;任务里有没有不可逆操作、需不需要人来确认;模型上下文里会不会带进不该带的凭据和页面正文。这五条过不去,代码写得再顺也别开跑。这个项目采用 MIT 许可证,仓库在 https://github.com/browser-use/browser-use ,读源码是核对本文每一处说法最快的方式。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 什么时候别用 browser-use 和 browser-use 的 Agent 循环拆解。