有一次我拿一个 AI 购物助手做现场演示:让它打开一个电商列表页,把某件商品加入购物车,再填好收货地址,最后点结算。原本预期一两分钟收工,结果我站在投影前等了足足九十秒,浏览器才像开了慢动作一样把下拉框缓缓展开,底下的同事已经开始刷手机了。
那次之后我就明白了一件事:AI 操控浏览器这条链路里,真正拖后腿的往往不是模型“想”得太慢,而是浏览器“演”得太慢。后来我把这套自动化全部切换到无头浏览器——也就是文章标题里说的“隐形浏览器”,同样一套任务,端到端耗时从四十多秒压到了四秒左右,差不多就是 11 倍的差距。这篇文章就把这个优化过程拆开讲清楚:慢在哪、快在哪、怎么复现,以及哪些场景里这套方案根本不适用。
1. 现场演示翻车之后:AI 操作浏览器到底慢在哪条链路上
1.1 大模型思考时间其实不是最大瓶颈
很多人一听“AI 操控浏览器慢”,第一反应就是大模型推理慢。这个想法对了一半。模型推理确实有耗时,但它只占整体链路中的一小段。举个例子:如果你让模型做的只是“读取当前页面结构,决定下一步点哪个按钮”,用文本模型处理一份精简过的 DOM 快照,几百毫秒到一两秒就能给出决策,这个开销是可控的。
真正的灾难往往发生在“让模型看图”这件事上。多模态模型每做一次决策,都要先给它截一张图,图片传到模型服务端,模型识别图像里的按钮位置,再返回像素坐标。这个过程的耗时直接和截图分辨率、图像压缩质量、模型服务端负载挂钩。实测下来,一张截图从生成到模型返回坐标,慢的时候能到三到五秒。你想想,一个十步操作的任务,光“看图说话”就要吃掉半分钟,这还只是定位,后面还有移动鼠标、点击、等待页面反馈。
所以我后来把优化思路从“换更大的模型”改成“让模型少看图”,这直接催生了后面要用 DOM 快照替代截图的方案。结论也很简单:模型思考是慢,但它慢得有限;浏览器为了“给人看”而做的那些渲染工作,才是真正的无底洞。
1.2 渲染与视觉定位占了八成耗时
有头浏览器(也就是我们平时双击打开的那种浏览器)在设计上是为了服务真实用户的。它的核心任务包括:解析 HTML、执行 JavaScript、计算样式、生成布局、绘制像素、合成图层,然后按每秒六十帧的节奏把画面推送到屏幕上。问题是,AI 操控浏览器的时候,屏幕上并没有坐着一个人盯着看。你让浏览器去跑完整渲染管线,相当于让一台冰箱一边制冷一边给路人播广告,纯属资源浪费。
更要命的是,很多自动化框架为了让操作更稳定,会在每次点击前后强制等待“页面视觉上稳定”。怎么判断稳定?常见做法是轮询截图对比像素差异,或者等待固定时间。这套机制在有头模式下尤其慢:一个本来几百毫秒就能完成的页面跳转,因为要等渲染完成、等动效结束、等延迟加载的图片出现,硬生生被拖到两三秒。
换句话说,AI 操作浏览器之所以慢,不是 AI 的数学算力不够,而是整条链路被“人眼视觉标准”绑架了。浏览器要把每个动作的中间过程都演一遍,AI 还要定期截屏确认演到哪了。这两件事加在一起,占了端到端耗时的八成以上。
2. “隐形浏览器”快在哪:无头模式不是简单把窗口藏起来
2.1 无头模式到底砍掉了什么
无头浏览器(headless browser)就是标题里说的“隐形浏览器”。它不是简单地把窗口最小化或者藏到后台,而是从内核层面就不创建可视化界面。你可能以为区别只是“没有窗口”,实际上它砍掉的东西要多得多:
- 不做屏幕合成(compositing),省掉 GPU 渲染和图层合并;
- 不运行动画帧循环,页面里那些 CSS 动效、滚动动画、轮播图不再空转;
- 不加载字体渲染和复杂的文本整形管线,这部分在富文本页面里开销很大;
- 没有显示输出,也就不需要维护一套持续推送画面的机制。
关键是,DOM 解析、JavaScript 执行、网络请求、事件回调这些逻辑核心全部保留。也就是说,网页该跑的代码照样跑,该发的请求照样发,只是“演给人看”的那部分全省了。对于一个 AI Agent 来说,它根本不需要“看”页面动起来,它只需要知道页面当前的状态,然后让页面状态变成它想要的样子。
这里要提一个选型细节:Chrome 的无头模式分新旧两代。旧版 headless 更轻量,启动更快,内存占用更低,但对现代网站的支持差一些,某些依赖 Canvas、WebRTC、扩展能力的页面会出问题。新版 headless(Chrome 112 以后的默认无头模式)和真实浏览器基本是同一个二进制,兼容性大幅提升,代价是比旧版稍微“胖”一点。我的建议是:优先用新 headless 保证兼容性,如果某个流水线对启动速度和并发数量极度敏感,再回头试旧 headless,用真实的操作序列验证一遍能不能跑通。
2.2 CDP 直连:去掉遥控器,直接按主板上的按钮
只开无头模式,速度提升大概在两到三倍,离 11 倍还差得远。第二个关键动作是切换操作方式。
传统自动化工具(包括早期的 Selenium 和部分 Playwright 用法)走的是一条“模拟真人”的路线:先移动鼠标到坐标,按下左键,松开左键,等待页面刷新。每一步都在模拟物理输入事件,本质上相当于你拿着一个遥控器站在电视机前面点按钮,遥控器还要先确认电视有没有收到信号。
CDP(Chrome DevTools Protocol)是另一条路。它允许你直接跟浏览器内核对话,跳过用户输入那一层,直接调用内部的 DOM 操作。Playwright 对 CDP 有很好的封装,你可以通过context.new_cdp_session(page)拿到会话,然后直接调用Runtime.evaluate执行 JavaScript,或者用Input.dispatchMouseEvent精准派发输入事件。这样一来,AI 不需要“假装成一个人”去操作浏览器,它只需要告诉浏览器“我要这个元素变成点击后的状态”,浏览器直接用内部管道完成。
打个比方:常规方案是让机器人用假手去按物理开关,CDP 方案是直接给电路板发一个高电平信号。结论一样,过程完全不同,速度当然也不一样。
3. 从 8 秒到 3.8 秒:我压下去的四个关键开销
3.1 资源拦截:图片、字体、媒体全部砍掉
无头模式开启后,页面加载还有一个巨大的隐藏开销:静态资源。一个普通的电商列表页,图片可能有几十张,每张几百 KB;再加上字体文件、视频封面、广告脚本。这些东西在 AI 操作任务里几乎全是噪音。AI 要的是商品名称、价格、按钮位置,不是图片的像素数据。
解决办法是在 Playwright 里对请求做路由拦截,把非关键资源直接 abort 掉:
import asyncio from playwright.async_api import async_playwright async def block_noise(route): if route.request.resource_type in ("image", "font", "media"): await route.abort() else: await route.continue_() async def main(): async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page() await page.route("**/*", block_noise) await page.goto("https://example.com/products", wait_until="domcontentloaded")注意wait_until="domcontentloaded"也很关键。默认的load事件要等页面所有资源加载完,包括那些被你 abort 掉的图片和字体;改成domcontentloaded只需要等 DOM 解析完成,和资源拦截配合起来效果最好。脚本文件、XHR 请求、接口响应不要拦,拦了页面功能就废了。
这个优化在图片密集的页面上立竿见影。我自己测过一个商品列表页,加载耗时从 4.2 秒降到了 0.9 秒,而且对后续操作没有任何影响,因为 AI 需要的信息全在 DOM 里。
3.2 持久化上下文:让浏览器“不关机”
第二个开销藏在重复启动里。很多人写自动化脚本时习惯每跑一个任务就launch()一个新的浏览器实例,用完再关。这相当于你每次点外卖都先开一次燃气灶、洗一次锅、切一次菜。浏览器冷启动消耗的时间在普通任务里可能不明显,但在 AI Agent 场景里会被放大:AI 的任务往往拆成多个阶段,阶段之间要多次访问同一个站点,如果每次都从零启动,缓存没了、Cookie 没了、Service Worker 重新注册,光这些就要浪费好几秒。
我的做法是:一个常驻的浏览器进程,配合持久化的用户目录复用登录态和缓存:
from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: context = await p.chromium.launch_persistent_context( user_data_dir="./browser_profile", headless=True, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", ) page = context.pages[0] if context.pages else await context.new_page() # 业务逻辑里可以循环创建新的 page,但不要关 contextlaunch_persistent_context会直接返回一个带持久化目录的 context,相当于一个始终开机的浏览器。第一次启动后,后续任务的 Cookie、LocalStorage、HTTP 缓存都还在,很多站点甚至连登录都不需要重新做。我在跑需要经过多页跳转的结算流程时,这一步省掉的时间非常可观。
另外一个小技巧:如果任务不依赖登录态,也可以只用browser.new_page()开轻量页面,但共享同一个 browser 实例,别轻易browser.close()。浏览器进程本身的常驻开销远比每次冷启动要小。
3.3 从“看图点坐标”到“读 DOM 报编号”
这是整套优化里最核心的一刀,也是拉开 11 倍差距的最大功臣。之前提到,AI 用多模态模型看截图定位,每一步要截图、传图、模型推理、返回像素坐标,整套流程三到五秒。我改成了这样:
- 每次页面状态变化后,用脚本自动采集当前页面的可交互元素清单;
- 清单里的每个元素都有一个唯一编号,字段包含元素类型(button、input、a、select)、可见文本、aria-label、name 属性等;
- 把这份清单连同任务目标一起发给文本模型,模型只输出“点击 5 号元素”“把 12 号输入框填成 xxx”这类指令;
- 执行器拿到编号后,通过 CDP 直接对元素操作。
这个过程把 AI 从“看图识物”变成了“读表决策”。文本模型处理几百行 JSON 格式的 DOM 快照,比多模态模型处理一张高分辨率截图要快得多,也更便宜,准确率还更高——毕竟截图经常被页面上浮动的弹窗、广告遮罩干扰,DOM 快照不会。
采集可交互元素清单的脚本大致是这样:
interactive_elements_js = """ () => { const els = document.querySelectorAll('button, a, input, select, textarea, [role="button"], [onclick]'); const result = []; let idx = 0; for (const el of els) { const rect = el.getBoundingClientRect(); if (rect.width < 2 || rect.height < 2) continue; const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 80); result.push({ id: idx++, tag: el.tagName, type: el.getAttribute('type'), name: el.getAttribute('name'), text: text, visible: rect.top >= 0 && rect.bottom <= window.innerHeight }); } return JSON.stringify(result); } """用文本模型输出结构化指令,执行器解析后调用 CDP 操作。这样做还有个额外好处:模型输出的是语义化指令,不是像素坐标,所以页面尺寸变化、窗口缩放、不同分辨率都不会导致定位失效。
3.4 合成事件替代 element.click():避免框架监听失效
这里是我踩过的一个大坑,必须单独说。一开始我图省事,让执行器直接调element.click()触发点击,结果发现两个问题:一是很多 React/Vue 页面里的按钮监听的是完整的 pointer 事件序列,单纯的.click()有时候触发不了;二是某些按钮的点击效果依赖 mousedown 和 mouseup 之间的状态变化,直接.click()跳过了中间态,导致页面逻辑走到错误分支。
后来改用 CDP 的Input.dispatchMouseEvent派发真实的合成事件。它模拟的是输入层的事件,虽然还是“合成”的,但事件序列和真实鼠标一致,前端框架识别不出来:
async def click_by_cdp(page, element_handle): box = await element_handle.bounding_box() x = box["x"] + box["width"] / 2 y = box["y"] + box["height"] / 2 cdp = await page.context.new_cdp_session(page) for etype in ("mouseMoved", "mousePressed", "mouseReleased"): await cdp.send("Input.dispatchMouseEvent", { "type": etype, "x": x, "y": y, "button": "left", "clickCount": 1, "buttons": 1 if etype == "mousePressed" else 0, })这个改动解决了我跑一个 Vue 3 管理后台时“按钮点了没反应”的怪问题。如果你用 Playwright 自带的locator.click()没出过这种毛病,那说明那个页面的事件绑定比较传统;但只要遇到框架层面的事件分发问题,直接用 CDP 合成事件是最稳的兜底方案。
4. 能上隐形浏览器的场景与第一批翻车现场
4.1 适合的场景:数据采集、批量操作、高并发页面任务
隐形浏览器的核心优势是省掉渲染、支持高并发、通过 DOM 快照直接交互。最适合它的场景有三类。
第一类是结构化数据采集。商品价格监控、公开信息抓取、竞品页面快照,这些任务要的是最终数据,不是“页面在屏幕上长什么样”。用无头模式加上资源拦截,采集速度能翻好几倍,而且同一台机器可以并行跑几十个上下文,这在有头模式下几乎不可能——你没有那么多屏幕,GPU 也顶不住。
第二类是批量表单操作。比如批量注册、批量填写某个后台系统、批量导入导出数据。这类操作交互模式固定,动作重复率高,非常适合 DOM 快照 + 编号指令的方式,速度快且不容易出错。
第三类是 AI Agent 工作流里不需要“展示过程”的中间环节。比如让 AI 去某个后台查询订单状态、核对价格、下载报表,这类任务用户只看最终结果,中间过程完全可以无头化。
4.2 会翻车的场景:强反爬、重交互可视化、必须留痕的环节
隐形浏览器不是万能的,第一批翻车现场我基本都见识过,帮你提前排雷。
首先是强反爬站点。无头浏览器再“隐形”,它的指纹特征和有头模式还是有差异,很多银行、政务、票务类网站的防护策略对无头 UA 和 CDP 特征非常敏感。常见症状是弹验证码、滑块校验,或者直接返回一个假的空页面。我踩过的坑包括:某票务平台在无头模式下即使登录成功,下单接口也会返回风控错误。这种场景只能对有头模式做降级,或者在无头模式下尽量隐藏自动化特征——把 UA 设置成真实 Chrome 的 UA,禁用自动化扩展痕迹,关闭--enable-automation相关开关。
其次是强交互可视化页面。比如那些依赖 Canvas/WebGL 做图表展示的页面,无头模式虽然也能跑,但如果你需要的是“把图表渲染结果截图发给用户看”,那无头模式反而要多绕一圈,因为它不产生画面。这类任务要么退回有头模式,要么用无头模式跑完逻辑后用page.screenshot()单独输出结果图——注意无头模式截图走的是软件渲染,速度慢,适合低频执行,不适合每一步都截。
还有一个容易忽略的问题:某些页面会在beforeunload或者跳转前拦截,如果你对这类页面直接操纵 DOM,会绕过一些前端埋点逻辑,导致后台统计缺失。如果业务方需要精确的行为留痕,就得保留一部分“人工操作”的痕迹,不能全量用 CDP 直连。
5. 复现 11 倍:一套不自欺欺人的基准测试口径
5.1 统一测试脚本和计时口径
很多时候大家测优化效果会犯一个错误:只测浏览器打开一个页面的时间。这个口径完全反映不了真实差距,因为无头模式优化的重点是“整套操作流程”,而不是单次页面加载。正确做法是定义一套完整的、可复现的业务动作序列,端到端计时。
我建议这样设计测试:
- 选一个真实业务页面,不要用
about:blank或者example.com,最好是你实际自动化任务里的那个页面; - 定义一条固定操作链,比如“搜索商品 → 点击第一个结果 → 加入购物车 → 进入结算页 → 填入地址 → 点击提交”;
- 整条链路只计一次总耗时,从发出第一个指令开始,到最后一个动作完成且页面反馈出现为止;
- 每条操作链跑完一次后清空状态,重新开始,取至少五次的平均值。
最重要的是口径统一:两种模式里,AI 模型拿到的是同样的输入信息(要么截图,要么 DOM 快照),执行器的动作序列也完全一致,只是浏览器模式和交互方式不同。如果连输入都不一样,那测出来的差距就不能归因到浏览器方案上。
5.2 我实测的一组对比数据
下面这组数据来自我自己的测试环境,硬件是普通云主机四核八线程,浏览器是 Chromium 124 内核。测试任务是商城里“搜索 → 详情 → 加购 → 结算页 → 填地址 → 提交”这条六步链路,其中涉及一次下拉框选择、一次多行文本填写。结果如下:
| 方案 | 端到端耗时(均值) | 相对倍数 |
|---|---|---|
| 有头模式 + 视觉模型定位(截图) | 42.6 秒 | 1.0x |
| 无头模式 + DOM 编号定位 | 12.1 秒 | 3.5x |
| 无头模式 + 资源拦截 + DOM 编号 | 6.4 秒 | 6.7x |
| 无头模式 + 资源拦截 + 上下文复用 + DOM 编号 + CDP 合成事件 | 3.8 秒 | 11.2x |
从这组数据里能看出,每一步优化都在往 11 倍方向叠加:无头模式本身贡献了 3 倍多,资源拦截再砍掉一半,持久化上下文和 CDP 精确操作再砍掉三分之一。所以标题里的 11 倍不是某个单一开关的功劳,而是一整套方案叠加后的结果。
也要说实话:页面类型对收益影响很大。如果是重交互但轻资源的后台管理系统,资源拦截的收益会变小;如果是图片非常多、接口很轻的资讯站或者电商列表,资源拦截的收益就很大。如果任务本身以等待后台接口返回为主,浏览器怎么优化都不可能有数量级变化,瓶颈不在渲染。所以你要根据自己任务的负载特征判断期望值,别看到哪个页面没到 11 倍就说方案无效。
我自己后来几乎把所有 AI 浏览器任务都默认跑在无头方案上,只有需要给客户实时演示过程时才开有头。一个小技巧是:接到一个新项目,先在 headless 里跑一遍核心操作,打印每个动作的耗时明细,占比最大的那一项往往就是你能优化的方向。这套方案的边界也很清楚——它解决的是“AI 在浏览器里干活”的速度问题,不是“所有网页秒开”的问题,别指望开着隐形浏览器,什么页面都跟着起飞。