最近在开发一个 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.navigate、Runtime.evaluate、DOM.querySelector 等底层能力。
- CDP 是什么
Chrome DevTools Protocol 允许工具对 Chromium 浏览器进行检测、检查、调试和分析。它被划分为多个域(Domain),如 DOM、Network、Runtime、Page 等,每个域包含一组方法和事件。
你可以把它理解为"浏览器的远程操作系统 API"。
二、大模型联动浏览器的核心机制
2.1 七步流程:从模型意图到浏览器动作
为了实现Browser Agent,让大模型能够指挥浏览器,我们构建了一套协同流程:
- 技能加载------引入自动化驱动
- 函数调用------大模型根据用户需求发起 Function Call
- 任务分发------渲染进程接收指令
- 会话解析------解析当前任务会话
- 标识匹配------依据 Tab 标识和 Session ID 定位目标
- IPC 转发------将指令转发到主进程
- 结果回传------主进程执行 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.debuggerAPI 支持扁平会话(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 通过 ipcMain 和 ipcRenderer 两个模块实现:
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);
安全警告
出于安全原因,不要直接暴露整个
ipcRendererAPI。应该使用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 选择器或 XPathByJSPath:使用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)之间迁移,每种模式下对外界的响应方式不同。
需要注意的是:
- 系统有哪些稳定状态?
- 状态之间如何迁移?
- 哪些迁移是非法的?
以后遇到类似问题时,可以快速套用这个模板:
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 的过程中,我学到的最重要一课。