我自己花了几个晚上把 browser-use 的源码完整读了一遍,又跑了好几个真实任务验证理解,最大的感受是:这个项目把“AI 操控网页”这件事拆解得非常干净,它不是靠什么黑魔法,而是靠一条清晰的数据链路把两件成熟的事拼起来——左边是 LLM 的意图推理,右边是浏览器自动化,中间用一套稳定的“动作协议”通信。很多人看到 AI 能自己点按钮、填表单就觉得神奇,实际上源码里每个环节都是工程问题:网页状态怎么变成模型能看的文本,模型的输出怎么变回浏览器里的真实操作,上下文怎么管理才不会爆,动作失败了怎么重试。这篇文章我会沿着调用链,把 DOM 处理、LLM 消息构建、Controller 动作注册、Agent 主循环这几个核心模块逐一拆开,最后附上我实际跑任务时踩过的坑,以及几个值得动手改造的切入点。无论你是想理解 AI Agent 的原理,还是准备基于它做二次开发,这篇文章应该都能帮你省下不少时间。
1. 项目全景:Browser-Use 解决的核心问题与模块地图
1.1 为什么我们需要一个“会操作网页的 AI”
先聊一个有点反常识的点:传统 Web 自动化不缺工具,Selenium、Playwright、Puppeteer 都能精确地打开网页、点击按钮、填写表单,但它们的核心问题是“流程写死”。你要先告诉它去哪个页面,等哪个选择器出现,点击哪里,然后等什么结果。页面稍微改一下布局,CSS 选择器变了,整个脚本就废了。维护成本极高,这也是 RPA 项目经常做到后面变成“天天修脚本”的原因。
而 LLM 的能力在于:你给它一个目标,它能理解并规划步骤。但 LLM 本身没有“手”,它只能输出文本。以前的做法是把网页内容抓下来塞给模型,让它总结、回答问题,可它没法真的去执行“点一下这个按钮”这种操作。
browser-use 的思路恰好是把这两者对接起来:让 LLM 作为大脑,负责观察当前页面状态、决定下一步做什么;让 Playwright 作为手脚,负责执行浏览器里的真实操作;中间再加一个“翻译层”,把网页 DOM 翻译成 LLM 能读懂的文本,把 LLM 输出的动作翻译回浏览器 API。这个“翻译层”正是整个项目源码的核心。
1.2 源码仓库的整体骨架
从 GitHub 拉下来之后,你首先会注意到它的代码组织并不复杂。以我常读的某个版本为例,核心分为几个目录:
browser_use/agent/:Agent 主循环、状态管理、消息构建和提示词。这是整个项目的调度中枢。browser_use/controller/:动作注册表。所有 AI 可以执行的动作,比如点击、输入、滚动、切 Tab,都在这边定义和登记。browser_use/dom/:DOM 提取与序列化。负责从浏览器里拿到页面结构,过滤无效节点,生成带编号的文本表示。browser_use/browser/:基于 Playwright 的浏览器封装,负责浏览器实例、页面上下文、截图等底层操作。browser_use/llm/:大模型接口的封装,负责调用各家模型,并解析输出。
这个分层很值得学习:各模块之间职责边界清楚,agent不直接碰浏览器细节,controller不关心模型怎么推理,dom只做网页状态提取。哪怕你不想用 browser-use,单看这套分层结构,也能学到很多 Agent 系统设计的经验。
从使用者的视角,启动一个任务的代码非常短,就三五行。但背后发生的事情远比你想象得多。下面我从“看懂网页”和“操控网页”两条线,分别拆解源码里的核心机制。
2. 让 AI “看懂”网页:DOM 序列化与视觉通道
2.1 为什么不能直接把整个 HTML 丢给模型
最容易想到的方案是:把网页的 HTML 源码直接塞给 LLM。但实测下来根本行不通,原因有三个。
第一是 Token 成本。一个普通新闻页面的 HTML 动不动几十 KB,折算成 Token 可能上万甚至几万。如果还把 CSS、script 标签都算上,LLM 的上下文瞬间就满了。OpenAI 的窗口再大,也经不住一轮操作就消耗几万 Token。
第二是噪音太多。HTML 里有大量对决策无用的信息:meta 标签、hidden 输入框、样式类名、内联脚本、广告位占位符。模型需要从一片噪音里找可交互元素,推理准确率必然下降。
第三是可操作性太差。即使模型看到了<button class="btn-primary" onclick="submit()">登录</button>,它也没法直接“点”这个按钮——它需要知道这个元素在 Playwright 里对应哪个节点,用什么方式定位。单纯给 HTML 文本,定位信息是缺失的。
所以 browser-use 做了一件关键的事:它不是把原始 HTML 塞给模型,而是提前在源码里对 DOM 做一轮“清洗和结构化”,把网页变成一个精简的、带索引的可交互元素清单。
2.2 DOM 树提取与序列化:网页是怎么变成一行行文本的
这里要提到browser_use/dom/service.py里的核心方法,它做的事情可以理解为对 DOM 树做一次“人眼扫描”。它从根节点出发,遍历页面上所有可见元素,然后做过滤和打分,最终保留那些真正可能被用户交互的节点。
具体来说有几个关键步骤:
- 过滤不可见节点。
display:none、visibility:hidden、尺寸为 0 的元素,或者被其他元素遮挡的元素,都会被剔除。 - 提取交互性信息。给每个节点标记
tag(标签名)、role(可访问性角色)、text(可见文本)、attributes(如href、placeholder)等字段。 - 给元素编号。保留下来的可交互元素(按钮、链接、输入框、下拉框)会分配一个唯一索引,比如
[12]。这个索引是模型与浏览器之间的沟通暗号。 - 按可访问性树排列。不是简单按 DOM 顺序拍平,而是尽量按照视觉顺序和层级关系排列,让模型看到的文本近似于人眼看到的页面。
代码里这些节点会被表示成类似下面的形式:
[12] <button> 登录 [13] <input> 请输入账号 [14] <input> 请输入密码模型看到的是这样的文本。它不需要理解 HTML 嵌套,不需要看无数 div 和 span,只需要知道“现在页面上有这些元素,每个元素有编号”,然后决定要去操作哪个。
这个过程我用一个生活类比解释:原始 HTML 相当于房间里堆满的杂物,你要让 AI 在里面找开关,直接拍一张“堆满杂物的全景照”它很难看清;而 DOM 序列化相当于先做一次整理,把可交互的东西挑出来贴好标签,整整齐齐摆一排,AI 一眼就知道哪个是开关、哪个是插座。
要注意的是,DOM 序列化的结果也会随版本变化。早期版本生成的是扁平列表,后来的版本会保留一些层级结构,让模型能理解元素之间的从属关系(比如某个按钮属于哪个表单)。无论格式怎么变,核心思路是一致的:把网页变成 LLM 可以直接消费的文本状态。
2.3 视觉通道:多模态模型怎么“看”截图
光有 DOM 文本还不够。实际使用中你会遇到一些文本描述解决不了的情况:页面加载了一半,某个按钮是灰色禁用状态;弹窗遮住了关键区域;某个元素的位置很怪,文本里看不出布局问题。这时候,文本序列化会丢失很多视觉信息。
browser-use 的解法是引入截图。它会在每一步把当前浏览器的视口截图下来,和 DOM 序列化文本一起交给多模态模型(比如 GPT-4o、Claude 的视觉版本)。这样模型既能读结构化的文本描述,又能直接看页面长什么样。
截图策略也不是简单的“拍一张就完事”。源码里会区分完整页面截图和视口截图,默认通常使用视口截图,因为可交互操作基本都发生在用户可见的区域内。完整页面截图适合“判断页面是否加载完整”这类全局性观察,但体积更大,视觉细节更模糊,Token 成本也更高。
我自己实验下来,视觉通道对多模态模型的提升非常明显。有一类任务是判断某个弹窗是否出现、某个按钮是否处于可点击状态,单看 DOM 文本往往要靠猜,加上截图后模型一次就能判断对。所以如果你的任务里有很多动态交互、弹窗、浮层,建议优先选择带视觉能力的模型。
3. 让 AI “操控”网页:Controller 与 Agent 循环
3.1 Controller:一切动作皆注册
“看懂”只是第一步,真正让 AI 发挥作用的是“操控”。browser-use 在browser_use/controller/service.py里实现了一个动作注册表,你可以把它理解成一个“工具箱”——里面装满了 AI 可以调用的小工具,每个工具都有名字、参数定义和真实的 Python 执行函数。
源码里给动作打上@controller.action装饰器来注册。内置的动作包括点击元素、输入文本、滚动页面、打开链接、切回上一页、切换标签页、提取页面内容、等待元素出现等等。每个动作都定义了严格的参数模型,比如click_element需要一个index参数,表示要点哪个编号的元素;input_text需要index和text两个参数,表示在哪个输入框里输入什么内容。
这种“动作描述 + 参数模型 + 执行函数”的设计,本质上是在给 LLM 定义一套可用的 API。LLM 在推理时看到的就是:
{ "click_element": { "index": 12 } }它不需要知道底层是 Playwright 的page.click还是 Selenium 的element.click(),只需要按协议输出动作名和参数,剩下的事情由 Controller 去完成。
这种设计最大的好处是扩展性。你可以随时往里加一个自定义动作,比如读取本地文件、调用内部 API、把当前页面内容发送到某个服务。加完之后,模型在后续推理中就能感知到这个新工具的存在并调用它。我给一个跑单据录入的项目加过一个“下载当前表格为 Excel”的动作,模型在判断需要保存数据时真的会主动调用它,这是纯提示工程很难做到的稳定效果。
3.2 Agent 主循环:观察、思考、执行、反思
有了工具,还得有一个“大脑”来驱动。这就是browser_use/agent/service.py里的Agent类要做的事。
我把整个循环简化为四步:
- 观察。从浏览器上下文拿到当前的 DOM 序列化文本和截图,再结合历史对话和用户原始任务,组装成一份“状态报告”。
- 思考。把这个状态报告发给 LLM,让模型输出下一步动作。
- 执行。解析模型输出,在 Controller 里找到对应的动作函数并执行。
- 反思。根据执行结果(成功还是失败、页面有没有变化),判断任务是否完成,没完成就回到第 1 步。
这个循环会一直持续到模型输出done动作,或者达到最大步数限制。源码里用_update_state和_process_model_output这样的方法分别处理状态更新和模型输出解析,每一步都会记录历史,包括模型的想法、动作、执行结果,形成一条完整的决策轨迹。
在实际跑任务时,我发现这个“反思”步骤非常有意思。模型在执行完一个动作后,会看到执行结果的反馈(比如“点击成功,页面跳转到新页面”),然后决定继续做还是修正错误。有一次模型试图点击一个加载中的按钮,点击失败后,它看了新的页面状态,自己说了一句“按钮还没加载完,我先等待再试”,然后真的先执行了等待动作,再重新点击。这种纠错能力,是单纯把 HTML 塞给模型比不了的。
3.3 消息架构与多轮历史管理
这个循环里最容易被忽略、但最影响效果的是消息构建。browser-use 在agent/message_manager.py里把所有信息打包成发给 LLM 的消息序列,包括系统提示词、原始任务、历史操作记录、当前 DOM 状态、截图,以及动作调用结果。
消息架构的核心难题是上下文管理。每一步都要往消息里追加新的 DOM 状态和新的动作历史,如果不做裁剪,跑不了几步上下文就爆了。源码里有几个优化策略:
- 限制 DOM 序列化长度。如果页面元素太多,就截断或只保留最相关的部分。
- 裁剪历史消息。超过一定轮次的旧消息会被压缩成摘要,而不是全部保留原文。
- 分段落优先级。系统提示词和任务指令永远保留,最近的操作记录和当前状态优先级高,旧的观察结果可以压缩。
这一点特别实用。我一开始做的时候没注意上下文管理,只是简单地把所有历史都塞进去,结果第 5 步就开始报超长错误。后来照着 browser-use 的消息管理策略重写,任务成功率明显提升。如果你自己设计 Agent 系统,这个“哪些消息必须保留、哪些可以压缩”的策略值得多花时间调。
4. 实操解析:跑通一个真实任务,观察调用链
4.1 最小示例与数据流追踪
理论讲再多,不如代码直观。下面这个例子展示如何用 browser-use 让 AI 打开一个网页并执行搜索:
from browser_use import Agent from langchain_openai import ChatOpenAI async def main(): agent = Agent( task="打开 example.com,在搜索框输入 browser automation,然后点击搜索按钮,把第一条结果的标题告诉我。", llm=ChatOpenAI(model="gpt-4o"), ) await agent.run() if __name__ == "__main__": import asyncio asyncio.run(main())这段代码看起来简单,但背后的数据流是完整的。Agent启动后,会创建浏览器上下文,调用 DOM 服务提取当前页面的状态,然后进入主循环。每一步,message_manager会构建一条包含系统提示、任务、DOM 状态、历史记录的消息序列,交给 LLM。LLM 返回一个结构化动作,比如:
{ "current_state": { "page_summary": "这是一个示例网站,页面上有一个搜索框和一个搜索按钮。", "thought": "用户需要我输入关键词并点击搜索。我应该在搜索框里输入内容。" }, "action": [ { "input_text": { "index": 3, "text": "browser automation" } } ] }Controller 收到动向后,找到input_text对应的执行函数,传入index=3和文本内容,调用 Playwright 在真实浏览器里完成输入。完成后把执行结果返回给 Agent,Agent 更新历史,再进入下一轮循环。
如果你打开 debug 模式,会有详细日志打印出来,显示每一步的 DOM 状态、模型思考过程、执行的命令和结果。我第一次开 debug 跑任务,光是观察日志就学到了很多:模型什么时候会犹豫、什么时候会判断错误、什么时候会突然调整策略。
4.2 关键参数与调优经验
跑真实任务时,有几个参数强烈建议你亲自调一调:
max_steps:最大步数限制。任务简单就设小一点,比如 10;任务复杂,像多页面流转、表单填写加数据提取,建议 30 以上。我见过模型在一个页面里来回试探就烧掉十几步,步数给太紧会导致任务中途放弃。use_vision:是否启用视觉。用多模态模型时建议开启,对动态页面帮助很大。return_screenshots:是否在历史中保留截图。截图太占空间,如果模型不需要视觉,关掉能省很多 Token。
还有一个容易被忽略的点:给 LLM 的任务描述越明确,模型越容易收敛。不要只说“帮我收集信息”,要说清楚“打开这个页面,找到表格里所有价格大于 100 的行,把产品名和价格整理成列表”。模型对模糊任务会倾向于反复尝试,浪费步数和 Token。
4.3 从源码看 Agent 的状态机设计
读源码时,另一个让我印象深刻的点是 Agent 内部的状态管理。它不只维护一个简单的“当前步骤”变量,而是记录了一个完整的状态对象,包括历史操作、当前浏览器上下文、Token 消耗、当前页面摘要、最大步数、错误信息、脚本执行结果等。这个状态对象几乎贯穿了所有方法,step()更新它,_process_model_output()读取它,_update_state()修改它。
我顺便看了一眼agent/views.py里的数据模型,很多字段都用了类型标注和默认值。这种设计对后来维护非常友好,你很容易搞清楚“这个 Agent 在某一步到底拿到了什么信息、做了什么决定、产生了什么结果”。写 Agent 框架的同学可以参考这套状态管理思路,而不是把一堆变量散落在循环里。
5. 源码阅读与二次开发:值得动手改造的几个方向
5.1 自定义动作:给 AI 加上“浏览器之外”的技能
browser-use 最有吸引力的扩展点就是自定义动作。很多业务场景不只是在网页里点来点去,还需要跟外部系统联动。比如:
- 读取本地 Excel 里的数据,再填到网页表单里。
- 调用内部接口验证数据。
- 把网页上提取到的结果写入数据库或发送到消息通知。
- 下载文件后做一次本地处理。
注册一个自定义动作非常简单,大致长这样:
from browser_use import Controller import pandas as pd controller = Controller() @controller.action('读取本地Excel', param_model=FilePathModel) async def read_excel(file_path: str): df = pd.read_excel(file_path) return f"读取成功,共 {len(df)} 行,字段:{list(df.columns)}"注册完成后,把它传给 Agent 的controller参数,模型就能在需要时调用它。我加过一个“查询订单状态”的动作,让模型在操作网页前先查一下内部系统,确认订单号是否有效,再决定如何填写表单,整体成功率提高了很多。这个扩展点可以说是整个项目里投入产出比最高的。
5.2 上下文优化:避免跑两步就爆 Token
如果你读过message_manager.py,会发现里面做了很多“省 Token”的细节。我自己实测下来,给一个复杂页面跑任务,单步 DOM 序列化可能就有 2000-4000 Token,加上历史和截图,五步之后就非常可观了。所以源码里的几个策略非常值得借鉴:
- 控制 DOM 序列化长度。页面元素太多时,只保留与当前任务最相关的部分,或者把不相关区域压缩成一句话。
- 定期压缩旧历史。用一个摘要替换前面几轮完整消息,而不是一直追加。
- 控制截图频率和尺寸。不是每一步都需要截图,尤其是简单操作;截图分辨率也可以调低,足够模型理解布局就行。
我做了一个小实验,把历史消息改成“每 5 轮压缩一次旧消息”,跑一个 20 步的任务,Token 消耗降低了大约 35%,任务成功率没有明显下降。这个优化思路,放在任何多轮 Agent 场景里都是通用的。
5.3 稳定性提升:错误重试与超时处理
源码里对浏览器操作时的异常场景做了不少处理,比如元素未找到、页面加载超时、点击被遮挡等。我翻了一遍常见的内置动作,每个动作几乎都有try/except,执行失败会返回一个ActionResult,里面包含错误信息和是否可重试的标记。Agent 拿到失败的反馈后,会把它作为上下文继续推理,而不是直接崩溃。
实际使用时,我发现有些页面非常“调皮”:按钮点击没有反应、弹窗一闪而过、网络延迟导致页面空白。这时候光靠模型自己试错是不够的,最好是结合 Playwright 的自动等待机制,或者在自定义动作里加上重试逻辑。比如点击动作失败时,可以先等待 1 秒再重试一次,再失败就返回更详细的错误信息。这种“让动作自己更健壮”的思路,比让 LLM 反复猜强多了。
6. 常见问题排查与避坑指南
为了让你少走弯路,我把实际使用中遇到的典型问题整理成一张表,附上排查思路。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 模型一直重复点击同一个元素 | DOM 序列化里元素编号不稳定,每次生成索引会变 | 检查页面是否在动态刷新;调整 DOM 序列化策略,或者给元素加上稳定属性 |
| 模型输出 JSON 解析失败 | 某些模型在复杂上下文下输出格式不稳定 | 升级模型版本;在提示词里强调 JSON 格式;给动作定义更严格的参数模型 |
| 跑到第 N 步后上下文超长 | 历史消息累计过多,没有裁剪 | 开启历史摘要压缩,限制 DOM 文本长度,关闭不必要的视觉截图 |
| 点击元素时报“no element found” | 页面加载慢,或者元素在 iframe 里 | 增加等待策略;确认 DOM 序列化是否覆盖 iframe 内部节点 |
| 页面弹窗遮住按钮,点击无效 | 视觉上能看到,但可访问性树认为不可点击 | 开启视觉模式让模型看到截图中弹窗;增加关闭弹窗的动作 |
| 任务明明完成了,模型还在继续操作 | 缺少明确的“完成”判断信号 | 在任务描述里写清楚完成条件;调低max_steps,或者在提取到目标结果后强制停止 |
这里我特别想展开说一下第一个问题。元素编号不稳定是刚上手的人最容易踩的坑。browser-use 在每一步都会重新提取 DOM 并编号,如果页面有异步加载,同一个按钮第一次是[12],第二次可能就变成了[15]。模型如果记住的是上一次的编号,再拿旧编号来点击就会失败。解决思路有两个:一是让模型每步都基于最新 DOM 状态做决策,不要靠记忆;二是给元素设置稳定的选择器或自定义属性,让 DOM 序列化能识别出来。
另外一个经验是:如果跑的任务需要处理大量数据,尽量把页面操作拆成小任务。比如“翻页抓取”这种操作,不要让模型一口气做几十步,而是让它每抓完一页就总结一次结果,再继续下一页。这样即使中间出错,你也能从历史记录里看到卡在哪一步,恢复成本低很多。
7. 我对这套架构的个人体会
读 browser-use 源码之前,我一直觉得 AI Agent 操作网页是个很“玄学”的事:模型要理解布局、理解语义、理解操作逻辑。读完之后才发现,真正让这件事跑得稳的,不完全是模型的智商,而是工程上的各种约束设计——DOM 提取怎么过滤、消息怎么裁剪、动作怎么定义、失败怎么反馈。这些细节决定了一个 Agent 是“偶尔能跑通”还是“稳定可复用”。
如果你也想动手改造或写自己的 Agent 系统,我的建议是从小处开始。别一上来就想做一个通用的网页助手,先挑一个封闭场景,比如“定时去内部系统抓取报表并发送通知”,把 DOM 处理、动作注册、消息循环都跑通,再逐步加功能。等你把 browser-use 这类源码吃透了,你会发现“AI 操控网页”这个能力,本质上是把一堆成熟的工程组件用一套清晰的协议串起来,并没有想象中那么神秘。最后再说一个小技巧:阅读时打开 debug 模式,跟着日志走一遍完整任务,你对这套系统的理解会远超翻十遍源码。