Browser Agent 开发:从浏览器插件到Electron CDP

最近在开发一个 Browser Agent 项目,核心工作是把原本跑在浏览器插件里的自动化 Agent 迁移到 Electron 应用中。

听起来只是换个外壳,但底层实现逻辑几乎推翻重来。

这次经历让我对 AI Agent 如何从"大脑"(模型)下达到"手脚"(浏览器操控)有了全新的认知,尤其是在架构迁移过程中遇到的那些深坑,值得记录下来。


一、为什么必须换一种思路

在插件环境下,Agent 拥有最高权限。它的工作方式类似于你打开 Chrome DevTools 的 Console,手动塞一段脚本进去------扫描元素、画 Overlay 框、模拟点击、输入、获取页面内容,都能直接完成。

  • Chrome 插件 vs CDP

插件的 Content Script 是直接注入到目标页面的,和页面共用同一个 DOM 树、同一个 Window 对象。你可以直接写 chrome.tabs.query({ active: true }) 拿到当前 Tab,直接写 document.querySelector 操作 DOM。

chrome.debugger API 则是通过 CDP 协议与浏览器内核通信。使用时需要通过 debuggee 参数指定目标标签页的 tabId,然后调用 sendCommand 发送协议命令。

但到了 Electron 应用里,这条路走不通了。

完整的调用链条变成了:

模型工具调用 → 渲染层 → IPC 进程间通信 → 主进程

当指令到达主进程后,我们无法直接"把手伸进页面",只能通过 CDP(Chrome DevTools Protocol) 协议,间接实现浏览器的操控。

理解 CDP 其实不难。

Chrome 内部有一个远程调试接口,向外开放了 Page.navigateRuntime.evaluateDOM.querySelector 等底层能力。

  • CDP 是什么

Chrome DevTools Protocol 允许工具对 Chromium 浏览器进行检测、检查、调试和分析。它被划分为多个域(Domain),如 DOM、Network、Runtime、Page 等,每个域包含一组方法和事件。

你可以把它理解为"浏览器的远程操作系统 API"。


二、大模型联动浏览器的核心机制

2.1 七步流程:从模型意图到浏览器动作

为了实现Browser Agent,让大模型能够指挥浏览器,我们构建了一套协同流程:

  1. 技能加载------引入自动化驱动
  2. 函数调用------大模型根据用户需求发起 Function Call
  3. 任务分发------渲染进程接收指令
  4. 会话解析------解析当前任务会话
  5. 标识匹配------依据 Tab 标识和 Session ID 定位目标
  6. IPC 转发------将指令转发到主进程
  7. 结果回传------主进程执行 CDP 操作后原路返回

这套链路看起来清晰,但真正跑起来的时候,问题接踵而至。

2.2 CDP 的核心概念:Target、Session、ExecutionContext

在深入 Debug 之前,有必要先理解 CDP 的三个核心概念:

CDP 中的 Target

Target 表示一个可以被调试的对象,例如一个页面(Page)、一个框架(Frame)或一个工作线程(Worker)。每个 Target 都由一个唯一的 UUID 标识。

概念 为什么需要 示例
Target ID 浏览器有多个页面(Tab、Extension、Service Worker),你要告诉 CDP 操控哪一个 targetId = "ABC123"
Session ID 同一个 Target 可以建立多个会话(比如同时操控同一个页面的不同层),用于隔离不同的调试器 sessionId = "SESSION_XYZ"
Execution Context ID 一个页面可能有多个 JS 执行环境(iframe、跨域隔离等),每个环境有独立的 contextId contextId = 5

为什么需要 Session ID

从 Chrome 125 开始,chrome.debugger API 支持扁平会话(flatten session)。你可以将其他 Target 作为子级添加到主调试器会话,通过 sessionId 属性标识要向哪个子 Target 发送命令。

以下是一个典型的 CDP 调用流程示例:

typescript 复制代码
// 1. 先列出所有 Target,找到目标页面
const targets = await CDP.List();
const target = targets.find(t => t.url.includes('example.com'));

// 2. 附加到这个 Target,建立 Session
const session = await CDP.AttachToTarget({ targetId: target.id });

// 3. 在 Session 里执行脚本
await session.send('Runtime.evaluate', {
  expression: 'document.querySelector("#searchBox").value',
  returnByValue: true
});

// 4. 用完记得分离
await CDP.DetachFromTarget({ sessionId: session.sessionId });

2.3 Electron IPC:进程间的通信桥梁

在深入问题根因之前,有必要理解 Electron 中渲染进程和主进程是如何通信的。

Electron 进程模型

Electron 继承了 Chromium 的多进程架构。主进程 (Main Process)负责管理应用生命周期和 BrowserWindow 实例;渲染进程(Renderer Process)负责渲染网页内容。两者职责不同,IPC 是它们通信的唯一方式。

在 Electron 中,IPC 通过 ipcMainipcRenderer 两个模块实现:

typescript 复制代码
// 主进程 (main.js) - 监听来自渲染进程的消息
import { ipcMain, BrowserWindow } from 'electron';

// 单向通信:监听事件
ipcMain.on('browser-navigate', (event, url) => {
  const win = BrowserWindow.fromWebContents(event.sender);
  win.webContents.loadURL(url);
});

// 双向通信:处理请求并返回结果
ipcMain.handle('dom-query', async (event, selector) => {
  const win = BrowserWindow.fromWebContents(event.sender);
  const result = await win.webContents.executeJavaScript(`
    document.querySelector('${selector}')?.innerText || ''
  `);
  return result;
});

*注:**webContents.executeJavaScript 是 Electron 提供的特权 API,能直接注入 JS,但这在非 Electron 环境(如远程调试或纯 CDP 场景)中不可用。*我们的 Browser Runtime 出于统一架构的考虑,最终还是选择了纯 CDP 路线,以便在不同宿主环境中保持一致的抽象层。

typescript 复制代码
// 预加载脚本 (preload.js) - 安全地暴露 API
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('electronAPI', {
  // 单向发送
  navigate: (url: string) => ipcRenderer.send('browser-navigate', url),
  // 双向调用
  queryDom: (selector: string) => ipcRenderer.invoke('dom-query', selector)
});
typescript 复制代码
// 渲染进程 (renderer.js) - 调用暴露的 API
// 单向:导航到 URL
window.electronAPI.navigate('https://example.com');

// 双向:查询 DOM 并获取结果
const text = await window.electronAPI.queryDom('#title');
console.log(text);

安全警告

出于安全原因,不要直接暴露整个 ipcRenderer API。应该使用 contextBridge 选择性暴露封装后的方法。同时,不要将 event 对象暴露给渲染进程,这可能会让恶意代码访问危险的 Electron API。


三、Debug 过程中的问题与根因分析

3.1 问题现象

初期,我们试图把插件那套注入 JS 的旧逻辑"硬搬"到基于 CDP 的新架构中。结果底层严重不兼容,各种 Bug 频发:操作失灵、元素定位错位。

最典型的症状是:Agent 无法在单个 Tab 页面中执行连续任务,甚至在需要验证的页面直接返回白屏。

一开始我以为是动态验证(验证码等)卡住了流程。看到 Terminal 打印出"登录成功"的日志,便没有深究。

但我后来尝试直接输入一个无法正常操作的 URL,发现页面也打不开。于是我决定切断上层所有干扰------LLM、Tool 定义、IPC 转发逻辑、Selector 选择器------只孤立验证最底层:

Browser Runtime + 当前 Session + 当前 Fingerprint + 网页入口

这才逐渐接近问题的核心。

3.2 问题根因

经过拆解,我发现问题分布在四个层面:

1. 模型侧的"上下文盲区"

系统提示词没有告诉模型 Electron 视图中的状态------现在开了几个 Tab、哪个是激活状态。模型不知道可以"复用现有页面",只会机械地不断开新窗口。

2. Handler 的"被动响应"

处理逻辑只是被动读取"当前激活的 Session",不会主动去准备环境。它不会自动打开浏览器面板,也不会主动切换页签或等待页面加载完成。在旧逻辑下,Agent 极其依赖用户手动把环境先"摆好"。

3. 登录态与 Session 的脱节

"正在检测登录态的对象"和"实际执行检索的 BrowserPanel Session"不一定是同一个。对于需要强认证的网站,Cookie 和 Session 的绑定稍有漂移,后续操作就会全线崩溃。

4. Browser Runtime 的竞态问题

自动化动作有时跑得太快------在 CDP 刚刚挂载、但指纹覆盖脚本还没就绪的窗口期就执行了操作。

typescript 复制代码
// 问题代码:没有 await,时序无法保证
async attachBrowserSession(session: Session) {
  this.attachCdp(session);  // ❌ 缺少 await,后续代码可能先执行
  this.injectStealthScripts();  // 可能在 CDP 未就绪时执行
}

// 正确做法:确保每一步都等待完成
async attachBrowserSession(session: Session) {
  await this.attachCdp(session);  // ✅ 等待 CDP 完全挂载
  await this.injectStealthScripts();  // 然后才注入脚本
  await this.waitForPageReady();  // 再等待页面就绪
}

3.3 为什么浏览器插件的方法不再适用

一个值得深入思考的问题是:为什么在插件环境下,我们不需要操心 Target ID、Session ID、Context ID 这些概念?

核心答案:插件和网页运行在同一个"世界"里,天然共享上下文。

类比一下:

插件 = 你坐在车里,方向盘和挡位就在手边,伸手就能操作。
CDP 方案 = 你在另一栋楼里,通过监控摄像头和遥控器来开车。

操作 插件方式 CDP 方式
获取当前页面标题 document.title Runtime.evaluate({ expression: 'document.title' })
点击按钮 button.click() DOM.querySelector + Runtime.evaluate 注入点击
获取 Cookie document.cookie Network.getCookies
切换 Tab chrome.tabs.update(tabId, { active: true }) Target.activateTarget({ targetId })

在 CDP 方案下,状态管理不再是可选项,而是必须项。插件时代的代码可以很"天真",但在 Agent 时代,每一行指令都必须包上一层严密的状态管理逻辑。


四、Browser Runtime 与状态机思维

4.1 Browser Runtime:"浏览器当前活着的整个世界状态"

这次项目让我对 Browser Runtime 有了更深的理解。

它不只是代码,而是"浏览器当前活着的整个世界状态",涵盖:

  • 当前 Tab、Session、Cookie、LocalStorage
  • 指纹环境、JS 注入状态、页面生命周期
  • iframe 层级、网络挂钩、权限状态
  • 甚至页面的 Focus 状态

当 Agent 运行时,它本质上是在这个复杂的动态状态机中滑行。

ChromeDP 的 DOM 选择机制

ChromeDP 提供了多种选择 DOM 元素的方式,每种方式对应不同的 CDP 命令:

  • ByQuery:使用 DOM.querySelector,类似 document.querySelector()
  • BySearch:使用 DOM.performSearch,支持文本、CSS 选择器或 XPath
  • ByJSPath:使用 Runtime.evaluate,通过直接执行 JS 来选择
go 复制代码
// 使用 ChromeDP 的 DOM 交互示例
package main

import (
    "context"
    "github.com/chromedp/chromedp"
)

func main() {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    var title string
    err := chromedp.Run(ctx,
        chromedp.Navigate("https://example.com"),
        // 通过 CSS 选择器获取文本
        chromedp.Text("#title", &title, chromedp.ByQuery),
        // 通过 JS 路径获取元素
        chromedp.Click("#submit-btn", chromedp.ByJSPath),
    )
}

4.2 从代码视角到系统行为视角

在 Debug 过程中,我意识到自己最初的局限性:我太依赖阅读代码逻辑,而忽略了状态观测

代码经常是"理论正确"的,但页面的真实跳转、Session 的实际漂移、CDP 的 Attach 时机------这些才是真实世界。

一个值得警惕的经验:不要轻信"成功日志"

比如"登录成功"这行日志,只代表某个检测函数返回了 true,并不代表页面没被风控,也不代表 Session 已经正确绑定。

在复杂系统里,"局部 Success + 整体 Fail"是常态。

对于 **Agent 这类系统,最有效的 Debug 手段是状态观测,而非单纯的代码阅读。**最有价值的信息来源往往是:

  • 导航日志(Navigation Log)
  • 网络与会话日志(Network/Session Log)
  • DOM 快照与页面截图(Snapshot & Screenshot)
  • CDP 事件流(Event Stream)
typescript 复制代码
// 推荐的 Debug 辅助代码
class CDPDebugger {
  private session: CDPSession;
  
  async enableDebugLogging() {
    // 监听 CDP 事件
    this.session.on('event', (event) => {
      console.log(`[CDP Event] ${event.method}`, event.params);
    });
    
    // 启用网络日志
    await this.session.send('Network.enable');
    this.session.on('Network.requestWillBeSent', (params) => {
      console.log(`[Network] ${params.request.method} ${params.request.url}`);
    });
    
    // 启用 DOM 变更日志
    await this.session.send('DOM.enable');
  }
  
  async captureSnapshot() {
    const { root } = await this.session.send('DOM.getDocument', { depth: -1 });
    const html = await this.session.send('DOM.getOuterHTML', { nodeId: root.nodeId });
    const screenshot = await this.session.send('Page.captureScreenshot');
    return { html, screenshot };
  }
}

4.3 可迁移的状态机思考框架

截止目前的我发现我遇到的很多问题本质上都可以归结为:

一个系统在多个明确的模式(Mode)之间迁移,每种模式下对外界的响应方式不同。

需要注意的是:

  1. 系统有哪些稳定状态?
  2. 状态之间如何迁移?
  3. 哪些迁移是非法的?

以后遇到类似问题时,可以快速套用这个模板:

markdown 复制代码
1. 用户目标是什么?
2. 理论链路是什么?
3. 当前实际停在哪一层?
4. 哪一层已经确认成功?
5. 哪一层其实只是"看起来成功"?
6. 如何切断上层来验证底层?
7. 当前系统的"真实状态源"是什么?

五、行业趋势与总结

5.1 从抽象包装到约束执行

这次迁移经历让我意识到,我们正在见证浏览器自动化领域的一个深层转变。

抽象包装的局限性

传统的浏览器自动化思路是:不信任模型,把它包装在一套固定的、预定义的动作中(click、type、scroll、select)。但现代 Web 应用基于 React、Vue、Angular 构建,具有异步状态更新和事件系统。一个说"type into this input"的抽象,在框架检测不到 value 变更时就会失效。

早期系统通过构建抽象层来约束 Agent------只暴露有限的工具,简化 DOM,缩小动作空间。但随着模型能力的提升,这种方式的局限性日益明显。

模型需要完整的动作空间,能够在运行时自行设计、执行、迭代,直到完成任务。

这个转变的核心思想是:约束,而非过度辅助

可靠性不来自给模型更多 helper 函数,而是来自更少但更强大的原语,以及在运行时层面设置正确的边界约束。

新的设计思路

停止问"应该暴露哪些浏览器动作",转而问"如何为模型提供完整的动作空间,同时通过运行时策略保证安全"。动作分类和抽象完整性变得不那么重要,更重要的是运行时策略、可观测性和步骤级评估。

5.2 总结

这次项目让我深刻体会到,最难的不是掌握 Electron、CDP 或 IPC 的 API,而是从"代码逻辑视角"切换到"系统行为视角"

从页面行为反推 Runtime 异常,从 Session 状态反推架构。

前不久, Tomoro.ai 团队发布了文章《From Browser Wrappers to Constrained Computer Use》(https://tomoro.ai/insights/from-browser-wrappers-to-constrained-computer-use)。

我想引用其中的一句话。

Less wrapper design. More systems engineering.

更少包装设计,更多系统工程。

这就是从插件迁移到 CDP 的过程中,我学到的最重要一课。

相关推荐
UXbot1 小时前
2026年文字转原型AI工具推荐:输入一句需求描述,自动生成多页面可交互界面
前端·低代码·ui·交互·ai编程·原型模式
维元码簿1 小时前
Claude Code 深度拆解:远程模式 2 — 环境注册与轮询架构
ai·agent·claude code·ai coding
我滴老baby1 小时前
企业级工具链设计从单一工具到分层工具体系的架构实践
java·开发语言·架构
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_30:(AbortController 实现可取消的异步请求)
前端·ui·html·edge浏览器·媒体
陈天伟教授1 小时前
图解人工智能(2)最智能
人工智能·安全·架构
前端若水1 小时前
选择器的威力 —— :has()、@layer、原生嵌套
前端·css·css3
nashane1 小时前
HarmonyOS 6学习:Web组件本地资源跨域访问全解析与实战
前端·学习·harmonyos·harmonyos 5
小陈同学,,1 小时前
地图第一次进来慢的问题二
前端
HIT_Weston2 小时前
76、【Agent】【OpenCode】用户对话提示词(addtionalProperties 属性)
人工智能·agent·opencode