为什么AI Agent不能同时开多个浏览器标签页:一个血泪教训
May 1, 2026
在我开发云电脑自动化技能的第一个月,遇到了一个让我抓狂的问题:我想让AI Agent同时处理多个任务——一个标签页登录,一个标签页填表,一个标签页截图。听起来很合理对吧?
现实给了我狠狠一巴掌。
我的"并行浏览器"设计
最初的架构是这样的:
// BrowserManager.ts - 错误的设计
class BrowserManager {
private tabs: BrowserTab[] = [];
async createParallelTasks() {
// 创建3个标签页
const tab1 = await this.browser.newPage();
const tab2 = await this.browser.newPage();
const tab3 = await this.browser.newPage();
// 并行执行3个任务
await Promise.all([
this.loginTask(tab1), // 任务1: 登录
this.formTask(tab2), // 任务2: 填表
this.screenshotTask(tab3) // 任务3: 截图
]);
}
}
我以为这样可以充分利用时间,3个任务同时跑,总时间应该是最慢那个任务的时间。
实际运行时发生了什么
代码跑起来后,我看到了这样的"奇观":
- 第5秒:用户名被输入到了密码框
- 第8秒:登录按钮被点击,但焦点跳到了填表页面
- 第12秒:截图任务执行,但页面内容是空白
- 第15秒:Cookie混乱,有的请求带上了其他标签页的会话
- 第20秒:整个浏览器崩溃
我反复检查代码,没有任何逻辑错误。到底是哪里出了问题?
根本原因:Chrome DevTools Protocol的同步设计
经过一周的调试,我终于找到了真相。
CDP(Chrome DevTools Protocol)是一个同步单会话的协议。这意味着:
- 同一时间只能有一个客户端发送命令
- 命令是FIFO队列执行,不是并行的
- 多个客户端同时发送命令时,会出现竞态条件
当我的3个任务同时执行时:
任务1 (登录): CDP.send(Command.Input.dispatchKeyEvent) → "admin"
任务2 (填表): CDP.send(Command.Input.dispatchKeyEvent) → "北京"
任务3 (截图): CDP.send(Command.Page.captureScreenshot)
实际执行顺序可能是:
1. 任务1发送 "a"
2. 任务2抢先把焦点切到填表框
3. 任务1继续发送 "d" → 结果是 "ad" 被输入到密码框...不对,是 "a" 被输入到密码框,然后焦点切走了
4. 任务3的截图时机完全不可控
深入理解:WebDriver vs CDP的区别
你可能会问:Selenium/Playwright是怎么实现多标签并行的?
答案是:它们使用的是每个标签页独立的会话ID,并且内部做了锁机制:
// Playwright的真实实现(伪代码)
class PlaywrightBrowser {
private sessionLock: Map = new Map();
async withSessionLock(page, fn) {
const lock = this.sessionLock.get(page.targetId);
await lock.acquire();
try {
return await fn();
} finally {
lock.release();
}
}
}
// 正确的并行使用方式
const page1 = await browser.newPage();
const page2 = await browser.newPage();
// 虽然是"并行",但每个页面的CDP调用是串行的
await Promise.all([
page1.type('#username', 'admin'), // 这里内部会加锁
page2.type('#address', '北京') // 这里也会加锁
]);
但是!当你使用底层的CDP直接调用时,这些锁机制是不存在的。这就是为什么我直接用CDP会遇到问题。
解决方案:任务队列串行执行
明确了问题后,我重新设计了架构:
// TaskQueue.ts - 正确的设计
class BrowserTaskQueue {
private queue: Array<{
task: () => Promise;
tab: BrowserTab;
priority: number;
}> = [];
private isExecuting = false;
private semaphore = 1; // 串行锁
async addTask(task: () => Promise, tab?: BrowserTab) {
return new Promise((resolve, reject) => {
this.queue.push({
task: async () => {
try {
await task();
resolve();
} catch (e) {
reject(e);
}
},
tab,
priority: Date.now()
});
if (!this.isExecuting) {
this.processQueue();
}
});
}
private async processQueue() {
if (this.isExecuting) return;
this.isExecuting = true;
while (this.queue.length > 0) {
// 按优先级排序
this.queue.sort((a, b) => a.priority - b.priority);
const item = this.queue.shift()!;
await item.task();
// 每个任务之间稍作等待,避免过快切换
await this.delay(100);
}
this.isExecuting = false;
}
private delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
性能对比
虽然变成了串行执行,但实际效率并没有降低很多:
| 场景 | 并行执行 | 串行执行 |
|---|---|---|
| 3个独立任务 | 总时间 ≈ max(T1,T2,T3) | 总时间 = T1+T2+T3 |
| 成功率 | ~30%(竞态问题) | ~99% |
| 调试难度 | 极高 | 简单 |
| 代码复杂度 | 简单 | 需要队列管理 |
实际上,由于并行版本的成功率太低(30%),你可能需要重试3-4次。串行版本虽然总时间稍长,但一次成功率超过99%。
进阶优化:伪并行(流水线模式)
如果你的任务涉及大量等待时间(如网络请求),可以用流水线模式:
// Pipeline.ts - 流水线模式
class BrowserPipeline {
private stages: TaskStage[] = [
new NavigationStage(), // 页面导航
new InteractionStage(), // 用户交互
new WaitStage(), // 等待响应
new ExtractionStage() // 数据提取
];
async process(request: Request) {
// 同一时间,3个请求在不同阶段
// 请求1: 等待响应
// 请求2: 用户交互
// 请求3: 页面导航
return this.stages.reduce(
(promise, stage) => promise.then(req => stage.process(req)),
Promise.resolve(request)
);
}
}
总结
- CDP是同步协议,多客户端同时调用会导致竞态条件
- Selenium/Playwright内部做了锁机制,但直接用CDP时没有
- 任务队列串行执行是浏览器自动化的正确姿势
- 流水线模式可以提高有大量等待时间的场景的吞吐量
相关开源项目
- mcp-data-api - 包含任务队列实现的云电脑远程控制方案