Caveman Browse 技术解析:基于 CDP 可访问性树压缩与 CCR 恢复的本地浏览器 MCP 服务
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,其工作链路为:
- 驱动层:
cdp.go基于 chromedp 启动或附着 Chrome,Snapshot时按需导航并调用accessibility.GetFullAXTree(),得到完整的 AX 节点 JSON(见 cdp.go); - 压缩层:原始载荷进入引擎的
Compress调用,且显式强制Options{Mode: ModeCompress, Type: TypeA11y, Query: query}——a11y类型从不由自动检测路由,调用方必须显式指定(见 session.go 与 engine/compressors/axtree.go 的注释 "Detect never routes here; callers must force"); - 恢复层:CCR 存储保留原始 AX 字节,快照结果携带
recovery_handle,browser_recover用该 handle 返回字节级一致的原始载荷(见 session.go 中eng.RetrieveQuery); - 计数层:
tokens_before/tokens_after/ratio全部来自引擎本地计数器,basis字段恒为inferred(定义于 engine/result.go 的BasisInferred),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(仅可强制调用)的压缩器,其 CompressWithMetadata(L40-L56)流程如下:
- 解析与校验:
parseAXTree接受裸节点数组或{nodes: [...]}信封两种形态;validAXTree要求 node id 唯一、非 ignored 节点必须有 role、且至少存在一个非 ignored 节点。这里有一个关键细节(L136-L142):某个childId解析不到本 payload 中的节点不算畸形树——因为Accessibility.getFullAXTree一次只返回一个 frame,<iframe>节点的子文档合法地位于另一 frame 的响应中,所以此类 childId 被当作叶子边界处理,而不是拒绝整棵树; - 整理(curate):
curateAXTree从根节点深度优先遍历,ignored节点被跳过但其子节点继续下沉;droppableGeneric(L254-L282)丢弃InlineTextBox、空壳LabelText,以及无 name/value/state 且不可聚焦的generic/presentational/none节点——这些是 AX 快照中最大的重复来源;pruneDuplicateAXText进一步去掉与父控件文本重复的StaticText; - UID 分配:
shouldExposeAXUID(L312-L344)决定哪些节点值得一个可操作句柄——button、checkbox、combobox、link、menuitem、option、radio、searchbox、slider、spinbutton、switch、tab、textbox、treeitem等交互角色,以及带focusable/clickable属性的任意节点,都分配 UID;一个显式的"不可分配"黑名单(heading、paragraph、table、row、statictext等结构性角色)之后,未知/自定义角色走 default 分支分配 UID——源码注释解释这是为了防止把自定义控件静默变成只读文本。UID 本身是u+ backendDOMNodeId 的 base36 编码(axUIDBase),重复时追加_2、_3后缀; - 渲染:
renderAXRecords(L500-L528)产出紧凑的缩进文本,每行形如:
[ub3] button "Save order"
[ub4] textbox "Email" "a@b.co" {editable}
即 [uid] role "name" = "value" {state} 格式;状态只保留 disabled、checked/unchecked、expanded/collapsed、selected、focused、editable 六种语义(a11yStateKeys,L15-L22)。这不是 JSON-lines,而是人类和 LLM 都友好的缩进文本。
压缩结果随附的恢复元数据(RecoveryMetadata)只包含实际出现在视图里的 UID → {backendDOMNodeId, frameId, nodeId} 映射(visibleUIDTargets),这就是 browser_act 后续定位 DOM 元素的依据。
query 聚焦:大页面的 token 高效路径
README 强调 browser_snapshot.query 是大页面上的 token 高效路径。其实现是 focusAXRecords(engine/compressors/axtree.go#L374-L479),完全确定性、基于词项匹配(无嵌入):
- 查询被切分为小写词项(
axQueryTerms),每个节点按"命中词项数"评分,对 role/name/value/state 拼接的文本做strings.Contains匹配; - 最多保留 12 个最高分匹配及其祖先链;若存在节点完整覆盖全部查询词(如
ORD-0173),则minScore提升到满分,避免所有ORD-*行都命中 "ord" 这种碎片噪声; - 若匹配落在表格
row或listitem内,会保留整行兄弟单元格——源码注释解释:没有客户/金额单元格,孤立一个订单号读起来像"数据不可用"; - 完全无匹配时退化为根节点加一行
note "no accessible match",而不是返回空。
关键在于:聚焦只裁剪发给 agent 的视图,CCR 始终保留完整的原始 AX 树。因此聚焦结果的 token 数只反映任务相关部分,而信息并未真正丢失——这是后文基准中大页面 99.97% less 数字的机制来源。
四个 MCP 工具及其参数语义
BrowserTools(session.go)注册四个工具。以下参数表以源码为准:
browser_snapshot
| 参数 | 类型 | 约束与语义 |
|---|---|---|
url |
string | 可选;提供则导航。仅允许 http(s)、about:blank、有界 data:text/html(≤1MB) |
wait |
number | 导航后等待毫秒,0..30000(maxSnapshotWaitMS,session.go#L25) |
query |
string | 聚焦词,≤4KB(maxQueryBytes) |
URL 校验逻辑在 validateSnapshotArgs(L246-L280):显式拒绝 file:、javascript: 与 Chrome 特权 scheme,违规返回 cave_browser_url_denied。成功时返回一个自校准的 JSON 载荷 finalizeSnapshotPayload(L227-L244):uids(紧凑树文本)、recovery_handle、tokens_before、view_tokens(仅序列化树本身的成本)、tokens_after(agent 实际可见的完整 JSON 结果的成本,最多迭代 8 轮直到计数自洽)、ratio、basis。README 对这两个计数的区分是明确的:tokens_after 数的是完整 agent-visible JSON,view_tokens 隔离出紧凑树本身的开销。
browser_act
| 参数 | 约束与语义 |
|---|---|
action |
click / type / select / scroll / wait(wait 固定休眠 250ms 并返回 settled:true) |
uid |
最近一次快照的 UID;type 必须非空,text/option 各 ≤1MB |
text / option |
type 的输入文本;select 优先用 option,否则回退 text |
actTool(L305-L345)在本地 uid 缓存中查找目标,未命中返回 cave_unknown_uid(未知句柄失败关闭)。驱动层的动作结果是 {ok, settled, note}:所有真实动作返回 settled:false,note: "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.Act(browse/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事件以触发框架监听; - scroll:
scrollIntoView+ 可操作检查。
waitActionable(L256-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:
directEndpoint(main.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 分别处理平台差异);- 因此
snapshot、act、eval作为相互独立的进程共享同一个浏览器目标:成功快照后,{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-profile(DefaultUserDataDir) |
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",对应 openRecoveryStore 中 MkdirAll(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/...无需外部依赖即可运行;- 带
integrationbuild 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.md 与 browse/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.md、browse/CLAUDE.md 与 browse/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 保留回到字节级原始载荷的通道。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00