1. 为什么大多数 Playwright MCP 项目最后都沦为玩具
1.1 一个尴尬的行业现状
过去大半年,我陆续看过不下二十个号称"Playwright + MCP"的项目,从个人练手到团队内部工具都有。说句不客气的话,其中八成在演示环节之后就再也没人打开过。它们的共同特征非常明显:跑一个"打开网页、点击按钮、截图"的流程,然后就没有然后了。演示的时候很惊艳,真到要用的时候,你会发现它既不能稳定复现,也没法接入真实业务,更谈不上替代任何现有工作流。
这不是 Playwright 的问题,也不是 MCP 协议本身的问题。问题出在定位上。绝大多数人做这类项目时,脑子里想的是"我要做一个能自动操作浏览器的 AI Agent",而不是"我要解决一个具体的、重复的、有明确验收标准的任务"。前者是 Demo 思维,后者才是工程思维。这两者之间的差距,就是玩具和工具之间的鸿沟。
我写这篇文章,不是要教你从零搭一个 Playwright MCP 的 Hello World——那种教程网上一抓一大把。我想聊的是:当你已经能跑通基础流程之后,怎么把它做成一个真正有人用、能长期维护、经得起真实场景考验的东西。适合的读者是已经对 Playwright 和 MCP 有基本认知、动手跑过 demo、但卡在"下一步该往哪走"的开发者。
1.2 玩具项目的三个典型死法
在展开讲怎么做之前,先说说我观察到的三种最常见的失败模式,你对号入座一下,看看自己是不是正在往坑里走。
第一种:能力堆砌型。项目里塞了几十个工具函数,什么点击、输入、滚动、截图、下载、上传、执行 JS 全都有,看起来功能齐全。但真用起来,模型根本不知道该在什么时候调哪个工具,参数也经常填错。工具越多,模型的决策空间越大,出错概率呈指数级上升。这是典型的"我有什么就给什么",而不是"任务需要什么才给什么"。
第二种:无状态裸奔型。每次调用都是独立的,浏览器上下文不保留,登录状态不维持,页面之间的跳转关系全靠模型自己"记住"。结果就是稍微复杂一点的多步任务,跑到第三步就崩了,因为模型已经忘了前两步干了什么。这类项目在单步演示里看着没问题,一上多步流程立刻原形毕露。
第三种:无验收标准型。项目跑完了,但你怎么知道它跑对了?没有断言,没有校验,没有失败重试,全靠人眼看截图。这种项目在演示时靠运气,在生产环境里就是灾难。一个不能自己判断"我做对了没有"的自动化系统,本质上还是个需要人盯着的半自动工具。
提示:如果你现在的项目同时中了上面三条里的两条以上,别急着加功能,先停下来重新设计架构。功能可以后加,架构错了推倒重来的成本高得多。
1.3 从 Demo 到工具的思维转变
真正能活下来的 Playwright MCP 项目,我总结下来都有一个共同点:它们不是"浏览器自动化 Agent",而是"某个具体任务的自动化执行器"。区别在哪?前者是通用能力,后者是专用工具。通用能力听起来很酷,但落地时处处是坑;专用工具听起来很窄,但能真正解决问题。
举个例子。一个"能操作任意网页的 AI"听起来很厉害,但你让它去填一个公司的报销系统,它大概率会卡在登录验证、动态表单、文件上传这些环节上。而一个"专门处理某类报销单填写"的工具,虽然只能干这一件事,但它把登录、表单映射、附件上传、提交确认全流程都打磨透了,反而能真正省下人力。
所以这篇文章的核心主张是:把 Playwright MCP 当成一个"任务执行引擎"来设计,而不是一个"通用浏览器操作器"。围绕这个主张,我会从架构设计、工具粒度、状态管理、验收机制、可观测性几个维度,把怎么做出一个不像 Demo 的项目讲透。
2. 架构设计:把 MCP 当成任务执行引擎而非万能遥控器
2.1 先想清楚"谁在调用、调用什么、怎么验收"
动手写代码之前,我强烈建议你先在纸上回答三个问题。这三个问题答不清楚,后面写多少代码都是白搭。
第一个问题:谁在调用这个 MCP 服务?是某个对话式助手在实时交互,还是一个批处理任务在后台跑?是人在旁边看着一步步确认,还是全自动无人值守?这个决定了你的工具设计是偏向"细粒度、可中断、可回滚",还是"粗粒度、一次调用完成一整件事"。
第二个问题:调用的是什么?是让模型自由发挥去操作网页,还是执行一个预定义好的、参数化的任务?前者灵活但不可控,后者可控但需要你提前把任务拆解清楚。我的经验是,生产环境里 90% 的场景应该走后者,前者只适合探索性任务。
第三个问题:怎么验收?任务执行完了,成功和失败怎么判定?是看某个元素出现,还是看某个接口返回,还是看数据库里的记录变了?没有验收标准的任务,等于没有终点线。
把这三个问题想清楚,你的架构轮廓基本就出来了。下面我按最常见的"后台批处理 + 参数化任务 + 元素/接口双重验收"这个组合,讲讲具体怎么设计。
2.2 分层架构:把浏览器操作和业务逻辑彻底分开
我见过太多项目把 Playwright 的调用直接写在 MCP 工具函数里,工具函数里又混着业务判断,最后变成一坨谁也不敢改的意大利面。正确的做法是分层,而且层与层之间的边界要划得清清楚楚。
我通常分成四层:
| 层级 | 职责 | 不该做的事 |
|---|---|---|
| 协议层 | 处理 MCP 的请求/响应、参数校验、错误码映射 | 不碰任何浏览器对象 |
| 任务层 | 编排业务流程、决定调用顺序、处理分支和重试 | 不直接写选择器 |
| 操作层 | 封装原子化的浏览器动作(点击、输入、等待) | 不做业务判断 |
| 适配层 | 针对具体站点的选择器、页面结构、特殊处理 | 不写通用逻辑 |
这么分的好处是,当目标网站改版时,你只需要改适配层;当业务流程调整时,你只需要改任务层。协议层和操作层基本是稳定的,可以复用。我自己的项目里,操作层代码半年没动过,适配层倒是改了好几轮,这就是分层带来的维护性。
2.3 工具粒度:少而精,别做"瑞士军刀"
MCP 工具的数量和粒度,是决定项目成败的关键因素之一。我踩过的最大坑就是一开始贪多,给模型暴露了二十多个工具,结果模型的选择困难症比人还严重。
后来我做了个实验:把工具从二十多个砍到五个,任务成功率反而从不到一半涨到了八成以上。为什么?因为工具少了,每个工具的语义更清晰,模型更容易判断"当前该用哪个"。而且工具少了,每个工具内部的逻辑可以做得更扎实,容错和重试都能照顾到。
我的建议是,面向模型的工具控制在 5 到 8 个之间,每个工具对应一个明确的、有业务含义的动作,而不是一个底层的浏览器操作。比如:
start_task:启动一个任务,传入任务类型和参数get_task_status:查询任务当前状态submit_input:在需要人工介入时提交输入confirm_step:确认某个关键步骤abort_task:中止任务get_task_result:获取最终结果
你看,这些工具没有一个直接对应"点击"或"输入",它们都是业务语义的。底层的点击、输入、等待,全部封装在任务层和操作层内部,模型根本不需要知道。这样模型只需要关心"我要启动什么任务、任务进行到哪了、要不要确认",决策负担大大降低。
2.4 状态管理:让任务有记忆、可恢复
无状态是 Demo 项目的通病,也是它跑不了多步任务的根本原因。真实任务往往需要跨多次调用、跨多个页面、甚至跨多个会话。所以你的项目必须有一套状态管理机制。
我的做法是给每个任务分配一个task_id,然后把任务状态持久化到一个轻量的存储里(SQLite 就够了,别上重型数据库)。状态里至少包含:任务类型、当前步骤、已完成的步骤、关键上下文数据(比如登录后的 cookie、页面上的关键 ID)、创建时间、最后更新时间。
有了这个状态,任务就可以做到断点续跑。比如一个任务跑到第三步时浏览器崩了,重启后根据task_id读回状态,从第三步继续,而不是从头再来。这在长流程任务里是刚需,也是区分玩具和工具的重要标志。
注意:状态里不要存敏感信息明文,尤其是登录凭证。我一般只存会话标识,真正的凭证通过安全的引用方式获取,用完即弃。
2.5 幂等性设计:重复执行不能出乱子
自动化任务最怕的就是"执行了一半,重试时又执行了一遍",导致重复提交、重复下单这类事故。所以每个任务步骤都要设计成幂等的,或者说,要有办法判断"这一步是不是已经做过了"。
具体怎么做?我的经验是,在每个关键步骤执行前,先检查目标状态是否已经达成。比如"提交表单"这一步,执行前先检查页面上是不是已经显示了"提交成功"的提示;如果是,直接跳过。再比如"上传附件",先检查附件列表里是不是已经有这个文件了。
这个检查逻辑看起来是额外开销,但它能救命。我见过一个项目因为没做幂等,重试机制触发后给同一个订单提交了三次,虽然最后人工处理掉了,但那种惊出一身冷汗的感觉,我至今记得。
3. 核心细节:让每一步操作都经得起真实场景的考验
3.1 选择器策略:别把希望寄托在脆弱的 CSS 上
选择器是 Playwright 项目的命脉,也是改版时最先崩的地方。我见过太多项目用div > div:nth-child(3) > span这种选择器,网站稍微调整一下结构就全废了。
Playwright 提供了几种选择器策略,我按推荐程度排个序:
第一优先:基于角色的选择器。比如get_by_role("button", name="提交")。这种选择器基于无障碍语义,只要按钮的语义和文案不变,结构怎么调都不影响。这是最稳的。
第二优先:基于文本的选择器。比如get_by_text("确认订单")。文案一般不会随便改,比结构稳定得多。但要注意文案可能有多个匹配,需要配合exact参数或上下文限定。
第三优先:基于测试 ID 的选择器。如果目标网站有>pip install playwright mcp sqlite3 playwright install chromium
这里有个细节:浏览器只用 Chromium 就够了,别装一堆。除非你的目标网站明确要求其他内核,否则 Chromium 的兼容性和性能都是最好的。装多了不仅占空间,还会让启动逻辑变复杂。
4.2 项目目录结构
我习惯的目录结构是这样的:
project/ mcp_server.py # MCP 协议层,工具定义 tasks/ # 任务层,每个任务一个文件 __init__.py base.py # 任务基类 login.py submit_form.py operations/ # 操作层,原子动作封装 __init__.py browser.py wait.py adapters/ # 适配层,站点特定逻辑 __init__.py site_a.py storage/ # 状态存储 __init__.py state.py config/ settings.py logs/这个结构的好处是职责清晰,新人接手时一眼能看出哪块该改哪里。我特别建议把adapters单独拎出来,因为这是改版时唯一需要频繁动的地方,隔离出来能减少误伤。
4.3 任务基类的设计
任务基类是整个任务层的核心,它定义了任务的通用生命周期。我的一般长这样:
class BaseTask: def __init__(self, task_id, params, state_store): self.task_id = task_id self.params = params self.state = state_store self.context = None def run(self): self.context = self.load_context() try: self.execute() self.mark_success() except Exception as e: self.handle_error(e) finally: self.cleanup() def execute(self): raise NotImplementedError def load_context(self): return self.state.get(self.task_id) or {} def mark_success(self): self.state.update(self.task_id, {"status": "success"})子类只需要实现execute方法,把具体的步骤写进去。这样每个任务的代码都很聚焦,不会掺杂生命周期管理的逻辑。
4.4 一个完整的任务实现示例
假设我们要做一个"填写并提交反馈表单"的任务。这个任务看起来简单,但要做得稳,需要考虑不少细节。
class SubmitFeedbackTask(BaseTask): def execute(self): page = self.context.get("page") if not page: page = self.open_page() self.context["page"] = page # 幂等检查:是否已经提交过 if self.is_already_submitted(page): return # 填写表单 page.get_by_label("标题").fill(self.params["title"]) page.get_by_label("内容").fill(self.params["content"]) page.get_by_label("联系方式").fill(self.params["contact"]) # 上传附件(如果有) if self.params.get("attachment"): page.get_by_label("附件").set_input_files(self.params["attachment"]) # 提交前截图存档 self.screenshot(page, "before_submit") # 提交 page.get_by_role("button", name="提交").click() # 等待结果 expect(page.get_by_text("提交成功")).to_be_visible(timeout=10000) # 提交后截图存档 self.screenshot(page, "after_submit")这段代码里有几个关键点值得说。幂等检查放在最前面,避免重复提交。提交前后各截一张图,出问题时能对比看。等待成功提示而不是 sleep,确保真的提交成功了才继续。
4.5 状态存储的实现
状态存储我一般用 SQLite,表结构很简单:
CREATE TABLE task_state ( task_id TEXT PRIMARY KEY, status TEXT, current_step TEXT, context TEXT, created_at TIMESTAMP, updated_at TIMESTAMP );context字段存 JSON 序列化后的上下文数据。读写用简单的封装函数,别搞太复杂。我见过有人为了"优雅"上了一套 ORM,结果状态读写反而成了性能瓶颈,得不偿失。
4.6 MCP 工具的定义
MCP 工具的定义要简洁,参数校验要严格。以start_task为例:
@server.tool() async def start_task(task_type: str, params: dict) -> dict: """启动一个任务""" if task_type not in SUPPORTED_TASKS: return {"error": f"不支持的任务类型: {task_type}"} task_id = generate_task_id() state_store.create(task_id, task_type, params) # 异步执行,立即返回 task_id asyncio.create_task(run_task_async(task_id, task_type, params)) return {"task_id": task_id, "status": "started"}注意这里是异步执行、立即返回。任务可能跑几分钟,不能让 MCP 调用一直阻塞。调用方拿到task_id后,通过get_task_status轮询进度。这是长任务的标准做法。
4.7 参数校验与错误码设计
参数校验别偷懒,该检查的都检查。比如文件路径要检查存在性,URL 要检查格式,必填字段要检查非空。校验失败要返回明确的错误信息,而不是让任务跑到一半才崩。
错误码我建议分几类:INVALID_PARAM(参数错误)、TASK_NOT_FOUND(任务不存在)、EXECUTION_FAILED(执行失败)、TIMEOUT(超时)、BUSINESS_ERROR(业务错误)。分类清晰,调用方才能针对性地处理。
5. 常见问题与排查技巧实录
5.1 元素定位失败的排查思路
元素定位失败是最常见的问题,排查起来有套路。我的顺序是:先看截图,确认页面状态对不对;再看 HTML 快照,确认元素在不在;然后检查选择器,确认写法对不对;最后看时机,确认是不是等太早了。
这里有个小技巧:在失败时自动保存页面 HTML 和截图,命名带上时间戳和任务 ID。这样事后排查时不用重现,直接看存档就行。我一般还会保存一份page.content(),因为有些元素在 DOM 里但不可见,光看截图看不出来。
5.2 超时问题的分类处理
超时也分好几种。加载超时是页面没加载完,可能是网络慢或资源太大;等待超时是元素一直没出现,可能是选择器错了或页面逻辑变了;操作超时是点击或输入卡住了,可能是元素被遮挡或不可交互。
针对不同超时,处理方式不一样。加载超时可以考虑加大超时时间或屏蔽不必要的资源;等待超时要去查选择器和页面逻辑;操作超时要去查元素的可交互状态。别一遇到超时就无脑加大 timeout,那只是掩盖问题。
5.3 浏览器崩溃与资源泄漏
长时间运行的项目,浏览器崩溃和资源泄漏是绕不开的。我的经验是每个任务用独立的浏览器上下文,任务结束后彻底关闭,不要复用。虽然启动开销大一点,但能避免上下文污染和内存泄漏。
另外,定期重启浏览器进程。我一般设置一个阈值,比如每执行 50 个任务就重启一次浏览器,把潜在的内存问题清零。这个策略看起来笨,但实测下来比精细的内存管理更可靠。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 元素找不到 | 选择器失效/页面未加载 | 看截图和 HTML 快照 |
| 点击无反应 | 元素被遮挡/未启用 | 检查元素可交互状态 |
| 任务卡住不动 | 等待条件永远不满足 | 检查等待逻辑和超时设置 |
| 重复提交 | 幂等检查缺失 | 补上执行前状态检查 |
| 内存持续增长 | 上下文未释放 | 检查浏览器关闭逻辑 |
| 登录状态丢失 | 会话未持久化 | 检查 cookie 存储 |
5.5 几个我踩过的坑
坑一:以为wait_for_load_state就够了。实际上很多 SPA 页面加载状态早就变成load了,但内容还在异步渲染。这时候要等具体的元素,而不是等加载状态。
坑二:忽略了 iframe。有些表单嵌在 iframe 里,直接在主页面找元素永远找不到。要先frame_locator切进去。
坑三:文件上传用错了方法。set_input_files只能用于<input type="file">,如果是自定义的上传组件,得用其他方式。这个坑我踩过,排查了半天才发现是组件类型不对。
坑四:并发任务共享了浏览器实例。多个任务同时操作同一个浏览器,上下文互相干扰,结果全乱套。后来改成每个任务独立实例,问题消失。
坑五:日志级别设太高。生产环境把日志设成 ERROR,结果出问题时什么线索都没有。后来改成 INFO,关键步骤都记日志,排查效率高多了。
5.6 可观测性:让项目自己会说话
一个成熟的项目,应该能在出问题时自己告诉你哪里出了问题。我的做法是结构化日志 + 关键指标。
结构化日志用 JSON 格式,每条日志包含任务 ID、步骤名、耗时、结果。这样日志可以直接被采集和分析,不用人肉 grep。
关键指标包括:任务成功率、平均耗时、失败原因分布、重试次数。这些指标定期汇总,能提前发现趋势性问题。比如成功率突然下降,可能是目标网站改版了;耗时突然上升,可能是网络或资源问题。
提示:别等到出问题才想起来加日志。日志和指标应该从第一天就设计进去,这是工程化项目和 Demo 项目的分水岭。
6. 让项目真正被用起来的一些经验
6.1 从最小可用任务开始
别一上来就想做通用平台。选一个具体的、重复的、有明确验收标准的任务,把它做到极致。比如"每天定时抓取某个报表并归档",或者"批量提交某类申请"。这个任务做稳了,再考虑扩展。
我自己的第一个能用的项目,就是帮团队自动填写一个内部系统的日报。任务很窄,但每天省下十几分钟,坚持用了半年多,期间不断打磨稳定性。后来基于这套骨架扩展出了好几个其他任务,都是水到渠成。
6.2 人工介入的接口要留好
再智能的自动化也会遇到搞不定的情况。这时候留一个人工介入的接口比硬扛着强。比如遇到验证码、遇到意料之外的弹窗、遇到需要判断的模糊场景,任务可以暂停,等人来处理,处理完再继续。
这个接口的设计要点是:暂停时保存完整现场(截图、HTML、当前步骤),恢复时能从暂停点继续,而不是从头再来。这样人工介入的成本才低,才有人愿意用。
6.3 版本管理和回归测试
目标网站会改版,你的代码也要跟着改。每次改完,怎么知道没改坏?答案是回归测试。我一般会准备一组固定的测试用例,覆盖主要流程和边界情况,每次改动后跑一遍。
测试用例不用太复杂,能覆盖核心路径就行。关键是要能自动跑、自动判断结果。我见过有人靠手动点一遍来验证,改一次代码验证半小时,久而久之就懒得验证了,然后问题就积累起来了。
6.4 文档和交接
最后说个容易被忽视的点:文档。一个项目如果只有你能维护,那它本质上还是个个人工具。要让它成为团队资产,就得有文档。
文档不用写得多正式,但至少要包含:项目能做什么、怎么启动、怎么配置、常见问题怎么处理、出问题了找谁。我一般会在项目根目录放一个README.md,把这几块写清楚。花半天时间写文档,能省下后面无数次的重复解释。
6.5 我个人的一点体会
做了这么多自动化项目,我最大的体会是:技术的复杂度往往不是瓶颈,对业务的理解才是。一个对业务理解透彻的简单实现,胜过一个技术花哨但不懂业务的复杂系统。
Playwright MCP 这套组合,技术门槛其实不高,难的是想清楚"到底要解决什么问题、怎么算解决好了、出问题了怎么办"。把这三个问题想透,代码怎么写都是对的;想不透,代码写得再漂亮也是玩具。
所以如果你现在正卡在"下一步该往哪走",我的建议是:先别急着写代码,去找那个真正需要被自动化的任务,跟用它的人聊一聊,把验收标准定下来,然后再动手。这一步花的时间,会在后面十倍地省回来。