作者:互联网 时间: 2026-07-11 09:07:06
AI Agent 操作浏览器的方式分两种。第一种是截图发给 LLM,LLM 返回一个 (x, y) 坐标,让浏览器自动化工具在该坐标模拟点击;第二种是把网页转换成一棵带编号的文本树,LLM 从文本树里选编号,浏览器自动化工具按编号定位到真实的 DOM 元素再执行操作。
目前主流的 AI Agent CLI(Gemini CLI、Claude Code、Codex 等)默认采用的基本是第二种:文本树 + 编号,在需要视觉识别的情况下才使用第一种。本文将配合 x-code-cli 的源码来讲解文本树和视觉这两种实现方式的原理。
很多 AI Agent 都自带 webFetch 工具来抓取网页内容。但 webFetch 本质上是无状态的 HTTP 请求——没有登录状态、不会执行 JavaScript、也不支持多步交互。例如下面这些场景它就处理不了:
webFetch 没有 cookie/session,拿不到登录后的内容。<div id="app"></div>,真实内容要等浏览器执行完 JS 才能渲染出来。这些正好是能用上 browser use 的场景:通过 Playwright 这类浏览器自动化工具启动一个真实的浏览器,执行登录、渲染、点击、填表等操作,就像人一样操作页面。
反过来,如果只是读一篇公开的静态文章,webFetch 抓取一次内容就足够了;如果是搜索信息,可以使用 webSearch 工具。简单来说:读取静态公开页面内容,webFetch 就够了;如果是需要登录、需要 JS 渲染或者需要多步交互的场景,才需要使用 browser use。
用户不需要自己判断什么时候该用 webFetch,什么时候该用 browser use。LLM 会根据工具描述自行判断当前任务应该用 webFetch 还是 browser use。x-code-cli 里名为 browser 的子 agent 的工具描述是这样写的:
LLM 读到这段描述后,会根据用户的需求自行决定是否委派给这个子 agent。
当然,这主要取决于 LLM 的判断能力和工具描述的质量。如果描述写得足够清楚并且 LLM 是强模型,委派基本都能命中;但如果页面“看起来像静态”或 LLM 偏弱,可能会用 webFetch 导致抓不到内容。
下面让我们用一个真实例子来看看实际效果(注意:browser use 不是默认开启的,需要先使用 /browser on 命令手动启用或配置 browser.enabled: true)。
在终端启动 x-code-cli 后输入以下命令:
这个任务 webFetch 工具是完成不了的。去哪儿网是纯 SPA 站点,航班数据靠 JS 动态加载,而且需要先输入出发地、目的地、出发日期,最后点击搜索,这四步交互缺一不可。webFetch 连第一步都过不去,更不用说后面的步骤了。
在上面输入的这条命令里,没有提到“浏览器”也没有提到“browser use”,都是靠 LLM 自己判断应该使用哪个工具。
LLM 根据 browser 子 agent 的工具描述做出判断:SPA、需要多步交互,应该委派给 browser 子 agent。brower 子 agent 会静默启动一个新的 chrome 进程,不会打扰到用户正在进行的活动。
Browser 子 agent 打开去哪儿首页,填表单、点搜索,等搜索结果加载完后获取文本树快照——航班号、时间、价格都在快照里,LLM 直接提取。
继续用去哪儿搜机票这个例子。在浏览器打开首页后,LLM 到底“看到”了什么?
页面里有大量样式、脚本和 DOM 节点,但发给 LLM 的并不是这些原始内容。经过 Playwright MCP server 的转换,LLM 收到的是这样一段 YAML 文本:
- tab "国内机票" [ref=e2] [selected] [cursor=pointer]- tab "国际·港澳台机票" [ref=e3] [cursor=pointer]- radio "单程" [ref=e5] [checked] [cursor=pointer]- radio "往返" [ref=e6] [cursor=pointer]- textbox "出发城市" [ref=e8]: 北京(BJS)- button "换" [ref=e10] [cursor=pointer]- textbox "到达城市" [ref=e12]: 上海(SHA)- textbox "出发日期" [ref=e15]: 2026-07-12- button "搜索" [ref=e20] [cursor=pointer]
LLM 不处理像素,也不解析 HTML,它拿到的是这棵语义树。树里每个可交互元素都带了一个 [ref=eNN] 编号:e8 是出发城市输入框,e12 是到达城市输入框,e15 是出发日期,e20 是搜索按钮。LLM 通过工具调用来操作页面——比如调用 browser_fill_form 把 e8 设为“北京”、e12 设为“上海”,再调用 browser_click 点击 e20,搜索就触发了。搜索完成后页面跳转到结果页,MCP 服务端返回一份新快照,里面是完整的航班列表——厦门航空 MF8561 07:50-09:45 ¥400、东航 MU9192 20:45-23:10 ¥420、中联航 KN5977 20:50-22:55 ¥420...
LLM 直接从中提取出最便宜的 3 个就行了,然后输出到终端。browser use 的大部分逻辑都围绕这棵树展开。后面几节将拆解它是怎么生成的。
这两种方式的区别在于 LLM 通过什么方式获取页面信息、如何定位要操作的元素。
方式 A:截图 + 坐标(视觉 / computer use)
把页面截图发给 LLM,LLM 返回一个 (x, y) 坐标,浏览器自动化工具就在该坐标模拟点击。这种方式必须用多模态模型(支持视觉识别,价格更高),坐标不稳定(页面改版或分辨率变了就失效),而且推理速度慢。但 canvas 渲染的应用、游戏、视频、远程桌面这类内容没有 DOM 语义,文本树是空的,只能采用视觉方式。Anthropic 的 Computer Use、OpenAI 的 Operator 采用的就是这种方案。
方式 B:文本树 + 编号(无障碍树 + ref)
把页面转换成带编号的文本树,LLM 会根据快照中的 ref 编号发起工具调用(如 browser_click 点击 e20 搜索按钮),浏览器自动化工具按编号找到对应的真实 DOM 元素并执行操作。这个方案不需要视觉能力,任何 LLM 都可以做到,包括 DeepSeek 这种纯文本模型。
这两种方式不是二选一的,一般是混合使用:文本树负责常规操作,截图在文本树覆盖不到的场景做补充。
每个主流浏览器内核里都维护着一棵无障碍树(accessibility tree)——根据 DOM 树、CSS 计算样式、ARIA 属性综合生成的语义结构。它原本是给屏幕阅读器、语音控制等辅助技术用的,存在了十几年,远早于 AI agent。browser use 直接复用了这种技术:无障碍树天生就是为程序解析页面内容而设计的,给 LLM 用正好合适。
以 <button>提交</button> 为例,它在无障碍树里会被解析为以下内容:
button(来自 <button> 标签的原生语义)"提交"(来自元素的文本内容)其中角色和名字是浏览器推断出来的,主要有两个依据:
<button> 被识别为按钮、<a> 被识别为链接、<input> 被识别为输入框。哪怕一个 ARIA 属性都没写,浏览器照样能识别出角色和名字。role="dialog"、aria-label="搜索" 这些属性告诉浏览器“这个 <div> 是个对话框”。ARIA 是补充,不是必需的。读取无障碍树需要一个浏览器自动化工具。这里用的是 Playwright——微软开源的浏览器自动化框架,支持 Chrome、Firefox、WebKit,可以用代码控制浏览器做点击、填写、截图、读取页面内容等操作。前端开发者常用它写 E2E 测试,在 AI Agent 场景里它被用来当作操控浏览器的底层引擎。
Playwright 本身是一个 Node.js 库,LLM 不能直接调用它。@playwright/mcp 这个 npm 包的作用就是把 Playwright 的能力包装成 MCP 工具——相当于给 Playwright 套了一层 LLM 能理解的接口。前面 YAML 快照示例里把 HTML 转成语义树的就是它。
我们来看一下整个读取流程:
LLM 调用 `browser_snapshot` 工具↓@playwright/mcp 这个 MCP server 进程收到请求后,调用 Playwright 的 page.ariaSnapshot({ mode: "ai" })↓Playwright 通过 CDP(Chrome DevTools Protocol)发送请求:Accessibility.getFullAXTree↓浏览器内核返回完整的无障碍树(一个巨大的 JSON)↓Playwright 做三件事:1. 裁剪:去掉不可见节点、纯装饰节点2. 编号:给每个可交互元素分配 ref 编号(e1, e2, e3...)3. 序列化:渲染成 YAML 格式文本↓YAML 快照作为工具返回值发送给 LLM
CDP 是 Chrome 暴露给外部工具的调试协议——Chrome DevTools 的检查元素、Network 面板底层用的就是 CDP。Accessibility.getFullAXTree 是 CDP 提供的 API 方法,专门用于读取页面的完整无障碍树,任何能连接 CDP 的工具都能调用。
为什么不直接用 DOM? 因为 DOM 的内容过于庞杂。一个普通页面可能有几千个节点,其中大量的节点是样式容器(<div class="flex gap-2 px-4">)、SVG 图标、隐藏元素,这些内容对 LLM 来说是无效信息。无障碍树对这些内容进行了过滤:只保留有语义的内容,而且用角色统一了表达方式——不管 HTML 怎么写,按钮就是 button,链接就是 link。
我们来看一下 ariaSnapshot({ mode: "ai" }) 的序列化逻辑:
[ref=eNN]。纯文本节点、装饰性图片不标注——LLM 不需要点击它们。<div><ul><li> 更容易解析。- role "name" [ref=eNN] 这种格式,比如 - button "提交" [ref=e18]。[cursor=pointer]。不是所有可交互元素都有这个标注——比如 <input> 有 ref 但通常没有 [cursor=pointer],只有 <button>、<a> 这类计算样式为 cursor: pointer 的元素才会有这个标注。经过处理后,几百 KB 的 HTML 变成了几十个节点的 YAML。信息密度高,几乎全是有效信息。
只要 HTML 写得规范——用 <button>、<a>、<input> 这些语义标签——浏览器就能识别出正确的角色和名字。所以写页面要尽量遵循 HTML5 语义化,大多数现代页面都能正常工作。
一般会出问题的是以下这两种情况:
<div onclick="..."> 手写控件,又没有编写 ARIA 属性。浏览器无法识别它是按钮,树里只有一个 generic 节点。遇到这两种情况时只能改用视觉方式(详见后面视觉处理章节)。
让我们回到去哪儿搜机票这个例子,下面从 LLM 的视角拆开每一步:
browser_snapshot 工具,获取首页的 YAML 快照。快照中包含出发城市、到达城市输入框和搜索按钮等可交互元素,以及它们的 ref 编号。browser_fill_form 在 e8(出发城市)填入“北京”、e12(到达城市)填入“上海”,browser_click 点击 e20(搜索按钮)。注意步骤 4 里的自动返回新快照是这套机制的核心——像 browser_click 这类会改变页面内容的工具,操作执行和新快照的获取在同一个来回中完成,LLM 不需要每次操作后手动再调一次 browser_snapshot。
[ref=e20] 这类编号不是 LLM 自己编的,而是快照生成时,Playwright 内部调用 computeAriaRef 函数来分配的。这个函数维护一个递增计数器,每遇到一个可交互元素时计数器加 1,然后拼接出 e1、e2、e3 这样的 ref 字符串,只给可见且可交互的元素分配。
分配后的编号会作为内部属性 _ariaRef 缓存在对应的 DOM 元素上。只要元素的角色和名字没变,快照都会复用同一个编号——编号在多次快照之间是相对稳定的。
分配编号的同时,computeAriaRef 还会建立一张双向映射表(存储在快照对象内部):elements 是 ref->DOM,refs 是 DOM->ref。
LLM 调用 browser_click 点击 e20 后,Playwright 为这个场景注册了一个名为 aria-ref 的自定义选择器引擎。当调用 page.locator("aria-ref=e20") 时,它不使用 CSS 选择器,而是直接从快照内部的双向映射表中取出 e20 对应的 DOM 元素,并检查该元素还在不在页面上(isConnected)。如果元素已经不在页面上,就抛错:Ref e20 not found in the current page snapshot. Try capturing new snapshot.——这就是“编号过期了需要重新获取快照”的原因。
browser use 涉及的所有操作(点击、填写、截图、获取快照等)都是通过 MCP(Model Context Protocol)暴露给 LLM。MCP 是一套统一的外部工具调用协议——@playwright/mcp 就是一个 MCP server,它把 Playwright 的浏览器自动化能力封装成一组 MCP 工具。
这些工具按能力分组定义。其中和页面交互相关的工具(click、fill_form、type 等)共享同一套参数:一个 element(人类可读的描述,当权限模式为“每次询问”时,这个描述会显示在确认对话框中)加一个 target(快照里的 ref 编号)。
@playwright/mcp 启动时默认只激活 core 这组能力(用 glob 匹配 core*):snapshot、click、type、fill_form、navigate、tabs、screenshot、wait_for 等——注意 screenshot 也在默认组里,不需要开启视觉模式。只有加了 --caps vision,才会多出一组基于坐标的工具(mouse_click_xy、mouse_drag_xy、mouse_move_xy、mouse_down、mouse_up、mouse_wheel)。所以默认就是文本树方案,坐标操作需要主动开启。
除了工具本身,子 agent 的系统提示对截图的使用做了明确的限制。x-code-cli 的 BROWSER_VISION_CAPTION_ADDENDUM 常量里写了这样一条规则:
大意是:始终以无障碍树快照为首选,它精确、成本低、ref 编号稳定;能在快照里读到的东西不要截图。截图只用于快照覆盖不到的场景,比如 canvas、图表、纯视觉元素等。
@playwright/[email protected] 现在只是一个轻量的转发封装——核心的快照生成、元素定位、ref 编号逻辑已经全部合并进 playwright-core 包里了。所以引用 @playwright/mcp 本质上就是在用 Playwright 官方提供的浏览器自动化能力。
x-code-cli 把 browser use 做成了一个独立的子 agent,而不是主 agent 的工具。
browser use 相关的工具有 20 多个(例如 snapshot、click、type、fill、navigate、tabs、screenshot 等等)。如果把它们全放进主 agent 的工具列表,会引起下列问题:
x-code-cli 的做法是:将这些工具注入到 browser 子 agent 的私有上下文里,主 agent 通过 task(subagent_type: "browser", ...) 委派任务。子 agent 执行完成后只返回最终的文本结果给主 agent,中间的快照、截图、多步操作对主 agent 不可见。这种子 agent 隔离架构在 Gemini CLI 等产品中也有类似应用。
browser 子 agent 使用的 MCP server(底层是 @playwright/mcp)在第一次被使用时才启动(npx -y @playwright/mcp@latest --browser chrome),不是 CLI 启动时就直接启动。启动成功后,MCP server 的连接被缓存下来,同一个 CLI 会话中后续的 browser 子 agent 调用都会复用同一个连接和浏览器实例,避免反复启动的开销。
如果浏览器或 MCP server 连接断开(用户关了 Chrome 或进程崩溃),缓存会自动清除,下次任务会重新连接。连接失败不会被缓存——用户装好 Chrome 后重试就行,不需要重启 CLI。
let cached: BrowserMcp | null = nulllet connecting: Promise<BrowserMcp> | null = nullexport async function getBrowserMcp(vision = false): Promise<BrowserMcp> {if (cached) return cachedif (connecting) return connectingconnecting = connectBrowser(vision)try {return await connecting} finally {connecting = null}}
启用 browser agent(/browser on 或配置 browser.enabled: true)本身就是用户的授权行为。所以除了敏感工具外,所有 browser 工具在启用时就获得 session 级别的预批准,不会每个 navigate/click/snapshot 都触发一次确认。
具体分三档:
browser_run_code_unsafe(在 MCP server 进程里执行任意 Node 代码,可以读写文件系统)不会注册到子 agent 的工具列表中browser_evaluate(在页面里执行 JS,能读 cookie / localStorage)每次调用都要确认文本树覆盖不了的情况主要有两种。一种是 canvas 渲染的内容——应用界面、游戏、图表,这些没有 DOM 结构,无障碍树取不到任何语义,快照基本是空的。另一种是依赖外观才能区分的目标,比如用户说“点那个黄色按钮”或“看看红色的报错”,文本树里有文字和角色,但没有颜色和位置信息,无法定位。这两类情况都需要通过截图 + 坐标来补充。
在实际使用中,文本树工具和截图工具同时暴露给 LLM,由系统提示的规则引导 LLM 根据页面特征自行选择用哪个工具,而不是由代码自动切换。
视觉能力是模型级别的属性:Claude、GPT-4o、Gemini、千问 VL、Kimi 支持图片输入,DeepSeek 是纯文本模型不支持。同一家供应商的不同模型也可能不一样(千问 Max 是文本模型,千问 VL 才支持图片),所以只能按具体的模型 ID 判断。
启用条件有两个:一是用户没有通过 config.browser.vision = false 显式关闭视觉功能;二是当前主模型支持图片输入(由 modelSupportsVision 函数根据模型 ID 判断,比如 GPT-4o、Claude、Gemini 返回 true,DeepSeek 返回 false)。两个条件都满足时,MCP server 启动时加上 --caps vision,在文本树工具之外额外暴露截图和坐标点击工具。
子 agent 的系统提示根据视觉能力分三种:
这里有一个接入问题:只有 Anthropic 的 API 支持在 tool_result 里直接放图片;OpenAI 兼容的供应商(DeepSeek、Kimi、千问、GLM、xAI)会把 tool_result 内容 JSON.stringify,图片就变成一长串 base64 文本——LLM 无法从中提取视觉信息,还可能直接超出上下文长度限制。
所以截图的送达分两条路径:
用于描述的视觉模型按成本优先选择:Gemini 2.5 Flash(免费额度大)-> GLM-4V Flash(免费)-> Qwen-VL Plus -> GPT-4o Mini -> 其他。加了超时机制,描述太慢就降级只用文本树。
x-code-cli 对截图参数做了硬编码处理:
type = 'jpeg'(减小传给描述模型的数据体积)fullPage(禁止全页长截图,避免超大尺寸截图)filename(不带文件名时截图 inline 返回,带了会写成文件,LLM 拿不到图片内容)1280x800browser 子 agent 在执行多步任务时会反复调用 snapshot 和 screenshot,每次调用的返回值都会留在对话历史里。这些旧的快照和截图对后续操作已经没有参考价值,但 LLM 每轮请求都要把它们全部重新发送。如果一个任务调用了 10 次 snapshot,历史里就会积压 10 份快照,每份都可能包含几百个节点——而这些过期的快照对后续操作毫无价值,纯粹是多出来的 token 开销。
x-code-cli 在每轮请求发出前,把旧的 snapshot 和 screenshot 的工具返回值替换成一行占位符,只保留最新的一份:
const placeholder = `[Older ${suf} result dropped to save context — only the most recent is kept.]`
这个思路参考了 Gemini CLI 的做法(Gemini CLI 通过 onBeforeTurn 钩子在每轮模型调用前将所有非最新的快照替换为占位符)。x-code-cli 只对 browser 子 agent 开启这个机制(通过 AgentOptions.collapseStaleToolResults 控制)。
截图的 token 成本按分辨率计算(模型将图像切分为固定大小的 patch 并逐块编码),与文件大小无关。所以把截图压成 JPEG 减小的只是传输体积和耗时,而不是 token 消耗。省 token 的具体手段前面已经提过,此处不再重复。
使用描述路径时,图片不进主 agent 的对话历史——只在借来的视觉模型的一次性调用里出现。使用 Anthropic inline 路径时,Anthropic 服务端会自动把图缩到约 1568px,一张大约一千多 token。
仅提供工具是不够的——LLM 需要明确的规则来约束操作行为。x-code-cli 的 browser 子 agent 的系统提示里指定了以下规则:
这些规则在其他 AI Agent CLI 的产品中也有类似体现,属于 browser use 场景的通用最佳实践。
内容密集的页面,一张快照可能有几百个节点,单次请求就可能消耗几千 token。多步任务会反复调用 snapshot,如果不做处理,这些快照的 token 费用会随步骤数量线性增长。前面讲的只保留最新快照是最有效的省 token 手段。
<div onclick> 手写的控件如果没有 ARIA 属性,树里也只有 generic 节点,LLM 无法识别它是什么。这类场景只能靠视觉方式补充,但视觉方式要求多模态模型,成本更高且坐标不够稳定。@playwright/mcp 支持 Chromium、Firefox、WebKit 三个引擎,覆盖了桌面端的主流浏览器。但不支持真实的移动端浏览器(Safari on iOS、Chrome on Android),移动端场景只能通过设置 viewport 和 user-agent 模拟,无法覆盖原生 app 内嵌的 WebView。| 产品 | 浏览器方案 | 视觉处理 | 特点 |
|---|---|---|---|
| x-code-cli | @playwright/mcp,树为主(browser_snapshot + ref),视觉按模型能力自动开启 | 非 Anthropic 使用 caption(借视觉模型转成文字描述) | 跨供应商,纯文本模型也能用 |
| Gemini CLI | 内置 browser_agent,底层 chrome-devtools-mcp + puppeteer-core,树为主(take_snapshot + uid) | analyze_screenshot 把识别和执行分开,图只进视觉模型的一次性调用,不进 browser agent 对话历史 | 内置子 agent 架构,支持 persistent / isolated / existing 三种会话模式 |
| Claude Code | claude-in-chrome(Chrome 扩展通过 Native Messaging 通信)+ 桌面级 computer use(@ant/computer-use-mcp,仅 macOS) | computer use 为纯视觉截图 + 坐标方案 | 扩展方案可复用用户已登录的 Chrome 会话(cookie、OAuth) |
| Codex CLI | 无内置 browser use,用户可通过 MCP server 配置接入 | 取决于配置的 MCP server | Rust 实现,通用 MCP 框架支持 |