browser-use 本地跑不住时:远程浏览器与沙箱执行的分工与代价
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
在 browser-use 里,远程浏览器和沙箱执行不是同一件事的两种规格:前者只把 Chromium 挪到别的机器上,你的 Python 进程、断点、日志都还在本机;后者把你写的那个函数连闭包和全局变量一起打包送到远端执行,本机只剩一条事件流。 选错的代价不是慢一点,而是调试手感和数据边界同时变化——尤其是第二条路,会顺手把你以为不会离开本机的东西一起带走。
一、先分清你到底扛不住哪一段
默认情况下这个项目跑的是本机浏览器。browser_use/browser/profile.py 里 is_local 默认为 False,但 BrowserSession 初始化时有一段回填逻辑:既没有 cdp_url 也没开云浏览器时,会把 is_local 置成 True;给了 executable_path 同样会被判成本地。真正启动发生在 browser_use/browser/session.py 的 on_BrowserStartEvent 里,本地分支通过事件总线派发 BrowserLaunchEvent,由本地浏览器 watchdog 拉起进程,再把拿到的 CDP 地址填回 profile。
本机顶不住通常先坏在三处,值得先确认是哪一处,否则搬走也白搬。一是内存与 CPU:每个会话都是一个真实 Chromium,加上 browser_use/browser/watchdogs/ 下 14 个 watchdog 各自订阅事件,并发几个会话就能把开发机压住。二是连接质量:on_BrowserStartEvent 里给 connect() 套了 15 秒硬超时,超时后会清理半初始化的客户端并抛 RuntimeError,在网络抖动的机器上很容易误判成”项目有问题”。三是环境本身跑不起浏览器:容器里缺依赖、缺沙箱权限时,启动失败的日志里会直接提示改用云浏览器。
这篇只谈 browser-use 这两条路的取舍。容器层面怎么隔离一个 Agent 进程,写在容器隔离的做法里;要不要为 Agent 自建一整套跑批基础设施、判断口径是什么,看AI 基础设施选型;给 Agent 划定可读写工作区的通用约定,在Agent 工作区隔离。三者不重叠,可以按需要串起来看。
二、远程浏览器:只搬 Chromium,控制面留在本机
开关在 profile 上:use_cloud 是布尔字段,cloud_browser 是它的兼容别名,另外还有一个 cloud_browser_params。启动时的分支非常直白:
if not self.cdp_url:
if self.browser_profile.use_cloud or self.browser_profile.cloud_browser_params is not None:
cloud_params = self.browser_profile.cloud_browser_params or CreateBrowserRequest()
cloud_browser_response = await self._cloud_browser_client.create_browser(cloud_params)
self.browser_profile.cdp_url = cloud_browser_response.cdpUrl
self.browser_profile.is_local = False
读懂这四行,远程浏览器这条路的本质就清楚了:它做的事情只是替你拿到一个 CDP 地址。拿到之后走的仍然是和本地完全一样的 connect()、BrowserConnectedEvent、watchdog 挂载流程。也就是说,你的 Agent 循环、模型调用、工具执行、日志全在本机,跨机器传输的是 CDP 协议帧。
干活的是 browser_use/browser/cloud/cloud.py 里的 CloudBrowserClient。它 POST 到 /api/v2/browsers,请求头是 X-Browser-Use-API-Key;密钥先从环境变量 BROWSER_USE_API_KEY 取,取不到再回落读 CloudAuthConfig.load_from_file()。401 抛 CloudBrowserAuthError,403 的文案是让你检查订阅状态。返回体由 CloudBrowserResponse 承接,字段是 id、status、liveUrl、cdpUrl、timeoutAt、startedAt、finishedAt——注意 timeoutAt,远程会话是有寿命的。
参数收在 browser_use/browser/cloud/views.py 的 CreateBrowserRequest 里,只有四项:cloud_profile_id(用哪个云端浏览器 profile,也就是登录态)、cloud_proxy_country_code(出口国家,字面量里列了一组两字母代码,但类型上还并了一个任意字符串,所以填一个不在清单里的值不会在这层被拦住)、cloud_timeout(会话分钟数,字段校验只卡了上界 MAX_PAID_USER_SESSION_TIMEOUT,免费档那个更小的 MAX_FREE_USER_SESSION_TIMEOUT 只写进了字段描述文案、不参与校验,具体数值以仓库和官方最新说明为准)、enable_recording(是否录制以便在控制台回放)。这个模型是 extra='forbid',多传一个键会直接报错,不会静默忽略。
收尾同样在 session 里。on_BrowserStopEvent 会尝试停掉云会话,会话 ID 有两个来源:一是 create_browser 时记下的 current_session_id,二是 _cloud_session_id_from_cdp_url()——从 CDP 地址的主机名里用正则抠出 UUID,这样即使你是拿着别人给的 cdp_url 连上来的,停止时也能清理。这条兜底只认 Browser Use 自家的主机名格式,你自己机房里那些浏览器容器不在这条路径上,得自己收。这里有个前置判断要记牢:keep_alive 为真且不是强制停止时,整段清理逻辑会被跳过。
顺带说一句,这条路不绑定官方托管服务。只要你手上有一个可连的 Chromium CDP 地址,直接传 cdp_url 就能用;connect() 里对 localhost/127.0.0.1 还专门关掉了代理环境变量的信任。自己在机房起一排浏览器容器,同样吃得上这条路。
三、沙箱执行:把那段函数整体搬走
第二条路是 browser_use/sandbox/sandbox.py 提供的 sandbox() 装饰器。示例是仓库里最短的入口:
@sandbox(
log_level='INFO',
on_browser_created=on_browser_ready,
)
async def pydantic_example(browser: Browser):
agent = Agent(
"""go and check my ip address and the location. return the result in json format""",
browser=browser,
llm=ChatBrowserUse(model='bu-2-0'),
)
res = await agent.run()
return res.final_result()
被装饰的函数第一个参数必须叫 browser 且注解里带 Browser,否则装饰阶段就抛 TypeError;调用时你不传 browser,装饰器会把它从签名里摘掉,由远端注入。
真正值得关心的是它怎么把代码送出去。_get_function_source_without_decorator() 用 ast 拿到源码并剥掉装饰器;_get_imports_used_in_function() 反查模块头部,只挑函数体和类型注解里真正引用到的 import;_extract_all_params() 收集参数——显式参数、闭包变量(走 func.__closure__ 与 co_freevars)、以及被函数引用到的模块级全局(遍历 co_names 去 func.__globals__ 里捞)。这三份东西用 cloudpickle 序列化后 base64 编码,拼成一段带 async def run(browser) 的源码字符串,POST 到 https://sandbox.api.browser-use.com/sandbox-stream。
两个细节容易被忽略。其一,这里的请求头是 X-API-Key,和云浏览器那条路的 X-Browser-Use-API-Key 不是同一个名字,照着改配置时别串了。其二,server_url 是装饰器参数,示例里被注释掉的那行就指向 localhost——协议是开放的,你可以先在本地起服务把链路跑通。
回传走 SSE。事件模型定义在 browser_use/sandbox/views.py:SSEEventType 有 browser_created、instance_created、instance_ready、log、result、error、stream_complete;BrowserCreatedData 带 session_id、live_url、status;LogData 用 level 区分 stdout/stderr/info;ResultData 包一层 ExecutionResponse,里面是 success、result、error、traceback。装饰器把这些暴露成 on_browser_created、on_instance_ready、on_log、on_result、on_error 五个回调,quiet=True 可以关掉它自带的终端打印。
四、零件对照与各自的代价
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
use_cloud / cloud_browser_params | 决定启动时走托管浏览器还是本地拉进程 | browser_use/browser/profile.py | 第一次想把浏览器挪出本机 |
on_BrowserStartEvent 的三分支 | 缺 cdp_url 时在云端、本地、报错之间选 | browser_use/browser/session.py | 报”没有 cdp_url 可连”时 |
CloudBrowserClient | 建/停远程浏览器,取回 cdpUrl 与 liveUrl | browser_use/browser/cloud/cloud.py | 鉴权失败、建不出浏览器 |
CreateBrowserRequest | profile、出口国家、会话时长、录制四项参数的定义与校验 | browser_use/browser/cloud/views.py | 想指定登录态或出口地区 |
sandbox() 装饰器 | 打包函数源码与参数上传,流式回传日志和结果 | browser_use/sandbox/sandbox.py | 想让整段脚本都在远端跑 |
| SSE 事件模型 | 各类事件的字段契约 | browser_use/sandbox/views.py | 写回调、做可观测性 |
| 最短可跑示例 | 基础用法与结构化输出用法 | examples/sandbox/example.py、examples/sandbox/structured_output.py | 上手第一次 |
| 托管产品说明 | Session/Profile/Task 概念与 v2 端点清单 | CLOUD.md | 决定用库还是直接调 HTTP 接口 |
examples/ 下有 124 个文件,沙箱相关的只有上面两个,structured_output.py 展示的是配合 output_model_schema 与 get_structured_output() 拿结构化结果。
看完零件表再看代价,取舍就落地了。远程浏览器放弃的是”浏览器在你手边”这件事。timeoutAt 到点会停,长任务必须自己切段并用云端 profile 承接登录态;下载文件不再落到你的磁盘,得走服务端的文件接口取;allowed_domains 这类护栏仍在客户端侧判定,但登录态存在远端 profile 里,两者的信任边界不再重合。它换来的是本机零浏览器依赖、并发不吃本地内存,以及一个可以直接点开看的 liveUrl。
沙箱执行放弃的东西更多,逐条列清楚更有用。
调试手感基本没了。本机不再有那段代码的栈,你只有 SSE 日志和最终的 traceback 字符串;中途连接异常会被统一包成 SandboxError。客户端的 httpx 流超时写死在 1800 秒,长任务会被硬截断。
序列化是硬约束。cloudpickle 抓不住的对象(打开的连接、锁、C 扩展句柄)会直接失败,而它抓得住的东西又可能超出你的预期——被函数体引用到的模块级全局都会进包。
返回值不做校验。_parse_with_type_annotation() 用 Pydantic 的 model_construct 重建对象,明确跳过校验逻辑,函数自己的说明写的是为了递归还原嵌套字段、保住类型保真度。代价是字段值与注解不符时不会有人拦你,问题会推迟到业务代码里才炸。
有几件事这两条路都明确不管:目标站点的使用条款、验证码与反自动化机制的合法性、账号被判为异常的后果。CLOUD.md 里对托管浏览器有一些”不易被识别为机器人”之类的宣称,那是产品描述,不构成你可以无视目标站点条款的依据;把它当成绕过风控的工具,风险和责任都在你这边。这个项目采用 MIT 许可证,许可证给的是代码使用自由,不是访问任意网站的授权。
五、把任务搬到别人机器上之前,先想清楚这几件事
登录态外迁是最大的一件。CLOUD.md 里的 Profile Sync 就是把你本机已登录的浏览器 cookie 上传到云端 profile,之后所有任务复用它。这在个人项目里很方便,在企业账号上多半直接撞内控红线——它等价于把一个已认证会话的持有权临时交给第三方基础设施。至少要问清楚:这个账号能不能被第三方持有?出问题时你怎么撤销?
密钥外迁是设计内的用法,不是你自己乱来。v2 的建任务请求里就有 secrets 和 allowedDomains 字段,说明”把凭证交给远端、同时把可访问域名收窄”是官方预期的组合。别只用前半截。browser_use/agent/service.py 里也有一段对应告警:给了 sensitive_data 却没锁 allowed_domains 时会明确提示,一旦 Agent 访问到恶意站点并遇到提示词注入,敏感数据可能外泄。这条告警值得当成硬门槛,配套的权限收窄思路见最小权限设计。
轨迹留痕范围要提前确认。任务步骤视图里带 screenshotUrl,也就是每一步的截图留在服务端;publicShareUrl 与 public-share 相关端点能一键生成公开分享链接,返回 shareToken、shareUrl、viewCount。截图里出现内部系统界面或用户信息时,这个便利功能就变成泄露面,把它当作需要审批的动作。更完整的排查清单可以对着AI 数据安全风险过一遍。
跨境这件事别忽略。cloud_proxy_country_code 让流量从指定国家出口,如果任务处理的是个人信息,这就涉及跨境传输,规则各地不同且会调整,以主管部门和服务方的最新说明为准。
六、上手与避坑清单
只加了云开关却没给密钥。 会踩是因为密钥有两个来源,本机登录过的开发机能跑,CI 上就抛鉴权错误,看起来像”代码在我这好好的”。避法是把 BROWSER_USE_API_KEY 显式注入 CI 环境,别依赖本地配置文件。
同时给了 cdp_url 和云开关。 启动分支只在 cdp_url 为空时才去建云浏览器,给了地址就直接连,云开关像是没生效。避法是两者只留一个。
is_local=False 又没给 cdp_url。 直接抛 ValueError,文案就是”没有 cdp_url 可连”。这通常是把 profile 字段改了一半留下的,回头确认 use_cloud 有没有真的打开。
keep_alive=True 时以为停了。 停止处理里第一件事就是判断 keep_alive,为真且非强制时直接返回,云会话的清理逻辑整段被跳过,计时还在走。避法是收尾时走强制停止,或在流程末尾显式停会话。
沙箱函数首参没叫 browser。 装饰阶段就抛 TypeError,注解里不含 Browser 也一样。这个错在导入时就出现,不是运行时,看到它别去查网络。
把凭证放模块级常量。 会踩是因为参数收集会遍历函数引用到的名字去全局里捞值,一起打包上传。避法是让被装饰的函数尽量自包含,需要的凭证通过装饰器的环境变量入口传,而不是散在模块顶部。
信任了远端返回对象的类型。 重建走 model_construct,不跑校验。避法是在业务侧对关键字段自己做一次断言,或直接用结构化输出示例里的模式,把 schema 约束前移到 Agent。
长任务不拆。 远端会话有 timeoutAt,客户端流超时另有上限,两道闸门都会截断。避法是按里程碑拆任务、用云端 profile 承接登录态续跑,把”能从中间恢复”当成任务设计的前提。
出问题无从下手。 SSE 中途断开会被统一包成一种异常,信息很薄。避法是上线前就把 on_log 和 on_error 接上并落盘,别只靠终端里那几行彩色输出。
收束
判断顺序其实很短:本机跑得动就别搬;只是浏览器吃不住,走远程浏览器,控制面留在本机,调试成本几乎不变;本机连 Python 环境都不合适(Serverless、受限容器、要并发几十个),才考虑沙箱执行,并接受调试退化和序列化约束。凡是涉及真实账号登录态的任务,先过一遍合规那一节的几个问题,再决定搬不搬。
给自己留三条自检:搬走之后,哪些数据离开了你的机器,你能列全吗?远端会话到点停止时,任务能安全恢复吗?出事时你手上有没有可回溯的日志?
接下来该读哪个文件:想把这条路接进现有代码,从 browser_use/browser/session.py 的 on_BrowserStartEvent 和 on_BrowserStopEvent 两个方法读起,这是两条路唯一交汇的地方;想评估沙箱是否可接受,重点读 browser_use/sandbox/sandbox.py 里的参数收集函数,看清楚哪些东西会被打包上传。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 的产物落盘 和 browser-use 容器化拆解。