生产级浏览器自动化:别让 AI 全程开车——Playwright + browser-use + CDP 三层架构全链路拆解
摘要:浏览器自动化这东西,2026 年最容易踩的坑,是把所有流程一股脑丢给 AI Agent。Demo 跑得飞起,上线三天被一个改了 class 名的按钮干翻。本文拆解一套在多个生产系统里验证过的「三层架构」:确定性层用 Playwright 兜稳定流程,探索层用 browser-use / Stagehand 处理会变的、需要理解的步骤,控制层用 CDP 直接抓网络、改指纹、拿底层状态。文章配 6 组可直接运行的代码(Python + TypeScript),从登录、抽取、Agent 探索、CDP 抓包到混合编排,最后给出性能优化与成本控制的工程清单。
一、背景:为什么你的 AI 浏览器 Agent 上线即翻车
2026 年,几乎每个团队都在做同一件事:让 AI 帮我们操作网页。登录内部系统导出报表、在电商后台批量改价、去招标网站盯标、把 SaaS 数据搬回自己的数仓。
需求很朴素,但落地时画风通常是这样的:
# 看起来很酷,但生产环境翻车是常态
from browser_use import Agent
from langchain_openai import ChatOpenAI
agent = Agent(
task="登录 XX 系统,找到今天的订单,导出 Excel,发邮件给老板",
llm=ChatOpenAI(model="gpt-4o"),
)
await agent.run()
这段代码在作者的演示视频里完美运行:AI 自己点登录、自己找订单列表、自己点导出。观众直呼「未来已来」。
然后你把它接进生产,第一天没事,第二天没事,第三天网站发了个版本,把那个「导出」按钮的 class 从 btn-primary 改成了 btn-export-v2。AI 在看截图、在犹豫、在重试,重试三次后超时,任务失败。你半夜被报警叫醒,发现 200 个订单没导出来,老板的邮件没发出去。
这不是个例。我在落地多个生产级浏览器自动化系统之后,总结出一个反直觉的结论:
「AI 全程开车」是浏览器自动化最贵的架构。
原因有三:
- LLM 调用又慢又贵又不可复现。 每一步「看页面、想下一步」都要一次模型推理,几十步的任务就是几十次 API 调用。同样一个页面,模型这次选对了按钮,下次可能因为页面多了一个弹窗就选错。
- 网页是会变的。 人类写死选择器(selector)很脆弱,但 AI 靠视觉理解更脆弱——它依赖截图和渲染,渲染一变它就懵。
- 失败不可诊断。 AI 失败了,你拿不到「第几步、哪个元素、为什么」。它只会告诉你「我尽力了」。
那正确的姿势是什么?答案不是「不用 AI」,而是把 AI 放到它真正擅长的地方,把稳定的部分留给确定性代码。这就是本文要拆解的三层架构。
二、核心概念:确定性 vs 探索性,以及那条隐形的分界线
浏览器里的操作,本质上分两类:
1. 确定性操作(Deterministic)
「打开这个 URL、在用户名框输入 admin、点击登录按钮、等待列表出现」。这类操作有固定的路径、固定的选择器(或者至少是稳定的语义锚点),不需要「理解」页面,只要忠实执行。
这类操作的正确实现永远是写死的代码,不是 AI。它的优势是:快(本地执行,零网络往返)、便宜(不要钱)、可复现(同样的输入永远同样的输出)、可诊断(报错的堆栈清晰)。
2. 探索性操作(Exploratory)
「找出这个页面里最便宜的商务舱机票」「从这篇文章里总结出三个关键论点」「如果弹出了验证码就处理一下」。这类操作需要理解页面语义、需要决策、路径不固定。
这类操作才该交给 AI。即便在这里,AI 也不是「自由发挥」,而是被框在一个狭窄的子任务里。
3. 控制性操作(Control)
「别解析 DOM 了,直接拦截它背后的那个 JSON 接口」「把浏览器的 WebGL 指纹改成一台真实 Mac」「等这个 XHR 回来再继续」。这类操作游走在浏览器内核层面,Playwright 的标准 API 够不着,需要直接发 CDP(Chrome DevTools Protocol)命令。
三层架构的划分
| 层 | 技术 | 负责什么 | 什么时候用 | 成本 |
|---|---|---|---|---|
| L1 确定性层 | Playwright(写死脚本) | 已知的、稳定的流程:登录、导航、固定字段抽取 | 80% 的步骤 | 免费、毫秒级 |
| L2 探索层 | browser-use / Stagehand + LLM | 会变的界面、需要语义理解的步骤 | 15% 的步骤 | 一次调用几秒、几分钱 |
| L3 控制层 | CDP(通过 Playwright 的 CDP session) | 抓网络、改指纹、拿底层状态、绕过反爬 | 5% 的逃生舱操作 | 免费、但难写 |
记住这个比例:默认用 L1,L2 只补位,L3 是逃生舱。 下面逐层拆开讲,并配可运行代码。
三、架构分析:三层如何咬合在一起
一个完整的生产任务,往往长这样:
[启动]
└─ L1 Playwright:打开登录页,输入账号密码,点击登录(确定性、快)
└─ 登录态拿到,存到浏览器 context(cookie/localStorage)
└─ L2 browser-use:拿着登录态,去「找今天待处理的订单」并点进去(探索、会变的列表)
└─ L3 CDP:进入订单详情后,直接拦截背后 /api/order/detail 的 JSON(跳过 DOM 解析)
└─ L1 Playwright:把 JSON 写回数据仓库(确定性、快)
关键设计点:
- 登录永远走 L1。 登录是固定流程里最固定的,反而最容易因为「AI 多点了一次」出事。让它确定性,再用持久化 context 把登录态复用给 L2。
- L2 拿 L1 的登录态。 不要各跑各的浏览器。browser-use 和 Stagehand 都支持「接管一个已经存在的浏览器 / context」,这是省下大量重复登录和风控的关键。
- L3 是 L2 的加速器,不是替代品。 当 L2 已经定位到目标页面,与其让 AI 从 DOM 里抠数据,不如直接抓它背后的 API 响应——又快又准又不怕改版。
下面每一层都给一组能跑的代码。
四、代码实战
4.1 L1 确定性层:Playwright 登录 + 抽取(Python,稳定优先)
先装依赖:
pip install playwright
playwright install chromium
最稳妥的登录流程,永远用语义锚点而非脆弱的 CSS 路径。优先用 getByRole / getByLabel,它们是面向无障碍树的定位,比 div > div:nth-child(3) > button 抗改版得多。
# login_and_scrape.py —— L1 确定性层
import asyncio
from playwright.async_api import async_playwright, TimeoutError as PWTimeout
async def login_and_pull_orders(email: str, password: str):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
# 用持久化 context,登录态会落盘,下次直接复用,免去重复登录触发风控
context = await browser.new_context(
user_agent=("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0 Safari/537.36"),
viewport={"width": 1440, "height": 900},
)
page = await context.new_page()
await page.goto("https://example-shop.internal/login", wait_until="networkidle")
# 语义定位:按 label / role,而不是 class
await page.get_by_label("邮箱").fill(email)
await page.get_by_label("密码").fill(password)
await page.get_by_role("button", name="登录").click()
# 关键:显式等待「登录成功」的标志,而非 sleep
try:
await page.get_by_text("控制台").wait_for(state="visible", timeout=10_000)
except PWTimeout:
raise RuntimeError("登录失败,未进入控制台,可能账号或验证码拦截")
# 进入订单页,抽取结构化数据(固定结构 → 确定性抽取)
await page.goto("https://example-shop.internal/orders?date=today")
await page.get_by_role("table").wait_for()
rows = await page.locator("tbody tr").evaluate_all(
"""rows => rows.map(r => {
const c = r.querySelectorAll('td');
return {
order_id: c[0]?.innerText.trim(),
customer: c[1]?.innerText.trim(),
amount: c[2]?.innerText.trim(),
status: c[3]?.innerText.trim(),
};
})"""
)
await context.close()
await browser.close()
return rows
if __name__ == "__main__":
data = asyncio.run(login_and_pull_orders("me@corp.com", "******"))
print(f"拉到 {len(data)} 条订单")
几个工程要点:
wait_until="networkidle"+ 显式wait_for,绝不time.sleep。sleep 是脆弱之源:网络慢了就漏,网络快了就浪费。- 语义定位 > CSS 选择器。
getByLabel("邮箱")在网站换皮肤时大概率还能用;#root > div > form > input一改版就挂。 - 持久化 context。登录态落盘后,L2 的 Agent 可以直接挂载,省掉重复登录被风控。
4.2 L2 探索层 A:browser-use 处理「会变的列表」(Python)
对于「从今天一堆订单里找出需要人工复核的那几个」这种没有固定选择器的步骤,交给 browser-use。它本质是:把页面截图 + DOM 摘要喂给 LLM,让模型规划点击路径。
pip install browser-use
# explore_with_agent.py —— L2 探索层(browser-use)
import asyncio
from browser_use import Agent, Browser, BrowserConfig
from langchain_openai import ChatOpenAI
async def find_flagged_orders():
# 直接复用 L1 的持久化登录态,不让 Agent 自己登录
browser = Browser(config=BrowserConfig(
headless=True,
# 指向你已经登录过的 user_data_dir,Agent 进来就是登录态
user_data_dir="/Users/you/Library/Application Support/playwright-profile",
))
agent = Agent(
task=(
"当前已在订单后台登录。请找出状态为「待复核」的订单,"
"点进前 3 个,把每个订单的订单号和驳回原因整理成列表返回。"
"注意:不要导出,只要整理信息。"
),
llm=ChatOpenAI(model="gpt-4o", temperature=0),
browser=browser,
# 限制步数,防止 Agent 无限兜圈
max_actions_per_step=1,
)
result = await agent.run(max_steps=25)
return result
if __name__ == "__main__":
out = asyncio.run(find_flagged_orders())
print(out)
注意这里的纪律:
task里明确「当前已登录」。不要让 Agent 去登录,登录是 L1 的活。Agent 一登录就容易多点、误触二次验证。max_steps和max_actions_per_step必须设上限。 不设上限的 Agent 会进入「我以为点对了再点一次」的死循环,烧钱又超时。temperature=0。 探索类任务要确定性输出,不要让它「发挥」。
4.3 L2 探索层 B:Stagehand 的结构化抽取(TypeScript,更可控)
如果你想要比 browser-use 更「工程化」的探索层,Browserbase 的 Stagehand 是 2026 年最值得关注的选择。它把 AI 操作收敛成三个强类型原语:
page.act({ action }):让 AI 执行一个动作(「点击登录按钮」)page.extract({ instruction, schema }):让 AI 从页面抽取符合 schema 的结构化数据page.observe({ instruction }):让 AI 先「观察」出可交互元素的定位,再交给确定性代码执行
act + extract 是 AI 兜底,observe 是把 AI 的发现固化成确定性选择器——后者尤其适合「先让 AI 找按钮,找到后我反复点」的场景。
npm install @browserbasehq/stagehand playwright
npx playwright install chromium
// stagehand_extract.ts —— L2 探索层(Stagehand,TypeScript)
import { Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod";
const stagehand = new Stagehand({
env: "LOCAL", // 本地跑;生产可换成 Browserbase 云浏览器
modelName: "gpt-4o",
modelClientOptions: { apiKey: process.env.OPENAI_API_KEY! },
});
await stagehand.init();
const page = stagehand.page;
await page.goto("https://example-shop.internal/orders?date=today");
// 1) observe:让 AI 只「看」不「做」,返回可点的元素定位
const buttons = await page.observe({
instruction: "找到页面上用来筛选『待复核』订单的按钮或标签",
});
if (buttons[0]) {
await page.locator(buttons[0].selector).click(); // 找到后确定性点击
}
// 2) extract:用 zod schema 约束输出,拿到干净的结构化数据
const orders = await page.extract({
instruction: "抽取当前列表里所有待复核订单",
schema: z.object({
orders: z.array(z.object({
order_id: z.string(),
customer: z.string(),
reason: z.string(),
amount: z.number(),
})),
}),
});
console.log(JSON.stringify(orders, null, 2));
await stagehand.close();
Stagehand 相比 browser-use 的关键区别:extract 带 schema 校验。browser-use 返回的是一段自由文本,Stagehand 直接给你一个能进数据库的 JSON。生产系统里,这种「强类型边界」比「AI 自由发挥」值钱得多。
4.4 L3 控制层:用 CDP 直接抓接口 JSON(跳过 DOM,又快又稳)
到了这一步,AI 已经帮我们导航到了目标页面。接下来要拿数据——大多数人会「让 AI 从 DOM 抠」。但更狠的做法是:这个页面的数据本来就是后端 API 返回的 JSON,直接拦截它。
Playwright 每个 page 都能开一个 CDP session,发送 Network.enable / Fetch.enable,在请求/响应经过时拦截。
# cdp_intercept.py —— L3 控制层(CDP 抓包)
import asyncio, json
from playwright.async_api import async_playwright
TARGET_API = "/api/order/detail"
async def capture_api_json(page_url: str):
captured = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
# 开 CDP session
cdp = await context.new_cdp_session(page)
await cdp.send("Network.enable")
# 监听响应,只抓目标接口
def on_response(params):
resp_url = params.get("response", {}).get("url", "")
if TARGET_API in resp_url:
# 注意:CDP 的 Network.getResponseBody 需要 requestId
asyncio.create_task(_dump(cdp, params["requestId"], captured))
async def _dump(session, req_id, store):
try:
body = await session.send("Network.getResponseBody", {"requestId": req_id})
store.append(json.loads(body["body"]))
except Exception as e:
print("抓取失败:", e)
cdp.on("Network.responseReceived", on_response)
# 导航到会触发该接口的页面(这一步可以来自 L2 的定位结果)
await page.goto(page_url, wait_until="networkidle")
await page.wait_for_timeout(2000) # 等接口回来
await context.close()
await browser.close()
return captured
为什么 L3 是「逃生舱」级别的存在:
- 抗改版:前端怎么重构、class 怎么改,只要后端 API 字段没变,你拿到的数据纹丝不动。
- 快一个数量级:解析一个 50KB 的 DOM 树、让 AI 看图抠字段,对比直接
Network.getResponseBody拿 JSON,后者是降维打击。 - 能拿前端不展示的字段:很多详情页只渲染部分字段,但 API 返回了全量数据(含内部状态、价格构成)。抓接口等于开了透视挂。
实战里我给 CDP 抓包加一条铁律:L2 负责到「页面」,L3 负责拿「数据」。只要目标页面背后有 XHR/Fetch 接口,绝不从 DOM 抠。
4.5 混合编排:把三层串成一条生产流水线
真正的生产代码,是把上面四段拼起来。下面这个编排器体现了核心纪律:登录走 L1,探索走 L2,数据走 L3。
# orchestrator.py —— 三层混合编排
import asyncio, json
from playwright.async_api import async_playwright
from browser_use import Agent, Browser, BrowserConfig
from langchain_openai import ChatOpenAI
PROFILE = "/Users/you/Library/Application Support/playwright-profile"
async def production_pipeline(email, password):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_data_dir=PROFILE, # L1 登录态落盘复用
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
)
page = await context.new_page()
# —— L1:确定性登录(首次建 profile,之后复用)——
await page.goto("https://example-shop.internal/login", wait_until="networkidle")
if await page.get_by_text("控制台").count() == 0:
await page.get_by_label("邮箱").fill(email)
await page.get_by_label("密码").fill(password)
await page.get_by_role("button", name="登录").click()
await page.get_by_text("控制台").wait_for(timeout=10_000)
# —— L2:探索层找目标页面 URL(只让它干「找」的活)——
browser2 = Browser(config=BrowserConfig(headless=True, user_data_dir=PROFILE))
agent = Agent(
task="你已登录。请进入『待复核订单』列表页,把当前页面的 URL 原样返回,不要做其他操作。",
llm=ChatOpenAI(model="gpt-4o", temperature=0),
browser=browser2,
max_steps=15,
)
target_url = (await agent.run()).final_result()
await browser2.close()
# —— L3:在目标页面开 CDP,抓背后的接口 JSON ——
cdp = await context.new_cdp_session(page)
await cdp.send("Network.enable")
captured = []
def _on(resp):
if "/api/orders/pending" in resp.get("response", {}).get("url", ""):
asyncio.create_task(_grab(cdp, resp["requestId"], captured))
async def _grab(s, rid, store):
try:
b = await s.send("Network.getResponseBody", {"requestId": rid})
store.append(json.loads(b["body"]))
except Exception:
pass
cdp.on("Network.responseReceived", _on)
await page.goto(target_url, wait_until="networkidle")
await page.wait_for_timeout(2000)
await context.close()
await browser.close()
return captured
# 调用:第二次起因为 profile 已登录,L1 直接跳过登录,全程确定性 + 一次轻量探索 + CDP 抓包
result = asyncio.run(production_pipeline("me@corp.com", "******"))
print(f"抓到 {len(result)} 个接口响应")
这条流水线在生产上的体感:登录 0 次额外成本(复用 profile)、探索只花 1 次 LLM 调用、数据走 CDP 毫秒级拿到。对比「全程 AI 开车」那种几十次 LLM 调用、动不动超时的方案,稳定性不是一个量级。
五、性能优化与成本控制
三层架构省下的不只是稳定性,还有真金白银。下面是我在生产里验证过的优化清单。
5.1 登录态复用:最划算的一笔
每次让 AI 登录,既要花 LLM 调用又要冒风控。用持久化 user_data_dir 把登录态落盘,除首次外永远不重复登录。配合 CDP 抓包,整个管道里 LLM 调用次数从「几十」降到「个位数」。
5.2 并发:用「一 context 一任务」做隔离并行
Playwright 的 browser.new_context() 是轻量隔离单元。要同时跑 20 个站点,就开 20 个 context,互不干扰、各自带自己的登录态。配合 asyncio 的 gather,吞吐线性上去。
async def run_many(urls):
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
tasks = []
for u in urls:
ctx = await browser.new_context()
tasks.append(_scrape_one(ctx, u))
return await asyncio.gather(*tasks)
async def _scrape_one(ctx, url):
page = await ctx.new_page()
await page.goto(url, wait_until="networkidle")
data = await page.locator("tbody tr").evaluate_all("r=>r.map(x=>x.innerText)")
await ctx.close()
return data
5.3 能抓接口就别解析 DOM
这是最容易被忽视的性能杠杆。一个页面如果数据来自 XHR,直接 CDP 抓 responseBody:
- DOM 解析 + AI 抠字段:~3–8 秒,且随页面复杂度恶化
- CDP 抓接口 JSON:~200–500 毫秒,复杂度无关
代价是 CDP 代码难写一点,但换来的是数量级的提速和改版免疫。
5.4 反爬与指纹:让浏览器「像个人」
很多站点会识别「你不是真人」。要做到像,关键不是换 UA 那么简单,而是指纹一致性:
context = await browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
viewport={"width": 1440, "height": 900},
locale="zh-CN",
timezone_id="Asia/Shanghai",
color_scheme="light",
)
# 注入 stealth 脚本,抹掉 navigator.webdriver 等自动化特征
await context.add_init_script(path="./stealth.min.js")
要点:
- UA、viewport、locale、timezone、color_scheme 要互相自洽。 一个声称 Windows 的 UA 配
Asia/Shanghai时区没问题,但如果 viewport 是手机尺寸却报桌面 UA,立刻穿帮。 - 抹掉
navigator.webdriver。 这是最基础的一道,不处理等于举着牌子写「我是机器人」。 - 鼠标轨迹要有人味。 关键点击前加一点随机偏移和停顿(
page.mouse.move+ 小幅wait_for_timeout),别让所有点击都精准落在元素正中。
5.5 重试与幂等:失败是常态,别让它变事故
浏览器自动化跑在别人家的网站上,失败是家常便饭。正确做法是:
- 操作幂等:同一任务跑两次结果一致(别重复下单、重复导出)。
- 指数退避重试:网络抖动重试 3 次,间隔 1s→2s→4s。
- 失败留痕:记录「第几步、哪个 URL、什么错误」,而不是只留一句「AI 尽力了」。
import asyncio
async def with_retry(fn, retries=3, base=1.0):
last = None
for i in range(retries):
try:
return await fn()
except Exception as e:
last = e
await asyncio.sleep(base * (2 ** i))
raise RuntimeError(f"重试 {retries} 次仍失败: {last}")
5.6 成本控制:模型分级 + 预算护栏
LLM 调用是唯一的真成本。纪律:
- 探索层用便宜快模型(如 4o-mini 级),只在「抽取复杂语义」时才上大模型。
- 给 Agent 设 token / step 预算,超出即停,避免「Agent 卡住狂烧 token」。
- 能确定性解决的绝不调模型。 这是架构本身带来的最大省钱点——三层架构把 LLM 限制在 15% 的步骤里。
六、总结与展望
回到开头的结论:「AI 全程开车」是浏览器自动化最贵的架构。 它的贵不在算力,而在不可复现、不可诊断、抗改版差。
真正稳的生产方案,是一套分层的纪律:
- L1 确定性层(Playwright)兜底 80% 的稳定流程。 语义定位、显式等待、登录态复用。快、免费、可复现。
- L2 探索层(browser-use / Stagehand + LLM)只补 15% 会变的、需要理解的步骤。 设步数上限、temperature=0、用 schema 约束输出。
- L3 控制层(CDP)做 5% 的逃生舱操作。 直接抓接口 JSON、改指纹、拿底层状态。抗改版、快一个数量级。
判断该用哪一层的经验法则就一句话:默认确定性,AI 只用于真正模糊的那一步,CDP 用于绕过 DOM 拿数据。
展望一下:2026 下半年,浏览器自动化的重心会从「让 AI 更能点」转向「让 AI 更知道何时不该点」。Stagehand 这类把 AI 收敛成 act/extract/observe 强类型原语的思路,会逐步成为主流;而 CDP 抓接口这种「绕过前端直取后端」的打法,会在数据密集型场景里越来越吃香。至于「全自动 AI Agent 接管浏览器」的叙事,留给 Demo 就好——生产里,永远是人写的确定性代码在托底。
最后一句工程箴言:能写死的别问 AI,能抓接口的别抠 DOM,能复用的别重复登录。 三层架构,不过是把这三句话变成代码结构而已。