编程 生产级浏览器自动化:别让 AI 全程开车——Playwright + browser-use + CDP 三层架构全链路拆解

2026-08-17 21:14:58 +0800 CST views 37

生产级浏览器自动化:别让 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 自己点登录、自己找订单列表、自己点导出。观众直呼「未来已来」。

然后你把它接进生产,第一天没事,第二天没事,第三天网站发了个版本,把那个「导出」按钮的 classbtn-primary 改成了 btn-export-v2。AI 在看截图、在犹豫、在重试,重试三次后超时,任务失败。你半夜被报警叫醒,发现 200 个订单没导出来,老板的邮件没发出去。

这不是个例。我在落地多个生产级浏览器自动化系统之后,总结出一个反直觉的结论:

「AI 全程开车」是浏览器自动化最贵的架构。

原因有三:

  1. LLM 调用又慢又贵又不可复现。 每一步「看页面、想下一步」都要一次模型推理,几十步的任务就是几十次 API 调用。同样一个页面,模型这次选对了按钮,下次可能因为页面多了一个弹窗就选错。
  2. 网页是会变的。 人类写死选择器(selector)很脆弱,但 AI 靠视觉理解更脆弱——它依赖截图和渲染,渲染一变它就懵。
  3. 失败不可诊断。 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 写回数据仓库(确定性、快)

关键设计点:

  1. 登录永远走 L1。 登录是固定流程里最固定的,反而最容易因为「AI 多点了一次」出事。让它确定性,再用持久化 context 把登录态复用给 L2。
  2. L2 拿 L1 的登录态。 不要各跑各的浏览器。browser-use 和 Stagehand 都支持「接管一个已经存在的浏览器 / context」,这是省下大量重复登录和风控的关键。
  3. 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_stepsmax_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,互不干扰、各自带自己的登录态。配合 asynciogather,吞吐线性上去。

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 全程开车」是浏览器自动化最贵的架构。 它的贵不在算力,而在不可复现、不可诊断、抗改版差。

真正稳的生产方案,是一套分层的纪律:

  1. L1 确定性层(Playwright)兜底 80% 的稳定流程。 语义定位、显式等待、登录态复用。快、免费、可复现。
  2. L2 探索层(browser-use / Stagehand + LLM)只补 15% 会变的、需要理解的步骤。 设步数上限、temperature=0、用 schema 约束输出。
  3. L3 控制层(CDP)做 5% 的逃生舱操作。 直接抓接口 JSON、改指纹、拿底层状态。抗改版、快一个数量级。

判断该用哪一层的经验法则就一句话:默认确定性,AI 只用于真正模糊的那一步,CDP 用于绕过 DOM 拿数据。

展望一下:2026 下半年,浏览器自动化的重心会从「让 AI 更能点」转向「让 AI 更知道何时不该点」。Stagehand 这类把 AI 收敛成 act/extract/observe 强类型原语的思路,会逐步成为主流;而 CDP 抓接口这种「绕过前端直取后端」的打法,会在数据密集型场景里越来越吃香。至于「全自动 AI Agent 接管浏览器」的叙事,留给 Demo 就好——生产里,永远是人写的确定性代码在托底。

最后一句工程箴言:能写死的别问 AI,能抓接口的别抠 DOM,能复用的别重复登录。 三层架构,不过是把这三句话变成代码结构而已。

推荐文章

前端如何一次性渲染十万条数据?
2024-11-19 05:08:27 +0800 CST
如何实现生产环境代码加密
2024-11-18 14:19:35 +0800 CST
快手小程序商城系统
2024-11-25 13:39:46 +0800 CST
Dropzone.js实现文件拖放上传功能
2024-11-18 18:28:02 +0800 CST
Vue3中如何进行错误处理?
2024-11-18 05:17:47 +0800 CST
PHP服务器直传阿里云OSS
2024-11-18 19:04:44 +0800 CST
MySQL 1364 错误解决办法
2024-11-19 05:07:59 +0800 CST
程序员茄子在线接单