browser-use 的三条回放线:轨迹动图、视频录制与录制看护各记了什么
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
回放不是拿来看热闹的,它只回答一个问题:模型当时看到的那一屏,和它自己写下的下一步目标,对不对得上。 一个浏览器 Agent 跑挂了,日志里通常只剩「点击了索引 12 的元素」这种字样,你没法从中判断它是把「加入购物车」认成了「收藏」,还是根本没等页面加载完。像素才是这个判断的唯一证据。browser-use(MIT 许可证)为这件事准备了三块东西,它们记录的粒度、开销和泄漏面完全不同,值得分开看。
站内已有三篇相邻的文章,分工要先说清:Agent 执行的复现与回放 讲的是通用的可复现执行设计,可观察日志怎么写 讲结构化日志字段该记什么,日志里的敏感信息 讲文本脱敏的做法。本篇不重复这些,只管一件事:在 browser-use 这个具体仓库里,像素级回放的三条线各自落什么文件、能证明什么、又必然丢掉什么。
一、三条线解决的不是同一个问题
先把三块东西摆开,别混着谈。
第一块是轨迹动图,代码在 browser_use/agent/gif.py,入口函数 create_history_gif。它吃的是 Agent 跑完之后的 AgentHistoryList,把每一步的截图取出来,在图上叠一个步号和这一步的 next_goal 文字,然后存成一个循环播放的 GIF。它是事后加工,不参与运行时。
第二块是视频编码器,代码在 browser_use/browser/video_recorder.py,类名 VideoRecorderService。它吃的是一帧一帧的 base64 PNG,用 imageio 加 libx264 写成视频文件。它不知道 Agent 在想什么,只管把画面按帧率串起来。
第三块是录制看护,代码在 browser_use/browser/watchdogs/recording_watchdog.py,类名 RecordingWatchdog。它是把上面那个编码器接到真实浏览器上的那层:订阅浏览器事件,通过 CDP 开关 screencast,把帧喂给编码器,并在 Agent 换标签页时把录制切过去。
这个分层有个直接后果:动图带语义但极其稀疏(一步一帧),视频稠密但完全无语义(只有画面)。排查的时候你几乎一定是先看动图定位到「第几步开始跑偏」,再去视频里看那几秒钟画面上到底发生了什么。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 轨迹动图生成 | 把历史里每步截图 + 步号 + next_goal 叠成 GIF | browser_use/agent/gif.py | 传了 generate_gif 之后,任务跑完自动出图 |
| 截图落盘服务 | 把每步截图写成 step_N.png,需要时再读回 base64 | browser_use/screenshots/service.py | 动图为空、或想直接翻某一步原图时 |
| 视频编码器 | base64 PNG 帧 → 缩放 → 补边 → 写入视频文件 | browser_use/browser/video_recorder.py | 装了视频可选依赖、开了录制目录之后 |
| 录制看护 | 订阅浏览器事件,用 CDP 开关 screencast 并跟随焦点切换 | browser_use/browser/watchdogs/recording_watchdog.py | 录制没启动、或只录到一个标签页时来查它 |
| 历史序列化 | 把 model_output、result、state 存成 JSON,可过滤敏感字段 | browser_use/agent/views.py | 需要机器可读的轨迹、需要脱敏时 |
二、轨迹动图:语义是从历史里借来的
create_history_gif 的签名里除了 task、history、output_path,还有一串排版参数:duration、show_goals、show_task、show_logo、font_size、title_font_size、goal_font_size、margin、line_spacing。默认每帧 duration=3000,也就是一帧停 3 秒;保存时 loop=0,无限循环;optimize=False,不做调色板优化。
它的取材路径值得留意。截图不是直接躺在内存里的:ScreenshotService 会把每步截图写成 screenshots/step_N.png,而这个 screenshots 子目录挂在 agent_directory 下,agent_directory 是系统临时目录里一个带 Agent id 和时间戳的路径。要用的时候,state.get_screenshot() 再从磁盘读回 base64。也就是说,动图能不能生成出来,取决于那批临时 png 还在不在。
拿到截图列表后,它做了两轮过滤。一轮是跳过占位截图:仓库里定义了一个 PLACEHOLDER_4PX_SCREENSHOT 常量,对应空白页那种极小图片,命中就跳过;另一轮是调用 is_new_tab_page(item.state.url),新标签页的帧也不要。所以最终帧数往往少于步数,中间是有空洞的——这一点在数「第几帧对应第几步」的时候特别容易搞错,因为叠在图上的步号取的是历史枚举下标,而不是帧序号。
叠字这块,_add_overlay_to_image 把步号画在左下角一个圆角矩形里,目标文字居中排在步号上方,也用圆角底衬,两层都是先画在一个透明层上再 alpha_composite 合回原图。文字内容来自 item.model_output.current_state.next_goal。
字体选择是一串候选名按顺序试:PingFang、STHeiti Medium、Microsoft YaHei、SimHei、SimSun、Noto Sans CJK SC、WenQuanYi Micro Hei,然后才是 Helvetica、Arial、DejaVuSans、Verdana。Windows 下会把候选名拼到 CONFIG.WIN_FONT_DIR 下面再加 .ttf 后缀去加载,这个目录默认是 C:\Windows\Fonts,可以用同名环境变量覆盖:
@property
def WIN_FONT_DIR(self) -> str:
return os.getenv('WIN_FONT_DIR', 'C:\\Windows\\Fonts')
一个候选都没命中,就退到 PIL 的内置默认字体,那时候非 ASCII 文本的渲染就没什么保障了。
还有个更隐蔽的中文问题在换行函数里:
words = text.split()
_wrap_text 是按空白切词再逐词量宽度的。中文目标句子里没有空格,整句会被当成一个「词」,触发到 len(current_line) == 1 那个分支,直接原样成为一行,宽度超了也不管。所以中文的 next_goal 在动图上很容易是一条横穿画面、两头被裁掉的长条。项目里还专门有个 decode_unicode_escapes_to_utf8,只在文本里出现 \u 字样时才尝试还原转义序列,用于模型把中文吐成转义写法的情况。
至于什么时候触发生成:Agent 侧有一个 generate_gif 设置,默认 False;传 True 就用默认输出名,传字符串就当路径用。生成动作发生在 run() 收尾阶段、事件总线停掉之前,文件确实存在才会发一个输出文件事件出去。
三、视频录制:帧进来,缩放、补边、写盘
VideoRecorderService 的构造参数只有三个:输出路径、尺寸、帧率。start() 里初始化写入器时固定了几件事:codec='libx264'、quality=8、pixelformat='yuv420p',以及 macro_block_size=None——最后这个是因为它自己处理对齐,不让底层再插手。
对齐这件事在 _get_padded_size 里:宽高各自向上取整到 16 的倍数。add_frame 拿到一帧之后的顺序是:base64 解码 → PIL 打开 → 尺寸和目标不一致就用 Image.Resampling.BICUBIC 缩放(注释里写明选 BICUBIC 是因为比 LANCZOS 快、对屏幕录制够用)→ 如果补边后的尺寸和目标尺寸不同,就新建一张黑底图把画面居中贴进去 → 转成 numpy 数组交给写入器。
所以视频里那圈黑边不是 bug,是编解码器的宏块对齐要求。只要录制尺寸的宽或高有一边不是 16 的倍数,补边分支就会命中,你一定会看到它。
这一层的容错取向很明确:能不炸就不炸。add_frame 整个包在 try 里,出问题只 logger.warning 一句「无法处理并添加视频帧」,那一帧就丢了,录制继续。缺可选依赖时更直接——模块顶部 import 失败会把可用标志置成 False,start() 里发现不可用就打一条 error 提示装 browser-use[video],然后 return,不抛异常。
这个选择对录制本身是对的(不能因为掉帧把正在跑的任务搞崩),但对你意味着:视频缺失或不完整属于静默失败,你不看日志是发现不了的。
四、录制看护:跟着焦点走的那层胶水
RecordingWatchdog 声明了它听三个事件:浏览器连上、浏览器停止、Agent 焦点变化;而它自己不发任何事件(EMITS 是空列表)。仓库里 browser_use/browser/watchdogs/ 一共 14 个 watchdog,录制这个在会话初始化时是无条件挂上去的,靠 record_video_dir 有没有配来决定要不要真干活。
浏览器连上时,它读 browser profile 里的 record_video_dir,没配就直接返回。配了的话,格式取 record_video_format(这个字段在 profile 里没有显式定义,是用 getattr 带缺省值 mp4 取的,取到后还会把两端的点号剥掉),文件名用 uuid7str() 生成,尺寸和帧率分别来自 record_video_size 和 record_video_framerate。profile 里这三个字段的定义是:录制目录(还兼容 save_recording_path 这个别名)、帧尺寸(不设就用视口尺寸)、帧率默认 30。
start_recording 里有几个判断顺序需要记住:已经在录了会抛 RuntimeError;尺寸没给就去问浏览器,走 Page.getLayoutMetrics 取 cssVisualViewport 的 clientWidth / clientHeight(注释说明选这个是因为它最贴近实际可见区域);问不出来也抛 RuntimeError;编码器 start 之后还要再确认它真的激活了,没激活同样抛错并提示装可选依赖。
但这些 RuntimeError 在浏览器连上那个入口是被吞掉的:
except RuntimeError as e:
# Preserve prior graceful degradation: a session configured with record_video_dir
# should not fail startup when video deps are missing or viewport detection fails.
self.logger.warning(f'Skipping video recording: {e}')
注释把取向写得很清楚:配了录制目录的会话,不该因为缺依赖或量不到视口就启动失败。代价还是那句——静默降级。
真正的帧流是这样接的:注册 screencast 帧回调,然后带一组参数开 screencast,参数里是 format 为 png、quality 为 90、最大宽高等于目标尺寸、everyNthFrame 为 1(不抽帧)。帧回调 on_screencastFrame 是同步函数,先按当前会话 id 过滤掉旧会话残留的帧(代码注释直接点了这是为了处理停止未完成时的竞态),然后把帧塞给编码器,最后异步补一个帧确认回去。
焦点切换是这条线最容易让人误判的地方。_start_screencast 每次都会拿当前焦点对应的 CDP 会话,如果和正在录的会话是同一个就什么都不做;如果换了,就先在老会话上停 screencast,再在新会话上开。也就是说,录出来的视频是「Agent 当前焦点标签页」的拼接流,不是浏览器全部标签页的合成画面。后台标签页里发生的事,视频里一片空白,而它们完全可能是失败的真正原因。这类「证据不覆盖故障现场」的情况在归因时最容易出错,别把「没录到」读成「没发生」。
收尾在浏览器停止事件里触发。stop_recording 的写法有个细节:它先把 recorder 和会话 id 从自身引用里摘掉,再去停 CDP screencast,最后把编码器的收尾放进线程池执行——写文件尾是阻塞操作,不能占着事件循环。
五、边界与代价:它明确不管什么
先说这套设计放弃了什么。
没有语义索引。 视频只有像素和时间轴,没有任何「第几步」的标记,帧上也不叠字。你想知道视频第 47 秒对应哪一步,只能自己拿步骤时间戳去对。动图有步号但只有一步一帧,中间那几秒的动态过程它一概不记。
没有 DOM 快照。 这两条线都不保存元素树。「模型看到的元素索引 12 到底是哪个节点」这种问题,回放里找不到答案,只能回去看历史 JSON 里的动作参数和状态。
不覆盖非焦点标签页,也不覆盖浏览器界面之外。 弹出的系统对话框、下载面板这类不在页面视口里的东西,screencast 拿不到。
掉帧和缺文件都不上报。 前面说过两处静默降级:帧处理异常只 warning,缺依赖只 error 加返回。回放文件在不在、完不完整,属于你自己要去核对的事。
再说隐私代价,这部分比技术细节更该慎重。
历史 JSON 是有脱敏能力的:save_to_file 可以接一个敏感数据字典,AgentHistory.model_dump 会在动作 dump 里带 input 键时,把命中的值替换掉。注意这个替换的作用范围——它处理的是动作的输入参数,代码注释还专门写了动作结果不做过滤,因为那里面是 Agent 自己要用的信息。
而截图和视频里没有任何这类替换。 这是整篇最需要你记住的一条。凡是当时显示在屏幕上的东西,都会一比一进到 png、GIF 和视频里:已登录账号的用户名、订单地址、聊天记录里第三方的个人信息、后台的数据看板、你没注意到的另一个标签页在切焦点瞬间闪过的一帧。这些文件的默认落点也需要留心:每步截图在系统临时目录下那个以 Agent id 命名的路径里,仓库代码里没有自动清理它们的动作;视频落在你指定的录制目录,文件名是随机 id,很容易在项目目录里堆着不被注意,甚至跟着提交或 CI 产物一起流出去。
还有一层是这类工具的固有风险,跟回放叠加起来会放大。这种 Agent 驱动的是真实浏览器,可能带着你现有的登录态操作真实账号、访问第三方站点:目标站点的使用条款可能并不允许自动化访问;站点的验证码与反自动化机制本来就是要拦你的,遇到了就该停下来交给人(做法参见 什么时候把控制权交回给人);账号也可能因为行为异常被风控。回放会把这一整段过程连同页面上的一切都固化成文件,所以录制这件事本身就应该和权限一样按最小范围来配,而不是「先都开着以防万一」。
六、上手与避坑清单
动图是空的或只有一两帧。 会踩,是因为动图取的是磁盘上的 step_N.png,而那批文件在系统临时目录里;同时占位截图和新标签页的帧会被主动跳过。避法:任务结束后先确认那个 Agent 目录还在、里面 png 的数量对得上步数,再看是不是被两条过滤规则筛掉了;确认没截图就先查视觉输入是不是关着。
中文目标文字排成一条被裁掉的长线。 会踩,是因为换行是按空白切词的,中文整句被当成单个词直接成行。避法:需要中文可读时,别指望默认排版,用 show_goals=False 关掉叠字、只留画面,语义那部分回历史 JSON 里读;要留字就得自己在写历史时把目标切短。
动图上的步号和你数的帧序号对不上。 会踩,是因为步号来自历史枚举下标,而帧被过滤过。避法:定位到可疑帧时,用步号回去查历史 JSON,别按「第几张图」推算。
视频文件没出现,或者只有几 KB。 会踩,是因为缺可选依赖时只打日志不抛异常,看护层又把启动阶段的错误降级成一条 warning。避法:第一次配录制,跑完立刻确认文件存在且能播;把「跳过视频录制」这类 warning 当成失败信号处理,而不是提示。
视频画面四周有黑边、或者比预期模糊。 会踩,是因为宽高被向上补到 16 的倍数、且尺寸不匹配时会做一次缩放。避法:把录制尺寸直接设成 16 的倍数,并且和视口尺寸保持一致,两道处理就都不触发了。
只录到了一个标签页。 会踩,是因为录制跟着 Agent 焦点走,切过去才录。避法:任务里如果会开新标签页,看视频时先按焦点变化把时间轴切段;关键证据落在后台标签页的场景,别指望视频,靠历史里的状态记录。
长任务的文件大得离谱。 会踩,是因为默认帧率 30、不抽帧,而动图那边默认一帧停 3 秒且不做优化。避法:长任务把帧率调低,或者只在复现失败时才开录;动图按需要调帧时长,别让它变成几分钟的循环。
动图里的 logo 没出来。 会踩,是因为它是按相对路径去读 ./static/browser-use.png 的,换了工作目录就读不到,代码里只会 warning 一句。避法:这个选项默认就是关的,除非你确认工作目录,否则别开。
录制目录被当成普通产物目录。 会踩,是因为文件名是随机 id,看不出内容,也没人会去点开一个几十 MB 的 mp4 检查。避法:录制目录单独放、加进忽略清单、定期清;跑之前先想清楚这一屏上会出现谁的信息。这件事和整体的权限收敛是一个问题,可以参照 Agent 的最小权限设计 的思路一起定。
收束:开录之前先过一遍
回放在这个项目里是两套解耦的东西:一套借历史拿到语义、稀疏但可索引;一套贴着 CDP 拿到画面、稠密但没有语义。排查时你会两头都用,但它们都不能替你回答「元素索引指向哪个节点」,也都不会替你脱敏。
开录之前值得过一遍的四个问题:这次任务屏幕上会出现谁的数据,其中哪些是不能落盘的;录制目录在哪、谁能读、什么时候清;缺依赖时你打算怎么发现(有没有人看那条 warning);焦点会不会切走、会不会导致关键画面根本没被录到。
想继续往下读的话,顺着这条线最值得看的是历史序列化那部分——browser_use/agent/views.py 里的 model_dump 和 save_to_file,那里决定了你的轨迹 JSON 里到底留下了什么、又替换掉了什么。看完再回头看这三个录制文件,会清楚哪些信息只在像素里、哪些只在 JSON 里。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 的判分器怎么用 和 拆 browser-use 的遥测与观测。