AI Developer Blog

为什么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个任务同时跑,总时间应该是最慢那个任务的时间。

实际运行时发生了什么

代码跑起来后,我看到了这样的"奇观":

  1. 第5秒:用户名被输入到了密码框
  2. 第8秒:登录按钮被点击,但焦点跳到了填表页面
  3. 第12秒:截图任务执行,但页面内容是空白
  4. 第15秒:Cookie混乱,有的请求带上了其他标签页的会话
  5. 第20秒:整个浏览器崩溃

我反复检查代码,没有任何逻辑错误。到底是哪里出了问题?

根本原因:Chrome DevTools Protocol的同步设计

经过一周的调试,我终于找到了真相。

CDP(Chrome DevTools Protocol)是一个同步单会话的协议。这意味着:

当我的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)
    );
  }
}

总结

相关开源项目