引子
「让 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 包
→ 验证通过的才允许出现在工具表里
两个关键约束:
- 默认先做只读工具。 探索阶段生成写工具的风险太高,先让它把「看」的能力做对。
- 验证不过不许上架。 草稿必须实际跑通一次并校验响应结构,否则宁可返回「这个站点暂不支持」。
必须承认的边界:动态渲染重、反自动化强的站点,探索成功率明显低于语义清晰的站点。这也是为什么我们保留了「人工写 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落回模型。
因为代价不对称:
- 漏判 → 多花一次模型调用,用户感知是「慢了一点」
- 误判 → 用一个错误的工具结果短路掉正确答案,用户感知是「它骗我」
一旦用户觉得「它骗我」,这个产品就没了。所以我们宁可在配置里写一堆保守的规则,也不追求把命中率刷高。
六、如果你也在做类似的东西,三条经验
- 先定契约,再定实现。 统一入口 > 统一实现。强行统一执行路径,最后一定会为某个场景写一堆例外。
- 安全策略放包里,别放参数里。 任何「调用方自己声明已确认」的设计,在有模型的系统里都等于没有设计。
- 错误的可诊断性比成功率更重要。 成功率是演示用的,错误码是用户用一周之后还愿不愿意用下去的原因。
关于项目
这套东西做成了一个开源项目 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 是按需求排优先级的。