Fork me on GitHub

2026年6月

给小龙虾配个浏览器:学习 browser 工具

前两篇我们把 OpenClaw 的内置工具箱挨个过了一遍,末了还留了个尾巴:其中有两个工具细节比较多,打算各开一篇单独讲,一个是浏览器工具 browser,一个是 ACP。这一篇就先来学习 OpenClaw 的 browser 工具。

browser 会真的开起一个 Chromium 浏览器,像人一样去操作网页,解决的是前面 web_searchx_searchweb_fetch 那套轻量工具搞不定的场景:要登录、要点按钮、内容得靠 JS 才渲染得出来的页面。它功能最强,配置也最讲究,光「浏览器跑在哪」就有 host、sandbox、node 三种情况,「用哪个浏览器」又分好几种 profile,内容实在不少,于是我们把这一个工具拆成上下两篇:这一篇先把环境铺好;下一篇再讲环境就绪之后,怎么真正驱动浏览器在页面上干活。

铺垫完了,先从它那张复杂的参数表看起。

工具参数定义

和前面的 messagenodes 一样,浏览器在 agent 侧也只暴露 browser 一个工具,靠不同的 action 分派不同的操作:

browser-1.png

它的 action 覆盖了一整套浏览器生命周期,粗看可以分成三组:一组管「进程和环境」,比如体检环境的 doctor、起停浏览器的 startstop;一组管「标签页」,列、开、切、关四件套;剩下一组才是真正在页面上干活的,导航、取结构、截图、执行动作,外加读日志、导出 PDF、传文件、处理弹窗。整理成一张图大致如下:

browser-actions.jpg

其中 act 本身又是个小分派器,要执行的动作由 kind 指定,对应参数(推荐打包进 request 对象)随 kind 不同而不同:

kind.png

除这几个核心参数外,还有不少参数,基本都是配合某个具体动作用的,这里先扫一眼,后面遇到时再展开:

browser-2.png

运行位置

exechost 参数类似,browser 也有一个 target 参数,取值是 host / sandbox / node 三种。不传 target 时,OpenClaw 按会话类型挑默认值:沙箱会话默认 sandbox,非沙箱会话默认 host;要是有一台带可控浏览器的 node 连了进来,工具还可能自动路由到那台 node 上。三个取值里 host 最直白、也最常用,非沙箱会话默认就走它,你不用做任何配置,所以下面先从 host 讲起,再回头看 sandboxnode

在 host 上运行浏览器

target="host" 时,就是在网关那台机器上启动或接管一个本地浏览器。但是启动哪个浏览器呢?这就要靠 profile 来确定了。OpenClaw 在 host 上内置了两个 profile:

  • openclaw(默认):一个专用的、隔离的 Chromium 实例;风险低,和你个人浏览器完全隔离。
  • user:经 Chrome DevTools MCP 接管你真实的、已登录的 Chrome;风险高,在你的登录身份里操作。

先说 openclaw profile,它是专门给 agent 自动化用的隔离 profile,用的是一份独立的用户数据目录,不会碰你个人浏览器的 profile;另外,它会走独立的 CDP 端口,从 18800–18899 这个区间里分配,特意避开了常见的 9222,免得和你本地开发调试时起的浏览器冲突;窗口默认还带了一圈橙色外框(#FF4500),方便用户一眼就能分辨「这是 agent 在用的浏览器,不是我自己那个」;如果本机上找不到指定的浏览器时,OpenClaw 会按 Chrome → Brave → Edge → Chromium → Chrome Canary 的顺序自动挑一个能用的。

这里的 CDP 指的是 Chrome DevTools Protocol(Chrome 开发者工具协议),是 Chromium 内核对外暴露的一套远程控制接口。你平时按 F12 打开的开发者工具,背后走的就是它。各种浏览器自动化框架(像 Playwright)之所以能在外部进程里驱动浏览器导航、点击、截图,靠的都是连上这个协议端口。

另一个 user profile 的思路则不同:它不自己另起一个隔离实例,而是走官方 Chrome DevTools MCP 的 attach 流程,直接接管你当下正开着的那个 Chrome,复用里面现成的标签页和登录态。它要解决的是「我已经在浏览器里登录好了某个网站,想让 agent 直接在我这个会话里接着操作」这类需求。方便归方便,代价是 agent 是货真价实地在你本人的身份下点点点。所以文档把它明确标为更高风险的路径,并且要求只在你本人就坐在电脑前、能亲手点掉那个 attach 授权弹窗的时候才用;它也只负责 attach,绝不会替你去启动浏览器。

有一个容易被忽略的限制:user 这类接管现有会话的 profile,能力其实比托管的 openclaw 要窄一截。它的动作只能基于 snapshot 的 ref 来(不支持 CSS selector),click 只认左键,type 也不支持 slowly 慢速输入;而批量动作、PDF 导出、下载拦截、读取 responsebody 这些进阶能力,都只有 openclaw 才有。

配置更多 profile

除了这两个内置的,你还能往配置里塞 workremotebrave 等任意多个 profile,每个都能单独设端口、外框颜色、可执行文件路径、headless 与否。具体怎么加,有两种办法。

最直接的是往配置文件的 browser.profiles 底下加一段:

{
  browser: {
    profiles: {
      // 本地托管:指定用某个 Chrome、跑 headless、占一个自定义端口和外框色
      work: {
        cdpPort: 18801,
        color: "#0066CC",
        headless: true,
        executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
      },
      // 接管现有会话:attach 到本机已开着的 Brave
      brave: {
        driver: "existing-session",
        attachOnly: true,
        userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
        color: "#FB542B",
      },
    },
  },
}

这里的几个字段稍微解释一下:

  • cdpPort 给这个 profile 单占一个 CDP 端口(不指定会从 18800–18899 自动分配);
  • color 是窗口外框色,方便一眼区分用的是哪个 profile;
  • headless 决定要不要无头跑,也就是不弹出可见窗口,在后台渲染,省资源,也适合没有图形界面的服务器,代价是你没法直接盯着页面看;
  • executablePath 指到具体某个浏览器的可执行文件;
  • driver 不写默认就是 openclaw,也就是由 OpenClaw 自己启动一个隔离的本地浏览器;写成 existing-session 则反过来,去 attach 一个你已经开着的浏览器,只接管、不启动。

你可能在旧资料或命令帮助里见过 openclaw browser create-profile / delete-profile / reset-profile 这几个命令,出于安全考虑,在新版本里它们已经用不了了,跑起来会直接报 browser.request cannot mutate persistent browser profiles

配好之后,agent 调用时用 profile="work" 显式选它,命令行里则是 --browser-profile work 参数指定。

用 cdpUrl 接管远程浏览器

除了上面介绍的本地 profile 其实还有另一种 profile,它指向的不是本机要启动的浏览器,而是一个已经在别处跑着的 Chromium,靠的是 cdpUrl 这个字段:

{
  browser: {
    profiles: {
      remote: { cdpUrl: "http://10.0.0.42:9222" },                  // 另一台机器上的 Chrome
      cloud:  { cdpUrl: "wss://xxx.browserless.io?token=YOUR_KEY" }, // 云端托管服务
    },
  },
}

cdpUrl 填的是一个 CDP 端点地址,也就是前面讲过的 Chrome DevTools Protocol。一旦配了它,OpenClaw 就不再自己启动浏览器,而是连过去接管那个现成的。地址有两种写法:http(s)://host:port 会先走标准的 /json/version 发现流程,找到真正的 WebSocket 调试地址再连;ws(s)://... 则是直连 CDP WebSocket。URL 里还能带认证(query token 或 HTTP Basic),OpenClaw 调 /json/* 接口和 WebSocket 握手时都会带上。

最省事的远程浏览器是托管服务,比如 BrowserlessBrowserbase,它们在云端跑 headless Chromium、还自带验证码处理,感兴趣的同学可以尝试一下。

登录态怎么准备

host 上的这些 profile,多半都要和登录态打交道。既然 browser 能像真人一样操作网页,一个很自然的念头是:让它自己登录不就完事了?官方的建议恰恰相反:碰到要登录的网站,最好你自己在浏览器里手动登一次,千万别把账号密码丢给模型让它替你填。原因有两条,一是自动化的登录流程极容易触发网站的反爬风控,轻则弹验证码,重则直接把账号给锁了;二是凭据本就不该在对话里来回流转。你手动登一次之后,登录态就留在了那个 profile 的数据目录里,之后 agent 在这个已登录的会话里接着干活,不用每次都重来,这也正是 openclaw 这类独立 profile 的价值所在。

在沙箱运行浏览器

我们在之前讲沙箱那篇里说过,沙箱本质上是个 Docker 容器,exec 跑命令、读写文件都在里头。当时还提到过两个镜像:默认的 openclaw-sandbox:bookworm-slim(只装了 bashgitpython3ripgrep 这几样基础工具)和功能更全的扩展镜像 openclaw-sandbox-common:bookworm-slim(额外带上 nodejsgolang 等)。

不过,这两个镜像里都没装浏览器,所以想在沙箱里运行浏览器,OpenClaw 专门提供了第三个镜像 openclaw-sandbox-browser:bookworm-slim,把 Chromium 内置了进去。

这个镜像本身不复杂,打开 scripts/docker/sandbox/Dockerfile.browser 看一眼,去掉缓存挂载、镜像摘要这些细节,骨架就这么几行:

FROM debian:bookworm-slim

RUN apt-get install -y --no-install-recommends \
    chromium \
    xvfb x11vnc novnc websockify socat \
    fonts-liberation fonts-noto-cjk fonts-noto-color-emoji \
    bash ca-certificates curl git jq python3

COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
EXPOSE 9222 5900 6080
CMD ["openclaw-sandbox-browser"]

除了浏览器本体 chromium,剩下这堆包大致分三块:

  • 虚拟显示栈xvfb(X Virtual Framebuffer)凭空造一块没有真实显示器的虚拟屏幕,让 Chromium 以为自己有屏可画;x11vnc 把这块屏幕通过 VNC 协议暴露出来;novnc 配上 websockify,再把 VNC 画面转成网页能直接看的形式(noVNC 是个纯网页的 VNC 客户端,websockify 负责把 VNC 的 TCP 流桥接成 WebSocket)。这三层叠起来,你就能直接用浏览器实时看到沙箱里这个 Chromium 的画面。
  • 字体fonts-noto-cjkfonts-noto-color-emoji 这些,保证中文页面和 emoji 不至于渲染成一片方块。
  • 端口转发:使用 socat 把容器里的 CDP 端口转发出去,并按 CIDR 白名单限制访问来源(具体怎么转发,下面讲 entrypoint 时再细说)。

镜像最后建了个非 root 的 sandbox 用户来跑浏览器,对外开放 9222(CDP)、5900(VNC)、6080(noVNC)三个端口,容器启动时执行 entrypoint 脚本,核心代码如下:

# 行为全由环境变量驱动,端口、超时等先校验合法性
CDP_PORT="${OPENCLAW_BROWSER_CDP_PORT:-9222}"
HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-0}"
# ...VNC_PORT / CDP_SOURCE_RANGE / AUTO_START_TIMEOUT_MS 等同理
validate_uint "CDP_PORT" "$CDP_PORT" 1 65535

# 容器退出(含被 kill)时统一回收所有子进程,不留孤儿
trap 'cleanup "$?"' EXIT
trap 'cleanup 130' INT
trap 'cleanup 143' TERM

# 1. 起一块 1280x800 的虚拟屏幕
Xvfb :1 -screen 0 1280x800x24 &

# 2. 拉起 Chromium,CDP 只监听 127.0.0.1(CHROME_CDP_PORT 取 CDP_PORT + 1)
chromium --remote-debugging-address=127.0.0.1 \
         --remote-debugging-port="$CHROME_CDP_PORT" \
         --disable-dev-shm-usage --disable-gpu --no-zygote ... about:blank &

# 3. 轮询 /json/version 等 CDP 就绪(最多 AUTO_START_TIMEOUT_MS,默认 12 秒;超时或 Chromium 退出就报错)
while ...; do curl -fsS "http://127.0.0.1:$CHROME_CDP_PORT/json/version" && break; sleep 0.2; done

# 4. 只有设了 CDP_SOURCE_RANGE 才起 socat:把边缘端口转发进去、并按 CIDR 放行来源
[ -n "$CDP_SOURCE_RANGE" ] && \
  socat "TCP-LISTEN:$CDP_PORT,fork,range=$CDP_SOURCE_RANGE" "TCP:127.0.0.1:$CHROME_CDP_PORT" &

# 5. 非 headless 时,起带随机密码、只绑 localhost 的 VNC + noVNC
x11vnc -display :1 -rfbport "$VNC_PORT" -rfbauth "$PASSWD" -localhost ... &
websockify --web /usr/share/novnc/ "$NOVNC_PORT" "localhost:$VNC_PORT" &

# 任一子进程退出就触发上面的 cleanup
wait -n

别看就这么几行,里头有不少细节值得注意:

  • 行为全由环境变量驱动OPENCLAW_BROWSER_CDP_PORT...HEADLESS...CDP_SOURCE_RANGE...AUTO_START_TIMEOUT_MS 这些,正好和后面 agents.defaults.sandbox.browser 里那几个配置项一一对应;脚本开头还用 validate_uint 把端口、超时挨个校验,非法值直接报错退出。
  • 容器专用启动参数:第 2 步为了让 Chromium 在容器里正常启动,加了不少适配的参数,比如 --disable-dev-shm-usage 避开容器里只有 64MB 一满就崩的 /dev/shm 目录、--disable-gpu 关掉容器里不存在的 GPU 加速、--no-zygote 关掉靠 fork 预热进程的 zygote 模型,免得撞上容器的 syscall 限制等。
  • CDP 并没有直接对外:Chromium 的调试端口只绑在 127.0.0.1 上(实际端口是配置值 +1),真正暴露到容器边缘的那个端口由 socat 转发,而且只放行 CDP_SOURCE_RANGE(就是配置里的 cdpSourceRange)指定的网段;这个网段要是没设,socat 索性不启动,外面根本连不进来。
  • CDP 就绪轮询:第 3 步死等 /json/version,默认最多 autoStartTimeoutMs(12 秒),超时或 Chromium 中途退出就直接报错。
  • noVNC 带随机密码:只有 enableNoVnc 开着、且非 headless 时才起 VNC 加 noVNC,而且每次启动都现生成一个随机密码、VNC 只绑 localhost,不至于裸奔。
  • 退出时清理干净:开头挂的那几个 trap,会在容器退出(或被 kill)时把 Xvfb、Chromium、socat、x11vnc 一并清掉,不留孤儿;结尾的 wait -n 则盯着所有子进程,任一个挂了就触发清理。

讲完了原理,我们再看下这个镜像怎么使用。OpenClaw 内置了一个构建脚本,下载源码后运行它:

$ ./scripts/sandbox-browser-setup.sh

就能得到上面那个 openclaw-sandbox-browser:bookworm-slim 镜像。然后在配置文件里将沙箱浏览器打开(默认是关的),并配上这个镜像,相关配置都集中在 agents.defaults.sandbox.browser 下面:

{
  agents: {
    defaults: {
      sandbox: {
        browser: {
          enabled: false,        // 默认关,要在沙箱里用浏览器得先打开
          image: "openclaw-sandbox-browser:bookworm-slim", // 内置 Chromium 的专用镜像
          network: "openclaw-sandbox-browser",             // 专用 Docker 网络
          cdpPort: 9222,
          cdpSourceRange: "172.21.0.1/32", // 只放行这个网段访问 CDP 的 CIDR 白名单
          autoStart: true,           // 工具要用时自动把这个容器拉起来
          autoStartTimeoutMs: 12000, // 等 CDP 就绪的超时(毫秒)
          allowHostControl: false,   // 沙箱里默认不许去碰宿主机的浏览器
          enableNoVnc: true,         // 开 noVNC,可以在浏览器里围观沙箱里这个浏览器
          vncPort: 5900,
          noVncPort: 6080,
        },
      },
    },
  },
}

这里也有几处值得留意:它和宿主机的 browser.enabled 互不相干,是沙箱自己那一套独立开关,开沙箱浏览器并不需要你把 host 上的浏览器插件也打开;另外浏览器运行在这个镜像起的专用容器里,挂在专用的 openclaw-sandbox-browser Docker 网络上,和那个跑命令的沙箱容器是完全隔离的。如果把参数 enableNoVnc 打开,OpenClaw 还会生成一个 noVNC 网页地址注入到 agent 的 system prompt。这个地址带一个一次性、短时效的 token(默认 60 秒过期),每次都不一样、没法提前收藏,所以才需要 agent 在你想看时把当前有效的那个递给你;用浏览器打开它,就能实时看到沙箱里这个 Chromium 正在点什么,调试起来很直观。

还有一个和风控有关的问题值得一提。沙箱会话比直接在宿主机上跑更容易被判定成机器人,所以像 X(推特)这种风控严的站点,官方反而建议用宿主机上的 host 浏览器去操作,而不是图隔离把它硬塞进沙箱。如果你的 agent 默认在沙箱里,又确实想让它操作 host 浏览器,就得回到上面那个 allowHostControl=true 开关,再在调用时显式带上 --target host。说到底,浏览器自动化里最像人的那部分动作(登录、过验证),目前还是交给真人最稳妥。

在 node 上运行浏览器

target="node" 时,OpenClaw 将把活儿派到一台连进来的设备上,它的关键在于:node 自己也跑着一套和网关一模一样的浏览器控制服务。网关并不直接去连那台机器的 CDP,而是通过 node 链路,把 browser 的每个动作代理过去,交给 node 本地的控制服务执行。所以能用哪些 profile、浏览器具体装在哪、怎么启动,全看那台 node 自己的 browser.profiles 配置,跟网关无关。

node 上的浏览器代理默认就开着,也是远程网关最常走的路:网关那台机器上没装浏览器不要紧,让有浏览器的 node(比如你那台日常用的 Mac、或者一部手机)替它跑就行,基本零配置。要调它的话,得分两头来配。node 那台机器用 nodeHost.browserProxy 决定自己对外暴露什么:

{
  nodeHost: {
    browserProxy: {
      enabled: true,               // 默认 true;设 false 就不再对外暴露这台 node 的浏览器
      allowProfiles: ["openclaw"], // 可选:只放行这几个 profile,留空则它全部 profile 都可被远程指定
    },
  },
}

网关那台机器则用 gateway.nodes.browser 决定要不要路由、路由到谁:

{
  gateway: {
    nodes: {
      browser: {
        mode: "auto",   // 路由策略:auto 自动选唯一一台浏览器 node(默认)/ manual 要求显式传 node / off 彻底关掉
        node: "my-mac", // 可选:钉死只用某一台 node,不写就按 mode 自动挑
      },
    },
  },
}

小结

浏览器是 OpenClaw 工具箱里能力最强、配置也最讲究的一个。这一篇我们先把它的运行环境从头铺了一遍:

  1. 一个工具一套参数browser 只暴露一个工具,靠 action 分派出一整套动作,按进程环境、标签页、页面操作分成三拨,act 底下又用 kind 二次分派,剩下那些参数都是配合具体动作用的。
  2. 先定运行位置targethost / sandbox / node 决定浏览器在哪台机器上跑,host 直接在网关本机起浏览器;sandbox 用内置 Chromium 的专用镜像;node 则把活儿代理给连进来的设备。
  3. host 上再挑 profile:当你在 host 上运行浏览器时,还可以通过 profile 选择用哪个浏览器;openclaw 是隔离专用的 profile,独立数据目录和端口,user 则是接管你真实已登录的 Chrome 实例;除这两个内置的,还能往配置里塞更多 profile,甚至用 cdpUrl 接管远程或云端浏览器。
  4. 登录态自己准备:碰到要登录的网站,宁可自己在浏览器里手动登一次、把登录态留在 profile 的数据目录里,也别把账号密码交给模型去填;风控严的站点更推荐用 host 浏览器,而不是图隔离硬塞进沙箱。

至此,浏览器环境已经准备好了,下一篇我们就让浏览器真正动起来,看看 agent 是怎么像人一样在页面上点点点的。敬请期待~

参考


学习 turbovec 的 SIMD 搜索内核

在上一篇中,我们沿着 encode.rs 把量化算法走了一遍:随机旋转把每个坐标压到 Beta 分布上,Lloyd-Max 码本求出最优的标量量化器,TQ+ 校准做 5/95% 分位对齐,长度归一化修正补回内积的系统性偏差。这一整套流程解决的是精度和内存的问题,一个 1536 维的 float32 向量被压成 4-bit 编码,体积缩小到原来的八分之一,Recall@1 还能稳住甚至反超 FAISS。

不过压缩只是故事的一半。向量被压成了一串 4-bit 编码以后,搜索的时候这些编码到底是怎么被高速扫描的?10 万条向量、每条 1536 维、4-bit,落到内存里是 73.6 MB 的紧凑字节流,一次查询要在这上面算出 top-k。turbovec 之所以能在 Apple M3 Max 上单线程跑到约 2.0 ms、比 FAISS IndexPQFastScan 快 19%,靠的就是 search.rs 里那套手写 SIMD 内核。今天我们就来读这部分代码,把整个搜索内核拆开看。

搜索路径全景

先看一次 search 调用都发生了什么。代码入口在 lib.rs 的 search_with_mask,真正的计算委托给 search.rs 的 search 函数。整条路径如下:

search-pipeline.png

这几步都对应 search.rs 里的一段代码,我们顺着往下走。

第一步:批量旋转

上一篇讲过,库向量在编码时都乘过一个随机正交矩阵 Q,它是数据无关的常量,放在 OnceLock 缓存里,整个索引共用一份。库向量活在旋转后的坐标系里,查询自然也得旋转到同一个坐标系,内积才算得对。所以这里把查询也乘上同一个 Q,就是 search 函数开头的一次 GEMM:

// q_ref:把输入 queries 按行包成查询矩阵(nq 条、每条 dim 维)
let q_ref = faer::mat::from_row_major_slice::<f32, _, _>(queries, nq, dim);
// r_ref:同样包好的旋转矩阵 rotation
let r_ref = faer::mat::from_row_major_slice::<f32, _, _>(rotation, dim, dim);
// out_mut 是输出 q_rot;matmul 把 q_ref 乘上 r_ref.transpose()(即 rotation^T)写进去
// 整句就是 q_rot = queries @ rotation^T
faer::linalg::matmul::matmul(
    out_mut, q_ref, r_ref.transpose(), None, 1.0_f32,
    faer::Parallelism::Rayon(0),
);

关键在于这里是把所有查询堆成一个矩阵、和旋转矩阵做一次 GEMM,而不是逐查询做矩阵向量乘。这样能把 FMA 吞吐喂满,缓存里那一份旋转矩阵也在多条查询间复用。

GEMM(General Matrix Multiply,通用矩阵乘)是线性代数库里最核心的操作,算的就是两个矩阵相乘 C = A × B。把多条查询堆成一个矩阵、和旋转矩阵一次乘完,比逐条做矩阵向量乘更能榨干 CPU 的缓存和向量单元,是 BLAS 这类库重点优化的对象。FMA(Fused Multiply-Add,融合乘加)则是把一次乘法和一次加法合并成单条 CPU 指令,一次算出 a × b + c,既省一条指令又少一次中间舍入;矩阵乘的内层全是乘加,所以 FMA 的吞吐基本就决定了 GEMM 的速度。

第二步:TQ+ 逆校准

这一步同样是上一篇的对偶操作。编码时 TQ+ 对每个坐标做过一次仿射 (shift, scale),把库向量的经验分布拉回理论 Beta;库向量既然被这样变换过,查询侧就要施加配套的逆变换,才能让两边在校准后的空间里算出和原来一致的内积。

干这件事的是 calibrate_queries,它对每条查询走下面这个循环:

// q_row:这条旋转后的查询;calib_row:要写出去的校准后查询;bc:正在累加的偏置
for d in 0..dim {
    // tqplus_scale[d] 是编码时给该坐标乘过的缩放,逆过来就是除以它
    calib_row[d] = q_row[d] / tqplus_scale[d];
    // tqplus_shift[d] 是编码时加过的平移,在内积里只贡献一个与库向量无关的常数项,
    // 不必逐坐标改查询,把这些常数全累加进 bc 即可
    bc -= (q_row[d] as f64) * (tqplus_shift[d] as f64);
}
// 循环结束,bc 就是这条查询的偏置修正 bias_corr
*bias = bc as f32;

两样合起来,SIMD 内核拿到的就是一条普通的校准后查询 calib_row 和一个偏置 bias_corr,对 TQ+ 的存在毫无感知,照常算内积即可。

后面三步

剩下的三步是这篇的主角,留到后面几节展开:

  1. 生成 LUT:每条查询独立构建一张查找表,是整个内核的核心。
  2. 块级 SIMD 评分:按 32 向量一块、按平台分发到 NEON / AVX2 / AVX-512BW 内核。
  3. top-k 堆更新:评分和堆更新融合在一起,不必先把全库的分数都算出来存成一个大数组。

我们从 LUT 开始。

LUT 查表

向量搜索的内积,本质是把查询和库向量的每一维相乘再求和。库向量已经被量化成了码字(code),每个坐标只有 2^bits 种可能的取值。以 4-bit 为例,一个坐标的码字只有 16 种。那么 查询[d] * 重建值[code] 这个乘积,对固定的查询和固定的坐标 d,也只有 16 种结果。

既然只有 16 种结果,就没必要在搜索时反复做乘法,提前把这 16 个结果算好存成一张表就行。搜索时拿库向量的码字当下标去查表、累加,乘法被彻底消掉了。这张表就是 查找表(Look-Up Table,简称 LUT)

建表的逻辑在 build_query_neon_lut_from_slice。在供 SIMD 扫描的块布局里,一个字节装两个 4-bit 码字(也就是半字节,英文叫做 nibble),低 nibble、高 nibble 正好各对应一个坐标。我们把「一个字节、两个坐标」这一小撮叫一个字节组,1536 维就拆成 768 个字节组。函数为每条查询、每个字节组建两张 16 项子表,低 nibble、高 nibble 各一张,每个表项就是前面说的那个预计算乘积(查询[d] * 重建值[code]):

// dim_start 是第 g 个字节组的起始坐标(低 nibble 对应的那一维)
// 低 nibble 子表,16 个表项(这里按 4-bit 简化)
for nibble_val in 0u16..16 {
    // 表项 = 查询在该坐标的值 × 该码字对应的重建中心点 centroid
    let s = q_rot_row[dim_start] * centroids[nibble_val as usize];
    float_vals[g * 32 + nibble_val as usize] = s;
}

算出来的表项是 float,但后面 SIMD 查表要的是 u8,所以还得量化一遍。turbovec 借鉴了 FAISS 的 per-sub-table 量化:每张 16 项子表先减掉自己的最小值再做 u8 取整,所有表共享一个 scale = max_span / max_lut,这样可以避免用全局最小值时不同子表值域不一致带来的系统性舍入偏差。函数注释里也提到了这点:

/// Uses FAISS-style per-sub-table quantization: each 16-entry nibble
/// LUT subtracts its own min before u8 rounding, with a single
/// shared `scale = max_span / max_lut`.

这里 max_lut 固定为 127,是个关键数字。为什么是 127 而不是 128 呢?因为搜索时,一个字节里低 nibble、高 nibble 这两张子表会各查一次,两个结果要先在 u8 里相加,而 u8 最大只能装 255。两个表项各自最大都是 max_lut,相加不能溢出,2 * 127 = 254 刚好不超,换成 128 就是 2 * 128 = 256,把 u8 撑爆了。所以表项的上限只能取到 127。

表建好了,接下来就是怎么用一条指令把它查出来。SIMD 的字节洗牌指令恰好能用一条指令完成 16 路甚至 32 路的并行查表,这正是 FAISS FastScan 的核心技巧,turbovec 把它原样搬了过来。

SIMD(Single Instruction Multiple Data,单指令多数据) 是一类 CPU 指令,一条指令同时对一批数据做相同的运算。普通指令一次加一对数,SIMD 指令一次加 8 对、16 对甚至 32 对。

字节洗牌指令(byte shuffle) 也是一条 SIMD 指令,只不过它每个通道做的不是加减乘,而是"按下标取数":给一张 16 字节的小表和一组下标,每个通道各自拿本通道的下标去表里取出对应的那个字节。当这张小表正好是 LUT、下标正好是库向量的码字时,一条指令就让所有通道同时各查各的。

具体到这条字节洗牌指令,调用它的内建函数在 ARM 上叫 vqtbl1q_u8,x86 上叫 _mm256_shuffle_epi8(对应 PSHUFB 这条机器指令)。

内建函数(intrinsics) 是和单条 SIMD 指令一一对应的函数。在代码里写一个内建函数调用,编译器就把它翻译成对应的那条机器指令,不用手写汇编就能发出 SIMD 指令。这套名字不是某门语言专有的,而是 ARM、Intel 随指令集一起发布的标准命名(ARM 的以 v 开头、x86 的以 _mm 开头)。

这两个内建函数做的事情是一样的:拿一个 16 字节的表当查找表,拿另一个寄存器的每个字节当下标,一条指令把所有通道的查表结果同时取出来。这里的通道(lane)就是 SIMD 寄存器里切分出来的一个个数据槽,一个 128 位寄存器装 16 个字节就是 16 个通道,后面说的 16/32 路并行,路数就是通道数。

把库向量的 nibble 码字塞进下标寄存器,一条指令就完成了 16 路 LUT 查表。查出来的每一路,就是对应那条库向量在这个坐标上的一项内积贡献。把同一条库向量在所有字节组上查到的贡献累加起来,就凑成了它和查询的完整内积,也就是搜索要的相似度得分。这个"累加成得分"的步骤,就由下面各平台的内核来完成。

NEON 内核

上一节讲的字节洗牌只是一次查表这个最小动作,真正的搜索要把库里成千上万个向量、每个向量几十上百个字节组都扫一遍,再把查到的贡献累加起来。把这套扫描加累加的流程在某个具体指令集上跑起来的,就是内核函数,也就是搜索路径里的第二步「块级 SIMD 评分」。这一步按平台分成三套内核:ARM 走 NEON,x86 走 AVX2 / AVX-512BW,本节先看 NEON,下一节看 AVX。

NEON 内核的主路径其实会把 4 条查询融合成一批来算(score_4query_block_neon,后面 x86 内核也是同样的思路);为了讲清楚单块的机制,下面看的是单查询版本 score_4bit_block_neon,4 查询版只是在它的基础上同时推进 4 条查询。这个函数一次处理一个 32 向量的块,把上一节那条洗牌指令放进循环里,沿字节组一组组扫过去,边查表边累加,块内的 32 个向量则靠 SIMD 通道一次并行算完,不用单独再开一层循环。

NEON 是 ARM 处理器的 SIMD 指令集,寄存器宽 128 位,能一次对 16 个字节(或 4 个 float32)做相同运算。苹果 M 系列、几乎所有手机 SoC 以及服务器端的 ARM 芯片都带它,aarch64 平台上的向量加速基本都靠它。下面用到的 vqtbl1q_u8vaddw_u8 这些内建函数,都是 NEON 指令。

我把这个函数的非核心部分精简掉,只保留主干:

let mask = vdupq_n_u8(0x0F);
let v_scale = vdupq_n_f32(scale);
let n_batches = (n_byte_groups + FLUSH_EVERY - 1) / FLUSH_EVERY;
let mut fa = [vdupq_n_f32(bias); 8];   // 8 个 float32x4 累加器,初值为 bias

for batch in 0..n_batches {
    let mut accum = [vdupq_n_u16(0); 4];   // uint16x8 累加器
    // 4 组展开的内循环,交错查表以隐藏 vqtbl1q_u8 的延迟
    while g + 3 < g_end {
        for (lp, cp) in [(lp0, cp0), (lp1, cp1), (lp2, cp2), (lp3, cp3)] {
            let lut_hi = vld1q_u8(lp);
            let lut_lo = vld1q_u8(lp.add(16));
            let c0 = vld1q_u8(cp);
            // 低 nibble 查 lut_lo,高 nibble 查 lut_hi,两次查表相加
            let s0 = vaddq_u8(vqtbl1q_u8(lut_lo, vandq_u8(c0, mask)),
                              vqtbl1q_u8(lut_hi, vshrq_n_u8(c0, 4)));
            accum[0] = vaddw_u8(accum[0], vget_low_u8(s0));   // 8→16 bit 加宽累加
            // ...
        }
        g += 4;
    }
    // 每批结束:uint16 → float,融合乘加进 fa
    for i in 0..4 {
        let lo = vcvtq_f32_u32(vmovl_u16(vget_low_u16(accum[i])));
        fa[i * 2] = vfmaq_f32(fa[i * 2], v_scale, lo);
    }
}

代码里 float32x4uint16x8 这类名字是 NEON 的向量类型,命名规则是「元素类型 + 通道数」:uint16x8 就是 8 个并排的 16 位无符号整数,float32x4 是 4 个并排的 float32,正好都占满一个 128 位寄存器。所谓"累加到 uint16x8 上",就是把查表结果累加进这样一个装着 8 个 16 位整数的寄存器,8 个通道各自独立累加。

这段代码里有两处工程细节值得展开。

第一处是FLUSH_EVERY 分批。内循环里每次查表的结果是 u8,NEON 用 vaddw_u8 把这些 u8 不断加宽累加到一个 uint16x8 累加器上。但 u16 最多装 65535,成百上千个字节组一路累下去会溢出,所以累加得分两层来做:内层先在 u16 累加器里快速攒,每攒满 FLUSH_EVERY 个字节组(lib.rs 里定为 256),就把 u16 累加器转成 float、乘以 scale、加进外层的 float 累加器 fa,再清零、接着攒下一批。这样 u16 累加器始终在安全范围内,整个块扫完,所有批的贡献都汇进了 fa

第二处是4 组展开隐藏延迟vqtbl1q_u8 这条查表指令有几个周期的延迟,如果一条接一条地依赖执行,流水线就会空等。内循环一次展开 4 个字节组、交错发射查表指令,让 CPU 在等前一条结果时就能去算后面几条,把延迟藏起来。处理完整的 4 组之后还有一段尾巴循环收拾剩下的 0~3 组。

最后把 8 个 float 累加器乘上逐向量的 vec_scales(就是上一篇讲的长度归一化修正),写出 32 个分数。整个块算完,乘法只在建表时做过一次,扫描阶段全是查表加累加。

AVX 内核

x86_64 平台要复杂一些,因为 x86 上的 SIMD 指令集分了好几代,不同年代的 CPU 支持到哪一代各不相同。所以这边不是一条内核走到底,而是在运行时用 is_x86_feature_detected! 探测 CPU 特性,按从快到慢的顺序挑一条路径,挑不到就一路回退,回退链是 AVX-512BW → AVX2 → 标量:

AVX(Advanced Vector Extensions) 是 Intel、AMD 在 x86 上的 SIMD 指令集,turbovec 用到两代:AVX2 寄存器宽 256 位,一次能处理 32 个字节;AVX-512BW 把寄存器加宽到 512 位,一次 64 个字节。它对应的查表指令是 _mm256_shuffle_epi8(AVX-512BW 则是 _mm512_shuffle_epi8),作用和 NEON 的 vqtbl1q_u8 一样,只是一次能处理的字节更多。

unsafe {
    if is_x86_feature_detected!("avx512bw") && is_x86_feature_detected!("avx512f") {
        search_multi_query_avx512bw(/* ... */);
    } else if is_x86_feature_detected!("avx2") {
        search_multi_query_avx2(/* ... */);
    } else {
        // 既没有 AVX-512BW 也没有 AVX2:逐查询标量评分
        for qo in 0..batch_nq {
            score_query_into_heap(/* ... */);
        }
    }
}

三条路径里,两条 SIMD 是性能主力,标量是兜底。

AVX2 路径:核心函数 search_multi_query_avx2 一次处理 4 条查询,用一个 4×4 的累加器 tile:4 条查询,每条查询 4 个 256 位累加器,4×4=16 个累加器全程待在寄存器里排成一个方阵,这个方阵就是 tile(高性能矩阵乘里「寄存器分块」的常用手法)。这么做是为了让库向量的码字只加载一次、nibble 只拆分一次,然后喂给 4 张不同的 LUT、更新到整个 tile 上,把加载和拆分的成本摊薄到 4 条查询头上。它的查表指令是 _mm256_shuffle_epi8,一次处理 32 个通道。和 ARM 不同的是,x86 这边的累加遇到了一个麻烦:_mm256_shuffle_epi8 的结果是 u8,但 AVX2 没有方便的 8→16 位加宽累加指令,只能在 16 位通道里累加,而相邻的两个 u8 会挤在同一个 u16 里互相干扰。turbovec 用了一个 SUB 技巧绕过去:

// 把高字节移到低位再相减,靠符号扩展恢复原值,避免 u16 溢出污染
lo_a0 = _mm256_sub_epi16(lo_a0, _mm256_slli_epi16(lo_a1, 8));

简单来说,就是把两个挤在一起的字节通过移位和减法重新分开。这套 SUB 技巧同样来自 FAISS FastScan。

AVX-512BW 路径search_multi_query_avx512bw 思路和 AVX2 一致,只是把寄存器换成 512 位:用 _mm512_inserti64x4 把两个块的 32 字节码区分别拼进一个 512 位寄存器的低、高 256 位,_mm512_shuffle_epi8 一条指令同时查两个块,LUT 则用 _mm512_broadcast_i64x4 广播到两个 256 位半区。

标量兜底:最后那个 else 分支处理两个 if 都不命中的情况,也就是一台既无 AVX-512 也无 AVX2 的老 x86 CPU。这时没有 SIMD 可用,就退回到 score_query_into_heap 逐条查询、逐个向量地标量评分。现代 CPU 基本都有 AVX2,这个分支很少走到,但留着它,能保证在任何 x86 机器上 search 都算得出正确结果,只是慢一些。

top-k 堆更新

这是搜索路径的第三步。内核每算完一个 32 向量的块,手里就有 32 个分数。一个直白的做法是把全库 N 个向量的分数都算出来、存进一个长数组,再排序取前 k 个。但那样要为 N 个分数分配内存、再扫一遍排序,纯属浪费。turbovec 的做法是边评分边维护一个 top-k 候选集,块一算完就地更新。

这个候选集代码里叫「堆」,但实现得很轻:一个大小为 k 的定长数组,外加一个变量记着当前数组里最小的那个分数(也就是「第 k 大」的门槛)。更新逻辑就两条:候选集还没装满 k 个时,分数直接塞进去;装满之后,只有当新分数比门槛还大,才替换掉最小的那一项,再重新扫一遍数组找出新的门槛。

这里还藏着一个 SIMD 小优化。候选集装满之后,对每一组分数先做一次阈值剪枝:把 8 个分数和门槛一起比一比,只要一个都没超过,整组直接跳过,连逐个比较都省了。

let v_hmin = _mm256_set1_ps(*hmin);                     // 当前门槛,广播成向量
let cmp = _mm256_cmp_ps(scores_v, v_hmin, _CMP_GT_OQ);  // 8 个分数各自和门槛比大小
if _mm256_movemask_ps(cmp) == 0 { continue; }           // 一个都没超过,整组跳过

库里绝大多数向量本来就进不了 top-k,靠这条剪枝,它们几乎不花什么力气就被刷掉了。所有块扫完,候选集里剩下的就是 top-k,最后排一次序,输出 scoresindices,整条搜索路径就走完了。

块级提前退出

上面走完的是一次普通搜索。如果查询还带上了 allowlist / mask,也就是第 2 篇讲的混合检索,内核里就多出一道优化,叫块级提前退出。它能在只有少量向量被放行时大幅省去无用功,函数是 block_has_allowed

pub(crate) fn block_has_allowed(mask: Option<&[u64]>, base_vec: usize) -> bool {
    match mask {
        None => true,
        Some(m) => {
            let word = m[base_vec >> 6];
            let bit_offset = base_vec & 63;
            // 这一块对应的半个 u64 字里只要有一位是 1,就说明块内有放行的槽位
            ((word >> bit_offset) & 0xFFFF_FFFF) != 0
        }
    }
}

逻辑很直接:每个 32 向量块开始评分前,先看这一块里有没有任何一个被 allowlist 放行的槽位。allowlist 被打包成 u64 位图,一个块对应位图里的半个 u64 字,一次整数加载加一次判断就能决定整块跳不跳。没有放行槽位就直接 continue,这一块的查表、累加、解码全部省掉。

AVX-512BW 内核更进一步,因为它一次处理 64 向量的块对、刚好对齐一整个 u64 字,所以用 block_pair_has_allowed 一次跳过两个块。在 1% 选择率下,这套提前退出在 ARM 上带来 6.4 倍、x86 上带来 12.7 倍加速。不带 mask 的普通搜索完全不受影响,这个逻辑只在传了 mask 时才触发。

小结

今天我们读完了 turbovec 搜索内核这块最硬的代码:

  1. 搜索路径:查询旋转用一次 GEMM 批量完成,TQ+ 逆校准合并成逐查询偏置,逐查询建 LUT,再分发到平台内核做块级评分和 top-k 堆更新。
  2. LUT 查表:把 查询 * 重建值 的有限种结果预先存表,用 vqtbl1q_u8 / _mm256_shuffle_epi8 一条指令完成 16/32 路并行查表,这是 FAISS FastScan 的核心技巧。
  3. 三路内核:NEON 顺序扫描、4 组展开隐藏延迟、uint16 加宽累加不溢出;x86 用 4×4 tile 和 SUB 技巧;运行时按 AVX-512BW → AVX2 → 标量的顺序分派,标量分支兜底。
  4. 块级提前退出:带 mask 的混合检索下,用 u64 位图判断整块是否被放行,没放行就跳过整块的查表与累加,1% 选择率下能快 6–13 倍。

回头看这四篇,从快速入门、混合检索,到量化算法、SIMD 内核,从 Python API 一路读到 NEON 内建函数,由表及里算是走了一遍。

说句实话,最后这两篇钻进源码的解读相当晦涩,到现在我对这块的不少逻辑还是似懂非懂,学习和成文的过程很大程度上是借助 Claude 一点点啃下来的。但即便只是似懂非懂,把代码摊开读一遍,心里也比只会调 API 时踏实了一些。希望这个系列能给你一个大致的轮廓,下次在自己的检索系统里遇到内存或速度的瓶颈时,知道有 turbovec 这样一个选择,也对它内部的大致原理有个印象。

参考


学习 turbovec 的量化算法

前两篇我们把 turbovec 的 API 过了一遍:第 1 篇跑通了 addsearch,第 2 篇学了混合检索和框架集成。它们都接口层面,但有一个核心问题一直没回答:为什么 turbovec 不需要训练就能把向量压到 2-4 bit,还能在召回上追平甚至超过 FAISS 的 PQ?

答案在它背后那套免训练量化算法 TurboQuant。这个算法来自 Google Research 的论文,今年初刚被机器学习顶会 ICLR 接收,turbovec 是它第一个公开的工程实现。今天我们就从传统量化的负担讲起,再读论文的核心思想,最后对照 rotation.rscodebook.rsencode.rs 三个文件,把整条编码管道从头到尾理清楚。

传统量化的训练负担

第 1 篇我们算过那笔内存账:1536 维 float32 一条向量约 6 KB,千万级语料就是 31 GB。量化(Quantization)做的就是用更少的比特去近似表示原本的浮点数,把每个坐标从 32 bit 压到 2-4 bit,体积掉到 1/8 到 1/16,单机就能装下,代价是牺牲一点精度。问题在于,怎么压才能让精度损失尽量小。

向量量化最经典的方案是乘积量化(Product Quantization,简称 PQ)2011 年由 Jégou 等人提出,FAISS 里的 IndexPQIndexPQFastScan 都是它的实现。

PQ 的思路是分段查表。把一条 1536 维向量切成若干个子段,比如 8 段,每段 192 维;对每一段,用 k-means 在训练数据上聚出 256 个聚类中心,存成一张码本(codebook)。编码时,每段只记录它最近的那个中心的下标,一个 8 bit 的整数就够了。原本 192 个 float32 的子段,压成了 1 个字节。整个分段查表的过程如下图所示:

pq-diagram.jpg

这套方案压缩率很高,但有一个绕不开的前提:码本要从数据里学出来。这就带来两个负担:

  1. 训练阶段不可省:用 FAISS 的 PQ,必须先准备一批有代表性的向量,调用 index.train(xs) 跑 k-means 训练码本,之后才能调用 add 添加向量。数据量不足时训练不出高质量的码本。
  2. 数据漂移要重训:码本是对当前数据分布的拟合。如果后续加进来的向量分布发生变化,比如更换了 embedding 模型、业务语料迁移,旧码本就不再贴合,召回率随之下降,需要重新训练并重新编码整个索引库。

对于一个需要支持在线增量写入的向量库来说,这个训练环节是个明显的负担。我们希望第一条向量进来就能直接编码、直接检索,不必经历积累数据、训练、回填这一整套流程。

这就引出了另一条路线:数据无关量化(data-oblivious quantization)。它的目标是设计一个不依赖具体数据分布的量化器,码本可以提前算好、写死在代码里,任何数据进来都用同一套。TurboQuant 走的正是这条路。

TurboQuant 的核心思想

TurboQuant 论文的全名是《TurboQuant: Online Vector Quantization with Near-optimal Distortion Rate》,arXiv 编号 2504.19874,去年 4 月提交,今年初被 ICLR 接收。它要解决的问题是:在不看数据分布的前提下,怎么设计一个接近最优的标量量化器。

它的关键观察分三步。

第一步,随机正交旋转。对每一条归一化后的单位向量,乘上一个随机生成的正交矩阵。正交变换不改变向量长度,也不改变向量之间的内积,所以旋转之后做的所有检索运算,结果和旋转前等价。旋转只是换了一组坐标基。

第二步,旋转后坐标服从已知分布。这是整个算法的支点。一个 d 维单位向量随机旋转之后,它的每个坐标不再是任意的,而是服从一个确定的 Beta 分布。坐标的取值被摊平到了一个集中、对称、与数据无关的形状上。原始数据可能在某些维度上特别集中、某些维度上特别分散,但随机旋转把这种各向异性打散了,每个坐标看起来都像是从同一个分布里采出来的。

第三步,高维下坐标近似独立,可以逐坐标独立量化。维度越高,不同坐标之间的相关性越弱,近似相互独立。既然每个坐标都同分布、又近似独立,那就不需要像 PQ 那样为不同子段学不同的码本了。我们只要针对这一个 Beta 分布,求出一个最优的标量量化器,然后对所有坐标用同一套量化器即可。

这套量化器是离线就能算好的,因为 Beta 分布的形状只由维度 d 决定,和数据没有半点关系。论文给出的理论保证是:这样做的失真率(distortion rate)落在信息论下限,也就是香农下界的约 2.7 倍以内。一个完全不看数据、提前写死的量化器,做到了接近理论最优的精度。

下面我们把随机正交旋转、Beta 分布、求最优量化器用的 Lloyd-Max 算法这三个可能陌生的概念逐一讲清楚,再回到源码。

正交矩阵为什么不改变内积

第一步具体做的,是把每条向量先归一化成长度为 1 的单位向量,再统一乘上同一个随机生成的正交矩阵 Q。归一化的用意要到下一节才说得清,这里先解决一个更要紧的疑问:凭什么乘了 Q 之后,检索结果还和原来一样?

答案是旋转「不改变向量之间的内积」,这是后面所有推理的前提,先把它讲透。

正交矩阵是一个方阵 Q,它的各列都是两两垂直的单位向量。这等价于 Qᵀ·Q = I,也就是它的转置恰好等于它的逆。几何上,乘一个正交矩阵就是对整个空间做一次刚性的旋转(或翻转),只改变朝向,不做任何拉伸或压缩。

为什么旋转后内积不变?两个向量 xy 旋转后变成 QxQy,把它们的内积按定义展开:

<Qx, Qy> = (Qx)ᵀ(Qy) = xᵀ·QᵀQ·y = xᵀ·I·y = xᵀy = <x, y>

中间这一步用到的正是 QᵀQ = I。内积不变,向量长度(||x||² = <x, x>)自然也跟着不变。

几何上更直观,如下图所示:旋转把整个空间当成一个刚体一起转,任意两点之间的夹角和距离都原封不动。

rotation-inner-product.png

而向量检索靠的就是内积或距离来判断谁离查询更近,这些量旋转后完全不变,所以最近邻关系一个都不会错。这正是 turbovec 在编码时先对所有向量做一次旋转的依据:旋到新坐标系里做检索,和在原始坐标系里做,结果完全等价。也就是说,旋转换来了一个数据无关的规整分布,却没有付出任何检索精度的代价。

旋转后为什么是 Beta 分布

要讲清楚旋转的作用,得先弄明白一件事:一个均匀随机落在球面上的单位向量,它的单个坐标长什么样。我们从低维往高维看。

二维时,单位向量是圆上一点,横坐标 x₁ = cos θ。θ 是均匀的,但 x₁ 并不均匀。cos 在 ±1 附近平缓、在 0 附近陡峭,把均匀的角度压成了两端堆积的横坐标。可以想象一颗珠子在圆上匀速转,看它投在横轴上的影子:转到左右两侧时它几乎在竖直运动,影子在 ±1 附近停留很久;转到上下时它几乎在水平运动,影子飞快划过 0。停留得久的地方点就密,所以二维时坐标反而堆在 ±1。

维度一高,情况彻底反过来,坐标转而向 0 集中。最直接的理由是长度预算:单位向量满足 x₁² + … + x_d² = 1,这个总量要分给 d 个坐标。二维时一个坐标可以占掉大部分,比如 (1, 0),但 1536 维下平摊到每个坐标只剩 1/√1536 ≈ 0.025,紧贴着 0。要让某个坐标取到 ±1 附近,它必须占掉几乎全部预算、其余 1535 个一齐趋零,这在随机方向上微乎其微。于是高维坐标被压在 0 附近,呈一个尖钟形,如下图所示:

beta-distribution.jpg

这个尖钟形有个名字,叫 Beta 分布。

Beta 分布是定义在 [0, 1] 区间上的一族连续概率分布,由两个形状参数 αβ 控制。当 α = β 时分布对称,集中在中间的 0.5 附近;两个参数越大,分布越往中间挤、越尖。

数学上,单位球面上一个坐标的密度有个统一的表达式,归一化后正是映射到 [-1, 1] 的 Beta 分布:

p-beta.png

这个表达式把三个维度串成一条线:d = 2 时退化成 1/√(1 - x²),就是上面两端堆积的形状;d = 3 时恰好均匀;d ≥ 4 起变成中间高、两端低的钟形,维度越高越尖。对 1536 维,α = β = 767.5,钟形尖到坐标几乎全落在 0 附近一条窄缝里。

但要注意,上面这套结论有一个前提:向量是均匀随机地落在球面上的。真实 embedding 并不满足这个前提,它们抱团、偏向某些方向,也就是各向异性(anisotropy):某些维度的方差特别大,另一些几乎是常数。所以不旋转时,真实数据每个坐标的分布由数据自身决定,对不齐那个统一的 Beta:方差大的坐标超出码本的覆盖范围,方差小的坐标又用不满码本的精度,一张共享码本顾此失彼。

这正是必须旋转的原因。随机正交旋转把每条向量的坐标重新组合成所有原坐标的均匀混合,原本被少数维度占据的能量被摊回到全部坐标上。各向异性由此被抹平成各向同性(isotropy):所有坐标的方差都收敛到相同的 1/d,分布形状也都收敛到同一个 Beta。这一步并不创造 Beta 形状(那是高维球面本身的几何性质),而是把真实数据搬到能享用这个形状的位置上,从而让一张离线算好的码本精准地覆盖每一个坐标。

Lloyd-Max 最优标量量化器

知道了坐标的分布,下一步是为它找最优的量化方案。这里用的是 Lloyd-Max 算法。

标量量化器可以理解成一个逐坐标的「四舍五入器」:在数轴上预先定好若干个档位(重建中心点)和档位之间的分界线(边界),编码时把每个坐标值归到最近的档位,只存这一档的编号,用的时候再用档位的中心值近似还原。4-bit 就是 16 个档位,编号占 4 bit,原来一个 float32 坐标从 32 bit 压到了 4 bit。所谓「最优」,就是这些档位和分界线不能随便摆,要放在让平均误差(均方误差)最小的位置上。

Lloyd-Max 是一种求解最优标量量化器的经典迭代算法,1957 年由 Stuart Lloyd 提出、1960 年由 Joel Max 独立给出。给定一个连续分布和量化级数(比如 4 bit 就是 16 级),它求出一组区间边界(boundaries)和一组重建中心点(centroids),使得量化的均方误差最小。

它的迭代逻辑是两条最优性条件交替满足:

  1. 给定中心点,最优边界是相邻两个中心点的中点:一个值该归到哪个量化级,取决于它离哪个中心点更近,分界线自然落在中点上。
  2. 给定边界,最优中心点是该区间在分布下的条件期望:一个量化级的重建值,应该取落在这个区间内所有数据的加权平均,权重就是概率密度。

这两步切出的边界和中心点如下图所示:

lloyd-max.jpg

反复迭代这两步直到收敛,就得到了针对该 Beta 分布的最优 boundaries 和 centroids。注意整个过程只用到分布本身,不碰任何真实数据,所以离线算好就行。这正是 turbovec 不需要训练阶段的根本原因。

源码剖析

理论讲完,我们回到代码,看 turbovec 是怎么一步步把这些思想落地的。一条向量从输入到存储,要经过归一化、旋转、校准、量化、打包这几道工序,实现分散在 rotation.rscodebook.rsencode.rs 三个文件里。整条管道是一条直线:

输入向量(float32)
  → 输入验证(检测 NaN / Inf)
  → 归一化(提取范数,得到单位向量)
  → 正交旋转(乘随机旋转矩阵)
  → TQ+ 校准(逐坐标平移缩放)
  → Lloyd-Max 量化(边界扫描得码字)
  → 比特平面打包
  → 长度归一化修正(为每条向量算一个 scale)
  → 存储(packed_codes 与 scales)

旋转矩阵的确定性生成

第一道核心工序是旋转,实现在 rotation.rs,整个文件只有 50 行出头。核心函数 make_rotation_matrix 做了三件事:

pub fn make_rotation_matrix(dim: usize) -> Vec<f32> {
    let mut rng = ChaCha8Rng::seed_from_u64(ROTATION_SEED);

    // 1. 生成 dim x dim 的高斯随机矩阵
    let mut g = faer::Mat::<f64>::zeros(dim, dim);
    for j in 0..dim {
        for i in 0..dim {
            g.write(i, j, rng.sample(StandardNormal));
        }
    }

    // 2. QR 分解,Q 就是一个正交矩阵
    let qr = g.qr();
    let q_full = qr.compute_thin_q();
    let r = qr.compute_thin_r();

    // 3. 符号修正:Q = Q * diag(sign(diag(R)))
    let mut q = q_full;
    for j in 0..dim {
        let sign = if r.read(j, j) >= 0.0 { 1.0 } else { -1.0 };
        // ... 把对应列乘上 sign
    }
    // ... 转成行主序 f32 返回
}

这段代码我做了简化,省略了符号修正的内层循环和最后转 f32 的部分。它的逻辑分三步:

  1. 生成高斯随机矩阵:用 ChaCha8 这个伪随机数生成器,配合标准正态分布,填出一个 dim × dim 的随机矩阵。
  2. QR 分解取正交矩阵:对随机矩阵做 QR 分解,得到的 Q 是一个正交矩阵。这里用的是 faer 这个纯 Rust 的线性代数库做非主元 QR 分解。一个高斯随机矩阵的 QR 分解,Q 的分布是均匀的,正好满足我们要的随机正交旋转。
  3. 符号修正:QR 分解的结果不唯一,不同实现可能给出符号相反的列。这里强制把 Q 的每一列乘上 R 对角元的符号,让结果唯一、可复现。

QR 分解是线性代数里的一个经典操作:把任意一个矩阵 A 拆成两个矩阵的乘积 A = Q·R,其中 Q 是正交矩阵(各列两两垂直、长度为 1),R 是上三角矩阵。我们这里只取 Q,看中的就是它的正交性。对一个元素独立来自标准正态分布的随机矩阵做 QR 分解,得到的 Q 在所有正交矩阵里是均匀分布的,正好是我们想要的随机正交旋转。

这里最值得说的是开头那个 ChaCha8Rng::seed_from_u64(ROTATION_SEED)ROTATION_SEED 定义在 lib.rs 里,是一个固定常量:

const ROTATION_SEED: u64 = 42;

种子写死成 42,意味着同样维度下生成的旋转矩阵每次都完全一样。这是一个有意为之的工程设计,好处有两点:

  • 索引文件里不用存旋转矩阵。一个 1536 维的旋转矩阵是 1536 × 1536 个 float32,约 9 MB,要是每个索引文件都带一份就太浪费了。既然种子固定、生成过程确定,加载索引时按维度现场重算即可,磁盘上一个字节都不用存。
  • 写入端和搜索端天然一致add 时用什么旋转,search 时查询走的就是同一个旋转,不会因为读写分属两次进程而错位。

回到 lib.rsTurboQuantIndex 结构体,旋转矩阵正是用 OnceLock 缓存的:

pub struct TurboQuantIndex {
    // ...
    rotation: OnceLock<Vec<f32>>,
    boundaries: OnceLock<Vec<f32>>,
    centroids: OnceLock<Vec<f32>>,
    blocked: OnceLock<BlockedCache>,
}

rotationboundariescentroids 这几个缓存都只依赖 (dim, bit_width) 和那个固定种子,不依赖任何向量内容。它们在第一次 add 时按需算出,之后无锁读取;如果是加载后直接检索、没有经过 add 的索引,则在第一次 search 时补算。结构体里还有第四个缓存 blocked,是 SIMD 内核用的分块布局,只在第一次 search 时才构建。这部分缓存的并发设计,我们留到第 4 篇细讲。

码本的 Lloyd-Max 迭代

旋转之后,每个坐标独立同分布于 Beta((d-1)/2, (d-1)/2)codebook.rs 就在这个分布上跑 Lloyd-Max,求出量化用的 boundaries 和 centroids。对外的入口很简洁:

pub fn codebook(bits: usize, dim: usize) -> (Vec<f32>, Vec<f32>) {
    lloyd_max(bits, dim, 200, 1e-12)
}

最多迭代 200 次,收敛阈值 1e-12lloyd_max 内部的主循环就是前面讲的两条最优性条件交替:

fn lloyd_max(bits: usize, dim: usize, max_iter: usize, tol: f64) -> (Vec<f32>, Vec<f32>) {
    let a = (dim as f64 - 1.0) / 2.0;
    let beta = Beta::new(a, a).unwrap();
    let n_levels = 1usize << bits;

    // 初始化中心点:在 ±3 个标准差内均匀铺开
    // ...

    for _ in 0..max_iter {
        // 条件一:边界 = 相邻中心点的中点
        let boundaries: Vec<f64> = (0..n_levels - 1)
            .map(|i| (centroids[i] + centroids[i + 1]) / 2.0)
            .collect();
        // ...
        for i in 0..n_levels {
            // 条件二:新中心点 = 区间 [lo, hi] 上的条件期望
            // mean = ∫ x·pdf(x) dx / prob,用 adaptive Simpson 数值积分
            let mean = adaptive_simpson(
                |x| {
                    let t = (x + 1.0) / 2.0;
                    x * beta.pdf(t) / 2.0
                },
                lo, hi, 1e-14, 50,
            );
            new_centroids[i] = mean / prob;
        }
        // 收敛判断:最大变化量小于 tol 就停
        // ...
    }
    // ...
}

这里有几个点比较有意思,我们逐一看下:

  1. 初始中心点在 ±3σ 内均匀铺开。这里的 σ 是这个 Beta 分布在 [-1, 1] 上的标准差,迭代从一组均匀铺开的初始猜测出发,逐步收敛到最优位置。
  2. 边界取中点(centroids[i] + centroids[i + 1]) / 2.0,对应最优性条件一。
  3. 中心点取条件期望:在每个区间 [lo, hi] 上算 ∫ x·pdf(x) dx / prob,对应条件二。其中 pdf(x)概率密度函数(probability density function),也就是前面那条 Beta 钟形曲线在 x 处的高度;∫ x·pdf(x) dx 把区间内每个 x 按密度加权累加,再除以区间总概率 prob,得到的就是落在这个区间里的值的平均,即条件期望。这个积分没有闭式解,代码用 adaptive_simpson 做自适应辛普森数值积分。

辛普森数值积分这块也单开一小段说一下。

辛普森法则(Simpson's rule)用抛物线去逼近曲线下的面积,比矩形法、梯形法精度高得多。自适应(adaptive)的意思是:先对整个区间估一次,再二分细算一次,如果两次结果差得超过容差,就继续递归二分,直到精度达标。这样曲线平缓处少算几次、陡峭处多算几次,既准又省。

codebook.rs 里的 adaptive_simpson_rec 就是这个递归过程,靠 (refined - whole).abs() < 15.0 * tol 判断是否需要继续细分。整个 Lloyd-Max 跑完,得到的 boundaries 和 centroids 就是这个维度、这个比特宽度下的最优标量量化器。它只和 (dim, bits) 有关,因此和旋转矩阵一样,只在内存里用 OnceLock 缓存、按需重算,无需写入索引文件。

完整编码管道

旋转矩阵和码本都备好了,encode.rs 里的 encode 函数把它们串起来,完成一条向量从浮点到比特的完整旅程。从函数签名就能看清它的输入和输出:

pub fn encode(
    vectors: &[f32], n: usize, dim: usize,
    rotation: &[f32],
    boundaries: &[f32], centroids: &[f32],
    bit_width: usize,
    existing_calibration: Option<(&[f32], &[f32])>,
) -> (Vec<u8>, Vec<f32>, Vec<f32>, Vec<f32>) {
    // 1. 归一化:逐行提取范数,得到单位向量
    norms.par_iter_mut()
        .zip(unit_flat.par_chunks_mut(dim))
        .enumerate()
        .for_each(|(i, (norm, unit_row))| {
            let row = &vectors[i * dim..(i + 1) * dim];
            let n_val = simd_norm(row);
            *norm = n_val;
            let inv = if n_val > 1e-10 { 1.0 / n_val } else { 0.0 };
            simd_scale(row, inv, unit_row);
        });

    // 2. 旋转:单位向量矩阵乘旋转矩阵的转置
    let rotated_mat = unit_mat.dot(&rot_mat.t());

    // 3. TQ+ 校准:拟合或复用逐坐标的 (shift, scale)
    let (shift, scale_tq) = match existing_calibration {
        Some((s, sc)) => (s.to_vec(), sc.to_vec()),
        None => compute_tqplus_calibration(rotated, n, dim),
    };

    // 4. 量化 + 打包 + 长度修正(融合在一个逐行处理的函数里)
    // ...
}

这段也做了简化,省去了校准值物化和并行打包的细节。整条管道是:

  1. 归一化:每行除以自己的范数,得到单位向量;范数 ||v|| 单独存下来,后面做长度修正要用。这里用 Rayon 把各行拆到多核上并行处理,因为行与行之间完全独立。
  2. 旋转:单位向量矩阵和旋转矩阵的转置做矩阵乘法,把每条向量旋到新坐标系。
  3. TQ+ 校准:逐坐标做一次平移和缩放,下一节细说。
  4. 量化 + 打包 + 长度修正:用 boundaries 做边界扫描得到每个坐标的码字,按比特平面打包成字节,同时算出长度修正的 scale。

第 4 步里的边界扫描量化方式有点意思。代码不是去比较坐标离哪个 centroid 最近,而是数它越过了几条边界fused_quantize_scale_pack 里的核心循环就是这么干的:

for j in 0..dim {
    // 数第 j 个坐标越过了几条边界,越过几条码字就是几
    let mut code = 0u8;
    for &b in boundaries {
        if rot_calib[j] > b { code += 1; }
    }
    // code 即这个坐标的量化码字,随后按比特平面打包进 packed_row
    // ...
}

因为 boundaries 是单调递增的,越过的边界数恰好等于该坐标落在第几个量化级,和找最近中心点等价,但用 SIMD 实现起来更顺手。

TQ+ 动态校准

到这里,纯理论版的 TurboQuant 已经能跑了。但 turbovec 在此基础上做了一个改进:TQ+ 校准。

它要解决的问题是理论和现实的偏差。论文说旋转后每个坐标服从规范的 Beta 分布,这是在数据各向同性的理想假设下推出来的。真实的 embedding 数据往往是各向异性的,旋转之后每个坐标的经验分布,和那个理论 Beta 分布之间还有残差。码本是按理论 Beta 拟合的,坐标分布一旦偏掉,码本就会失配,精度打折扣。

TQ+ 的修正办法很轻量:给每个坐标配两个自由参数,一个平移 shift、一个缩放 scale,把这个坐标的经验分布拉回到理论 Beta 上。具体做法是对齐 5% 和 95% 分位数:

fn compute_tqplus_calibration(rotated: &[f32], n: usize, dim: usize)
    -> (Vec<f32>, Vec<f32>)
{
    // 批量过小 → 返回恒等校准 (shift=0, scale=1)
    if n < TQPLUS_MIN_SAMPLES { return (shift, scale); }

    // 目标分布 Beta((d-1)/2, (d-1)/2) 的 5/95% 分位点
    let qc_lo = (2.0 * beta.inverse_cdf(0.05) - 1.0) as f32;
    let qc_hi = (2.0 * beta.inverse_cdf(0.95) - 1.0) as f32;
    let qc_span = qc_hi - qc_lo;

    // 逐坐标拟合 (shift, scale),Rayon 并行
    shift.par_iter_mut().zip(scale.par_iter_mut()).enumerate().for_each(
        |(d, (sh, sc))| {
            let mut coord: Vec<f32> = (0..n).map(|i| rotated[i * dim + d]).collect();
            coord.sort_unstable_by(/* ... */);
            let qe_lo = coord[lo_idx];   // 该坐标经验 5% 分位
            let qe_hi = coord[hi_idx];   // 该坐标经验 95% 分位
            let qe_span = qe_hi - qe_lo;
            if qe_span > 1e-6 {
                *sc = qc_span / qe_span;
                *sh = qc_lo / *sc - qe_lo;
            }
        },
    );
    (shift, scale)
}

逻辑分三步:先算出目标 Beta 分布的 5% 和 95% 分位点 qc_loqc_hi,这是理论目标;再对每个坐标,把它在这批数据里的所有取值排序,取出经验的 5% 和 95% 分位 qe_loqe_hi;最后解一个线性映射,让经验分位对齐到理论分位,得到这个坐标的 scaleshift。每个坐标互相独立,用 Rayon 并行铺开。

这里有两个工程细节要注意:

  • 样本太少就不校准TQPLUS_MIN_SAMPLES 是 1000,批量小于这个数时直接返回恒等校准(shift=0、scale=1)。encode.rs 的注释解释了原因:样本不足时分位数估计本身噪声太大,校准带来的精度反而被噪声吃掉,所以索引照常能用,只是这一批拿不到 TQ+ 的召回增益。
  • 只学一次,之后冻结。校准参数在第一次 add 时拟合出来,存进 TurboQuantIndextqplus_shifttqplus_scale 字段,后续 add 全部复用同一套,靠的就是 encode 那个 existing_calibration 参数。这样索引里所有向量都活在同一个校准坐标系里,增量写入也不会前后不一致。搜索时,校准的逆操作被施加到查询侧,整个 SIMD 内核一行都不用改。

效果上,TQ+ 在不同数据集上给 Recall@1 带来 0.3 到 1.8 个百分点的提升。比如 OpenAI 1536 维 2-bit 上 +1.5pp,3072 维 2-bit 上 +1.8pp。

长度归一化修正

最后还有一个修正,借鉴了 RaBitQ 论文的思路。

RaBitQ 是 2024 年提出的高维向量量化方法,论文《RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound for Approximate Nearest Neighbor Search》,arXiv 编号 2405.12497。它的贡献是给量化误差给出了可证明的理论上界,并用一个逐向量的标量修正(每条向量配一个)去消除内积估计的系统性偏差。Elastic 在他们的 search-labs 博客里写过一篇通俗易懂的科普。

要修正的问题是:标量量化会系统性地低估内积。我们归一化时把向量变成了单位向量,但量化重建出来的那个向量,长度通常比 1 短一点,因为重建中心点是区间的条件期望,会向分布中心收缩。重建向量偏短,算出来的内积就整体偏小。

turbovec 的修正是给每条向量存一个标量 scale,定义为:

scale = ||v|| / <u_rot, x_hat>

其中 ||v|| 是原始向量的范数,u_rot 是旋转后的单位向量,x_hat 是 Lloyd-Max 重建出来的向量。这个 scale 同时干了两件事:用 ||v|| 把单位向量还原回原始长度,再用 1 / <u_rot, x_hat> 抵消掉重建变短带来的低估。encode.rs 文件头的注释把这层意思说得很清楚:

Applying this scale at the final score-multiplication site in the SIMD kernel gives an unbiased estimator of <v, q>.

这里的 <v, q> 是库向量 v 和查询 q 的内积,也就是检索真正要算的那个相似度分数。所谓 unbiased estimator(无偏估计),是说内核在压缩码上算出来的近似分数乘上这个 scale 之后,平均而言正好落在真实的 <v, q> 上,把前面那个系统性低估抵消掉了。也就是说,这个修正放在 SIMD 内核打分的最后一步,乘一下 scale 就行,搜索延迟零增加。

回到源码,这个 scale 是在 fused_quantize_scale_pack 里算出来的:

fn fused_quantize_scale_pack(/* ... */ norm: f32, /* ... */) -> f32 {
    // 参数 `norm` 就是 `||v||`
    let mut inner = 0.0f64;
    for j in 0..dim {
        // 边界扫描得到 code(见前文),再把中心点还原回原始空间
        let centroid_in_orig = centroids[code] * inv_scale_tq[j] - shift[j];
        // 逐坐标累加,凑出原始空间的内积 <u_rot, x_hat>
        inner += rot_orig[j] * centroid_in_orig;
        // ... 比特平面打包 ...
    }
    norm / inner   // 返回 ||v|| / <u_rot, x_hat>,正是上面那个 scale
}

效果很可观:GloVe 200 维 2-bit 上,Recall@1 提升了 4.7 个百分点,而搜索延迟没有任何变化。一个只在编码期多存一个 float、搜索期多乘一次的小修正,换来近 5 个点的召回,性价比很高。

小结

今天我们把 turbovec 量化算法的来龙去脉走了一遍:

  1. 量化的动机:1536 维 float32 一条 6 KB,千万级语料就是 31 GB,压到 2-4 bit 能把内存砍到 1/8 到 1/16,单机就能装下。
  2. 数据无关量化:传统 PQ 要用 k-means 学码本,数据漂移还得重训。TurboQuant 走数据无关路线,码本提前算好、写死在代码里。
  3. TurboQuant 三步:随机正交旋转让每个坐标服从 Beta 分布,高维下坐标近似独立,于是对所有坐标用同一个 Lloyd-Max 最优标量量化器,失真在香农下界的约 2.7 倍以内。
  4. 三个核心源码文件rotation.rs 用 ChaCha8 加固定种子 42 加 faer QR 分解确定性地生成旋转矩阵,索引文件因此不用存它;codebook.rs 在 Beta 分布上跑 Lloyd-Max,边界取中点、中心点取条件期望、用自适应辛普森积分;encode.rs 把归一化、旋转、校准、量化、打包串成完整管道。
  5. 两个工程修正:TQ+ 校准为每个坐标拟合一组 5/95% 分位映射,把经验分布拉回理论 Beta,只在第一批数据上学一次后冻结,Recall@1 提升 0.3 到 1.8pp;长度归一化修正为每条向量存一个 ||v|| / <u_rot, x_hat>,抵消标量量化对内积的系统性低估,GloVe 2-bit 上 +4.7pp,搜索零开销。

算法解决了精度的问题,那速度又是从哪来的?turbovec 敢说比 FAISS 快,靠的是手写的三路 SIMD 内核。明天我们就来读 search.rs,看它怎么用 NEON、AVX2、AVX-512BW 在 32 向量块上做查表打分、提前退出,在压缩后的比特码上实现高效检索。

参考


学习 turbovec 的混合检索与框架集成

昨天我们快速入门了 turbovec,体验了 TurboQuantIndexadd / search / write / load 等基本接口,也简单认识了带稳定 ID 的 IdMapIndex,当时提过一句,它是后面做混合检索过滤的基础。不过当时只是照着接口跑了一遍,没有解释为什么 TurboQuantIndex 的下标不能当业务主键用,也没讲这层 ID 映射是怎么实现的,更没涉及按租户、按权限过滤这样的真实检索场景。

今天我们就把这些坑填上:先从 TurboQuantIndex 删除时的下标移动说起,弄清楚 IdMapIndex 的实现,再体验过滤搜索和两阶段混合检索,最后是四大框架的开箱集成。

TurboQuantIndex 的下标移动问题

先看 TurboQuantIndex 的删除方法 swap_remove。它的命名直接对标 Rust 标准库的 Vec::swap_remove:把最后一个向量挪进被删的槽位,再把长度减一。这样删除是 O(1) 的,代价是顺序不保证。

之所以这么设计,是因为所有向量的压缩编码紧挨着存在一个连续数组里,槽位号就是存储位置。从中间删一条,要么把后面的数据整体前移,复杂度 O(n),删得越靠前越慢;要么留下空洞,破坏检索时批量打分依赖的致密布局。而把最后一条搬过来补位,只需要拷贝一条向量的数据,数组始终致密。

举个例子,假设索引里有 6 个向量,槽位编号 0 到 5。当我们 swap_remove(2) 之后,原来在槽位 5 的向量会被搬到槽位 2:

idx = TurboQuantIndex(dim=1536, bit_width=4)
idx.add(vectors)                # 灌入 6 个向量,槽位 0 到 5

moved_from = idx.swap_remove(2) # 槽位 5 的向量补到槽位 2
# moved_from == 5

问题来了。如果你的业务库里用槽位号当外键,比如数据库某行记了 vector_slot = 5,删除后这一行指向的就不再是原来的向量了。search 返回的也是槽位下标,下一次查询拿到的 indices 跟你之前缓存的对应关系全乱套。

简单来说,TurboQuantIndex 的下标是位置性的,适合纯追加、不删除的场景,不适合拿来当业务主键。要在删除频繁的系统里稳定地标识一个向量,得换一个工具。

用 IdMapIndex 解决下标移动

IdMapIndex 就是为这个场景设计的。它在 TurboQuantIndex 外面包了一层双向映射:一边是你自己定的业务 ID(64 位无符号整数),一边是向量实际所在的槽位,两边可以互查。调用方始终用业务 ID 来标识向量,其他向量的增删不会改变这个 ID。官方文档把它类比成 FAISS 的 IndexIDMap2,哈希表支撑、按 ID O(1) 删除,熟悉 FAISS 的话可以直接对应过来。

它的设计思路在 id_map.rs 的模块注释里说得很清楚:

//! `IdMapIndex` wraps the positional index
//! with a bidirectional `id ↔ slot` mapping so callers can identify
//! vectors by a stable `u64` ID that doesn't change when other vectors
//! are inserted or removed.

结构体本身只持有两张表,向量存储、旋转、打分、序列化全部委托给内层的 TurboQuantIndex

pub struct IdMapIndex {
    inner: TurboQuantIndex,
    /// slot → external id
    slot_to_id: Vec<u64>,
    /// external id → slot
    id_to_slot: HashMap<u64, usize>,
}

Python 侧的基本用法上一篇已经见过,这里快速过一眼:

import numpy as np
from turbovec import IdMapIndex

index = IdMapIndex(dim=1536, bit_width=4)
index.add_with_ids(vectors, np.array([1001, 1002, 1003], dtype=np.uint64))

scores, ids = index.search(query, k=10)   # 返回的是你的 uint64 外部 ID
index.remove(1002)                         # 按 ID 删除,O(1)

assert 1002 not in index                   # 被删的 ID 已经不在
assert 1003 in index                       # 其余 ID 照旧,__contains__ 语法糖
scores, ids = index.search(query, k=10)    # 仍可正常检索,结果里不会再出现 1002

删完之后查一下,被删的 ID 已经不在,其余 ID 照旧可查。

这里有几个点值得展开。

add_with_ids 接收一批向量和等长的 uint64 ID 数组。turbovec 会在真正写入前先做一遍校验,既拒绝索引里已存在的 ID,也拒绝这一批内部重复的 ID,避免写了一半才发现 ID 冲突。

remove(id) 是 O(1) 的,这是 IdMapIndex 相比裸 TurboQuantIndex 真正解决问题的地方。它内部仍然走 swap_remove,但会同步修正映射表:被删 ID 从两张表里抹掉,原来最后一个向量搬到空出的槽位后,它对应的 ID 也跟着更新指向新槽位。底层那次 swap_remove 照样会挪动槽位,但这次挪动被映射表吸收了,对外暴露的 ID 始终不变。所有框架集成内部用的都是 IdMapIndex,原因正是它。

使用 mask 与 allowlist 实现过滤搜索

光有稳定 ID 还不够。真实查询往往要先把候选集缩小到一个子集,再在子集里找最近邻。比如多租户系统只能返回当前租户的文档,访问控制场景只能返回有权限看到的内容,时间窗口检索只看最近 7 天的数据。

turbovec 的两个索引类型 TurboQuantIndexIdMapIndex 各提供了一种过滤入口。

TurboQuantIndex.search 接收一个 mask 参数,它是一个长度等于 len(idx) 的布尔数组,只有 mask[i] == True 的槽位才参与打分:

mask = np.ones(len(idx), dtype=bool)
mask[disabled_slots] = False
scores, slots = idx.search(query, k=10, mask=mask)

IdMapIndex.search 则接收一个 allowlist,它是一组外部 uint64 ID,结果被限制在这些 ID 内:

allowed = np.array([1003, 1010, 1042], dtype=np.uint64)
scores, ids = idx.search(queries, k=10, allowlist=allowed)

allowlist 在内部会先把每个 ID 翻译成对应的槽位、拼成一个布尔 mask,也就是说,两种方式背后是同一个机制,区别只是 mask 按槽位寻址,allowlist 按外部 ID 寻址。

这里有个容易被忽略的细节。turbovec 的过滤是前过滤而不是后过滤。后过滤是先搜出 top-k 再丢掉不合规的,结果常常不足 k 个。turbovec 在打分阶段就将禁止的向量过滤掉,所以你拿到的是过滤集合里完整的 top-k。

两阶段混合检索实战

把稳定 ID 和过滤搜索拼起来,就是一套实用的两阶段混合检索。第一阶段用外部系统(SQL、BM25、标签库等)筛出候选 ID 集合,第二阶段在候选集内做稠密向量重排。

整个流程如下图所示:

two-steps.png

我们用一个多租户的例子来体验。每个租户只能检索属于自己的文档,租户隔离由 SQL 元数据保证,语义相关性由向量打分保证:

import numpy as np
from turbovec import IdMapIndex

idx = IdMapIndex(dim=1536, bit_width=4)
idx.add_with_ids(vectors, ids)

# 阶段一:外部系统把候选缩小到当前租户的文档 id
allowed = np.array(
    db.execute("SELECT id FROM docs WHERE tenant=?", (t,)).fetchall(),
    dtype=np.uint64,
)

# 阶段二:在候选集内做稠密重排
scores, ids = idx.search(query, k=10, allowlist=allowed)

可以看到,租户边界完全由那条 SQL 决定,向量索引不需要知道租户的概念。SQL 擅长结构化条件过滤,向量索引擅长语义相似度排序,两者各做各擅长的事。前过滤的设计保证了一点:哪怕某个租户只有几十篇文档,只要这几十篇里有相关的,你照样能拿满 k=10 个结果,不会因为全局 top-k 里挤满了别的租户的文档而被挤空。

开源框架集成

自己调 IdMapIndex 固然灵活,但如果你的项目已经跑在某个 RAG 框架上,更省事的是直接换掉它的内置向量库。turbovec 为四大框架提供了 drop-in 集成,每个集成内部都用 IdMapIndex 存储,对外保持原框架的接口不变。

drop-in 全称 drop-in replacement(直接替换),意思是新组件和原组件的接口、行为完全兼容,接入时只需要换掉 import,其余代码一行不用改。turbovec 的四个集成正是这样:换个向量库的导入,你的检索管道照旧工作,换来的是内存占用降一个量级。

它们各自替代的类如下:

drop-in.png

下面以 LangChain 为例看一段最简示例。安装时在方括号里带上框架名,pip 会把对应的依赖一并装好:

$ pip install turbovec[langchain]

turbovec.langchain.TurboQuantVectorStore 实现了 LangChain VectorStore 接口,维度从嵌入模型推断,不用提前指定:

from langchain_huggingface import HuggingFaceEmbeddings
from turbovec.langchain import TurboQuantVectorStore

# 一批待检索的文档,实际项目里通常来自知识库或文档切片
texts = [
    "TurboQuant is a data-oblivious vector quantizer that needs no training phase.",
    "Product Quantization must train a codebook on representative data before adding vectors.",
    "FAISS is Meta's open-source library for efficient vector similarity search.",
    "HNSW is a graph-based algorithm for approximate nearest neighbor search.",
    "An inverted index is the core data structure of traditional full-text search.",
    "BM25 is a classic keyword-based ranking function used for first-stage retrieval.",
    "Hybrid retrieval combines keyword filtering with dense vector reranking.",
    "RAG augments large language models with knowledge retrieved from external sources.",
    "A vector database stores and searches high-dimensional embedding vectors.",
    "Cosine similarity measures how close the directions of two vectors are.",
]

embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-base-en-v1.5")
store = TurboQuantVectorStore.from_texts(
    texts=texts,
    embedding=embeddings,
    bit_width=4,
)
retriever = store.as_retriever(search_kwargs={"k": 3})

# 检索与查询语义最相关的前 3 条文档
docs = retriever.invoke("a quantization method that compresses vectors without training")
for i, doc in enumerate(docs, 1):
    print(f"{i}. {doc.page_content}")

运行后会按语义相关性返回前 3 条,第一条正是讲 TurboQuant 免训练量化的那句:

1. TurboQuant is a data-oblivious vector quantizer that needs no training phase.
2. A vector database stores and searches high-dimensional embedding vectors.
3. Product Quantization must train a codebook on representative data before adding vectors.

可以看到,from_texts 建库、as_retriever 拿检索器,这套写法和 LangChain 原生的 InMemoryVectorStore 一模一样,把导入换成 turbovec 即可,拿到检索器之后怎么用都不变。底层换成了 4-bit 量化存储,内存占用直接降下来,这正是 drop-in 的意义。

另外三个集成的用法大同小异,都是按同样的方式装好对应的包(turbovec[llama-index]turbovec[haystack]turbovec[agno]),再把框架内置的向量库换成 turbovec 提供的类,其余管道代码不动。各家在过滤算子、去重策略等方面有一些贴合自身生态的细节差异,具体接口以各自的官方文档为准,这里不再逐一展开。

小结

今天我们体验了 turbovec 面向真实业务的几项功能:

  1. 稳定 IDTurboQuantIndex 的槽位会因 swap_remove 移动,不适合当业务主键;IdMapIndex 用一层业务 ID 和槽位的双向映射提供稳定外部 ID
  2. 过滤搜索TurboQuantIndex 用布尔 maskIdMapIndexallowlist,两者翻译成同一套前过滤内核,能在过滤集合里凑满 top-k,而不像后过滤那样常常返回不足 k 个
  3. 两阶段混合检索:第一阶段 SQL 等外部系统筛候选 ID,第二阶段稠密重排,结构化过滤与语义排序各司其职,多租户隔离天然落在 SQL 一侧
  4. 框架集成:LangChain、LlamaIndex、Haystack、Agno 四大框架 drop-in,内部统一用 IdMapIndex,换个 import 就能接入

功能层面我们基本都体验完了,从压缩、检索到过滤、集成。不过到现在为止,免训练量化对我们还是个黑盒:为什么不需要 train 就能把向量压到 2-4 bit,还能在召回上追平甚至超过 FAISS 的 PQ?明天我们就深入 TurboQuant 算法本身,看看随机旋转、Beta 分布和 Lloyd-Max 码本背后的数学原理。

参考


turbovec 快速入门

做 RAG 的朋友多半都算过这样一笔账:一条 768 维的 embedding 向量,用 float32 存就是 768 × 4 = 3072 字节,正好 3 KB;语料攒到 1000 万条,光向量就要占 31 GB 内存。768 维是开源模型很常见的维度,BGE 的 bge-base-zh-v1.5、Sentence Transformers 的 all-mpnet-base-v2 都是这个规格。换更高维的模型,这笔账还要成倍往上涨:BGE-M3 是 1024 维(41 GB),OpenAI 的 text-embedding-3-small 是 1536 维(61 GB),text-embedding-3-large 到了 3072 维,直接奔 123 GB 去了。对于做 RAG 的团队来说,光是把向量塞进内存就是一笔不小的开销,更别提还要留余量给检索本身。

常见的解法是向量量化(vector quantization),用更少的比特近似表示每个向量,FAISS 的 Product Quantization(PQ)就是这条路线的代表。不过 PQ 是有代价的:建索引前要先用一批代表性数据训练码本,语料分布变化大了还可能要重训。今天我们要介绍的主角 —— turbovec,走了一条不一样的路。它是一个用 Rust 实现、带 Python 绑定的向量索引库,核心算法来自 Google Research 的 TurboQuant 论文(arXiv:2504.19874,已被 ICLR 2026 接收),不需要训练,也不需要重建,向量加进去就能搜。

turbovec-intro.png

它的项目首页放了一句颇有冲击力的话:

A 10 million document corpus takes 31 GB of RAM as float32. turbovec fits it in 4 GB - and searches it faster than FAISS.

这里的 31 GB 正是我们开头算的那笔账:1000 万条 768 维向量,用 float32 存要占 31 GB 内存,turbovec 能把它压到 4 GB,而且检索速度比 FAISS 还快。4 GB 是这么来的:TurboQuant 算法把每个坐标从 float32 的 32 bit 量化到 2~4 bit,压缩 8~16 倍,31 GB 的数据 4-bit 量化后约 3.9 GB;如果用 2-bit 还能再减半,只剩 2 GB 不到。正是由于它内存占用小、速度又快,最近几个月接连被几家海外科技媒体报道。

turbovec-memory-compression.jpg

它的几个核心特性是:

  • 在线量化(online quantization):添加向量即建索引,没有单独的训练阶段,也不需要随语料增长重建索引
  • 比 FAISS 更快:手写 NEON(ARM)、AVX2 / AVX-512BW(x86)三路 SIMD 内核,运行时自动选择,ARM 上所有配置比 FAISS 的 IndexPQFastScan 快 10%~19%
  • 搜索时过滤search() 支持传入 id allowlist 或 bitmask,量化内核直接在块级别处理过滤,做混合检索时不用先取后筛
  • 纯本地运行:不依赖托管服务,数据不出本机或 VPC,配合任意开源 embedding 模型就能搭一套完全离线的 RAG 栈

与 FAISS 的对比定位

FAISS 是向量检索领域的老牌选手,由 Meta 开源,功能非常全。turbovec 没有想做一个大而全的库去取代 FAISS,它的对标对象很明确,就是 FAISS 里走量化快速扫描路线的 IndexPQFastScan

两者最核心的差别在于有没有训练阶段

FAISS 的 PQ 是数据相关(data-dependent)的量化。它需要先用一批代表性数据跑 train(),学出一套码本(codebook),之后才能 add() 向量、search() 查询。码本学得好不好,直接影响召回质量;语料分布变化大了,码本还可能要重学。

turbovec 用的 TurboQuant 是数据无关(data-oblivious)的量化。它的思路是:先对输入向量做一次随机正交旋转,旋转之后每个坐标会服从一个集中的 Beta 分布;在高维空间里不同坐标近似独立,于是可以对每个坐标独立套用一个最优标量量化器。整个过程不依赖具体数据,所以不需要训练、不需要学码本,向量加进去就直接建好了索引。

它们两在使用上的主要差别如下表所示:

faiss-vs-turbovec.png

数据无关并不意味着 turbovec 完全不看数据。它在第一次 add() 时会做一次 TQ+ 校准,从首批向量里学一组 per-coord 的微调参数,但只学这一次,之后不再变。这部分细节我们留到后面讲量化算法的文章里展开。

安装

turbovec 同时面向 Python 和 Rust 用户。Python 这边直接 pip 安装:

$ pip install turbovec

它通过 maturin 把 Rust 核心编译成原生扩展,再用 PyO3 做绑定,所以装好之后就是一个普通的 Python 包,不需要额外的运行时。

PyO3 是 Rust 生态里最主流的 Python 绑定库,它处理了两种语言之间的类型转换、内存管理和 GIL 交互,让你可以用 Rust 编写原生的 Python 扩展模块,也可以反过来在 Rust 程序里调用 Python 代码。maturin 则是与之配套的构建工具,能把基于 PyO3 的 Rust crate 打包成标准的 wheel 并发布到 PyPI,本地开发时一条 maturin develop 就能编译并装进当前虚拟环境。这套 Rust + PyO3 + maturin 的工具链如今在 Python 生态里相当流行,pydantic-corepolars 等明星项目用的都是它。

如果你是 Rust 用户,想直接在自己的工程里用核心库,则用 cargo 添加依赖:

$ cargo add turbovec

下面的体验主要以 Python 为例。

基础用法体验

我们从最简单的场景开始:建索引、添加向量、查询。这里用 numpy 生成一批随机向量来演示,实际项目里把它换成 OpenAI、BGE 之类模型产出的 embedding 即可。

建索引与添加向量

import numpy as np
from turbovec import TurboQuantIndex

# 维度 1536(对齐 OpenAI text-embedding-3-small),4-bit 量化
index = TurboQuantIndex(dim=1536, bit_width=4)

# 假设这是一批文档向量,实际中来自 embedding 模型
vectors = np.random.randn(10000, 1536).astype(np.float32)
index.add(vectors)

# 还能继续增量添加,不需要重建
more_vectors = np.random.randn(5000, 1536).astype(np.float32)
index.add(more_vectors)

print(len(index))   # 15000

可以看到,整个流程没有 train() 这一步,add() 完就能用。两次 add() 之间也不需要做任何额外处理,这就是在线量化带来的便利。

构造函数有两个关键参数:

  • dim:向量维度。也可以传 None 做惰性构造,等第一次 add() 时再根据数据确定维度
  • bit_width:每个坐标量化用的比特数,可选 234,默认 4。这是使用 turbovec 时最需要权衡的参数:比特数越低,索引越小,但量化损失越大、检索的召回率也越低。一般 4-bit 是多数场景的默认选择;内存极度敏感、可以接受召回率略降时用 2-bit;3-bit 则是介于两者之间的折中。两者在召回率和压缩比上的具体差距,后面性能一节有实测数据

查询

添加完向量后,用一个查询向量做检索:

# 查询也要是二维的,1 条查询就是形状 (1, 1536)
query = np.random.randn(1, 1536).astype(np.float32)

# 返回 top-10 的分数和下标
scores, indices = index.search(query, k=10)

print(indices[0])   # 命中的向量下标
print(scores[0])    # 对应的相似度分数

要注意的是,search() 是批量接口,接收形状为 (n_queries, dim) 的二维数组,单条查询也要写成 (1, dim) 的形式,直接传一维向量会报 TypeError。返回的 scoresindices 同样是二维的,形状为 (n_queries, k)scores 是相似度分数,indices 是命中向量在索引中的下标,单条查询取第 0 行即可。

持久化

索引可以写到磁盘,之后再加载回来,省去重新建索引的开销:

index.write("my_index.tv")

loaded = TurboQuantIndex.load("my_index.tv")
scores, indices = loaded.search(query, k=10)

.tv 是 turbovec 的索引文件格式,当前为 v3 版本,带 magic 头和版本号。

IdMapIndex:稳定 ID

TurboQuantIndex 返回的是内部下标,删除向量后下标会变。如果你需要给每个向量挂一个稳定的外部 ID(比如数据库主键),用 IdMapIndex

import numpy as np
from turbovec import IdMapIndex

index = IdMapIndex(dim=1536, bit_width=4)

# 3 条向量,配上 3 个业务侧的 uint64 ID
docs = np.random.randn(3, 1536).astype(np.float32)
index.add_with_ids(docs, np.array([1001, 1002, 1003], dtype=np.uint64))

scores, ids = index.search(query, k=10)   # 返回的是你的 uint64 外部 ID
index.remove(1002)                         # 按 ID 删除,O(1)
print(1002 in index)                       # False,支持成员判断

index.write("my_index.tvim")
loaded = IdMapIndex.load("my_index.tvim")

IdMapIndexsearch() 返回的 ids 就是你传入的外部 ID,remove() 按 ID 删除且是 O(1) 操作。它还支持 id in index 这样的成员判断。持久化用的是单独的文件格式 .tvim,和 TurboQuantIndex.tv 互相区分。IdMapIndex 也是后面做混合检索过滤的基础,相关内容我们下一篇再细看。

性能数字

下面我们来看仓库给出的几组基准数据。速度基准的对比对象是 FAISS 的 IndexPQFastScan,召回率基准的对比对象是 FAISS 的 IndexPQ,并且把 PQ 的参数调到让两边压缩后的索引大小完全相同,保证是在同等存储预算下比召回率。数据集用了 ann-benchmarks 的 GloVe d=200,以及 DBpedia 子集的 OpenAI embeddings d=1536 / 3072,各 10 万向量。下面引用的数字均取自仓库 benchmarks/results/ 目录下的 JSON 文件。

ann-benchmarks 是近似最近邻搜索领域最常用的基准测试套件,收录了 GloVe、SIFT 等一批标准数据集,并对主流 ANN 库做统一评测,论文和开源项目做性能对比时基本都会引用它。DBpedia 是一个从维基百科抽取结构化信息构建的开放知识库,这里用的是它的文本条目经 OpenAI embedding 模型向量化后得到的数据集,向量维度高、语义真实,常用来评测接近生产场景的检索效果。

召回率

召回率(recall)衡量的是量化后检索结果跟精确检索的吻合程度。下表是 Recall@1 的对比(10 万向量):

recall-table.png

表头的 @1 是 Recall@k 记号在 k=1 时的写法:只看第 1 个返回结果,统计它命中真实最近邻的查询占比。之所以用 @1 对比,是因为它对量化误差最敏感,k 放宽之后差距会迅速消失,benchmarks/results/ 里的 JSON 记录了 @1 到 @64 的完整曲线,在 OpenAI 数据集上到 @4 两边就都收敛到 1.0 了。

另外,差异列的 pp 是 percentage point(百分点)的缩写,指两个比例直接相减的绝对差。比如 0.891 对 0.872,差异就是 1.9pp。不写成 1.9% 是为了避免歧义,百分号容易被理解成相对提升。

可以看到,在高维的 OpenAI 向量上,turbovec 的召回率全面略优于 FAISS,2-bit 时领先 1.7~1.9pp,4-bit 时领先 0.2~0.8pp。要注意的是,在低维的 GloVe d=200 上优势就不明显了:4-bit 领先 0.9pp,2-bit 则与 FAISS 基本打平(0.5637 vs 0.5643)。这跟 TurboQuant 的原理有关:它依赖高维下旋转后坐标分布趋于规整的渐近假设,维度低时这个假设没那么成立。

仓库的 docs 目录里附了三张召回率对比图,分别对应 GloVe d=200、OpenAI d=1536 和 d=3072 三个数据集,下面是其中 OpenAI d=1536 这张:

turbovec-recall-chart.png

速度

速度测的是单次查询的中位数耗时(10 万向量、1000 次查询、k=64,取 5 轮中位数),ARM 平台是 Apple M3 Max,x86 平台是 Intel Sapphire Rapids(8 vCPU)。下表汇总了全部 16 组配置,每格是 turbovec 和 FAISS 的毫秒数对比:

speed-table.png

可以看到,ARM 上 turbovec 的手写 NEON 内核全面领先,所有配置快 10%~19%,4-bit 的优势比 2-bit 更明显。x86 上则分化:4-bit 配置小幅领先 2%~5%(d=3072 多线程持平),2-bit 配置全面落后 3%~8%。也就是说 turbovec 并非处处更快,如果你的场景是 x86 加 2-bit,FAISS 反而略占优。

仓库的 docs 目录里附了四张速度对比图,按 ARM / x86、单线程 / 多线程分成四组,下面是其中 ARM 单线程这张:

turbovec-speed-chart.png

上面这些速度差异,主要来自检索最内层那段循环的实现:一次查询要给索引里的每条压缩向量算一个相似度分数,10 万向量就是 10 万次计算,检索耗时几乎全花在这里。turbovec 用 SIMD 指令手写了这段循环,ARM 上是 NEON 版本,x86 上是 AVX-512 版本外加 AVX2 回退,运行时根据 CPU 自动选择。这部分实现细节我们留到系列后面的文章专门拆解。

压缩比

最后看不同 bit_width 下的压缩比(10 万向量):

compress-table.png

项目首页那句宣传词,本质上就是 8 倍压缩(31 GB → 4 GB)加上速度领先的组合。从这组数据看,4-bit 大约 8 倍、2-bit 大约 16 倍的压缩比是站得住的。

值得注意的是,官方文档里 always faster than FAISS 的说法并不在所有场景都成立(比如前面 x86 2-bit 的四组配置和 GloVe 2-bit 召回率就没有优势),基准数据跟硬件、数据集、参数都强相关,用到自己的场景前最好亲自测一遍。

小结

通过这篇文章,我们对 turbovec 有了一个整体认识:

  1. 项目定位:Rust 实现、带 Python 绑定的向量搜索引擎,核心是 Google Research 的 TurboQuant 算法(ICLR 2026),专注于用 2~4 bit 量化把向量内存压下来,同时保持检索速度
  2. 与 FAISS 的核心差异:TurboQuant 是数据无关量化,没有训练阶段、不需要学码本,向量加进去即建索引,对标 FAISS 的 IndexPQFastScan
  3. 基础用法TurboQuantIndexadd / search / write / load 四个方法,bit_width 在 2/3/4 之间权衡压缩比和召回率,需要稳定 ID 时用 IdMapIndex
  4. 性能表现:高维向量上召回率略优于 FAISS,速度 ARM 上全面领先、x86 上互有胜负,压缩比与宣传相符;低维数据和 x86 2-bit 是它的弱项,用之前最好在自己的场景实测

到这里我们只用到了 turbovec 最基础的稠密检索。实际做 RAG 时,往往还需要按租户、按权限、按时间窗口先把候选集圈出来,再在候选集里做向量重排,也就是混合检索。明天我们就来体验 turbovec 的混合检索能力,以及它跟 LangChain、LlamaIndex 等框架的集成。

参考


让小龙虾自己写手册:Skill Workshop

上一篇我们围绕自定义 skill 做了三件事:从 ClawHub 上搜索并安装别人的 skill、从零开始自己手写一份 skill、再把它发布回 ClawHub 让别人也能安装。三件事做下来你应该感觉到了,手写 skill 终究是个体力活,触发短语得自己琢磨、正文得自己组织、哪些步骤值得沉淀也得自己判断。前面提过的 skill-creator 能稍微帮你减负,但本质还是一份「按步骤填空」的写作指导,触发权和判断权都在你手里。

OpenClaw 有个实验性的内置插件叫 Skill Workshop,方向反过来:每轮成功的会话结束之后,它会自动扫一遍历史消息,把里面值得沉淀的可复用流程提议成一份 workspace skill,如果安全扫描通过就能直接写盘,让小龙虾把这一轮里学到的流程顺手写进下一轮自己能用的手册

开启与配置

这是个实验性插件,默认关闭,需要你在配置文件里显式打开才会加载。打开 OpenClaw 的配置文件,在 plugins.entries 下加上这么一段:

{
  plugins: {
    entries: {
      "skill-workshop": {
        "enabled": true,
        "config": {
          "autoCapture": true,
          "approvalPolicy": "pending",
          "reviewMode": "hybrid"
        }
      }
    }
  }
}

改完保存后,再运行 openclaw gateway restart 重启网关让它生效。

这几个配置参数逐个看一下:

  • autoCapture: true —— 让它在每轮成功对话结束后,自动扫一遍历史消息找可沉淀的东西;关掉的话它就不主动扫了,只有你开口让小龙虾「把这个存成 skill」时才会存。
  • approvalPolicy: "pending" —— 它扫到觉得该存的东西时,不会直接写成文件,而是先攒成一条「提议」排进待办队列,等你审批通过后才真正落到磁盘上。建议先使用该配置,等你确认它提议得靠谱了,再换成 auto 让它跳过审批、自己直接写。
  • reviewMode: "hybrid" —— 用哪种方式去发现可沉淀的内容,一共四挡:off 完全不自动找,heuristic 只靠关键词扫描,llm 让模型回看一遍整段对话,hybrid 则是前两者一起上。新手用 hybrid 就行,几挡的差别后面讲。

因为还在实验阶段,它的内部行为各版本之间可能会变,所以第一次用务必先 pending 模式,亲眼看几轮它都想存些什么,别一上来就让它自动写文件。

这几个参数取不同的值,能搭出好几种不同的组合,对应不同的使用场景:

// 保守:只接受模型显式调工具,关闭一切自动捕获
{ autoCapture: false, approvalPolicy: "pending", reviewMode: "off" }

// 评审优先(推荐):自动捕获,但都先排队等审批
{ autoCapture: true,  approvalPolicy: "pending", reviewMode: "hybrid" }

// 受信自动化:本地工作区里安全提案自动写盘
{ autoCapture: true,  approvalPolicy: "auto",    reviewMode: "hybrid" }

// 省钱:不跑回看模型,只认显式纠正短语
{ autoCapture: true,  approvalPolicy: "pending", reviewMode: "heuristic" }

除了上面这四个,还有几个调阈值、限大小的配置项,第一次上手用不到,附在这里备查:

默认范围作用
reviewInterval151..200累计多少轮成功对话后跑一次回看模型
reviewMinToolCalls81..500累计多少次工具调用后跑回看模型
reviewTimeoutMs450005000..180000回看模型单次运行的超时
maxPending501..200每工作区最多保留多少提案
maxSkillBytes400001024..200000生成的 skill / 支持文件单文件大小上限

实战 Skill Workshop

插件启用之后,这一节我们手动走一遍完整流程,亲眼看它怎么把一句话变成一份 skill。整个过程就三件事:在对话里说一句带「纠正口吻」的话、看看提案有没有进队列、再把它 apply 落地。

说一句带「纠正口吻」的话

第一步,造一段能被插件捕到的对话。插件会盯着你消息里有没有 next time / always / from now on 这类纠正口吻(完整短语列表后面会讲),命中了就尝试把它沉淀成 skill。注意这些短语得出现在用户消息里,小龙虾回复里出现不算。比如:

next time we work with animated GIFs, always verify the URL resolves
to image/gif before committing the file.

小龙虾会正常回复:

next-time.png

但是在回复之后自动触发 skill workshop 插件,对当前会话进行沉淀。

查看提案队列

第二步,看队列里进东西没有。在同一会话或新会话里跟小龙虾说一句:

> 帮我看下 skill workshop 现在有几个 pending 提案

小龙虾会调 skill_workshop 工具,参数是 { "action": "list_pending" },把待审队列列出来,大致这样:

skill-workshop-pending.png

如果查看 Control UI 中的对话详情,可以看到工具调用结果如下:

[
  {
    "id": "1548054d-4aa2-493d-a1f5-96409a4e56be",
    "createdAt": 1780959647860,
    "updatedAt": 1780959647860,
    "workspaceDir": "~/.openclaw/workspace",
    "agentId": "main",
    "sessionId": "161be786-5ac8-439c-ab61-245fa0bdbb56",
    "skillName": "animated-gif-workflow",
    "title": "Animated GIF Workflow",
    "reason": "User correction for animated GIF requests",
    "source": "agent_end",
    "status": "pending",
    "change": {
      "kind": "create",
      "description": "Reusable workflow notes for animated GIF requests.",
      "body": "# Animated GIF Workflow\n\n## Workflow\n\n- next time we work with animated GIFs, always verify the URL resolves to image/gif before committing the file.\n- Verify the result before final reply.\n- Record durable pitfalls as short bullets; avoid copying transcript noise."
    },
    "scanFindings": []
  }
]

这条记录里有几个字段值得注意。

先看 status:它是 pending,说明此刻还没创建任何 skill 文件,这只是一条排在待审队列里的提议,真正写盘要等下一步 apply

再看 skillName:插件从你那句话里识别出 animated GIFs,给这条提议起了个现成的名字 animated-gif-workflow。OpenClaw 内置了一张话题映射表,把几类常见话题各对到一个固定的 skill 名:

用户话里出现落到的默认 skill 名
animated / gifanimated-gif-workflow
screenshot / screen capture / asset / imageoptimscreenshot-asset-workflow
qa / scenario / test planqa-scenario-workflow
pr / pull request / githubgithub-pr-workflow
其它都进兜底名称learned-workflows

你这句话里有 animated GIFs,命中第一行,自然就归到 animated-gif-workflow;要是一句话谁都不沾,就统一落到 learned-workflows 这个兜底名称。注意这只是这份 skill 将来 apply 后会用的目录名,现在还只是提议里的一个建议值。

另外还有个 source 字段,表示这个提案是从哪冒出来的,它的取值有:tool(你或模型显式调工具生成)、agent_end(自动扫到纠正口吻)、reviewer(回看模型产出),正好对应插件捕获提案的三条路径,后面「三条捕获路径」一节会拆开讲。

应用 skill 提案

第三步,把它应用:

apply.png

OpenClaw 会调用下面的工具:

// skill_workshop({ "action": "apply", "id": "1548..." })

应用成功后,自动生成 <workspace>/skills/animated-gif-workflow/SKILL.md 文件。关键的一点是:所有写入都会立刻刷新内存里的 skills 快照,新 skill 不用 /new 也不用重启网关就能在当前会话被看到。

如果觉得这个提案不够好,就拒绝:

// skill_workshop({ "action": "reject", "id": "1548..." })

被拒的提案状态变成 rejected,留在状态文件里供审计。

工具用法速查

跑通之后你会发现,整个生命周期其实都是围绕 skill_workshop 这个工具撑起来的。它的 action 一共八个:

action用途
status统计当前工作区里各个状态(pending / applied / rejected / quarantined)的提案各有多少条
list_pending列出待审队列里的提案,默认只看 pending,也可以传 status 换成查看其它状态
list_quarantine列出被安全扫描拦下、隔离起来的提案
inspectid 查看某一条提案的完整详情
suggest由模型主动提交一条新提案,pending 策略下默认入队等审批
apply把某一条 pending 提案应用掉,真正把 skill 文件写到磁盘上
reject拒掉某一条提案,状态改为 rejected 但保留在记录里
write_support_file往 skill 目录下的支持目录写一份支持文件(如 references/scripts/

其中大部分都比较简单,只有 suggestwrite_support_file 值得单独讲下。

suggest 是模型主动建议的入口,比自动捕获更精确。一个完整的 suggest 长这样:

{
  "action": "suggest",
  "skillName": "animated-gif-workflow",
  "title": "Animated GIF Workflow",
  "reason": "User established reusable GIF validation rules.",
  "description": "Validate animated GIF assets before using them.",
  "body": "## Workflow\n\n- Verify the URL resolves to image/gif.\n- Confirm it has multiple frames.\n- Record attribution and license.\n- Avoid hotlinking when a local asset is needed."
}

上面例子用的是基础参数,默认走 create 模式新建一份 skill 文件。除此之外 suggest 还有几个额外参数,用来切换写入方式或绕开审批策略:

  • apply —— 强制指定写不写盘,绕开当前的 approvalPolicyapply: true 在 pending 策略下也强行立即写(仍然要过安全扫描);反过来 apply: false 在 auto 策略下也强行只入队
  • section —— 传了它就切到 append 模式,把 body 追加到已有 skill 的指定 section 下,而不是新建。
  • oldText + newText —— 这俩一起传就切到 replace 模式,把 skill 正文里的 oldText 精确替换成 newText(要求 oldText 在文件里唯一存在,否则会拒绝)。

write_support_file 用于往 skill 目录下的支持目录写一份支持文件,要知道,skill 不止是 SKILL.md 一份文件,它可以带 references/templates/scripts/assets/ 这四种支持目录,支持目录里的内容不会自动进 prompt,只在 SKILL.md 正文显式引用时被小龙虾 Read 进来。这条命令就是让 Workshop 把 skill 写到一个比 SKILL.md 更深的位置:

{
  "action": "write_support_file",
  "skillName": "release-workflow",
  "relativePath": "references/checklist.md",
  "body": "# Release Checklist\n\n- Run release docs.\n- Verify changelog.\n"
}

和写 SKILL.md 一样,这条命令也守着同一套安全约束:写入位置被严格限定在 workspace 目录内、不许越界,文件大小受 maxSkillBytes 限制,内容同样要过一遍安全扫描,最后再原子落盘,不会写到一半留下半截文件。

三条捕获路径

前面讲 source 字段时提到,一条提案可能从三条不同的路径冒出来(tool / agent_end / reviewer)。这一节就把这三条路径拆开:

skill-workshop-seq.png

1. 工具显式建议:模型看到一段可复用流程、或用户明说「把这个存成 skill」时,直接调 skill_workshop 工具。这是最显式的一条,autoCapture: false 也能用,是关掉自动捕获时唯一的入口。

2. 启发式捕获autoCapture 开 + reviewModeheuristic 时,插件会扫成功那一轮对话里用户消息中的明确纠正短语(也就是上面实战里说的那句话能被捕到的原因)。这套短语列表是写死在 extensions/skill-workshop/src/signals.ts 里的:

const CORRECTION_PATTERNS = [
  /\bnext time\b/i,
  /\bfrom now on\b/i,
  /\bremember to\b/i,
  /\bmake sure to\b/i,
  /\balways\b.{0,80}\b(use|check|verify|record|save|prefer)\b/i,
  /\bprefer\b.{0,120}\b(when|for|instead|use)\b/i,
  /\bwhen asked\b/i,
];

命中之后,会根据内置的那张话题映射表给提案起名,就是前面实战里见过的(animated/gifanimated-gif-workflow,其它一律进 learned-workflows 兜底),这里不再重复。

3. 回看模型(LLM reviewer)reviewModellm 时,攒够阈值(默认 15 轮成功对话或 8 次工具调用)后,起一次紧凑的内嵌回看。这里的「回看模型」其实就是当前对话用的那个模型,起一次独立子调用,重新审一遍对话。这次调用有几个限制:

  • 输入只喂最近 12,000 字对话记录、最多 12 个已有 skill(每个截到 2,000 字);
  • 不给它任何工具;
  • 只允许输出 JSON 格式;

模型返回结果只能是 {"action":"none"} 或一个提案对象:

{
  "action": "create",
  "skillName": "media-asset-qa",
  "title": "Media Asset QA",
  "reason": "Reusable animated media acceptance workflow",
  "description": "Validate externally sourced animated media before product use.",
  "body": "## Workflow\n\n- Verify true animation.\n- Record attribution.\n- Store a local approved copy.\n- Verify in product UI before final reply."
}

其中 action 可以是:create 建新 skill、append 往现有 skill 加 section、replace 替换段落里的精确字符串。

安全扫描兜底

在实战里,我们已经见过提案的三种状态:pending(在队列里排队待批)、applied(应用后已写入磁盘)、rejected(被你手动拒掉)。其实还有第四种 quarantined(被安全扫描拦下隔离),这一节我们就来看下它。

生成 SKILL.md 和支持文件落盘前会先经过安全扫描,这个扫描器不是模型,而是一组写死的正则规则。规则分两档:命中 critical 的提案直接隔离;命中 warn 的只记录,不拦截。

五条 critical 规则(命中即隔离):

  • prompt-injection-ignore-instructions —— 匹配「ignore all/previous instructions」这类经典的越权开场白。skill 正文里如果出现「忽略上面/之前的所有指令」,摆明了是想覆盖更高优先级的系统指令。
  • prompt-injection-system —— 匹配「system prompt」「developer message」「hidden instructions」这些字眼。正经的流程 skill 没理由去提隐藏 prompt 指令,如果提了八成是想撬动或套出上层指令。
  • prompt-injection-tool —— 匹配「调用某工具……无需……许可/审批」这种句式,也就是怂恿小龙虾绕过工具审批关。
  • shell-pipe-to-shell —— 匹配 curl https://... | sh 这种命令,把远程脚本直接输入 shell 运行,等于让小龙虾执行陌生代码。
  • secret-exfiltration —— 匹配同一句里既出现 env / process.env、又出现网络动作(fetch / curl / http 等)的情况,大概率是想把环境变量里的密钥往外发。

两条 warn 规则(只记录、不拦截):

  • destructive-delete —— 匹配 rm -rf /rm -rf ~ 这种冲着根目录 / 家目录 / 当前目录去的大范围删除。不直接拦,是因为正常的清理脚本也可能这么写,留给人工判断。
  • unsafe-permissions —— 匹配 chmod 777 / chmod -R 777 这种把权限全部放开的危险操作,同样只记录、不拦截。

这里 OpenClaw 为什么用死正则、不用模型呢?我想可能有三方面原因:一是确定性,同样的内容每次扫结果一样,使用模型的话不够稳定;二是零成本零延迟,每次写盘都过一遍也不占 token、速度也很快;三是不会被反将一军,它本来就是防 prompt injection 自我投毒的最后一道闸,要是它自己也是个模型,反倒有可能被同一波注入策反。

注意:这里的三条 prompt-injection 规则全是英文关键词(ignore ... instructions / system prompt / ... tool ... without approval),用中文写的注入话术,比如「忽略以上所有指令」「调用工具无需审批」,它就失效了。

小结

今天我们学习了 skill 系列的第三篇,通过 Skill Workshop 让小龙虾在每轮对话后,把可复用流程沉淀成 workspace skill。这也是整个系列的最后一篇,回头看这一路,我们其实是按一个由浅入深的顺序,把一只小龙虾从「只会说话」一点点养成了「能干活、可扩展」的个人 AI 助手。

最开始是接入,我们把它装起来,接上 Telegram 和飞书,让它能在你天天用的聊天软件里收发消息;接着是自动化,靠 cron、heartbeat、webhook、standing orders 这些机制,让它不用你每次戳一下才动,而是能按时间按事件自己运行起来。再往后是协作与隔离,我们给它配齐了后台任务、多 agent 与子 agent、沙箱、远程网关,还有 macOS 和手机上的 Node,让它既能分身同时干好几件事,又能被安排到该跑的机器上、关进该关的沙箱里。

接着,我们学习它的手脚,从内置工具体系,到浏览器 browser 工具,再到能把活转派给外部编码 agent 的 ACP,学习了它能调动的各种能力。最后这几篇则落在手册上,先学习了 skill 的基本原理,然后是通过 ClawHub 安装别人的 skill 以及怎么把自己的 skill 发布出去,再到今天这篇 Skill Workshop,教它怎么在干活的过程中给自己写手册。

走到这里,小龙虾已经不是一个只会回消息的 bot,而是一个有手有脚、有手册、还能在干活中自我升级的 agent。

这个系列就到这里。感谢一路读到最后,现在,去给你自己的小龙虾配齐工具箱吧。

参考


带小龙虾逛 ClawHub:自定义 Skill 实战

上一篇我们把 Skills 系统的基本盘讲完了:一份 SKILL.md 就是一份操作手册,仓库自带 53 个 skill,加上内置插件捎来的 14 个,开箱就有 67 个能用;加载时按优先级合并、按声明的依赖条件筛选,再把每个 skill 的「名字 + 描述 + 路径」拼成一段索引塞进系统提示,正文则由模型在命中后自己用 Read 工具按需加载。

但这 67 个终究是官方给的,对每个人的工作流来说都谈不上贴身。真正让 OpenClaw 生态贴近场景的,是自定义 skill,既包括别人写好放到第三方市场 ClawHub 上的,也包括你自己写出来的。今天我们就先带小龙虾去 ClawHub 上逛逛,挑一个 skill 装到本地试试;然后自己动手写一个最小可用的 sysinfo skill;最后把它发布回 ClawHub,让别人也能装来用。

ClawHub 是什么

ClawHub 是 OpenClaw 官方的 skill 和 plugin 注册中心,站点是 clawhub.ai

clawhub.png

它在 OpenClaw 生态里的位置,类似 npm 之于 Node、PyPI 之于 Python:一个能公开浏览、版本化、可搜索的技能仓库。官方文档把 ClawHub 提供的能力归成下面这张特性表:

特性说明
公开浏览skill 目录和 SKILL.md 全文都可以匿名公开查看
语义搜索走 embedding 向量匹配,而不是只对关键词
版本管理用语义化版本号管理,每次发布生成一个新版本,配带变更说明和标签
下载分发每个版本一份 zip 包
社区反馈支持收藏(star)和评论
安全扫描详情页展示 SkillSpector 和 VirusTotal 安全扫描结果,安装前一眼可见
作者修复面板被扫描扣留的版本,作者能在 /dashboard 看到并申诉复扫
复扫请求误判时作者可以申请有限次数的复扫
人工审核申请审核和审计流程
CLI 友好的 API适合自动化和脚本调用

装一个 skill 试试

ClawHub 上的 skill 怎么装到本地呢?官方提供了两套 CLI 命令行工具,我们可以使用 CLI 手动搜索和安装,也可以直接和小龙虾对话,让它给你安装。

两个 CLI 工具

ClawHub 有两套官方 CLI 入口:

  • openclaw skills:OpenClaw 自家命令族里的一个子命令,装 OpenClaw 时一并就有。负责搜索、安装、更新 ClawHub 上的 skill,外加查看本地 skill 状态(list / info / check)。
  • clawhub:独立的 CLI 工具,靠 npm i -g clawhub 单独安装,覆盖 ClawHub 全部功能,除了上面那些不用登录就能做的搜索、安装类操作,还包括登录、发布、删除、复扫、同步等需要鉴权的写操作。

两者常用命令对照如下:

操作openclaw skillsclawhub备注
搜索openclaw skills search "<query>"clawhub search "<query>"走向量语义搜索,不只匹配关键词
安装openclaw skills install <slug>clawhub install <slug>默认装 latest 标签的版本,拉到 workspace 的 skills/
安装指定版本openclaw skills install <slug> --version <ver>clawhub install <slug> --version <ver>想锁定某个旧版本时用
更新openclaw skills update <slug>clawhub update <slug>.clawhub/lock.json 里的来源元数据重新拉新版本
全部更新openclaw skills update --allclawhub update --all把 lock.json 里登记过的 ClawHub skill 一起升级
查看列表openclaw skills listclawhub listopenclaw skills list 列本地全部 skill(bundled / managed / workspace);clawhub list 只列从 ClawHub 装下来的
查看详情openclaw skills info <slug>看本地 skill 的来源、frontmatter 元数据、当前是否可用
验证openclaw skills check把本地所有 skill 的依赖条件挨个核对,不可用的列出来排障
登录clawhub login默认走浏览器授权;也可以 --token <token> 直接贴 API token
发布clawhub skill publish <path>把本地 skill 目录推到 registry,生成新 semver 版本
删除clawhub delete <slug> --yes作者下架自己发布的 skill
复扫clawhub skill rescan <slug>安全扫描误判时申请重扫(每个版本有次数限制)
同步clawhub sync扫本地 skills 目录,把改过或新增的批量推上去,按内容 hash 比对,不重发

一般来说,安装别人写的 skill 两种 CLI 工具都可以,但 OpenClaw 自家的 openclaw skills 用起来更顺手一些,因为它还兼顾了 list / info / check 这些看本地状态、排查问题的操作,一个命令全部搞定。要自己往市场上发布 skill,就必须切到 clawhub 了,登录、发布、删除、同步这些写操作只它那边有。

来跑一遍直观感受下,比如我们想要找一个做 PPT 的技能,先搜:

$ openclaw skills search "ppt"

ppt  ppt  将用户讲稿一键生成乔布斯风极简科技感竖屏HTML演示稿...
hnytit-ppt-generator  河南油田工程科技PPT生成器  河南油田工程科技股份有限公司专属PPT制作技能...
...

然后选第一个安装:

$ openclaw skills install ppt

Downloading ppt@1.0.0 from ClawHub…
Installing to ~/.openclaw/workspace/skills/ppt…
Installed ppt@1.0.0 -> ~/.openclaw/workspace/skills/ppt

openclaw skills install 拉的就是 ClawHub 上对应 slug + 版本的 zip 包,解压到当前 workspace 的 skills/ 目录下,并往 .clawhub/lock.json 里写一条来源记录。下次 openclaw skills update --all 时它就知道这份 skill 是从 ClawHub 来的、应该去哪里拉新版本。clawhub install ppt 的效果完全一致,只是走的是独立 CLI 的实现。

让小龙虾帮你装

除了 CLI 手动安装之外,OpenClaw 还提供了一种更顺手的玩法。仓库自带了一份名叫 clawhub 的 skill,作用就是把上面的 CLI 包装成大白话:你用人话告诉 agent「装个能做幻灯片的技能」,它自己去搜索、判断、调命令。skill 本身写得非常薄,frontmatter 里只声明依赖一个二进制:

---
name: clawhub
description: Search, install, update, sync, or publish agent skills with the ClawHub CLI and registry.
metadata:
  {
    "openclaw":
      {
        "requires": { "bins": ["clawhub"] },
        "install":
          [
            { "id": "node", "kind": "node", "package": "clawhub", "bins": ["clawhub"], "label": "Install ClawHub CLI (npm)" }
          ],
      },
  }
---

正文部分就是一张 cheat sheet:search、install、update、list、publish 几个子命令各贴了一段示例。agent 加载这个 skill 后,看到用户说要装个能做幻灯片的技能,就知道该去跑 clawhub install

先确认 CLI 装好了:

$ clawhub --cli-version
0.18.0

$ clawhub whoami
not logged in

匿名状态可以装公开 skill,发布才需要 clawhub login。回到 Telegram 或飞书的会话里,直接用大白话跟小龙虾说:

openclaw-skills-install.png

这背后就是 clawhub skill 驱动的,agent 实际做的事和你自己敲 clawhub search + clawhub install 完全等价。装完之后当前会话就能直接用,让它做一个 PPT 试试:

openclaw-skills-use.png

使用浏览器打开,一个 8 页的关于 OpenClaw 的 PPT 就做好了:

openclaw-ppt.png

关于 skill 安全

通过上面的学习我们知道,从 ClawHub 上装一个 skill 又简单又方便,但方便里隐藏着风险。把别人写的 skill 装到本地,本质上就是在跑陌生人写的代码。2026 年以来,ClawHub 上就出过几起规模化投放恶意 skill 的真实事件,因此 skill 安全不能忽略。

ClawHub 的发布门槛刻意做得很低。按官方文档的说法,任何人都能上传 skill,唯一的限制是发布者的 GitHub 账号要至少注册满一周,这意味着市场里什么都有。OpenClaw 官方对第三方 skill 的态度也很明确:把它当作未经审查的代码看待,启用前一定要先读一遍。

为此,ClawHub 这边主要靠两道防线:自动扫描人工举报。每个版本的详情页都挂着一行 Security audit 状态,点进去就是完整的审计报告,由两家扫描器并行出结果:

  • SkillSpector:NVIDIA 出的安全检查工具,对 prompt 注入、数据外泄、权限提升、供应链风险、过度代理几类规则挨条做检查,命中哪条就在哪条上标红;

skill-spector.png

  • VirusTotal:把 skill 的下载包丢进多家杀软引擎跑一遍,将结果汇总,量化判断有没有被投毒;

virustotal.png

没过扫描的版本会从公开安装面下架,普通用户搜不到也装不了,只有作者自己能在 /dashboard 里看到被扣留的版本。如果作者觉得是误判,可以用前面命令表里的 clawhub skill rescan <slug> 申请重扫,每个版本有限次数,避免反复滥用。

第二道防线是人工举报。任何登录用户都能就某个版本发起举报,超过 3 个独立举报,该版本也会被自动隐藏。

最后,skill 装到本地之后,真正的安全其实还是在用户自己手里,可以对照着 skill 的 metadata.openclaw.requires 检查一遍,里面的 bins / env / config 是这份 skill 的权限申报,扫一眼看看合不合理。一个号称只做幻灯片的 skill,不该去读 ~/.openclaw/openclaw.json,也不该往陌生 webhook 发数据。还拿不准的,丢进前面讲过的 sandbox 里跑一段时间再决定。

从零写一个 skill

装别人写好的 skill 我们已经讲完了,下面进入本篇的第二步,自己动手写一份。我们要写的这个叫 sysinfo,目标很朴素:用户随口问一句「这台机器现在剩余多少空间」、「内核是哪个版本」,agent 自动跑 df / uname 给个答案。

仓库里其实带了一个官方推荐的 skill-creator 技能,专门用来指导 agent 一步步创建新 skill:它会先跟你确认需求和触发场景,再生成目录骨架和 SKILL.md 模板,最后做校验和打包。你完全可以直接在 Telegram 或飞书里跟小龙虾说一句「帮我生成一个查系统信息的 skill」,它会按照 skill-creator 的说明,自动把 sysinfo 这份 skill 写出来。不过为了把 SKILL.md 的字段挨个过一遍,我们这次不靠它,纯手写一份只有一个文件的 skill。

第一步,在 workspace 里建目录:

$ mkdir -p ~/.openclaw/workspace/skills/sysinfo

第二步,写 SKILL.md,内容如下:

---
name: sysinfo
description: "用 df / uname / uptime 报告本机的磁盘占用、内存、运行时间和系统信息。"
user-invocable: true
metadata:
  {
    "openclaw":
      {
        "emoji": "🖥️",
        "os": ["darwin", "linux"],
        "requires": { "bins": ["df", "uname", "uptime"] }
      }
  }
---

# sysinfo

当用户询问本机的磁盘空间、内存、运行时长、内核 / 操作系统版本,或者「这台机器现在状态怎么样」时,
通过 `exec` 工具运行对应命令,并用一两句话汇报结果。

## 命令对照

- 各挂载卷的磁盘占用:  `df -h`
- 内核和系统版本:       `uname -a`
- 运行时长与负载:       `uptime`

## 规则

- 总是先把原始命令打出来,再附一句简短总结,不要花哨格式化;
- 不要编造数字——命令失败就老实说;
- 拒绝任何需要 sudo 或会写入文件系统的请求。

frontmatter 字段详解

上一篇介绍 summarize 的 SKILL.md 时已经讲过 name / description / emoji / requires.bins / install 几个字段的含义,sysinfo 这份例子里新引入的字段主要是两个:

  1. user-invocable:设为 true 时,表示这个 skill 同时变成一个 slash command,用户可以直接 /sysinfo 内核版本是什么 强制调用,不用等模型自己判断;
  2. metadata.openclaw.os:把 skill 限定在指定平台,["darwin", "linux"] 表示只在 macOS 和 Linux 上加载,跑在 Windows 上的 OpenClaw 就看不到;

除此之外,frontmatter 里还有几个常用字段虽然 sysinfo 没用到,也一并介绍一下:

requires.env 声明必须存在的环境变量,常用在依赖 API key 才能工作的 skill 上:

"requires": { "env": ["OPENAI_API_KEY"] }

意思是环境变量里必须有 OPENAI_API_KEY,否则不启用该 skill。

requires.config 声明依赖的 OpenClaw 配置项必须开启:

"requires": { "config": ["browser.enabled"] }

意思是 openclaw.jsonbrowser.enabled 配置的值必须为真才会加载,常用在「依赖 browser 工具才能跑」「依赖某个插件启用了才能用」这类场景。

requires.anyBins 声明一组二进制里至少要有一个。比如某个跨平台截图 skill:

"requires": { "anyBins": ["screencapture", "gnome-screenshot", "scrot"] }

三者有任意一个就算可用,解决 macOS / Linux 不同发行版工具名不一致的问题。

primaryEnv 声明这份 skill 默认从哪个环境变量取认证,让用户在 openclaw.json 里能用 apiKey 这个简写配 API key。比如仓库自带的 gh-issues 在 SKILL.md 里写了:

"primaryEnv": "GH_TOKEN"

不声明 primaryEnv 时,用户得在 openclaw.json 里写完整的环境变量名:

{
  skills: {
    entries: {
      "gh-issues": {
        env: { GH_TOKEN: "ghp_xxx" },
      },
    },
  },
}

声明了 primaryEnv 之后,可以直接用 apiKey 简写:

{
  skills: {
    entries: {
      "gh-issues": {
        apiKey: "ghp_xxx",
      },
    },
  },
}

OpenClaw 在 skill 运行前会把这个值注入到 GH_TOKEN 环境变量里。此外,apiKey 比直接写 env.<KEY> 还多了一个好处:它能接 SecretRef 对象,从 keychain、1Password 这类密钥存储里取值:

{
  skills: {
    entries: {
      "gh-issues": {
        apiKey: { source: "keychain", provider: "default", id: "gh-token" },
      },
    },
  },
}

env.<KEY> 字段只接受明文。

skill 验证

SKILL.md 写完之后,先让 OpenClaw 把它扫进来。skill 快照是按会话锁定的,所以新加的 skill 要么 /new 起个新会话,要么直接重启网关:

$ openclaw gateway restart

接着用 CLI 检查一遍,确认 skill 被识别到了,并且当前确实 eligible:

$ openclaw skills list --eligible | grep sysinfo

│ ✓ ready  │ 🖥️ sysinfo │ 用 df / uname / uptime │ openclaw-workspace │

$ openclaw skills info sysinfo

🖥️ sysinfo ✓ Ready

用 df / uname / uptime 报告本机的磁盘占用、内存、运行时间和系统信息。

Details:
  Source: openclaw-workspace
  Path: ~/.openclaw/workspace/skills/sysinfo/SKILL.md
  Visible to model: yes
  Available as command: yes

Requirements:
  Binaries: ✓ df, ✓ uname, ✓ uptime
  OS: ✓ darwin, ✓ linux

CLI 验证没问题之后,回到聊天窗口试两种触发方式:

# 模型自动触发
> 帮我看下这台机器现在还剩多少空间

# 用户显式触发
> /sysinfo 内核版本是什么

第一种走 description 匹配,第二种因为 user-invocable: true 直接以 slash command 命中,效果相同:agent 调一次 exec,把 df -h / uname -a 的原始输出附上一两句总结回给你。

把它发布到 ClawHub

sysinfo 已经在本地跑通了,下面把它发布到 ClawHub 上,让别人也能像我们前面装 ppt 那样一行命令装下来。发布这一步走独立的 clawhub 命令,我们先登录:

$ clawhub login

$ clawhub whoami
✔ aneasystone

clawhub login 默认走浏览器授权,也可以 clawhub login --token <token> 直接贴 token。登录后发布单个 skill:

$ clawhub skill publish ~/.openclaw/workspace/skills/sysinfo \
    --slug sysinfo-demo \
    --name "Sysinfo Demo" \
    --version 1.0.0 \
    --changelog "Initial release" \
    --tags latest

这里的 --slug 是市场上的唯一标识,sysinfo 已经存在了,因此我这里改成了 sysinfo-demo,发布成功后可以在 ClawHub 的 dashboard 中查看:

clawhub-dashboard.png

如果本地有一堆 skill 要一起处理,用 clawhub sync --all 扫一遍当前工作目录全量上传,它会按内容 hash 和 registry 比对,只发新增或有改动的。

发布后 ClawHub 会自动跑一遍安全扫描,扫描状态会显示在详情页上。如果误报了,作者可以在 dashboard 里申请有限次数的重新扫描,或者用 clawhub skill rescan <slug> 触发。

小结

回顾今天的内容,我们围绕自定义 skill 学习了如何从 ClawHub 上装别人写好的 skill、如何自己动手手写一份 skill、以及如何将自己写的 skill 发布回 ClawHub 市场。

前面提过的 skill-creator 虽然能让小龙虾按步骤帮你写 skill,但本质只是一份写作指导文档,触发权还在你手里;OpenClaw 还有个叫 Skill Workshop 的内置插件,方向反过来,每轮会话结束自动扫一遍历史消息,把可复用的流程沉淀成 workspace skill,下一篇我们就来看看它。

参考


给小龙虾写本操作手册:Skills 系统

前面几篇我们把 OpenClaw 的工具挨个过了一遍,但工具终究只是零件。想象一下你跟小龙虾说一句「把 openclaw 仓库里带 bug 标签的 issue 都过一遍,能修的就开个 PR 修掉」,它得先用 execgh issue list 拉 issue 列表,按 label 筛出要动的几个,再用 sessions_spawn 分头派子 agent 去改代码,修完再用 gh pr create 开 PR,最后还得盯着 review 评论看要不要再改一轮。这一串环节该按什么顺序走、被 rate-limit 怎么回退、PR 评审打回来怎么继续,光靠模型每次临场发挥并不稳。它需要一份写好的操作手册。这正是 Skill 这套机制要解决的问题。

围绕 Skills 系统我们打算分三篇讲完:今天这一篇先把基本盘讲清楚,什么是 skill、从哪儿加载、如何参与对话;下一篇带大家逛一逛 ClawHub 市场,再动手写一个自己的 skill 并发布上去;第三篇看一个实验性玩法 Skill Workshop,让 agent 把自己干活时学到的流程自动写成 workspace skill。今天先从最基础的开始。

什么是 Skill

根据官方文档,OpenClaw 的 skill 用的是 Agent Skills 规范:每个 skill 就是磁盘上的一个目录,目录里至少有一份 SKILL.md,文件头是一段 YAML frontmatter,正文是 Markdown 写的操作说明。说白了,Skill 就是给 agent 备的一份操作手册:什么时候该触发、要调哪些底层工具、按什么步骤走、出错了怎么办。它和直接把这些步骤塞进系统提示词最大的区别是按需加载。OpenClaw 只在 agent 真有可能用到时才把它的存在告诉模型,平时不占用上下文;模型觉得用户问题和哪个 skill 相关了,再去把手册正文读出来。

agent-skills.png

最小可用的 SKILL.md 长这样:

---
name: hello-world
description: A simple skill that says hello.
---

# Hello World Skill

When the user asks for a greeting, use the `echo` tool to say
"Hello from your custom skill!".

frontmatter 里 namedescription 是必填的,如果缺一个,这份 skill 直接被丢掉。其中 description 是给模型看的一句话说明,模型靠它判断当前对话要不要使用这个 skill;正文则是给 agent 看的执行手册。

除了 name 和 description 这两个必填字段,OpenClaw 还在 frontmatter 里加了不少自家扩展:声明依赖二进制 / 环境变量 / 配置项的 requires.bins / env / config、控制触发方式的 user-invocable / disable-model-invocation / command-dispatch、限定平台的 os、给桌面端 UI 用的 install 安装提示,以及配合密钥注入的 primaryEnv 等等。具体含义留到后面再细看。

三类来源

OpenClaw 把 skill 按来源分了好几类,信任级别和生命周期各不相同。优先级从高到低依次是:

优先级来源路径
1Workspace skills<workspace>/skills
2Project agent skills<workspace>/.agents/skills
3Personal agent skills~/.agents/skills
4Managed / 本地 skills~/.openclaw/skills
5Bundled skills随安装包发出
6额外目录skills.load.extraDirs(配置里指定)

如果几个来源里有同名 skill,高优先级覆盖低优先级。这套规则保证了用户工作区里自己定制的版本永远盖得住官方版本,方便做覆写和打补丁。中间那两层 .agents/skills 是给项目级或个人级的 agent profile 用的,平时不太会动,一般我们只关心三类:

  • bundled:仓库自带,随安装包发出
  • managed~/.openclaw/skills,从 ClawHub 装下来的或本地打的补丁
  • workspace<workspace>/skills,用户自己写的定制版本

除此之外,插件也可以自带 skill:在 openclaw.plugin.json 里声明一个 skills 目录,插件启用时这些 skill 就会按和 extraDirs 同等的最低优先级合并进来。前面 browser 那篇提过的 browser-automation 手册,就是浏览器插件顺带捎进来的 skill。

多 agent 模式下每个 agent 有自己的工作区,所以 workspace 类的 skill 是 per-agent 的,互相看不到。这一点在企业部署里很关键:同一台机器上同时跑客服 agent 和开发 agent,让他们看不到对方的私有 skill 就能做到权限隔离。另外要提醒一句,第三方 skill 等同于未经审查的代码,启用前一定先读一遍,不放心就丢进沙箱里跑。

仓库自带 skill 一览

先看 bundled 这一类,也就是仓库自带的 skills/ 目录,它会直接被打包进发行版,截至本系列动笔时一共 53 个,覆盖面相当广。下面按主题分组挨个过一遍:

笔记、任务与项目管理

skill用途
apple-notes在 macOS 上通过 memo CLI 创建、查看、编辑、搜索、移动、导出 Apple 笔记
apple-reminders通过 remindctl 增删改查 Apple 提醒事项和列表
bear-notes通过 grizzly CLI 创建、搜索、管理 Bear 笔记
obsidian通过 obsidian-cli 操作 Obsidian vault(纯 Markdown 笔记)
notion用 Notion API 创建、管理 page、database、block
things-mac在 macOS 的 Things 3 里增删改查 todo、inbox、today、project、area、tag
trello通过 Trello REST API 管理 board、list、card
taskflow把多步骤分离任务编排成一份持久的 TaskFlow 作业,带 owner 上下文、状态、等待和子任务
taskflow-inbox-triageTaskFlow 的示例:邮件收件箱分流、意图路由、等回复、回头总结

IM 与社交

skill用途
imsg通过 Messages.app 读写 iMessage / SMS:列会话、看历史、发消息
bluebubbles通过 BlueBubbles 收发 iMessage,支持附件、tapback、编辑、回复、群聊
slack通过 Slack 工具发消息、贴 reaction、pin/unpin、改/删消息、查成员
discord通过 message 工具(channel=discord)做 Discord 日常操作
wacli通过 wacli 给第三方 WhatsApp 发消息或同步/搜历史(不接管你的活跃聊天)
xurl通过 xurl 认证后做 X 发帖、回复、搜索、DM、上传媒体、查粉丝等 v2 API 调用
voice-call通过 OpenClaw 的 voice-call 插件发起语音通话

邮件与办公

skill用途
himalaya用 himalaya 收发、搜、组织 IMAP / SMTP 邮件
gogGoogle Workspace 全家桶 CLI:Gmail、Calendar、Drive、Contacts、Sheets、Docs

编码、GitHub 与 OpenClaw 生态

skill用途
github用 gh CLI 处理 GitHub issue、PR 状态、CI/日志、评论、review、release 和 API 查询
gh-issues自动从 GitHub 拉 issue,派给子 agent 去修,开 PR,跟踪 review
coding-agent把编码任务派给 Codex、Claude Code、OpenCode、Pi,跑在后台进程里
skill-creator创建、编辑、改进、整理、审核、重构 AgentSkills 和 SKILL.md
clawhub和 ClawHub 注册中心交互,搜索、安装、更新、同步、发布 skill
mcporter通过 mcporter 列出、配置、认证、调用、查看 MCP server / 工具(HTTP 或 stdio)
oracle用 oracle CLI 打包 prompt 和文件喂给第二个模型做 debug、refactor、设计或审查
model-usage汇总 CodexBar 的本地成本日志,按模型拆 Codex 或 Claude 的当前/全部花销
session-logs用 jq 搜索分析自己的会话日志(更早或父级会话)

音频、视频与媒体

skill用途
spotify-player在终端里走 spogo(首选)或 spotify_player 控制 Spotify 播放和搜索
sonoscli控制 Sonos 音箱:发现 / 状态 / 播放 / 音量 / 分组
blucliBluOS CLI(blu),发现、播放、分组、调音量
songsee用 songsee CLI 给音频生成频谱图和特征面板
sherpa-onnx-ttssherpa-onnx 本地 TTS(离线、不走云)
sag用 ElevenLabs TTS,仿 macOS say 的 UX
openai-whisper本地 Whisper CLI 做语音转文字(无 API key)
openai-whisper-apiOpenAI Whisper API 转写音频
summarize用 summarize.sh 总结或转写 URL、YouTube 视频、播客、文章、转录稿、PDF 和本地文件
video-frames用 ffmpeg 从视频里抽帧或剪短片段
gifgrep在 GIF 提供方搜索(CLI/TUI),下载并提取静帧 / 拼图
camsnap从 RTSP / ONVIF 摄像头抓帧或录短片
nano-pdf用 nano-pdf CLI 通过自然语言指令编辑 PDF
blogwatcher用 blogwatcher CLI 监控博客和 RSS/Atom feed 的更新

搜索、信息与查询

skill用途
weather查天气、降水、温度、未来几天预报(出行用)
goplaces通过 goplaces 查 Google Places:文本搜索、地点详情、评论、脚本化 JSON
gemini用 Gemini CLI 做 one-shot 问答、总结、生成

系统、家居与杂项

skill用途
1password配置 1Password CLI:登录、桌面端集成、读取/注入 secret
healthcheck审计加固跑 OpenClaw 的主机:SSH、防火墙、更新、暴露面、cron、风险姿态
node-connect排查 Android / iOS / macOS Node 的配对、二维码、路由、认证、连接故障
tmux远程控制 tmux 会话,靠发按键和抓 pane 输出来驱动交互式 CLI
peekaboo用 Peekaboo CLI 抓屏和自动化 macOS UI
canvas在已连接的 OpenClaw Node(Mac/iOS/Android)上展示 HTML 内容,用来跑游戏、可视化、仪表盘
openhue通过 OpenHue CLI 控制 Philips Hue 灯和场景
eightctl控制 Eight Sleep 床垫:状态、温度、闹钟、日程
ordercliFoodora 订单 CLI:查历史订单、看活跃订单状态(Deliveroo 在开发中)

插件自带 skill 一览

除了 skills/ 目录下的内置技能,extensions/ 目录下的内置插件也会顺带捎上自己的 skill,截至本系列动笔时,共有 8 个内置插件贡献了 14 个 skill:

插件skill用途
acpxacp-router把用户的自然语言请求路由到合适的 ACP harness(Claude Code、Codex、Cursor、Gemini CLI、Kimi、Qwen 等),相当于 ACP 的路由器
browserbrowser-automationbrowser 工具控制网页时的操作手册:多步流程、登录检查、tab 管理、stale ref 恢复
diffsdiffsdiffs 工具生成真正可分享的 diff(viewer URL 或文件 artifact),而不是让 agent 手写「我改了哪些行」的总结
feishufeishu-doc飞书云文档读写、docx 表格创建
feishufeishu-drive飞书云空间的文件夹和文件管理
feishufeishu-perm飞书文档和文件的权限、协作者管理
feishufeishu-wiki飞书知识库导航
memory-wikiobsidian-vault-maintainer把记忆 wiki 维护成 Obsidian 友好格式:wikilink、frontmatter、obsidian-cli 配合
memory-wikiwiki-maintainer维护 OpenClaw memory wiki:确定性页面、托管块、源回溯更新
open-proseproseOpenProse VM 的 skill pack,处理 prose 命令和 .prose 文件,编排多 agent 工作流
qqbotqqbot-channelQQ 频道管理:列频道、子频道、成员、发帖、公告、日程
qqbotqqbot-mediaQQ 富媒体收发:图片、语音、视频、文件,靠扩展名自动识别
qqbotqqbot-remindQQ 定时提醒:一次性 + 周期性的创建、查询、取消
tavilytavilyTavily 网页搜索、内容抽取和研究类工具的入口

这里有两点值得留意。一是每个插件支持挂多个 skill,feishuqqbotmemory-wiki 都拆成了好几份,触发能更精准,用户问飞书权限只会激活 feishu-perm,不会把发文档的步骤一起喂进来,代价是 skill 列表涨得快,得靠 description 写得准来避免互相误触。二是这些 skill 都跟着所属插件走,不需要单独配置,如果插件没有启用,对应的 skill 也就不会启用。

Skill 实战

这一节我们以 summarize 技能为例,实战一下 skill 的用法。先看下它的 SKILL.md 头部声明了哪些东西:

---
name: summarize
description: Summarize or transcribe URLs, YouTube/videos, podcasts, articles, transcripts, PDFs, and local files.
homepage: https://summarize.sh
metadata:
  {
    "openclaw":
      {
        "emoji": "🧾",
        "requires": { "bins": ["summarize"] },
        "install":
          [
            {
              "id": "brew",
              "kind": "brew",
              "formula": "steipete/tap/summarize",
              "bins": ["summarize"],
              "label": "Install summarize (brew)",
            },
          ],
      },
  }
---

这块信息量浓缩了几条:

  • description:列了它能处理的输入类型(URL、YouTube、播客、文章、转录稿、PDF、本地文件),模型靠这一行决定是否进入;
  • homepage:给 UI 用,显示成「Website」链接;
  • emoji: 🧾:纯粹是给 Skills 列表前面挂个图标,不影响功能;
  • requires.bins: ["summarize"]:这份 skill 的启用条件只有一条,PATH 上必须有 summarize 这个二进制,否则 OpenClaw 加载时就把它过滤掉;
  • install:装这条二进制的官方建议,当你使用 openclaw configure 走 onboarding 流程时就会读这一段自动安装;

bundled skill 默认是启用的,只要上面的依赖满足了,该 skill 就能用。我们可以运行 openclaw configure 命令,自动安装依赖:

configure-skills.png

或者在 Control UI 的 Skills 列表中找到该 skill,点击安装:

summarize.png

也可以照着 install 那段,手动安装依赖:

$ brew install steipete/tap/summarize

安装完成后,检查 summarize 是否可用:

$ summarize --version
0.16.3

虽然该 skill 的 requires 里没讲,但实际上该 CLI 工具还需要配置一个模型的 API key,比如:

$ openclaw config set skills.entries.summarize.env.GEMINI_API_KEY "your-key-here"

改完配置要让 OpenClaw 起一个新会话才生效,skill 快照是按会话锁定的,老会话不会自动刷新。在聊天窗口里输 /new 起个新会话,或者直接重启网关:

$ openclaw gateway restart

接着到 Telegram 或飞书里跟小龙虾说一句:

帮我总结下这个视频:https://youtu.be/dQw4w9WgXcQ

agent 看到 URL 加上一个总结意图,于是命中 summarize 的触发条件,它会先使用 read 阅读该技能的 SKILL.md 文件,然后按照说明运行 summarize "<url>" --youtube auto 命令,再把结果整理一下回给我们。

我们可以在 Control UI 中看到执行流程:

summarize-telegram-toolcall.png

聊天界面大致是这样:

summarize-telegram.png

Skill 加载原理

实战完了之后,我们回过头看看 Skills 的工作原理。从 Gateway 启动到 skill 出现在对话里,整条链路画成图大概是这样:

skill-seq.png

整条链路其实就两件事:先把 skill 集齐(扫描 → 去重 → 过滤 → 注入),再把它装进对话(拼 XML → 进 system prompt → 按需 Read 正文)。我们一步步看:

  • 扫描:从前面讲过的 6 个来源各扫一遍,每个目录读 SKILL.md、解析 frontmatter,缺 namedescription 直接丢弃;
  • 合并去重:同名 skill 按优先级表保留高优先级的那一份;
  • gating 过滤:对每份 skill 核对 metadata.openclaw.requires.bins / env / config / os 等条件,不满足就当做不可用,直接踢出列表,这一步保证模型只看到当前真正跑的 skill,不会去尝试一个根本调不起来的命令;
  • per-agent allowlist 过滤:再按 agents.list[*].skillsagents.defaults.skills 把当前 agent 不让看的那些过滤掉;
  • env / apiKey 注入:对剩下的 skill,把 skills.entries.<name>.envapiKey 临时塞进 process.env,每轮 agent 会话结束再还原。

到这里,当前会话能用的 skill 集合就锁定了。接下来是它怎么进对话,OpenClaw 并不会把每份 SKILL.md 的正文都拼进 system prompt,而是拼成一段 索引型 XML 格式:

<available_skills>
  <skill>
    <name>summarize</name>
    <description>Summarize or transcribe URLs, YouTube/videos, ...</description>
    <location>/Users/.../skills/summarize/SKILL.md</location>
  </skill>
  ...
</available_skills>

每条只有三个字段:name 是身份description 是触发说明location 是 SKILL.md 的绝对路径。XML 前面还附了一段说明告诉模型:

When the task matches a skill's description, **use the read tool to load** the skill's file.

翻译过来就是:若某个 skill 的描述贴合当前任务,便循着它的 location,用 Read 工具取来正文细读。这本质上是和 Anthropic 那套渐进式披露同源的设计:只把摘要放进上下文,正文按需加载。

为了防止 description 太长把这层索引撑爆,OpenClaw 还留了个 compact 降级:如果整段 XML 超过 maxSkillsPromptChars 上限,就自动把 description 去掉、只留 name 和 location,模型仍然知道有这么一份 skill 存在,只是判断要不要打开时少了点上下文。降级时 prompt 顶端会附一行 ⚠️ Skills catalog using compact format,方便排查。

和 Anthropic 官方 Agent Skills 的区别

OpenClaw 的 skill 格式不是自己另发明的。SKILL.md 加 YAML frontmatter 这套,源头是 Anthropic 在 2025 年 10 月推出的 Agent Skills 规范,现在已经发展成一个被 Claude Code、Codex、Cursor 等一批工具共同采用的开放标准,OpenClaw 也完全兼容 Agent Skills 格式:一个目录、一份 SKILL.md、frontmatter 里必填 namedescription、正文是给模型看的操作说明,加载策略也都是渐进式披露,先只让模型看到 name + description,匹配上了再读完整正文。

但是两者在运行时机制上还是有些差别的:

  • Anthropic 官方走的是软门控:能不能用某个 skill 几乎完全看 description 写得准不准、模型自己怎么判断。它默认假设 agent 自带文件系统和代码执行环境,能自己决定什么时候去读更多、什么时候跑脚本,所以官方仓库里的 skill 常常做成「一份 SKILL.md + 一整套配套脚本和模板」的形态(references/scripts/ 这些子目录),运行时让模型把脚本通过 code execution 去执行。
  • OpenClaw 在它前面再叠了一层硬门控metadata.openclaw.requires.bins / env / config / os 在加载期就把跑不了的 skill 直接筛出去,根本不进 system prompt 给模型添乱;再叠一层 per-agent allowlist,让同一个网关上多个 agent 看到的 skill 列表完全不同;再叠一层中心化的密钥注入,把 skills.entries.<name>.apiKey 在一轮会话开始时塞进环境变量、结束再还原。

将两者的差异对比总结成表格如下:

维度Anthropic 官方 Agent SkillsOpenClaw
进上下文方式渐进式披露:description → 正文 → 附件渐进式披露之外多一层 compact 降级,预算紧时去掉 description 只留 name + location
触发门控主要靠 description 让模型判断description 之外还有 requires.bins/env/config、os 硬门控
来源与优先级personal / project / plugin 几个位置6 级 precedence 加 per-agent allowlist
密钥注入靠环境或容器自带中心化 skills.entries.env/apiKey,按 run 注入再还原
默认重心偏 bundled 脚本,使用 code execution 跑脚本偏纯 SKILL.md,指挥 agent 调 OpenClaw 自己的工具
额外入口以模型调用为主可同时是 slash command,还能 bypass 模型直派工具

这里也能看出 OpenClaw 的定位,它面向的不是「一个独立的 IDE 用户」,而是「一个常驻、多账户、多 agent 的网关」。

小结

回顾今天的学习内容,Skills 系统其实就一件事:给 agent 备一份操作手册,一个目录,一份 SKILL.md,文件头声明元数据,正文写操作步骤。

从加载到出现在对话里,一共六步:扫描所有来源目录、按优先级合并去重、按声明的依赖条件过滤掉跑不了的、注入配置里写好的环境变量、把每个 skill 的「名字 + 描述 + 路径」拼成一段索引塞进系统提示,最后由模型按需把对应的 SKILL.md 正文读进来。

这套设计和 Anthropic 官方的 Agent Skills 同源,核心都是按需加载,差别在于 OpenClaw 在模型自行判断之前,先按二进制、环境变量、配置项、平台是否满足做了一道硬筛,把跑不了的 skill 提前挡掉;又把所有密钥注入收到一处统一管理,既兼容开放标准,又留住了网关侧的治理。

到这里,我们对 OpenClaw 的 Skills 系统也学习的差不多了。不过内置的 53 个 skill 和插件捎进来的 14 个 skill 终究是官方给的,真正让 OpenClaw 生态长起来的,是一个公开的第三方 skill 注册中心,那就是 ClawHub。我们下一篇继续。

参考


让小龙虾给 Claude Code 派活:学习 OpenClaw 的 ACP 工具

用了好几篇把 OpenClaw 的内置工具箱挨个过完,又花了两篇专门讲浏览器,小龙虾「自己能干哪些活」到这里基本就讲齐了。今天换个方向,看它的另一项能力:收到任务后,它不亲自处理,而是把整个任务交给一个真正的外部编码 agent 去完成。

这事其实和之前「让小龙虾分身」那篇里讲的 sub-agents 有点像。当时我们看过,主 agent 跑到一半可以用 sessions_spawn 把子任务派给后台的子 agent。但那些子 agent 都是 OpenClaw 自己的 agent,同一套运行时、同一批工具、同一份 system prompt,本质上还是小龙虾在跟自己的分身协作。

ACP 要解决的是另一种诉求:派出去的不是 OpenClaw 子 agent,而是 Claude Code、Gemini CLI、Cursor、Codex 这些外部编码 harness。你在飞书上给小龙虾发一句「把这个仓库里的调试日志清理一下」,它接到之后并不自己写代码,而是在网关那台机器上拉起一个真正的 Claude Code 进程,让 Claude Code 去改文件、跑命令,干完再把结果播报回飞书。

OpenClaw 的文档和命令里把这些外部 agent 统称为 harness,本文后面也沿用这个叫法。

标准的 ACP 协议

动手之前,先花点篇幅认识一下 ACP 这套协议本身,它并不是 OpenClaw 自创的东西。

ACP 是 Agent Client Protocol(Agent 客户端协议) 的缩写,由开发 Zed 编辑器的团队提出并开源(Apache 许可),项目主页在 agentclientprotocol.com。它要解决的是一个典型的 N×M 问题:一边是越来越多的编辑器(Zed、JetBrains 系、各种 CLI),另一边是越来越多的 AI 编码 agent(Claude Code、Gemini CLI、Codex 等)。如果每个编辑器都要为每个 agent 单独写一套对接,每个 agent 又得反过来适配每个编辑器的私有接口,组合数量很快就会失控,用户也被绑死在某个特定的「编辑器 + agent」组合上。

熟悉 LSP(Language Server Protocol,语言服务器协议) 的同学对这个套路应该不陌生。当年 LSP 用一套标准协议把「编辑器」和「语言能力」解耦,任何编辑器配任何语言服务器都能用;ACP 想做的是同一件事,只是解耦的两端换成了「编辑器」和「编码 agent」。

协议本身定义了两个角色:

  • Client:通常是编辑器,或者任何想接入 agent 的宿主程序,它掌握着工作区、权限和界面。
  • Agent:真正干编码活的 AI 工具,比如 Claude Code。

技术实现上,ACP 走的是 JSON-RPC 2.0 over stdio:Client 把 Agent 作为一个子进程拉起来,双方通过标准输入输出收发 JSON-RPC 消息。一次典型的交互大致是:Client 先 initialize 握手,再用 session/new 开一个会话,然后把用户的指令通过 session/prompt 发过去;Agent 干活的过程中,靠 JSON-RPC 的通知把进展实时流式推回 Client,需要读写文件或执行命令时,则用反向请求回头向 Client 申请权限。整个过程中,掌权的始终是 Client:权限给不给、界面怎么渲染、能碰工作区里的哪些东西,都由 Client 说了算,Agent 只能请求、不能擅自越界。

把上面这条交互链画成时序图,大致是这样:

acp-seq.png

如果你了解 MCP(Model Context Protocol,模型上下文协议),会觉得上面这套流程很眼熟:同样是 JSON-RPC 2.0、同样把对方作为子进程通过 stdio 拉起来、同样有 initialize 握手和流式通知。这并不是巧合,ACP 在设计上就刻意向 MCP 看齐、复用它的传输模型。两者的区别在于解决的问题不同:MCP 标准化的是「agent 如何连上工具和数据源」,这时 agent 是 client,MCP server 提供工具;ACP 标准化的是「编辑器或宿主如何连上一个 agent」,这时编辑器是 client,agent 负责编码。它们还能叠在一起用:使用 session/new 开新会话时,可以顺带把要连的 MCP server 一起声明掉,让被拉起来的 ACP Agent 自己再去当一回 MCP Client,连上它需要的工具。

acp.png

讲清楚标准协议,再看 OpenClaw 的位置就顺理成章了:在 ACP 这套协议里,OpenClaw 扮演的是 Client 角色。平时 Zed、JetBrains 是用 ACP 在编辑器里接入 Claude Code,OpenClaw 则是在一个聊天网关里做同样的事。它通过官方的 @openclaw/acpx 后端插件,把 Claude Code、Codex、Cursor、Copilot、Droid、Gemini CLI、OpenCode、Qwen 等一长串 agent 当成子进程拉起来,让你可以通过小龙虾去跟它们对话。

值得注意的是,OpenClaw 反过来也能充当 server:openclaw mcp serve 把它暴露成 MCP server、openclaw acp 把它暴露成 ACP server 供外部客户端或 IDE 连进来,方向和本文讲的正好相反,感兴趣的同学可以尝试一下。

安装 acpx 插件

ACP 的后端是个独立插件,先把它装上并启用:

$ openclaw plugins install @openclaw/acpx
$ openclaw config set plugins.entries.acpx.enabled true

如果你是从源码 checkout 跑的 OpenClaw,仓库里的 extensions/acpx 就是 acpx 的工作区版本,pnpm install 装完依赖后这个插件直接可用,前面那条 openclaw plugins install 去 npm 拉发布版的步骤就不用跑了。

还有一步建议顺手做掉:ACP 默认拿 codex 当就绪探针,而本篇以 Claude Code 为主,所以把探针 agent 换成 claude

$ openclaw config set plugins.entries.acpx.config.probeAgent claude

配好之后,跑一下就绪检查:

$ /acp doctor

如果一切顺利,会显示 healthy: yes

acp-doctor.png

开始派活之前,还有一点要注意,各个编码 harness 得在宿主机上提前登录好。OpenClaw 只负责把 harness 进程拉起来,碰不到 harness 进程内部,所以这一步必须自己完成:要派 claude,宿主机上得先有 Claude Code 的登录态;要派 gemini,得配好 Gemini CLI 的认证;其它 harness 同理。

第一次派活

环境就绪后,我们走一遍最典型的流程:在飞书或 Telegram 这种真实 IM 频道里拉起一个 Claude Code,让它去改一个真实的仓库。

第一步,在飞书或 Telegram 里找一个 OpenClaw 已经接入的会话,发 spawn 命令:

$ /acp spawn claude --bind here --cwd /Users/zhangchangzhi/Codes/demo/sudoku

--bind here 是这条命令的关键,它把当前这个对话直接绑到新起的 ACP 会话上;--cwd 指定 Claude Code 干活的目录,要写完整的绝对路径。绑定一旦建立,这个会话里之后发的每一句话,都会被直接路由给 Claude Code,它的输出也回到同一个会话里。绑定之后,这个对话就成了你和那台机器上 Claude Code 的一条直连通道。

acp-spawn.png

--bind here 要求所在频道支持「当前对话绑定」能力。飞书、Telegram、Discord、Slack 这类 IM 频道都支持;本地的 webchat / TUI 没有这个抽象,跑这条命令会直接报 Conversation bindings are unavailable for webchat。这种情况要么改到 IM 频道里演示,要么去掉 --bind here、让 agent 自己用 sessions_spawn 把活派到后台跑。

接着就可以在这个对话里直接给它派活,跟平时用 Claude Code 没两样:

介绍下这个项目

acp-spawn-claude.png

可以看到,Claude Code 真的在那台机器上跑起来了:它读取文件,总结代码,过程和你在终端里亲自敲 claude 一模一样,只不过这一切是被小龙虾代理着,发生在聊天频道里。中途想看看它现在是个什么状态,随时一条 /acp status

$ /acp status

status 会把这个会话的后端、绑定的 harness、当前模式(persistent 还是 oneshot)、运行状态、各项运行时选项和能力都列出来;要是上一轮出过错,lastError 里也会留着。

任务做完后,收尾有两条命令:

$ /acp cancel   # 只中止当前这一轮,会话还留着,能接着发指令
$ /acp close    # 从 OpenClaw 视角结束会话并解绑当前对话

cancel 只在 harness 支持取消时中止当前会话轮次,它并不会删除绑定和会话元数据,停下来之后你还能继续给它发新指令。close 才是真正的结束,它从 OpenClaw 这边结束会话,解除当前对话的绑定。

非交互权限

在真正使用过程中,你会很快撞上 ACP 实战里最常见的一个坑。

接着上面那个例子,假设你在飞书里给绑定好的 Claude Code 派一句「帮我加一个计时器功能」,期待它直接动手改文件。结果会发现飞书这一头迟迟没有动静,过了一会儿就超时报错了。回到网关那台机器的终端一看,原来是 Claude Code 想写文件时弹了一条权限请求,一直在那等你按 y/n 确认:

[permission] Allow Edit src/i18n/locales/zh.ts [edit]?  (y/N)

Claude Code 要写文件的时候,按 ACP 协议会向 OpenClaw 这边的 Client 反向发出权限请求;OpenClaw 默认配置下需要询问用户,也不知道是不是 OpenClaw 的 bug,ACP 反向权限请求目前不会被转发到飞书 / Telegram 这些聊天频道,只会将弹窗渲染到了网关进程的本地 TTY 上,因此你在飞书里不会收到任何通知。也就是说,只要批准这一动作的人不在网关那台机器的 TTY 前,权限请求就没人能批。如果网关是以 daemon / systemd 这种无 TTY 方式启动,甚至会直接以 AcpRuntimeError: Permission prompt unavailable in non-interactive mode 报错中止。

下面是我以 TTY 方式启动网关后弹出的权限请求:

acp-edit-permission-tty.png

你需要一个个的按 y 确认,可以看到权限弹框和日志混在一起,既不直观,也不方便。为此,OpenClaw 提供了两个配置项管这件事:

  • permissionMode:粗粒度总开关,三种取值。approve-all 全部放行;approve-reads默认)只放行读,写操作和命令执行仍走审批;deny-all 把所有请求全部拒掉,实际上等于把 Claude Code 锁死,一般是用不到的。
  • nonInteractivePermissions:该开关只在 permissionMode=approve-reads 当前没有 TTY 时才生效。fail默认)直接以 AcpRuntimeError 中止会话;deny 把这次操作静默拒掉,会话仍然继续跑。

所以实际可用的组合其实就两种:

  • permissionMode=approve-all:让 harness 全自动改文件或跑命令,这也是最常见的设置;
  • permissionMode=approve-reads + nonInteractivePermissions=deny:不放权写操作但会话会正常进行;

对应的两条命令如下:

# 选项一:让 harness 全自动放行读写和 shell(破窗开关,请确认 cwd 和权限范围可控)
$ openclaw config set plugins.entries.acpx.config.permissionMode approve-all

# 选项二:不放权,但让它静默拒绝后继续,而不是让整个会话中止
$ openclaw config set plugins.entries.acpx.config.nonInteractivePermissions deny

选项一是把 permissionMode 调成 approve-all,让 harness 写文件、跑命令都不再过问。它是 ACP 会话的破窗(break-glass)开关,效果最直接,但也意味着这个会话能在 cwd 范围内不受限制地操作,开之前务必确认目录和权限范围是你能接受的。选项二保守得多,权限照旧不放,只是把 nonInteractivePermissionsfail 改成 deny,让那些过不了审批的操作被静默拒掉、会话继续往下走,至少不会因为一次写文件就让整个会话中止。

我这里选择选项一,就可以在聊天窗口里愉快的写代码了:

acp-edit-ok.png

要注意的是,关于 acpx 这套 harness 权限,和我们在工具篇讲过的 tools.exec.security 审批配置,是两套完全独立的东西。前者管 ACP harness 在它自己进程里的行为,后者管 OpenClaw 内置 exec 工具的审批,互不影响。另一个差异是:tools.exec.security 已经支持把审批弹窗推到飞书 / Telegram 让你点卡片,ACP 目前还没看到这样的实现。

命令速查

前面用到的 spawnstatuscancelclose 只是 /acp 的一部分。这里将全部子命令列出来供参考:

acp-commands.png

这里有几点值得展开说说。

其一,几乎每条命令后面都能跟一个目标参数,可以是会话 key、会话 id 或会话 label 三种写法。不带目标时,作用在当前绑定的会话上;想操作另一个会话,先 /acp sessions 把它的 key 或 label 列出来,再贴到命令后面。

其二,/acp spawn 有几个常用的参数:

  • --mode persistent|oneshot 决定会话生命周期
  • --bind here|off 决定要不要绑当前对话
  • --thread auto|here|off 决定要不要绑到「线程」
  • --cwd <path> 指工作目录
  • --label <name> 给会话起个好记的名字

会话生命周期有两种:persistent 开的是持久会话,spawn 完之后 ACP 会话一直留着,绑定的对话里之后发的消息都接着走它,直到你显式 /acp closeoneshot 开的是一次性会话,跑完当前轮次会话就自动收尾、元数据清掉,再发消息得重新 spawn;手动在对话里敲 /acp spawn 默认走 persistent,因为你这时通常想持续跟 harness 对话。

另外上面的「线程」指的是聊天频道对话内部的子会话面,每个频道叫法不同:Discord、Slack 里都叫 Thread(针对一条父消息开出的回复线索),Telegram 群里叫 Topic(开了 Topic 模式的群里那一栏栏话题),飞书群里也叫话题。三个取值的含义是:--thread off 不绑任何线程;--thread here 要求你当前就在某个线程里敲这条命令,会把那个线程绑到新 ACP 会话上,否则报错;--thread auto 自动判断,在线程里就绑当前线程,不在线程里则在频道支持的前提下新开一个子线程来承载这个 ACP 会话,原对话不受打扰。

要注意 --bind here--thread ... 不能各自独立生效:前者把整条对话(DM/群/频道本体)原地钉住、不另开任何线程,所有消息都直接走 ACP;后者只钉住一个子线程,原对话里其它消息照常进 agent。两者同时传时 --bind here 优先级更高,--thread 会被忽略。

其三,调参那几条命令背后其实是写运行时的配置项:比如 /acp modelmodel/acp permissionsapproval_policy/acp timeouttimeout,剩下叫不上名的就用通用的 /acp set <key> <value> 兜底,想一键还原就 /acp reset-options

两种派活方式

前面 /acp spawn 那个例子是人在对话里手动绑定,属于 ACP 的第一种派活方式:交互式绑定会话,它将当前对话钉到一个 ACP 会话上,之后这个对话里的消息直接转给 harness,输出回到同一通道。其实还有第二种派活方式,直接用自然语言的方式,让 agent 用 sessions_spawn 工具把活派给 Claude Code 去跑:

sessions-spawn-acp.png

我们可以打开 OpenClaw 的 Control UI 页面,找到这次对话:

webchat-tool-use.png

这里可以看到 agent 调用了两次工具:

第一次是 Read,读的是 ~/.openclaw/plugin-skills/acp-router/SKILL.md,这是 OpenClaw 内置的「ACP 派活路由」skill 文件,agent 在动手 spawn 之前先翻了下手册看该怎么派(skill 体系下一篇会专门讲)。

第二次才是真正干活的 sessions_spawn,传入的参数如下:

{
  "mode": "run",
  "agentId": "claude",
  "runtime": "acp",
  "task": "..."
}

这本质上是个后台子任务,每次运行会生成一条 background task 记录,和前面讲的子 agent 用的是同一个工具、同一套机制,只是将参数 runtime 换成了 acp(默认值是 subagent)。和子 agent 一样,后端的 harness 运行时不会卡住主会话,运行结束后会自动通知到当前会话。可以将它和子 agent 放一起对照一下:

acp-vs-subagents.png

参数 agentId 用于指定派给哪个 harness,省略时会用配置里的 acp.defaultAgent(如果设了的话)。常用的几个如下:

harness-id.png

除这些之外,清单里还有 iflowkilocodekiropi,以及一个比较特别的 openclaw,它走的是 openclaw acp 桥接,让一个支持 ACP 的 harness 反过来连回 OpenClaw 的会话。它们都可以作为 /acp spawn <id>sessions_spawn({ runtime: "acp", agentId: "<id>" }) 的目标。

参数 mode 和前面 /acp spawn--mode 是同一件事换了个名字,run 对应 oneshot、session 对应 persistent,后者还得配合 thread: true 才能真的留住绑定。

参数 task 值得注意,我们原话只是「总结一下这个项目」这种口语化指令,agent 派给子任务时却自己把它扩成了一段结构化的英文 prompt:

Summarize this project at /Users/zhangchangzhi/Codes/demo/sudoku. Focus on:
1. What the project does
2. Project structure and key files
3. Main technologies used
4. How to run it

Return a clear, concise summary.

这正是 sessions_spawn 设计上鼓励的做法:父 agent 拿到模糊指令后自己先把它翻译成清晰、可执行的任务描述,再用 task 字段独立派出去,子会话拿到的是一份完整自洽的任务说明,不需要把整段聊天上下文一起 fork 过去。

进阶用法

通过上面的学习,我们已经将 OpenClaw 的 ACP 工具基本流程跑通了,除了上面介绍的内容,ACP 还有一些进阶玩法,感兴趣的同学可以尝试一下。

MCP 桥接

默认情况下,OpenClaw 的内置工具和插件工具并不会暴露给 ACP harness。Claude Code 在 ACP 会话里用的还是它自己那套原生工具,碰不到小龙虾的 cronmessage 这些。如果你确实想把 OpenClaw 的某些能力透出去给 harness 用,acpx 提供了两个默认关闭的 MCP 桥接开关:

# 把已启用的「插件工具」透给 harness
$ openclaw config set plugins.entries.acpx.config.pluginToolsMcpBridge true
# 把选定的「内置核心工具」(目前是 cron)透给 harness
$ openclaw config set plugins.entries.acpx.config.openClawToolsMcpBridge true

打开后,acpx 会在 ACP 会话启动时分别注入一个名为 openclaw-plugin-toolsopenclaw-tools 的内置 MCP server,harness 就能通过 MCP 调到这些工具了。要注意,这等于扩大了外部 harness 的能力面,开之前最好先盘一遍当前装了哪些插件。把插件工具透给 harness,相当于让 harness 拥有了和这些插件在 OpenClaw 内部执行同等的信任级别。

常驻绑定

前面 /acp spawn --bind here 是临时绑定,对话一关、会话一 close 就没了。如果你想要的是长期效果,比如 Discord 上那个 #codex 频道,以后所有消息都直接进一个常驻的 Codex ACP 会话,可以直接在配置里声明一条 type: "acp" 的绑定:

{
  agents: {
    list: [
      {
        id: "codex",
        runtime: {
          type: "acp",
          acp: { agent: "codex", backend: "acpx", mode: "persistent", cwd: "/workspace/repo" },
        },
      },
    ],
  },
  bindings: [
    {
      type: "acp",
      agentId: "codex",
      match: { channel: "discord", accountId: "default", peer: { kind: "channel", id: "222222222222222222" } },
      acp: { label: "codex-main" },
    },
  ],
}

这样配好之后,那个频道就成了一个常驻的 Codex 工作台,不用每次手动 spawn。它和我们在「让小龙虾分身」那篇讲的普通 type: "route" 路由规则差不多,只是把目标换成了一个 ACP 运行时。

小结

最后,我们来总结下今天学习的内容:

  1. ACP 是把活派给外部编码 agent 的标准协议。它由 Zed 团队提出并开源,用来解决编辑器和编码 agent 之间 N×M 对接的麻烦。OpenClaw 在这套协议里扮演 Client 的角色,通过 acpx 后端插件把 Claude Code、Gemini CLI、Codex 这些 harness 当成子进程拉起来,让你在飞书 / Telegram 这种聊天频道里就能调度它们。
  2. 环境准备并不复杂。把 acpx 装上、启用,把就绪探针指到你常用的 harness,跑一次 /acp doctor 看是否正常即可。harness 自己的厂商登录得在网关那台机器上提前准备好,OpenClaw 只管把进程拉起来,碰不到它们内部。
  3. 派活有两种姿势。你本人想在某个聊天频道里持续盯着 harness 干活,就 /acp spawn --bind here 把整个对话接到它上面;让另一个 agent 在自己轮次里甩一个后台任务出去,则用 sessions_spawn 把 runtime 换成 acp,按后台 task 跑完播报回来。
  4. 非交互权限是头号坑。ACP 的反向权限弹窗目前不会被转发到聊天频道,所以远程操作时几乎都得把 acpx 的 permissionMode 调成 approve-all,让 harness 在你给的工作目录里自由读写。
  5. 两个进阶用法:MCP 桥接能把 OpenClaw 的工具透给 harness 调用;常驻绑定能把一个聊天频道钉到某个 ACP 会话上当工作台用。

到这儿,小龙虾的工具体系算是彻底讲完了:内置工具让它能直接动手处理各种操作,浏览器是其中能力最强的一种,ACP 则是把整个任务交给外部 agent 来完成的方式。不过,工具和外部 agent 终究只是一个个单独的能力,agent 接到一个稍复杂的任务,该按什么顺序调用、中间出错怎么回退、什么场景触发哪套流程,光靠模型临场发挥并不稳定,它还需要一份事先写好的操作手册,这就是 OpenClaw 的 Skills 系统。我们下一篇继续~

参考


给小龙虾配个浏览器:学习 browser 工具(二)

上一篇我们把 browser 工具的运行环境从头捋了一遍:首先学习了它的参数定义,然后使用 host / sandbox / node 确定浏览器运行在哪里,以及使用 profile 确定运行哪个浏览器。环境备齐,这一篇书接上文,来看 agent 怎么驱动浏览器在页面上点点点,以及这套动作背后的原理。

标签页管理

browser 一次只在一个标签页上干活,所以为了保证后续的动作没问题,我们首先要认准操作哪个标签页。我们可以使用 openclaw browser <动作> 命令行来管理它,开新标签页用 open,网址作参数,再用 --label 顺手贴个标签:

$ openclaw browser open https://www.aneasystone.com --label blog

opened: https://www.aneasystone.com/
tab: t1
label: blog
id: 7E73979D64CBF48058818D78D50FB94B

该命令返回了几个参数:tab 是形如 t1 的稳定 tabIdlabel 是我们自己起的 blog 标签,id 则是 CDP 协议的原始 targetId。这几个参数都可以作为后续操作这个标签页时的标识。比如之后想再操作它,用 focus 把它切到前台就行,tabIdlabeltargetId 这三种都认,下面三条指向的都是同一个页面:

$ openclaw browser focus t1          # 用 tabId
$ openclaw browser focus blog        # 用 label
$ openclaw browser focus 7E73979D    # 用 targetId(前缀也认)

顺带分清一个常被搞混的点:open 每次都新开一个标签页,而 navigate 是让当前标签页原地跳到另一个网址,所以常见用法是先 focus 选中、再 navigate 到某个网址;也可以加上可选的 --target-id 直接跳转:

$ openclaw browser navigate https://www.aneasystone.com/about-me.html --target-id blog

再开一个不带 label 的,然后用 tabs 列出所有的标签页:

$ openclaw browser open https://www.baidu.com

opened: https://www.baidu.com/
tab: t2
id: 9B0D7E3F1A2C4856E7F0A1B2C3D4E5F6

$ openclaw browser tabs

1. aneasystone's blog [t1 label:blog]
   https://www.aneasystone.com/
   id: 7E73979D64CBF48058818D78D50FB94B
2. 百度一下,你就知道 [t2]
   https://www.baidu.com/
   id: 9B0D7E3F1A2C4856E7F0A1B2C3D4E5F6

我们还可以加一个 --json 参数,看到完整的数据结构:

$ openclaw browser --json tabs

{
  "tabs": [
    {
      "targetId": "7E73979D64CBF48058818D78D50FB94B",
      "title": "aneasystone's blog",
      "url": "https://www.aneasystone.com/",
      "wsUrl": "ws://127.0.0.1:18800/devtools/page/7E73979D64CBF48058818D78D50FB94B",
      "type": "page",
      "suggestedTargetId": "blog",
      "tabId": "t1",
      "label": "blog"
    }
  ]
}

收尾要关掉标签页就用 close 参数:

$ openclaw browser close blog

closed tab

如果忘记关也没有关系,OpenClaw 给主 agent 的浏览器会话配了一套自动清理机制:空闲超过一定时间(默认 120 分钟)的标签页会被回收,每个会话还有个标签页数量上限(默认 8 个),后台每隔几分钟扫一遍;子 agent、cron、ACP 这类任务跑完时,也会顺手把自己开的标签页关掉,不至于在后台留一堆的孤儿窗口。

SSRF 防护

刚才用 opennavigate 打开网址,看着什么链接都能打开,其实不然。之前学习工具箱时就提到过,web_fetch 自带一套 SSRF 防护:凡是能由外部输入决定去访问哪个地址的工具,都得防着被人诱导去访问内网。浏览器更是如此,它能被导航到任意 URL,风险敞口只大不小,所以 OpenClaw 也给它单配了一套独立的、默认 fail-closed 的 SSRF 防护,下面就稍微展开看看。

OpenClaw 会在导航和新开标签页之前先过一道 SSRF 检查,等浏览器真正解析出最终那个 http(s) 地址之后,再复查一遍。这一前一后是有讲究的,专门防 30x 重定向绕过:链接乍看是个干净的公网地址,轻松过了第一关,跳转之后才露出内网地址,正好被第二道复查逮住。默认拦在门外的,包括私网地址、本机环回、link-local,以及云厂商的元数据地址。

那真要放行某个内网地址呢?最稳妥的做法是只给信得过的那几个域名开放,把它们加进 browser.ssrfPolicy 的白名单。比如想让 agent 访问内网的 grafana.corp.example,配置写在 ~/.openclaw/openclaw.json 里:

{
  browser: {
    ssrfPolicy: {
      // 精确匹配:列出的域名原样放行
      allowedHostnames: ["grafana.corp.example"],
      // 通配匹配:* 能匹配子域,一条顶一片
      hostnameAllowlist: ["*.corp.example"],
    },
  },
}

这两个参数的差别只在匹配方式:allowedHostnames 按完整域名精确比对,hostnameAllowlist 支持 * 通配符。命中白名单的域名,即便最终解析到私网地址,也照样放行。

要是内网地址比较多、一个个域名往白名单里加嫌麻烦,也可以用一个开关把私网整个放开:

{
  browser: {
    ssrfPolicy: {
      dangerouslyAllowPrivateNetwork: true,
    },
  },
}

不过光看名字里那个 dangerously 就该警觉:它会让浏览器对所有私网地址都不再拦截,所以默认是关着的,只有在你确实信任、也评审过的私网环境里才建议打开。

页面操作

标签页开好了,接下来就是在这个页面上干活。browser 在页面这一层的动作不少,但总体来说无非是看、做、核对三件事:先用 snapshot 看清页面上有什么,再用 act 系列动手操作,需要时用 screenshot 截图核对;除此之外还有几个偏辅助的动作。下面一类一类来看。

看清页面:snapshot

OpenClaw 在看清页面这块做得比较巧妙,它不会把页面的原始 HTML/DOM 一股脑塞给模型。原因很简单,网页源码又长又乱,满屏 <div><div>、一堆样式类名,模型读起来既理不清结构,又白白烧掉一大把 token。

那它给模型看什么呢?是浏览器的无障碍树(accessibility tree)。这名字听着陌生,其实天天都有人在用:盲人靠屏幕阅读器读网页,背后就是这棵树。它不管页面长什么样,只按语义把内容归成按钮、输入框、链接、标题这些角色,每个角色再配一个看得懂的名字(被称为 accessible name,一般就是按钮上的文字、输入框旁边的 label 等),父子层级用缩进表示。说白了,它把一个给人看的花哨页面,压缩成了一份给机器读的、干净的结构清单。

OpenClaw 又在这棵树上加了道工序:给每个能点、能填的节点编一个稳定的编号,也就是 ref。这么一来,模型根本不用管某个元素长什么样、藏在第几层,认准要操作哪个 ref 就行。快照的结果大致长这样:

- button "Save" [ref=e1]
- link "Docs" [ref=e2]:
  - /url: https://docs.openclaw.ai/
- generic "Clickable Card" [ref=e3] [cursor=pointer]
- textbox "Email" [ref=e4]

每一行就是「角色 + 名称 + [ref=eN]」:链接底下会挂一个 /url: 标出它的目标地址,可点击的非标准元素则注明 [cursor=pointer](表示它虽然不是标准的按钮或链接,但点得动)。碰到 iframe,则嵌套着往里展开:

- Iframe "Child" [ref=e1]
  - button "Inside" [ref=e2]

模型拿到这棵树,想点「Save」,只回一句 act 带上 ref=e1 就行,完全不用关心它在 DOM 里到底是第几个 <button>、外面套了几层 div。这背后是 OpenClaw 的一个刻意设计:点击、输入这类操作只认 ref,不收 CSS selector。原因是 selector 出了名的脆,页面结构稍微一改,像 .btn-primary > span:nth-child(2) 这种立马失效;换成语义稳定的 ref,多步操作才不容易因为页面的一点微调就整条链路崩掉。

你可能注意到 browser 工具里有个 selector 参数,但它管的不是「点哪个元素」,而是另外几件事:给快照划定范围(snapshot 时只取某个子树或某个 iframe)、截取单个元素(screenshot --element)、或者等某个元素出现(wait)。

知道快照长什么样,命令行里 snapshot 一下就能看到真东西。还是上一节那个博客标签页,输出就是上面那种带 ref 的缩进树:

snapshot-ai.png

上面这份输出走的是 snapshot 默认的 ai 格式。其实 snapshot 支持两种格式,用 format 参数切换:

  • ai(默认):给模型优化过的形态,就是上面那种缩进表示层级、每个可交互节点都带 ref 的文本树。简洁好读,模型能直接挑出 ref 来操作,日常驱动页面用它就够了。
  • aria:用 --format aria 切过去,吐出的是更原始的无障碍树节点,结构上更贴近浏览器内部,主要拿来排查页面结构,refs 不一定能直接拿去点。

snapshot-aria.png

说到底,aria 就是那棵原始的无障碍树,ai 则是 OpenClaw 在它基础上加工出来的好读版本(编上 ref、补上链接和可点击提示)。

默认情况下 ai 快照不会主动精简,复杂页面拉出来的树有时相当庞大,所以 snapshot 还留了几个参数专门给它瘦身。最基础的是 maxChars(默认 4 万)这个字符上限兜底,超出就自动截断;如果还是嫌它啰嗦的话,可以加 compact 拉一份精简版,把没名字的纯容器节点、以及底下不含任何可操作元素的空枝条都剪掉,只留有内容的骨架,或者用 interactive=true 只留按钮、链接、输入框这类能交互的节点,把纯展示用的文字一股脑滤掉;页面层级套得太深时,还能用 depth 限制往下钻几层。

ref 又是怎么映射回真实元素的呢?这取决于 refs 参数选的风格。OpenClaw 支持两种 ref 风格:默认的 role 风格里,e1e2 这些编号背后其实存着「角色 + 名称 + 第几个」这组信息,OpenClaw 收到 ref=e1,就把它翻译成 Playwright 的 getByRole("button", { name: "Save" }) 去重新定位;另一种 aria 风格走的是 Playwright 原生的 aria-ref,编号形如 ax1,OpenClaw 会通过 CDP 给对应的 DOM 节点打上一个 data-openclaw-browser-ref 标记属性,靠这个属性精确命中。

不管哪种风格,都要注意的是,ref 只对最近一次快照有效。每取一次新快照,旧的那张 ref 表就被整张覆盖。所以页面一旦变了,如果没有重新 snapshot 就拿着老 ref 去操作,多半会遇到 Unknown ref "e1". Run a new snapshot and use a ref from that snapshot 这样的报错,这时就得老老实实重拍一张了。

操作页面:act

看清了页面结构,接下来就能用 act 动手操作页面了。它本身是个分派器,要执行哪种动作由 kind 参数决定;命令行里把这些 kind 拆成了一个个独立的子命令,对应关系大致如下:

kind.png

我们挑几个重点的看下。

click

最常见的就是拿 ref 点一下,比如点击上面快照里那个「归档」链接:

$ openclaw browser click e13

clicked ref e13

这个命令默认是左键单击,除此之外,还可以实现 --double 双击、--button right 唤出右键菜单、--modifiers Meta(或 Control)按住某个键再点击,能在新标签页打开链接。另外,如果某个要点击的元素快照不出来或点不到(比如被遮挡、藏在 shadow DOM 里、和其他元素的 z-index 冲突),我们还可以使用 clickCoords 这个参数,它不按 ref 来点击,而是直接按视口坐标 click-coords 120 340 点击。

这里还有一个挺关键的细节:click 点击后不会立刻返回,而是检查一下页面有没有因为这次点击发生跳转,一旦跳了,就把新地址带回结果里,并对这个新地址补一道 SSRF 复查。

type / fill

type 往输入框里打字,最常见就是 type <ref> "文本"。它还有两个开关:--slowly 让它放慢、一个字一个字地敲,装得像真人在打字,可以绕过那些根据输入速度判人机的站点;--submit 则在打完之后顺手回车,省得再单独按一下 Enter:

$ openclaw browser type e4 "openclaw" --submit --slowly

有时候要一次填一整张表单,一个个 type 就太慢了,可以用 fill 把多个字段打包进一次调用:

$ openclaw browser fill --fields '[{"ref":"e4","value":"Ada"},{"ref":"e5","value":"ada@example.com"},{"ref":"e6","type":"checkbox","value":true}]'

fill 一次能填一整批字段,每个字段可以带个 type 说明控件类型:默认 text 直接填值,标成 checkboxradio 就改成勾选(值传 true 勾上、false 取消)。

顺带说说 typefill 底下的实现,它们其实默认调的都是 Playwright 的 fill() 方法,把值一次性整体填到输入框里;而 type --slowly 使用的是 Playwright 真正逐字符的 type() 方法(每个字停 75ms);fill 碰到 checkbox、radio 则走的是 setChecked 方法。

select

这个命令用于选择下拉框里的选项,一次选一个或多个都行:

$ openclaw browser select e7 cn

selected cn

它底层走 Playwright 的 selectOption 方法,多选下拉一次能选好几项(select e7 a b c 就是把 a、b、c 一起选上)。传进去的字符串会同时匹配 <option>value 和显示文字,填哪个都行。

那怎么知道一个下拉里有哪些选项可选?snapshot 里下拉通常会把 option 一项项列出来,名字就是它的显示文字,照着填即可:

$ openclaw browser snapshot

- search [ref=e45]:
  - textbox "请输入关键字" [ref=e46]
  - combobox [ref=e47]:
    - option "所有" [selected]
    - option "中国"
    - option "美国"
    - option "日本"
  - button "筛选" [ref=e48] [cursor=pointer]

要是想拿到确切的 value 值(比如显示的文字有重复时),可以用 evaluate 针对 DOM 执行一段 JS 即可:

$ openclaw browser evaluate --ref e47 --fn '(el) => [...el.options].map(o => ({ value: o.value, label: o.text }))'

[
  {
    "value":"cn",
    "label":"中国"
  },
  {
    "value":"us",
    "label":"美国"
  }
]

press

直接敲键盘,比如回车、Tab、方向键、Esc 这些都行,常用来在没有明确按钮时触发提交或翻页:

$ openclaw browser press Enter

pressed Enter

它直接调 Playwright 的 keyboard.press 方法,除了单个键,也能按组合键,比如 press Control+A 全选、press Shift+Tab 反着跳焦点。另外 pressclick 一样会做导航检测:按 Enter 很可能提交表单、触发跳转,OpenClaw 会盯着这次跳转,照样走一遍前面那套 SSRF 复查。

hover

把鼠标悬到某个元素上,常用来触发那种鼠标悬停才显示的下拉菜单、提示气泡:

$ openclaw browser hover e5

hovered ref e5

底层是 Playwright 的 locator.hover,只改 UI 状态、不触发导航。它最常见的搭配是先 hoversnapshot:很多菜单是悬停才渲染出来的,先让它冒出来、再拍一张快照,才能拿到菜单项的 ref 去点。

drag

从一个 ref 拖到另一个 ref,做拖拽排序、移动滑块这类操作:

$ openclaw browser drag e3 e8

dragged e3 → e8

它接收起止两个 ref,首先自动算好坐标,底层调 Playwright 的 dragTo 方法,然后模拟按下、移动、松开这一整套动作。拖拽排序、移动滑块、把文件拖到上传区这类交互都可以靠它来实现。

resize

改视口尺寸,用于测试页面在不同屏幕宽度下的响应式表现:

$ openclaw browser resize 1280 720

resized to 1280x720

底层是 Playwright 的 setViewportSize 方法,改完视口浏览器会重新布局,并触发 CSS 媒体查询,所以拿它配合多次 snapshot / screenshot,就能看页面在手机、平板、桌面不同宽度下分别长什么样。

wait

多步操作里最容易翻车的,就是上一步点完、页面还没加载好,就急着点下一步,结果扑空。browserwait 命令就是用来避免这种情况的,它能等到某个具体状态再往下走:等某段文字出现(或用 --text-gone 等它消失)、等 URL 匹配某个 glob、等加载状态(load / domcontentloaded / networkidle)、等某个 CSS 元素可见、甚至等一段 JS 谓词为真。每个条件对应一个参数,可以单用,也能叠着用:

$ openclaw browser wait --text "上传完成"              # 等某段文字出现
$ openclaw browser wait --text-gone "上传中"           # 等某段文字消失
$ openclaw browser wait --url "**/dashboard"           # 等 URL 匹配某个 glob
$ openclaw browser wait --load networkidle             # 等加载状态(load/domcontentloaded/networkidle)
$ openclaw browser wait "#main"                         # 等某个 CSS 元素可见
$ openclaw browser wait --fn "window.ready === true"   # 等一段 JS 谓词为真

# 叠着用:下面几个条件全部满足才往下走
$ openclaw browser wait "#main" --url "**/dashboard" --load networkidle

比起等一个固定的时长,这样等到确切状态再继续,要可靠得多。

注意这里的 "#main" 是个 CSS selector,不是 ref。前面说过点击、输入这些操作只认 ref,但是 wait 则是少数几个例外之一。道理也好理解:你要等的元素往往还没出现在页面上,自然没进快照,也就没有 ref 可用,只能拿 selector 去等。

evaluate

它能往页面里注入一段 JS 脚本并运行,再把结果回传给你。它有两种用法:不带 ref 时,函数跑在整个页面的上下文里;带上 ref 时,OpenClaw 会把那个元素当参数喂进你的函数。

比如不带 ref,取一下当前页面的标题:

$ openclaw browser evaluate --fn '() => document.title'

aneasystone's blog

带上 ref,就能针对某个元素读数据,比如把一个链接喂进去,读出它的真实地址(前面 select 那节列出下拉有哪些选项,用的也是这招):

$ openclaw browser evaluate --ref e2 --fn '(el) => el.href'

https://www.aneasystone.com/archives.html

这等于在页面里开了个口子,能跑任意 JS、做的事几乎没有限制,威力大、风险也大。所以 OpenClaw 允许把它一键关停,在 ~/.openclaw/openclaw.json 里把 evaluateEnabled 设成 false

{
  browser: {
    evaluateEnabled: false,
  },
}

关掉之后再调 evaluate,会返回一个 ACT_EVALUATE_DISABLED 错误。要留意的是,这开关一关,连 wait 里那条 JS 谓词(--fn)也一并禁了,因为它俩底层都是在页面里执行你给的代码。

截图核对:screenshot

想把当前页面存成一张图片,或者让人眼或视觉模型核对一下前面那些操作的结果,就用 screenshot 命令。它截的是真实像素,返回截图文件的本地路径。最常用的是 --full-page,把整页连同滚动区域一起截下来:

$ openclaw browser screenshot --full-page

MEDIA:~/.openclaw/media/browser/6a8827a2-e75e-4c9a-b3de-3949d4b12683.jpg

除了整页,也能只截某一个元素,有两种指定方式:--ref 指定快照里的 ref--element 指定 CSS selector:

$ openclaw browser screenshot --ref e13          # 截快照里的某个 ref
$ openclaw browser screenshot --element "#main"  # 截某个 CSS 元素

另外,截图存成什么文件格式,可以用 --type 指定,支持 png(默认)和 jpeg 两种。

不过你可能注意到了,上面截全屏时我们并没有指定 --type,按说该存成 png 的,但是最终生成的却是 .jpg 文件。这是因为 OpenClaw 存图前会先做一道归一化:截图最长边超过 2000 像素、或者体积超过 5MB 时,就自动缩放并转成 JPEG,按先压边长、再降画质的顺序依次尝试,直到压到 5MB 以内。整页截一张长页面很容易超,于是被转成了 JPEG 格式。这道工序是为了不让一张超大图占掉模型一大截上下文,顺带也减轻传输和存储的负担。

还有个给视觉模型量身定制的参数 --labels,它会在截图上把每个 ref 的位置用橙色方框圈出来、标上编号,这样视觉模型不光看得到画面,还能照着编号说出要操作哪个 ref

screenshot-labels.jpg

它只标当前视口里看得见的元素,滚动条以外的会跳过,而且最多标 150 个,免得整张图被标签填满。

辅助动作

除了上面这些主力动作,browser 还有几个偏辅助的 action,平时用得不算频繁,但偶尔可以用来救急:

  • console 把页面控制台的日志读出来,配上 level 还能按级别(errorwarning 等)过滤,调试页面脚本时有用;
  • pdf 把当前页导出成 PDF,适合存档或者把长报告整页留底,注意这是托管 profile 才有的能力,也依赖 Playwright;
  • upload 处理文件上传,要传的本地文件写进 paths,再用 inputRef 指准页面上那个文件选择框;
  • dialog 应付 alert / confirm / prompt 这类原生弹窗,accept 决定点「确定」还是「取消」,prompt 弹窗要填的内容则写在 promptText 里。

小结

讲了这么多,browser 其实还有不少细节值得你接着挖。比如它是个能伪装的环境:地理位置、时区、语言、设备类型都能改,还能切到离线模式、直接读写 cookie 和 localStorage,拿来测网站在不同环境下的表现正合适;比如它带熔断,某个 profile 的 Chromium 反复起不来时,OpenClaw 会按 profile 暂停一阵子启动尝试,免得一个配置损坏的浏览器把网关反复拖垮;又比如浏览器插件除了 browser 工具,还顺手捎了一份 browser-automation 技能,把前面那套先看、再动、变了重看的循环写成给 agent 看的操作手册,而且按需加载、平时不占 system prompt 的篇幅。这些就留给你自己去探索了。

最后,我们再来总结下今天的学习内容:

  1. 认准标签页:browser 一次只盯着一个标签页干活,所以动手之前得先认准目标。open 开新页、focus 切到前台、navigate 原地跳转、tabs 列清单、close 收尾,这几条命令都认 tabId、label、targetId 三种标识;就算忘了关,后台也有一套自动清理在兜底。
  2. SSRF 防护:不是什么 URL 都能打开。browser 单配了一套默认 fail-closed 的 SSRF 防护,导航前后各查一遍以防重定向绕过,私网、本机环回、云厂商元数据这些地址一律拦在门外;真要放行内网,优先用 allowedHostnames、hostnameAllowlist 开个窄口子,而不是把整片私网敞开。
  3. 看清页面(snapshot):它不把原始 DOM 丢给模型,而是走无障碍树,把页面压成一棵带 ref 的语义树。默认的 ai 格式好读、能直接挑出 ref 来操作,aria 格式更原始、适合排查结构;遇上复杂页面,还能用 compact、interactive、depth 几个参数瘦身省 token。
  4. 操作页面(act):动手的活儿都收在一个 act 里,靠 kind 分派成 click 点击、type / fill 输入填表、select 选择、wait 等待、evaluate 跑 JS 等一整套动作,底层大多落到 Playwright 的对应方法上。
  5. 核对与救急:screenshot 截的是真实像素,存图前会自动归一化,把尺寸和体积压进 2000 像素、5MB 以内;另外还有 console、pdf、upload、dialog 几个辅助动作,平时用得不多,调试、存档、传文件、应付弹窗时拿来救急。

至此,OpenClaw 工具箱里最复杂的 browser 就算彻底过完了。下一篇我们换个方向,看另一类「派活儿」的本事:通过 ACP,把整个任务直接甩给 Claude Code、Gemini CLI、Codex 这些外部编码 agent 去跑,敬请期待~

参考