1. 为什么 Browser-Agent 评测总在“能跑”和“可复现”之间反复横跳
Computer-Use 和 Browser-Agent 这两类任务,落到工程里最容易被低估的一环不是模型选型,而是评测骨架。你大概率见过这样的场景:本地写个 Playwright 脚本,点几下按钮、填个表单,跑通了,截图一发,感觉 Agent 已经能操作网页了。可一旦换台机器、换个浏览器版本、或者把任务交给同事复现,结果就开始飘——有的步骤点不到元素,有的页面加载慢半拍,有的干脆因为一个弹窗把整条链路带偏。
问题不在模型,而在观察空间和动作空间没有钉死。Browser-Agent 的本质是“带 UI 副作用的多步控制器”,它需要一套稳定的观察表示(Accessibility Tree 就是目前性价比最高的选择)和一套可回放的动作记录。WebArena 这类基准之所以被反复提起,就是因为它把任务、环境、评测标准打包成了一个可对比的闭环。但很多人只把它当成一个跑分工具,忽略了它背后那套“环境准备 → 任务执行 → 结果验证”的最小骨架。
这篇内容面向的是想自己搭一套可调试、可复现 Browser-Agent 评测环境的开发者。我会用 Playwright 驱动浏览器,把 Accessibility Tree 作为主观察通道,串起从启动配置、快照脚本、动作执行到任务验证的完整链路,并给出一个统一的 Key/API 接入骨架,让模型调用和浏览器控制解耦。你不需要先有 WebArena 的完整镜像,跟着步骤就能搭出一个能跑通单任务、能回放、能定位失败点的最小闭环。
2. TaoToken 前置:把模型通道和浏览器控制拆开
在动手写 Playwright 之前,先把模型调用这一层理清楚。Browser-Agent 的循环通常是:观察页面 → 把观察结果发给模型 → 模型返回动作 → 执行动作 → 再观察。这个循环里,模型通道如果和浏览器控制耦合在一起,调试会非常痛苦——你分不清是页面没加载好,还是模型返回了非法动作。
我的做法是先把模型通道独立出来,用一个统一的 Key 和 API 入口。TaoToken 在这里扮演的就是这个角色:它提供一个兼容常见接口规范的通道,你可以在环境变量里配好 Key,然后在 Agent 循环里通过一个薄封装去调用。这样浏览器侧只负责“观察”和“执行”,模型侧只负责“决策”,两边可以分别 mock、分别回放。
具体操作上,你需要在 TaoToken 控制台创建一个 API Key,然后把它写进本地环境变量。注意不要把 Key 硬编码进脚本,也不要把带 Key 的请求日志提交到仓库。下面是一个最小配置示例,你可以直接复制到.env文件里:
# .env TAOTOKEN_API_KEY=sk-your-key-here TAOTOKEN_BASE_URL=https://taotoken.net/api然后在 Node.js 侧读取:
// config.js import 'dotenv/config'; export const config = { apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, model: 'gpt-4o-mini', // 按你实际可用的模型名替换 };这里有个容易踩的坑:很多人会把baseURL写成带路径的形式,比如https://taotoken.net/api/v1,结果请求 404。正确的做法是只写到/api,具体路径由 SDK 或你的请求封装去拼。如果你用的是 OpenAI 兼容的 SDK,通常只需要把baseURL指向https://taotoken.net/api即可。
提示:如果你还没创建 Key,可以先到 TaoToken 控制台的 API Keys 页面生成一个。建议按项目分 Key,方便后续做用量归因和轮换。
模型通道准备好之后,浏览器侧就可以专心处理 Playwright 的启动和观察逻辑了。这样拆分的好处是,当任务失败时,你可以先单独测模型通道是否正常,再单独测页面快照是否完整,排查路径清晰很多。
3. 可复制配置:Playwright 启动与 Accessibility Tree 快照
3.1 Playwright 启动配置
Browser-Agent 的浏览器实例不要复用,每个任务开一个独立的 BrowserContext。这样 cookie、localStorage、session 都是隔离的,回放时也不会互相污染。下面是一个生产可用的启动配置,我把它封装成了一个createBrowserContext函数:
// browser.js import { chromium } from 'playwright'; export async function createBrowserContext(task) { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'], }); const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, locale: task.locale || 'en-US', timezoneId: task.timezone || 'America/New_York', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', }); // 开启 tracing,方便后续回放 await context.tracing.start({ screenshots: true, snapshots: true, }); const page = await context.newPage(); // 设置默认超时,防止挂死 page.setDefaultTimeout(10000); page.setDefaultNavigationTimeout(30000); return { browser, context, page }; }几个参数值得说明。headless: true是默认值,但如果你需要调试,可以临时改成false看实际渲染。viewport固定成 1280x720 是为了让 Accessibility Tree 的布局相对稳定,避免响应式布局导致元素位置漂移。tracing一定要开,它会把每一步的截图和 DOM 快照存下来,后面做失败复盘时非常有用。
3.2 Accessibility Tree 快照脚本
Accessibility Tree 是 Browser-Agent 的主观察通道。相比截图,它的优势是结构化、token 成本低、元素可引用。Playwright 提供了page.accessibility.snapshot()方法,但原始输出比较冗长,需要做一层压缩,把每个节点映射成一个可引用的ref。
下面是一个快照脚本,它会把 Accessibility Tree 转成模型友好的扁平结构:
// snapshot.js export async function captureAccessibilityTree(page) { const snapshot = await page.accessibility.snapshot({ interestingOnly: true, }); const nodes = []; let refCounter = 0; function walk(node, depth = 0) { if (!node) return; const ref = `e${refCounter++}`; nodes.push({ ref, role: node.role, name: node.name || '', value: node.value || '', depth, disabled: node.disabled || false, focused: node.focused || false, }); if (node.children) { for (const child of node.children) { walk(child, depth + 1); } } } walk(snapshot); return { nodes, text: nodes .map((n) => `${n.ref} ${n.role} "${n.name}"${n.value ? ` value="${n.value}"` : ''}`) .join('\n'), }; }这个脚本输出的text字段可以直接塞进模型的 prompt 里。每个节点都有一个ref,模型返回动作时只需要引用这个ref,比如{ type: 'click', ref: 'e12' },执行器再根据ref去定位真实元素。这样就避免了让模型输出坐标或 CSS selector,稳定性会高很多。
注意:
interestingOnly: true会过滤掉一些纯装饰性节点,减少 token 消耗。但如果你的任务涉及自定义组件或 Canvas,可能需要关掉这个选项,或者额外加截图作为 fallback。
3.3 动作执行器
有了快照,还需要一个执行器把模型返回的动作映射到 Playwright 操作。下面是一个最小实现:
// executor.js export async function executeAction(page, action, refMap) { const locator = refMap.get(action.ref); if (!locator) { throw new Error(`Unknown ref: ${action.ref}`); } switch (action.type) { case 'click': await locator.click(); break; case 'fill': await locator.fill(action.value || ''); break; case 'select': await locator.selectOption(action.value || ''); break; case 'press': await locator.press(action.key || 'Enter'); break; default: throw new Error(`Unsupported action: ${action.type}`); } // 等待页面稳定 await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); }这里的关键是refMap。在生成快照时,你需要同时把ref和 Playwright 的 Locator 对应起来。一个简单的做法是用page.locator()配合nth或getByRole,但更稳妥的方式是在快照阶段就记录每个节点的可定位信息。如果你不想自己维护映射,也可以直接用page.getByRole(role, { name })去重新定位,但要注意 name 可能重复。
4. 验证请求:跑通一个 WebArena 风格任务
4.1 任务定义
我们用一个简化的 WebArena 风格任务来验证整条链路:打开一个电商页面,搜索指定商品,把第一个结果加入购物车,然后验证购物车数量变成 1。任务定义如下:
// task.js export const task = { id: 'cart-add-001', startUrl: 'https://example-shop.com', goal: 'Search for "wireless mouse" and add the first result to cart', maxSteps: 15, allowedOrigins: ['https://example-shop.com'], };4.2 Agent 循环
把前面的模块串起来,就是一个最小的 Agent 循环:
// agent.js import { createBrowserContext } from './browser.js'; import { captureAccessibilityTree } from './snapshot.js'; import { executeAction } from './executor.js'; import { callModel } from './model.js'; export async function runTask(task) { const { browser, context, page } = await createBrowserContext(task); const trace = []; try { await page.goto(task.startUrl); for (let step = 0; step < task.maxSteps; step++) { const observation = await captureAccessibilityTree(page); const action = await callModel({ goal: task.goal, observation: observation.text, history: trace, }); trace.push({ step, observation: observation.text, action }); if (action.type === 'done') { break; } await executeAction(page, action, observation.refMap); } // 验证结果 const finalObservation = await captureAccessibilityTree(page); const success = finalObservation.text.includes('Cart (1)'); return { success, trace }; } finally { await context.tracing.stop({ path: `traces/${task.id}.zip` }); await browser.close(); } }4.3 模型调用封装
模型调用这一层,用前面配好的 TaoToken 通道:
// model.js import OpenAI from 'openai'; import { config } from './config.js'; const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL, }); export async function callModel({ goal, observation, history }) { const prompt = ` You are a browser agent. Goal: ${goal} Current page accessibility tree: ${observation} History: ${history.map((h) => `Step ${h.step}: ${JSON.stringify(h.action)}`).join('\n')} Return a JSON action. Supported types: click, fill, select, press, done. Example: {"type": "click", "ref": "e12"} `; const response = await client.chat.completions.create({ model: config.model, messages: [{ role: 'user', content: prompt }], temperature: 0, }); const text = response.choices[0].message.content.trim(); return JSON.parse(text); }跑通之后,你会看到 trace 里记录了每一步的观察和动作,traces/cart-add-001.zip里也有完整的 Playwright trace。如果任务失败,你可以打开 trace 看是哪一步点错了,或者把 observation 拿出来单独分析。
5. 本篇常见错排查
5.1 Accessibility Tree 为空或节点极少
最常见的原因是页面还没加载完就抓快照。Playwright 的page.goto默认等待load事件,但很多 SPA 在load之后还会异步渲染。解决办法是在抓快照前加一个waitForLoadState('networkidle'),或者显式等待某个关键元素出现。
另一个原因是interestingOnly: true过滤太狠。如果你的页面大量使用div+role自定义组件,可以试着关掉这个选项,或者改用page.locator('body').ariaSnapshot()。
5.2 ref 定位失败
模型返回的ref在执行时找不到对应元素,通常是因为快照和执行之间页面发生了变化。比如快照时元素还在,执行时因为动画或异步请求被移除了。解决办法是在执行前重新抓一次快照,或者给执行器加一个重试机制:如果ref找不到,重新抓快照并让模型重新决策。
还有一种情况是refMap没有正确建立。如果你用的是page.getByRole重新定位,要注意 name 可能包含动态内容。建议在快照阶段就把 Locator 存下来,而不是只存文本。
5.3 模型返回非法 JSON
有些模型会在 JSON 外面包一层 markdown 代码块,或者加一些解释性文字。解决办法是在解析前先做清洗:
function extractJSON(text) { const match = text.match(/\{[\s\S]*\}/); if (!match) throw new Error('No JSON found'); return JSON.parse(match[0]); }如果模型经常返回非法动作,可以在 prompt 里加 few-shot 示例,或者把temperature设成 0。
5.4 任务超时或死循环
Browser-Agent 很容易陷入“点不动 → 再点 → 还点不动”的循环。除了设置maxSteps,还可以加一个 stuck detector:连续三步的 DOM hash 相同,就强制终止或转人工。DOM hash 可以用页面 HTML 的简单哈希,不需要太精确。
5.5 回放时结果不一致
回放不一致通常来自几个非确定性来源:动画、A/B 测试、时钟、随机验证码。解决办法是在测试环境冻结 feature flag、用 mock clock、拦截第三方脚本。Playwright 的page.route可以用来拦截和 mock 网络请求,把不稳定的外部依赖挡掉。
6. 接入骨架与后续扩展
到这里,一个最小的 Browser-Agent 评测骨架就跑通了。它的结构是:Playwright 负责浏览器控制,Accessibility Tree 负责观察,模型通道负责决策,trace 负责回放。你可以在这个骨架上继续加东西,比如:
- 把单任务扩展成任务集,批量跑并统计成功率;
- 加一个 Verifier,在每个写操作后做 DOM 断言,而不是只看最终状态;
- 把 trace 和 observation 存到对象存储,方便后续做数据分析和模型微调;
- 用 TaoToken 的 Coding Plan 跑长期编码任务,把 Agent 循环和代码生成结合起来。
如果你在接入模型通道时遇到问题,可以先到 TaoToken 的接入文档里对照请求格式,或者直接用模型对话页面手动测一下 Key 是否生效。浏览器侧的调试则更多依赖 Playwright trace 和快照对比,建议每跑一个任务就把 trace 存下来,失败时第一时间看回放。
这套骨架的价值不在于跑分多高,而在于每一步都可观察、可回放、可定位。Browser-Agent 的工程化,本质上就是把“黑盒点击”变成“白盒控制”。你先把这条最小闭环跑稳,后面加多少任务、换什么模型,都只是在这个骨架上做增量。