首页
/ Caveman Browse 技术解析:基于 CDP 可访问性树压缩与 CCR 恢复的本地浏览器 MCP 服务

Caveman Browse 技术解析:基于 CDP 可访问性树压缩与 CCR 恢复的本地浏览器 MCP 服务

2026-09-06 20:46:03作者:舒璇辛Bertina

caveman-browse 是 Caveman 项目中的本地浏览器交互 MCP 服务器:它附着到一个真实 Chrome 进程,通过 CDP 读取 Accessibility.getFullAXTree,用 Caveman 引擎的强制式 a11y 压缩器把原始可访问性(AX)树压缩为紧凑的 [uid] role "name" 视图,同时由 CCR(Cache & Recover)存储保留字节级完整的原始载荷供精确恢复。读完本文,你将掌握它的四个 MCP 工具(browser_snapshot / browser_act / browser_eval / browser_recover)的完整参数语义、query 聚焦大页面的工作原理、直连 CLI 模式下分离式 Chrome 的共享机制,以及它刻意保持的诚实边界——所有 token 节省量均为 inferred(本地计数推断),而非账单事实。

产品定位与核心数据流

根据 browse/README.md 的定义,caveman-browse 是一个 local browser-interaction MCP server,其工作链路为:

  1. 驱动层cdp.go 基于 chromedp 启动或附着 Chrome,Snapshot 时按需导航并调用 accessibility.GetFullAXTree(),得到完整的 AX 节点 JSON(见 cdp.go);
  2. 压缩层:原始载荷进入引擎的 Compress 调用,且显式强制 Options{Mode: ModeCompress, Type: TypeA11y, Query: query}——a11y 类型从不由自动检测路由,调用方必须显式指定(见 session.goengine/compressors/axtree.go 的注释 "Detect never routes here; callers must force");
  3. 恢复层:CCR 存储保留原始 AX 字节,快照结果携带 recovery_handlebrowser_recover 用该 handle 返回字节级一致的原始载荷(见 session.goeng.RetrieveQuery);
  4. 计数层tokens_before / tokens_after / ratio 全部来自引擎本地计数器,basis 字段恒为 inferred(定义于 engine/result.goBasisInferred),Browse 永远不输出 verified

一个重要的设计契约在 browse/CLAUDE.md 中写明:uid 映射表是 browser_snapshot 的契约,而不是压缩率的副产物。当引擎未产生恢复句柄(树没有变小、或没有 CCR store)时,快照必须失败关闭并报错 cave_browser_snapshot_uncompressed,绝不能把原始 AX 树直接倒进 uids 字段,也不能清空上一次页面的 uid 缓存。这条契约在 session.go 中实现为两道闸门:res.RecoveryHandle == "" 时直接拒绝;tokens_after >= tokens_before 时同样拒绝,并保留上一页面的 targets。

许可方面需要明确:caveman-browse 的源码与二进制遵循 BSL 1.1(见 browse/package.json"license": "BSL-1.1"browse/LICENSE)。在 Change Date 之前它是 source-available 而非 OSI 开源;第一方自托管生产使用被允许,第三方托管、托管服务或嵌入使用需要商业许可(详见 LICENSING.md)。

a11y 压缩器:从数百 KB 的 AX JSON 到紧凑缩进文本

压缩的核心实现在 engine/compressors/axtree.go,这是一个 forced-only(仅可强制调用)的压缩器,其 CompressWithMetadataL40-L56)流程如下:

  1. 解析与校验parseAXTree 接受裸节点数组或 {nodes: [...]} 信封两种形态;validAXTree 要求 node id 唯一、非 ignored 节点必须有 role、且至少存在一个非 ignored 节点。这里有一个关键细节(L136-L142):某个 childId 解析不到本 payload 中的节点不算畸形树——因为 Accessibility.getFullAXTree 一次只返回一个 frame,<iframe> 节点的子文档合法地位于另一 frame 的响应中,所以此类 childId 被当作叶子边界处理,而不是拒绝整棵树;
  2. 整理(curate)curateAXTree 从根节点深度优先遍历,ignored 节点被跳过但其子节点继续下沉;droppableGenericL254-L282)丢弃 InlineTextBox、空壳 LabelText,以及无 name/value/state 且不可聚焦的 generic / presentational / none 节点——这些是 AX 快照中最大的重复来源;pruneDuplicateAXText 进一步去掉与父控件文本重复的 StaticText
  3. UID 分配shouldExposeAXUIDL312-L344)决定哪些节点值得一个可操作句柄——buttoncheckboxcomboboxlinkmenuitemoptionradiosearchboxsliderspinbuttonswitchtabtextboxtreeitem 等交互角色,以及带 focusable / clickable 属性的任意节点,都分配 UID;一个显式的"不可分配"黑名单(headingparagraphtablerowstatictext 等结构性角色)之后,未知/自定义角色走 default 分支分配 UID——源码注释解释这是为了防止把自定义控件静默变成只读文本。UID 本身是 u + backendDOMNodeId 的 base36 编码(axUIDBase),重复时追加 _2_3 后缀;
  4. 渲染renderAXRecordsL500-L528)产出紧凑的缩进文本,每行形如:
[ub3] button "Save order"
  [ub4] textbox "Email" "a@b.co" {editable}

[uid] role "name" = "value" {state} 格式;状态只保留 disabledchecked/uncheckedexpanded/collapsedselectedfocusededitable 六种语义(a11yStateKeysL15-L22)。这不是 JSON-lines,而是人类和 LLM 都友好的缩进文本。

压缩结果随附的恢复元数据(RecoveryMetadata)只包含实际出现在视图里的 UID → {backendDOMNodeId, frameId, nodeId} 映射(visibleUIDTargets),这就是 browser_act 后续定位 DOM 元素的依据。

query 聚焦:大页面的 token 高效路径

README 强调 browser_snapshot.query 是大页面上的 token 高效路径。其实现是 focusAXRecordsengine/compressors/axtree.go#L374-L479),完全确定性、基于词项匹配(无嵌入):

  • 查询被切分为小写词项(axQueryTerms),每个节点按"命中词项数"评分,对 role/name/value/state 拼接的文本做 strings.Contains 匹配;
  • 最多保留 12 个最高分匹配及其祖先链;若存在节点完整覆盖全部查询词(如 ORD-0173),则 minScore 提升到满分,避免所有 ORD-* 行都命中 "ord" 这种碎片噪声;
  • 若匹配落在表格 rowlistitem 内,会保留整行兄弟单元格——源码注释解释:没有客户/金额单元格,孤立一个订单号读起来像"数据不可用";
  • 完全无匹配时退化为根节点加一行 note "no accessible match",而不是返回空。

关键在于:聚焦只裁剪发给 agent 的视图,CCR 始终保留完整的原始 AX 树。因此聚焦结果的 token 数只反映任务相关部分,而信息并未真正丢失——这是后文基准中大页面 99.97% less 数字的机制来源。

四个 MCP 工具及其参数语义

BrowserToolssession.go)注册四个工具。以下参数表以源码为准:

browser_snapshot

参数 类型 约束与语义
url string 可选;提供则导航。仅允许 http(s)about:blank、有界 data:text/html(≤1MB)
wait number 导航后等待毫秒,0..30000maxSnapshotWaitMSsession.go#L25
query string 聚焦词,≤4KB(maxQueryBytes

URL 校验逻辑在 validateSnapshotArgsL246-L280):显式拒绝 file:javascript: 与 Chrome 特权 scheme,违规返回 cave_browser_url_denied。成功时返回一个自校准的 JSON 载荷 finalizeSnapshotPayloadL227-L244):uids(紧凑树文本)、recovery_handletokens_beforeview_tokens(仅序列化树本身的成本)、tokens_afteragent 实际可见的完整 JSON 结果的成本,最多迭代 8 轮直到计数自洽)、ratiobasis。README 对这两个计数的区分是明确的:tokens_after 数的是完整 agent-visible JSON,view_tokens 隔离出紧凑树本身的开销。

browser_act

参数 约束与语义
action click / type / select / scroll / waitwait 固定休眠 250ms 并返回 settled:true
uid 最近一次快照的 UID;type 必须非空,text/option 各 ≤1MB
text / option type 的输入文本;select 优先用 option,否则回退 text

actToolL305-L345)在本地 uid 缓存中查找目标,未命中返回 cave_unknown_uid(未知句柄失败关闭)。驱动层的动作结果是 {ok, settled, note}:所有真实动作返回 settled:falsenote: "dispatched; resnapshot to verify"——源码注释(cdp.go#L224-L229)解释 CDP 确认"已派发"不等于应用状态已落定,廉价的验证手段就是对目标区域再做一次聚焦快照。

browser_eval

单一参数 expression(≤1MB),通过 CDP Runtime.evaluate 在当前页面执行任意 JS 并返回 {result}evalTool)。这是四工具中最自由、也最不经压缩器的一条路径。

browser_recover

recovery_handle(≤512 字符,必填)+ 可选 query,调用 eng.RetrieveQuery 从 CCR 取回原始 AX 字节(recoverTool,句柄解析见 engine/retrieve_query.go)。该工具标记 ExemptResultCap: true,允许返回体绕过结果长度上限。

动作层:有界可操作性的完整配方

CDPDriver.Actbrowse/cdp.go#L160-L222)实现了刻意"有界"的可操作性(actionability)配方,覆盖同源仪表盘与可预测的设计系统控件,而不是任意开放网页:

  • click:先 scrollIntoView({block:"center", behavior:"instant"}) 自动滚入视口(基准测试中的 "offscreen auto-scroll click" 即此),再 waitActionable,最后用 input.DispatchMouseEvent 派发 mouseMoved → pressed → released 三连;
  • type:点击聚焦后 input.InsertText 直接插入文本;
  • select:在节点上执行内联 JS,按 value / label / textContent 三键匹配 <option>,然后派发 input + change 事件以触发框架监听;
  • scrollscrollIntoView + 可操作检查。

waitActionableL256-L292)是一个 5 秒 deadline 的轮询循环:要求 dom.GetBoxModel 返回的边框盒连续两次(间隔 32ms)位移 < 0.5px(稳定),getComputedStyle 判定可见(非 display:none / visibility:hidden / opacity:0 / pointer-events:none),enabled 检查排除 disabled 属性、aria-disabled[inert] 祖先,最后 dom.GetNodeForLocation 命中测试确认坐标点确实落在目标节点或其后代上。任一条件不满足则继续轮询直至超时,返回 element not actionable——disabled 控件因此被显式拒绝(这是集成测试锁定的行为之一)。

另一个安全相关的实现细节:CDP 驱动保留 Chrome 默认的 Site Isolation(site-per-process)cdp.go#L59-L63 的注释说明,早期曾禁用站点隔离来掩盖跨域 iframe 问题,但在 axtree 压缩器把跨 frame 的 childId 当作叶子处理后,不再有理由为了修 iframe 而牺牲这一安全边界。

直连 CLI 模式:分离式 Chrome 与进程间共享

除 stdio MCP 服务外,cmd/caveman-browse/main.go 提供直连 CLI,命令形式与 browse/README.md 一致:

caveman-browse snapshot http://127.0.0.1:3000
caveman-browse snapshot http://127.0.0.1:3000 "save settings"
caveman-browse act <uid> click
caveman-browse recover <handle>
caveman-browse close

这一模式的关键设计是分离(detached)Chrome

  • directEndpointmain.go#L202-L233)先在默认端口 9333 探测是否已有健康 Chrome(HTTP 请求 /json/version 并检查 webSocketDebuggerUrl);健康则直接附着,否则 launchDirectChrome--remote-debugging-port、独立 user-data-dir、--headless=new(除非 CAVEMAN_BROWSE_HEADFUL=1)启动 Chrome,Process.Release() 使其脱离 CLI 进程独立存活(detach_unix.go / detach_windows.go 分别处理平台差异);
  • 因此 snapshotacteval 作为相互独立的进程共享同一个浏览器目标:成功快照后,{endpoint, target_id, targets, owned} 以原子写 + 0600 权限落盘到 browse-session.json(默认 ~/.caveman/browse-session.json,见 main.go#L391-L437),后续 act 读取该状态并重载 uid 缓存;
  • close 通过 browser.Close 终止 owned 的 Chrome 并清理状态文件;附着的外部 Chrome(通过 CAVEMAN_BROWSE_CDP 指定)则只清本地状态、不杀进程。

可调行为通过环境变量控制(均在 main.go 中解析):

环境变量 作用 默认
CAVEMAN_BROWSE_CDP 附着到既有 CDP endpoint,跳过本地启动 本地 127.0.0.1:9333
CAVEMAN_BROWSE_PORT 直连模式调试端口(1..65535) 9333
CAVEMAN_BROWSE_CHROME Chrome 二进制路径 平台候选路径 + PATH 查找
CAVEMAN_BROWSE_USER_DATA_DIR Chrome profile 目录 ~/.caveman/browse-profileDefaultUserDataDir
CAVEMAN_BROWSE_HEADFUL 1 使用有头模式 headless
CAVEMAN_BROWSE_EPHEMERAL 1 时 CCR 使用内存 store,不落盘 落盘
CAVEMAN_CCR_DB CCR SQLite 路径 ~/.caveman/ccr.db
CAVEMAN_HOME 状态根目录(profile、CCR、session 状态) ~/.caveman

首次使用会自动创建 profile 与 CCR 目录(README 所述 "first use creates profile and CCR directories",对应 openRecoveryStoreMkdirAll(dir, 0o700)main.go#L61-L81)。全新的 CAVEMAN_HOME 必须可用、状态写入必须原子且 0600,这些是 browse/CLAUDE.md 列出的 Gotchas 并由测试锁定。

构建、运行与测试

构建与运行命令与 browse/README.md 一致:

go build ./browse/cmd/caveman-browse
caveman-browse          # 以 stdio MCP 服务器身份运行

caveman-browse 同时以 npm 包 caveman-browse(BSL-1.1,Node ≥ 18)的形式发行,入口为 bin/caveman-browse.mjs。测试分两档(browse/CLAUDE.md):

  • go test ./browse/... 无需外部依赖即可运行;
  • integration build tag 的 CDP 契约测试需要真实 Chrome,通过 make test-browse 解析 Playwright Chromium 或系统 Chrome,例如 browse/BENCHMARK.md 给出的复现命令:
CAVEMAN_BROWSE_CHROME="/path/to/Chrome" \
  go test -tags=integration -run 'TestCDPQueryScales|TestCDPFullTokenEfficient' -count=5 -v ./browse

集成测试门(integration gates)覆盖:type、select、离屏自动滚动点击、动作后聚焦验证、disabled 控件拒绝、过期 UID 拒绝、字节级在线恢复、全新 home 启动、跨进程直连 CLI 重附着、以及显式 Chrome 关闭(完整清单见 BENCHMARK.mdbrowse/cmd/caveman-browse/main_integration_test.go)。

量化证据与诚实的失败案例

browse/BENCHMARK.md 记录了一次完整测量(2026-08-10,Chrome 151.0.7922.108,锁定 Playwright 1.56.1,离线 o200k_base 计数器;五个独立 Chrome 运行取中位数,且明确说明所有数字是单次快照的 inferred 计数,不是 provider 用量或账单)。

大页面(200 行运营表格,testdata/order_dashboard.html):

表示方式 Tokens 相对原始 AX 相对 Playwright
原始 Accessibility.getFullAXTree JSON 398,494 [398,493–398,497] n/a n/a
Playwright locator("body").ariaSnapshot() 15,704 96.06% less n/a
Caveman 完整 agent-visible 结果 13,368 [13,367–13,368] 96.65% less 14.88% less
Caveman 聚焦结果,query ORD-0173 121 [121–122] 99.97% less 99.23% less / 129.8× 更小

小页面(结账表单,testdata/agent_checkout.html)——一个被如实记录的失败案例:

表示方式 Tokens 相对原始 AX 相对 Playwright
原始 AX JSON 4,186 [4,183–4,188] n/a n/a
Playwright ARIA 快照 67 98.40% less n/a
Caveman 完整 agent-visible 结果 157 [156–159] 96.25% less 2.34× 更大
Caveman 聚焦结果,query Email Plan Save order 111 [110–113] 97.35% less 1.66× 更大

BENCHMARK.md 对此的解释值得引用:页面本身很小时,Caveman 的恢复句柄、精确计数器、诚实性 basis 与动作 UID 的固定开销超过了裸 Playwright ARIA 文本;且 Playwright 基线只计 ARIA 文本,不含 MCP 信封、动作引用、恢复句柄与计数字段——这种不对称实际上有利于 Playwright。文档结论是"不声称存在普适的 snapshot-only 胜利",大页面上 query 聚焦优势显著,小页面上存在可接受的劣势,换来的是可 type/select/click/verify 以及字节级恢复的完整状态。

适用前提与边界

综合 browse/README.mdbrowse/CLAUDE.mdbrowse/BENCHMARK.md 的 "Claim boundary",使用 caveman-browse 前需要知道:

  • 阶段边界:Phase 1 覆盖同源、可预测控件;OOPIF(跨进程 iframe)、对话框、下载与任意站点可操作性被明确推迟;大页面默认走 query 聚焦的渐进披露,任务意图不明时用完整快照;
  • 计数边界:所有节省量是本地计数器的 inferred 估计,压缩前无法获得 provider 用量;
  • 安全边界:导航白名单(http(s) / about:blank / 有界 data:text/html)、未知句柄与未知动作失败关闭、站点隔离保持开启、状态文件 0600 原子写入;
  • 许可边界:BSL 1.1 source-available,第三方托管或嵌入分发需商业许可;
  • 仓库路由说明browse/CLAUDE.md 指出 Browse 产品工作的上游源在独立仓库,本目录是 consumer 副本;但可访问性压缩器(engine/compressors/axtree.go)因 Engine 归本仓库所有而保留在仓内维护——阅读上述压缩器行为时以本仓库代码为准。

对需要让 agent 以最少 token 理解并操作真实网页的集成方,caveman-browse 给出的答案是:用可访问性树替代 DOM/截图,用强制压缩加 CCR 恢复保证"看得少、丢不了",用 settled:false 契约把"动作已派发"与"状态已验证"区分开,并始终用 recovery_handle 保留回到字节级原始载荷的通道。

登录后查看全文
热门项目推荐
相关项目推荐