这两年AI写代码已经见怪不怪了,但真正让我觉得“有点东西”的方向,是让大模型自己操控浏览器去完成任务。你给它一个目标,它自己打开网页、看内容、点按钮、填表单、翻页、提取信息,一套操作下来跟个真人似的。这里最绕不开的开源项目就是Browser-Use。
我花了不少时间把它源码啃了一遍,从最早只会跑demo,到后来能改它的controller注册自己的动作,再把整套结构迁移到自己的自动化工具里。这篇文章不打算给你念API文档,而是直接聊源码:AI到底是怎么“看懂”网页的?又是怎么把手伸进浏览器里完成点击和输入的?每个关键环节我会结合自己的调试经历讲明白,也会附上能直接跑的实操代码,适合正在做AI Agent、浏览器自动化、或者想深入理解RAG之外“智能体怎么和真实世界交互”的朋友。
1. 项目概述:browser-use到底解决了什么问题
1.1 一句话理解browser-use
Browser-Use是一个开源的Python库,核心目标很简单:让大语言模型能像一个真实用户一样操作浏览器。整体运行逻辑不复杂,就是一个“观察→决策→行动→再观察”的循环:
- 把当前浏览器页面的DOM结构提取出来,转换成LLM能读懂的文本形式,比如带索引的交互元素列表。
- LLM根据用户给出的任务和当前页面状态,输出一个结构化的“动作”,比如“点击索引为5的按钮”“在输入框3中输入‘Python’”。
- 框架解析这个动作,通过Playwright在真实浏览器里执行。
- 执行完成后重新提取页面状态,把新的DOM交给LLM,继续下一轮,直到任务完成或达到最大步数。
这个项目之所以流行,是因为它把“LLM和网页交互”这件事封装得很干净。你不需要自己处理复杂的DOM解析、XPath定位、浏览器调试协议、历史记录管理,只需要写一个任务描述,剩下的交给框架。
1.2 为什么不能直接调API完成网页操作
有人可能会问:我直接用大模型API,让它给我返回一段JS代码,然后我用Playwright执行不就行了?理论上可以,但实际操作会遇到一堆麻烦。
网页不是纯文本,而是一个高度结构化的环境。直接扔给模型原始HTML,几万个token就把上下文吃光了,而且模型很难理解“这个按钮到底在页面哪个位置”“点击之后会不会弹出新窗口”。更麻烦的是,模型生成的JS代码经常飘,要么选择器写错,要么没考虑到iframe、Shadow DOM、元素懒加载,一旦报错整个任务就断掉。
Browser-Use的做法相当于给模型发了一个“操作面板”,面板上只有几个按钮和输入框,每个都标好了编号和类型。模型不需要写JS,只需要说“按第7号按钮”,框架负责把编号翻译成具体坐标和元素。这大大降低了模型的操作难度。传统RPA是录制固定脚本,页面一改就废;Browser-Use是让模型实时看页面再决定怎么操作,天然抗页面改版。
1.3 源码整体目录与模块划分
我第一次打开Browser-Use源码仓库时,第一感觉是“结构比想象中清晰”。核心代码集中在browser_use目录下,几个重要模块各司其职:
agent/:控制主循环,定好“每一步怎么想、怎么做、怎么记”,是整个框架的大脑。browser/:封装浏览器的启动、页面管理、浏览器上下文,底层主要依赖Playwright。dom/:DOM提取与转换,把密密麻麻的HTML变成模型能读的紧凑文本。controller/:动作注册与执行中心。所有模型能做的动作都从这里注册,包括内置的点击、输入、滚动等操作。llm/:大模型调用封装,支持OpenAI、Anthropic、Google、Ollama以及各种兼容接口。
我当时先跳过了agent,直接从dom模块开始读,因为我想搞清楚那套“页面翻译”是怎么实现的。后来才发现,agent主循环才是把一切串起来的关键,两个都绕不开。
2. 核心设计思路:Agent循环是如何转起来的
2.1 一次完整的“看-想-做”循环
Browser-Use的agent主循环在agent/service.py里,虽然代码版本迭代过几轮,但核心结构一直很稳定。最关键的方法大概是_run_step或类似命名(不同版本略有差异),但思路是一致的:
每一步都会组装一个“提示包”,包含系统提示、任务描述、历史操作记录、当前页面状态,发给LLM。LLM的输出会被解析成两种结果:一种是Thought(模型的理由),另一种是Action列表。Action会逐一交给controller执行,执行结果(包括成功或失败、是否改变了URL、是否弹出了新标签页)会被记录下来,作为下一轮的历史输入。
简化后的伪代码如下:
for step in range(max_steps): messages = build_messages( task=task, history=history, state=current_state, ) response = llm.invoke(messages) thought, actions = parse_llm_output(response) for action in actions: result = controller.execute(action) history.append(result) current_state = browser.get_state() if task_is_done(history): break我当时第一次看到这个循环时,脑子里冒出来的类比是:这就像一个员工坐在工位上,领导给了一个目标,他每做完一步就抬头看一眼屏幕,然后写下“我做了啥、结果咋样”,再决定下一步。Browser-Use只是把这个过程自动化了。
2.2 为什么用“动作指令集”而不是让模型直接写JS
这个问题我想了很多遍,后来在看源码时彻底明白了。Browser-Use在controller/里定义了一套结构化的动作指令集,模型只能从中选择,不能自由发挥写代码。这样做有几个明显的好处:
第一,可控性。模型永远不会直接执行任意JavaScript,只能点击指定元素、输入指定文本、滚动指定方向。就算模型“发疯”,造成的破坏也有限。
第二,可解释性。每一步记录都是结构化的,比如“点击了id=3的按钮”就是一条明确记录。如果模型自己写JS,你很难判断它到底做了什么。
第三,token效率。一个动作只需要说出动作名和参数,比生成完整JS代码省几十倍token。
第四,容错。框架可以在执行动作前做校验,比如目标元素是否还存在、输入内容是否合法,失败时能给出明确的错误信息反馈给模型。
你可以把controller看成是给模型发了一副“只能按这几个键的手柄”,模型再聪明也只能在预设操作里组合。别觉得这是限制,实际用下来反而让任务成功率更高。
2.3 状态历史与上下文管理
细读源码就会发现,历史记录不是一个简单的list,而是经过了一层“压缩管理”。每一步的观察结果、动作、执行结果都会进入history,但历史不能无限增长。模型上下文窗口就那么大,塞满之后后面的决策质量会断崖式下跌。
Browser-Use的处理策略比较聪明,它主要靠两种手段:
一是只保留最近若干轮的关键信息。在源码里你能看到类似history.maximum_history_length这样的配置,默认不会包含全量历史,早期步骤会被截断。对于很长的任务,还可能触发“总结模式”,把早期步骤压缩成一小段摘要,避免信息丢失。
二是状态提取有严格的token预算。dom/模块提取出的页面内容会限制节点数量和文本长度,防止单轮状态就把上下文撑爆。这个我也在实操中深有体会:一个网易首页级别的复杂页面,直接转成文本能有好几万个字符,不控制的话几轮对话直接爆掉。
3. 关键源码细节:AI是怎么“看懂”网页的
3.1 DOM是怎么被“翻译”成模型能读的内容
这个环节是整个项目里最有技术含量的地方。我在dom/目录下翻源码时,发现它并没有简单地把HTML转成文本,而是做了一个“交互元素提取”。核心思想是:不要给模型看所有东西,只给它看能操作的东西。
具体来说,框架启动后会遍历页面DOM树,过滤掉不可见元素、非交互元素,只保留链接、按钮、输入框、下拉框、复选框、文本区域等可交互节点。每个节点会分配一个编号,属性包括标签名、type、name、id、文本内容、href等,最终输出类似这样的结构:
[2] <button> 提交订单 [3] <input type="text" placeholder="请输入关键词"> [5] <a href="/product/123"> 无线耳机这个过程用到了自定义的DOM遍历器和文本转换器。源码里会有一些关键类,比如DomService负责从Playwright页面拿到可交互元素树,TreeBuilder负责把节点转成带缩进和索引的文本树。如果你去看它的实现,会发现它不依赖BeautifulSoup之类的外置解析器,而是通过JavaScript在浏览器内部直接基于document对象抽取信息,这样可以拿到更准确的布局和可见性数据。
这个“缩小范围”的设计非常关键。原始的Google首页可能只有几十个节点,但一个电商网站可能有上千个节点。如果全部塞给模型,不仅token爆炸,模型还会被无关内容干扰。只保留重要交互节点之后,页面状态一般能控制在2000~6000字符内,成本和准确率都更可控。
3.2 坐标与元素定位:点击输入是怎么精准落地的
模型拿到的是带编号的元素列表,但浏览器真正执行点击时需要的却是坐标或选择器。Browser-Use怎么把编号翻译成真实操作?
我在源码里看到,每个提取出的节点都附带了一个xpath或者用于定位的索引信息。当模型说“点击第5号元素”,controller会先去当前页面状态里查询编号对应的节点信息,然后使用Playwright的locator(xpath)定位到真实元素,再执行点击。某些版本还会计算元素在视口中的中心坐标,通过鼠标事件模拟点击。
这就引出一个重要体验:模型看到的页面状态必须和实际页面状态保持同步。如果模型刚看完页面,页面又做了异步更新,那么编号对应的元素可能已经变了。所以每执行一步后,框架都会重新提取状态,避免模型拿着过期数据硬操作。这也是为什么我在调试时经常看到模型会先“重新观察页面”,再做决策,它其实是在等新状态。
有一点值得提醒:Browser-Use的“提取状态”时机比较讲究,比如在页面跳转后或点击后,它会等待网络空闲或元素稳定,缩短状态和真实页面之间的差距。这个等待策略在browser/里也有对应的wait_for配置,我在实操中会把wait_for_network_idle打开,尤其是处理SPA单页应用时非常管用。
3.3 Controller动作注册机制
controller/模块是一个“插件式”动作中心。内置动作包括打开URL、点击元素、输入文本、滚动、切换标签页、提取内容、返回上一页、刷新页面等,大概十几个。每个动作都以装饰器方式注册,源码里长得像这样:
class Controller: @action("打开网页", param_model=GoToUrlParams) async def go_to_url(self, url: str): await self.browser.go_to_url(url) return ActionResult(include_state=True) @action("点击元素", param_model=ClickElementParams) async def click_element(self, index: int): element = self.browser.get_element_by_index(index) await element.click() return ActionResult(include_state=True)这里有一个细节很关键:每个动作都定义了自己的参数模型(基于Pydantic)。LLM输出的动作是结构化JSON,框架会用它反序列化成参数对象。如果模型给的参数不合法,比如点击元素时没给index,框架会返回参数校验错误,并把错误信息反馈给模型,让它下次修正。
如果你用过这个库,也许见过我自己注册自定义动作的例子。比如我有个任务是每天自动填一个内部报表,我直接在controller上注册了一个fill_report动作,动作内部只需要两个参数,剩下的页面逻辑全部在函数里写死。这样模型只需要做极少的决策,稳定性和速度都上来了。
3.4 浏览器控制层:本地浏览器、CDP、Playwright
Browser-Use并没有自己从零写浏览器自动化,而是基于Playwright。Playwright负责启动Chromium、管理标签页、模拟鼠标键盘、截图、生成页面状态。Browser-Use在browser/模块里做了几个有用的封装:
- 多标签页管理:新开标签页后,状态提取会自动聚焦到当前激活的标签页。
- 持久化用户数据:可以传入
user_data_dir保留登录态,我处理需要登录的站点时就用这个,避免每次都要重新登录。 - CDP支持:底层也可以连接一个已有浏览器实例,这让它能在某些复杂登录场景下无缝接管用户已登录的浏览器。
观看源码时你会发现,Playwright这一层并不“智能”,它是纯粹的“手脚”。智能全在上面的决策层,Browser-Use的巧妙之处就在于把“手脚”包装成了一套对LLM友好的接口。
4. 实操:从源码读完之后跑通一个最小Agent
4.1 环境准备与安装
如果你看完源码想立刻跑起来,环境准备其实非常快。我建议用一个干净的新虚拟环境,避免依赖冲突。
pip install browser-use playwright install chromium如果你在服务器上跑,可能还需要安装一些系统依赖,playwright install-deps可以一键搞定。然后就是模型配置。我最早用的是OpenAI的接口,后来为了省钱换成了本地Ollama和兼容OpenAI协议的模型,都跑通过。选模型时有一个建议:不要用小参数模型,至少也要7B以上,而且指令遵循能力要强,否则模型经常乱给参数。
如果你没有OpenAI的key,用本地模型也很方便。Ollama拉一个qwen2.5:7b或类似模型,再把base_url改成http://localhost:11434/v1就行。
4.2 最小Demo:让AI自动打开网页并提取内容
下面这段代码我非常精简,但确实能跑通。它让AI打开一个页面,找到搜索框输入关键词,然后点搜索,最后提取结果标题。我这里以公有技术社区为例,换成任意公开网站同理。
import asyncio from browser_use import Agent from langchain_openai import ChatOpenAI # 如果你用本地模型,把base_url换成Ollama地址即可 llm = ChatOpenAI( model="gpt-4o-mini", base_url="https://api.openai.com/v1", api_key="YOUR_API_KEY", ) async def main(): agent = Agent( task="打开 https://news.ycombinator.com ,在搜索框里输入 'AI agent'," "点击搜索按钮,然后把结果的第一条标题和链接告诉我。", llm=llm, max_steps=10, ) history = await agent.run() print(history.final_result()) asyncio.run(main())跑这段代码时你会发现,模型的思考过程会打印出来,比如“我需要先定位搜索框”“我看到了输入框编号3”。如果页面复杂导致模型犯错,它会收到错误信息并自动修正。第一次跑通的时候很兴奋,感觉真像有个远程员工在替我操作浏览器。
4.3 参数调优与常见配置
实际使用时,直接裸跑默认参数往往不够。我根据源码里的配置项和自己的测试,整理了几个最值得调的参数:
max_steps:最大步数。任务越复杂越要放宽,但步数越多token消耗越大,一般10~20步能覆盖大部分简单任务。use_vision:是否启用视觉理解。开启后模型会看截图,对判断页面布局很有帮助,但会显著增加token消耗,而且小模型吃截图效果并不好。max_actions_per_step:每步最多执行几个动作。默认是1,可以调大减少轮次,但模型出错的概率会变高。temperature:生成温度。浏览器操作需要确定性,我一般调到0或0.1,防止模型发挥太飘。wait_for_network_idle:页面跳转后等待网络空闲,再提取状态。处理SPA时我强烈建议开启。
这些参数都在Agent构造函数里直接传,用起来非常方便。现在实践中我通常先用较高的max_actions_per_step跑快速任务,遇到复杂页面则调回1,让模型每一步都谨慎观察。
4.4 踩坑记录:我在跑源码时遇到的几个问题
说几个很容易踩的坑。第一个是无头模式登录失效。默认跑在无头浏览器里,很多网站会直接拦住,验证码弹个不停。我的解决方案是先用普通模式手动登录一次,然后保存浏览器上下文。Browser-Use支持传入user_data_dir参数,指定一个持久化目录,之后每次启动都能保持登录态。
第二个是滚动加载的页面。有些网页是无限滚动,模型看不到底部内容。给模型的任务里最好明确说“滚动到页面底部”或者“滚动一段距离”,否则模型不会主动滚,信息永远提不全。
第三个是动态下拉框。比如你要选“省-市-区”,点击某个下拉后选项是异步加载的,模型点击太快会扑空。后来我用了一个经验做法:在controller里自己注册一个wait_and_click动作,点击前先强制等待500毫秒或者轮询目标元素稳定。这种“笨办法”反而比模型自己连续点击靠谱得多。
第四个是token爆掉。页面大、历史多的时候,上下文很容易超限。源码里会有设置项来限制DOM提取的最大节点数,同时把history.maximum_history_length设小一点,比如10。如果还是爆,最简单的办法就是换更长上下文的模型。
5. 常见问题与排查技巧实录
5.1 模型总是乱点或者不动
现象:模型给出的动作明显不合理,比如在搜索结果页去点击底部的版权链接,或者迟迟不执行任何动作。原因一般有两个:一是模型太弱,没有足够能力理解页面结构;二是页面状态太混乱,给了太多无关元素。
排查顺序:先在任务描述里写得更具体,比如“只在主内容区域点击链接,不要点击导航和底部版权”;把use_vision关掉,因为带截图的提示对小模型干扰很大;如果还不行,就换成更强的大模型。我个人经验是,7B模型做简单搜索和点击够用,但严格多步任务还是得靠更大模型撑住。
5.2 DOM太大导致上下文爆掉
现象:报错提示超过上下文长度,或者模型在长任务后期明显“失忆”。这是最常遇到的问题之一。
解决思路:限制DOM提取的节点数量。在Browser-Use的DomService或者agent初始化那里,会有max_depth、max_nodes之类的参数,把它们调小。页面只保留最外层可交互元素,模型没有压力,任务成功率反而更高。另外缩短历史记录,history.maximum_history_length调成5~10,效果立竿见影。
5.3 有些输入框输入不进去
现象:模型执行了输入动作,但没有报错,页面里的值却没变。这个大概率是输入框不是原生的<input>,而是富文本组件或模拟输入框。框架的输入动作是直接往元素里填值,很多前端组件监听的是input事件,简单的赋值触发不到。
我的兜底方法:自己注册一个动作,使用Playwright的fill()后再手动派发事件,或者在输入前先点击目标元素,激活焦点再输入。源码层面你可以扩展controller,给输入动作加上一个强制触发input事件的后处理步骤。
5.4 登录与验证码无法自动处理
现象:打开目标网站就跳登录,验证码识别不了,任务直接卡死。
这个我没找到完美的自动化方案,比较靠谱的做法是“人机结合”:第一步手动登录,把浏览器用户目录保存下来;第二步让Agent基于这个已登录上下文继续操作。Browser-Use支持传入user_data_dir,复用Chromium的登录状态。另外验证码我建议不要硬扛,完全没有必要为这种边缘场景投入太多时间,除非你想做一个专门的验证码识别服务。
5.5 代码级调试技巧
如果任务一直失败,光看打印日志是不够的。我常用的办法是打开源码的调试日志,设logging.basicConfig(level=logging.DEBUG),这样能看到每一步的提示词、模型输出、动作执行结果。还有一个办法是在浏览器层面无头改有头,亲眼观察浏览器在干嘛。
我自己在调试时最喜欢在controller.execute执行前打印一次动作JSON,执行后再打印一次ActionResult。这样能看出是模型决策问题还是执行层问题。如果是执行层问题,我会拿着元素索引去浏览器里定位,看那个元素是不是隐藏的、被遮挡的或者已经消失了。
6. 读完源码后,顺着这个思路还能做什么
6.1 注册自己的动作扩展能力边界
内置动作只覆盖“通用点击输入”这些场景。实际做业务时,我强烈建议把重复操作封装成自定义action。我在一个数据采集项目里,注册了select_date_range、export_table、download_attachment这些动作。模型的任务描述变得非常短,动作执行成功率也高得离谱。这其实是把“聪明的模型”和“确定性的代码”结合起来,模型的职责从“怎么操作”退化为“什么时候操作什么动作”。
6.2 与本地知识库和RAG结合
你可以让Agent先从多个页面提取内容,再把内容存入本地向量库,后续由另一个LLM进行归纳总结。Browser-Use不擅长长文本推理,但很擅长“把网页上的内容搬出来”。我有个小工具就是让Agent负责跨网站收集竞品信息,统一整理成Markdown,再交给下游的总结模型输出报告。这样分工明确,速度和成本都更可控。
6.3 用本地模型降低成本
如果你对商业API的费用敏感,完全可以引入本地模型。Browser-Use本身对模型来源不敏感,只要支持OpenAI协议就能接入。我在本机用Ollama跑qwen2.5时,简单任务效果还能接受,复杂布局就差点意思。但如果你只是让Agent执行“打开某页面,点击某个固定按钮”,本地模型完全能胜任。这个思路特别适合内部自动化工具,隐私和成本都能兼顾。
6.4 把它改造成定时巡检助手
最后一个我很推荐的扩展方向是“定时巡检”。业务流程中经常需要每天查看后台数据、检查订单状态、关注某个页面有没有更新。用Browser-Use加一个cron定时任务,Agent每天自动登录、切换页面、提取关键数据、推送通知。我在团队内已经跑了好几个月,最大的体会是“不用再教新人去哪找数据了”,Agent自己会用自然语言理解任务。
如果你也想往这个方向做,我建议一开始就把任务拆得足够小。先让它每天只做一件事,跑稳了再扩展。不要一上来就让它“维护整个后台”,那会把你自己也绕晕。Browser-Use的源码给了你一个很好的起点,把循环跑通之后,剩下的就是不断往controller里加你需要的动作,让它越来越像一个真正的“浏览器操作员”。这大概是这个开源项目最让人上瘾的地方。