读 browser-use 的 beta 支线:这个开源项目想把 Agent 的哪几块换成 Rust 实现
本文基于 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 对外只导出三个实体:Agent、BetaAgentError、find_browser_use_terminal_binary,再加一批懒加载符号——BrowserSession、BrowserProfile 以及 ChatOpenAI、ChatAnthropic、ChatGoogle、ChatBrowserUse、ChatGroq、ChatLiteLLM、ChatMistral、ChatAzureOpenAI、ChatOCIRaw、ChatOllama、ChatVercel。这些名字和主包里的同名类是同一个东西,走的是 importlib.import_module 转发。也就是说,beta 包没有另起一套模型抽象,它复用的还是主包 browser_use/llm/ 下那 15 个 provider 目录。
真正被替换的是 Agent 本身。browser_use/beta/service.py 里的 Agent.__init__ 参数表几乎和主包的 Agent 对齐:task、llm、browser_session、tools、sensitive_data、initial_actions、output_model_schema、max_actions_per_step、calculate_cost、llm_timeout、message_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.close 和 browser.close。
_run_sdk_agent() 里那句 result = await sdk.call(method, params) 就是整次 Agent 执行的全部——一次 JSON-RPC 请求,等一个响应。中间的每一步决策、每次工具调用,Python 都不参与,只能从通知流里旁听。
三、协议层长什么样:一个 stdio 客户端撑住整条链路
因为决策权交出去了,这条链路上最容易出问题的地方从「模型选错动作」变成了「传输把结果吞了」。RustSdkClient 里三个可调参数就是为这个准备的:BROWSER_USE_SDK_STREAM_LIMIT_BYTES、BROWSER_USE_SDK_READ_CHUNK_BYTES、BROWSER_USE_SDK_MAX_LINE_BYTES。_read_stdout() 不用 asyncio 的按行读,而是自己按块读、自己找 \n 切 JSON-RPC 帧。集成清单里给了原因:一次响应里塞进截图、工具输出和可观测数据之后,单行会大到触发 asyncio 的分隔符长度限制。
进来的消息分两类:method 是 agent.event 或 agent.projected_event 的算通知,追加进 notifications 列表(只留最近的一批)并塞入 notification_queue;带 id 的才是响应,去 _pending 里找 future。_log_sdk_progress() 从队列里捞通知,用 _sdk_notification_summary() 压成一行进日志,还会把 model.stream_delta、tool.output_delta、browser.script.output_delta 这类高频增量事件过滤掉,相同摘要在一段冷却时间内不重复打印。
下面这张表是这条支线的骨架,位置都是这次实际读过的文件:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| beta 包出口 | 导出 Agent、BetaAgentError、find_browser_use_terminal_binary 与懒加载的 Chat* / BrowserSession | browser_use/beta/__init__.py | 换 import 那一行的时候 |
| 二进制发现与协议握手 | 找 browser-use-terminal、拼 sdk-server --transport stdio、用 runtime.ping 校验 SDK 协议版本 | browser_use/beta/service.py 里 find_browser_use_terminal_binary()、_sdk_server_argv()、_ensure_sdk_client() | 第一次跑不起来、报找不到二进制或协议不匹配 |
| stdio JSON-RPC 客户端 | 起子进程、按块读 stdout 切帧、分派响应与通知 | browser_use/beta/service.py 里 RustSdkClient | 大历史被截断、进程提前退出、响应拿不到 |
| 参数映射层 | 把 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 是 task、cwd、llm、max_steps、browser_mode、browser、calculate_cost、use_vision、max_actions_per_step、config_overrides,条件项还有 agent_id、browser_id、followups、output_schema。llm 里只有 provider、model 和 timeout 三项。浏览器那块由 _sdk_browser_payload() 负责,它逐个 put:cdp_url、cdp_headers、user_agent、viewport、window_size、storage_state、downloads_path、allowed_domains、blocked_domains、state_dir、no_viewport、accept_downloads、headless、keep_alive、profile_id、proxy_country_code。空值和空字符串会被跳过。
browser_mode 由 _browser_mode() 判定:拿到 CDP 地址就是 remote-cdp,否则先看 BROWSER_USE_RUST_BROWSER_MODE、再看 BROWSER_USE_BROWSER_MODE,云端偏好为真则 cloud,剩下按 headless 落到 managed-headed 或 managed-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_KEY、LLM_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_count、last_token_usage 与 provider model.usage 谁该当账单来源的修改。各家模型服务商的计费规则不同且会调整,具体口径以官方最新说明为准;这里能确定的只是:这个数字是拼出来的,不要直接当账单用。 想把自建统计做扎实,可以参考 Agent 的成本失控从哪来 里那套按事件对账的思路。
留在 Python 的还有一批与执行无关的设施:MessageManager 与 SystemPrompt 仍被构造出来(主包 browser_use/agent/system_prompts/ 下有 8 份系统提示词)、FileSystem、ScreenshotService、TokenCost、ProductTelemetry、EventBus,以及一整套 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 search、browser_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_state、profile_id、cdp_url,意味着它可以带着你已有的登录态去操作真实账号;downloads_path 意味着它能往磁盘写文件;proxy_country_code 意味着流量出口可能不在你机房。由此产生的边界必须自己划:目标站点的使用条款是否允许自动化访问;遇到验证码与反自动化机制时该停下来而不是想办法穿过去(本文也不讨论穿过去的做法);高频自动操作可能让账号被判为异常;带登录态跑第三方页面时,凭据的外泄面等于模型能看到的页面内容加上工具能读到的一切。
仓库自己也留了一处提醒:_warn_sensitive_data_domain_constraints() 在传了 sensitive_data 却没锁 allowed_domains 时会打警告,文案直接点到提示注入导致敏感数据外泄的场景。这也说明 allowed_domains 在这套设计里不是可选项。相关的权限收口思路可以看 最小权限怎么落到 Agent 上。
六、上手与避坑清单
- 先跑
examples/beta_agent/basic.py再改自己的代码。 会踩的是链路问题被当成业务问题:二进制没找到、协议版本不对、CDP 连不上,任何一处失败都表现为「Agent 没结果」。这个示例只依赖BU_TASK、BU_MAX_STEPS、BU_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)') - 别用
pip show之类的包版本判断能力边界,要用二进制自检。 会踩的原因是能力实际由那个 Rust 二进制决定:仓库里的做法是跑--help找sdk-server字样,握手时再用runtime.ping的返回值核协议版本对不对得上。所以定位问题时先确认你手上是哪个二进制(必要时用BROWSER_USE_TERMINAL_BINARY显式指定),再谈别的。 - 看到「没有最终结果」先看是不是传输问题。 会踩的原因是失败长得都一样:真的没做完、响应被压缩、通知流和响应流不一致,最后都落成一句空结果。日志里
Rust SDK reconstructed history那行会同时打出响应事件数、通知事件数、usage 事件数和最终答案来源,先读这行再决定往哪查。 follow_up()前先确认会话还在。 会踩的原因是它有前置条件:terminal_session_id和内部的 SDKagent_id缺一个就直接抛BetaAgentError,提示要先run()。跨进程、跨请求复用 Agent 对象时尤其容易撞上——那两个 id 是内存态。keep_alive决定收尾行为,别靠猜。 会踩的原因是清理逻辑是条件式的:close()会调_close_sdk_browser_resources()与_close_sdk_client_if_not_keep_alive(),而两者都先问_should_keep_browser_alive()。设了 keep_alive,子进程和浏览器就留着;批量跑任务时这是复用,长跑服务里这可能是泄漏。- 上真实站点前把
allowed_domains和sensitive_data一起定下来。 会踩的原因是默认不设域名白名单也能跑,只会多一条警告,而 Agent 走错站点的后果不是警告级别的。顺序应当是:先定允许域名,再决定要不要给凭据,最后才考虑加storage_state。 - 别指望文档条目和代码默认值永远一致。 会踩的原因上面已经演示过:
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 的两层 skills 和 browser-use 两种并发。