用途:AI Agent 工程方向项目解析博客(代码级 Agent runtime 深度拆解)
原则:讲"为什么这样设计";结论锚定真实源码(browser-use 仓库,类名/行号可查);主角是 browser-use 本身
仓库:browser-use/browser-use(本文基于browser-use-main源码)
定位:接续 LangGraph、OpenHands,从「Workflow Agent → Coding Agent」走向「Browser Agent」第三种架构视角
零、为什么看完 LangGraph 和 OpenHands,要来看 browser-use
前两篇项目解析,我们收获了两个视角:
- LangGraph:任务怎么编排(State / Node / Edge / Checkpoint / HITL)。
- OpenHands:Agent 怎么和真实世界交互(Agent / Event / Workspace / Security / Sandbox)。
但这两个项目有一个共同点:它们的"执行环境"里,网页是一个黑盒。OpenHands 能用 CDP 拉起浏览器、能用 Playwright 截图,但它不关心"这张网页长什么样、哪里可以点"——它把浏览器当成一个普通的执行工具,用坐标或 API 去操作。
而 browser-use 问了一个更本质的问题:
Agent 要像人一样用浏览器,它得先"看懂"网页,还得能"点"网页——这件事,架构上到底怎么做?
这就是第三种完全不同的视角。如果说 LangGraph 回答"任务怎么编排"、OpenHands 回答"Agent 怎么执行真实动作",那么 browser-use 回答的是:
Agent 怎么感知一个高动态、超复杂的真实世界界面(网页),并在上面做出精确操作。
browser-use 是当前最火的浏览器自动化 Agent 库之一(GitHub 数万 star,自己的测评 Odessey 榜上 87.4% 均值,压过 OpenAI / Anthropic / Google / Microsoft 的 computer-use 方案)。但它真正值得学习的,不是"它能操作浏览器"这个结果,而是它为了解决"LLM 看懂网页"这件事,造出的那一整套工程机制。
这一篇,就拆这套机制。
一、项目定位:它解决什么工程问题
- 解决什么:让 LLM 驱动的 Agent 像人一样使用浏览器——打开页面、点击、输入、填表、滚动、提取数据、多标签页切换,完成任务。
- 为什么难:网页不是给机器看的。它是一棵动辄几千节点的 DOM 树,其中绝大多数节点对完成任务毫无意义;而真正可点击的元素,又被 CSS、脚本、shadow DOM、iframe 层层包裹。直接把整个 DOM 或截图丢给 LLM,上下文瞬间爆炸,模型也分不清"哪个元素是哪个"。
- 两条技术路线:一类是 OpenAI / Anthropic 的computer-use(视觉路线)——给模型看截图,让模型输出坐标去点。browser-use 走的是另一条——DOM 文本化路线:把网页"可交互的部分"压缩成一段带索引的文本,让模型输出"我要点
[17]",而不是"我要点坐标 (312, 88)"。
项目一句话介绍:
browser-use 是一个浏览器自动化 Agent 框架,核心是把真实网页 DOM 序列化成"带索引的可操作文本",让 LLM 通过"文本感知 + 结构化动作输出 + 事件驱动执行"完成浏览器任务,而不是靠截图猜坐标。
它和 computer-use 路线之争,是这篇赏析最值得品味的背景——后文会看到,DOM 文本化带来的是精确、可回放、上下文可控,代价是实现复杂度极高(要处理树、布局、遮挡、语义、跨 iframe 一堆脏活)。
二、先记住 browser-use 最重要的循环
它依然是一个 reasoning-action loop,但循环的两个端点都是"浏览器":
和 ChatBot 的本质区别,在这里变成了一件事:LLM 看到的不是"你发的消息",而是一张实时的、经过精心压缩的"网页棋盘"。每一步,棋盘都会因为上一次操作而更新。
三、核心数据流:一个任务从进来到结束
拿 README 里的经典例子"找到 browser-use 这个 repo 的 star 数"走一遍完整链路:
用户: agent = Agent(task="找到 browser-use repo 的 star 数", llm=...) await agent.run(max_steps=50) ──────────────────────────────────────────────────────────────── Agent.run() agent/service.py:2506 ├─ 启动浏览器:browser_session.start() │ └─ LocalBrowserWatchdog 拉起 Chrome + --remote-debugging-port ├─ 初始动作:_extract_start_url(task) 提取出 github.com/browser-use/... │ → navigate → CDP 导航 → 页面加载完成 └─ while n_steps <= max_steps: agent/service.py:2603 └─ _execute_step() agent/service.py:2441 └─ step() agent/service.py:1029 ├─ [1] _prepare_context() agent/service.py:1081 │ ├─ get_browser_state_summary() browser/session.py:1587 │ │ └─ 派发 BrowserStateRequestEvent │ │ └─ DOMWatchdog 响应 browser/watchdogs/dom_watchdog.py │ │ ├─ DomService.get_serialized_dom_tree() dom/service.py │ │ │ ├─ 抓 4 路原始数据(DOMSnapshot/DOM/AX/JS监听) │ │ │ └─ DOMTreeSerializer.serialize_accessible_elements() │ │ │ → SerializedDOMState(_root, selector_map) │ │ └─ take_screenshot() │ └─ MessageManager 组装成【一条】状态消息 │ → <browser_state>[1]<a>...[17]<button>...</browser_state> ├─ [2] _get_next_action() agent/service.py:1170 │ └─ llm.ainvoke(messages, output_format=AgentOutput) │ → 输出 AgentOutput(action=[click(index=17)]) ├─ [2] _execute_actions() agent/service.py:1205 │ └─ multi_act() agent/service.py:2733 │ → tools.act() → registry.execute_action() │ → click(index=17) │ ├─ selector_map[17] 查到 backendNodeId │ ├─ 发 ClickElementEvent → DefaultActionWatchdog │ └─ CDP Input.dispatchMouseEvent → 真的点下去 └─ [3] _post_process() agent/service.py:1213 └─ _finalize() agent/service.py:1350 └─ history.add_item(AgentHistory)(model_output + result + 状态 + 截图) └─ 直到 LLM 输出 done(success=True) → 结束 Agent.run() finally: ├─ token_cost_service 汇总 usage / cost → history.usage └─ close() → 关闭浏览器 / CDP注意这条链路上两个"派发事件"的节点——状态的获取和执行动作,都不是直接函数调用,而是通过事件总线派发,由 watchdog 响应。这是后文的核心闪光点之一。
四、闪光点一:DOM 序列化——把网页变成 LLM 的"棋盘"(招牌)
这是 browser-use 最核心的工程创造,也是它区别于 computer-use 路线的地方。
问题:网页对 LLM 来说是"天书"
一个真实网页的 DOM 可能有上万节点,其中 99% 不可点击、不可见、对任务毫无意义。直接丢给 LLM,要么上下文爆炸,要么模型在海量噪音里找不到该点哪个。
方案:六步流水线,压缩成"带索引的可操作文本"
DOMTreeSerializer.serialize_accessible_elements()(dom/serializer/serializer.py:110)的源码,本身就是一篇很好的工程文章:
async def serialize_accessible_elements(self) -> tuple[SerializedDOMState, dict]: # Reset state self._interactive_counter = 1 self._selector_map = {} # Step 1: Create simplified tree (includes clickable element detection) simplified_tree = self._create_simplified_tree(self.root_node) # Step 2: Remove elements based on paint order(去掉被遮挡的元素) if self.paint_order_filtering and simplified_tree: PaintOrderRemover(simplified_tree).calculate_paint_order() # Step 3: Optimize tree (remove unnecessary parents)(剪掉无用父节点) optimized_tree = self._optimize_tree(simplified_tree) # Step 3: Apply bounding box filtering(处理嵌套可点容器) filtered_tree = self._apply_bounding_box_filtering(optimized_tree) # Step 4: Assign interactive indices to clickable elements self._assign_interactive_indices_and_mark_new_nodes(filtered_tree) return SerializedDOMState(_root=filtered_tree, selector_map=self._selector_map), self.timing_info最终产出的文本长这样(serializer.py的serialize_tree()静态方法):
[Start of page] [1]<a href="/browser-use/browser-use"> browser-use / browser-use </a> |scroll element[9]<div role="combobox"> [15]<input type="text" placeholder="Search..."> compound_components=(role=combobox,count=4,options=A|B|C|D) [End of page]最巧的设计:[N]索引 ↔ backendNodeId 的双向映射
注意那个[17]——它同时是两样东西:
给 LLM 看的文本标记: "我要点击 [17]" 给系统用的执行句柄: selector_map[17] → EnhancedDOMTreeNode → backendNodeId → CDP Input.dispatchMouseEvent(x, y) 真的点下去这就是"文本 ↔ 可操作"的闭环:SerializedDOMState同时携带两份东西——_root(给 LLM/渲染看的简化树)和selector_map(给执行层用的索引表)。一次序列化,同时服务"理解"与"行动"。LLM 不需要知道坐标、不需要知道 DOM id,它只需要说"点 [17]",剩下的系统全部搞定。
还有两个细节值得注意:
is_new标记(*[N]):序列化时和上一轮的selector_map对比,页面新增的元素标上*,提示 LLM"这轮页面变了,出现了新东西"——这是"观察页面变化"的廉价实现。- 只在"可见且可点击"的元素上编号:不可见、不可点击的元素根本不会进编号池,从源头砍掉上下文噪音。
五、闪光点二:三路数据融合的 DOM 快照
"哪些元素可点击"这个问题,比想象中难得多。因为网页有四种信息源,单独任何一个都不够:
| 数据源 | 提供什么 | 单独用的问题 |
|---|---|---|
DOMSnapshot.captureSnapshot | 布局 bounds、computed styles | 不知道哪些有语义、可交互 |
DOM.getDocument | 节点层级、shadow DOM、iframe | 纯结构,无语义 |
Accessibility.getFullAXTree | 无障碍名称(可点击语义的重要来源) | 会漏掉 JS 绑定的点击 |
JS 注入getEventListeners() | 谁挂了 click/mousedown 监听 | 覆盖不全(还有 role/tag 判断) |
DomService._get_all_trees()(dom/service.py:403)一次会话内并发拿这四路数据,再按 backendNodeId 融合成一颗EnhancedDOMTreeNode(dom/service.py:703,支持 iframe / shadow DOM / 跨源 iframe)。
这个设计解决了"框架渲染的 div 没有语义"这个老大难:一个<div onclick="...">,AX 树可能不认为它可点击,但 JS 监听器探测会抓到它;一个<button>,AX 树会给它语义名,但 bounds 可能被遮挡。只有融合,才能既不漏、也不错。
一句话总结:可点击性不是一个布尔值,而是一个需要四种证据交叉验证的判断。
六、闪光点三:事件驱动 + watchdog 插件架构
这是 browser-use 浏览器层最优雅的设计,和 OpenHands 的 Event System 有异曲同工之妙,但实现得更"轻"。
问题:浏览器层有十几个横切关注点
拉浏览器、采 DOM、执行点击、监控崩溃、处理验证码、跟踪下载、关弹窗、持久化 cookie、录屏、抓 HAR、防 CSRF……如果每个都塞进BrowserSession类,这个类会膨胀到不可维护(现在session.py已经 4133 行了,再塞会更糟)。
方案:事件总线 + 单一职责 watchdog
核心抽象是BaseWatchdog(browser/watchdog_base.py:15),它有一个非常巧的注册机制——用方法名反射自动订阅事件:
for method_name in dir(self): if method_name.startswith('on_') and callable(getattr(self, method_name)): event_name = method_name[3:] # 去掉 'on_' 前缀 event_class = event_classes[event_name] self.attach_handler_to_session(self.browser_session, event_class, handler)也就是说,一个 watchdog 里写on_ClickElementEvent(),它就自动成为点击事件的响应者。于是每个关注点一个文件,职责单一:
| Watchdog | 职责 |
|---|---|
LocalBrowserWatchdog | 启动/关闭本机 Chrome |
DOMWatchdog | 响应BrowserStateRequestEvent:建树 + 序列化 + 截图 |
DefaultActionWatchdog | 执行 click/type/scroll 等真实 CDP 交互 |
CrashWatchdog | 监控 target crash,触发重连 |
CaptchaWatchdog | 等验证码求解完成 |
DownloadsWatchdog | 自动下载跟踪 |
PopupsWatchdog | 自动关 JS 弹窗 |
SecurityWatchdog | 域名白名单安全限制 |
StorageStateWatchdog | cookie/localStorage 持久化 |
而且BaseWatchdog会给每个 handler 统一注入熔断逻辑:CDP WebSocket 断开时跳过、自动重连等待、异常后重建 CDP 会话。这意味着"每个功能天然健壮"是框架免费送的,而不是每个 watchdog 自己写。
一句话总结:动作执行的路径是"工具层 → 事件 → watchdog → CDP",横切关注点被组织成一排可独立增删的插件。
七、闪光点四:动态判别联合 ActionModel——每加一个工具,LLM 的 schema 自动更新
browser-use 的动作输出,走的是强类型结构化输出,而不是让 LLM 自由生成 JSON。
关键在Registry.create_action_model()(tools/registry/service.py:517):它用pydantic.create_model运行时动态生成一个判别联合ActionModel,把当前所有已注册的工具(内置的 + 用户自定义的 + 页面特定的 + skills)合并成一个类型:
class AgentOutput(BaseModel): # agent/views.py:388 thinking: str | None = None evaluation_previous_goal: str | None = None memory: str | None = None next_goal: str | None = None current_plan_item: int | None = None plan_update: list[str] | None = None action: list[ActionModel] = Field(..., json_schema_extra={'min_items': 1})然后AgentOutput作为output_format传给llm.ainvoke(...),各家模型走结构化输出(OpenAI 用response_format=json_schema)。
这个设计的收益是惊人的:每新增一个工具,LLM 能输出的动作类型就自动多一种,零手工同步。你@registry.action(...)注册一个upload_to_s3,下一次 LLM 的 JSON schema 里就有upload_to_s3这个选项了。
配套的还有工具注册的两个细节:
- 特殊参数注入:
_normalize_action_function_signature()(registry/service.py:75)用inspect.signature把动作函数拆成"用户参数"和"特殊注入参数"——browser_session、page_url、cdp_client、page_extraction_llm、file_system、sensitive_data会自动注入,动作函数签名里写了就给你,不用手动传。 terminates_sequence标记 + 页面变更双保险:navigate/search/go_back 这些动作会"让页面大变",标记后自动截断剩余动作序列;另外multi_act()还会动态检测 URL/focus 变化来截断(service.py:2815-2831)——防止 LLM 一口气输出一串动作,执行到一半页面变了,后面的动作全失效。
八、闪光点五:上下文经济——每一步的 token 都被精确算计
浏览器 Agent 是 Token 消耗大户:每步都要带网页文本 + 截图。browser-use 的上下文管理,几乎把能省的都省了:
- 单状态消息,不是追加:
MessageManager维护三类消息槽(agent/message_manager/service.py:104),其中state_message只有一个——每步覆盖式替换,而不是把历史追加进对话。历史被压缩成<agent_history>文本块并按max_history_items截断(保留首条 + 最近 N 条)。 - 历史压缩(compaction):长会话用
maybe_compact_messages()(service.py:216),让 LLM 自己把旧历史总结成<compacted_memory>。 - 前缀缓存:状态消息标
cache=True,吃 OpenAI 前缀缓存,重复前缀不重复计费。 - 精确截断:
max_clickable_elements_length=40000限制网页文本;URL 会被缩短;截图use_vision='auto'只在需要时带图。 - Token 成本追踪:
tokens/service.py的TokenCost直接包装llm.ainvoke,每次调用后记录 usage,定价数据远程拉取 + 本地缓存,run()结束输出完整成本账单。
一句话总结:让一个"每步都要看网页"的 Agent 跑 50 步不爆上下文、不烧穿预算,不是靠运气,是靠一整套上下文经济学。
九、闪光点六:工具系统 Registry 双层架构
browser-use 的历史包袱很有意思:早期有个Controller类(browser_use/controller/__init__.py),现在它只剩 75 字节——一个兼容别名:
from browser_use.tools.service import Controller __all__ = ['Controller']旧的 Controller 已被Tools+Registry双层取代:
Registry(tools/registry/service.py:33):通用注册中心。@registry.action(...)装饰器注册动作,execute_action()统一执行,create_action_model()生成判别联合,get_prompt_description()按域名过滤生成工具描述文本。Tools(tools/service.py:441):预置的浏览器动作集合 + 对外入口。内置search/navigate/go_back/wait/click/input/upload_file/switch/close/extract/scroll/screenshot/done等二十多个动作。
用户自定义工具极其简单(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}"一个装饰器,注册、schema 生成、LLM 可见、可执行,全齐了。这就是第七节"动态判别联合"的收益在用户侧的体现。
十、底层技术选型:不是 Playwright,是 CDP 直连
一个反直觉的事实:browser-use不用 Playwright,而是直接基于 CDP(Chrome DevTools Protocol)和事件总线库bubus:
# browser/session.py:15-18 from cdp_use import CDPClient from cdp_use.cdp.fetch import AuthRequiredEvent, RequestPausedEvent from bubus import BaseEvent, EventBusBrowserSession(session.py:134)负责:连接管理(connect/reconnect/_auto_reconnect)、CDP 会话管理(每个 tab/iframe 一个 session,由SessionManager维护映射)、页面操作(navigate_to/take_screenshot/get_element_by_index)、状态聚合(get_browser_state_summary)。
为什么选 CDP 而不是 Playwright?从源码看,理由是控制和可观测性:CDP 让你拿到DOMSnapshot、Accessibility.getFullAXTree、getEventListeners这些 Playwright 抽象不暴露的原始数据——而第五节的"四路数据融合"恰恰需要这些。这是"为了核心创新,敢于选更底层的基础设施"的典型案例。
(注:browser_use/actor/目录里其实还藏着一个"Playwright 替代品"——底层 CDP 自动化库,提供Page/Element/Mouse,给高级用户和 Agent 内部复用。)
十一、LLM 抽象层:为什么有十几个 provider
browser_use/llm/下有 openai/anthropic/google/deepseek/ollama/groq/mistral/cerebras/aws/azure/litellm/openrouter/vercel/browser_use 十几个子目录,每个都有chat.py和serializer.py。
核心是一个 Protocol(llm/base.py:18):
@runtime_checkable class BaseChatModel(Protocol): model: str async def ainvoke(self, messages, output_format: type[T] | None = None, **kwargs) -> ChatInvokeCompletion[T]: ...统一返回ChatInvokeCompletion[T](completion + usage + stop_reason)。Agent 完全不关心底层是哪个厂商。
为什么每家一个适配器?因为每家的"方言"不同:消息结构、结构化输出方式(OpenAI 用response_format=json_schema、Anthropic 用 tool forcing、Google 用google-genai)、推理参数都不同。而第七节的"结构化输出"是 browser-use 的命根子,所以每家都必须把AgentOutput的 schema 正确翻译成自家格式——这个适配成本,只能每个 provider 单独付。
这是"抽象层值得做"的正面案例:因为 Agent 核心强依赖结构化输出,所以 provider 适配器不是可有可无的装饰,而是核心链路的必要一环。
十二、三个不足:成熟项目 + 商业公司的代价
赏析不能只说好话。browser-use 有三个很实在的不足,而且它们暴露了"开源库 + 云商业公司"双重身份下的张力。
不足一:历史包袱和兼容层很多
controller/只剩一个兼容别名,actor/是一个独立的 CDP 自动化库(和browser/层职责重叠),sync/把事件流同步到云端,beta/是试验代码。一个 4166 行的agent/service.py、4133 行的browser/session.py——功能复杂度最终变成了架构复杂度,这和 OpenHands 一样,是成熟项目的宿命。
不足二:云服务耦合开始渗入开源代码
ChatBrowserUse是一个闭源托管模型入口;browser_use/sandbox/的@sandbox装饰器会把你的函数cloudpickle 后丢到 browser-use 云端执行;sync/默认把事件同步到云端平台。对于想完全离线的用户,这些是"藏在开源代码里的云依赖",需要额外注意。
不足三:对个人学习者,认知门槛高
DOM 序列化(四路融合 + paint order + bbox + 索引编号)、事件总线 + watchdog、动态判别联合、上下文经济学——每一个单拎出来都是不小的概念。如果只是"想用浏览器自动化的能力",直接用它是最快的;但如果想"读懂它",学习曲线比 LangGraph 和 OpenHands 都陡。
十三、和 LangGraph / OpenHands 的对比:三种视角补完
这是这次赏析最重要的一个位置。三个项目恰好覆盖了 Agent 工程的三层:
LangGraph 任务怎么编排 State / Node / Edge / Checkpoint / HITL OpenHands Agent 怎么执行真实任务 Agent / Event / Workspace / Security / Sandbox browser-use Agent 怎么操作真实界面 DOM 序列化 / Event+Watchdog / 结构化动作| 维度 | LangGraph | OpenHands | browser-use |
|---|---|---|---|
| 核心问题 | 多步任务怎么组织 | Agent 怎么动手 | Agent 怎么"看懂并操作"界面 |
| 状态方案 | 类型化 Channel + Checkpoint | 事件历史(Stateless Agent) | 单状态消息 + 历史压缩 |
| 事件 | 非核心 | 核心(公共语言) | 核心(watchdog 插件) |
| 和 LLM 交互 | 普通调用 | 普通调用 | 强结构化输出(判别联合) |
| 环境感知 | 无 | 文件/Shell/浏览器(粗粒度) | DOM 级细粒度感知 |
| 招牌 | 检查点 + HITL | Runtime 抽象 + Security | DOM 序列化 + 索引闭环 |
关键洞察:browser-use 补上了前三者都刻意忽略的一环——"感知"。LangGraph 和 OpenHands 把"看东西"交给你的代码去实现,而 browser-use 把"看懂网页"本身做成了一门工程。这就是为什么它是研究 Agent 感知层最好的范本。
十四、源码阅读路线(不要从头读到尾)
- 第一层 Agent 循环:
agent/service.py的step()(:1029)——先看懂三阶段(prepare → act → post-process)。 - 第二层 感知:
dom/service.py+dom/serializer/serializer.py——这是全项目最该抠透的核心(六步流水线 + 索引闭环)。 - 第三层 动作:
tools/registry/service.py(注册/校验/判别联合)+tools/service.py(内置动作)。 - 第四层 执行:
browser/session.py+browser/watchdogs/default_action_watchdog.py(事件 → CDP 的真实交互)。 - 第五层 事件与 watchdog:
browser/watchdog_base.py(反射注册 + 熔断包装)——理解"横切关注点怎么组织"。 - 第六层 上下文经济:
agent/message_manager/+tokens/service.py——看它怎么让长任务跑得下去。 - 最后:
actor/(底层 CDP 库)、mcp/、sync/、sandbox/——看生态层,但别被它们带偏。
十五、工程启示:从 browser-use 能学到什么(10 点)
- "感知"是一等工程:Agent 面对真实世界,第一难的不是决策,而是把世界压缩成 LLM 能用的输入。DOM 序列化就是最好的例子。
- 文本索引闭环:给 LLM 的标记(
[17])必须是系统可执行的句柄(backendNodeId)——"理解"和"行动"共享同一份序列化产物。 - 多源证据融合:单一数据源(AX 树、DOM、布局)都有盲区,关键判断(可点击性)要用多路证据交叉验证。
- 事件驱动组织横切关注点:十几个关注点(崩溃/下载/验证码/弹窗)各自一个 watchdog,用方法名反射自动订阅,可独立增删。
- 动态 schema 生成:工具注册后自动并入 LLM 的判别联合,新增工具零手工同步——这是"工具系统"的终极形态之一。
- 上下文经济学:单状态消息 + 历史压缩 + 前缀缓存 + 精确截断,让"每步都要看世界"的 Agent 跑得下去、烧得少。
- 为创新敢选底层:为了拿到
getEventListeners这类原始数据,直接上 CDP 而不是 Playwright——技术选型服务于核心创新。 - 结构化作输出:让 LLM 输出强类型判别联合(Pydantic 校验),而不是自由 JSON——校验在"生成时"就完成。
- 页面变更要防呆:
terminates_sequence+ 动态 URL 检测,防止"一串动作执行到一半页面变了"——这是浏览器 Agent 特有的竞态问题。 - 统一异常入口:
step()里所有异常都进_handle_step_error(),所有收尾都进finally: _finalize()——不优雅的异常,也要有章法。
十六、选型视角:browser-use 适合什么、边界在哪
- 适合:需要"像人一样用浏览器"的自动化任务(填表、抓数据、监控、QA);想研究"Agent 感知层"怎么做的学习者;作为自定义 Agent 的浏览器能力底座。
- 边界:
- 它是框架不是平台:生产级的大规模并行、防检测、代理轮换,官方推荐用云端(这恰恰是它"云耦合"的体现)。
- 学习曲线陡:DOM 序列化 + watchdog + 判别联合,概念密度高于 LangGraph/OpenHands。
- 视觉类任务不是它的强项:它靠 DOM 文本,如果某个按钮是纯 canvas 绘制的,DOM 路线会失效——这是和 computer-use 路线的根本分界。
- 状态与 HITL 不是它的主场:它没有像 LangGraph 那样的检查点中断模型,长任务靠 compaction 续命,人工介入要自己接。
十七、如果让我给 browser-use 做一次工程评价
| 维度 | 评价 |
|---|---|
| DOM 序列化(感知层) | ★★★★★(全行业标杆) |
| 索引闭环(文本↔执行) | ★★★★★ |
| 事件 + watchdog 架构 | ★★★★★ |
| 动态判别联合(结构化输出) | ★★★★★ |
| 上下文经济学 | ★★★★★ |
| 多 provider 适配 | ★★★★☆ |
| 代码可读性 / 学习成本 | ★★☆☆☆ |
| 历史包袱 / 职责重叠 | ★★☆☆☆ |
| 离线纯净度(云耦合) | ★★☆☆☆ |
| 工程参考价值 | ★★★★★ |
最大的优点:把"Agent 怎么感知并操作真实网页"这件没人做好的事,做成了教科书级的工程。
最大的缺点:复杂度高、云依赖渗入、对学习者和离线用户不友好。
十八、不要抄 browser-use,要学 browser-use
你不需要在自己的项目里复刻六步 DOM 流水线、十几个 watchdog。但你要带走它的核心判断:
- 你的 Agent 面对的真实世界,需要先被"压缩成 LLM 能用的输入"——这比调模型参数重要得多。
- 给 LLM 的标记,最好同时是可执行的句柄——理解与行动共享同一份表示。
- 横切关注点多的时候,事件驱动比"加方法"优雅。
- 能动态生成的东西(工具 schema),不要手工维护。
真正优秀的 Agent 工程,永远是"只在问题真正出现时,引入对应的工程机制"——但 browser-use 给你展示了,当"感知真实界面"这个问题足够大时,机制可以深到什么程度。
十九、最终总结
browser-use 真正值得赏析的,不是"它让 LLM 会点按钮",而是它围绕"感知 + 执行"这两个端点,长出了一整套工程:
感知:四路数据融合 → DOM 序列化 → [N] 索引文本 + 截图 决策:判别联合 ActionModel → 结构化输出(Pydantic 校验) 执行:事件总线 → watchdog → CDP 真实交互 观察:页面变更检测(is_new / URL 变化 / 遮挡过滤) 上下文:单状态消息 + 压缩 + 前缀缓存 + token 成本追踪它和 computer-use 走的是两条路:一条让模型"看图猜坐标",一条把世界"翻译成可操作文本"。browser-use 证明了第二条路的工程深度——也顺带证明了:
在 Agent 时代,"如何把现实世界喂给模型"正在成为一个比"如何调模型"更硬的工程问题。
下一篇预告:至此,「Workflow Agent → Coding Agent → Browser Agent」三种视角已经齐了。下一步建议去看一个Research / SuperAgent 项目(如 DeerFlow),从"Agent 感知网页/操作文件"走向"Agent 多阶段研究、检索、写作",拿到第四种架构视角——也可以回到你自己的 Repo Doctor 项目,把这三篇的启示真正落地。