browser-use 两种并发:并行 Agent 与多标签页的分工

2026-07-30

本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。

在 browser-use 里,「同时开好几个」有两条完全不同的路:一条是起多个 Agent 对象让它们各跑各的任务,另一条是让同一个 Agent 在多个标签页之间来回切。前者拼的是浏览器实例与账号态的隔离,后者拼的是那一个「焦点」归谁。把这两件事混成一件,你会在日志里看到一堆莫名其妙的截图错页、录屏跳台、以及标签页被别人关掉。

这篇只谈机制。任务怎么切分、多个 Agent 的结果怎么合并、并发跑崩之后怎么值班,站内另有分工:并发编排的思路看并发编排,多个会话同时改同一份状态导致的冲突看多会话并发冲突,跑起来之后的值班与告警看日常运维;本篇只钻一件事——browser-use 这个项目在浏览器这一层是怎么把并发接住的,以及它接不住什么。

一、先分清两种并发

仓库里两个示例文件几乎是并列摆着的,但设计意图完全相反。

examples/features/parallel_agents.py 是「多 Agent 共用一个浏览器会话」。它先建一个 BrowserSession,然后把同一个 session 对象喂给三个 Agent:

browser_session = BrowserSession(
	browser_profile=BrowserProfile(
		keep_alive=True,
		headless=False,
		record_video_dir=Path('./tmp/recordings'),
		user_data_dir='~/.config/browseruse/profiles/default',
	)
)

agents = [
	Agent(task=task, llm=llm, browser_session=browser_session)
	for task in [...]
]
print(await asyncio.gather(*[agent.run() for agent in agents]))

这个文件自己在注释里写得很直白:NOTE: This is experimental - you will have multiple agents running in the same browser session。注意 keep_alive=True 这个配置项——如果不保持存活,第一个跑完的 Agent 收尾时会把浏览器带走,剩下两个当场失去落脚点。

examples/features/multi_tab.py 走的是另一条路。它只有一个 Agent,任务本身描述的就是标签页操作:open 3 tabs with elon musk, sam altman, and steve jobs, then go back to the first and stop。这里没有任何并发原语,main() 里就一句 await agent.run(),外面套一个 asyncio.run 而已。多标签页在这里是模型的工具能力,不是运行时的并发能力。

再往上一层还有第三条路:examples/browser/parallel_browser.py,多个 Agent 各带一个独立浏览器,每个实例一个 user_data_dir=f'./temp-profile-{i}'。这个文件的注释同样保留了保留意见:NOTE: This is still experimental, and agents might conflict each other.

三条路的取舍很清楚:共用一个浏览器省内存但抢焦点;一 Agent 多标签页最稳但上限是单条时间线;一 Agent 一浏览器隔离最干净,代价是内存和启动成本乘以并发数。

二、多标签页是模型手里的一件工具

browser_use/tools/service.py 里注册了标签页管理动作。切换动作的描述原文开头是:Switch to another open tab by tab_id. Tab IDs are shown in browser state tabs list (last 4 chars of target_id).,后面还接了一句提示模型什么时候该用它。参数模型 SwitchTabAction 只有一个字段,browser_use/tools/views.py 里定义为 tab_id: str = Field(min_length=4, max_length=4, description='4-char id')。关闭动作 CloseTabAction 同样是四位。

也就是说模型看到的不是完整 CDP target id,而是它的后四位。这个设计省 token,但也意味着标签页在模型眼里是一串没有语义的短码,browser_session.get_target_id_from_tab_id(params.tab_id) 负责把短码翻回真身。

切换动作上挂了一个容易被忽略的标记:terminates_sequence=True。browser-use 允许模型在一步里返回多个动作连续执行,而切标签页会终止这个序列——切完这一轮就结束,下一轮重新看页面状态。这个约束是对的:切页之后的 DOM 索引跟切页之前完全是两套,接着往下执行等于在拿旧地图走新路。

系统提示词也在推标签页这条路。browser_use/agent/system_prompts/system_prompt.md 里明确写了:If research is needed, open a **new tab** instead of reusing the current one. 该目录下一共 8 份提示词变体,覆盖不同模型家族与是否输出思考过程的组合。

还有一段值得留意的自动行为:service.py 里有个 _detect_new_tab_opened,动作执行前后比对一遍标签页集合,如果多出来一个,就派发 SwitchTabEvent 自动切过去,并在返回的记忆里写明 Automatically switched to new tab (tab_id: ...)。点了一个 target="_blank" 的链接不用模型自己反应过来,这层帮它兜住了。

三、会话管理器挡在中间的那一层

browser_use/browser/session_manager.py 的类文档把定位写成了大写:SessionManager is the SINGLE SOURCE OF TRUTH for all targets and sessions. 它维护四张表:

  • _targets:target id → Target 对象,实体(页面、iframe、worker)
  • _sessions:session id → CDPSession 对象,通信通道
  • _target_sessions:一个 target 上挂了哪些 session
  • _session_to_target:反向映射

关键在于它不轮询。start_monitoring() 先调 Target.setDiscoverTargets,再注册四个 CDP 事件处理器:Target.attachedToTargetTarget.detachedFromTargetTarget.targetInfoChangedPage.lifecycleEvent。标签页的增删由 Chrome 主动通知,标题和 URL 的变化也由 targetInfoChanged 推过来——文件注释里那句 no polling needed! 说的就是这个。

Page.lifecycleEvent 的处理方式暴露了一个真实踩过的坑。代码注释写着:cdp-use 的事件注册表是每个 CDP 方法单槽位的,如果按 session 逐个注册闭包,后注册的会顶掉先注册的,结果是「除最近附着的那个标签页之外,所有标签页都收不到生命周期事件」。所以这里只注册一个全局处理器,靠 get_target_id_from_session_id(session_id) 把事件路由回对应 target,再存进 _lifecycle_events 里一个 deque(maxlen=50) 的环形缓冲。多标签页并发下这类「单槽位注册表」的坑,属于不踩一次很难想到的那种。

而真正让并行 Agent 打起来的,是焦点只有一个BrowserSession 上有一个字段 agent_focus_target_id,全局单值。get_or_create_cdp_session(target_id, focus=True) 拿会话的同时会把焦点搬过去;只有 target_type == 'page' 才允许搬,iframe 和 worker 的焦点请求会被忽略——注释解释了原因:这类 target 随时可能 detach,把焦点放上去等于埋一颗定时炸弹。

焦点丢了怎么办?_handle_target_detached 里,当某个 target 的最后一个 session 也断开时,先立刻把 agent_focus_target_id 置空,注释写的理由是「防止后续操作打到已经 detach 的 target 上」;同样的置空动作在 _get_session_for_target 里还兜了一遍,那里的注释直接写作 defense in depth。清空焦点之后派发 TabClosedEvent(只对 page/tab 类型派,iframe 和 worker 不派),然后在锁外起一个 _recover_agent_focus 任务。恢复逻辑是:从 get_all_page_targets() 里挑最后一个还活着的页面切过去并调 Target.activateTarget;一个都不剩就 _cdp_create_new_page('about:blank') 现开一个,并补派 TabCreatedEvent 让各个 watchdog 有机会初始化。成功后派发 AgentFocusChangedEvent

等待方一侧是 ensure_valid_focus(timeout=3.0),用 asyncio.Event 等恢复完成而不是轮询;get_or_create_cdp_session 在没指定 target 时会以 timeout=5.0 调它,失败就抛 No valid agent focus available。整套机制设计得相当谨慎——但它保证的是「焦点始终指向一个活着的页面」,不保证这个页面是你那个 Agent 想要的页面

组成部分它负责什么仓库位置你什么时候会碰到它
SessionManager用 CDP 事件维护 target/session 四张映射表,做焦点恢复browser_use/browser/session_manager.py日志里出现 [SessionManager] 前缀的告警时
agent_focus_target_id全局单值,标记「当前操作哪个页面」browser_use/browser/session.py多 Agent 共用一个 session、动作打到了别人的页面上
get_or_create_cdp_session取 CDP 会话,顺带按需切焦点browser_use/browser/session.py排查焦点为什么被搬走
标签页动作(switch / close)把四位 tab_id 翻成 target id 并派事件browser_use/tools/service.py模型自己在标签页之间腾挪
_lifecycle_events 缓冲每 target 一条环形队列,存页面生命周期事件browser_use/browser/session_manager.py等待页面就绪判断不准时
watchdog 群(14 个)崩溃、下载、弹窗、截图、录屏、验证码等旁路监控browser_use/browser/watchdogs/某类浏览器副作用没被处理干净

四、并发开上去之后,先出问题的通常不是模型

把并发调大,第一个报警的往往是浏览器侧的资源与状态,不是模型的推理质量。几个可以在仓库里找到依据的点:

截图和录屏跟着焦点走。 watchdogs/screenshot_watchdog.py 取截图前先 get_focused_target(),拿到之后调 get_or_create_cdp_session(target_id, focus=True)watchdogs/recording_watchdog.py 更直接,它监听 on_AgentFocusChangedEvent,焦点一变就 Page.stopScreencast 旧 session、Page.startScreencast 新 session。共用一个 session 的多个 Agent 各自要截图,等于轮流把焦点抢过来——录像文件会在几个任务之间反复跳台,某个 Agent 的截图也可能拍到隔壁的页面。

CDP 请求会互相饿死。 browser_use/dom/service.py 里有一段注释说得很实在:每次 DOM.describeNode 调用都可能触发 target/session 的簿记开销,「即使只有几十个并发调用,也足以让截图和构建浏览器状态所需的其它 CDP 请求挨饿」。所以那里用 _DESCRIBE_NODE_BATCH_SIZE 分批 gather,而不是一把全丢出去。单个 Agent 内部都要这么克制,多个 Agent 同时压同一条 CDP 连接是什么光景可以自己推。

验证码等待只跟踪一个。 watchdogs/captcha_watchdog.py 的文档注释写明:Only a single captcha solve is tracked at a time. 多个验证码重叠时只跟踪最新那个,先前在等的可能提前返回。

用户数据目录是独占资源。 browser_use/browser/profile.py 里的 _copy_profile() 会把 Chrome 的 profile 目录复制到一个 browser-use-user-data-dir- 前缀的临时目录再用。复制失败且判定为文件锁错误时,它抛的是一段人话很清楚的 RuntimeError:关掉正在用这个 profile 的 Chrome 窗口,或者改用 --cdp-url 连一个已经在跑的浏览器。同一份 profile 想被两个浏览器实例同时持有,这一步就会拦下来。

登录态配置项自己会打架。 profile 里有个校验器 warn_storage_state_user_data_dir_conflict,同时设了 storage_stateuser_data_dir 会告警,并给出建议:并行跑多个浏览器时,要么只用 storage_stateuser_data_dir=None,要么每个浏览器一个独立的 user_data_dirstorage_state=None。这句话基本就是并行方案的选型答案。

网络超时先于模型超时触发。 watchdogs/crash_watchdog.pynetwork_timeout_seconds 有一个默认值,超时的请求会被单独上报。并发一高、带宽和 CPU 被摊薄,页面加载被拖慢,这类超时事件会明显变多——看起来像模型开始犯傻,实际是页面根本没加载完就被喂进去了。

项目 README 的 FAQ 里也没回避这件事,原话是 Chrome 会吃掉大量内存、并行跑很多 Agent 管理起来会很棘手。

五、边界与代价:它明确不管的事

它不做任务编排。 两个 parallel 示例都是裸的 asyncio.gather,没有并发上限、没有队列、没有失败重排、没有优先级。想要限流和退避,得你自己在外面套一层。

它不合并结果。 asyncio.gather 返回一个列表,谁跟谁对应、冲突了听谁的,全在调用方。共用一个 BrowserSession 时,几个 Agent 对同一个浏览器的改动(登录状态、cookie、当前页面)是互相可见的,谁后写谁生效。

焦点恢复保「活着」,不保「对」。 上一节说过,_recover_agent_focus 挑的是「最后一个还活着的页面」。对单 Agent 是合理兜底,对多 Agent 共用 session 就是掷骰子。

它不替你判断目标站点允不允许。 profile 里有 allowed_domainsprohibited_domains 让你自己划范围,这是给你的工具,不是它对站点条款的判断。这类工具会驱动一个真实浏览器、可能带着你的登录态操作真实账号、访问第三方站点:目标站点的使用条款、验证码与反自动化机制、账号被判定为异常的可能、以及页面内容和凭据经由模型上下文外泄的风险,都是使用方的责任。并发越高,请求特征越集中,被判异常的概率越高——这是并发的固有代价,不是可以靠配置消掉的。仓库里有 examples/features/sensitive_data.pysecure.py 两个例子处理敏感数据与访问限制这条线,配套的数据面风险可以对着数据安全风险那篇一起看。

两条并行路子都还挂着 experimental。 共用 session 的那个示例注释是「多个 Agent 跑在同一个浏览器会话里」,一浏览器一 Agent 的那个示例注释是「Agent 之间可能互相冲突」——措辞不同,但都在说别当成定型方案。拿它做生产方案之前,你得接受写法可能变。

六、上手与避坑清单

共用一个 session 时忘记 keep_alive=True 为什么会踩:多个 Agent 里最快跑完的那个会走收尾流程,浏览器被带走,剩下的当场失去落脚点,报错却指向后面这几个 Agent,看起来像它们自己崩了。怎么避:照 parallel_agents.py 的写法在 BrowserProfile 里设 keep_alive=True,并由外层显式收尾(示例在 asyncio.gather 之后紧跟一句 await browser_session.kill())。

默认拿真实 Chrome profile 去跑并行。 为什么会踩:user_data_dir 指向日常在用的目录时,_copy_profile() 要复制它,而 Chrome 正开着就会撞上文件锁;就算复制成功,你也把日常登录态整份带进了自动化流程。怎么避:并行场景按 profile 校验器给的建议二选一——独立 user_data_dir 或纯 storage_state;要连已有浏览器就走 --cdp-url

同时设 storage_stateuser_data_dir 为什么会踩:这两者都管登录态,同时给会由前者强行覆盖后者里的 cookie 和 localStorage,症状是「明明上次登录过,这次又要重新登」。怎么避:看到那条 ⚠️ BrowserSession(...) was passed both 告警就别放过,按上一条二选一。

指望模型自己维护标签页秩序。 为什么会踩:模型看到的只有四位 tab_id 和标题,开了七八个页面之后它自己也分不清哪个是哪个,最典型的表现是把还要用的页面 close 掉。怎么避:在任务描述里就限定标签页数量与用途(multi_tab.py 的任务串里连「回到第一个然后停」都写明了),别让页面数无限长。

把切标签页和后续动作塞进同一步。 为什么会踩:切页之后 DOM 索引整套换新,旧索引指向的元素已经不存在。怎么避:这条项目已经用 terminates_sequence=True 帮你拦住了,别去改;写自定义动作时如果也会改变当前页面,记得考虑同样的约束。

并发数直接按 CPU 核数拍。 为什么会踩:瓶颈不在 CPU,而在浏览器内存和那条 CDP 连接的排队;数字一大,先看到的是网络超时和状态构建变慢,而不是模型质量下降。怎么避:从 2 到 3 起步,盯着日志里 [SessionManager] 的告警数量和网络超时事件数往上加,出现焦点恢复告警就说明已经过线了。

把日志里的焦点告警当噪音。 为什么会踩:Stale agent_focus detectedRecovery not in progress for stale focus! 这类消息在代码里是 warning 甚至 error 级别,其中有一条注释直接写了「这说明有 bug」。怎么避:把这几个字符串加进你的告警规则,它们是并发过载最早的信号。

收束

一句话记住分工:多标签页是给模型的工具,并行 Agent 是给你的运行时;中间那个 agent_focus_target_id 是单值的,谁碰谁负责。 需要真隔离就一 Agent 一浏览器,各自一个 user_data_dir;只是想让模型开几个页面对比信息,那就一个 Agent 加清晰的任务约束,别动并发。

接下来读哪个文件,按你的问题挑:想搞清焦点是怎么被搬走的,从 browser_use/browser/session.pyget_or_create_cdp_session 往下追;想知道某类浏览器副作用有没有人管,去 browser_use/browser/watchdogs/ 那 14 个文件里对照命名找;想比较两种并行写法,examples/browser/parallel_browser.pyexamples/features/parallel_agents.py 对着读一遍最省事——两个文件都带着 experimental 注释,但前者靠独立 user_data_dir 把隔离做实了,后者共用 session、焦点问题全留在了台面上。整个 examples/ 下有 124 个文件,browser_use/llm/ 下有 15 个 provider 目录——模型接哪家在这个项目里基本是配置问题,而并发能开多大是浏览器问题,两者别混着调。

把并发任务切成互不干扰的单元,是这套机制能不能用起来的前提,思路见工作区隔离。项目采用 MIT 许可证,源码在 https://github.com/browser-use/browser-use ,上面每一条都可以自己打开对着看。

本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 读 browser-use 的 beta 支线什么时候别用 browser-use

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。