浏览器 Agent 的两种宿命:像素派和工具派

4 阅读7分钟

引子

「让 AI 操作浏览器」基本收敛成两条路线。

一条是像素派:截图喂给多模态模型,模型输出坐标,点下去。代表是各种 computer-use 方案。

另一条是工具派:把网站能力封装成带 schema 的确定性工具,模型只负责选工具、填参数。

我们做 WeWorker 时选了第二条。不是因为它更新,而是因为在真实场景里跑了一个月之后,像素派的三个问题没有一个能靠模型升级解决。

这篇文章讲清楚:为什么换路线、Adapter 到底是什么、以及我们为一个「写操作」的安全边界返工了三次。


一、像素派在真实站点上的三个硬伤

1.1 失败不可诊断

模型点偏了,你收到的是一个坐标和一句「已完成」。到底是选择器变了、弹窗挡了、还是登录态掉了?不知道。而工具派在同样场景下会返回结构化错误:

{ "ok": false, "code": "NOT_LOGGED_IN", "adapter": "jd.com", "tool": "search" }

差别在于错误能不能被上层利用。Agent 拿到 NOT_LOGGED_IN 可以提示用户登录后重试;拿到「点偏了」只能放弃或乱试。

1.2 正确率随改版线性衰减

DOM 结构和视觉布局都会变,但对工具派来说,只有它依赖的那几个语义节点变了才需要修;对像素派来说,任何布局调整都可能导致全站失效。中文电商站点的 A/B 实验频率,你们懂的。

1.3 成本结构不对

一次跨站任务,像素派要几十次截图+推理;工具派是一次工具调用 + 一次结果摘要。差的不是模型能力,是每步都要过模型这个结构本身。

维度像素派工具派
单步成本一次多模态推理一次函数调用
失败可诊断性低(只有坐标)高(结构化 error code)
改版耐受度低中(只修相关工具)
首次接入成本低(通用)高(每站要写工具)
写操作可控性差好(schema + 权限)

工具派的代价很明确:首次接入成本高。所以真正的工程问题不是「选哪条路」,而是「怎么把每站接入成本压下来」——这是后面第三节的内容。


二、Adapter 是什么:一份契约,两种执行

我们没把 Adapter 做成一个类,而是做成一份契约 + 一个注册中心。上层只认契约。

2.1 调用入口只有一个

webcli call <adapter-id>.<tool-id> --key=value

工具名是完整名,按完整名精确解析,不按域名或第一个点拆分。这条规则看着啰嗦,但救过我们:早期按 . 切分,mail.qq.com.search 会被切成三段,域名本身带点就完了。

重复注册同一个完整名 → 直接失败关闭,不允许后注册的静默覆盖前一个。工具表被静默污染是 Agent 最难受的故障,因为模型会「合理地」用错工具,而且看起来一切正常。

2.2 两种执行后端,一个统一入口

webcli call xxx.yyy
  → WebCLI Adapter Registry
     ├─ adapter_type=extension
     │    → Bridge → Chrome 扩展(MV3)→ 当前标签页
     └─ adapter_type=toolkit
          → Toolkit Runner → 内置 Runtime → toolkit.py → Playwright / CDP

为什么要有两种?

  • 有些能力必须在用户已登录的真实页面里做(邮箱、后台、需要人机验证的流程)→ extension
  • 有些能力接口语义稳定、纯 API 化更可靠 → toolkit
  • 硬要用一种覆盖全部,结果就是「为了统一而牺牲可靠性」

契约统一在发现、描述、调用三件事上;执行实现故意不统一。这是我们的第一版返工:一开始想用扩展接管一切,结果 Playwright 场景被 MV3 的生命周期折磨得不行。

2.3 Toolkit 的产物是文件,不是内存对象

一个 toolkit adapter 交付五个文件:

<adapter-id>/
├── manifest.json
├── SKILL.md
└── scripts/
    ├── toolkit.py
    ├── toolkit_runtime.py
    └── ...

好处是它可以被版本管理、被 diff、被人 review。Agent 生成的工具草稿也得先落成这些文件,才能走验证和安装流程。工具不能只活在内存里,否则出了事没人能查。


三、把接入成本压下来:网站探索 agent

工具派最大的痛点是「每站都要人写」。我们的做法是让 Agent 自己去探索。

流程大致是:

未知站点 URL
  → siteGate 判断是否已有确定性工具
  → 没有则进入探索:读页面结构、抓关键请求、归纳接口
  → 生成只读工具草稿 → 本地验证 → 安装到 Adapter 包
  → 验证通过的才允许出现在工具表里

两个关键约束:

  1. 默认先做只读工具。 探索阶段生成写工具的风险太高,先让它把「看」的能力做对。
  2. 验证不过不许上架。 草稿必须实际跑通一次并校验响应结构,否则宁可返回「这个站点暂不支持」。

必须承认的边界:动态渲染重、反自动化强的站点,探索成功率明显低于语义清晰的站点。这也是为什么我们保留了「人工写 Adapter」这条路——自动化的是 80%,剩下 20% 交给愿意贡献的人,这恰好也是开源在这里的价值。


四、写操作的安全边界(返工三次的部分)

这是我认为整个项目里最值得写的一段,因为在 Agent 场景下,安全边界的设计错误会被模型主动利用,而不是被人被动触发。

4.1 第一版:靠 prompt 约束

系统提示词里写「涉及支付的操作要先问用户」。上线三天就被绕过:用户说「帮我把这个买了,不用再问我」,模型很听话地不问了。

结论:自然语言约束不是边界,是可协商的建议。

4.2 第二版:调用参数带 __confirmed

写工具要求调用方传 __confirmed: true。看起来是硬约束了,但实际更糟——这个参数是模型自己填的。模型完全有能力在「用户说不用问我」之后自己填上。

结论:把安全决策交给调用方,等于没有安全决策。

4.3 第三版:策略属于包,不属于调用

最终方案是把确认策略写进 manifest:

{
  "safety": "write",
  "requires_confirmation": true
}
  • requires_confirmation 对写命令默认 true
  • 策略参与 manifest 的内容摘要校验,改策略等于改包
  • 要把某个写命令改成免确认,需要走一次包权限变更审查(unconfirmed-write:<command-id>),不是改一行配置就生效
  • 调用时即便传了 __confirmed: true,也只用于满足旧版兼容守卫,不会跳过业务侧审批、登录校验、dry-run 开关、参数必填

一句话总结原则:谁能改变「这个动作要不要确认」,谁就必须是维护者,不能是运行时。


五、路由:宁可漏判,不可误判

用户一句话,如果能被确定性规则命中(正则、模板、工具 scenario),就不进大模型,直接调工具、直接渲染结果,llmMs = 0。省一次往返,体感差别很大。

但我们给这层设了一条硬红线:

只要不自信,一律 return false 落回模型。

因为代价不对称:

  • 漏判 → 多花一次模型调用,用户感知是「慢了一点」
  • 误判 → 用一个错误的工具结果短路掉正确答案,用户感知是「它骗我」

一旦用户觉得「它骗我」,这个产品就没了。所以我们宁可在配置里写一堆保守的规则,也不追求把命中率刷高。


六、如果你也在做类似的东西,三条经验

  1. 先定契约,再定实现。 统一入口 > 统一实现。强行统一执行路径,最后一定会为某个场景写一堆例外。
  2. 安全策略放包里,别放参数里。 任何「调用方自己声明已确认」的设计,在有模型的系统里都等于没有设计。
  3. 错误的可诊断性比成功率更重要。 成功率是演示用的,错误码是用户用一周之后还愿不愿意用下去的原因。

关于项目

这套东西做成了一个开源项目 WeWorker:Electron 桌面端 + Chrome 扩展 + TypeScript 后端,内置 33 个 Adapter(中文站点为主:邮箱、京东、小红书、B站、知乎、微博、12306、BOSS 直聘等),Apache 2.0。

仓库:github.com/WeWorker/We… 架构文档:docs/webcli_toolkit_adapter_design.md、docs/intelligent_routing_architecture.md

如果你常用某个站点还没被支持,开 issue 说站点和使用场景就行,Adapter 是按需求排优先级的。