browser-use 跑不动时的排查顺序:异常类型、崩溃看护、CDP 超时与日志分层
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
排查 browser-use 的第一件事不是看模型输出,而是先判断这次失败发生在哪一层:浏览器进程还活着吗、CDP 通道还回话吗、模型还给不给结构化结果。 这三层的症状在终端里长得很像——都是一条红色 ERROR 加一句”失败 N/6 次”,但修法完全不同。仓库里恰好有三处代码分别对应这三层的兜底,读完它们,你就有了一条固定的排查顺序,不用每次靠猜。
这篇只讲定位顺序和这几个机制的边界。通用的 Agent 失败分类框架在Agent 失败模式分类里,跨框架的调试手法在Agent 框架调试里,重试与幂等的取舍在Agent 失败重试里;本篇是把这套方法落到 browser-use 这一个具体仓库的源码上,告诉你去读哪个文件、看哪个日志器。
一、先把故障分家:一张模块地图
先建立索引。下面这张表里的路径都是仓库里的真实文件,排查时按这个顺序往下走,比全文搜索报错字符串快得多。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
LLMException | 只带 status_code 和 message 的一个异常类 | browser_use/exceptions.py | 几乎碰不到,别把它当主链路 |
ModelProviderError / ModelRateLimitError / ModelOutputTruncatedError | 模型侧错误分层,带 status_code 与 model | browser_use/llm/exceptions.py | 被限流、密钥失效、输出被截断 |
BrowserError / URLNotAllowedError | 浏览器动作失败,且携带要喂给模型的记忆字段 | browser_use/browser/views.py | 元素操作失败、域名被策略拦住 |
CrashWatchdog | 周期体检 + 监听崩溃事件,出事派发 BrowserErrorEvent | browser_use/browser/watchdogs/crash_watchdog.py | 标签页崩了、浏览器进程变僵尸 |
TimeoutWrappedCDPClient | 给每个 CDP 请求套超时,把静默挂死变成 TimeoutError | browser_use/browser/_cdp_timeout.py | 连远端云浏览器、WebSocket 假活 |
_handle_step_error | 步内异常分诊,并累加 consecutive_failures | browser_use/agent/service.py | 每一次报错都会经过它 |
_auto_reconnect | WebSocket 掉线后的重连退避 | browser_use/browser/session.py | 网络抖动、代理换上游 |
setup_logging / setup_log_pipes | handler、级别、名字改写、三方静音、管道分流 | browser_use/logging_config.py | 日志里没有你要的那一层信息 |
顺序上,_handle_step_error 是所有步内异常的必经之路,所以它的分支逻辑就是天然的分诊表;两个看护机制在它之外独立跑;日志配置决定你能不能看见前面这些东西。
二、异常类型说明了什么
新手容易被 browser_use/exceptions.py 误导。这个文件从头到尾只有一个类:
class LLMException(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
super().__init__(f'Error {status_code}: {message}')
在仓库里搜 LLMException,除了这个定义之外没有别的引用点。也就是说,你在实际运行中看到的模型侧错误不是它,而是 browser_use/llm/exceptions.py 里那一组:ModelError 是根,ModelProviderError 带 message、status_code(默认 502)和 model,ModelRateLimitError 把默认状态码设成 429,ModelOutputTruncatedError 则专门表示输出在 token 上限处被切断,它的默认状态码是 400——注释里写明这么设是为了”让它别落进同一家 provider 的重试循环”。
这个分层不是摆设。browser_use/agent/service.py 里解析模型输出的地方捕获 (ModelRateLimitError, ModelProviderError),交给 _try_switch_to_fallback_llm 决定是否换到备用模型;判断可重试的依据是异常类型或状态码落在 {401, 402, 429, 500, 502, 503, 504} 集合里,而且注释明确列了每个码的含义(401 密钥失效、402 额度问题、429 限流、5xx 服务端)。换过一次之后 _using_fallback_llm 置真,不会再换第二次。所以你看到日志里说”no fallback_llm configured”,那不是模型的问题,是你没配备用模型。至于各家服务商具体在什么条件下返回 429、返回体长什么样,各家规则不同且会调整,以官方最新说明为准。
浏览器侧的异常是另一套。browser_use/browser/views.py 里的 BrowserError 除了 message,还带 short_term_memory、long_term_memory、details 和 while_handling_event 四个字段,docstring 写得很直白:short_term_memory 是”只给模型看一次”的上下文,long_term_memory 是”跨步保留”的错误信息。这意味着一个浏览器动作失败时,报错文本会被有意设计成模型能消化的形式,而不只是给你看的堆栈。URLNotAllowedError 继承它,表示 URL 被策略拒绝。
第三类是连接类。它没有专门的异常类,而是靠字符串特征识别:
def _is_connection_like_error(self, error: Exception) -> bool:
error_str = str(error).lower()
return (
isinstance(error, ConnectionError)
or 'websocket connection closed' in error_str
or 'connection closed' in error_str
or 'browser has been closed' in error_str
or 'browser closed' in error_str
or 'no browser' in error_str
)
_handle_step_error 的分诊顺序是:InterruptedError 视为正常中断直接返回;连接类错误如果正赶上会话在重连,就等最多 RECONNECT_WAIT_TIMEOUT(54.0 秒);确认浏览器真的关掉了就把 state.stopped 置真终止;其余一切走通用分支,累加 consecutive_failures,把错误文本塞进 ActionResult(error=...)。这里有个对你有用的细节:只有累计失败达到上限时才用 ERROR 级别打印,之前都是 WARNING。所以终端里的黄色警告不代表可以忽略,它可能已经是第 4 次同样的失败了。
三、崩溃看护与 CDP 超时各自兜哪一类
这两个机制经常被混为一谈,其实一个管”进程死了”,一个管”通道哑了”。
崩溃看护:浏览器还是活的吗
CrashWatchdog 是 browser_use/browser/watchdogs/ 下 14 个看护器之一。它监听浏览器连接、停止、标签页创建与关闭四个事件,只派发 BrowserErrorEvent。做的事分三块:
一是在 attach_to_target 里给目标注册 Target.targetCrashed 回调,崩溃时派发 error_type='TargetCrash' 的事件,details 里带上 URL、target_id 和”崩的是不是 agent 当前聚焦的标签页”。日志里那句”Agent focus tab crashed”后面跟着”SessionManager will auto-recover”,说明看护器本身不负责恢复,只负责报告。
二是周期体检 _check_browser_health,核心是一次极小的探活:
await asyncio.wait_for(
cdp_session.cdp_client.send.Runtime.evaluate(params={'expression': '1+1'}, session_id=cdp_session.session_id),
timeout=1.0,
)
只给 1.0 秒。探活失败不抛出,而是打一条 ERROR,措辞是”Chrome 会发 detach 事件,SessionManager 会自动恢复”。所以你在日志里看到”Crashed/unresponsive session detected”,它是一个信号,不是终止原因。
三是进程状态检查:拿到本地浏览器子进程后,如果 psutil 报的状态是僵尸或已死,就派发 error_type='BrowserProcessCrashed',然后主动停掉自己的监控循环——进程都没了,继续体检没有意义。
节奏上要记住两个数:监控循环启动后先 await asyncio.sleep(10),注释说是留给浏览器启动和首次 LLM 调用后的首屏加载;之后每轮间隔 check_interval_seconds,默认 5.0 秒。这意味着运行前 10 秒的崩溃,你不会从体检里看到,只能从崩溃事件回调里看到。
还有一处值得你自己去仓库确认:这个文件里定义了 _on_request_cdp、_on_response_cdp、_on_request_failed_cdp、_on_request_finished_cdp 四个网络请求追踪函数,以及 network_timeout_seconds(默认 10.0)和对应的 _check_network_timeouts,会在超时时派发 error_type='NetworkTimeout'。但全仓搜索这几个函数名,只在本文件的定义处有命中——它们没有被注册到任何 CDP 网络事件上。结论是:_active_requests 在当前代码里保持为空,那条网络超时告警实际上不会触发。别把”没看到 NetworkTimeout”当成”网络没问题”。
CDP 超时:通道哑了怎么办
browser_use/browser/_cdp_timeout.py 的模块 docstring 把要解决的故障描述得非常具体:底层客户端的 send_raw() 等的是一个”只有浏览器回消息才会 resolve”的 future;如果服务端中途沉默——文档点名的观测场景是远端云浏览器,WebSocket 在 TCP/keepalive 层面还”活着”,但浏览器容器已死或代理丢了上游——这个 future 永远不会 resolve,整个 agent 就挂在那里。
做法是一个薄子类:
class TimeoutWrappedCDPClient(CDPClient):
async def send_raw(self, method, params=None, session_id=None):
try:
return await asyncio.wait_for(
super().send_raw(method=method, params=params, session_id=session_id),
timeout=self._cdp_request_timeout_s,
)
except TimeoutError as e:
raise TimeoutError(
f'CDP method {method!r} did not respond within {self._cdp_request_timeout_s:.0f}s. '
...
) from e
三个点值得你记住。第一,它刻意抛的是内置 TimeoutError,注释写明是为了让现有的 except TimeoutError 分支统一处理,不引入新异常类型——这也解释了为什么你在异常文件里找不到”CDP 超时异常”。第二,超时值来自 BROWSER_USE_CDP_TIMEOUT_S 环境变量,缺省 60 秒;docstring 解释这个数的取法是”对截图、打印 PDF 这类慢操作足够宽松,同时明显低于 180 秒的步超时”。第三,环境变量和构造参数都过一遍防御性校验:非数字、非有限值(nan、inf)、零或负数一律打警告并退回默认值,理由写在注释里——一个坏值会让每次 CDP 调用要么立刻超时,要么永不超时。
把时间层级串起来看会更清楚:单次 CDP 请求 60 秒 → agent 单步 step_timeout 默认 180 秒(超时后在 _handle_step_error 之外单独处理,累加失败并推进步计数)→ 连接错误时等重连最多 54.0 秒,而 _auto_reconnect 默认尝试 3 次、退避 1/2/4 秒、每次重连本身限时 15.0 秒,全部失败才派发 error_type='ReconnectionFailed'。你的外层超时如果设得比这些数还小,看到的就永远是自己的超时,而不是仓库给你的诊断信息。
四、日志该从哪一层开始看
browser_use/logging_config.py 里有几处设计会直接影响你能看到什么,不知道的话很容易误判”框架没打日志”。
第一,格式化器会改名字。BrowserUseFormatter.format 在级别高于 DEBUG 时,把以 browser_use. 开头的 logger 名压成 Agent、BrowserSession、tools、dom 这几个短名,其余取最后一段。好处是 INFO 输出干净,代价是你看不出具体是哪个模块说话。想定位到文件,就得把级别调到 debug——注释里明确写了”只在 INFO 模式清理名字,DEBUG 模式保留全部”。
第二,三方 logger 被压到 ERROR。列表里包括 httpx、httpcore、openai、anthropic._base_client、groq、google_genai、urllib3、asyncio、trafilatura 等,并且 propagate 置假。这就是为什么模型请求的 HTTP 细节默认看不见。要看,需要你自己在应用侧把对应 logger 级别调回来。
第三,CDP 日志是独立开关。级别取自 CDP_LOGGING_LEVEL(默认 WARNING),代码用 getattr(logging, cdp_level_str, logging.WARNING) 转换——写错级别名不会报错,会静默退回 WARNING。排查通道问题时,把这个变量调低比调 BROWSER_USE_LOGGING_LEVEL 更对症。
第四,级别里有个自定义档。addLoggingLevel('RESULT', 35) 加了一个介于 WARNING 和 ERROR 之间的 RESULT 级;当 BROWSER_USE_LOGGING_LEVEL 取 result 时,控制台格式变成只有 %(message)s,也就是没有级别和名字的纯净输出。这个模式适合演示,不适合排查。
第五,落文件与分流。browser_use/__init__.py 在导入时就调用 setup_logging,两个文件路径来自 BROWSER_USE_DEBUG_LOG_FILE 和 BROWSER_USE_INFO_LOG_FILE;只要设了 debug 文件,effective_log_level 就整体变成 DEBUG。另外 setup_log_pipes(session_id) 会建三个命名管道,把日志按层分开:agent.pipe 收 browser_use.agent 与 browser_use.tools,cdp.pipe 收 websockets.client 与 cdp_use.client,events.pipe 收 bubus 与 browser_use.browser.session。这三个名字本身就是最好的分层提示——先看 agent 管,判断是不是模型没给出可执行动作;再看 events 管,判断事件有没有派发和被消费;最后才看 cdp 管的原始报文。 顺序倒过来,你会淹死在 CDP 消息里。管道基于 os.mkfifo,Windows 上不适用。
还有一个容易踩的开关:环境变量 BROWSER_USE_SETUP_LOGGING 设成 false 时,导入阶段完全跳过配置,只拿一个裸 logger。如果你在自己的框架里接管日志,这是入口;如果你什么都没配又设了这个变量,那就什么都看不到。
五、边界与代价
这套机制的取向是”把挂死变成可观测的错误”,不是”自动修好”。承认这一点,能省掉很多无效等待。
看护器只报告,不恢复。崩溃与探活失败都只是派发 BrowserErrorEvent 或打一条 ERROR,恢复由会话管理侧接手。而且在仓库里搜 on_BrowserErrorEvent 是零命中——这个事件没有默认订阅者,它的价值在于给你的监控代码提供挂点,不在于框架自己会据此做什么。你要做告警,得自己订阅。
每请求超时换来的是”可能误杀”。60 秒对绝大多数 CDP 方法足够,但如果你在超大页面上做重活,或者链路本身很慢,超时会把一次本来能成功的调用变成失败。调大它的代价是挂死的检出变慢,这是一个必须由你按场景定的取舍,仓库只给了一个偏保守的缺省。
字符串匹配的连接判断有识别上限。_is_connection_like_error 靠若干关键词识别连接故障,措辞不在列表里的底层错误就会落进通用分支,只是累加失败次数,不触发等待重连的路径。遇到反复”失败 N/6 次”却看不出原因时,先把原始异常文本抓出来对一遍这几个关键词。
它明确不管的事更要说清。这类工具驱动的是真实浏览器,可能带着你的登录态在真实账号上操作、访问第三方站点。框架层面有域名策略(越界导航会被拦下并派发 NavigationBlocked、TabCreationBlocked,同时把页面导到 about:blank),但目标站点的使用条款、验证码与反自动化机制、账号被判为异常的可能,都不在框架的职责范围内——这些是你自己要评估的合规与风险边界,不要指望超时和看护器帮你兜住。本文也不讨论任何规避这些机制的做法。
另有一条数据面的代价:错误信息会进模型上下文。_handle_step_error 把格式化后的错误文本放进 ActionResult(error=...),BrowserError 的两个记忆字段本身就是为喂给模型设计的,而崩溃与网络相关的 details 里带着完整 URL。这意味着页面地址、查询参数这类信息会随上下文发给模型服务商,也会落进你的日志文件。相关取舍参见日志中的敏感信息处理。
六、上手与避坑清单
- 别按报错字符串去
exceptions.py找答案。这个文件只有一个没被引用的LLMException,真正在用的是browser_use/llm/exceptions.py和browser_use/browser/views.py。踩的原因是命名太像入口;避法是排查前先确认异常类的定义位置,再决定读哪个模块。 - 没配备用模型就不要指望自动切换。可重试判断和切换逻辑都在
_try_switch_to_fallback_llm,但前提是备用模型已配置,且一次运行里只切一次。踩的原因是日志里的”no fallback_llm configured”是 WARNING,容易被忽略;避法是配置阶段就把它当必填项,并在日志里检索这句话。 - 看到 WARNING 别放过。失败计数没到上限时一律 WARNING,只有到达上限才升 ERROR。这个上限不是
max_failures(默认 5)本身,而是它加上final_response_after_failure(默认为真)折算的 1 次,所以日志里印出来的分母默认是 6。踩的原因是习惯只看红色;避法是按”失败 N/M 次”这个前缀去数,而不是按颜色。 - 不要用”没有 NetworkTimeout”证明网络健康。那条链路的请求追踪函数在当前代码里没有注册到任何 CDP 事件上,告警不会触发。避法是网络类怀疑走 CDP 日志或你自己的抓包,别依赖这个告警。
- 不要给
BROWSER_USE_CDP_TIMEOUT_S塞非法值。写成inf、0、负数或非数字,都会被防御性校验退回默认并只打一条警告——你以为改了,其实没改。避法是改完跑一次,在日志里确认没有那条 fallback 警告。 - 外层超时要比内层宽。单请求 60 秒、单步 180 秒、等重连 54 秒是层层嵌套的;你的调度器如果 30 秒就杀任务,所有诊断信息都会丢。避法是先把这几个数抄下来,再定你自己的超时。
- 排查前先把日志级别和名字问题解决掉。INFO 模式下模块名被压成短名,三方库被静音,CDP 默认只到 WARNING。踩的原因是拿 INFO 日志去定位底层问题;避法是复现时同时设
BROWSER_USE_LOGGING_LEVEL=debug和更低的CDP_LOGGING_LEVEL,并用BROWSER_USE_DEBUG_LOG_FILE落盘,事后慢慢读。 - 前 10 秒的问题走另一条路。监控循环启动前有 10 秒静默期,这段时间的异常只会经由崩溃事件回调出现。避法是启动阶段的问题优先看事件流,而不是等体检日志。
一次排查可以按四问自检:这次的异常类是模型侧、浏览器侧还是连接类?失败计数是第几次,之前几次是不是同一个原因?CDP 层有没有超时消息,即那句”did not respond within”?日志级别是不是已经调到能看见模块全名?
四问都答完还没头绪,按这个顺序继续读仓库:browser_use/agent/service.py 的 _handle_step_error 看分诊、browser_use/browser/session.py 的重连与错误派发看会话状态、browser_use/browser/watchdogs/ 下与你症状最接近的那个看护器看具体机制。仓库里 examples/ 有 124 个文件,先找一个跟你场景最接近的最小例子复现,再回到源码,比直接读大文件省力。项目采用 MIT 许可证,读和改都没有障碍,但记住你读到的任何常数都可能在下一次迭代里变——每次升级后,值得重新对一遍上面这几个数。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 读懂 browser-use 用量统计层 和 browser-use 跑通第一个任务。