说实话,刚看到"Playwright + AI"这个组合的时候,我的第一反应是:这又是给自动化测试套了一层聊天框,没什么新鲜的。但真上手跑通一版之后,我收回这个判断——当AI真正接管浏览器的"思考"环节,让一句大白话变成一串真实执行的浏览器操作时,那个体验确实有点黑科技的味道。
这个东西能做的事,简单说就是:你告诉AI"打开某网站,找到价格低于300的商品,把链接整理出来",它自己拆分步骤、操作页面、读取信息,最后交给你一份结果。整个过程不再需要你写一行一行牢固的selector定位代码,也不需要提前把业务流程写死。适合谁?如果你搞自动化测试、写爬虫脚本、做RPA流程,或者只是每天被重复网页操作烦到,这篇内容都值得看一看。
我不会只放一段炫酷的演示代码就完事,而是把选型逻辑、设计思路、实际翻车记录和调试过程都讲一遍,尽量让你看完之后真能自己搭一套。
1. 为什么底座选Playwright,而不是Selenium或Puppeteer
先聊一个很实际的问题:AI控制浏览器,底层驱动不能随便选。你可以自己试试,用Selenium也不是不行,但接入AI Agent之后会有一堆隐性成本。我最终把Playwright作为底座,主要是这么几条理由。
1.1 定位机制:AI最讨厌写xpath,而Playwright正好不用它
传统Selenium项目里,最耗精力的就是维护元素定位器。xpath一长串,页面稍微改个class就挂;CSS selector也经常因为前端框架渲染规则变化失效。而Playwright自带一套非常友好的定位方式:get_by_role、get_by_text、get_by_placeholder这组API,让定位方式更接近"人怎么看页面"。
比如一个搜索框,Selenium可能要求你写driver.find_element(By.XPATH, '//input[@id="search-input"]')。Playwright可以直接:
page.get_by_role("textbox", name="搜索")这对我后面设计AI工具协议来说极其重要。LLM在看到页面摘要时,更容易理解"搜索框""导航链接""登录按钮"这种语义化描述,再映射到role/name/text定位符,远比让它猜xpath靠谱得多。实测下来,AI生成有效定位的成功率提升不是一点半点。
1.2 自动等待:省掉80%的sleep噩梦
写自动化的人都经历过那种"加了sleep太慢、不加sleep就崩"的尴尬。Playwright的定位操作自带actionability检查,元素不可见、被遮挡、未附加到DOM,它会自动等到可操作状态;等不到就超时报错。这种机制放在AI Agent里是救命的,因为AI规划的步骤里可没有精确的等待时间,自动等待机制让一系列动作像流水一样连贯执行。
1.3 one context多标签页,天然适合任务型Agent
自然语言任务经常是"打开一个页面,对比几个商品,再切回原来的页面"。Playwright的BrowserContext设计,可以让每个任务独享一个隔离的上下文环境,多标签页、iframe都统一管理。对比Puppeteer只绑定Chrome系,Playwright对Chromium、Firefox、WebKit通吃,也免去了"只支持Chrome内核"的限制。
所以选型阶段我的结论很直接:AI Agent的浏览器控制器,本质需要的是一个"定位友好、等待自动、上下文隔离"的底座。Playwright几乎是当前最优解。
2. 自然语言到浏览器动作:中间隔着一层"工具协议"
很多人误以为AI控制浏览器,就是把自然语言直接翻译成Playwright代码,然后执行。这个方向没有错,但落地会遇到一个大麻烦:LLM直接生成代码,语法一旦错了,整个程序就崩了。就算不崩,每次执行都要编译一次、启一个浏览器进程,效率和稳定性都很差。
我的做法是走"工具协议"模式,这也是后面所有代码的核心。
2.1 把浏览器操作封装成一组标准工具
简单来说,我不让AI写代码,而是为AI准备一组"工具函数",每个函数负责一个浏览器操作。AI的能力被限制在"决定调哪个工具、传什么参数"这个层面。
这个思路参考了OpenAI的function calling以及各种Agent工具的设计。我的工具列表最初长这样:
open_page(url):打开指定网址click(selector):点击页面上的某个元素fill(selector, text):在输入框填入文本extract_text(selector):提取元素文字wait_for(selector):等待指定元素出现snapshot():获取当前页面结构化快照scroll(direction):页面上下滚动finish(result):任务结束,返回最终结果
每个工具都有一段JSON Schema描述,告诉AI这个工具是干嘛的、参数是什么类型、有什么约束。执行时用Playwright真正操作浏览器,然后把操作结果(成功、失败、页面标题、提取到的文字等)返回给AI。AI再根据结果决定下一步。
这有点像让AI做"选择题"而不是"填空题"。生成代码的自由度高但容错率低;调用工具的自由度低但每一步都可控、可校验、可重试。在浏览器这种毫秒级变动的环境里,可控性远比自由度重要。
2.2 页面"摘要"怎么传给AI:全量DOM是病
这里要重点说一个我踩过的坑:直接把page.content()返回的整个HTML丢给LLM,让AI自己找信息。
一次两次没问题,页面一大就完了。一个电商详情页的HTML动辄几百KB,按token算可能要几十万Token,根本塞不进上下文。而且DOM里的脚本、样式、隐藏元素、注释全是噪声,AI反而容易被干扰。
我的方案是生成"可访问性快照"。Playwright可以导出页面的accessibility tree,相当于把页面的结构简化成类似"按钮-搜索""输入框-关键词""链接-购物车"这样的语义化清单。快照体积能压缩到原来的几十分之一,保留的信息又是AI做决策最需要的那部分。
如果你爬出来的页面没有语义化标签,快照会很稀薄,这时候我建议补充一个get_text()工具,把可视区域内的大段文本取出来,让AI抓重点。
2.3 对话循环:execution-feedback-reasoning
整个系统的运行逻辑是一个循环:
- AI接收当前页面快照 + 任务目标
- AI输出"下一个动作"(调用哪个工具、参数是什么)
- 程序执行这个动作,把结果结构化返回
- AI看到结果,判断任务是否完成;没完成就继续从第2步循环
这个循环本身不复杂,真正的复杂度在"AI怎么规划多步动作"和"执行错误后怎么恢复"。这两个问题我会在后面的实测章节展开讲。
3. 动手搭一个最小可用的自然语言浏览器助手
理论讲完,直接上实操。我会用一个最简版本演示整个链路:用户输入一句自然语言任务,程序驱动AI一步步操作浏览器,最终返回结果。
3.1 准备环境
你需要安装两个Python库:
pip install playwright openai python -m playwright install chromium如果你的AI模型走OpenAI兼容接口,可以用base_url指向本地或第三方网关。这样就不必被某个厂商绑死,Ollama、vLLM、各种中转服务都能用。我开发时大部分时间挂在本地模型上调试,成本低得多。
3.2 核心代码实现
下面这段代码是我从项目里裁剪出来的最小可运行版本。注意:它不是一个玩具Demo,而是完整可用的骨架,你可以在它基础上加更多工具函数。
import json import os from playwright.sync_api import sync_playwright from openai import OpenAI client = OpenAI( api_key=os.getenv("OPENAI_API_KEY", "sk-xxx"), base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), ) MODEL = os.getenv("AI_MODEL", "gpt-4o-mini") # 工具定义:这是LLM唯一能调用的“操作系统” TOOLS = [ { "type": "function", "function": { "name": "open_page", "description": "打开指定网址", "parameters": { "type": "object", "properties": { "url": {"type": "string", "description": "完整网址,必须带https://"} }, "required": ["url"], }, }, }, { "type": "function", "function": { "name": "click", "description": "点击页面上的元素,使用Playwright角色定位语法", "parameters": { "type": "object", "properties": { "selector": {"type": "string", "description": "例如: button:has-text(\"登录\") 或 text=加入购物车"} }, "required": ["selector"], }, }, }, { "type": "function", "function": { "name": "fill", "description": "在输入框中填入文本", "parameters": { "type": "object", "properties": { "selector": {"type": "string"}, "text": {"type": "string"}, }, "required": ["selector", "text"], }, }, }, { "type": "function", "function": { "name": "extract_text", "description": "提取页面指定区域的文本内容", "parameters": { "type": "object", "properties": { "selector": {"type": "string", "description": "CSS选择器或角色定位语法"} }, "required": ["selector"], }, }, }, { "type": "function", "function": { "name": "snapshot", "description": "获取当前页面的可访问性快照,了解页面结构和可用元素", "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "wait_for", "description": "等待指定元素出现在页面上", "parameters": { "type": "object", "properties": {"selector": {"type": "string"}}, "required": ["selector"], }, }, }, { "type": "function", "function": { "name": "finish", "description": "任务完成,请传入最终结果", "parameters": { "type": "object", "properties": { "result": {"type": "string", "description": "给用户的最终回答"} }, "required": ["result"], }, }, }, ] def get_accessible_snapshot(page): """把页面压缩成结构化快照""" lines = [] try: page.wait_for_selector("body", timeout=3000) snapshot = page.accessibility.snapshot() def walk(node, depth=0): if not node: return name = node.get("name", "") role = node.get("role", "") if name and role: lines.append(f"{' ' * depth}[{role}] {name}") for child in node.get("children", []) or []: walk(child, depth + 1) walk(snapshot) except Exception as e: return f"快照获取失败: {e}" return "\n".join(lines[:300]) if lines else "(页面没有可访问性节点)" def execute_tool(tool_name, args, page): """执行工具,返回结构化结果""" try: if tool_name == "open_page": page.goto(args["url"], timeout=15000) page.wait_for_load_state("networkidle") return f"已打开 {page.title()}" elif tool_name == "click": page.click(args["selector"], timeout=5000) return f"已点击: {args['selector']}" elif tool_name == "fill": page.fill(args["selector"], args["text"], timeout=5000) return "已填写文本" elif tool_name == "extract_text": text = page.inner_text(args["selector"], timeout=5000) return text[:2000] elif tool_name == "snapshot": return get_accessible_snapshot(page) elif tool_name == "wait_for": page.wait_for_selector(args["selector"], timeout=8000) return "元素已出现" elif tool_name == "finish": return args["result"] except Exception as e: return f"执行出错: {type(e).__name__}: {e}" def run_agent(task: str, headless: bool = True): with sync_playwright() as p: browser = p.chromium.launch(headless=headless) page = browser.new_page() # 首轮消息:任务 + 初始快照 init_snapshot = get_accessible_snapshot(page) messages = [ {"role": "system", "content": "你是一个浏览器操作助手。你会一次执行一个动作," "每次动作后等待结果,直到完成用户任务。" "定位元素时优先使用get_by_role或text语法。" "任务完成后调用finish工具返回结果。"}, {"role": "user", "content": f"任务:{task}\n\n当前页面快照:\n{init_snapshot}"}, ] for step in range(20): resp = client.chat.completions.create( model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto", ) msg = resp.choices[0].message if msg.tool_calls: messages.append(msg.model_dump(exclude={"role": "function"})) for tc in msg.tool_calls: fn_name = tc.function.name fn_args = json.loads(tc.function.arguments) print(f"[{step + 1}] 调用工具: {fn_name} {fn_args}") result = execute_tool(fn_name, fn_args, page) if fn_name == "finish": browser.close() return result messages.append({ "role": "tool", "tool_call_id": tc.id, "content": str(result), }) else: browser.close() return msg.content or "AI没有给出有效指令" browser.close() return "步骤数超限,任务未完成" if __name__ == "__main__": task = input("请输入你想让AI做的事:") print(run_agent(task, headless=False))3.3 为什么这样设计参数和返回结构
仔细看看工具定义,你会发现每个工具的参数都写得比较细,特别是description里加了很多约束。比如open_page的url参数,我强调"必须带https://"。这不算啰嗦。LLM对空泛的描述会自由发挥,一旦生成https://遗漏,Playwright立刻报错,整个任务就断在第一步。把常见错误扼杀在工具描述里,比事后重试效率高很多。
还有finish这个工具,很多人会忽略。没有它,AI做完任务后会开始自由发挥,比如突然说"任务已经完成,我可以帮你做点别的",然后无限循环。有了finish,等于给Agent一个明确的"结束出口",也方便拿到结构化结果。
工具执行结果统一返回文本,而不是返回Python对象。这里有个好处:LLM的输入输出本来就是文本,你把结果序列化成文本,省去一层格式转换,也减少错误。唯一要注意的是结果长度,extract_text我做了2000字符截断,避免回复内容太长把上下文冲爆。
3.4 用一句话任务跑通全流程
拿"打开百度,搜索Playwright是什么,提取第一条搜索结果"这句话来测试,过程大概是:
- 程序打开空白页,把快照传给AI
- AI发现当前页没有内容,调用open_page("https://baidu.com")
- 页面打开后AI获取新的快照,看到搜索框,调用fill填入"Playwright是什么"
- AI调用click点击"百度一下"按钮
- 等待搜索结果渲染,AI调用extract_text提取结果区域
- AI整理回复并调用finish返回
全程不需要写一行选择器代码。你可能觉得这就是把原来的自动化脚本换了一层皮,但注意一点:这个流程不是写死的,换一个完全不同的网站、换一个完全不同的任务指令,同样代码都能跑。这就是自然语言控制浏览器的价值。
4. 实测翻车记录:AI驱动浏览器最常踩的五个坑
跑通Demo只是开始,实际用起来问题一大把。我挑五个自己翻车最狠的坑详细说一下,基本覆盖了AI操作浏览器的大部分故障类型。
4.1 页面快照太多太杂,AI抓不住重点
有次测试一个后台管理页面,侧边栏几十个菜单,表格上百行数据,快照展开到300行还不够。结果就是AI每一步都在纠结"应该点哪个按钮",明明任务只是"找到用户名是admin的那一行,点编辑"。
后来我给快照工具加了一个"聚焦"参数:可选传入一个CSS选择器,只提取某个区域的快照。AI在明确目标后,先用快照定位大致区域,再聚焦到具体区块。另外快照截断从300行改成"每个区域最多显示80行",信息密度反而更高。
4.2 元素定位符看起来对,但Playwright执行报错
AI最常生成的定位符有两种典型错误:一是用CSS选择器语法生成一个不存在的class;二是根据页面文字定位,但文字里包含空格、换行等隐藏字符。
我的解决办法是给click、fill这些工具加自动降级逻辑。比如AI给出的selector在5秒内定位失败,程序自动尝试几个变体:text=xxx、internal:has-text=xxx、get_by_role的组合。如果仍然失败,才把错误返回给AI,让它根据错误信息重新规划。
这个兜底策略帮我少跑了不知道多少条token。
4.3 页面加载时序:networkidle不是万能的
我最初在open_page工具里强制等待networkidle,觉得等网络请求全部结束总没错吧。结果有个网页内部有轮询接口,每5秒请求一次,networkidle永远等不到,任务直接卡死。
改成domcontentloaded又太急,很多异步渲染的内容还没出来,AI看到的是一个空壳页面。
最终方案是:open_page只等domcontentloaded,然后立即用wait_for选择性等待核心元素出现。我把这个判断也交给了AI——快照返回后,AI结合任务自行判断是否真的需要等待某个关键元素,必要时调用wait_for。人不可能预料所有站点行为,但AI可以临场判断,这才是Agent比脚本灵活的地方。
4.4 AI在ifame和多标签页前期迷失方向
处理"登录第三方平台"这种任务时,页面经常跳到新的标签页,或者弹出一个iframe登录框。我最初只操作默认page,AI莫名奇妙点击失败,日志报"元素不在当前页面"。后来才意识到:AI以为自己在看整个浏览器,其实我只能操作一个page对象。
为此我补充了两个工具:list_pages()列出所有标签页标题和URL;switch_page(title)切换到指定标签页。iframe则更麻烦,我把快照工具扩展为深度扫描iframe内的可访问性节点,并在描述里明确写清"当前页面包含iframe时,自动合并其内容",让AI不用自己猜。
这个改动之后,AI处理跨域登录、社交分享这类任务的稳定性直接上了一个台阶。
4.5 对话历史累积,AI开始遗忘初始任务
长任务做到第15步,AI偶尔会"失忆"。最典型的一次:任务是"统计当前页面所有商品的名称和价格,最后汇总",AI走到第12步,开始纠结页面上某个banner的颜色,整个跑偏。
问题出在每一步的工具结果都塞进messages,历史一长,初始任务被淹没。优化方案是裁剪历史:每轮对话只保留最近4轮的工具调用和结果,但始终把"用户初始任务"放在system消息里,并且每一轮都重复一遍。代价是每轮都多花一点token,但换来的是任务稳定性。
5. 从Demo到Agent:进阶方向与我的三点建议
代码跑通、坑也踩过,接下来怎么把这套东西做成真正能用的工具,我有几个实践方向和建议。
5.1 视觉反馈闭环:多模态模型与截图配合
纯快照模式遇到复杂页面布局,AI还是会"理解偏差"。一个比较有效的进阶方案是引入截图:每次行动后自动截一张图,如果是视觉模型,直接把截图和工具结果一起喂给模型,让AI"看着页面"做决策。
我在一个需要精确点击柱状图的项目里试过,纯快照模式AI定位柱子的成功率不到一半,加上视觉反馈之后,模型可以直接描述"点击从左数第3根柱子",成功率提升到可以接受的水平。缺点是token消耗大,但复杂场景下值得。
当然,如果你的模型不支持视觉输入,截图可以只存盘,任务失败后用trace viewer复盘,调试效率也比只看日志高得多。Playwright自带的trace记录功能把整个会话录下来,操作回放非常方便。
5.2 任务级状态机:让AI做人脑,而不是全自动飞控
完全信任AI每一步决策,风险很高。我后来引入了一个简单的状态机:
- 初始态:接收任务,生成计划草案
- 执行态:一次一个动作,每个动作必须返回"预期结果"
- 校验态:执行后对比实际结果与预期,不一致则进入重试
- 完成态:结果整理
这套机制不限制AI的自由度,但每一步都要过校验。比如AI说"点击登录按钮",程序点击后检查URL是否变化、是否有弹窗出现;没有变化就标记为"疑似失败",AI看到标记后自己决定是重试还是换方案。
5.3 和其他AI平台的对接思路
如果你不想自己维护这套循环,也可以把Playwright封装成工具,接入Dify这类AI应用平台。思路和前面完全一致:把open_page、click、fill这些函数在Dify里注册为自定义工具,让Agent编排调用。我实际用下来,Dify的任务编排、知识库管理和日志追踪对生产环境很友好。自定义工具API接口返回JSON,参数结构直接用前面的TOOLS定义即可。
5.4 我的三点建议
第一,别迷信AI定位元素的能力,但别否定它的规划能力。把定位细节尽量封装在工具层,用降级策略兜底,把规划判断留给AI,这分工最合理。
第二,给Agent设硬性边界。最大步数、超时时间、可访问域名白名单这些一定要有。AI操作浏览器和人操作浏览器一样,该有的权限控制、合规意识不能省,只在自己拥有或被授权的网页里运行。
第三,别一上来就上最强模型。我测试时发现,小模型在简单任务上跑得又快又省,只有遇到复杂交互才需要大模型。一个"小模型预筛选+大模型兜底"的分级方案,成本能省一大截。
当初我以为这玩意就是个玩具,现在它已经成了我日常处理网页数据的主力工具之一。回头复盘,最有价值的不是AI替我省了多少时间,而是它改变了我和浏览器交互的方式:我不再需要提前预判页面长什么样、按钮叫什么名字,只需要说清楚目标,剩下的交给AI临场应对。这种"目标导向"的自动化,才是自然语言控制浏览器真正的意义所在。