Clipping 微信公众号

浏览器工具架构:CDP直连+反检测+视觉兜底

by Jameszyh 原文 ↗
Created: 2026-07-07

公众号名称:James的成长日记

作者名称:Jameszyh

发布时间:2026-07-07 10:13

大家好,我是James。

上一篇我们聊了七种执行环境的抽象层设计——从本地到云端,一套接口驾驭所有执行场景。那浏览器这个最重要的外部工具,Hermes 又是怎么处理的?

真实情况比你想象的复杂很多。

一个 Agent 要”看懂”一个网页,背后可能涉及三层兜底:先用无障碍树解析页面结构,解析不了就截图交给视觉模型,模型被 bot 检测拦了就换 Camoufox 伪装成真人。这三层是怎么串联起来的?为什么 Hermes 没选 Playwright,而是自己造了一套架构?今天把这些都说清楚。


01 浏览器自动化的真实困境:为什么现有方案都有硬伤

你以为浏览器自动化很简单——不就是 Playwright 一行代码点个按钮吗?

上线之后你会发现:

  • Playwright/Puppeteer 默认暴露 navigator.webdriver = true,reCAPTCHA 直接认出你是机器人,验证码页面拦住

  • 纯截图 + OCR 方案识别率低,动态元素和 SPA 页面完全不适用

  • CDP 直连需要你自己管 WebSocket 生命周期、多 tab 切换、iframe 隔离,全是坑

  • Selenium早就被主流网站列入黑名单,伪装成本极高

更大的问题是:这些方案都是”单一模式”设计。Playwright 只能跑 Playwright,截图只能截图,没有任何降级和兜底机制。一旦遇到验证码、动态加载、iframe 跨域,就直接卡死。

Hermes 做了一个关键判断:Agent 的浏览器工具必须是「分层降级」架构,而不是单一方案。主路径用无障碍树(轻量、文本友好),遇到动态内容用视觉截图,遇到 bot 检测用 Camoufox 伪装。三层各司其职,每层都有明确的触发条件和降级路径。


02 行业调研:主流 Agent 框架怎么处理浏览器

我查了几个主流 Agent 框架的浏览器方案,差异很大:

框架底层技术反检测能力视觉兜底多 Tab无障碍树
LangChainPlaywright❌ 无截图+GPT-4V有限❌ 无
AutoGPTSelenium❌ 无❌ 无❌ 无
OpenDevinPlaywright插件级截图+视觉支持部分
Browser UsePlaywright云服务❌ 无支持部分
Browserbase云 Chromium✅ 云端代理❌ 无支持❌ 无
HermesCDP/agent-browser✅ Camoufox✅ 视觉模型支持✅ ariaSnapshot

几个核心观察:

1. 几乎所有框架都在 Playwright 上叠加功能,没有人从协议层重新设计

Playwright 本身是个测试框架,它的抽象层级高,但会引入 webdriver 特征。Hermes 选择直接操控 CDP(Chrome DevTools Protocol)——这是 Playwright 底层也在用的协议,但跳过了 Playwright 那层会暴露特征的包装。

2. 反检测要么没有,要么依赖昂贵的云服务

Browserbase 的高级反检测需要付费升级,Browser Use 也是托管云。Hermes 的 Camoufox 路径是本地自托管的,基于 Camoufox(Firefox fork,C++ 层面伪造指纹),免费且可控。

3. 视觉兜底普遍缺失

大多数框架截图只是”截个图给 LLM 看”,没有与无障碍树联动的降级逻辑。Hermes 的 browser_vision 工具有明确的触发条件:无障碍树返回空或解析失败时自动触发。


03 设计结论:三层架构 + 可插拔后端

从调研中可以提炼出三个核心设计原则:

原则一:优先无障碍树,截图是兜底而非主路径

ariaSnapshot 返回的是结构化文本,不消耗视觉 token,响应更快。截图走视觉模型是有成本的,不能作为默认路径。

原则二:反检测能力必须是架构内置,而不是插件

如果反检测靠的是”用之前装个扩展”,就会被网站进化掉。Camoufox 在 Firefox 的 C++ 层伪造 Canvas、WebGL、AudioContext 指纹,这不是浏览器扩展能做到的。

原则三:后端可替换,接口不变

本地 agent-browser、云端 Browserbase、云端 Browser Use,对上层 browser_navigate 完全透明。这就是 BrowserProvider ABC 存在的意义。


04 BrowserProvider ABC:可插拔后端的接口设计

# agent/browser_provider.pyclass BrowserProvider(abc.ABC): """可插拔云浏览器后端的抽象基类。 子类实现三个生命周期方法: - create_session: 创建会话,返回 cdp_url - close_session: 清理会话 - emergency_cleanup: 进程退出时的强制清理 """ @property @abc.abstractmethod def name(self) -> str: """注册键,用于 browser.cloud_provider 配置""" @abc.abstractmethod def is_available(self) -> bool: """快速检查凭据是否齐全,不做网络请求""" @abc.abstractmethod def create_session(self, task_id: str) -> Dict[str, object]: """创建会话,必须返回包含 cdp_url 的字典""" # 向后兼容:旧版 is_configured() → is_available() def is_configured(self) -> bool: return self.is_available()

关键设计点:

create_session 返回统一结构,包含 cdp_url——无论是 Browserbase 还是 Browser Use,上层工具都通过同一个 CDP WebSocket URL 与浏览器通信,后端差异完全屏蔽。

is_available() 严禁网络调用——这个方法在工具注册时就会被调用,如果有网络请求会拖慢启动。只做环境变量检查。

向后兼容 shim——is_configured() 是旧版 API,新版叫 is_available(),但旧名字保留为委托调用,不打破下游代码。


05 BrowserRegistry:三级优先级选路

# agent/browser_registry.py —— 核心选路逻辑_LEGACY_PREFERENCE = ("browser-use", "browserbase") # 自动发现顺序def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]: """ 三级优先级: 1. 显式 "local" → 直接返回 None,强制本地模式 2. 显式配置名 → 无论是否可用都返回,让下游报精确错误 3. 自动发现 → 按 browser-use → browserbase 顺序,过滤不可用 """ # 1. 本地模式短路 if configured == "local": return None # 2. 显式配置:忽略 is_available() # 目的:让用户看到 "BROWSERBASE_API_KEY 未设置" 而非静默切换后端 if configured: provider = snapshot.get(configured) if provider is not None: return provider # 3. 按优先级自动发现,过滤不可用 for legacy in _LEGACY_PREFERENCE: provider = snapshot.get(legacy) if provider and _is_available_safe(provider): return provider return None # 降级到本地 agent-browser

Firecrawl 为什么不在自动发现列表里?

这是个值得记住的细节:Firecrawl 同时作为网页提取工具和云浏览器后端。用户设置 FIRECRAWL_API_KEY 通常是为了网页提取,不希望浏览器也跑到云上消耗配额。所以 Firecrawl 被排出自动发现,只有显式配置 browser.cloud_provider: firecrawl 才能启用。这是”最小惊讶原则”的典型应用


06 Camofox 反检测:Firefox 级别的指纹伪造

# tools/browser_camofox.py —— 核心逻辑def is_camofox_mode() -> bool: """ Camofox 生效的判断逻辑: - CAMOFOX_URL 已设置 → 启用 - 但 BROWSER_CDP_URL 存在 → 优先 CDP 直连(用户已手动接入真实浏览器) """ if os.getenv("BROWSER_CDP_URL", "").strip(): return False # CDP 直连优先级更高 return bool(get_camofox_url())def get_camofox_identity(task_id: Optional[str] = None) -> Dict[str, str]: """ Hermes 管理的 Camofox 身份: - user_id:基于 profile 路径的 UUID5 派生,同一 profile 永远相同 - session_key:task_id 级别的稳定标识,用于跨重启复用同一浏览器 profile 这样 Camoufox 服务器能把 session 映射到持久化目录(cookies/LocalStorage 都保留) """ scope_root = str(get_camofox_state_dir()) user_digest = uuid.uuid5(uuid.NAMESPACE_URL, f"camofox-user:{scope_root}").hex[:10] session_digest = uuid.uuid5(uuid.NAMESPACE_URL, f"camofox-session:{scope_root}:{task_id}").hex[:16] return { "user_id": f"hermes_{user_digest}", "session_key": f"task_{session_digest}", }

Camoufox 是什么?它是基于 Firefox 的 fork,在 C++ 层伪造了浏览器指纹:

  • Canvas 指纹:每次 canvas.toDataURL() 返回略微不同的像素噪声

  • WebGL 指纹:显卡型号、渲染器信息随机化

  • AudioContext 指纹:音频处理特征模糊化

  • navigator.webdriver:强制设为 undefined

为什么用 UUID5 而不是随机 ID?

UUID5 是确定性的——同样的输入总是生成同样的 UUID。这让 Hermes 重启后、新建 session 后,都能生成相同的 user_id,映射到 Camoufox 服务器上同一个浏览器 profile,保留 cookies 和登录状态。这是持久记忆在浏览器层面的延伸


07 CDP 直连:原生协议操作浏览器

# tools/browser_cdp_tool.py —— CDP 核心调用async def _cdp_call( ws_url: str, method: str, # 如 "Runtime.evaluate" params: Dict, target_id: Optional[str], # 用于 OOPIF iframe 跳转 timeout: float,) -> Dict: """ 单次 CDP 调用的完整实现: 1. 如果有 target_id,先 Target.attachToTarget 建立子 session 2. 在对应 sessionId 上发送实际方法 3. 过滤事件消息,等待带有匹配 id 的响应 """ async with websockets.connect(ws_url, ping_interval=None) as ws: if target_id: # 附着到目标(OOPIF 处理) await ws.send(json.dumps({ "id": 1, "method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True} })) # 等待响应,过滤掉中间的事件消息 session_id = await _wait_for_session_id(ws, attach_id=1) # 发送真正的 CDP 命令 req = {"id": call_id, "method": method, "params": params} if session_id: req["sessionId"] = session_id # 指定到 OOPIF session await ws.send(json.dumps(req)) return await _wait_for_response(ws, call_id, timeout)

为什么 ping_interval=None

CDP 服务器不期望收到 WebSocket ping 帧——Chromium 实现里没有 pong 处理器,ping 会导致连接意外断开。这是个常见的 CDP 坑,Hermes 显式禁掉了 ping。

OOPIF(跨域 iframe)怎么处理?

浏览器的跨域 iframe 在独立进程中运行(Out-of-Process IFrame),有自己的 CDP target。Target.attachToTargetflatten=True 模式可以在同一个 WebSocket 上通过不同的 sessionId 多路复用,访问 OOPIF 内部的 DOM 和 JavaScript 上下文。这是大多数浏览器自动化框架不支持的细节。


08 CDPSupervisor:持久监听 + 对话框拦截

# tools/browser_supervisor.py —— 对话框桥接脚本_DIALOG_BRIDGE_SCRIPT = r"""(() => { if (window.__hermesDialogBridgeInstalled) return; window.__hermesDialogBridgeInstalled = true; // 接管 alert/confirm/prompt,转成同步 XHR window.alert = function(message) { ask("alert", message, ""); }; window.confirm = function(message) { return ask("confirm", message, ""); }; window.prompt = function(message, def) { return ask("prompt", message, def); }; function ask(kind, message, defaultPrompt) { // 发到 hermes-dialog-bridge.invalid(一个不存在的 magic host) // Hermes 通过 CDP Fetch domain 拦截这个请求 const xhr = new XMLHttpRequest(); xhr.open("GET", "http://hermes-dialog-bridge.invalid/?" + params, false); xhr.send(null); // ...解析响应决定 accept/dismiss }})();"""

这个设计解决了一个棘手问题:Browserbase 等云端服务会自动 dismiss 原生浏览器对话框,Agent 根本没机会响应 window.alert()

Hermes 的方案:

  1. Page.addScriptToEvaluateOnNewDocument 把桥接脚本注入到每个新页面

  2. 桥接脚本把 window.alert/confirm/prompt 替换成 XHR 调用

  3. XHR 目标是 hermes-dialog-bridge.invalid——这是个 magic hostname,永远不会真正解析

  4. CDPSupervisor 通过 Fetch domain 拦截这个 XHR,暂停执行,通知 Agent

  5. Agent 做决策后,CDPSupervisor 返回响应,页面 JS 继续执行

这样无论后端是否会 dismiss 原生对话框,Agent 始终能参与决策。


09 视觉兜底:browser_vision 的三路降级

# tools/browser_tool.py —— browser_vision 的降级逻辑def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None): """ 视觉兜底工具的三路策略: 路由 1:Camofox 模式 → 走 Camofox REST API 截图 路由 2:Lightpanda 引擎 → 无图形渲染器,预路由到 Chrome fallback 路由 3:标准 agent-browser → 截图 + 视觉模型分析 """ # 路由 1:Camofox if _is_camofox_mode(): from tools.browser_camofox import camofox_vision return camofox_vision(question, annotate, task_id) # 路由 2:Lightpanda 预路由(Lightpanda 没有图形渲染能力) engine = _get_browser_engine() if engine == "lightpanda": fb_result = _chrome_fallback_screenshot(task_id, screenshot_args, timeout) # 把 Lightpanda 的元数据 + Chrome 截图合并返回 # 路由 3:标准路径 → agent-browser screenshot result = _run_browser_command(task_id, "screenshot", args) # 截图完成后: # - 主模型有视觉能力 → 直接把截图附到对话上下文 # - 主模型无视觉能力 → 调辅助视觉模型(auxiliary_client)返回文字分析

Lightpanda 是什么?

Lightpanda 是一个超轻量浏览器引擎(Rust 实现),1.3-5.8x 比 Chrome 快,但没有 GPU 渲染管线,不能截图。Hermes 支持把它作为 browser.engine: lightpanda 配置——用来加速不需要截图的页面操作。当检测到用 Lightpanda 但需要截图时,自动切换到 Chrome,并在结果里标注 fallback_warning,让 Agent 知道这次用了不同的引擎。

视觉模型降级怎么判断主模型有没有视觉能力?通过 _get_vision_model() 检查配置,如果主模型不是多模态的,调 auxiliary_client 用辅助视觉模型分析截图,把视觉结果转成文字返回。


常见坑

坑1:设置了 CAMOFOX_URL 但 CDP 直连失效了

这是优先级问题:is_camofox_mode() 会检查 BROWSER_CDP_URL。如果你之前用 /browser connect 接入了一个真实浏览器,BROWSER_CDP_URL 会被设置,所有请求都走 CDP 直连,Camofox 被旁路。清除 BROWSER_CDP_URL 就恢复了。

坑2:Browserbase 创建 session 返回 bb_session_id 但名字很奇怪

bb_session_id 是个历史遗留键名——当年只有 Browserbase,字段名直接叫 bb_session_id。后来加了 Browser Use、Firecrawl 等后端,但改字段名会破坏下游代码,所以就这么保留了。所有 provider 返回的”provider session ID”都放在这个键里,无论你用的是哪家云服务。

坑3:Docker 里跑 Camofox,内部页面访问不到 localhost:3000

Docker 容器里的 localhost 是容器自己,不是宿主机。设置 CAMOFOX_REWRITE_LOOPBACK_URLS=true,Hermes 会把 http://127.0.0.1:3000 自动改写成 http://host.docker.internal:3000,浏览器就能访问宿主机服务了。

坑4:Lightpanda 截图返回空文件或者极小的图(< 17KB)

Lightpanda 的截图是个占位图(logo),不是真实页面渲染。Hermes 有检测逻辑:截图大小 < 17KB 就判断为 Lightpanda 占位图,触发 Chrome fallback。如果你强制用 Lightpanda 又必须截图,直接设 browser.engine: chrome 跳过检测。


总结

浏览器工具不是一个函数,是一套有降级策略的分层架构——无障碍树失效找视觉,视觉被封锁换 Camoufox,后端挂了走本地,每一层都有明确的触发条件和兜底路径。

BrowserProvider ABC + BrowserRegistry 的可插拔设计,让 Hermes 不绑定任何单一浏览器服务——Browserbase、Browser Use、Firecrawl、本地 agent-browser,对上层工具完全透明,配置一行就能切换。

Camoufox 的指纹伪造在 C++ 层完成,不是扩展插件——Canvas、WebGL、AudioContext 在字节层面做了随机噪声,绕过了绝大多数 bot 检测系统,且完全本地自托管,不依赖付费云服务。

UUID5 派生身份让浏览器 profile 跨重启持久化——同一个 Hermes profile 永远映射到同一个 Camoufox profile,cookies 和登录状态不丢失,这是记忆系统在浏览器层面的自然延伸。

CDPSupervisor 的对话框桥接,解决了云端服务自动 dismiss 原生 dialog 的顽固问题——magic hostname + Fetch domain 拦截,让 Agent 在任何后端上都能参与 alert/confirm/prompt 的决策。

Lightpanda 的自动降级保证了速度与能力的平衡——轻量引擎跑快速操作,需要截图时无缝切到 Chrome,降级过程对 Agent 透明,只留一条 fallback_warning

下一篇我们进入 LSP 代码智能——给 Agent 接上语言服务器协议,精确跳转和增量编辑的工程实现,看 Hermes 怎么让 Agent 真正”读懂”代码。


关注我,James 的成长日记,持续分享干货,帮你在 AI 时代少走弯路。


内容效果不满意?点此反馈

输入关键词开始搜索