☰
Browser-Use源码拆解:大模型如何看懂并操控网页
2026/10/22 20:04:27 网站建设 项目流程

我最初接触 Browser-Use 这个项目时,第一反应是:这不就是把大模型接上浏览器自动化库的壳子吗?真等自己跑起一个任务、再回头翻源码,才发现“让 AI 看懂网页”这件事,远比“让 AI 点击网页”难得多。模型看不到屏幕、看不到 DOM、甚至不知道页面有没有加载完,你交给它的只是一个压缩后的文本快照和一段动作描述,它得像人一样在这个快照里找线索、做判断、执行操作,再根据反馈修正自己。

这篇文章我会从一个实际从业者的视角,把 Browser-Use 这类“大模型 + 浏览器”方案的源码拆开看:它到底怎么把网页变成模型能读懂的“视觉”,又怎么把模型的一句“点击第三个按钮”变成浏览器里真实发生的一次操作。内容不追求逐行粘贴源码,而是把最核心的观察层、决策层、执行层这三条链路讲透,适合想自己动手改一版网页 Agent 的人参考。

1. 这个项目到底在做什么:给 LLM 装上眼睛和手

1.1 核心痛点:模型离真实网页只差一层“代理”

先抛一个反直觉的结论:让大模型操控网页,难点根本不在“操控”,而在“观察”。大模型再强,它也看不到像素。你只有两条路:把页面截图喂给视觉模型,或者把 DOM 转成文本喂给语言模型。Browser-Use 这类项目选的是后一种,并且把这件事做成了稳态的工程链路。

但“把 DOM 转成文本”听起来容易,做起来到处都是坑。一份真实的电商页面 HTML,少则几百 KB,多则几 MB,其中 script、style、meta、隐藏元素、无意义的嵌套容器占了九成。如果你把整段 HTML 直接塞进上下文,先不说 token 成本爆炸,模型会在大量噪声里迷失,分不清哪些按钮真正可见、哪些表单字段真正可交互。

所以这个项目最核心的模块其实是“DOM 提取与压缩”,它决定了模型能看到什么。模型看到的信息质量,直接决定后续每一步决策对不对。我在读源码时最深的体会就是:Agent 效果好坏的七成,在观察层就定死了,模型能力只是剩下的三成。

1.2 和传统自动化脚本的本质区别

很多人会问:这跟以前用 Selenium 或 RPA 写脚本有什么区别?区别非常大。传统脚本是你把每一步写死:“找到搜索框,输入关键词,点击按钮,等待结果出现”。每一步的选择器、等待条件、异常处理都要人工设计。Browser-Use 的做法是:你只给一句任务描述,比如“打开某商城,找到最贵的手机,把参数整理成表格”,剩下的每一步动作都是模型根据当前页面快照现想的。

这意味着它没有固定的执行路径。页面结构变了,模型会随机应变;元素没找到,模型会换一个思路;页面加载慢了,模型会自己选择追加等待。这种适应能力是脚本完全不具备的。

但代价也很明显:不可预测性变高了。模型可能给出完全离谱的动作,所以必须有一整套约束机制,包括动作注册表、状态反馈、重试限制、最大步数控制。读源码时你会发现,这套约束体系的代码量远大于模型调用本身,这恰恰是一个生产级网页 Agent 和“一个 demo”之间的分水岭。

2. 宏观架构与数据流:一次网页任务是怎么转圈的

2.1 四个模块的职责边界

把 Browser-Use 这类项目的源码在脑内做一次模块划分,你会发现所有代码几乎都落在四个筐里。

模块职责关键输出
DOM 处理层抓取当前页面状态,过滤压缩紧凑的文本快照 + 元素索引表
Agent 决策层组装消息,调模型,解析动作结构化的 Action 列表
动作执行器把 Action 落到浏览器事实操作执行结果、错误信息、页面变化摘要
会话管理层维护历史、控制步数、处理生命周期多轮上下文与最终答案

这四层是分层单向依赖的,决策层不直接碰浏览器,执行器也不直接碰模型。这样设计的好处是每一层都能单独测试和替换:你想换一个更强的模型,不用动浏览器层;你想把 Playwright 换成别的自动化库,也不用动 Agent 逻辑。我在改造过一版之后可以负责任地说,这种边界清晰的分层,是后续所有调试和扩展能顺利进行的地基。

2.2 从任务指令到第一个动作:完整调用链

一次任务的完整数据流大概是这样的:

  1. 用户输入任务描述,例如“在搜索框输入‘机械键盘’,记录前三条结果的价格”。
  2. 系统启动浏览器会话,打开初始页面(可能是用户指定的 URL,也可能是空白页)。
  3. 循环开始:DOM 处理层抓取当前页面状态,生成快照和元素索引。
  4. Agent 把任务、历史、当前快照、动作说明拼成消息,发给模型。
  5. 模型返回一个动作列表,可能是“先点击搜索框,再输入文本”这种组合动作。
  6. 执行器逐个执行动作,每个动作成功后,把页面变化反馈给 Agent。
  7. Agent 把新反馈追加到历史,进入下一轮循环,直到模型判定任务完成或达到最大步数。

这个循环本质上是一个有状态的多轮对话。每一轮里,模型看到的不是整个浏览器,而是“我上次做了什么、现在页面变成了什么样、接下来有哪些元素可用”。如果我是一个刚开始接触这项目的人,一定会先把这条链路在纸上画出来,再去看每个模块的实现,不然很容易被各种回调函数绕晕。

3. “看懂”网页:DOM 提取和上下文压缩的实现

3.1 为什么不能把整个 HTML 丢给模型

这里先说一组我在自己测试环境里算过的账:一个中型新闻首页,HTML 原始文本大概 600 KB,转成 token 差不多 15 万。如果你是按 token 付费调用大模型 API,这个量级一本上千页的书都装得下,对于一轮页面分析来说是绝对不可接受的。而且,就算你不在乎钱,模型在 15 万 token 的上下文里寻找一个藏在深处的按钮,注意力也会被大量与操作无关的文本稀释。

更麻烦的是,HTML 里的语义和“可操作性”是两回事。一个<div>可能通过事件监听变成了按钮,一个disabled的 input 在 DOM 里依然存在,前端框架还可能渲染大量隐藏副本。模型如果直接读原始 HTML,很容易被这些信息误导,产生“我看到一个输入框,但实际上它不可编辑”的错误判断。

所以 DOM 处理层做的第一件事,就是把“文档结构”转成“可操作界面”。它只保留那些真正和用户操作相关的元素,再为这些元素补充语义描述,让模型在一个干净的环境里做决策。

3.2 元素索引机制:给每个可交互节点发“座位号”

这个设计我觉得是整个项目里最聪明的一点。压缩后的快照里,每个可用于操作的元素都会配一个数字索引,模型在动作里引用这个索引而不是直接写选择器。

为什么要这样?一个真实的建议是:让 LLM 直接生成 CSS 选择器或 XPath 是一件极不靠谱的事。哪怕模型看到了完整的 HTML,它生成的div > div:nth-child(3)也可能错得离谱,而且一旦页面发生渲染变化,选择器立刻失效。但是,如果我们在每一轮抓取时对可见可交互元素按顺序编号,模型只需要说“点击 5 号元素”“在 12 号元素里输入文本”,准确率会高很多。

而且索引号是“这一轮快照里的局部坐标”,模型不需要理解整个页面的层级结构,只需要把快照里一串文本和一个编号对应起来。我在调试时发现,让模型解释它为什么选这个元素,它的理由往往会提到快照里的按钮文字或输入框 placeholder,这就是我们想要的行为——它在读界面,而不是在猜源码。

3.3 截断与排序:把页面压到模型读得完的尺寸

快照不能无限大,所以源码里必然会有几道过滤规则,通常按优先级排列:

  • 丢弃 script、style、noscript、hidden 属性等不可见内容;
  • 优先保留 input、button、a、textarea、select、iframe 等交互型标签;
  • 对普通文本节点做截断,只保留前几个字或前一句话;
  • 设置总字符数上限,超出部分按元素在文档中的顺序截断,并在末尾提示模型“还有部分元素未展示”。

除了过滤,还需要做“可交互性判断”。一个元素就算在 DOM 里,也可能被 CSS 隐藏、遮挡、或者还没渲染。通常会用坐标、offsetParent、display、visibility这类信息来判断它是否真正可见。懒加载页面尤其麻烦,很多图片和按钮是滚动到窗口附近才出现的,所以这层判断不是一次性的,而是每一轮循环里都要重新计算。

实际效果是这样的:一个 600 KB 的首页,经过处理后可能只剩 3000-5000 字符,里面有几百个带索引的交互元素,模型既能看懂全貌,又不至于被细节淹没。这个数字区间是我反复调整后觉得性价比比较高的。

4. “操控”网页:Action 注册、参数生成与浏览器执行

4.1 动作不是手写的,而是注册出来的

观察层解决了“模型能看到什么”,接下来要解决“模型能做什么”。Browser-Use 这类方案不会让模型自由写代码来控制浏览器,而是提供一组预定义的动作,让模型从中选择并填参。我更关注的是这套动作体系是怎么组织起来的。

源码里通常会有一个“动作注册表”的概念。每个动作由四部分组成:动作名称、动作描述、参数结构、执行函数。定义好之后注册进去,框架会自动把所有动作的描述和参数结构拼到系统提示词或工具描述里。模型看到的就像是“你有一个工具叫 click_element,参数包括元素编号 index,还有一个工具叫 input_text,参数包括 index 和 text”,然后它按需选择。

这种注册表模式带来的扩展性非常强。我后来自己加了一个“将当前页面的表格数据导出为 CSV”的动作,总共只写了不到二十行代码,不需要动 Agent 主循环。因为框架已经把“动作能力清单”变成动态生成的提示词,新增动作对模型来说是天然可见的。

4.2 JSON 动作如何映射到浏览器底层操作

模型返回的动作如果不落地,再漂亮也只是纸面功夫。执行器的核心工作就是把“语义动作”翻译成浏览器自动化操作。

模型动作示例浏览器底层执行
click_element {index: 5}根据索引反查元素节点,执行点击
input_text {index: 5, text: "机械键盘"}定位元素,聚焦,清空后输入文本
scroll {direction: "down"}对当前页面执行滚动,并等待渲染
wait {ms: 800}固定延时,等待异步请求返回
go_to_url {url: "..."}导航到新链接,等待加载完成
extract_content {selector 描述}从当前页面抽取需要的文本或数据

其中“索引反查”是一个很关键的动作。DOM 快照生成时,每个索引背后都保存了一个指向真实 DOM 节点的引用,执行器拿到 index 后先确认节点还存在、还可见,再调用底层自动化库的方法。如果元素在上一轮和这一轮之间被前端框架整体重渲染了,旧引用可能失效,执行器必须能发现并上报错误。

还有一点很多人会忽略:动作是允许组合的。模型可以一次返回多个动作,比如“先点击输入框,再输入文字,最后按回车”,这样显著减少多轮延迟。但也正因为组合动作的存在,执行器必须支持批量执行,并且在某个动作失败时,及时终止后续动作并把这个错误反馈给模型,否则很可能造成一系列连锁的错误操作。

5. 决策循环与失败恢复:Agent 怎么从错误里自愈

5.1 观察-推理-执行-再观察的闭环设计

这个闭环是整个 Agent 的脉搏。每一轮循环的输入由三部分构成:系统提示词、任务目标、历史轨迹(包含动作结果和页面摘要)。模型根据这些信息输出下一步动作。等动作执行完,新的页面状态又会被抓取,作为下一轮历史的一部分。

这里要多说一句:为什么每轮都要重新抓取 DOM,而不是只让模型“继续”?因为浏览器是一个外部系统,它的状态会自己变。你点击一个按钮之后,可能有异步请求、动画、重定向,这些都不是模型能凭空推理出来的,只能重新观察。所以“页面摘要”也很重要,执行器在点击或输入之后,会尽量提取页面上新出现的文本、标题或弹窗,作为“这次操作造成了什么变化”的反馈信号。模型有了这个信号,才能判断自己是不是做对了。

5.2 错误反馈和自我修正的机制

模型出错不可怕,可怕的是出了错还不知道。浏览器的错误反馈机制是我觉得这个框架里最值得抄作业的部分。

第一种错误是动作执行失败,比如点击一个已经消失的元素。执行器会把异常信息捕捉下来,作为文本反馈给模型:“元素 5 在当前页面中不存在,可能是页面已跳转或元素被移除。请根据最新快照重新选择。”模型看到这个反馈后,往往会查看最新的元素列表,换一个索引或换一个策略。

第二种错误是动作执行成功但页面没有变化。这种情况更隐蔽,可能点了“展开”按钮但内容还在异步加载。框架的常见做法是给模型一个“上一轮操作没有观察到明显变化”的提示,让模型决定是等待、滚动还是换一种方式。

第三种是模型的输出本身不合法,比如返回了不存在的动作名称,或者参数缺失。解析层会做容错处理,把可以修复的部分修复掉,修复不了的再请求模型补充一次。最终还有一个最大步数限制,防止模型在一个死循环里无限打转。我在使用中见过模型连续五次点击同一个无效按钮,如果没有步数限制,它可能真的会“倔强”到天荒地老。

6. 源码关键路径拆解:核心模块里的设计取舍

6.1 DOM 服务:从 HTML 到紧凑视图的转换管线

把观察层单独抽成一个服务,是典型的“高内聚”设计。这个服务内部通常有一条明确的处理管线:原始 HTML 通过解析器变成树结构,再过滤出候选元素,再逐个做可见性判断和文本提取,最后按规则排序、截断、编号。

伪代码大概长这样:

def get_dom_state(browser): html = browser.get_page_html() root = parse_html(html) candidates = collect_interactive_elements(root) visible = [el for el in candidates if is_visible(el)] compact_lines = [] index_map = {} for index, el in enumerate(visible): leaf_text = extract_repr(el) compact_lines.append(f"[{index}] <{el.tag}> {leaf_text}") index_map[index] = el if total_length(compact_lines) > MAX_CHARS: compact_lines = truncate(compact_lines, MAX_CHARS) return { "content": "\n".join(compact_lines), "index_map": index_map }

这个服务把“DOM 节点”和“给模型看的文本”完全解耦。模型看到的是文本,执行器需要的是真实节点引用,中间的映射关系就是上面代码里的index_map。这个设计让观察层和执行层可以分开演进,我单独优化可见性判断逻辑的时候,完全不影响其他模块。

6.2 动作执行器:注册表模式带来的扩展性

执行器的核心不是一大堆 if-else 分派,而是注册表。每个动作在执行器里注册自己的处理函数,Agent 决策层解析出动作名称后,直接查表调用。

class ActionRegistry: def __init__(self): self._actions = {} def register(self, name, description, params_schema, handler): self._actions[name] = { "description": description, "params_schema": params_schema, "handler": handler } def execute(self, action): name = action["name"] params = action.get("params", {}) if name not in self._actions: raise UnknownActionError(name) return self._actions[name]["handler"](**params)

注册表模式最大的价值不是代码优雅,而是“能力即提示词”。框架遍历注册表,就能自动生成模型可见的动作清单。新增动作时,只要写好处理函数和参数结构,模型下一轮马上就能感知到新能力,不需要人工改提示词模板。这种设计思路,我后来在自己的几个 Agent 项目里都照搬了。

6.3 Agent 主循环:把状态、历史和模型调用串起来

Agent 主循环是整条链路的“总装车间”。它负责维护一份消息列表,每轮把当前 DOM 快照作为用户消息的一部分追加进去,调用模型拿到动作输出,执行,再把执行结果追加进去。

def run(task, browser): messages = [system_message(task)] for step in range(MAX_STEPS): state = get_dom_state(browser) messages.append(user_message(state["content"])) response = llm_client.generate(messages) actions = parse_action_response(response) for action in actions: result = action_registry.execute(action) messages.append(observation_message(action, result)) if is_task_done(messages): break return extract_final_answer(messages)

这一份伪代码基本可以对应到源码主流程,你如果去翻真实实现,看到的只会比这里多一些细节处理,比如消息历史的截断、模型输出清理、并发控制等。但主干就是这样一个循环。理解它之后,不管是给项目加记忆能力、加多步规划,还是加断言验证,你都知道往哪个环节里插。

7. 真实使用中的踩坑与调优:跑通之后才会遇到的问题

7.1 Token 消耗比想象中大,怎么压

我把一个真实任务完整跑下来,发现 token 消耗的分布极其不均匀:模型输出的动作文本只占很小一部分,真正吃 token 的是每轮重复塞进去的 DOM 快照和历史。一个复杂页面十步跑下来,消耗的 token 可能够写一篇长文。

想压 token,最有效的开关就是 DOM 快照的字符上限。从默认值往下调,你会发现模型开始“瞎”,找不到页面上靠后的元素。这是因为截断策略把后面元素的索引全部删了。我的妥协方案是:主流任务把快照上限控制在 4000-6000 字符,同时把wait动作的默认时长加大,让页面关键内容尽量在快照生成前渲染出来。另外,历史消息里已经过时的 DOM 快照也可以做压缩,只保留“动作+结果摘要”,不再保留完整页面状态。

7.2 动态渲染、iframe、懒加载这类“看不见”的问题

真实网页远比测试页复杂,最常踩的坑有三个。

第一个是异步渲染。点击按钮后页面可能在 500 毫秒后才加载出结果,模型如果立刻抓快照,看到的是“页面没变化”,就会误判。我的处理是:每个动作执行后强制等待一小段固定时间,再进行下一轮快照。这个时间不能太短,否则抓不到结果;也不能太长,否则任务慢到让人失去耐心。

第二个是 iframe。很多第三方登录框、地图、富文本编辑器都藏在 iframe 里,默认 DOM 快照看不到内部内容。源码层面需要单独处理 iframe 场景,或者在执行器里提供“进入某个 iframe 上下文”的能力。我遇到过一个任务,模型反复说“页面上没有登录按钮”,最后排查发现按钮就在一个跨域 iframe 里,快照根本没抓到。

第三个是懒加载。页面滚动到底部才会加载更多商品,如果模型只拿初始快照,会以为商品只有三件。解决办法是主动给模型提供滚动能力,并让它在观察摘要里看到滚动后新出现的元素。

7.3 建议的起步配置和稳定参数

如果你是第一次在本地跑这类项目,我的建议是从一个简单任务开始:打开一个静态页面,点击一个按钮,读取一段文本。先别急着挑战复杂动态应用,等把观察层和执行层的反馈调顺了,再逐步加难度。

一些我实测下来比较稳的参数组合:

参数推荐值说明
模型温度0 到 0.1越低越稳定,网页操作不需要创意
最大步数10 到 15防止模型反复横跳
DOM 快照上限4000-6000 字符太低看不到元素,太高费 token
动作间等待500-1000 毫秒给前端渲染反应时间
失败重试次数同一动作最多重试 2 次超过就换策略,避免硬磕

最后再分享一个我自己的体会:网页 Agent 的调试和传统小程序调试完全不同,它不是“找到一个 bug 修掉”的单次过程,而是在观察、决策、执行三层之间不断做平衡。你调深了 DOM 提取,模型的准确率会提升,但 token 成本会上涨;你放宽了等待时间,稳定性会变好,但任务耗时变长。读源码最有价值的收获,其实就是看清这些参数和工程选择背后各自的代价,然后在你自己的场景里选一个能接受的平衡点。这套分析路径,比记住某一个参数值要管用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询