给大模型装一双手:一个“能自己开 Chrome 把票买完”的 Agent,是怎么设计出来的
这两年聊 Agent 的人很多,但大多数 Demo 还停留在“问一句答一句”的阶段。真正让大模型从“聊天机器人”变成“能干活的人”,关键就差一双手——能操作浏览器、能点按钮、能填表单、能把一整条流程跑完的手。
我最近做了一个比较完整的尝试:让一个大模型 Agent 自己打开 Chrome,自己登录购票网站,自己选择场次座位,完成支付页面前的所有流程,把票“买”到待支付状态。整个过程不需要人手工干预,模型像人一样看页面、做判断、点操作。这中间踩了不少坑,也趟出了一些可复用的设计思路。这篇文章就把整个方案的设计过程、核心模块、实操代码和常见问题完整拆一遍,给同样在折腾 Agent 落地的朋友一个参考。
1. 整体设计思路:为什么是“浏览器操作”,而不是调 API
1.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 | 通过 CDP(Chrome DevTools Protocol)驱动浏览器 | 速度快、自动等待机制强大、支持多浏览器、截图和追踪功能完善 | 相对较新,部分旧项目迁移成本高 |
| Puppeteer | 同样基于 CDP,Node.js 生态 | 轻量、Chrome 系支持最好 | 只支持 JavaScript,不支持 Firefox |
我最终选了 Playwright + Python。原因很实际:Python 生态做大模型相关开发最方便,Playwright 的自动等待机制能大幅减少“元素还没加载出来就点击”这类低级错误,而且它对动态页面的处理能力明显优于 Selenium。
2.2 大模型与浏览器之间的“翻译官”:MCP 还是自研工具调用
确定了浏览器控制工具后,下一个关键决策是:大模型怎么知道浏览器里发生了什么,又怎么下达操作指令?
这里有两套主流做法:
- MCP(Model Context Protocol):Anthropic 提出的标准化协议,把工具能力封装成标准接口,模型可以通过协议直接调用。好处是标准化、可复用,坏处是对于浏览器操作这种高度动态的场景,抽象层级偏高,调试起来反而不够直观。
- 自研工具调用(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_element | element_id | 点击指定编号的元素 |
fill_input | element_id, text | 在指定输入框中填入文本 |
select_option | element_id, option_text | 在下拉框中选择选项 |
scroll_page | direction | 向上或向下滚动页面 |
take_screenshot | 无 | 截取当前页面截图 |
press_key | key_name | 模拟键盘按键,如 Enter、Escape |
wait | seconds | 等待指定时间 |
navigate | url | 跳转到指定网址 |
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(headless=False) context = browser.new_context( viewport={"width": 1280, "height": 800}, locale="zh-CN" ) page = context.new_page() page.goto(url, wait_until="domcontentloaded") 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(f"Step {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', '[role="button"]', '[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, f"URL: {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 - 滚动页面,参数:direction(up/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 f"scrolled {direction}" elif name == "wait": page.wait_for_timeout(args["seconds"] * 1000) return "waited" elif name == "press_key": page.keyboard.press(args["key_name"]) return f"pressed {args['key_name']}" elif name == "take_screenshot": page.screenshot(path=f"screenshot_{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_size=30): # 座位网格的起点和每个座位的尺寸需要预先配置 x = seat_area_start_x + col * seat_size y = seat_area_start_y + row * seat_size page.mouse.click(x, y) return f"clicked 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 拦截,或元素被遮挡 先滚动到元素可见区域再点击,必要时用 force=True模型输出不存在的元素编号 页面在模型决策期间发生了变化 获取快照后标记时间戳,执行前重新校验元素存在性 输入框填入的文字丢失 页面有 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 一个聪明的大脑,但要让这个大脑真正动起来干活,还得靠我们这些做工程的人,把每一根神经、每一块肌肉都接好。