🎯 一款可定制、具备反检测功能的云浏览器,由自主研发的 Chromium驱动,专为网页爬虫AI 代理设计。👉立即试用
返回博客

Set-of-Mark Prompting: 给浏览器代理一个可以点击的内容

Daniel Kim
Daniel Kim

Lead Scraping Automation Engineer

07-Aug-2026

TL;DR:

  • 一个视觉模型在实时目录页面上请求像素坐标时,每一次都没能达到目标 — 在一次会话中 0 的 5 次试验,在三次中 0 的 15 次 — 这并不是随机的:在温度 0 时,它每次返回几乎相同的点。
  • Set-of-Mark 提示 — 将每个可点击元素编号,并要求模型提供索引而不是坐标 — 在同一会话中得分 5 的 5,在总共的实验中 15 的 15,在同一页面上与同一模型。
  • 标记并不是免费的:提示从 1,343 增加到 2,100 个标记 (+56.4%),每个决策约 $0.00021,按照测试模型的费率。
  • 在远程 CDP 浏览器中,page.set_viewport_size() 没有调整布局视口。在三次独立运行的十二个新会话中,innerWidth 在调用后没有改变,真实窗口的范围从 800 px 到 3,840 px — 从未达到请求的大小 — 而屏幕截图总是恰好是请求的大小。
  • 这就是根本原因:模型推理的图像和你点击的位置是不同的坐标框架,偏移量在每个会话中都会改变。通过 DOM 解决的索引对此是免疫的。
  • Scrapeless 免费计划涵盖了本指南中的云浏览器运行。

一个浏览器代理必须将“打开第二本书”转换为某个特定位置的点击。这种转换 — 归因 — 是大多数代理运行悄然出错的地方。模型正确读取页面,正确解释其计划,然后点击某个不是它刚刚描述的东西。

通常的解决方案是 Set-of-Mark 提示:在每个可点击元素上绘制一个编号的框,将图片和数字列表交给模型,让它回答 60 而不是 (564, 623)。这一技术来自于 微软的 Set-of-Mark 论文,如今它以某种形式出现在大多数代理堆栈中。

难以找到的是一个测量。本指南针对同一实时页面与同一模型构建这两种方法,在单次会话中每种方法运行五次,并报告实际发生的事情 — 包括解释 为什么 坐标版本如此一致地失败的远程浏览器细节。

为什么坐标失败

从失败的版本开始,因为其失败的形状是有趣的部分。

任务:在实时书籍目录上,打开书名为 Soumission 的产品页面。模型获取屏幕截图并被请求一个点击点。

python Copy
import base64, json, os, urllib.request
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright

TARGET = "https://books.toscrape.com/"
TASK = "open the product page for the book titled 'Soumission'"
MODEL = "google/gemini-2.5-flash-lite"
CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
    "token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US"})


def ask(png, prompt):
    body = {"model": MODEL, "max_tokens": 200, "temperature": 0,
            "messages": [{"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {
                    "url": "data:image/png;base64," + base64.b64encode(png).decode()}}]}]}
    req = urllib.request.Request(
        "https://openrouter.ai/api/v1/chat/completions", data=json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
                 "Content-Type": "application/json"})
    d = json.load(urllib.request.urlopen(req, timeout=120))
    return d["choices"][0]["message"]["content"].strip(), d["usage"]


with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(CDP)
    page = browser.new_page()
    page.set_viewport_size({"width": 1280, "height": 1400})
    page.goto(TARGET, wait_until="domcontentloaded")

    answer, usage = ask(page.screenshot(),
        f"This is a 1280x1400 browser screenshot. To {TASK}, give the pixel coordinates "
        f'of the element to click. Reply with JSON only: {{"x":int,"y":int}}')
    print("model answered:", answer)

    c = json.loads(answer[answer.index("{"):answer.rindex("}") + 1])
    landed = page.evaluate(
        "([x,y]) => { const e = document.elementFromPoint(x,y);"
        "return e ? (e.closest('a')?.getAttribute('href') || '<'+e.tagName+'>') : null }",
        [c["x"], c["y"]])
    print(f"click ({c['x']},{c['y']}) lands on -> {landed}")
    print("prompt tokens:", usage["prompt_tokens"])
    browser.close()

模型没有拒绝、含糊其辞或返回任何不规范的内容。它立即并自信地回答:

text Copy
model answered: {"x": 564, "y": 623}
click (564,623) lands on -> <BODY>
prompt tokens: 1343

<BODY> 意味着点击落在了无处 — 空白页面背景,根本不是链接。代理现在将报告它已经点击,等待一个从未发生的导航,并继续执行建立在一个默默未发生的步骤上的计划。

在同一会话中运行五次,失败不会平均分配:

trial answer element hit result
1 (564, 623) <BODY> MISS
2 (563, 630) <BODY> MISS
3 (563, 630) <BODY> MISS
4 (563, 630) <BODY> MISS
5 (564, 623) <BODY> MISS

0 的 5。 一个不稳定的失败是可以生存的 — 你三次取样,取多数决,并继续进行。这个失败是 稳定的。在温度 0 时,模型返回同一点几像素内,而每次重复都落在同一死区。自我一致性投票会返回错误答案三次,并称之为共识。

在三次独立会话中重复整个比较每次都得出相同的裁决:0 的 15 对于坐标。会话之间改变的仅仅是 如何 它错过 — 在一个会话中每个点击落在目标旁边的产品上而不是空白背景上。它击中的目标随着会话而变化;它错过的事实没有变化。

底下的框架不匹配

显而易见的解释是视觉模型在精确坐标上简单地表现不佳,GUI-归因文献对此有大量支持。在远程浏览器上,还有第二个原因叠加其上,值得了解,因为它也会破坏与模型完全无关的方法。

在设置视口之前和之后,询问页面它认为自己有多大:

python Copy
import os
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright

CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
    "token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US"})

with sync_playwright() as p:
    for run in range(1, 5):
        browser = p.chromium.connect_over_cdp(CDP)
        page = browser.new_page()
        before = page.evaluate("() => [innerWidth, innerHeight]")
        page.set_viewport_size({"width": 1280, "height": 900})
        page.goto("https://books.toscrape.com/", wait_until="domcontentloaded")
        after = page.evaluate("() => [innerWidth, innerHeight]")
        shot = page.screenshot()
        print(f"run{run}: innerWH before={before} after={after} png_bytes={len(shot)}")
        browser.close()

四个新会话:

text Copy
run1: innerWH before=[1550, 1050] after=[1550, 1050] png_bytes=196373
run2: innerWH before=[1240, 560] after=[1240, 560] png_bytes=116304
run3: innerWH before=[1680, 1002] after=[1680, 1002] png_bytes=171659
run4: innerWH before=[3840, 1152] after=[3840, 1152] png_bytes=5289

set_viewport_size() 对布局视口 没有影响 — 在所有四次运行中,innerWidthinnerHeight 在调用前后是相同的。真实窗口大小也不是你选择的。相同脚本的重复运行返回的大小为 2,560×1,408,1,920×1,070,1,440×839,800×570 和 3,840×1,050 等;在三次运行的十二个会话中,布局视口从未达到请求的大小,宽度范围从 800 px 到 3,840 px。
截图的大小恰好是每次要求的大小。在 run4 中观察一下这会导致什么:为 3,840 像素窗口布局的页面,捕获到 1,280 像素画布中,生成了一个 5 KB 的 PNG,而其他版本生成了 116–196 KB。裁剪捕获了大部分空白布局。该截图是模型被要求推理的内容。

相同的脚本在本地启动的浏览器上表现得如您所期望的,这正是开发中容易忽视的地方:

text Copy
local innerWH: [1280, 1400]
after set_viewport_size: [1280, 900]

在本地,调用正常工作,帧也一致。连接到远程浏览器时,调用会静默停止工作。

所以页面为 3,840 像素窗口布局,交给模型的 PNG 宽 1,280 像素,模型从该 PNG 中读取的坐标则用于具有更宽几何形状的 DOM。这两个帧之间的关系是每次打开会话时都会改变的因子——这正是为什么在一次会话中未命中会落在相邻产品上,而在另一会话中则落在空白背景上的原因。当 connect_over_cdp 连接到一个已经运行的浏览器时,Playwright 是该浏览器的客户端,而不是它的拥有者,视口仿真是它自己创建的上下文的一个属性——Playwright 的上下文文档明确区分了这一点 Playwright的上下文文档

模型并不是在随机猜测。它正确地读取图像并在图像的帧中回答,然后该编号在不同的帧中使用。

改为标记元素

Set-of-Mark 通过从未跨越帧传输坐标来消除了帧问题。模型返回一个索引;该索引在页面自身中被解析回一个元素。

标记通道遍历交互元素,筛选出用户实际上可以单击的元素,在每个元素上绘制一个编号的框,并返回一个平行的列表。将其保存为 mark.js

javascript Copy
() => {
  const sel = 'a[href], button, input, select, textarea, [role="button"], [onclick]';
  const out = [];
  document.querySelectorAll('#som-layer').forEach(n => n.remove());
  const layer = document.createElement('div');
  layer.id = 'som-layer';
  layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647';
  document.body.appendChild(layer);
  const vw = innerWidth, vh = innerHeight;
  let i = 0;
  for (const el of document.querySelectorAll(sel)) {
    const r = el.getBoundingClientRect();
    if (r.width < 8 || r.height < 8) continue;
    if (r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw) continue;
    const cs = getComputedStyle(el);
    if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
    const box = document.createElement('div');
    box.style.cssText = `position:absolute;left:${r.left}px;top:${r.top}px;width:${r.width}px;height:${r.height}px;border:2px solid #E11D48;box-sizing:border-box`;
    const tag = document.createElement('div');
    tag.textContent = i;
    tag.style.cssText = `position:absolute;left:${r.left}px;top:${Math.max(0, r.top - 14)}px;background:#E11D48;color:#fff;font:bold 12px monospace;padding:0 4px;line-height:14px`;
    layer.append(box, tag);
    out.push({ i, tag: el.tagName.toLowerCase(),
               name: (el.innerText || el.getAttribute('aria-label') || el.value || '').trim().slice(0, 60),
               x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) });
    i++;
  }
  return out;
}

大小和可见性过滤器 比看起来更重要。没有 width < 8 || height < 8 测试,您会标记跟踪像素和折叠的包装器;没有视口边界测试,您会标记整个长页面的页脚,并向模型提供它无法看到的事物的编号。这两种情况产生的菜单中,索引与图像不一致,这比没有标记要糟糕得多。

叠加是一层 position:fixedpointer-events:none,所以它绘制在页面上方,而不会拦截您即将进行的单击,也不会重新流动任何内容。

每个元素的中心是在标记时从 getBoundingClientRect() 捕获的,以页面的坐标系统为准。这是单击将使用的号码,它从未通过图像进行往返。

完整循环

标记、截图、请求索引并单击它:

python Copy
import base64, json, os, pathlib, urllib.request
from urllib.parse import urlencode
from playwright.sync_api import sync_playwright

TARGET = "https://books.toscrape.com/"
TASK = "open the product page for the book titled 'Soumission'"
MODEL = "google/gemini-2.5-flash-lite"
MARK_JS = pathlib.Path("mark.js").read_text()
CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
    "token": os.environ["SCRAPELESS_API_KEY"], "sessionTTL": 300, "proxyCountry": "US"})


def ask(png, prompt):
    body = {"model": MODEL, "max_tokens": 200, "temperature": 0,
            "messages": [{"role": "user", "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {
                    "url": "data:image/png;base64," + base64.b64encode(png).decode()}}]}]}
    req = urllib.request.Request(
        "https://openrouter.ai/api/v1/chat/completions", data=json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
                 "Content-Type": "application/json"})
    d = json.load(urllib.request.urlopen(req, timeout=120))
    return d["choices"][0]["message"]["content"].strip(), d["usage"]


with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(CDP)
    page = browser.new_page()
    page.goto(TARGET, wait_until="domcontentloaded")

    marks = page.evaluate(MARK_JS)
    shot = page.screenshot()
    page.evaluate("() => document.querySelectorAll('#som-layer').forEach(n => n.remove())")
    print(f"marked {len(marks)} interactive elements")

    menu = "\n".join(f"[{m['i']}] <{m['tag']}> {m['name']}" for m in marks)
    answer, usage = ask(shot,
        f"The screenshot has numbered red marks on every clickable element.\n{menu}\n\n"
        f"To {TASK}, which mark do you click? Reply with JSON only: "
        '{"index":int}. Do not explain.')
    print("model answered:", answer)

    idx = json.loads(answer[answer.index("{"):answer.rindex("}") + 1])["index"]
    chosen = next(m for m in marks if m["i"] == idx)
    print(f"chose [{idx}] {chosen['name']!r}")

    with page.expect_navigation(wait_until="domcontentloaded"):
        page.mouse.click(chosen["x"], chosen["y"])
    print("landed on:", page.url)
    print("prompt tokens:", usage["prompt_tokens"])
    browser.close()

输出:

text Copy
marked 85 interactive elements
model answered: {"index": 60}
chose [60] 'Soumission'
landed on: https://books.toscrape.com/catalogue/soumission_998/index.html
prompt tokens: 2100

五次实验,同一会话,同一模型,同一温度:

trial index label landed on result
1 60 Soumission soumission_998 HIT
2 60 Soumission soumission_998 HIT
3 60 Soumission soumission_998 HIT
4 60 Soumission soumission_998 HIT
5 60 Soumission soumission_998 HIT

5 of 5,在坐标得分为 0 of 5,而在所有三个会话中得分为 15 of 15 的会话中。

确定性现在反过来了。导致坐标失败无法修复的同一属性——在温度 0 时的稳定答案——使得标记版本可靠正确,而不是可靠错误。

请期待绝对数字在会话之间有所变化。因为布局视口是远程窗口实际的大小,所以同一脚本在一个会话中标记了 85 个元素并选择索引 60,而在另一个会话中标记了 52 个元素并选择索引 43。两者都落在 soumission_998 上。计数依赖于会话;而目标则不依赖于会话,这正是通过 DOM 解析带来的好处。

这要花费什么

标记将元素菜单添加到提示中,菜单的规模随着页面的增大而增长:

approach prompt tokens cost per decision
raw screenshot → coordinates 1,343 $0.00014
set-of-mark → index 2,100 $0.00021
delta +757 (+56.4%) +49.1%

原始截图提示是固定的 1,343 个 token,因为图像大小是固定的;而标记提示随着您决定标记的元素数量而变化。对于一个标记了 85 个元素的页面,额外增加了 56% 的提示,以大约千分之一美分的成本做出一次决策。

这种缩放是上述可见性过滤器的真正论点:您拒绝标记的每个元素都是您未花费的 token,以及模型无法错误选择的索引。在代理点击正确事物的可能性上,0 到 100% 的摆动并不是一个接近的选择。

这仍然在哪里失败

Set-of-Mark 修复了定位问题,但并不是解决所有问题。

画布和 WebGL 表面没有要标记的元素。 地图、图表画布或游戏渲染为像素,而没有底层的 DOM 结构。标记无法找到任何内容,你又回到了坐标,或者回到了组件暴露的任何可访问性钩子。

标记是视口绑定的。 一切低于折叠区域的内容都是故意不标记的,因此需要更下方元素的代理必须滚动并重新标记。将标记视为每次观察的步骤,而不是一次性设置。

密集页面产生长菜单。 一个有 400 个控件的页面会产生一个 400 行的菜单,并且令牌账单会在循环的每一步都出现。根据角色、区域或与任务的接近度来过滤,然后再进行标记。

索引的有效性仅与标签相关。 一个可访问名称为空的元素显示为 [31] <a>,模型没有任何推理依据。这是屏幕阅读器遇到的相同命名问题,而修复方法与 可访问名称和描述计算 中已经指定的相同:优先使用 aria-label,后备使用标题或附近的文本。

每次导航后重新标记是强制的。 索引是位置性的,并会在下次渲染时重新分配。在页面转换过程中保持一个索引是一个错误,给人一种定位失败的感觉。

在云浏览器上运行

以上内容在 Scrapeless Scraping Browser 上通过 CDP 运行,这就是视口查找能够呈现的原因 — 本地的 chromium.launch() 为你提供了请求的视口,并在你部署之前隐藏了问题。

连接是 WebSocket URL:

python Copy
import os
from urllib.parse import urlencode

CDP = "wss://browser.scrapeless.com/api/v2/browser?" + urlencode({
    "token": os.environ["SCRAPELESS_API_KEY"],
    "sessionTTL": 300,
    "proxyCountry": "US",
})
print(CDP.split("?")[0])

proxyCountry 固定了出口区域,这对本地化价格或可用性的任何目录都是重要的。循环的其余部分没有改变 — Playwright 的 API 无论是启动浏览器还是附加到一个浏览器都是相同的,因为两者在底层都使用 Chrome 开发者工具协议。如果底层协议不熟悉,CDP 初学者指南 涵盖了传输,而 计算机使用代理循环 涵盖了此定位步骤所插入的观察-决定-行动循环。

从上述测量中有两个习惯延续下来。读取 innerWidthinnerHeight,而不是假设你请求的视口 — 在远程会话中,它们是不同的数字。并且通过页面交给你的元素而不是通过从图像计算出的坐标来处理每次点击。

结论

测量是粗略的。在同一页面上,使用相同的模型和提示,要求像素坐标正确获取元素的次数是 0 次中的 15 次;要求索引正确获取的次数是 15 次中的 15 次。坐标失败并不是可以通过重复采样平滑掉的噪声 — 在温度为 0 时,它返回相同的点,并且每次都以相同的方式错过。

在模型的定位弱点之下,存在一个更简单的错误:在远程浏览器上,屏幕截图和 DOM 并不在同一坐标系中,而 set_viewport_size() 不会将它们放在那里。为元素编号可以以几百个令牌的代价绕过这两个问题。

准备好在管理的浏览器上构建循环了吗? 免费开始使用 Scrapeless — 免费计划覆盖本指南中的每次运行,而 定价 从那里开始逐步扩展。

常见问题

问:为什么我的浏览器代理点击错误的元素,即使它描述的是正确的元素?

因为描述和定位是两个独立的能力。模型正确读取页面,然后必须发出一个准确的数字,而从图像中获得准确数字是它弱的输出。添加远程浏览器时,屏幕截图和 DOM 使用不同的坐标框架,错误就不再是偶然的,而是系统性的 — 在以上运行中,它在每次 15 次尝试中都未能抓取。

问:Set-of-Mark 比发送 DOM 或可访问性树更好吗?
它们解决不同的部分。文本表示告诉模型什么存在;标记则告诉它这些事物在它所查看的图片上的位置,并给它一个可以解析回真实元素的标记。标记也保持简洁——每个元素一个索引和一个简短的标签——而在一个适度的目录页面上,原始 HTML 的标记数量则达到五位数。

问:标记增加了多少个标记?

在测试页面上,757 个标记,将提示从 1,343 提高到 2,100——对 85 个标记元素增加了约 56%。原始屏幕截图提示是固定的,因为图像大小是固定的;而标记提示则随着标记数量的增多而变化,因此可见性和大小过滤器既进行成本控制,又进行准确度控制。

问:这在远程或云浏览器上有效吗?

在那里的效果更好,而且更为必要。因为索引通过页面内的 getBoundingClientRect() 解析,它不受屏幕截图大小与远程窗口的真实布局视口之间不匹配的影响——这种不匹配每次都导致坐标方法失败。

问:每次操作后我需要重新标记吗?

是的。索引是按照文档顺序分配给当前可见元素的,因此在任何导航、滚动或 DOM 更新后,它们会被重新分配。在每个观察步骤中进行标记;重用来自先前屏幕截图的索引是这种技术常见的错误实现方式。

问:在没有可点击元素可标记的页面上会发生什么?

画布、WebGL 和视频呈现没有可枚举的 DOM 结构,因此标记无法返回有用的内容。那些需要不同的策略——由组件提供的可访问性钩子,或者显式纠正帧不匹配的坐标方法。

在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。

最受欢迎的文章

目录