浏览器智能体框架源码解析:从DOM到动作执行的完整链路
最近把一套开源的浏览器智能体框架从头到尾读了一遍起因是我想搞清楚一个大模型到底是怎么做到“看着网页、点着网页、把活干完”的。市面上讲 Agent 概念的文章很多但真正落到底层逻辑的太少。很多人以为 AI 操控网页是什么黑魔法其实拆开之后你会发现核心就两件事第一把网页结构压成模型能读的文本第二把模型输出的意图翻译成浏览器能执行的动作。这篇东西我打算从源码层面把这套机制完整捋一遍读完你不仅能看懂这类框架的原理还能自己动手改出适合业务场景的浏览器智能体。1. 两个绕不开的问题网页怎么被“看懂”操作怎么被“执行”先说一个很多人都忽略的事实大模型本身既不认识网页也不会点击按钮。它只能接收文本、输出文本。所以任何浏览器智能体框架本质上都在做同一件事——在这两层之间搭一座桥。第一层叫信息压缩。一个普通网页的 DOM 节点动辄几百上千个大型后台管理系统一个页面几万个节点也不稀奇。如果把这些原始 DOM 全部塞进大模型的上下文先不提 token 烧钱的问题模型根本抓不住重点。它需要的是“哪些按钮可以点、哪些输入框可以填、哪些文字描述了这个页面的状态”而不是那些埋在 style 里的颜色、字体、布局信息。第二层叫动作投影。即便模型看懂了页面它也没法自己移动鼠标。它只能输出一段文本说“我想点击搜索按钮我想在输入框里填入关键词”。框架要做的是把这段自然语言或者结构化指令变成浏览器协议层面的真实调用比如page.click(selector)、page.fill(selector, text)。这两层需求决定了框架的基本架构一个负责把 DOM 转成紧凑文本的格式化模块一个负责定义动作集合和执行动作的执行器还有一个把两者串起来的 Agent 主循环。理解了这三个模块整个开源项目的骨架就在你脑子里了。很多人直接上手读代码会被各种抽象类绕晕我的建议是先抓住一个核心数据流网页 - 可交互元素列表 - 文本快照 - 模型 - 动作 JSON - 浏览器执行 - 新的页面状态。这个循环跑通了剩下的一切都是在这个主线上做工程优化。2. 网页的压缩与重建DOM 树如何变成 LLM 能读的文本这一章讲框架中最基础的模块——页面表示层。它解决的是“怎么让模型看见网页”。2.1 可交互元素的提取不是所有节点都值得进上下文我在读源码时发现这个框架的第一步并不是把整个 DOM 序列化成文本而是先做一次“可交互元素提取”。它遍历整个 DOM 树把所有符合交互条件的节点挑出来生成一个元素清单。这个清单大概长这样class InteractiveElement: index: int # 元素编号模型通过这个编号引用元素 tag: str # 标签名如 button、input、a、textarea text: str # 可见文本内容 attributes: dict # 关键属性如 id、class、name、type xpath: str # 元素定位路径判断一个元素是否“可交互”框架里有几层过滤逻辑。第一层是标签过滤button、input、a、textarea、select必然保留因为它们是天然的用户交互入口。第二层是可见性过滤元素不能是display: none、visibility: hidden、opacity: 0尺寸不能为 0不能脱离视口。第三层是状态过滤disabled的按钮、只读的输入框通常会被剔除因为它们点了也没用。这里有个工程上的小技巧值得注意判断元素可见性的顺序是有讲究的。先判断标签类型成本极低再判断 CSS 属性需要计算样式成本中等最后判断是否在视口内需要几何计算成本最高。这个顺序能在大页面上省下大量计算时间。2.2 从元素清单到模型文本编号和元信息的组织方式拿到可交互元素清单后下一步是把它们翻译成模型能读的文本。这个框架的做法非常直接——它在每个元素前面加一个索引标记把整个页面变成一张“编号地图”。格式化后的页面快照大致长这样Page Title: 商品管理后台 URL: https://example.com/admin/products [1] button 新增商品 [2] input placeholder搜索商品名称 [3] button 搜索 [4] a 下一页 [5] div 共 23 条记录这种格式对模型极其友好。编号给了模型一个“指代方式”标签和文本给了模型“判断依据”placeholder 和 aria-label 这些属性进一步补充了元素的语义信息。我在调试这类框架时发现一个很实用的技巧直接把格式化后的文本打印出来看。如果模型行为异常十有八九是这一步出了问题——要么该保留的文本被裁掉了要么无关的导航按钮占用了大量 token。你需要在调试器里导出这份文本站在模型的角度去审视如果我只看到这些信息我能正确操作这个页面吗这一步能快速定位很多“看起来是模型笨、其实是喂料有问题”的案例。2.3 容易被忽略的数据源iframe、Shadow DOM 与图片的文本替代如果你只解析主文档的 DOM一定会踩坑。现在很多中后台系统都在用 iframe 内嵌表单流量大的电商页面也喜欢用 Shadow DOM 封装组件这些节点在主文档树里就是一个空壳真正的可交互元素藏在子文档里。这个框架的处理方式是递归遍历所有 iframe 的 contentDocument把内部的可交互元素并入主清单再在文本里标注这些元素来自哪个 iframe。标注很重要因为执行动作时需要切到对应的 frame 上下文。另外一个是图片的处理。很多导航栏是图标加文字文字可以提取但纯图标按钮就需要靠aria-label或者alt属性。如果这两个都没有框架会做一次视觉兜底——把截图里对应的区域标记出来配合视觉模型识别。这个能力不是默认开启的因为调用视觉模型的成本比纯文本高不少通常只在文本信息不足时才启用。这里的取舍逻辑很值得学习文本能搞定的就绝不麻烦视觉模型一个模块的复杂度应该匹配它解决的实际问题而不是越强大越好。3. 动作不是自由发挥交互协议与可执行动作空间模型看懂了网页接下来要“动手”。这一章讲框架怎么定义和管理动作。3.1 动作用 JSON 定义而不是自由文本这个框架在系统提示词里明确要求模型以 JSON 格式输出下一步动作。原因不难理解JSON 结构稳定、容易解析、便于做参数校验。如果让模型自由输出文本比如“点击一下页面底部的确认按钮”解析层还得做语义理解准确率和鲁棒性都会下降。动作的 schema 大致如下{ actions: [ { action: click, index: 3, description: 点击搜索按钮 }, { action: input_text, index: 2, text: 无线耳机, description: 在搜索框输入关键词 } ] }注意这里是一个动作数组允许模型一次输出多个连续动作。框架内部会按顺序逐个执行每执行一个就重新抓取一次页面状态避免多个动作基于同一个过期快照导致定位失败。3.2 动作白名单每个动作都要对应一个真实可执行的方法我在源码里看到框架预定义了一套动作集合每一种都对应执行器模块中的一个具体函数。常见的动作包括动作名参数执行语义clickindex点击指定编号的元素input_textindex, text在指定元素输入文本scrolldirection页面滚动select_optionindex, option下拉框选值navigateurl地址栏跳转switch_tabtab_id切换标签页go_back无浏览器后退extract_contentindex提取元素内容文本wait无等待网络空闲动作白名单是框架可控性的基石。模型再聪明也只能在这套固定动作里组合不能自由发挥。这种“有限动作空间”的设计哲学在 Agent 系统里非常关键——你不可能让一个语言模型直接操作正则表达式去修改网页但你可以让它选择“等待 0.5 秒”或者“点击重试按钮”。3.3 参数校验与失败的反馈回路动作执行不是拿到 JSON 就直接跑中间还有一层校验。比如click动作的index必须是整数且在索引范围内input_text的text长度不能超过限制navigate的 URL 必须通过合法性和协议检查。如果校验失败框架不会直接崩溃而是把错误信息追加到对话历史中让模型看到“我刚才提交的 action 非法原因是索引越界”然后由模型自行修正。这个设计非常优雅——它不是用 if-else 去覆盖每一种错误而是把错误当成上下文喂回给模型利用模型自身的推理能力完成自我修复。这也解释了为什么这类框架通常需要多轮调用模型一次推理往往不够模型需要基于上一次动作的执行结果逐步调整。整个系统的智商上限取决于模型但工程下限取决于错误反馈链路是否通畅。4. 一个“想一步、做一步、看一步”的循环主控逻辑的拆解前面讲的是“眼睛”和“手”这一章看“大脑”怎么调度它们。Agent 主控循环是整个框架的中枢神经系统。4.1 状态机的流转从页面快照到动作执行主循环可以用一个简化的状态机来描述while step max_steps: snapshot get_page_snapshot(browser_state) messages.append({role: user, content: format_messages(snapshot)}) response llm.chat(messages) actions parse_actions(response) for action in actions: if action.name done: return summarize(result) result execute_action(action) if result.is_error(): messages.append({role: system, content: f执行失败: {result.error}}) break else: messages.append({role: system, content: f成功: {action.to_string()}}) step 1这个循环里最微妙的一点是每次模型回复之后框架不会立刻判定失败而是先把动作结果写进消息历史然后开始下一轮。换句话说整个 Agent 运行过程是一个连续对话——“我看到了什么、我做了什么、结果怎么样、我下一步做什么”。4.2 上下文的增长与裁剪无限对话是不可能的。每一步动作加上页面快照少说也要一两千 token跑 20 步之后上下文就非常拥挤了。这个框架的处理方式是在窗口溢出时保留系统提示词和最初的目标描述把中间的执行过程摘要化。摘要化有两种实现。一种是直接截断只保留最近 N 轮另一种是用一次额外的模型调用把中间过程压缩成一段摘要。前者省 token后者信息保留度好。源码里默认用截断但预留了摘要策略的扩展接口。这里我建议你在二次开发时认真考虑上下文策略和任务类型的匹配。如果任务是单页表单填写最近几轮的信息就够用如果是多页面长链路操作历史中的关键中间状态比如之前拿到的订单号、跳转前的页面 URL必须保留否则模型会“失忆”重复执行已经完成的前置操作。4.3 断点续跑与序列化长任务的救命稻草这个框架支持把 Agent 的完整运行状态序列化到本地文件。状态包括对话消息列表、当前页面 URL、已执行动作的记录、剩余步数。如果中间进程崩溃可以从断点恢复继续跑。这个能力看起来不起眼但对真实业务非常重要。长任务的 LLM 调用链跑一次动辄几十步中途任何网络抖动或者进程被杀都可能前功尽弃。断点续跑相当于给了整个系统一个“存档”能力让长时间运行的 Agent 任务具备可恢复性。5. 比点击更难的环节动态页面里的元素定位策略前面讲过模型操作动作里携带的是索引编号。但真实浏览器执行点击时不能用索引直接点击——索引只是快照里的排序页面经过任何一次渲染变化就可能失效。这一章专门拆解框架的定位策略也是最容易出现自研问题的地方。5.1 四种定位策略的优先级与适用场景框架内部对元素的定位分为四个层级按优先级从高到低排列优先级定位方式原理适用场景1索引直接定位从当前快照列表取节点引用页面未改版快照仍有效2选择器回退用节点的 id、稳定 class、data 属性生成 CSS 选择器页面局部刷新结构未变3XPath 定位根据元素层级路径定位结构稳定无 id/class4语义重定位触发重新抓取 DOM生成新快照后交给模型重找页面大范围重建第一层级其实很快因为元素清单本身还保留着节点对象引用点击就是直接调element.click()。但如果点完一次之后页面发生了 React 或 Vue 的重新渲染旧引用就失效了这时候必须降级到第二层级。5.2 为什么不能用保存的 JavaScript 对象引用一直点很多第一次做浏览器自动化的人会踩一个坑我明明已经拿到了某个按钮的元素对象为什么下一次点击就报element is detached from the DOM原因是现代前端框架几乎都是“数据驱动视图”状态一变整个组件子树都会重新渲染旧的 DOM 节点被销毁、新节点被创建。你手里拿的那个引用指向的是一栋已经被推平的老房子地址虽然一样但住户已经换了。所以任何只缓存节点引用的方案在 SPA 应用面前都会失效慢。这个框架的做法是动作执行立刻重新获取页面状态每一轮都基于最新快照做决策。上一轮的索引只在那一轮有效进入下一轮就要重新生成。这个“每步一快照”的设计牺牲了一点 token但换来了定位的可靠性。5.3 定位失败之后的“重新理解”机制定位策略全部失败后框架不会把动作标记为 fatal而是把“元素未找到”的信息连同当前页面的新快照一起交给模型让模型再想别的办法。比如点击“确认付款”按钮失败可能是因为真正该点的是弹窗里的“确认”或者按钮文字被改成了“立即支付”。模型看到新快照之后会自动调整策略。这种“失败即反馈”的机制实质上是把元素定位从“程序逻辑问题”转化成了“模型理解问题”。后者显然更容易解决因为模型天然擅长文字的灵活匹配。6. 状态管理与安全兜底能让 Agent 放心裸奔的前提Agent 跑得再好如果安全措施跟不上就是定时炸弹。这一章聊框架在状态管理和安全兜底上的工程细节。6.1 Token 预算与步数上限双重保险框架默认设置了最大执行步数比如 50 步。超过步数上限主循环强制终止返回当前状态供人工接管。这个设置防止了模型在错误方向上死循环烧钱。token 预算方面框架在每次调用模型前会估算消息列表的 token 数超限就触发裁剪策略。裁剪不是简单砍掉最早的消息而是优先保留目标描述和最近一步的快照——这两个是最重要的决策依据。6.2 浏览器上下文的隔离与复用对于多任务并发场景框架为每个任务创建单独的浏览器上下文。上下文之间完全隔离cookie、localStorage、sessionStorage 互不干扰。任务结束后可以关闭并清理避免内存泄漏。这里值得注意的是“用户数据目录”的概念。如果任务需要保持登录态可以挂载一个持久化的用户数据目录让每次运行都复用同一套浏览器配置。这在跑需要登录的业务系统时非常实用省去了每次都要走登录流程的麻烦。6.3 动作执行的错误分类与重试策略源码里对执行错误做了分类不同的错误走不同的处理路径错误类型典型场景处理策略元素未找到页面状态变了、选择器失效降级定位未果则反馈模型重决策元素不可交互被遮挡、处于 loading 状态等待后重试网络错误请求超时、连接中断指数退避重试浏览器崩溃标签页异常关闭重启浏览器恢复会话错误分类的价值在于不是所有错误都值得让模型“思考”一遍。很多错误是瞬时性的等一下、重试一次就好了。如果每次都调用大模型费钱费时还容易把上下文带偏。工程里的“少打扰模型”有时候比“多给模型反馈”更明智。7. 读源码时最值得动手改的三处地方最后分享一点个人经验。如果你准备把这个框架接入自己的业务有几个点是最值得动手改造的也是最能体现框架灵活性的地方。7.1 自定义动作的注册方式框架允许注册自定义动作。比如你的业务里有个“发送告警邮件”的按钮你可以给框架新增一个动作send_alert_email在系统提示词里告诉模型这个动作的存在和适用场景。模型会在遇到相关需求时自动选择调用它。这个扩展机制非常强大——它相当于把任意工具函数变成模型的能力。我建议你优先把自己业务流程中固定、重复的页面操作封装成动作而不是让模型一步步去点。每封装一个动作Agent 的可靠性和速度都会上一个台阶。7.2 页面快照格式的定制框架默认的快照格式是通用的但你的业务页面可能更适合其他格式。比如你可以把表格数据压缩成 CSV 喂给模型让它直接做数据分析或者把产品列表渲染成更紧凑的摘要形式减少视觉复杂度。快照格式直接决定了模型“读到”的信息密度。我见过一个案例某个项目把原始文本快照改成表格形式后模型的正确率提升了大概十几个百分点。这一步的收益往往被低估。7.3 定位策略的按需替换某些企业内部系统会大量使用特殊组件比如自研的表格、树形控件标准选择器可能定位不到。这时候可以在定位模块里注入针对这些组件的定制逻辑比如“如果元素带>