ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Browser-Use源码拆解:DOM提取与状态压缩如何驱动AI操作浏览器

Browser-Use源码拆解:DOM提取与状态压缩如何驱动AI操作浏览器 先抛出我的结论Browser-Use 这个项目的核心套路不是让 AI “看见”网页而是把网页翻译成 AI 最擅长的纯文本格式再给 AI 一个遥控器。整个过程拆开看无非是“DOM 提取 → 状态压缩 → 动作生成 → 结果执行”的一条链路。但这条链路里每一步都有讲究踩过的坑也远比想象中多。这篇文章我直接对照源码讲清楚它的设计逻辑顺带把我实际使用中遇到的几个典型问题一并交代了。1. 从“LLM 不会用浏览器”这个痛点说起Browser-Use 到底解决什么问题1.1 LLM 和浏览器之间的“语言鸿沟”先想一个很朴素的问题让一个没有视觉能力的大模型去操作网页第一步卡在哪卡在输入格式上。LLM 本质上是文本处理引擎给它一张网页截图它只能靠 OCR 之类的额外模型去“看”而浏览器自身的可访问性结构和 DOM 树信息其实是更高质量的结构化数据。问题在于DOM 树这种层级复杂、动辄几万节点、充满无意义 div 和 span 的东西直接塞给 LLM不但会撑爆上下文窗口还会让模型迷失在海量的无关标签里。这里就出现了一个矛盾浏览器能产出的数据是最完整的但 LLM 能消化的数据是最精简的。Browser-Use 要解决的就是怎么把“完整但冗长”的网页信息压缩成“精简但足够决策”的文本状态再通过一套动作协议让模型把决策变成真实浏览器里的操作。1.2 两条技术路线的对比视觉方案 vs DOM 方案做 AI 操控网页业内基本分两派。一派是视觉派。思路是截图丢给多模态大模型让模型输出坐标或者“在哪里点”的描述。典型代表是 OpenAI 的 Operator以及早期的 UI-TARS 这类模型。优势是直观因为模型真的“看”到了渲染后的页面对 CSS 遮挡、视觉层级、动态弹出层理解得很自然。但代价非常大截图编码成 token 的成本高延迟高而且坐标点击对页面布局变化极其敏感——同一个按钮换个分辨率就点偏了。另一派就是 DOM 派。核心思路是捕捉页面里可交互的元素提取它们的标签、文本、属性整理成一份带编号的清单发给 LLM。模型不需要理解像素只需要理解“有一个 id5 的按钮文本是‘立即购买’点击它会触发结账流程”就够了。Browser-Use 走的就是这条路线。两派比较下来DOM 方案在 token 效率、稳定性、可解释性上都有明显优势代价是它对提取逻辑的完备性要求很高——要是提取时漏掉了一个 iframe 里的元素AI 可不会像人一样“换个角度再看一眼”。这也是源码里 DOM 提取部分实现得特别重的原因。1.3 这个项目选型为什么这么设计Browser-Use 选择 DOM 方案我觉得还有一个很现实的动机可复现性。你让模型基于文本状态做决策每一步的输入输出都是可记录、可回放的如果走视觉方案同一个页面在不同机器上的渲染结果都可能不一样排查问题会变成噩梦。另外这个项目的定位是“给开发者的库”不是“给普通用户的浏览器插件”。它需要支持二次开发、自定义动作、离线部署这些需求决定了它必须把核心逻辑做成清晰的模块而不是端到端的黑盒。你去看源码的目录结构能把“DOM 处理”“Agent 循环”“动作执行”拆得清清楚楚这种组织方式本身就是它选型思路的体现。读这份源码建议先把握三条主线的分界状态从哪来、决策怎么做、行动怎么落。后面几个章节我就按这个逻辑拆开讲。2. 让 AI“看懂”网页DOM 提取与状态压缩的核心机制2.1 读源码前先理解“观察空间”在强化学习和 Agent 相关的代码里有个概念叫观察空间observation space意思是 Agent 每一步能够获取到的环境信息。Browser-Use 的观察空间不是截图而是一段经过精细加工的文本。你去翻 DOM 处理相关模块核心工作围绕一个关键词可交互元素。凡是 button、input、textarea、select、a 标签、带 role 属性的元素都会被重点捕获而那些只做布局用的 div 包裹层、空的 span、纯装饰性的 SVG基本会被过滤掉。这个过程很像做信息压榨——把一份 2MB 的 HTML 压成 2KB 的“操作清单”。2.2 DOM 提取的底层实现思路实际源码里 DOM 提取不是只在页面加载时做一次而是在 Agent 每一步决策之前都会重新提取一份最新状态。原因很直接页面的状态是动态变化的你点了 tab 之后页面结构可能就变了模型必须感知到这种变化才能继续决策。提取的过程一般走两步。第一步在浏览器上下文中执行一段 JavaScript 脚本直接遍历 DOM 树筛选出所有可见的、可交互的元素。这里“可见”的判断有很多细节元素的 display 是不是 none、visibility 是不是 hidden、有没有被别的元素遮挡、尺寸是不是为 0、有没有离开视口。这些判断逻辑本身就是一段值得单独读的代码想当然地认为“存在即可见”会在实际使用中吃大亏。第二步对筛选出来的元素做编号和属性抽取。每个元素会得到一个全局唯一的索引再附带上标签名、可见文本、href、role、aria-label、输入类型这类关键属性。最终生成的文本状态就长这样[1] BUTTON: 提交订单 [2] INPUT[typetext]: 收件人姓名 [3] INPUT[typetext]: 收货地址 [4] SELECT: 省份选择 [17] BUTTON: 添加到购物车模型看到的是这样一份清单而不是网页的“图片”。这就是它理解网页的起点。2.3 状态压缩为什么不能把整个 HTML 塞给模型有人可能问为什么不直接把innerHTML丢给模型省事啊。你可以实际试一次。随便打开一个资讯网站按 F12 复制 body 的 innerHTML粘贴到一个文本文件里看看大小。很多页面一个 body 的 HTML 就有 500KB 以上里面包含的字符数超过 15 万。以当前主流的 token 换算方式这大概是七八万 token。且不说成本很多模型的上下文窗口也未必撑得住。更重要的是直接给 HTML 会让模型在无关信息里迷失。一个网页的 DOM 树可能有八九层嵌套真正的操作目标可能在一个很深的层级里。如果模型每一步都要从一片标签海洋里找出该点的按钮它的决策质量会明显下降还容易出现幻觉——把某个隐藏元素的文本当成可见内容。Browser-Use 在压缩上做得很绝的地方不只是过滤元素它还会对文本内容做截断。比如一个文章卡片里的超长简介只保留前几十个字符一个按钮的一长串图标字体文本直接跳过。目的就是让每一条记录都短小精悍留出 token 空间给对话历史和系统指令。2.4 文本表示里的玄机状态压缩完成后还有一道关键工序把元素清单组装成符合模型偏好的文本块。这一步的代码看起来简单但设计上有讲究。清单不是干巴巴地列出来就完事它一般会带一个简短的前缀说明告诉模型“这些是页面上可交互的元素带编号的元素可以通过点击、输入等动作来操作”。在系统提示词层面项目还会加入规则说明如果目标元素暂时没有出现在列表里应该先滚动或等待而不是凭空猜测编号如果列表里的文本有歧义应该选择最接近语义的一项。这一步的实质是在原始的“元素编号-属性”数据和 LLM 的决策层之间搭建一个桥梁。桥搭得好模型每一步都理解自己看到了什么、能做什么桥搭得烂模型就会“一本正经地胡说八道”选了不存在的编号。读源码时你会发现状态文本这块的工程细节特别多。比如如何处理 iframe 内部的元素主文档里找到 iframe 的坐标再往 iframe 的 document 里继续遍历比如如何处理 Shadow DOM 里的可交互节点需要穿透 shadowRoot 边界去递归查找。这些 edge case 才是这个项目真正值钱的部分。3. 从“看懂”到“动手”工具调用与 CDP 操控链路3.1 给 LLM 设计一套“浏览器遥控器”模型理解了页面状态之后关键一步是让它“动手”。这一步不是让模型直接写 JavaScript而是给它一套定义好的工具集让它在工具里选一个来调用。源码里这套工具集的抽象思路非常像我们在智能音箱上定义语音指令——只是把“播放音乐”换成了“点击第 5 号元素”。工具集具体包括这些常用动作动作作用关键参数点击元素模拟鼠标点击元素索引输入文本在输入框填内容元素索引、文本滚动页面按方向或量滚动方向、幅度返回上一页浏览器后退无选择下拉项操作 select 选项元素索引、选项值切换标签页在多个标签间切换标签页 ID等待延迟一段时间再做判断秒数提取内容记录页面上的指定文本供后续使用元素索引或描述每一个动作的定义里都包含了名称、描述、参数结构。模型就是根据这些结构化的 JSON schema 来决定“我现在要调用哪个动作传什么参数”。这套思路对做过 LLM 应用开发的人来说应该很眼熟本质就是 OpenAI function calling 的浏览器版。3.2 CDP 命令如何被执行工具集定义好了真正执行时要和浏览器通信。Browser-Use 的底层执行器主要依赖两个通道一个是 Chrome DevTools Protocol也就是 CDP另一个是更高层的自动化库比如 Playwright对 CDP 的封装。CDP 的本质是一条 WebSocket 通道浏览器端通过这条通道暴露出上千个可调用方法。你在 DevTools 面板里看到的“选中元素”“模拟点击”“查看网络请求”底层都是 CDP 的命令。Browser-Use 在执行“点击第 5 号元素”的时候实际发生的流程是Agent 层解析出动作类型是 click目标索引是 5执行器把索引 5 映射回之前提取的 DOM 节点里的引用通常是backendNodeId或者页面内 JS 变量通过 CDP 的DOM.querySelector这类方法拿准元素位置再通过Input.dispatchMouseEvent派发鼠标事件整个过程录制成日志包括点击坐标、目标元素、是否成功这里的日志非常关键。调试 AI Agent 的时候看不到模型为什么选这个动作、点击之后页面发生了什么你会特别抓狂。源码里每个动作的执行前后都会有详细的状态捕获这也是它能做“边想边做”的基础。3.3 动作参数校验与容错模型毕竟是生成式模型不是恒定的程序它传参时偶尔会胡来。典型的情况有点击一个不存在的编号、给日期输入框填了中文文本、在模态框弹出之前就去点模态框里的按钮。源码里对参数处理有专门的校验环节。比如元素编号越界了不会直接把异常抛出来让整个 Agent 崩溃而是会把错误信息作为反馈返回给模型“你选择了编号 42但当前页面只有 1-18 号元素请重新选择有效的编号。”这种把错误当作新的观察状态继续丢给模型的方式是 Agent 设计里一个非常实用的容错思路。另一个值得注意的细节是动作执行失败后的文本反馈。比如点击后没有产生任何视觉变化源码会把“点击完成但页面没有明显跳转”这类的信息记录进历史让模型自行判断是继续点还是换个思路。这种设计在架构上叫“让它重试但不强制重试”模型保留最终决定权。这套链路用一句话总结工具定义决定了模型“能做什么”CDP 执行决定了“怎么做到”而校验与反馈决定了“做错了怎么补救”。三者缺一不可。4. Agent 主循环源码拆解思考、决策、执行、纠错4.1 一个主循环的骨架把状态理解和动作执行串起来的是 Agent 主循环。这个循环的核心逻辑可以用下面这个简化代码来表达def run_agent(goal: str, browser_session): history [] history.append(系统提示词 当前任务 goal) for step in range(max_steps): state extract_interactive_elements(browser_session) history.append(state) response llm_chat(history) # 让模型输出决策 action parse_action(response) # 从模型回复里解析动作 result execute_action(action, browser_session) # 执行动作 history.append(动作结果 result) if is_goal_finished(response): break return history真实代码当然比这个复杂得多但骨架就是这段。每一步流程都是“重新提取状态 → 把状态追加到上下文 → 请求模型决策 → 解析动作 → 执行动作 → 记录结果”然后循环。这个循环本身的设计目标只有一个让模型在每一步都基于最新、完整的上下文做决策而不是凭猜测。4.2 Token 预算和上下文管理这个循环跑起来之后第一个实际问题就是上下文膨胀。每执行一步就要追加当前页面的元素清单、模型的思考、执行结果。跑个五六十步之后积累的文本量很容易突破模型的窗口上限。所以源码里必然有一套上下文管理机制这也是我读源码时觉得特别真实的部分。基本的办法是设定一个最大步数超过就强制终止同时给每一步的元素清单做更加激进的压缩。更精细的做法是把历史对话里的“思考原文”做截断只保留模型最终输出的动作名称和关键参数那些过长的思考中间过程用一句“模型经过思考后选择了点击按钮 X”来替代。这相当于把历史做了“摘要化”保证关键信息不丢但不让无关思考过程占用窗口。另外一个很实际的处理是每隔一段时间把“过去 N 步里的状态和结果”做一个简短总结然后塞回上下文同时把原始记录从上下文中滑出去。这种“滑动窗口 定期总结”的策略在比较长的自动化任务里几乎是必不可少的。4.3 模型决策与动作解析时的坑模型输出决策后解析动作那一步是个容易出细节问题的环节。现在的 LLM 输出结构化内容一般会通过 JSON 格式。但模型在某些边缘场景下会输出不标准的 JSON——比如漏个括号、字符串里多了一个换行符、在 JSON 前加了一段 “好的我来分析一下” 的前缀。如果解析器写得太死板整个 Agent 流程会频繁中断。源码里这块的处理思路是“分层容错”优先直接解析标准 JSON失败就把模型输出里的 JSON 部分用正则提取出来再解析还不行就把文本丢给一个宽松的解析器尝试从自然语言里找出工具名称和参数。这个“层层降级”的思路在各类 LLM 应用开发里都是可以借鉴的。另外还有一步容易被忽略模型输出和工具定义之间的对齐。如果工具是“点击元素”模型返回的参数是{index: 5}但元素清单里编号是字符串5这中间需要一次类型归一化。看起来是小问题实际跑的时候特别容易在类型检查上翻车。4.4 纠错逻辑与自动化程度的选择整个循环里最体现工程水平的其实是纠错逻辑。这里的取舍很有意思到底要不要让模型自己做错误恢复还是出错就停Browser-Use 的做法偏向“给模型纠错的机会但限制重试次数”。比如点击元素时元素不可见源码会检查是不是页面还没加载完如果是就先触发一次等待动作把“等待 2 秒”当作一次独立操作交给模型执行。再比如模型想要输入的文本被输入框的某种限制拦住了执行器会把这个限制反馈给模型让它换一种写法。这种设计背后有一个理念Agent 不能像传统爬虫那样“决定好了就走到黑”。LLM 的价值在于它能够基于反馈调整策略所以源码把纠错做成“正常流程的一部分”而不是异常处理分支。这才让整套循环看起来像是真的在“思考和行动”。5. 实战踩坑记录那些文档里没写的细节5.1 最坑的不是 AI是网页本身跑 Browser-Use 一段时间后你会有一个直观感受网页的动态性才是最大的敌人。静态页面用它跑任务流畅得让人怀疑是不是没用 AI但一旦遇到单页应用尤其是 Vue、React 这类驱动的后台系统问题就来了。典型的坑是“元素还在但操作无效”。比如一个 React 表单输入框的onChange事件是框架内部通过合成事件触发的直接派发原生 input 事件虽然会让 DOM 里的值改变却不一定会触发表单状态更新。Browser-Use 在执行输入动作时对一些常见框架做过事件捕获的兼容处理但在实际项目里你仍然可能遇到输入了文本但下一步提交时表单认为它是空的情况。遇到这类问题我的排查经验是先用浏览器开发者工具手动操作一遍注意控制台里触发了哪些事件然后去源码的执行器部分把对应的事件触发逻辑打开看看有没有自定义事件名可以覆盖。不要把锅全甩给 AI很多时候是你用的那个技术栈的事件机制比较特殊。5.2 首页加载和元素清单过期造成的连锁反应第二个实战高频坑是“元素清单过期”。模型在一个长任务里保留了较早步骤记忆但页面状态已经变了它手里那张编号清单还是旧的。这就可能出现它坚持点击编号 3但页面现在的 3 号元素已经完全是另一个东西了。源码为了缓解这个问题会在页面发生显著变化时比如 URL 变了或者检测到 DOM 大面积替换强制清空旧的元素引用重新提取。但如果是小范围变化比如点击了一个按钮后弹出新区域旧清单和新状态会混在一起。这种情况下模型能不能正确判断完全取决于它的基本功。实测下来Claude 和 GPT-4 级别的模型基本能应付弱一点的模型会非常吃力。解决方案其实不在源码里而在你的任务设计里尽量把长任务拆成多个短任务每一步之间重新构造状态上下文避免模型在旧状态里打转。5.3 浏览器自动化库的版本兼容问题再一个坑就是浏览器内核版本。CDP 接口的稳定性虽然不错但不同版本的 Chromium 内核会有细微差异。比如某些老版本里DOM.getElementById返回值的行为和 Chrome 128 不一样就会导致元素定位失败。Browser-Use 平时锁定的自动化库版本如果你不跟着它的版本走自己换了新版 Playwright很可能会出现“昨天还能跑今天同样代码全失败”的情况。遇到这种状况先别怀疑你的代码逻辑去检查自动化库的版本和项目要求的是否一致。这个项目对版本是有依赖约束的但如果你是从 GitHub 拉的新源码而本地环境装的是旧依赖会出现很多莫名其妙的错。用虚拟环境重新装一遍依赖通常能解决 80% 的版本问题。5.4 自建模型接入时的提示词适配最后一个很常见但容易被忽略的点如果你把模型从默认的闭源模型换成开源模型或者自建微调模型提示词结构可能需要调整。因为开源模型的指令遵循能力和默认模型有差距项目里那套复杂的系统提示词在弱模型上会表现得不稳定输出 JSON 的频率也会下降。我的建议是接开源模型时先跑一个最简单的测试任务把系统提示词精简掉一半再看看输出是否稳定。不要以为源码里的提示词对所有模型通用。6. 顺着源码还能怎么改二次开发思路6.1 把状态压缩改成自己领域的形式Browser-Use 默认提取的元素属性对大部分网页都够用。但如果你要自动化的目标网站是一种特殊形态——比如表格特别密集的后台管理系统或者是 Canvas 渲染的图形界面默认的提取策略可能拿不到关键信息。这时候值得动手改的就是 2.2 节说的那段 DOM 提取脚本。逻辑很简单在元素属性抽取阶段补上你需要的字段。比如把每个单元格的>
返回列表