把 Playwright 和 AI 大模型接起来是什么体验?我最近花了两周时间,把“用一句话控制浏览器干活”这件事从想法变成了可以跑的脚本——不用手写 CSS 选择器,也不用一帧帧录脚本,只要把任务描述给 AI,浏览器会自己打开页面、找输入框、点按钮、把结果回传给我。这篇文章就把我的完整思路、核心代码和踩坑记录都交代清楚。想做网页自动化、AI Agent,或者单纯对“浏览器机器人”感兴趣的开发者,都可以从里面拿到一套能直接跑的方案。
这个玩法不是什么魔法。底层仍然是 Playwright 那一套成熟的自动化 API,AI 在里面扮演的是“指挥官”的角色,负责把自然语言拆解成计算机能执行的步骤。我试下来最大的感受是:稳定性比“让 AI 直接生成自动化代码”高太多,这背后其实是一套很明确的设计取舍。
1. 自然语言控制浏览器,到底在解决什么问题
1.1 传统自动化脚本的痛点,以及 AI 的切入点
先聊一个每个做网页自动化的人都绕不开的痛点:业务一变,脚本就废。比如要写一个“在电商网站搜索某商品并加购”的脚本,你得先打开网站,按 F12 找到输入框的 id、按钮的 class,再处理各种加载等待和弹窗。页面结构一改,选择器失效,脚本就得从头排查。维护成本长时间累积下来,比最初写脚本的成本还高。
AI 解决的并不是“点击按钮”这个动作本身,而是“如何把自然语言任务翻译成浏览器操作序列”。换句话说,执行层仍然用 Playwright 的高质量 API,AI 只做决策层。比如“帮我查一下今天上海到北京的高铁”这句话,AI 需要拆解成:先打开 12306,找到出发地输入框,填入上海,找到目的地输入框,填入北京,选择今天日期,点击查询按钮,最后读取结果列表。这些动作拆解能力是大模型的强项,而“稳定点击”是 Playwright 的强项。两者一拍即合。
这也是 Midscene、browser-use 等项目的核心思路。它们没有让 AI 直接写 Python 代码,而是让 AI 输出结构化的操作指令,再由 Playwright 去执行。这样做的好处非常明显:每一步动作都经过严格校验,不用依赖模型生成的随机代码。
1.2 为什么选 Playwright,而不是裸写 CDP
你可能已经听说过 Chrome DevTools Protocol,也就是 CDP,它是浏览器调试的底层协议,功能很强,但裸写 CDP 的体验有点像直接拿汇编语言写界面。Playwright 在 CDP 之上封装了一层稳定、友好的 API,是自然语言控制浏览器最合适的底座。
| 对比维度 | Playwright | 裸写 CDP | 传统 Selenium |
|---|---|---|---|
| 元素定位 | 支持角色、文本、测试ID,语义化定位 | 需要自己处理 Runtime.evaluate,很原始 | 主要靠 CSS/XPath,脆弱 |
| 自动等待 | 内置动作等待机制 | 自己实现等待逻辑 | 有显式等待但灵活度一般 |
| 多页面/弹窗处理 | Page/Context 模型清晰 | 手动管理 target 事件 | EventContext 较繁琐 |
| 可访问性快照 | 原生accessibility.snapshot() | 自己调用 Accessibility domain | 支持较弱 |
| 浏览器隔离 | 上下文天然隔离 | 需要手动配置 profile | 靠 options 折腾 |
其中最关键的一点是accessibility.snapshot()。它能拿到浏览器为读屏器准备的语义结构树,也就是可访问性快照。这棵树上包含按钮、输入框、链接、标题等元素的 role 和 name,相当于把复杂页面压缩成一个“语义版视图”。AI 拿到这份快照,就不需要猜测一堆残缺的 CSS 选择器了。
1.3 一条自然语言任务是怎么变成浏览器操作的
我这里用的流程是一个受控的多轮循环,拆开来看其实很直白:
- 打开浏览器并进入目标页面,获取当前页面的可访问性快照。
- 把快照转成紧凑文本,连同用户任务、历史动作结果一起发给大模型。
- 大模型返回一个受 JSON 格式约束的动作,比如
{"action":"click","params":{"node_id":12}}。 - Playwright 解析这个动作,根据
node_id找到真实元素并执行。 - 检查任务是否完成;如果没完成,回到第 1 步继续循环,直到模型返回
done。
这个循环看起来简单,但有一点值得强调:AI 每一轮看到的“页面状态”都是全新的。点击按钮之后页面跳转了,下一轮快照自然就变成了新页面的内容,模型不需要硬背旧状态。这种方式既降低了模型的记忆负担,也减少了很多状态错乱的问题。
2. 核心原理拆解:AI 怎么“看到”页面并决定动作
2.1 可访问性快照:给 AI 看一个“语义版”页面
为什么不直接给 AI 传 HTML?因为整个 DOM 树又长又杂,充满布局节点、脚本节点、隐藏的装饰性元素,直接发给大模型非常浪费 token,而且模型很容易被无关内容干扰。可访问性快照就不一样了,它只保留对用户有意义的结构,比如“按钮:登录”“文本输入框:手机号”“链接:帮助中心”。
我用 Playwright 拿快照非常快:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com") snap = page.accessibility.snapshot(interesting_only=True) print(snap)不过原始快照是嵌套的 Python 字典或 JSON,模型直接读它还是不够方便。我一般会把它拍平成带编号的文本,方便模型引用:
def flatten_a11y(node, counter, locators): lines = [] role = node.get("role") name = node.get("name") if role and (name or node.get("value")): key = f"{role}|{name}" counter[key] = counter.get(key, 0) + 1 node_id = len(locators) value = node.get("value", "") extra = f" value={value}" if value else "" lines.append(f"[{node_id}] {role}: {name}{extra} (same_key={counter[key]})") locators.append({ "role": role, "name": name, "index": counter[key] }) for child in node.get("children", []): lines.extend(flatten_a11y(child, counter, locators)) return lines这样模型看到的页面状态长这样:
[0] navigation: 主导航 [1] link: 首页 (same_key=1) [2] textbox: 搜索关键词 (same_key=1) [3] button: 搜索 (same_key=1) [4] textbox: 手机号 (same_key=1) [5] button: 提交 (same_key=1)同一类link可能有多个,所以我在后面加了一个(same_key=N),表示这是同名节点中的第几个,执行时用.nth(N-1)定位,避免歧义。这套做法让模型只需要选一个数字,而不是编选择器。
2.2 动作收敛:让 AI 做选择题,而不是写代码
这是整个方案里最重要的一条经验:永远不要让 AI 自由生成page.locator("xxx").click()这样的代码。模型一旦开始写 Python,语法错误、选择器幻觉、元素不存在、方法名拼错,各种问题都会冒出来。我调试过几次之后,彻底放弃了这个方向。
正确的做法是给 AI 定义一套很小的动作集合,把它逼成“选择题”:
| 动作 | 参数 | 用途 |
|---|---|---|
goto | url | 打开新页面 |
click | node_id | 点击某个可访问节点 |
fill | node_id,value | 在输入框填内容 |
press | node_id,key | 按键,比如 Enter |
extract | 无 | 提取当前页面正文文本 |
done | result | 标记任务完成并返回最终结果 |
动作集越小,模型的出错空间越小。而且这套动作在大多数网页上已经够用。真正复杂的操作,比如拖拽、上传文件,我也只会慢慢往里加“受控动作”,不会放开让 AI 自己发挥。
2.3 多轮循环与状态记忆:AI 的短期工作记忆
大模型本身没有真正常驻的“页面状态”,它每轮看到的是我发过去的最新快照和历史动作结果。所以我的消息结构是:
- system:定义浏览器助手的角色和 JSON 输出规则。
- user:包含“用户任务 + 当前页面状态 + 历史动作结果”。
这样 AI 在同一轮里就能知道:“我刚才填了关键词,页面结果已经加载出来了,现在应该点第一个结果链接。” 历史记录不需要很长,我一般只保留最近 3-5 步,太长的历史会稀释模型对当前页面的注意力。这和写自动化测试用例时的思路很像:断言不是越多越好,而是在关键路径上设置足够的节点。
2.4 再往前走一步:Function Calling 和 MCP
我最初实现的是用提示词硬约束 JSON 输出,这种方案兼容任何大模型。后来我换到支持 Function Calling 的接口,体验又顺了一点:模型可以在 API 层选择调用哪个工具,不需要我在 prompt 里反复强调 JSON 格式。本质上思路完全一样,只是工程上更规范。
另外,现在社区里流行的 MCP 也走了类似路径。Playwright MCP Server 会把浏览器操作包装成可供 AI 调用的工具,Claude 这类客户端通过 MCP 协议调用它们,就能操作浏览器。你可以把 MCP 理解成“AI 时代的 USB 接口”,而 function calling 是这个接口的底层协议之一。如果你想做更通用的 Agent,直接研究 MCP 生态是顺势而为。
3. 实操:从零搭一个 Playwright + AI 浏览器助手
3.1 环境准备:装什么、怎么装最省心
我的示例环境是 Python 3.10,配 Chrome/Chromium。先安装依赖:
pip install playwright openai playwright install chromium这里有个小提醒:playwright install chromium下载的是 Playwright 自己管理的 Chromium,不是你在电脑上日常用的 Chrome,两者不冲突。如果你电脑里已经有可用的 Chrome 或 Edge,也可以不执行这行命令,直接在代码里指定可执行文件路径,这样能省一次下载。
我后面的代码用 OpenAI SDK 调用大模型,但如果你想接本地模型,比如 Qwen、DeepSeek 这类兼容 OpenAI 接口的服务,只需要改base_url和api_key,代码框架完全不用动。
3.2 核心代码:快照生成、动作执行与控制循环
先把基础工具函数补齐。首先是动作执行器,它负责把 JSON 动作真正变成 Playwright 调用:
from playwright.sync_api import sync_playwright from openai import OpenAI import json, os client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL", "https://api.openai.com/v1"), ) MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini") def get_state(page): snap = page.accessibility.snapshot(interesting_only=True) if not snap: return "页面无可用可交互元素", [] counter = {} locators = [] lines = flatten_a11y(snap, counter, locators) return "\n".join(lines), locators def resolve_locator(page, locator_info): loc = page.get_by_role(locator_info["role"], name=locator_info["name"]) return loc.nth(locator_info["index"] - 1) def execute_action(page, action, locators): act = action.get("action") params = action.get("params", {}) try: if act == "goto": page.goto(params["url"], wait_until="domcontentloaded") return "goto ok", True if act == "click": info = locators[params["node_id"]] resolve_locator(page, info).click() return "click ok", True if act == "fill": info = locators[params["node_id"]] resolve_locator(page, info).fill(params["value"]) return "fill ok", True if act == "press": info = locators[params["node_id"]] resolve_locator(page, info).press(params["key"]) return "press ok", True if act == "extract": txt = page.locator("body").inner_text() return txt[:2000], True return f"unknown action {act}", False except Exception as e: return f"执行失败: {e}", False然后需要把模型输出里的 Markdown 代码块去掉,防止json.loads崩掉:
def clean_json(text): text = text.strip() if text.startswith("```"): lines = text.split("\n")[1:] if lines and lines[-1].strip() == "```": lines = lines[:-1] text = "\n".join(lines) start = text.find("{") end = text.rfind("}") if start != -1 and end != -1: return text[start:end+1] return text最后是控制循环。这一步相当于把整个任务拆成一个“感知-决策-执行”的循环:
def run_task(task, start_url=None, max_steps=15): with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto(start_url if start_url else "about:blank") history = [] completed = False for step in range(max_steps): state, locators = get_state(page) user_content = ( f"任务:{task}\n\n" f"当前页面状态:\n{state}\n\n" f"已执行历史:{history}\n\n" f"请输出下一步 JSON 动作。" ) resp = client.chat.completions.create( model=MODEL, temperature=0, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ], ) raw = resp.choices[0].message.content try: action = json.loads(clean_json(raw)) except Exception: history.append(f"step{step}: JSON 解析失败 {raw}") continue if action["action"] == "done": print("任务完成:", action["params"].get("result")) completed = True break result, ok = execute_action(page, action, locators) history.append(f"step{step}: {action} => {result}") page.wait_for_timeout(800) if not completed: print("到达最大步数,任务未完成") browser.close()控制循环里我没有把每一步的完整页面快照存进历史,只存了动作和结果,避免 token 爆炸。后续如果任务太复杂,可以在历史里保存“上一轮动作执行后的短摘要”,效果会更明显。
3.3 完整演示:让浏览器自己搜索并点击第一条结果
我拿一个最常用的场景来跑:在百度搜索“Playwright 最新版本”,然后点击第一条搜索结果。任务描述会直接写进 user_content:
任务:打开百度,搜索 Playwright 最新版本,然后点击第一条搜索结果。模型在循环里大概会输出这样的动作序列(示意):
goto-> https://www.baidu.comfill-> node_id 对应搜索输入框,value 为“Playwright 最新版本”click-> node_id 对应“百度一下”按钮,或者直接pressEnter- 等待页面跳转后,新快照会显示搜索结果列表
click-> node_id 对应第一个结果链接done-> result 里返回当前页面标题或 URL
这个流程里最关键的一步是第 4 步:搜索结果页加载完成后,快照里会突然出现很多link节点。AI 要能识别出“第一条结果”到底是第几个link,所以我在快照文本里对同名节点做了编号。实测下来,只要页面语义结构正常,模型选对的概率非常高。
3.4 提示词和参数调优,哪里有坑
Prompt 决定了整个系统的下限,我试过各种写法,最后沉淀出一个比较稳的系统提示词:
你是一个浏览器操作助手。你只能输出一个 JSON 对象,不要输出任何解释、Markdown 或代码块。 可选 action: - goto: {"action":"goto","params":{"url":"..."}} - click: {"action":"click","params":{"node_id":整数}} - fill: {"action":"fill","params":{"node_id":整数,"value":"..."}} - press: {"action":"press","params":{"node_id":整数,"key":"Enter"}} - extract: {"action":"extract","params":{}} - done: {"action":"done","params":{"result":"最终回答"}} 页面状态里每一行是一个可交互节点,格式为 [node_id] role: name (same_key=N),表示同名节点中的第 N 个。 你只选择 node_id 来操作,不要编造选择器。若任务已完成,返回 done。几个调优经验:
temperature=0是必须的。浏览器操作是确定性任务,不需要“创意”。- 任务描述尽量带验证点。比如“点击标题为 X 的链接”比“点击一个看起来像结果的链接”靠谱得多。
- 设置
max_steps防止死循环。我见过模型在一个失败动作上反复重试,如果没步数上限,它会一直在原地转圈。 - 页面状态文本如果太长,可以先截断到 1500 字符左右,优先保留按钮、链接、输入框等可交互节点,减少干扰。
4. 常见问题与排查技巧实录
4.1 模型输出非法 JSON 怎么办
这是我遇到的第一个大坑。很多大模型即使你反复强调“只输出 JSON”,它还是会给你一段带 Markdown 的代码块,甚至开头写一句“好的,以下是下一步动作”。直接json.loads就崩了。
我的处理分三层:
- 先用
clean_json去掉代码块、提取首尾花括号。 - 如果解析成功但动作不在白名单里,返回错误信息给模型,让它重新输出。
- 如果连续三次解析失败,就停止循环并打印上下文,方便排查。
说到底,这是模型服务本身的不稳定因素。越强的模型越遵守指令,但如果用本地小模型,就要容忍一定比例的格式错误,把重试机制写好。
4.2 元素定位不稳定,同名节点和自定义控件
页面状态里同名节点很常见,比如导航栏一堆“link: 首页”可能不同位置出现。我用same_key计数和.nth()解决了一部分,但还有一类问题更难办:很多现代前端控件不是原生 button,而是<div>加了onclick,在可访问性快照里可能只有generic角色,AI 不知道它能不能点。
我的兜底思路是:如果快照里找不到合适的可交互节点,可以先让 AI 执行extract,拿到页面正文文本,然后根据正文内容判断下一步。更彻底的办法是把可访问性快照和常见可点击元素信息合并,在图谱上补充>