资讯详情

让大模型操控浏览器:基于Playwright的Agent自动购票系统设计实践

📅 2026/9/15 7:25:33 | 华诺云谱 👁 阅读
让大模型操控浏览器:基于Playwright的Agent自动购票系统设计实践
给大模型装一双手一个“能自己开 Chrome 把票买完”的 Agent是怎么设计出来的这两年聊 Agent 的人很多但大多数 Demo 还停留在“问一句答一句”的阶段。真正让大模型从“聊天机器人”变成“能干活的人”关键就差一双手——能操作浏览器、能点按钮、能填表单、能把一整条流程跑完的手。我最近做了一个比较完整的尝试让一个大模型 Agent 自己打开 Chrome自己登录购票网站自己选择场次座位完成支付页面前的所有流程把票“买”到待支付状态。整个过程不需要人手工干预模型像人一样看页面、做判断、点操作。这中间踩了不少坑也趟出了一些可复用的设计思路。这篇文章就把整个方案的设计过程、核心模块、实操代码和常见问题完整拆一遍给同样在折腾 Agent 落地的朋友一个参考。1. 整体设计思路为什么是“浏览器操作”而不是调 API1.1 三类 Agent “动手”方案我为什么选中了浏览器给大模型接上操作能力目前大致有三条路直接调官方 API比如买票就调票务平台的开放接口。问题是绝大多数业务根本没有公开 API就算有权限、配额、风控也都不是你说了算。RPA 脚本硬编码用传统 RPA 工具录制鼠标键盘操作流程固定能跑但页面一改就废而且毫无智能可言遇到弹窗、验证码、加载延迟就死。Agent 浏览器自动化让大模型充当“大脑”通过浏览器自动化工具充当“手”模型每走一步都观察页面状态自己决定下一步点什么、填什么。页面改版了它能随机应变遇到异常它能自己调整策略。我最终选了第三条路。核心原因很简单浏览器是互联网最通用的“接口”不管后端技术栈是什么只要能打开网页Agent 就能操作。这相当于给大模型配了一双万能的手而不是给每个业务单独定制一双手套。1.2 核心设计目标让 Agent 像人一样“看懂页面再动手”这个项目的目标非常明确我用三句话概括观察Agent 每执行一步操作后必须能“看到”当前页面变成了什么样而不是蒙着眼睛乱点。决策模型要能根据页面内容判断自己处于哪个环节接下来该干什么。执行决策完成后能精确地操作页面元素——点击、输入、选择、滚动一个都不能少。这三点说起来简单但真正落地时每一个环节都有无数细节。比如“看到页面”这一步就有截图像素太大、模型看不懂 DOM、关键信息被遮挡等一系列问题。后面我会逐个拆解。1.3 为什么“买票”是最合适的试验场选“买票”这个场景不是因为我真的天天抢票而是因为它是一个绝佳的 Agent 能力测试场流程长登录、选场次、选座位、填观影人、确认订单五六个环节能测试 Agent 的长流程规划能力。页面状态多有弹窗、倒计时、 loading、售罄提示、网络错误能测试 Agent 的异常处理能力。有实时变化座位图是动态渲染的不是静态页面能测试 Agent 对动态内容的感知能力。结果可验证最终是否到达待支付页面一眼就能判断成功与否方便评估。如果你也想做 Agent 实操项目我非常建议从一个类似的、流程完整且结果可验证的场景入手。2. 工具链选型Chrome 控制方案的关键抉择2.1 浏览器控制的三大主流方案对比确定了“让 Agent 操作浏览器”这个大方向之后第一个要决策的就是用什么工具来控制 Chrome。我对比了目前主流的三套方案方案原理优点缺点Selenium通过 WebDriver 协议驱动浏览器生态成熟、资料多、支持语言广速度偏慢、对动态页面支持一般、定位元素方式老旧Playwright通过 CDPChrome DevTools Protocol驱动浏览器速度快、自动等待机制强大、支持多浏览器、截图和追踪功能完善相对较新部分旧项目迁移成本高Puppeteer同样基于 CDPNode.js 生态轻量、Chrome 系支持最好只支持 JavaScript不支持 Firefox我最终选了 Playwright Python。原因很实际Python 生态做大模型相关开发最方便Playwright 的自动等待机制能大幅减少“元素还没加载出来就点击”这类低级错误而且它对动态页面的处理能力明显优于 Selenium。2.2 大模型与浏览器之间的“翻译官”MCP 还是自研工具调用确定了浏览器控制工具后下一个关键决策是大模型怎么知道浏览器里发生了什么又怎么下达操作指令这里有两套主流做法MCPModel Context ProtocolAnthropic 提出的标准化协议把工具能力封装成标准接口模型可以通过协议直接调用。好处是标准化、可复用坏处是对于浏览器操作这种高度动态的场景抽象层级偏高调试起来反而不够直观。自研工具调用Function Calling自己在代码里定义一组工具函数比如click_element、fill_input、get_page_snapshot让模型以 JSON 格式输出调用指令代码解析后执行。我这个项目里选了自研工具调用。原因是我需要精细控制每一步的观察和操作逻辑而且浏览器操作的工具函数本身不复杂自研反而更灵活。如果你的场景是让 Agent 操作一堆外部系统MCP 会更有优势如果只是聚焦某一个具体的浏览器流程自研工具调用更可控。2.3 环境准备最小可运行的依赖清单动手之前先把环境搭好。我的运行环境是 Ubuntu 22.04 Python 3.10完整的依赖如下pip install playwright openai playwright install chromium playwright install-deps提示playwright install-deps这步不能省。Linux 服务器上缺少系统库是浏览器启动失败的常见原因它会自动安装 Chromium 运行所需的全部底层依赖。OpenAI 的库是用来调用大模型的如果你用的是其他模型服务换成对应的 SDK 即可。模型我建议至少用具备较强工具调用能力的比如 GPT-4o 或者 Claude 系列小参数模型在这个场景下很难稳定输出正确的结构化指令。3. 核心循环拆解观察—思考—行动的完整闭环3.1 Agent 的工作循环每一轮都像人在操作电脑整个 Agent 的核心是一个循环我把每一轮迭代设计成这样1. 获取当前页面快照文本化的结构化信息 截图 2. 把快照发给大模型附带任务目标和历史操作记录 3. 大模型输出决策要么执行某个操作要么宣布任务完成/失败 4. 代码解析决策调用 Playwright 执行对应操作 5. 回到第 1 步直到模型宣布结束或达到最大轮数这个循环本质上模拟的是人操作电脑的过程看一眼屏幕想一下怎么做动一下鼠标键盘再看一眼屏幕确认结果。每一步之间都有“反馈”闭环模型不是盲猜下一步而是基于最新状态做决策。也正是因为这个特性每一轮的页面快照质量直接决定了整个 Agent 的上限。这就是为什么我在“观察”环节花了远比其他环节更多的时间。3.2 最关键的一步如何把网页“翻译”给大模型大模型是纯文本模型它没法直接“看”浏览器里的 HTML 或者截图。所以必须把页面状态转化成模型能理解的信息。我试过好几种方案这里直接说结论第一纯 DOM 文本化是最可靠的信息来源。我把页面上的可见文本、按钮文字、输入框占位符、链接文字等提取出来转成结构化文本。模型通过这些文字就能理解页面在说什么。第二截图是辅助但很有效。对于座位图这类纯图形信息文本提取是无效的必须依靠视觉模型看截图。我的做法是把截图缩小后发给模型让模型基于图像判断座位位置。第三元素编号是连接“模型决策”和“代码执行”的桥梁。页面上的每个可交互元素我都给一个唯一编号比如[42] 按钮 [立即购买]。模型的输出不需要描述“请点击页面左上角那个红色按钮”只需要说“点击元素 42”。这样设计的好处是极大地降低了模型的输出难度。让模型写 CSS 选择器或者 XPath 是不现实的但让它从编号列表里选一个数字准确率会高很多。3.3 行动空间的设计有限的原子操作无限的组合可能我定义了这样一组原子的工具函数模型的所有操作都是由它们组合而成的工具名参数作用get_page_state无获取当前页面文本化快照click_elementelement_id点击指定编号的元素fill_inputelement_id, text在指定输入框中填入文本select_optionelement_id, option_text在下拉框中选择选项scroll_pagedirection向上或向下滚动页面take_screenshot无截取当前页面截图press_keykey_name模拟键盘按键如 Enter、Escapewaitseconds等待指定时间navigateurl跳转到指定网址task_finished无宣布任务完成这套设计的原则是工具要原子化不能让模型一步完成多个动作。如果我把“选票并提交订单”封装成一个工具模型就失去了对中间过程的控制权一旦页面结构和预期不符整个流程就废了。拆成原子的 click 和 fill模型每一步都在感知页面变化适应能力会强很多。3.4 让页面“开口说话”状态简报的生成逻辑在把页面信息发给大模型之前我还会让代码层自动生成一段“状态简报”包含当前 URL 和页面标题当前处于流程的哪个阶段通过 URL 或关键元素判断页面上是否有弹窗、倒计时、错误提示等关键信号最近一次操作的结果点击成功、元素未找到、输入完成这段简报会连同文本化页面信息一起发给模型。别小看这一步它相当于给了模型一个“短期记忆锚点”。模型不必每次都从头理解页面而是能快速定位“我现在在哪、刚才发生了什么”。4. 实操实现让 Agent 从打开浏览器到完成购票4.1 搭建 Agent 主循环框架下面是整个 Agent 的核心循环代码我用最简洁的方式把它写出来import json from openai import OpenAI from playwright.sync_api import sync_playwright client OpenAI() class BrowserAgent: def __init__(self, task): self.task task self.history [] # 记录所有历史操作和观察结果 self.max_steps 30 # 最大执行轮数防止死循环 def run(self, url): with sync_playwright() as p: browser p.chromium.launch(headlessFalse) context browser.new_context( viewport{width: 1280, height: 800}, localezh-CN ) page context.new_page() page.goto(url, wait_untildomcontentloaded) for step in range(self.max_steps): # 1. 获取页面状态 state self._get_page_state(page) # 2. 构造发给模型的上下文 messages self._build_messages(state) # 3. 让模型做决策 decision self._ask_model(messages) print(fStep {step}: {decision[thought]}) # 4. 执行决策 if decision[action] task_finished: print(任务完成) break executed self._execute_action(page, decision[action]) self.history.append({ step: step, decision: decision, result: executed }) browser.close()4.2 页面状态提取从 DOM 到结构化文本_get_page_state是这个项目里最重要的函数核心逻辑是遍历页面上的可交互元素并编号。我简化后的实现如下def _get_page_state(self, page): # 在浏览器上下文中执行 JS提取可交互元素 elements page.evaluate( () { const results []; const selectors [ button, a, input, select, textarea, [rolebutton], [onclick] ]; const nodes document.querySelectorAll(selectors.join(,)); let index 0; nodes.forEach(node { const rect node.getBoundingClientRect(); // 只保留可见且尺寸合理的元素 if (rect.width 0 rect.height 0 rect.width 800 rect.height 200) { let text node.innerText || node.value || node.placeholder || node.textContent || ; text text.trim().slice(0, 50); if (text) { results.push({ id: index, tag: node.tagName.toLowerCase(), text: text, x: Math.round(rect.x), y: Math.round(rect.y) }); } } }); return results; } ) # 转成文本化描述 lines [ 页面可交互元素 ] for el in elements: lines.append(f[{el[id]}] {el[tag]} {el[text]} (位置:{el[x]},{el[y]})) # 附加当前 URL 和标题 lines.insert(0, fURL: {page.url}) lines.insert(1, f标题: {page.title()}) return \n.join(lines)这里有两个关键细节过滤不可见元素很多页面的 DOM 里有大量隐藏元素比如下拉菜单里未展开的选项如果不过滤模型会被无效信息干扰。我通过 getBoundingClientRect 判断元素是否有实际渲染尺寸。限制文本长度每个元素最多取 50 个字符避免一个按钮文字超长把其他信息挤掉。页面元素过多时我还会做截断最多保留 60 个元素超出部分舍弃。4.3 模型决策与工具执行模型决策部分我使用的是 OpenAI 的 function calling。系统提示词是决定成败的关键我的完整写法是SYSTEM_PROMPT 你是一个浏览器操作助手任务是通过操作浏览器完成购票流程。 你可以使用的工具 1. click_element - 点击指定编号的元素 2. fill_input - 在输入框中填入文本参数element_id, text 3. select_option - 在下拉框中选择选项参数element_id, option_text 4. scroll_page - 滚动页面参数directionup/down 5. wait - 等待参数seconds 6. press_key - 按键参数key_name 7. take_screenshot - 截图查看视觉效果 8. task_finished - 确认任务已完成 操作原则 1. 一次只执行一个操作执行后观察页面变化再决定下一步 2. 如果点击后页面没有变化尝试等待 1-2 秒再观察 3. 遇到弹窗先关闭弹窗通常有关闭按钮或可以按 Escape 4. 如果遇到错误提示截图确认错误内容 5. 不要重复执行已经做过的操作 6. 当确认所有购票步骤已经完成进入支付页面调用 task_finished 7. 如果连续失败 3 次以上说明遇到了无法解决的问题调用 task_finished 并说明情况 用户的任务{task} 执行层的核心函数是_execute_action它会根据模型返回的 action 名称分发到对应的 Playwright 调用def _execute_action(self, page, action): name action[name] args action.get(arguments, {}) if name click_element: el_id args[element_id] # 通过编号重新定位元素元素编号是全局递增的需要重新提取 elements self._get_elements_plain(page) target elements[el_id] locator page.locator( f{target[tag]}:has-text({target[text][:20]}) ).first locator.click() return clicked elif name fill_input: el_id args[element_id] text args[text] elements self._get_elements_plain(page) target elements[el_id] locator page.locator( f{target[tag]}[placeholder{target[text]}] ).first locator.fill(text) return filled elif name scroll_page: direction args[direction] delta_y -600 if direction up else 600 page.mouse.wheel(0, delta_y) return fscrolled {direction} elif name wait: page.wait_for_timeout(args[seconds] * 1000) return waited elif name press_key: page.keyboard.press(args[key_name]) return fpressed {args[key_name]} elif name take_screenshot: page.screenshot(pathfscreenshot_{len(self.history)}.png) return screenshot saved注意这里用「文本内容定位元素」的方式有一个隐藏坑。如果页面上有两个按钮文字相同:has-text会匹配到多个节点必须加.first限定。更稳妥的做法是在提取元素时同时记录一个稳定的 DOM 属性如>def _click_seat(self, page, col, row, seat_area_start_x, seat_area_start_y, seat_size30): # 座位网格的起点和每个座位的尺寸需要预先配置 x seat_area_start_x col * seat_size y seat_area_start_y row * seat_size page.mouse.click(x, y) return fclicked seat at col {col}, row {row}这一块的通用性不如前面几个工具但它验证了一个重要思路当 DOM 信息不足时视觉理解 坐标映射是可以走通的替代方案。如果你的项目里也会遇到 canvas 绘制的内容地图、图表、画板等这个思路可以直接借鉴。4.5 完整演示一场真实的购票运行记录我用这个 Agent 跑过一次完整流程记录如下Step 0: 打开购票网站首页看到 [登录] 按钮 Step 1: 点击 [登录]页面跳转到二维码登录页 Step 2: 等待二维码出现截图确认 Step 3: 用户扫码登录页面自动跳转回首页 Step 4: 点击 [选座购票] 进入场次列表 Step 5: 识别到当前没有可购买场次点击 [明天] 切换日期 Step 6: 看到目标场次 [19:30 场]点击进入选座页 Step 7: 截图查看座位图选择座位 (13, 8) 和 (13, 9) Step 8: 点击 [确认选座] Step 9: 填写观影人从列表中选择已保存的联系人 Step 10: 点击 [提交订单] Step 11: 确认进入支付页面调用 task_finished总共 11 步耗时约 1 分 40 秒。过程中没有人工干预模型在 Step 5 面对“当天无可售场次”这个意外情况时自己决定切换日期这个表现比我预期的要好。5. 踩坑实录那些文档里不写的问题和排查方法5.1 问题速查表高频故障与对应解法现象根因解决方案模型反复点击同一个元素页面无反应点击被 JS 拦截或元素被遮挡先滚动到元素可见区域再点击必要时用forceTrue模型输出不存在的元素编号页面在模型决策期间发生了变化获取快照后标记时间戳执行前重新校验元素存在性输入框填入的文字丢失页面有 input 事件监听直接赋值不触发改用locator.type()模拟真实键盘输入弹窗一直出现导致流程卡住弹窗是异步加载的模型没有及时感知在状态简报里强制附加弹窗检测结果模型陷入重复操作死循环上下文中缺少“已执行操作”的记录将历史操作摘要注入每一轮的 prompt截图里的元素模型看不到截图分辨率太高关键元素太小截图前先按元素位置裁剪局部图或缩放至合适分辨率Chrome 在 Linux 服务器上启动崩溃缺少系统依赖库执行playwright install-deps安装全部依赖页面加载极慢导致等待超时图片和静态资源阻塞加载使用page.route拦截图片请求加速页面加载5.2 最隐蔽的一个坑元素编号不稳定导致误点这是我在调试过程中遇到的最诡异的问题模型明明输出了正确的元素编号但点击的却是完全不相干的地方。排查了很久才找到原因。我的编号方案是每次提取页面元素时从 0 开始递增但页面如果有动态内容更新比如倒计时文本变化两次提取的元素列表会因为文本长度不同而错位——第 15 个元素在第二次提取时可能变成了第 16 个。解决方案是在一次决策轮次中只提取一次元素编号模型决策输出后立刻执行执行前不再重新编号如果执行时元素已经失效触发重试机制重新提取页面状态后再决策一次。def safe_click(self, page, el_id, state_elements): # 只在当前轮次内使用提取时的元素信息 if el_id len(state_elements): return {error: element id out of range, need re-observe} target state_elements[el_id] # 执行点击 ...5.3 防反爬与风控一个需要摆正心态的话题很多人担心 Agent 操作浏览器会被网站风控识别。我的实际经验是如果你的项目是给自己买票、测试自己的账号只要操作频率合理大部分网站不会特别针对。但如果你要做大规模自动化那就是另一回事了。我建议的做法是控制操作速度每步之间至少间隔 0.5-1 秒不要像机器一样瞬间连点。使用真实用户代理Playwright 默认的 UA 带有HeadlessChrome字样改成正常 Chrome 的 UA。不要并发开太多实例一次一个浏览器实例做完关掉别搞几十个并发。尊重网站规则不绕过验证码、不破解登录限制、不用于抢票倒卖。Agent 是工具合理使用的前提是遵守平台规则。特别提醒做 Agent 自动化一定要把握好边界。技术上能做到的事情不代表就应该做。我这个项目的目的是验证技术方案而不是提供抢票工具这一点在项目定位上一定要清楚。5.4 调参心得三个最影响成功率的参数调了一段时间后我发现有三个参数对最终成功率影响极大最大轮数max_steps设小了容易在复杂流程中途被截断设大了模型会在失败场景里浪费大量时间。我的经验值是 30-50 轮每轮包括一次模型调用和一次页面操作足够覆盖大多数 10 步以内的流程同时不会无限跑下去。等待策略Playwright 的 auto-wait 是一个好东西但默认的等待条件元素可见对某些场景不够。比如按钮可见但被半透明遮罩挡住点击仍然会失败。我在关键步骤前会额外加一个 300-500ms 的固定等待虽然慢一点但稳定性提升明显。模型温度temperature工具调用场景下温度必须设得很低。我用的值是 0确保模型每次都选择最确定的那个操作。温度高的时候模型会“发挥创意”在一个明明已经填好的表单上反复纠结这是最浪费轮数的行为之一。6. 这个设计还能怎么扩展6.1 从“买票”到“任意浏览器流程”整个架构里和“买票”强相关的部分其实只有状态简报里的领域提示词和座位点击那一个工具函数。其余的观察模块、决策循环、工具执行框架都是通用的。我后来把同样的框架迁移到了另一个场景——自动填写并提交一份多步骤的长表单包含上传附件、多级联动选择等核心代码几乎没改只调整了初始 URL 和任务描述。这验证了这套设计的可复用性换一个场景你只需要换任务描述、微调元素提取规则、补充领域相关的工具函数框架本身不用动。6.2 未来可以做的几个增强方向如果这个项目继续迭代我会优先做这三件事一是引入记忆模块把之前的成功路径缓存下来下次执行类似任务时优先参考减少试错轮数。二是增强异常恢复能力比如断网重连、页面崩溃后自动重启浏览器、从断点继续执行而不是从头再来。三是接入更丰富的感知方式比如监听网络请求来判断操作是否真正生效点击按钮后有没有发出对应的 API 请求这比纯看 DOM 变化更可靠。这些方向做好了Agent 就不再只是“能跑通一条路”而是真正像一个有经验的人在操作浏览器了。最后说一点个人感受做这个项目的过程中我最大的体会是Agent 的智能程度固然重要但工程细节才是决定成败的关键。页面状态怎么提取、元素怎么定位、历史记录怎么管理、异常怎么恢复每一个看起来不起眼的环节都可能成为整个系统的瓶颈。大模型给了 Agent 一个聪明的大脑但要让这个大脑真正动起来干活还得靠我们这些做工程的人把每一根神经、每一块肌肉都接好。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。