做 Web Agent 久了你会发现,绝大多数失败不是模型不聪明,而是它压根儿没看对地方。页面明明有一个“确认订单”按钮,模型读了 DOM 文本却跑去点旁边的“优惠券”;表格需要拖动列宽才能看到完整数据,纯文本的 HTML 结构里根本找不到这个操作入口。这些问题不是靠堆参数就能解决的,而是 Agent 对“页面”的认知方式出了偏差。
我最近把项目重心放在多模态 Web Agent 上,代号 OpAgent,核心思路是让模型同时读“结构的文字”和“像素的图像”,而不是只看某一种。做完一轮完整实验之后,OpAgent 在 WebArena 这套公开基准上拿到了相当靠前的成绩,刷新了我自己之前的最好记录。这篇文章不打算复述论文式的套话,就讲讲我实际踩过的坑、绕过的弯路,以及最后沉淀下来可复用的设计。
如果你正在做 AI Agent、浏览器自动化、RPA 方向的工程化改造,或者单纯好奇“多模态 Agent 到底怎么组合视觉和文本”,这篇内容应该能给你一些比跑通 demo 更深的东西。
1. 为什么偏偏选 WebArena 这套基准环境来验证
1.1 “看得见”不等于“看得懂”:Web 任务的真实难点
先说个我的直观感受:Web 自动化最难的不是“执行动作”,而是“知道现在该看哪里”。一个正常网页在浏览器里呈现出来的信息,包括布局、层级、视觉分组、图表的横纵轴、按钮的可用状态,它本来就是一个多模态对象。你给模型一段包含几千个节点的 DOM 文本,它当然能提取出不少信息,但这些信息丢掉了很多人类决策时依赖的线索。比如图上购物车角标显示“3”,你可能要切换商品规格之后它变成“4”,模型从文本里很难快速定位到这个状态变化的局部区域。
WebArena 这套环境比较“刁钻”的地方在于,它模拟的不是静态截图,而是一批带功能的真实网站——购物站、论坛、内容管理系统、项目管理工具、代码仓库界面,任务描述接近自然语言。比如“把购物车里的红色 T 恤数量改成 3 件,然后结算”,又比如“在项目列表里找出优先级为高的任务,把截止日期延后两天”。这些任务往往要跨页面、跨模块、经过多步交互才能完成,中间还夹着弹窗、分页、异步加载、表单校验。这样一个环境天然要求 Agent 同时理解语义和布局,纯粹的文本抽取方案很容易在“信息残留”上翻车——你读了所有文本,却不知道哪段文本对应页面上的哪个位置,自然无法操作。
我在早期实验里也试过只用 DOM 序列化喂给模型,测试集上每十个任务至少要挂掉四个,而且挂得毫无规律。反查日志时发现,大多数失败案例里模型都“看到”了正确文案,只是不知道该点哪。这让我下定决心,观察输入必须带上视觉通道。
1.2 WebArena 与传统 RPA 评估逻辑的区别
传统 RPA 的做法是你写死“点击按钮 selector #submit”,它按照固定路径去执行,脚本本身没有理解能力。而 WebArena 这类基准测试的是通用智能:同一个任务,不同页面的结构不同,元素状态不同,需要 Agent 根据当前页面内容现场做决策。这里有一个很关键但又容易忽略的差异,那就是评估指标。
传统 RPA 看的是“脚本有没有报错”,WebArena 看的是“任务目标有没有达成”。一个很大的坑是:动作全部执行成功,但目标没完成,分数照样是零。比如你成功打开了商品详情页,却没有把商品加入购物车,整个过程所有步骤技术上都没有报错,但本质上白干了。这就要求 Agent 不仅要“手稳”,还要“脑清”:每一个动作都要服务于最终目标。
也正因为 WebArena 的任务目标是开放的,所以评测结果能比较真实地反映 Agent 的规划能力、修正能力、以及对陌生页面的泛化能力。我选择它作为主要验证基准,就是看中它对“完成度”的苛刻要求。这比单纯跑一两个手工 demo 能暴露的问题多得多。
1.3 多模态不是炫技,而是补全决策信息
经常有人问,Agent 用截图会不会太贵、太慢,文本不是更省吗?我的回答是,多模态的真正价值是补全“结构化文本表达不了的信息”。有些网页的核心操作区域只有图标没有文字,比如一个放大镜表示搜索、一个垃圾篓表示删除、一个下拉箭头表示更多选项;有些信息是通过颜色和位置表达的,比如红色错误提示、置灰按钮、进度条;有些页面元素是 Canvas 渲染的复杂图表,你要读取柱状图的数值,从 DOM 里根本拿不到,只能看像素。
我把纯文本方案、纯截图方案、以及 OpAgent 最终采用的双轨方案做过一组对比实验,结论差异很明显:
| 方案 | 对文字类任务的完成度 | 对视觉类任务的完成度 | 单步成本 | 主要问题 |
|---|---|---|---|---|
| 纯文本(DOM/HTML) | 尚可 | 极差 | 低 | 丢失布局和图像信息,定位困难 |
| 纯截图 | 一般 | 较好 | 高 | 文字识别不稳,细节容易忽略 |
| 双轨(文本+局部截图) | 好 | 好 | 中 | 需要较强的融合调度逻辑 |
多模态的难点不在于“把图片塞给模型”,而在于“让模型知道这张图片对应页面上的哪个意义单元”。一个整页长截图丢给模型,分辨率可能超过模型处理上限,里面的小字根本看不清;把整个页面切成几十个小图,又会让上下文爆炸。如何平衡信息完整度和成本,正是 OpAgent 架构里最花心思的地方。
2. OpAgent 的设计主线:观察双轨与动作梯度
2.1 观察层:语义快照 + 视觉裁切的双轨机制
先说结论:我最终采用的观察结构,是每个时间步生成一个“语义快照(Semantic Snapshot)”,同时按照候选元素的位置生成一组“视觉裁切(Visual Patches)”。两个通道不合并成一个混合字符串,而是作为结构化对象分别送给规划模块。
语义快照的构成包含当前 URL、页面标题、可交互元素的有序列表、页面中与任务相关的文本区域、以及每个候选元素的位置信息。这里要注意,我不用原始 DOM 全文,而是先经过一轮精简,把不可见的、纯装饰性的、隐藏在 display:none 里的节点全部过滤掉,然后只保留距离任务目标最相关的部分。原文里可能有两千个节点,精简之后大概两百个左右,这一步能显著降低后续模型的阅读负担。
视觉裁切则是基于候选元素的位置坐标,将对应区域从页面截图中切出来。每个 patch 的尺寸我一般控制在 512×512 像素以内,并且会带一个坐标标注,告诉模型这个 patch 在页面中的相对位置。这样做的好处是,模型看到按钮截图时,同时知道它在页面的左上角还是右下角。裁切还有一个很实用的副产品——避免整页截图缩放后小字糊成一团,局部裁切能把按钮上的文案清晰保留下来。
整个观察层的数据结构大致长这样:
{ "step": 14, "url": "https://shop.example.com/cart", "semantic": { "title": "购物车", "candidates": [ { "id": "btn-checkout", "text": "提交订单", "type": "button", "position": [320, 142], "props": {"enabled": true} }, { "id": "lbl-count", "text": "数量: 3", "type": "input", "position": [280, 180] } ] }, "visual_patches": [ {"candidate_id": "btn-checkout", "image": "<base64...>", "bbox": [320, 142, 180, 42]}, {"candidate_id": "lbl-count", "image": "<base64...>", "bbox": [280, 180, 120, 36]} ] }这套双轨结构里,文本通道提供了可搜索、可引用的准确信息,视觉通道提供了布局、状态、图标等非文字信息。规划模块在推理时,先看语义快照决定“下一步操作谁”,再看对应的视觉裁切确认“这个元素长什么样、可不可点”。我实测下来,这种先语义定位、再视觉确认的方式,比把两者混合成一大段文本丢给模型要稳得多。
2.2 动作空间:从“模拟点击”升到“JS 表达式”
做 Web Agent 的另一个常见误区,是认为动作空间只有点击、输入、滚动这几类。OpAgent 在实验过程中逐步拓展出一个有梯度的动作原语集合,低层是模拟鼠标键盘,高层则直接执行 JS 表达式。为什么需要 JS?因为很多现代页面的交互逻辑不在 DOM 的静态结构里。比如某个按钮的点击事件由 React 合成事件绑定,单纯的 CDP 点击有时候触发不了;又比如修改输入框的值,React 受控组件需要派发 input 事件,而普通的元素赋值不会生效。JS 表达式可以直接绕过这些问题。
动作原语大致分四层:
- 第一层是基础模拟操作,包括 click、dblclick、hover、scroll、input。
- 第二层是组合宏操作,比如 drag_and_drop、select_option、upload_file、switch_tab。
- 第三层是 JS 注入操作,比如执行一段脚本来修改某个元素的状态、读取某个元素的 computed style、触发特定事件。
- 第四层是页面导航操作,包括 goto、back、forward、wait_for、screenshot。
举个例子,在项目管理页面里,任务的状态切换通常是一个下拉菜单。用基础模拟操作,Agent 要先点击下拉箭头、等待选项列表渲染、再点击目标选项。用 JS 注入,我们可以直接拿到下拉元素,找到包含目标文案的 option,并把它的 value 赋给 select,然后派发 change 事件。后者不仅更快,还更不容易被 CSS 动画和弹层遮挡干扰。
动作接口我定义成类似下面的形式:
type Action = | { type: "click"; target: string; button?: "left" | "right" } | { type: "input"; target: string; value: string } | { type: "hover"; target: string } | { type: "scroll"; direction: "up" | "down" | "to"; target: string } | { type: "js_execute"; script: string; await_selector?: string } | { type: "switch_tab"; index: number } | { type: "wait"; ms: number } | { type: "done"; result: string }这里最需要注意的边界是安全。JS 注入虽然强大,但不能让 Agent 随心所欲地执行任意脚本,否则它可能绕过页面逻辑、篡改数据、或者把评测环境搞坏。我在实现时给 js_execute 加了一层白名单策略:允许读取操作,允许触发 UI 事件,不允许直接修改后端数据或跳转到非白名单域名。这样既保留了灵活性,又把失控风险压到最低。
2.3 推理调度:ReAct 模式的落地变体
OpAgent 的决策循环基于 ReAct 的“思考-动作-观察”模式,但做了一些针对 Web 场景的改动。标准 ReAct 每一步都输出完整思考文本,这在开放式任务里很有效,但在 Web 任务中会产生大量冗余 token——模型反复描述页面长什么样,而不是直接行动。
我的落地变体是“目标栈 + 状态卡”。每一轮推理,模型只输出三样东西:对当前子目标的一句话判断、一个动作、以及动作完成后的验证条件。状态卡保存任务原始目标、已完成步骤、当前尝试次数。目标栈则维护未被完成的子目标,每完成一个就弹出,回到上层目标继续推进。
这里有一个容易被忽视的设计点:动作和验证必须绑定。OpAgent 不会盲目执行动作然后等待下一轮,而是执行完动作后立刻用一组轻量断言去验证。比如点击“加入购物车”之后,立即检查是否出现“已添加”提示或购物车角标数字是否变化。如果验证没通过,模型会进入恢复子流程,而不是简单重试同一个动作。
恢复子流程同样有个优先级顺序:先刷新当前快照重新判断页面状态,再尝试备用选择器或点击坐标偏移,最后才回退到上一个可靠检查点。这个基于检查点的回退机制,是后期成功率提升最明显的一块。因为很多复杂任务在前几步是对的,越往后越容易因为一个小偏差整体跑偏,如果没有回退能力,前面的动作全部白费。
3. 实操链路上最容易翻车的七个细节
3.1 等页面稳定再截图,不然训练和评测全白搭
这是第一号“反直觉”坑。测试环境里的页面加载有快有慢,很多表格和列表是异步拉数据后渲染的。如果 Agent 在 DOM 还没填充完的时候就截图,视觉 patch 拍到的就是空壳。我一开始用简单的固定等待 sleep(1000),结果发现慢接口要 3 秒,快接口 0.5 秒就完成了,固定等待要么浪费大量时间,要么依然等不到数据。
最终方案是“视觉稳定检测”。在每个动作完成后,持续轮询页面的关键区域截图,计算相邻两次截图之间的哈希差异,直到连续两次差异小于阈值才判定稳定。同时配合 MutationObserver 监听 DOM 变化,两者有一个满足即可继续。核心代码如下:
async function waitForPageStable(page, { interval = 200, timeout = 4000 } = {}) { const start = Date.now(); let lastSignature = null; while (Date.now() - start < timeout) { const signature = await page.evaluate(() => { const keyNodes = document.querySelectorAll('[data-testid], button, input, a'); return Array.from(keyNodes).slice(0, 200).map(n => n.textContent.trim()).join('|'); }); if (lastSignature && signature === lastSignature) { return true; } lastSignature = signature; await new Promise(r => setTimeout(r, interval)); } return false; }这里踩过的坑是:只监听 DOM 不算够,因为有些动画是 CSS 层面的,DOM 没变但位置在动。所以视觉哈希校验还是必要的,两者双保险。
3.2 元素定位:中心点坐标并不总可点
用视觉 patch 定位元素时,我最初直接把 patch 的中心点当作点击坐标,结果翻车翻得很惨。有几个典型场景:元素上方浮着一个吸顶导航栏,中心点被遮挡;元素的某个子区域是透明的,点击命中了内层其他元素;懒加载页面里元素的位置在滚动前后发生变化,截图时的坐标和点击时的坐标已经不一致了。
现在的做法是三层处理。第一层,滚轮事件先把候选元素滚动到视口中央,再计算坐标。第二层,用 elementFromPoint 检测目标坐标处实际命中的元素,如果命中的不是预期元素,就沿元素边界做网格偏移尝试,直到命中为止。第三层,如果候选元素有多个子元素,优先点击其内部文本节点的中心,而不是整个外部容器的中心。这样一个简单的“可点区域修正”,直接让首轮点击成功率提高了近两成。
3.3 多模态输入的 token 预算分配
把截图喂给大模型,成本问题绕不开。我的体感是,一个整页截图经过编码后,视觉 token 数量可能是一段完整 DOM 文本的 5 到 10 倍。如果每个时间步都塞一整张图,很多任务的成本会撑不住,而且模型处理那么多图像 token 后,对文字的注意力反而下降了。
最终我采用了一个“三级筛选”的策略。第一级,用文本快照判断候选交互区域,这一步成本极低。第二级,对候选区域生成小尺寸预览图,宽度压缩到 224 像素左右,先让模型判断“是否需要进一步放大”。第三级,只有被判定为高相关度的区域,才生成 512×512 甚至更高分辨率的裁切图。这样每轮真正进入高精度视觉通道的通常只有 2 到 4 个 patch,既保住了关键细节,又把成本控制在可接受范围。
我还试过用 OCR 模型先把图片中的文字抽出来,与视觉 patch 拼接。实测下来,对于按钮文字、表单标签这类内容,OCR 文本和视觉图像分开送模型的效果不如直接送高质量裁切图,因为模型自己能从图里读字。OCR 更适合的场景是长截图上的零散文字,比如公告栏内容、图表标题。所以要分场景选用,不能一刀切。
3.4 iframe 和 Shadow DOM:跨“文档边界”的操作万坑之源
Web Agent 踩坑排行榜里,iframe 和 Shadow DOM 绝对排前三。很多后台管理系统的表格是在 iframe 里嵌着的,主文档的 JS 根本拿不到 iframe 内部的 DOM;而现代组件库大量使用 Shadow DOM,常规 querySelector 无法穿透 shadow root。一开始我用自动化框架的默认 API 去查找元素,遇到 iframe 时十次有八次找不到。
解决方案是给观察层加一个“文档边界探测器”。每当语义快照生成时,同时扫描页面里有哪些 iframe 和 shadow root,并为它们建立独立的元素索引。操作候选元素时,动作接口支持指定 frame 上下文或 shadow root 路径,比如 target 写成iframe#main->shadow/btn-submit。这样 Agent 就具备跨边界操作能力了。
这里要提醒大家一个隐蔽问题:跨 iframe 的坐标换算。iframe 内部的元素坐标是相对于 iframe 文档的,必须加上 iframe 在主文档中的偏移量,才能得到页面全局坐标。我在早期版本里忘了做这个换算,导致视觉 patch 和点击位置对不上,排查了半天才定位到问题。现在所有候选元素的坐标在生成语义快照时统一转换成全局坐标,从源头消掉这个隐患。
3.5 失败恢复不能全靠“重试同一个动作”
一个很容易让人崩溃的现象是:Agent 执行点击后,页面什么都没发生。如果此刻模型决定“再点一次”,大概率还是什么都不会发生,页面还多出一堆无意义的事件记录。这种问题通常不是点击力度不够,而是动作根本没有命中正确的目标。
我给 OpAgent 设计了三级失败反馈机制,而不只是把错误信息返回给模型。第一级是“动作级验证”,上文提过,点击后立刻检查预期变更,没有变更就把“元素状态快照”一起发给模型,让它看看目标元素是不是真的可交互。第二级是“策略切换”,如果两次尝试都失败,就换一类动作,比如把 click 换成 js_execute 触发事件,或者先把遮挡元素移开再点。第三级是“检查点回退”,如果连续三个动作都无效,就不再继续往深处走了,而是回到上一个明确成功的状态,重新规划路径。这个机制的价值在于,它能把单点小错误限制在局部,不让错误像雪球一样越滚越大。
我还专门记录过失败原因分布:大约有四成的失败是元素定位不准,三成是页面状态和模型预期不符,剩下的是任务目标本身有歧义或需要外部数据。理解这个分布之后,后续优化就变得非常有针对性,不再盲目调模型参数。
4. 从评测数字到业务落地的迁移思考
4.1 成绩单里最有价值的不是那个总分数
OpAgent 最终在 WebArena 上的整体任务成功率,从基线方案的不足三成提升到了接近四成的水平,在一批公开实验结果里处于靠前位置。但说实话,总分提升当然开心,让我觉得真正有收获的,是分项拆解里的几个指标。
看分站点数据,购物类任务的成功率最高,论坛类中等,项目管理类最低。这个顺序完全符合预期:购物流程的页面相对规整、元素类型单一;项目管理软件的结构复杂、状态变更多、权限控制也多。这说明视觉能力在当前阶段的瓶颈已经从“看清页面”转移到了“理解复杂业务规则”。模型能看见任务卡片,也能看见截止日期,但要准确理解“把这个任务的负责人改成张三,并且只改这一个任务而不是整个列表”这类约束,仍需要更强的指令跟随能力。
还有一组对比数据来自任务长度。短任务(不超过 5 个动作)的成功率已经比较可观,超过 15 个动作的长任务成功率显著下降,主要失败点在中间某一步的操作偏差和上下文遗忘。这给我一个重要信号:多模态 Web Agent 的下一个突破口,不只是看得更准,而是记得更牢。上下文压缩与关键状态记忆,可能是比“换更大的模型”更有效的杠杆。
4.2 WebArena 的“实验室滤镜”与真实场景差异
虽然 WebArena 是目前业界公认的权威基准之一,但它的测试环境毕竟是一个封闭、受控的模拟世界。直接把它当作真实业务场景的验收标准,是很危险的。我的经验是,从 WebArena 迁到真实项目时,至少有三层滤镜需要摘掉。
第一层是复杂度滤镜。评测环境里的站点,一般不会有复杂的登录验证码、多因素认证、人机校验、实时风控,而这些在真实系统中比比皆是。第二层是数据滤镜。评测页面里的商品和项目通常是静态种子数据,状态变化有限;真实系统里,同一个按钮在不同用户权限下可能表现出完全不同的行为,还要面对千人千面的推荐、个性化展示。第三层是容错滤镜。评测环境里,误操作最多是任务失败,重新开始就好;真实环境里,一个误操作可能就是一笔错误订单、一条错误配置,影响的是真实业务。
所以我在项目里单独维护了一个“生产适配清单”,把登录态管理、重试策略、操作审计、安全白名单这些评测阶段根本碰不到的能力,作为独立模块设计和实现。它们和核心 Agent 逻辑解耦,部署时可以按需启用。
4.3 可复用的中间层:Agent 不一定要全盘落地
做完整个 OpAgent 项目,我自己最大的收益不是“一个端到端的 Agent”,而是一套可以拆开的中间件。平时做业务系统,不一定需要完整的自主 Agent,但观察层、动作层、验证层这些能力,单独拿出来都能解决实际痛点。
观察层可以单独用于 UI 自动化测试的智能元素定位,替代脆弱的 CSS selector;动作层可以接在传统 RPA 工具后面,给固定流程脚本增加视觉定位和 JS 注入能力;验证层可以嵌入爬虫或监控系统,自动判断页面状态是否符合预期。把这些组件做成轻量级服务,通过标准 API 暴露给上层,比强行推一套“全知全能 Agent”要容易落地得多。我现在遇到客户需求,第一反应不再是“要不要上一整套 Agent”,而是“这个场景最需要哪一层能力”。
5. 我最后囤下的几点工程经验
总结性的理论框架写得再多,不如几条亲手攒下的经验来得实在。最后分享几个我实操时反复受益的习惯,不保证放之四海而皆准,但至少能让你的多模态 Web Agent 少走很多弯路。
第一,观察层的输出格式一定要稳定。模型最怕的并不是信息少,而是同一类信息有时候出现在这里、有时候出现在那里。把语义快照和视觉 patch 的 schema 固定下来,让模型形成稳定的“读页面”习惯,比给它塞更多信息更重要。
第二,视觉 patch 的最大值宁可保守一点。四个高分辨率 patch 看到的关键细节,远超十张模糊小图。为了省 token 而把补丁压得太小,模型就会开始“猜字”,一旦靠猜,错误率就螺旋上升。
第三,一定要建一个“失败日志可视化面板”。不要只记录模型最终的错误文本,而是把每一步的动作、截图、预期结果、验证反馈都可视化地列出来。没有这个面板,你排查问题时就像在黑盒里摸象;有了它,几个典型失败模式看一天就全懂了。
关于多模态 Web Agent 的后续方向,我目前正在尝试把“记忆压缩”做得更极致。比如一个购物任务经历过十步操作之后,前五步的原始截图是否真的还有必要继续留在上下文里?如果能把“已经完成的部分”压缩成一个稳定的状态摘要,释放出来的上下文空间可以让模型更专注地处理当前步骤。这部分实验还在推进中,等有阶段性结果再回来更新。
如果你也在做类似的 Agent 项目,碰到过比上面更怪的坑,欢迎评论区聊两句。这种项目本质上就是在无数个“小意外”里磨出来的,每一个意外背后都是对“页面理解”这件事更深一层的认识。