
把 Playwright 和 AI 大模型接起来是什么体验我最近花了两周时间把“用一句话控制浏览器干活”这件事从想法变成了可以跑的脚本——不用手写 CSS 选择器也不用一帧帧录脚本只要把任务描述给 AI浏览器会自己打开页面、找输入框、点按钮、把结果回传给我。这篇文章就把我的完整思路、核心代码和踩坑记录都交代清楚。想做网页自动化、AI Agent或者单纯对“浏览器机器人”感兴趣的开发者都可以从里面拿到一套能直接跑的方案。这个玩法不是什么魔法。底层仍然是 Playwright 那一套成熟的自动化 APIAI 在里面扮演的是“指挥官”的角色负责把自然语言拆解成计算机能执行的步骤。我试下来最大的感受是稳定性比“让 AI 直接生成自动化代码”高太多这背后其实是一套很明确的设计取舍。1. 自然语言控制浏览器到底在解决什么问题1.1 传统自动化脚本的痛点以及 AI 的切入点先聊一个每个做网页自动化的人都绕不开的痛点业务一变脚本就废。比如要写一个“在电商网站搜索某商品并加购”的脚本你得先打开网站按 F12 找到输入框的 id、按钮的 class再处理各种加载等待和弹窗。页面结构一改选择器失效脚本就得从头排查。维护成本长时间累积下来比最初写脚本的成本还高。AI 解决的并不是“点击按钮”这个动作本身而是“如何把自然语言任务翻译成浏览器操作序列”。换句话说执行层仍然用 Playwright 的高质量 APIAI 只做决策层。比如“帮我查一下今天上海到北京的高铁”这句话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(headlessFalse) page browser.new_page() page.goto(https://example.com) snap page.accessibility.snapshot(interesting_onlyTrue) 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_key1) [2] textbox: 搜索关键词 (same_key1) [3] button: 搜索 (same_key1) [4] textbox: 手机号 (same_key1) [5] button: 提交 (same_key1)同一类link可能有多个所以我在后面加了一个(same_keyN)表示这是同名节点中的第几个执行时用.nth(N-1)定位避免歧义。这套做法让模型只需要选一个数字而不是编选择器。2.2 动作收敛让 AI 做选择题而不是写代码这是整个方案里最重要的一条经验永远不要让 AI 自由生成page.locator(xxx).click()这样的代码。模型一旦开始写 Python语法错误、选择器幻觉、元素不存在、方法名拼错各种问题都会冒出来。我调试过几次之后彻底放弃了这个方向。正确的做法是给 AI 定义一套很小的动作集合把它逼成“选择题”动作参数用途gotourl打开新页面clicknode_id点击某个可访问节点fillnode_id,value在输入框填内容pressnode_id,key按键比如 Enterextract无提取当前页面正文文本doneresult标记任务完成并返回最终结果动作集越小模型的出错空间越小。而且这套动作在大多数网页上已经够用。真正复杂的操作比如拖拽、上传文件我也只会慢慢往里加“受控动作”不会放开让 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_keyos.getenv(LLM_API_KEY), base_urlos.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_onlyTrue) 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], namelocator_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_untildomcontentloaded) 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 funknown 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:end1] return text最后是控制循环。这一步相当于把整个任务拆成一个“感知-决策-执行”的循环def run_task(task, start_urlNone, max_steps15): with sync_playwright() as p: browser p.chromium.launch(headlessFalse) 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( modelMODEL, temperature0, 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(fstep{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(fstep{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_keyN)表示同名节点中的第 N 个。 你只选择 node_id 来操作不要编造选择器。若任务已完成返回 done。几个调优经验temperature0是必须的。浏览器操作是确定性任务不需要“创意”。任务描述尽量带验证点。比如“点击标题为 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拿到页面正文文本然后根据正文内容判断下一步。更彻底的办法是把可访问性快照和常见可点击元素信息合并在图谱上补充>