读 browser-use 的 beta 支线:这个开源项目想把 Agent 的哪几块换成 Rust 实现

2026-07-30

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

这条 beta 支线不是给现有 Agent 加功能,它在做一次内核替换:任务循环、工具注册、浏览器会话归属、模型调用这四块的实际执行者被挪到了仓库外的一个 Rust 二进制 browser-use-terminal 里,Python 这侧留下的是参数映射、事件重建和对外那套 AgentHistoryList 形状。 这个判断不需要推测,仓库根目录 BETA_AGENT_INTEGRATION_FEATURES.md 的开头就把边界写死了:这条分支不改动 Python Agent,除非调用方显式写 from browser_use.beta import Agent

站内已有三篇相邻的文章,分工和本篇不同:Agent 是做成状态机还是放开自由度 讲的是控制流形态的选择,主流 Agent 框架横向对比 讲的是选型口径,改动边界怎么和团队约定 讲的是协作纪律;本篇只做一件事——把 browser-use 这个具体仓库的 beta 目录读一遍,拿文件里的证据说话,不外推到别的项目。

一、先确认边界:这条支线只在你显式导入时生效

browser_use/beta/__init__.py 对外只导出三个实体:AgentBetaAgentErrorfind_browser_use_terminal_binary,再加一批懒加载符号——BrowserSessionBrowserProfile 以及 ChatOpenAIChatAnthropicChatGoogleChatBrowserUseChatGroqChatLiteLLMChatMistralChatAzureOpenAIChatOCIRawChatOllamaChatVercel。这些名字和主包里的同名类是同一个东西,走的是 importlib.import_module 转发。也就是说,beta 包没有另起一套模型抽象,它复用的还是主包 browser_use/llm/ 下那 15 个 provider 目录。

真正被替换的是 Agent 本身。browser_use/beta/service.py 里的 Agent.__init__ 参数表几乎和主包的 Agent 对齐:taskllmbrowser_sessiontoolssensitive_datainitial_actionsoutput_model_schemamax_actions_per_stepcalculate_costllm_timeoutmessage_compaction 等一个不少。文件末尾还有一个 _align_browser_use_agent_signatures(),它从 browser_use.agent.service 里 import _PythonAgent(那就是主包 Agent 的别名),把同名方法的 __signature____annotations__ 逐个拷过来,连 Agent.__module__ 都被改写成 'browser_use.agent.service'

这个细节值得停一下:签名对齐是刻意做的,目的是让替换对调用方不可见。 你的业务代码、IDE 提示、文档里的参数名都不用改,只改一行 import。代价是排障时容易被误导——看到的类名和模块名像主包,实际执行的却不是 Python 那套循环。

二、被换掉的第一块:执行循环搬进了 Rust 运行时

主包 Agent 的心跳是 Python 里的步循环:取浏览器状态、组消息、调模型、解析动作、执行动作、写一条历史。beta 的 Agent.run() 里没有这套循环——它调 _run_terminal(),做完日志与生命周期初始化后直接进 _run_sdk_agent(),然后把整个任务当成一次远程调用发出去。

发出去的对象是一个子进程。_sdk_server_argv() 把命令行拼出来:

def _sdk_server_argv(self) -> list[str]:
	explicit = os.environ.get('BROWSER_USE_SDK_SERVER')
	command = [explicit] if explicit else [find_browser_use_terminal_binary()]
	return [
		*command,
		*self._state_dir_args(),
		'sdk-server',
		'--transport',
		'stdio',
	]

find_browser_use_terminal_binary() 的查找顺序也写得很直白:先看环境变量 BROWSER_USE_TERMINAL_BINARY,再试从 browser_use_core 包里取打包好的二进制,然后按 BUT_HOME(默认 ~/.browser-use-terminal)和 BUT_INSTALL_DIR(默认 ~/.local/bin)拼候选路径,最后才 shutil.which。候选路径还要过一道 _terminal_supports_sdk_server():跑一次 --help,在输出里找 sdk-server 这个词,找不到就不认。都失败则抛 BetaAgentError,错误文案里带安装提示。

进程起来之后走的是 RustSdkClient,注释写的是「Minimal stdio JSON-RPC client for browser-use-terminal sdk-server」。握手用 runtime.ping,返回里的 sdk_protocol_version 必须等于代码里硬编码的那个期望值,否则关掉子进程并抛错,错误文案会把实际收到的值打出来。任务方法有两个:首次跑 agent.run_task,当已经拿到 agent_id 或者带 followups 时改用 agent.run。收尾时按需调 agent.closebrowser.close

_run_sdk_agent() 里那句 result = await sdk.call(method, params) 就是整次 Agent 执行的全部——一次 JSON-RPC 请求,等一个响应。中间的每一步决策、每次工具调用,Python 都不参与,只能从通知流里旁听。

三、协议层长什么样:一个 stdio 客户端撑住整条链路

因为决策权交出去了,这条链路上最容易出问题的地方从「模型选错动作」变成了「传输把结果吞了」。RustSdkClient 里三个可调参数就是为这个准备的:BROWSER_USE_SDK_STREAM_LIMIT_BYTESBROWSER_USE_SDK_READ_CHUNK_BYTESBROWSER_USE_SDK_MAX_LINE_BYTES_read_stdout() 不用 asyncio 的按行读,而是自己按块读、自己找 \n 切 JSON-RPC 帧。集成清单里给了原因:一次响应里塞进截图、工具输出和可观测数据之后,单行会大到触发 asyncio 的分隔符长度限制。

进来的消息分两类:methodagent.eventagent.projected_event 的算通知,追加进 notifications 列表(只留最近的一批)并塞入 notification_queue;带 id 的才是响应,去 _pending 里找 future。_log_sdk_progress() 从队列里捞通知,用 _sdk_notification_summary() 压成一行进日志,还会把 model.stream_deltatool.output_deltabrowser.script.output_delta 这类高频增量事件过滤掉,相同摘要在一段冷却时间内不重复打印。

下面这张表是这条支线的骨架,位置都是这次实际读过的文件:

组成部分它负责什么对应仓库位置你什么时候会碰到它
beta 包出口导出 AgentBetaAgentErrorfind_browser_use_terminal_binary 与懒加载的 Chat* / BrowserSessionbrowser_use/beta/__init__.py换 import 那一行的时候
二进制发现与协议握手browser-use-terminal、拼 sdk-server --transport stdio、用 runtime.ping 校验 SDK 协议版本browser_use/beta/service.pyfind_browser_use_terminal_binary()_sdk_server_argv()_ensure_sdk_client()第一次跑不起来、报找不到二进制或协议不匹配
stdio JSON-RPC 客户端起子进程、按块读 stdout 切帧、分派响应与通知browser_use/beta/service.pyRustSdkClient大历史被截断、进程提前退出、响应拿不到
参数映射层把 browser-use 风格选项翻成 SDK 请求体(llm / browser / max_steps / output_schema 等)browser_use/beta/service.py_sdk_run_params()_sdk_llm_payload()_sdk_browser_payload()_run_env()某个 profile 设置在 Rust 侧没生效,需要确认它有没有被传下去
历史与用量重建把归一化事件流还原成 AgentHistoryList、去重、算 usage、补最终答案browser_use/beta/service.py_history_from_events()_dedupe_sdk_events()_sdk_notification_events()_usage_from_events()最终结果为空、token 统计对不上、trace 里少东西
可选的前置导航CDP 会话下先在 Python 侧导航一次并核对 URL,再把状态写进任务上下文browser_use/beta/service.py_execute_direct_initial_navigation_actions()_direct_initial_navigation_enabled()任务带起始 URL、模型开局反复重新导航
集成清单与验证记录逐条记录当前特性和跑过的验证命令BETA_AGENT_INTEGRATION_FEATURES.md想知道某个行为是不是刚改的
最小可跑示例环境变量驱动的一个 run() 调用examples/beta_agent/basic.py第一次验证链路是否通
回归测试覆盖会话复用、大帧读取、参数翻译、通知恢复等tests/ci/test_beta_agent.py想知道某个行为有没有被钉住

四、Python 这侧还留着什么

答案是:对外契约和账本,加上一部分前置动作。

请求体的组装留在 Python。 _sdk_run_params() 组出来的 key 是 taskcwdllmmax_stepsbrowser_modebrowsercalculate_costuse_visionmax_actions_per_stepconfig_overrides,条件项还有 agent_idbrowser_idfollowupsoutput_schemallm 里只有 providermodeltimeout 三项。浏览器那块由 _sdk_browser_payload() 负责,它逐个 putcdp_urlcdp_headersuser_agentviewportwindow_sizestorage_statedownloads_pathallowed_domainsblocked_domainsstate_dirno_viewportaccept_downloadsheadlesskeep_aliveprofile_idproxy_country_code。空值和空字符串会被跳过。

browser_mode_browser_mode() 判定:拿到 CDP 地址就是 remote-cdp,否则先看 BROWSER_USE_RUST_BROWSER_MODE、再看 BROWSER_USE_BROWSER_MODE,云端偏好为真则 cloud,剩下按 headless 落到 managed-headedmanaged-headless。除了请求体,还有一份环境变量:_run_env() 会把 CDP 地址塞进 BU_CDP_URL、把域名白名单黑名单塞进 BU_BROWSER_ALLOWED_DOMAINS / BU_BROWSER_PROHIBITED_DOMAINS_llm_env_overrides() 则按 provider 把 api_key / base_url 落到 LLM_BROWSER_OPENAI_API_KEYLLM_BROWSER_ANTHROPIC_API_KEY 这类变量上。同一件事有两条通路(params 与 env),排障时两边都得看。

历史是重建出来的,不是记录出来的。 _history_from_events() 先过 _events_after_terminal_compaction()_events_after_terminal_rollbacks(),再抽最终结果、失败原因、附件,然后按 turn 切成历史项。这里有一段防御逻辑值得单独记住:如果响应里的事件为空、含 sdk.transport.truncated、比通知流更短,或者通知流里有最终结果而响应里没有,就整体切换到通知流重建;切换之后如果通知流里确实有最终结果,process_error 会被清掉——也就是说传输层报的错不算任务失败。判断传输错误的 _sdk_transport_error_after_final_result() 是靠匹配错误文案片段实现的,比如 Rust SDK JSON-RPC line exceeded。这类兜底越多,说明这条链路上「结果丢了」出现过越多次。

用量和成本也是从事件里算的。 _usage_from_events() 吃的是 usage_events,而 usage_events 可能来自响应的 usage_events 字段、可能是父子事件拼起来的,还可能被 _usage_event_from_sdk_history_usage() 换成响应里的聚合值——条件是聚合值的 token 数比事件流算出来的更大。集成清单里对应着好几条关于 token_countlast_token_usage 与 provider model.usage 谁该当账单来源的修改。各家模型服务商的计费规则不同且会调整,具体口径以官方最新说明为准;这里能确定的只是:这个数字是拼出来的,不要直接当账单用。 想把自建统计做扎实,可以参考 Agent 的成本失控从哪来 里那套按事件对账的思路。

留在 Python 的还有一批与执行无关的设施:MessageManagerSystemPrompt 仍被构造出来(主包 browser_use/agent/system_prompts/ 下有 8 份系统提示词)、FileSystemScreenshotServiceTokenCostProductTelemetryEventBus,以及一整套 Laminar span 记录函数。对照着看更清楚的是那些没有参与 beta 路径的东西:主包 browser_use/browser/watchdogs/ 下 14 个 watchdog,是给 Python 自己驱动的浏览器会话用的;beta 路径下浏览器由 Rust 拥有,Python 只在做前置导航时才碰 browser_session

那个前置导航是这条支线里少见的一处口径分歧,值得亲手核。集成清单写的是:CDP 场景下的首次导航默认交给 Rust SDK,旧的 Python 直连预导航要靠 BROWSER_USE_RUST_DIRECT_INITIAL_NAVIGATION=1 打开。但代码里的判定是这样:

def _direct_initial_navigation_enabled() -> bool:
	raw = os.getenv('BROWSER_USE_RUST_DIRECT_INITIAL_NAVIGATION')
	if raw is None:
		return True
	return raw.strip().lower() not in {'0', 'false', 'no', 'off'}

不设变量返回的是 True。两处以哪个为准,只能看你手上那个 commit——这也正是为什么这条支线的文档叫 ledger(台账)而不是手册。另外,_execute_direct_initial_navigation_actions() 只在拿到 CDP 地址、browser_session 上有可调用的 navigate_to、且初始动作全是导航类(open_tab / go_to_url / navigate)时才动手;导航后还要用 _direct_initial_navigation_state_matches() 比对域名与路径,比不上就清空已完成记录、把导航退回给 Rust 任务上下文。

五、边界与代价:它放弃了什么、不管什么

放弃了对单步的控制。 主包那套按步插桩的位置在 beta 路径上基本失效:一次 run() 就是一次 RPC,on_step_start 在请求发出前调一次,on_step_end 和 done 回调在响应回来后统一补。同名方法确实还在,但语义换了:beta 的 step() 转给 take_step(),后者的实现是 run(max_steps=1),等于再发一次 RPC;multi_act() 的文档字符串把话说得很直白——浏览器动作归 Rust 终端所有,非 done 的动作批次会被序列化成一条 follow-up 指令交给当前 Rust 会话,只有单独的 done 动作还保留 Python 本地的完成语义。所以想在跑到某一步之后插人工确认,只能靠外层把任务切成段:每段用一次 run()take_step() 收口,回来由你的调度器决定是否继续,别指望框架在循环中间给你回调。

放弃了工具面的自主掌控。 集成清单里明确写了不再下发 tool_allowlist 覆盖,工具注册表归终端 SDK server 所有。好处是 Rust 侧新注册的能力(清单里提到本地执行的 DuckDuckGo Lite searchbrowser_script 系列、有 child runner 时才出现的子代理工具)不改 Python 就能用;代价是你在 Python 侧传的 tools 不再是那个真正在跑的工具集,能力边界随二进制版本浮动。

它不管旧的进程适配路径。 清单的 Known Transitional Debt 一节写着:生产路径不再走 _run_process / _load_events、不再拼 CLI run-* 命令;browser_use.beta.Agent 也不再把 _run_process 的 monkeypatch 当作备用运行时。你要是照着老写法去 patch,会静默失效。

它不替你承担浏览器侧的现实风险,这一条最该写清楚。 这类 Agent 驱动的是真实浏览器:_sdk_browser_payload() 里能传 storage_stateprofile_idcdp_url,意味着它可以带着你已有的登录态去操作真实账号;downloads_path 意味着它能往磁盘写文件;proxy_country_code 意味着流量出口可能不在你机房。由此产生的边界必须自己划:目标站点的使用条款是否允许自动化访问;遇到验证码与反自动化机制时该停下来而不是想办法穿过去(本文也不讨论穿过去的做法);高频自动操作可能让账号被判为异常;带登录态跑第三方页面时,凭据的外泄面等于模型能看到的页面内容加上工具能读到的一切。

仓库自己也留了一处提醒:_warn_sensitive_data_domain_constraints() 在传了 sensitive_data 却没锁 allowed_domains 时会打警告,文案直接点到提示注入导致敏感数据外泄的场景。这也说明 allowed_domains 在这套设计里不是可选项。相关的权限收口思路可以看 最小权限怎么落到 Agent 上

六、上手与避坑清单

  1. 先跑 examples/beta_agent/basic.py 再改自己的代码。 会踩的是链路问题被当成业务问题:二进制没找到、协议版本不对、CDP 连不上,任何一处失败都表现为「Agent 没结果」。这个示例只依赖 BU_TASKBU_MAX_STEPSBU_CDP_URL / BROWSER_USE_CDP_URL 这几个环境变量,链路通不通一眼能定。
    agent = Agent(
        task=task,
        llm=ChatBrowserUse(model='openai/gpt-5.5'),
        browser_session=browser_session,
    )
    history = await agent.run(max_steps=max_steps)
    print(history.final_result() or '(no final result)')
    
  2. 别用 pip show 之类的包版本判断能力边界,要用二进制自检。 会踩的原因是能力实际由那个 Rust 二进制决定:仓库里的做法是跑 --helpsdk-server 字样,握手时再用 runtime.ping 的返回值核协议版本对不对得上。所以定位问题时先确认你手上是哪个二进制(必要时用 BROWSER_USE_TERMINAL_BINARY 显式指定),再谈别的。
  3. 看到「没有最终结果」先看是不是传输问题。 会踩的原因是失败长得都一样:真的没做完、响应被压缩、通知流和响应流不一致,最后都落成一句空结果。日志里 Rust SDK reconstructed history 那行会同时打出响应事件数、通知事件数、usage 事件数和最终答案来源,先读这行再决定往哪查。
  4. follow_up() 前先确认会话还在。 会踩的原因是它有前置条件:terminal_session_id 和内部的 SDK agent_id 缺一个就直接抛 BetaAgentError,提示要先 run()。跨进程、跨请求复用 Agent 对象时尤其容易撞上——那两个 id 是内存态。
  5. keep_alive 决定收尾行为,别靠猜。 会踩的原因是清理逻辑是条件式的:close() 会调 _close_sdk_browser_resources()_close_sdk_client_if_not_keep_alive(),而两者都先问 _should_keep_browser_alive()。设了 keep_alive,子进程和浏览器就留着;批量跑任务时这是复用,长跑服务里这可能是泄漏。
  6. 上真实站点前把 allowed_domainssensitive_data 一起定下来。 会踩的原因是默认不设域名白名单也能跑,只会多一条警告,而 Agent 走错站点的后果不是警告级别的。顺序应当是:先定允许域名,再决定要不要给凭据,最后才考虑加 storage_state
  7. 别指望文档条目和代码默认值永远一致。 会踩的原因上面已经演示过:BROWSER_USE_RUST_DIRECT_INITIAL_NAVIGATION 在清单里的描述和 _direct_initial_navigation_enabled() 的返回值就不一样。这个仓库用 MIT 许可证、代码公开,遇到行为存疑的开关,直接读函数比读说明快。

收束成一份自检清单:你能说清手上跑的是哪个终端二进制吗;browser_mode 实际落成了哪个值;域名白名单和凭据是不是配套的;日志里那行历史重建计数你看过吗;用量数字你知道它是从哪条事件流拼出来的吗。五个问题都有答案,这条支线就可以进你的试验环境。

接着往下读的顺序建议是:BETA_AGENT_INTEGRATION_FEATURES.md(知道最近改了什么)→ browser_use/beta/service.py_sdk_run_params()_sdk_browser_payload()(知道你的配置有没有被传下去)→ _history_from_events() 及其周边(知道你看到的历史是怎么来的)→ tests/ci/test_beta_agent.py(知道哪些行为被钉住了)。仓库 examples/ 下有 124 个文件,beta_agent 目录里目前只有一个示例,这个比例本身也是个信息:这条支线还在验证阶段,别把它当成稳定接口用在关键链路上。

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

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