本文含有约 20% 的 AI 文字。
现在 Agent 和 Browser Use 很火,但如果你真的使用过,就会发现它完成任务的效率还是很低下。但等等,对于网页,我们明明一直有更高效的交互方式:不如让 Agent 直接运行 JavaScript 代码吧!
这几天我搓了一个 Maputnik-AI。它是地图样式编辑器 Maputnik 的一个实验性分支:导入一份 CSV,再告诉 Agent 想画成什么样,它就会直接修改眼前这张 MapLibre 地图。

比如让它把热点数据画成绿色方格,按数值调整深浅,同时简化底图,得到的是下面这张还能继续编辑的地图:

项目本身可以在 在线 Demo 里玩(需要自备支持 Responses API 的 API Key),我们来对比它相对于 Browser Use 或者 MCP 的好处。
Browser Use
现在让 Agent 操作网页,最直接的办法当然是 Browser Use。Agent 看截图、DOM 或 accessibility tree,找到输入框和按钮,然后 click、type、scroll,再观察页面有没有发生预期的变化。
这套方式的好处是通用。但如果应用有一些非原生的控件,模拟操作本身可能就会很费时费力。比如说,Maputnik 有一个可以拖拽的图层列表,用来移动图层。如果模拟点击和移动,Agent 初版可能会生成这样的代码:
const from = document.querySelector('[data-wd-key="layer-list-item:fantasy:buildings"]');
const to = document.querySelector('[data-wd-key="layer-list-item:fantasy:towns"]');
const r = to.getBoundingClientRect();
from.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
to.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: r.x, clientY: r.y }));
to.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
看起来很好很简单,点下去移动过去释放。但是它不工作。
当然经过一些迭代 Agent 最后会发现这个列表是 dnd-kit 的 sortable,然后用很鬼畜的多达 77 行代码完成任务:
(async () => {
const source = document.querySelector(
'[data-wd-key="layer-list-item:fantasy:buildings"]'
);
const target = document.querySelector(
'[data-wd-key="layer-list-item:fantasy:towns"]'
);
if (!source || !target) {
throw new Error('source / target layer not found');
}
const handle = source.querySelector('.maputnik-layer-list-item-handle');
const from = handle.getBoundingClientRect();
const to = target.getBoundingClientRect();
const startX = from.left + from.width / 2;
const startY = from.top + from.height / 2;
const endX = to.left + to.width / 2;
const endY = to.top + to.height / 2;
const pointerId = 1;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const fire = (type, x, y, target) => {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
pointerId,
pointerType: 'mouse',
isPrimary: true,
clientX: x,
clientY: y,
button: 0,
buttons: type === 'pointerup' ? 0 : 1,
}));
};
// 1. 按下
fire('pointerdown', startX, startY, handle);
// 模拟人按下鼠标之后稍微停一下
await sleep(150);
// 2. 平滑移动
const duration = 800;
const steps = 40;
const interval = duration / steps;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
// easeInOut
const p = t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2;
const x = startX + (endX - startX) * p;
const y = startY + (endY - startY) * p;
const el = document.elementFromPoint(x, y) || document.body;
fire('pointermove', x, y, el);
await sleep(interval);
}
// 到达目标以后稍微停一下
await sleep(200);
// 3. 松开
const el = document.elementFromPoint(endX, endY) || document.body;
fire('pointerup', endX, endY, el);
})();
嗯……你看到这么多代码在刷屏一定已经开始不耐烦了。还好 Agent 是大模型在看代码。
那么同样的效果,如果我们使用 MapLibre 的 API 完成:
map.moveLayer('fantasy:buildings', 'fantasy:towns');
啊!太让人感动了。
下面我们看看怎么实现。
暴露浏览器 JS 环境
项目给模型注册的核心 tool 其实只有一个:
run_javascript({ code })
这段 JavaScript 执行时,可以直接访问:
map // 正在显示的 MapLibre Map
datasets // 浏览器本地导入的 CSV 数据
map 就是 MapLibre 原生的实例,map.getStyle()、map.setStyle() 这些方法都能直接用。代码的返回值就是 tool 的结果。
于是 Agent 可以写这样的代码:
const ds = datasets.get("<dataset id>");
const {columns, rows} = ds.data;
const valueIndex = columns.indexOf("value");
const values = rows.map(row => Number(row[valueIndex])).filter(v => v > 0);
const max = Math.max(...values, 1);
const nextStyle = map.getStyle();
for (const layer of nextStyle.layers) {
if (layer.type === "symbol") {
layer.layout = {...layer.layout, visibility: "none"};
}
}
const sourceId = `agent-dataset:${ds.id}`;
nextStyle.sources[sourceId] = {
type: "geojson",
data: datasets.csv.toGeoJSON(ds, {
type: "Point",
coordinates: ["lon", "lat"],
}),
};
nextStyle.layers.push({
id: `${sourceId}-layer`,
type: "circle",
source: sourceId,
metadata: {"maputnik:role": "overlay"},
paint: {
"circle-radius": [
"interpolate", ["linear"],
["to-number", ["get", "value"]],
0, 2,
max, 12,
],
"circle-color": "#238b45",
"circle-opacity": 0.75,
},
});
map.setStyle(nextStyle);
return {rows: rows.length, max};
筛选、求最大值、遍历图层、修改样式、创建数据图层,都在一次执行里完成。传统 MCP 可能要把它拆成 hide_label、get_next_layer、calculate_max、add_circle_layer 几个工具,让模型分多轮完成。虽然模型实际上可以一轮调用多个工具,但如果有数据依赖就没办法合并了。此时能直接执行代码的组合性优势也体现出来了。
MCP 和 PTC
Browser Use 只能在表现层猜应用内部发生了什么,MCP 或普通 tool calling 则已经提供了可靠的接口。Chrome 最近推进的 WebMCP 就是这个方向:网页可以用 JavaScript 主动声明 tools,让 Agent 理解每个操作的目的。
但额外维护一套 schema 其实未必轻松:一方面是接口定义,我们要不要提供 batch、filter 操作?另一方面是随着应用变复杂,维护成本也会上升。不如直接把应用底层对象暴露出来让 Agent 自己写 JS 操作了。
其实引入代码执行这个想法并不新鲜。Anthropic 叫它 programmatic tool calling(PTC)。它的好处一方面是组合性:模型不再顺序发 tool call,而是自己写一段代码,引入了控制流,一次执行可以调用多个 tool。
另一方面是省 context:代码跑完只有最终结果回到上下文里。假如浏览器里有一万行 CSV,Agent 想筛选、分组后找出前十项,没必要先把一万行发给模型,程序可以直接在本地算完,只返回十行摘要。
改造之后的 Maputnik 天然具备了上面的好处,同时也不需要写一大堆 tool 的兼容性 schema 和 bridge 代码。真正要做的只是把应用自己的对象和接口交给模型,map 和 datasets 本来就是这个页面里的 JavaScript 对象。
另外注意到在浏览器里,页面本来就是一个 JavaScript 环境,代码执行直接 new Function 跑就好了。轻轻又松松啊。
后话
如果网页只是登录、填写表单、购买商品,那么 Browser Use 或几个定义清楚的 tools 已经很好用。给“加入购物车”套上循环,只会多出安全问题。
Programmable runtime 更适合那些对象很多、内部状态很多、操作组合几乎无法枚举的应用:GIS、设计工具、BI、IDE、科学模拟器。它们通常本来就有不错的底层 API,用户也经常提出开发者没有提前做成按钮的操作。
当然,直接执行模型生成的 JavaScript 非常危险。Maputnik-AI 现在就是用 new Function 在页面上下文中执行代码,没有沙箱,只适合作为可信模型、可信数据和可信环境下的 prototype。这一点倒是和 coding agent 面对的处境很像:都是直接执行模型写的代码,只能靠可信环境和事后审查来保证安全。
现代复杂网页都有两层:用户看到的 GUI(表现与交互),和程序对象模型与 API(数据与逻辑)。过去 scripting API 必须要先会编程才能使用,而且有开发成本。LLM 出现以后,自然语言也成了它的入口。
未来的 Agent 不一定只是在网页外面看截图、学着操作软件,它也可以进入网页正在运行的 runtime,拿到真实对象,写一小段程序,和人一起编辑同一个世界。
评论加载中...