ClaudeClaude API系列第 23 / 24 篇

用 SDK 的工具集类让 Claude 操作浏览器:自己写驱动,配网址策略、请求拦截和确认

Python 和 TypeScript 的 SDK 里有浏览器和电脑两个工具集类,替你做参数解析、网址检查、确认和结果拼装,你只写操作浏览器的那一层。本站用 Playwright 写了一个四个成员的驱动,实测各种情况下 Claude 读到什么,并直连官方让它真的打开网页、点链接。通过中转站时有一处不兼容。

发布 · 约 16 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-15。

本页目录(12 节)
  1. SDK 替你做什么,你要做什么
  2. 写一个驱动
  3. 成员返回什么,Claude 读到什么
  4. 跑起来:tool runner,或者自己写循环
  5. 网址策略:只管 navigate 里写的那个网址
  6. 请求拦截:点链接、跳转、页面自己发的请求
  7. 确认和成员开关
  8. 电脑工具集有什么不同
  9. 上线之前
  10. 用量
  11. 这次实测没有覆盖到的
  12. 相关文章

这篇教程解决一个问题:想让 Claude 自己开浏览器查东西、点页面,循环和各种检查不想从头写,怎么办。

上一篇讲现成工具的文章里,浏览器操作和电脑操作只讲了“工具集”这种写法:tools 里写一项,Claude 得到一组成员工具,调用怎么分发、结果怎么回都要你自己处理。官方的 Python 和 TypeScript SDK 在这上面又包了一层:一个浏览器工具集类,一个电脑工具集类,都还在测试阶段。用它们的话,你只写“驱动”,也就是真正操作浏览器或桌面的那部分,其余交给 SDK。

本站在服务器上写了一个驱动,把文档里说的各种情况逐个跑了一遍。

实测环境
日期 2026-10-13
SDK Python 版 anthropic 1.13.0
浏览器 Playwright 1.64.0 带的无界面 Chromium,Ubuntu 24.04
模型 Sonnet 5.5
连接方式 2026-10-15 直连官方接口(用的是 Max 套餐每月赠送的 API 额度);2026-10-13 先通过酷站AI 中转站测过一轮

先说结论:

  • 直连官方,官方示例的写法原样能用。 把驱动交给 tool runner,三四轮就完成了“打开网页、点链接、读出标题”的任务;打开“边流式输出边开始执行”也一样能跑。
  • 通过酷站AI 中转站,有一处不兼容。 第一次请求能通,把结果传回去时返回 400:SDK 在每个结果末尾附了一个 browser_state 内容块,中转站不认识这种块,所以 tool runner 在第二次请求上就停了。把这个块去掉再发能走通,2026-10-13 那一轮就是这样测的;但这只是为了测试,正常用法不该去掉它,原因见后面。电脑工具集的结果里没有这个块,不受影响。

SDK 替你做什么,你要做什么

谁 做什么
SDK 把 Claude 的调用解析成带类型的参数;按你的设置关掉成员;调用你的网址策略、文件策略和确认函数;把驱动返回的东西拼成 tool_result;每次调用之后向驱动要一份浏览器状态;一轮里有调用失败时,后面的不再执行
tool runner 替你跑“请求、执行、回结果”的循环。不用它也可以,自己写循环
你(驱动) 真的去开页面、截图、点击、输入,并报告现在开着哪些标签页

驱动怎么来,文档给了三条路:

  1. 用合作方现成的驱动。 文档列了四家:Browser Use 和 Browserbase 有浏览器工具集的驱动,Daytona 有电脑工具集的,E2B 两种都有。用现成的驱动,一个能开浏览器的智能体只要十来行。这条路本站没有测。
  2. 从官方的示例驱动改。 claude-quickstarts 仓库里有一个通过 Chrome DevTools 协议控制 Chromium 的例子,文档说明它不是生产代码。
  3. 自己写。 文档把它归为进阶用法。下面走的是这一条。

写一个驱动

驱动是 BetaAbstractBrowserToolset20260801 的子类。浏览器工具集一共 31 个成员,你的浏览器支持哪个就实现哪个,没实现的 SDK 会在发给接口的工具项里标成关闭。我们实现了 4 个,发出去的配置里另外 27 个都是关着的。

文档的例子里有一个 backend,代表你自己对浏览器自动化库的封装。我们的 backend 是一个很薄的类:它启动一个 Node 子进程跑 Playwright,两边用一行一个 JSON 通信。这样做只是因为测试机上已经有 Node 版的 Playwright,不想再装东西;你用 Python 写的话,直接用 Python 版的 Playwright 更省事。启动那个子进程时,我们只传了几个必要的环境变量,没有把调接口用的 key 带给浏览器那一头,这也是文档的要求。

驱动本身:

import re
from urllib.parse import urlsplit

from anthropic.tools import ToolError
from anthropic.tools.browser import (
    BetaAbstractBrowserToolset20260801,
    BetaBrowserNavigateResult,
    BetaBrowserState,
    BetaDialogDismissed,
    BetaNavigationRefused,
    BetaScreenshotResult,
)
from anthropic.types.beta import BetaBrowserStateTabEntryParam

from backend import Backend, BackendError

SCHEME_PREFIX = re.compile(r"[a-z][a-z0-9+.-]*:", re.IGNORECASE)


def with_scheme(url):
    return url if SCHEME_PREFIX.match(url) else f"https://{url}"


class PwBrowser(BetaAbstractBrowserToolset20260801):
    def __init__(self, backend: Backend, **options):
        super().__init__(**options)
        self.backend = backend

    # 每个驱动都必须有:现在开着哪些标签页,上次报告之后发生了什么
    def _browser_state(self, context):
        state = self.backend.call("state")
        changes = [BetaNavigationRefused() for _ in range(state["refused"])]
        changes += [
            BetaDialogDismissed(kind=d["kind"], message=d["message"])
            for d in state["dialogs"]
        ]
        return BetaBrowserState(
            tabs=[BetaBrowserStateTabEntryParam(**tab) for tab in state["tabs"]],
            state_changes=changes,
        )

    def navigate(self, context, input):
        history = ("back", "forward", "reload")
        url = input.url if input.url in history else with_scheme(input.url)
        try:
            page = self.backend.call("goto", url=url, tab=input.tab_id)
        except BackendError as exc:
            raise ToolError(f"navigation failed: {exc}") from exc
        return BetaBrowserNavigateResult(
            url=page["url"], status=page["status"], title=page["title"]
        )

    def screenshot(self, context, input):
        data = self.backend.call("shot", tab=input.tab_id)["data"]
        return BetaScreenshotResult(data=data, media_type="image/png")

    def get_page_text(self, context, input):
        return self.backend.call("text", tab=input.tab_id)["text"]

    def left_click(self, context, input):
        if input.target.type != "coordinate":
            raise ToolError("this driver only clicks by coordinate")
        self.backend.call(
            "click", tab=input.tab_id, x=input.target.x, y=input.target.y
        )

    def close(self):
        super().close()  # 先关工具集,保证没有调用还在用浏览器
        self.backend.close()

几处要留意:

  • 每个成员收到两个参数:这次调用的上下文,和已经解析成对象的输入(input.url、input.tab_id 这样取)。
  • Claude 写网址可以不带协议,比如只写 example.com。驱动收到的是它的原话,所以 navigate 里要自己补上 https://。
  • 点击的目标有两种:页面上的坐标,或者 read_page、find 返回的元素编号。我们没有实现后两个成员,所以只接受坐标,碰到编号就用 ToolError 告诉 Claude 换一种办法。
  • 截图的大小要你自己管。 SDK 不读也不缩图,超过模型的图片上限接口会直接拒绝。我们把窗口固定在 1024×900。

成员返回什么,Claude 读到什么

不调接口也能看 SDK 的行为:自己造一个调用块,交给工具集的 tool_result,看它拼出什么。下面都是这样得到的真实输出。

调用 Claude 读到
navigate,网址 example.com Navigated to https://example.com/ — Example Domain (HTTP 200)
get_page_text 页面的文字
get_page_text,页面是空的 (empty)
screenshot 一张图片
left_click Clicked.

成功的结果末尾都跟着一个 browser_state 块,内容来自你的 _browser_state:

{"type": "browser_state",
 "tabs": [{"tab_id": "tab_1", "title": "Example Domain",
           "url": "https://example.com/", "active": true}]}

Claude 靠它知道现在开着哪些标签页、哪个是当前的。这就是前面说中转站不认识的那个块。去掉它,单标签页的简单任务还能做(它从 navigate 的那行文字里也看得到网址),但点链接跳到了哪、开没开新标签页,它就只能靠截图和读页面去猜了。

出错时读到的内容:

情况 Claude 读到 这一轮
调了你没实现的成员(find) The browser toolset member 'find' is not available in this environment. 继续
调了一个不存在的成员 Error: unknown browser toolset member '…' 继续
驱动抛出 ToolError 你写的那句话 继续
标签页编号不对 我们在驱动里转成的 navigation failed: no such tab: tab_99 继续
_browser_state 自己抛了异常 什么也读不到,SDK 抛出 ToolsetUsageError 停止(按文档,没有测)

驱动里别让底层的异常直接漏出去。文档提醒,SDK 不会替你把本机路径从报错文字里去掉,所以要自己接住异常,换成你自己写的话再抛 ToolError。

跑起来:tool runner,或者自己写循环

官方的写法是把驱动实例直接放进 tools,交给 tool runner:

from anthropic import Anthropic

client = Anthropic()
with PwBrowser(backend, url_policy=url_policy) as browser:
    runner = client.beta.messages.tool_runner(
        model="claude-sonnet-5-5",
        max_tokens=1024,
        tools=[browser],
        messages=[{"role": "user", "content": "Open example.com and tell me its heading."}],
    )
    for message in runner:
        print(message)

直连官方,这段代码原样能跑。 任务换成“打开 example.com,点页面上的 Learn more 链接,告诉我落到的那一页的标题”,跑了两次:

[第 1 轮] navigate {"url": "https://example.com"};screenshot {}
[第 2 轮] left_click {"target": {"type": "coordinate", "x": 511, "y": 822}};wait {};get_page_text {}
[第 3 轮] get_page_text {};screenshot {}
[第 4 轮] The heading on the page I landed on is "Example Domains". Clicking "Learn more"
          on example.com took me to an IANA (Internet Assigned Numbers Authority) page.
  • 一共 9 秒。tool runner 回给模型的结果里,browser_state 块原样带着,接口照单全收。
  • 第 2 轮它多调了一个我们没实现的 wait。按规则,这一轮里排在它后面的调用不再执行,所以第 3 轮它又读了一次页面。这条规矩 tool runner 替你守了。
  • 输入从第 1 轮的 2,776 个 token 涨到第 4 轮的 6,140 个,涨的主要是截图。

边流式输出边开始执行: 给 tool runner 加上 stream=True 和 run_tools_eagerly=True,循环里拿到的是流,取 stream.get_final_message() 看结果。同样的任务三轮做完,8 秒,和不开时差不多。这么短的任务看不出差别,调用多、模型回复长的时候才有意义。

通过中转站,这段代码的第一次请求成功,第二次请求返回 400,原因就是前面说的那个块。

不用 tool runner 的话,循环自己写。要守一条规矩:一轮里有一个调用失败了,这一轮后面的调用就不能再执行,要用一句固定的话回它。下面是 2026-10-13 经中转站测试时实际用的循环,带一个只为了过中转站才加的开关;直连官方时把 STRIP 设成 False,或者干脆删掉那三行:

NOT_EXECUTED = "Not executed: an earlier action in this turn failed."
STRIP = True   # 只在走中转站测试时用:把 browser_state 块去掉再发

messages = [{"role": "user", "content": task}]
for turn in range(10):
    r = client.beta.messages.create(
        model="claude-sonnet-5-5", max_tokens=1000,
        tools=[browser], messages=messages,
    )
    messages.append({"role": "assistant", "content": r.content})
    calls = [
        b for b in r.content
        if b.type == "tool_use" and b.toolset_name == browser.toolset_name
    ]
    if not calls:
        break
    results, failed = [], False
    for call in calls:
        if failed:
            results.append({
                "type": "tool_result", "tool_use_id": call.id,
                "toolset_name": call.toolset_name,
                "content": NOT_EXECUTED, "is_error": True,
            })
            continue
        res = browser.tool_result(call)
        failed = bool(res.get("is_error"))
        if STRIP:
            kept = [b for b in res["content"] if b["type"] != "browser_state"]
            res = {**res, "content": kept}
        results.append(res)
    messages.append({"role": "user", "content": results})

同样的任务,用这个循环经中转站跑(去掉了 browser_state 块),三轮做完:

[第 1 轮] navigate {"url": "https://example.com"}
          -> Navigated to https://example.com/ — Example Domain (HTTP 200)
[第 1 轮] screenshot {} -> 一张截图
[第 2 轮] left_click {"target": {"type": "coordinate", "x": 511, "y": 822}}
          -> Clicked.(标签页到了 https://www.iana.org/help/example-domains)
[第 2 轮] get_page_text {} -> Domains Protocols Numbers About … Example Domains …
[第 3 轮] The page you land on after clicking "Learn more" has the heading
          "Example Domains". It's an IANA page, which is where the link goes.

它先截图找到链接的位置,再按坐标点。一轮里可以发几个调用,SDK 按它写的顺序一个一个执行。

网址策略:只管 navigate 里写的那个网址

不传网址策略时,SDK 不检查任何网址,接口那边也不过滤。策略是你写的一个函数,在 navigate 运行之前被调用,拿到的是 Claude 写的原话。要拒绝就抛 ToolError,Claude 会读到你写的那句话。

我们用的是文档里的示例策略,只放行两个站点和它们的子域名:

ALLOWED_HOSTS = ("example.com", "iana.org")


def is_allowed(url):
    # 示例,不是能直接上生产的策略
    if url.lower() == "about:blank":
        return True
    try:
        parts = urlsplit(with_scheme(url).replace("\\", "/"))
    except ValueError:
        return False
    host = parts.hostname or ""
    listed = any(host == h or host.endswith(f".{h}") for h in ALLOWED_HOSTS)
    return parts.scheme in ("http", "https") and listed


def url_policy(context, url):
    if not is_allowed(url):
        raise ToolError(f"blocked: {url} is not on an allowed host")

实测的结果:

Claude 要去的网址 结果
example.com 放行
https://www.wikipedia.org/ 被拒,读到 blocked: … is not on an allowed host
file:///etc/passwd 被拒
javascript:alert(1) 被拒
reload 不经过策略,直接执行

后两种被拒是因为示例策略只认 http 和 https。这一条不能省:文档说 SDK 不检查协议,file: 能让 Claude 读到浏览器所在机器上的文件,javascript: 会在当前页面里执行脚本。

让模型去开名单以外的网站,它读到拒绝之后如实报告了,同一轮里跟在后面的截图没有执行:

[第 1 轮] navigate {"url": "https://www.wikipedia.org/"}
          -> (报错)blocked: https://www.wikipedia.org/ is not on an allowed host
[第 1 轮] screenshot {} -> 没有执行(前一个动作失败了)
[第 2 轮] I couldn't open https://www.wikipedia.org/ because the browser tool
          blocked it with the error "not on an allowed host." …

还有一个细节:把策略显式传成 None,SDK 会拒绝所有带网址的 navigate,Claude 读到 refused by the URL policy。这是故意的,免得配置里读出一个空值,网址就悄悄没人查了。实测时 SDK 还会往日志里打一段异常的堆栈,不影响运行。

请求拦截:点链接、跳转、页面自己发的请求

网址策略只看 navigate 调用里的网址。Claude 点一个链接、提交一个表单、页面跳转、页面上的图片和脚本,都不经过它,只有驱动看得见。所以文档要求第二层:在驱动里拦请求,用同一份规则。

Playwright 里是给浏览器上下文注册一个路由。我们那个 Node 子进程里是这样写的:

await context.route("**/*", (route) => {
  const req = route.request();
  if (isAllowed(req.url())) return route.continue();
  const mainFrame = req.frame().parentFrame() === null;
  if (req.isNavigationRequest() && mainFrame) refused += 1;
  return route.abort("blockedbyclient");
});

被拦下的页面跳转记个数,下一次状态报告时每一次报一个 BetaNavigationRefused。

为了看效果,我们让浏览器这一层只放行 example.com,再做同样的任务。example.com 上那个链接指向 iana.org:

[第 2 轮] left_click {"target": {"type": "coordinate", "x": 511, "y": 822}}
          -> Clicked.
             A navigation was refused.
[第 2 轮] screenshot {} -> 一张截图(标签页的网址变成了 chrome-error://chromewebdata/)
[第 3 轮] get_page_text {} -> (empty)
[第 5 轮] I couldn't get the heading, because the click on "Learn more" didn't
          take me to a new page. … the browser reported "A navigation was refused."

A navigation was refused. 这句话是 SDK 加的,不带网址。Claude 读到之后知道是被挡了,没有硬闯。

文档说明这样的拦截也有看不到的地方:WebSocket 握手、service worker 发的请求、跳转中间的每一跳。service worker 要整个禁掉,我们在创建浏览器上下文时加了 serviceWorkers: "block"。再往外一层是容器的出站规则,那是部署的事,本站没有测。

确认和成员开关

confirm 是一个返回真假的函数,SDK 在每个即将执行的调用之前问它。我们写了一个“导航一律不批”的版本来看顺序:

调用 结果 confirm 被问到了吗
navigate,名单内的网址 被拒,读到下面那句话 问了
navigate,名单外的网址 被网址策略拒绝 没有问
get_page_text 执行 问了,放行

被 confirm 拒绝时 Claude 读到的是:

The user did not grant permission to run 'navigate'. Do not retry it unless the user asks you to.

顺序是先网址策略,再 confirm,已经被拒的调用不会再去问人。confirm 能拿到成员的名字、解析好的输入,以及目标标签页在上一次状态报告里的网址。

有两个成员默认是关的,而且硬性要求有 confirm:javascript_exec(在页面里执行脚本)和 file_upload。我们给驱动加了一个 javascript_exec 来验证:

设置 结果
实现了,不打开 构造成功。按文档,这个成员默认是关的,不提供给 Claude
打开,不给 confirm 构造时报配置错误,说它需要一个 confirm
打开,给 confirm 构造成功

文档提醒了一点:付款、发消息、同意条款,都是通过普通的点击和输入完成的,confirm 没法按成员名字把它们挑出来。想让人来批这些动作,就得对点击和输入也问。

configs 用来开关成员,只写你要改的那几个:

browser = PwBrowser(backend, configs={"navigate": {"enabled": False}})

关掉之后 Claude 再调 navigate,读到的是这个成员不被本应用的权限允许。反过来,打开一个驱动没实现的成员,构造时就报配置错误。

电脑工具集有什么不同

电脑工具集类是 BetaAbstractComputerToolset20260801,写法一样:一个工具一个方法。按文档,不同的地方有这些:

  • 没有网址策略、文件策略和状态报告,这三样是浏览器才有的。
  • 你实现的工具默认都是开的。
  • 实现了 type、key、hold_key 这三个键盘工具,就必须给 confirm,或者用 configs 把它们关掉,否则构造时报错。没有 confirm 时,包括每一次点击在内的所有调用都直接执行。
  • 坐标要你自己换算。 Claude 给的每个点都是你返回的截图里的像素位置。截图和真实屏幕大小不一样时,两个方向都要在驱动里换算,整个会话里截图的尺寸保持不变。
  • 一轮里失败之后,回给后面那些调用的固定句子和浏览器的不一样,中间多一个 computer。

本站对它只做了一个最小的测试:一个只实现了截图的假桌面,返回一张 320×200 的灰色图片。通过中转站,一个来回走通了,模型描述了它看到的画面。真的桌面没有搭,官方接口上也没有再测。

上线之前

上面的驱动只能对着一个用完就扔的浏览器跑。文档列了六步,SDK 只负责其中的第 1、4、5 步,其余靠你的驱动和部署:

  1. 写网址策略,任务用不到的网址一律拒绝。
  2. 在驱动里拦请求,用同一份规则。
  3. 给容器加出站规则:封掉内网和链路本地地址(包括云主机的元数据地址),只放行任务需要的主机。
  4. 管住上传和下载。上传默认是关的;要开的话,给一个只装任务文件的上传目录。
  5. 用 confirm 把有实际后果的成员交给人批。
  6. 每个会话用一个专用的、权限最小的容器或虚拟机跑浏览器,用全新的浏览器配置,不要登录任何你不愿意交给 Claude 的账号。调接口的那段代码放在这个容器外面,因为 key 和对话都在它那里。

页面返回的一切都当成不可信的:正文、截图、标签页标题、下载的文件名。页面上的文字可以冒充指令,让 Claude 去访问内网的服务或者做有后果的操作。前面每一层检查,防的都是这个。

用量

工具集的定义按成员计。只实现 4 个成员时,一次请求的输入是 2,779 个 token,同一句话不带工具是 46 个。上一篇里测的是全部成员都提供时的数字,有六千多。所以用不到的成员不要实现,或者用 configs 关掉。

经中转站那一轮发了二十来次请求,直连官方的补测又跑了两遍完整任务,都用的 Sonnet 5.5,花费以美分计。

这次实测没有覆盖到的

  • 网址策略、请求拦截、确认这些情况是在本地拼结果看的(“成员返回什么”一节),以及经中转站跑的;直连官方只跑了完整任务的两遍。
  • 只实现了 4 个成员。 按元素编号点击、输入、多标签页、读控制台、上传下载都没有做。
  • 文件策略、execute 的前后钩子没有试。
  • 合作方的驱动和官方的示例驱动没有试。
  • TypeScript 版没有测。 按文档,写法对应,成员要写成方法不能写成箭头函数,type 成员在 TypeScript 里叫 type_。
  • 电脑工具集只做了一次假桌面的来回。
  • 容器的出站规则、隔离这些部署上的事没有涉及。
  • 每种情况只跑了一次。

相关文章

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章