
“浏览器智能体”这个标题我第一反应就是自己在做的那个项目——给大模型装上一双“手”让它真的能在浏览器里干活。说白了就是你给AI一句话比如“帮我把这个页面上所有商品的名称和价格抓下来填到那个表格里”它自己打开页面、找按钮、点下去、输入文字、提交表单全程不需要人盯着。这个方向最近特别火业内一般叫它Browser Agent也有人叫网页操作智能体。我把它做成了一个能用的原型过程中踩了不少坑积累了一些非常实际的经验。这篇文章就把我的设计思路、技术方案、以及最关键的避坑经验全部写出来适合正在研究AI应用开发、AI编程辅助、或者想把大模型真正落到业务流程里的朋友参考。1. 先聊清楚浏览器智能体到底在解决什么问题1.1 为什么要有“会点网页”的AI先说个最常见的痛点。现在很多业务系统都是网页形态比如ERP、CRM、各类后台管理平台。这些系统大多没有开放的API你想把数据导出来、或者往里面填数据只能靠人工一点一点操作。我以前帮朋友做过一个数据整理的需求他每天要花两个小时把A系统的订单数据一个个复制粘贴到B系统的表格里枯燥不说还容易出错。这种活儿最适合交给AI干但它需要的不只是“能理解问题”还得“能在界面上动手”。传统RPA工具也能做这种事但有个致命问题它们依赖固定的选择器。网页一改版按钮位置变了整个流程就废了。浏览器智能体的核心区别在于它把“操作网页”变成了一种动态决策过程。AI每一次点击之前都会先“看一眼”当前页面想清楚下一步做什么再动手。页面变了没关系它自己能适应。这就把“写死规则”变成了“实时决策”维护成本大幅下降。1.2 三条实现路线我为什么选了这条路做浏览器智能体目前市面上主要有三条路线我一开始也纠结了很久后来把每条都试了一遍。第一条是纯视觉方案。直接把网页截图丢给多模态大模型让它看图决定点哪里。这个方案的问题很明显——贵、慢、不稳。一张截图要消耗大量token而且模型容易误判元素的位置尤其是遇到下拉菜单、弹窗遮挡的时候。我测试过一个场景弹窗出来后模型愣是没看到后面的按钮连续点了三次空气。第二条是DOM结构方案。把整个HTML源码塞给模型让它自己理解。这个方案的缺点是上下文太长一个普通网页的HTML经常几十万字符根本喂不进去而且大量标签都是无意义的布局元素模型很难聚焦。第三条是可访问性树方案这也是我最终采用的。浏览器本身有一个无障碍接口叫Accessibility Tree它是浏览器根据DOM计算出来的语义结构相当于网页的“骨架”。里面只保留有实际意义的元素按钮、输入框、链接、标题。我把这棵树转成文本喂给模型既保留了语义信息又大幅缩短了上下文长度。实测下来一个复杂页面的AXTree文本通常在2000到5000字符之间模型处理起来非常轻松准确率也远高于单纯看图。选择这条路还有一个关键原因它天然是结构化的。模型输出的动作可以直接映射到页面元素上不像视觉方案那样还需要额外的坐标换算。对我来说这个选择的收益是实实在在的——定位失败率从肉眼可见的频繁降到了偶尔出现。2. 核心原理拆解AI到底是怎么“看懂”网页的2.1 可访问性树把网页变成模型能读懂的“盲文”很多人不理解为什么不用截图、不用HTML偏偏选可访问性树。我用一个生活化的类比解释网页对AI来说就像一份写得密密麻麻的说明书里面有大量无用信息比如某个div是圆的还是方的、背景色是什么、间距多大。这些对视觉类操作有意义但对决策类AI来说是噪音。可访问性树就相当于有人帮你把说明书提炼成一份“要点清单”这里有一个按钮文字是“提交”那里有一个输入框名字叫“用户名”。AI看到的是这份清单而不是原始说明书。具体到技术实现上浏览器会把每个可交互元素提取成一个节点包含角色role、名称name、状态如disabled、checked。比如一个“保存”按钮在AXTree里就是一个rolebutton、name保存的节点。我把这些节点按层级缩进排好用特殊标记控制最大长度再交给模型。这样做还有个好处模型给出的操作目标可以直接映射到节点上不需要猜测坐标。实现这一步在Playwright里其实很简单一行代码就能拿到当前页面的可访问性快照。但要把这些快照整理成模型友好的格式需要自己写一个转换器。比如去掉无意义的静态文本、合并重复节点、处理嵌套的折叠面板等。这个转换器是整个系统里最重要的基础组件我前前后后迭代了三版。2.2 动作空间设计不只是“点一下”这么简单“点网页”听起来就是点击但实际落地的时候动作空间远不止点击一种。一个合格的浏览器智能体至少要支持这六类动作打开新页面、点击元素、输入文本、滚动页面、等待加载、任务完成。缺少任何一个都会在真实场景里卡住。我遇到过一个典型案例某个页面需要先滚动到底部点击“下一页”才能加载后续内容。如果智能体只会点击、不会滚动这个任务就彻底卡死。再比如输入操作除了普通的文本输入还要处理清空原有内容、按下回车提交、键盘快捷键等。后来我把动作空间扩展到了十几种包括focus、hover、press_key、upload_file等。关键点在于动作的定义要既抽象又具体。抽象是说给模型的动作名称要稳定不能一个改版就变具体是说每个动作的参数要明确比如点击操作必须带上选择器输入操作必须带上文本内容。我在这里踩过一个坑早期设计动作格式太自由模型有时候只给一个动作名不给参数执行器只能原地报错。后来我严格定义了动作输出的JSON结构每个动作绑定了必填参数模型的输出规范多了。2.3 视觉识别与结构化定位的互补有了可访问性树之后大部分元素都能通过结构定位找到。但总有一些情况是结构方案搞不定的比如页面里有一个自定义绘制的图表或者某个按钮的文本是通过Canvas渲染出来的。遇到这种场景纯结构方案会失明。我采用了一套互补机制当模型报告某个元素无法通过结构定位时系统会自动切换到视觉辅助模式截取页面局部区域的小图连同坐标一起给模型由视觉模型判断准确位置。这个方法在动态图表或者地图类页面上表现很好但只在必要时启用避免频繁调用视觉模型导致成本飙升。另外我还做了一层容错如果模型给出的选择器在DOM里找不到执行器不会直接报错而是尝试模糊匹配比如按文本包含、按邻近元素的特征来寻找替代节点。这个“定位失败后的自动降级”流程把真实场景里的失败率又压低了一截。2.4 循环控制让AI每一步都有反馈浏览器智能体的本质其实是一个循环看页面、做决策、执行动作、再回看页面。这个循环如果缺少反馈机制模型就会“睁眼瞎”——它以为点击成功了实际上页面根本没反应。我在设计里加了三层反馈。第一层是动作执行结果的即时反馈比如点击某个按钮时执行器会立刻校验按钮是否真的被点击了、状态有没有变化。第二层是页面变化的摘要反馈每次动作执行完系统提取页面上新增的动态信息比如弹窗文案、验证提示把整个页面的可见文本做一次摘要喂回给模型。第三层是历史轨迹的压缩反馈把过去几步的操作记录压缩成一个短列表给模型“复盘”用防止它反复做同一个无效动作。这个循环控制是整个系统中最容易被人忽略、但实际影响最大的部分。模型本身不会“记住”它之前的失误全靠这个循环把状态喂回去。我把最大步数限制在了15步超过就自动终止避免模型在错误路径上越走越远。3. 实操落地从零写一个能自己点网页的智能体3.1 技术栈选型与核心依赖我最终的选型非常轻量Python Playwright 大模型API兼容OpenAI格式的都行。没有用任何重型Agent框架因为这类框架把事情搞复杂了自己写一个执行循环反而更可控。Python自不用说生态完善。Playwright是微软开源的浏览器自动化库比Selenium好用太多它对现代网页标准的支持、自动等待机制、多浏览器支持都是碾压级的。大模型API我用的通用接口既支持普通的文本模型也能接视觉模型方便我在不同任务之间切换。整个项目代码量不大核心文件也就三百多行。安装依赖很简单先装两个包再把对应浏览器内核下载下来。Windows、macOS、Linux都支持实测下来在Linux服务器上跑无头模式最稳定。pip install playwright playwright install chromium3.2 核心代码骨架一个可运行的智能体循环下面这段代码是我项目的核心骨架完全可以直接复制去改。整体逻辑不算复杂先初始化一个浏览器上下文然后进入循环每一步都先截图页面状态、获取可访问性快照再交给模型决策最后执行动作并把结果反馈给模型。import json from playwright.sync_api import sync_playwright SYSTEM_PROMPT 你是一个浏览器操作助手。你会在每一步收到当前任务的描述、历史操作记录、以及当前页面的可访问性文本快照。 请根据这些信息决定下一步执行的动作。只允许输出一个JSON对象不要输出任何多余内容。 可用的动作包括 - open: 在浏览器中打开新页面。参数url - click: 点击页面上的某个元素。参数selector, ref - type: 向输入框输入文本。参数selector, ref, text - scroll: 滚动页面。参数direction, value - wait: 等待片刻。参数seconds - done: 任务已完成退出循环。参数summary 选择器参数ref来自页面快照中的元素编号。如果页面快照里有名为提交按钮的节点你就用它的编号。 输入必须是一个严格的JSON对象格式如下{thought: 你的思考过程, action: 动作名称, params: {...}} def get_accessibility_snapshot(page): 获取页面可访问性树并转换为简洁文本 # 核心是从页面计算得到AXTree再提取关键节点 # 简化实现从页面抓取主要的可交互元素 import re elements page.query_selector_all(button, input, a, textarea, select, [rolebutton]) lines [] for idx, el in enumerate(elements): tag el.evaluate((e) e.tagName.toLowerCase()) text el.inner_text().strip()[:50] placeholder el.get_attribute(placeholder) or role el.get_attribute(role) or tag if tag input: lines.append(f[{idx}] input name{el.get_attribute(name) or } placeholder{placeholder}) elif text: lines.append(f[{idx}] {role} {text}) return \n.join(lines) def execute_action(page, action, params): 根据模型决策执行具体动作并返回执行结果 if action open: page.goto(params[url]) page.wait_for_load_state(networkidle) return 页面加载完成 elif action click: ref int(params[ref]) elements page.query_selector_all(button, input[typebutton], a, [rolebutton]) el elements[ref] el.click() page.wait_for_timeout(800) return 点击完成 elif action type: ref int(params[ref]) elements page.query_selector_all(input, textarea) el elements[ref] el.fill(params[text]) return 输入完成 elif action scroll: page.mouse.wheel(0, int(params.get(value, 500))) page.wait_for_timeout(500) return 滚动完成 elif action wait: page.wait_for_timeout(int(params.get(seconds, 1)) * 1000) return 等待完成 elif action done: return 任务结束 return 未知动作 def run_agent(goal, start_url, max_steps15): with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page(viewport{width: 1280, height: 800}) page.goto(start_url) page.wait_for_load_state(networkidle) history [] for step in range(max_steps): snapshot get_accessibility_snapshot(page) user_prompt f任务目标{goal}\n\n历史操作{json.dumps(history[-5:], ensure_asciiFalse)}\n\n当前页面快照\n{snapshot}\n\n请输出下一步动作。 response llm_call(SYSTEM_PROMPT, user_prompt) decision parse_llm_json(response) if decision[action] done: return True, history result execute_action(page, decision[action], decision[params]) history.append({thought: decision[thought], action: decision[action], result: result}) if decision[action] wait and step 0: # 防止死循环连续两次wait后自动终止 if any(h[action] wait for h in history[-2:-1]): return False, history browser.close() return False, history当然这个版本是最简化的。其中llm_call函数就是你调用大模型的接口这里不展开写具体实现不同厂商的调用方式略有差异但格式都差不多。3.3 关键参数设置温度、步数限制与上下文管理参数调优在这个项目里非常重要而且和普通的文本生成场景差别很大。先说温度temperature。决策型任务我强烈建议设置成0或者极其接近0。我自己的测试结果是温度一高模型就开始“发挥创作力”给你编出不存在的元素编号或者动作参数写错。这个场景就是要求确定性不需要创造性。即使想要一些探索性也不要超过0.3否则整个循环的稳定步数会断崖式下降。再说步数限制max_steps。这个值是保命用的。我曾经有一次让智能体做一个简单的“查找价格”任务结果因为页面一直弹广告模型一直点击关闭按钮陷入了20多步的循环。设置一个硬上限非常必要我一般默认15到20步复杂任务可以放宽到30但绝对不要没有上限。上下文的处理也很关键。因为每一步都会生成新的页面快照历史记录需要做一步压缩。我这里的做法是维护一个最近5步操作的精简列表每步只保留“做了什么、结果怎么样”不保留中间细节。页面快照也是做完即弃不叠加历史。这样能保证上下文长度始终可控不会随着步数增加而膨胀。3.4 提示词设计怎么让模型输出“靠谱”的决策提示词是浏览器智能体能不能跑通的命门。国产模型和开源模型都有个通病你让它输出JSON它可能会给你夹一段解释。我后来想了一个土办法效果出奇好——在系统提示词里加一句“不要输出任何多余内容”同时把输出格式用代码块包起来。模型一般就很听话了。另一个技巧是给每个可交互节点编号。让模型不要描述“点击那个蓝色的提交按钮”而是让它直接说“点击ref23”。这样做有两个好处一是避免模型自己抠字眼去找元素二是编好号的元素在程序端做映射非常容易不需要再做模糊匹配。系统提示词里我还会加一条“如果当前页面没有你需要的元素可以尝试滚动、等待或者明确告诉我办不到不要编造一个不存在的元素”。这句话看似多余其实是踩坑踩出来的。模型在没有把握的时候有一种“强行完成任务”的倾向会编造一个不存在的按钮给你点。明确给它退出的选项反而能减少这种幻觉让它在能力边界处停下来而不是卡住。3.5 完整闭环实战自动登录并抓取数据我从一个真实场景来说明这个系统怎么跑通目标是从一个后台管理页自动登录然后抓取表格里的订单号。第一步智能体打开登录页页面上有账号输入框、密码输入框和登录按钮。模型会依次输出三个动作typing输入账号、typing输入密码、click登录。每一步执行完系统都会刷新页面快照模型能看到登录后的新内容。第二步进入数据页。登录后的页面结构变了出现了一个表格组件。模型根据表头识别出“订单号”这一列然后输出等待加载、重复滚动、采集数据等动作。第三步模型确认数据已经全部采集完成输出done并把汇总结果交给我。这个流程看起来很顺畅但中间很容易翻车。最容易出错的地方是登录按钮点击后的等待时间——如果点击后立刻刷新快照大概率拿到的是登录前的旧页面。我一般会在执行click这类动作后固定加一个800毫秒到1秒的等待再用wait_for_load_state确保网络空闲。这个小细节能减少一半以上的“状态失联”问题。4. 真实场景中的坑与排查技巧4.1 状态失联页面还没加载完就开始点这是我在测试中遇到频率最高的问题。原因很简单模型输出“点击登录”后页面发起了一个异步请求但Playwright的默认等待可能没有覆盖这个场景。解决思路是在执行关键动作后强制等待页面网络空闲。我在execute_action里加了page.wait_for_load_state(networkidle)但这个函数有时也会因为某个长连接比如WebSocket一直挂起而超时。稳妥的退路是配合一个最短等待时间比如800毫秒两者取长。4.2 循环抖动模型反复点同一个按钮很多智能体项目都有这个问题页面弹出对话框模型决定点“确定”但点击后对话框根本没消失。原因往往是对话框的按钮有蒙层遮挡正常点击被拦截了。排查到此我先看Playwright的执行日志确认click是否真的触发了DOM事件然后手动在浏览器里打开同样的页面试一次点击。最后发现是某个CSS动画导致按钮一直在移动点击瞬间目标元素已经变了位置。解决方案是把Playwright的actionability检查打开配合鼠标事件手动触发绕开动画干扰。4.3 元素定位失败动态内容、iframe、shadow DOM网页开发者的“创造力”是智能体最大的敌人。如果目标页面里的内容是在iframe里外界的主框架快照根本看不到里面的元素模型就会以为这个页面上什么都没有。我第一次踩这个坑时页面快照是空的模型直接报“找不到登录框”。后来我在快照生成器里加了递归解析iframe的逻辑把所有子框架的可交互元素合并进主快照问题才解决。Shadow DOM也是类似的坑。某些组件库比如很多UI框架把按钮包在Shadow DOM里普通的query_selector_all扫不到。好在Playwright的穿透能力比较强配合轻量的DOM遍历可以处理大多数情况。我在代码里加了个“深度模式”遇到定位失败就自动切换到穿透查询。4.4 模型“编造”成功反馈机制不闭环这个坑非常隐蔽。早期版本里点击动作执行完我就把“点击完成”作为一个反馈喂给模型。结果模型下一轮就不继续了直接说“任务完成”。我一开始以为是提示词问题后来仔细看了日志模型其实根本没确认页面是否跳转它只是从“点击完成”这个反馈里推断出了“任务成功”。这完全是实时反馈缺失导致的幻觉。我的解决办法是每一个动作执行完成后不急着把结果summary喂回去而是先重新采集页面快照让模型看到新页面与新状态。如果新页面和动作前相比没有任何变化说明动作大概率失效了我会把这个异常情况标出来。这样模型就能感觉到“我点了但好像没反应”然后选择另一种方式继续。4.5 安全与合规数据最小化操作白名单做这类自动化时安全边界非常重要。我的建议是默认用无头模式跑不用登录生产环境用一个专门的测试账号。如果涉及真实数据先在沙箱环境验证整个流程同时做到操作记录全量日志。另外对于支付、删除这类不可逆操作我在动作执行器里增加了一个人工确认钩子模型可以请求执行但在碰到这类动作时停在确认状态由用户手动点击通过。4.6 常见问题排查速查表现象可能原因排查与解法点击后页面无任何反应有蒙层、动画、或者按钮状态disabled查看Playwright执行日志确认是否命中元素手动打开页面复现使用force点击或键盘事件绕过模型反复滚动但位置不对滚动容器不是主页面是内部滚动区在快照里标注滚动容器的reference模型需要定位到具体容器再执行滚动输出JSON格式变形模型生成了Markdown代码块在提示词中明确禁止用代码块包裹输出解析时先剥除markdown标记页面快照太长上下文爆了拦截到的元素太多限制最大节点数过滤不可见元素、隐藏元素、纯装饰文本弹窗遮住关键元素一些网站有固定的悬浮引导层在初始化时加一套公共脚本自动关闭常见弹窗、遮罩、Cookie横幅5. 从单任务到多智能体协作的扩展思路单个智能体跑通之后我做过一个更有意思的扩展把任务拆成“规划者”和“执行者”两个角色。规划者负责分析大目标拆成若干子任务执行者就是上文这个浏览器操控循环每次只执行一个子任务。当执行者完成后规划者会根据结果决定下一步直到全部子任务完成。这个“规划-执行”架构处理复杂任务时比单循环稳定得多。举个例子一个任务是“查看三个不同平台的价格然后给出最低价方案”。单循环要在不同的页面之间来回切换模型很容易忘记前一个页面的数据。多智能体方案里规划者会先把任务拆成“去平台A看价格”、“去平台B看价格”、“去平台C看价格”三个独立子任务执行者每次只需要专注在一个页面上操作做完一个再做下一个。规划者汇总结果给出最终结论。这样每个环节的上下文都很短模型就不用承担大量记忆压力。我还在实验一个“校验者”的角色。执行者完成任务后会有一个单独的大模型实例不参与操作只在最后用目标列表核对实际结果。比如目标是“抓取所有订单号”校验者会数一下执行者采集到的数据条数和页面快照里的数据条数是否一致不一致就自动标记失败。这个策略在数据采集类任务上效果非常显著准确率几乎翻了一倍。对于想要深入研究的朋友我强烈建议在控制循环里加上状态的可视化——把每步的模型思考、动作输出、执行结果、页面快照渲染成一个图流。调试多智能体的时候这个可视化面板能省掉无数个盯日志的夜晚。做完这个项目我最大的感受是浏览器智能体的技术门槛没有想象中那么高真正的难点全在“闭环”和“反馈”这两个词上。只要动作执行有反馈、状态变化有感知、失败路径有回溯哪怕用的模型不是最顶级的也能稳定跑完大多数网页操作任务。如果你是第一次做先别急着接最复杂的业务系统找一个只有两三个步骤的简单页面把整个循环调通再逐步加复杂度。这条路径我是亲身走过来的确实有效。