browser-use 的产物落盘:表格、PDF 与下载文件各走哪条路
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
browser-use 里最容易被忽略的一层,是它没让模型直接碰磁盘。 模型调用的写文件动作操作的是一批常驻内存的文件对象,每次写完再把内容同步到一个受控目录;而网页导出的 PDF 和浏览器真实下载的文件根本不走这条路,它们各有自己的落盘通道。三条路径由谁写、落在哪个目录、能否被模型再读回来都不一样——网页导出的 PDF 恰好和内存文件共用同一个目录,但它绕开了文件对象;浏览器下载的文件连目录都是另一个。这些差别决定了你的任务跑完之后,产物到底能不能拿到手。
本篇只管产物落在哪、由谁写、谁能读回来。关于中间产物该不该落盘、落盘的取舍原则,站内已有 Agent 中间产物落盘的罗盘;关于工具执行完该往上下文里回什么,见 Agent 工具返回值设计;关于模型输出格式本身不稳该怎么兜,见 结构化输出不稳怎么办。这里讲的是一个真实项目把这些想法落成了什么代码。
一、这层抽象在解决什么问题
让模型自己操作浏览器的 Agent,天然会产出一堆东西:抓到的列表要存成表格,读到的长文要留档,网页要留一份可打印的快照,页面上的附件要点下载。如果每次都让模型直接生成路径去写,会撞上几类麻烦:写出带目录甚至 ../ 这种上跳路径;给文件起带空格、带奇怪符号的名字;试图把截图当文本写进 .png;写出格式破掉的 CSV。
browser_use/filesystem/file_system.py 的做法是先把「文件」抽象成受控对象,再由框架决定它怎么变成磁盘上的字节。BaseFile 是个 pydantic 模型加抽象基类,只有 name 和 content 两个字段,子类只需要给出 extension;full_name 由 name 和扩展名拼出来。写入分两步:write_file_content 更新内存内容,sync_to_disk 再把内容写到磁盘。这两步分开是后面所有机制的前提。
注册表就写在 FileSystem.__init__ 里,一眼能数清支持哪些类型:
self._file_types: dict[str, type[BaseFile]] = {
'md': MarkdownFile,
'txt': TxtFile,
'json': JsonFile,
'jsonl': JsonlFile,
'csv': CsvFile,
'pdf': PdfFile,
'docx': DocxFile,
'html': HtmlFile,
'xml': XmlFile,
}
不在这张表里的扩展名一律写不进去。文件里另有一份 UNSUPPORTED_BINARY_EXTENSIONS 集合,列出了 png、jpg、mp4、zip、exe 这类二进制扩展名,_build_filename_error_message 命中它们时会返回一句专门的错误话术,明确告诉模型这个工具只支持文本文件,并且顺带交代一句:截图是浏览器自动捕获的,不要试图当文件保存。这类「错误消息本身就是给模型看的引导」的写法,在这个文件里反复出现。
文件名的清洗也在同一层。_is_valid_filename 用一条正则限定名字部分只能是字母、数字、下划线、连字符、点、圆括号、空格,外加 CJK 区间 一-鿿——也就是中文文件名是被允许的。sanitize_filename 负责把空格换成连字符、去掉非法字符、把连续连字符压成一个、剥掉首尾的连字符和点,扩展名统一转小写。_resolve_filename 则先取 os.path.basename,注释里写明这一步是为了防止 ../secret.md 这类目录穿越,然后才尝试用清洗后的名字去匹配。名字被自动纠正过时,返回给模型的消息里会带上一句 auto-corrected 提示,模型下一步引用文件名不至于对不上。
二、目录结构与「双层」的代价
FileSystem 收到的 base_dir 不是最终写文件的地方。构造函数会在它下面建一个子目录,名字是模块常量 DEFAULT_FILE_SYSTEM_PATH,值为 browseruse_agent_data,所有文件都落在这个 data_dir 里。有一处行为需要记牢:如果 data_dir 已经存在,构造函数会先 shutil.rmtree 把它清掉再重建。也就是说这个目录被当作本次运行独占的工作区,不是给你放素材的地方。类上还有个 nuke() 方法,直接删掉整个 data_dir。
base_dir 从哪来?browser_use/agent/service.py 的 _set_file_system 有三条分支:Agent 状态里已存在 file_system_state 就走 FileSystem.from_state 原地还原;显式传了 file_system_path 就用它;都没有就退回 self.agent_directory——这个目录名的构造是 browser_use_agent_{id}_{timestamp},挂在临时目录下。同一个方法里还有一条硬约束:file_system_state 和 file_system_path 同时给会直接抛 ValueError,理由写在报错文本里,要么从既有状态恢复,要么在指定路径新建,不能两个都来。
内存与磁盘双份的好处,是整个文件系统可以被序列化。FileSystemState 只有三个字段:files、base_dir、extracted_content_count。get_state() 把每个文件对象存成 {'type': 类名, 'data': model_dump()},from_state() 按类名查一张映射表重建对象,再逐个 sync_to_disk_sync 写回磁盘。这就是断点续跑时产物不丢的机制——代价是磁盘上的手工改动不会被感知,重建时以内存快照为准,磁盘只是它的投影。
默认文件只有一个:default_files = ['todo.md']。系统提示词里 <file_system> 那一段专门交代了它的用法,要求模型把已知子任务写成清单,完成一项就用 replace_file 去改标记;同一段末尾还有一句很干脆的限流——任务少于 10 步就不要用文件系统。describe() 方法在拼给模型看的文件清单时会跳过 todo.md,因为待办内容由 get_todo_contents() 单独喂进去,不必重复占位。
describe() 里还藏着一处取舍:常量 DISPLAY_CHARS = 400,内容长度小于它的 1.5 倍就整篇展示,否则只给头尾两段预览,中间用 ... N more lines ... 顶替,模型想看全文得自己调读文件动作。产物越长,模型默认看到的越少。
三、三类产物各走哪条路
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
FileSystem 与 BaseFile 家族 | 内存文件对象、名字校验清洗、同步到 data_dir、状态序列化 | browser_use/filesystem/file_system.py | 任何模型主动写文件的场景 |
write_file / append_file / replace_file_str | 覆盖写、追加、按串替换三个方法(动作层只暴露两个动作:写文件动作带一个追加开关,替换单独一个动作) | 同上,动作注册在 browser_use/tools/service.py | 模型维护 todo、累积结果 |
CsvFile._normalize_csv | 每次写入前把 CSV 重新解析再序列化 | browser_use/filesystem/file_system.py | 让模型产出表格 |
PdfFile.sync_to_disk_sync | 用 reportlab 把 markdown 文本渲染成 PDF | 同上 | 模型写 .pdf 文件名 |
save_as_pdf 动作与 SaveAsPdfAction | 走 CDP 打印当前网页为 PDF | browser_use/tools/service.py、browser_use/tools/views.py | 要留网页快照 |
| 下载看门狗 | 监听 CDP 下载事件、落到 downloads_path、派发下载完成事件 | browser_use/browser/watchdogs/downloads_watchdog.py | 页面上点下载按钮 |
downloaded_files 与 available_file_paths | 把会话内下载的文件登记成模型可读、可上传的清单 | browser_use/browser/session.py、browser_use/agent/service.py | 下载完还要读内容 |
表格这条路,赌注押在写入时的强制规整上。 CsvFile 的类文档说得很直白:模型经常产出畸形 CSV——含逗号的字段不加引号、字段内的引号不转义、空字段处理不一致,所以它在每次写入时都把原始内容过一遍 Python 的 csv 模块。_normalize_csv 用 csv.reader 读进来、丢掉完全为空的行、再用 csv.writer 以 lineterminator='\n' 写回去,最后剥掉末尾换行交给调用方决定行尾。里面还有一段专门对付工具调用被双重转义的情况:
if '\n' not in stripped and '\\n' in stripped:
stripped = stripped.replace('\\"', '"')
stripped = stripped.replace('\\n', '\n')
判据是「整段里没有真换行却有字面量的反斜杠 n」,那基本就是 JSON 被多转义了一层,于是先还原引号再还原换行。追加写也不是简单字符串拼接:append_file_content 先把新增部分规整一遍,为空就直接返回,非空则补齐分隔换行拼到旧内容后面,再把合并结果整体重新规整一次。examples/features/csv_file_generation.py 这个例子的注释把这件事定性为「在基础设施层修掉」,例子最后用 agent.file_system.get_file('top_cities.csv') 取回对象打印 content,再用 get_dir() 拼出完整路径——这也是从代码侧确认产物的标准姿势。
PDF 有两条完全不同的路,很容易混。 第一条是模型调写文件动作、文件名以 .pdf 结尾。这时命中的是 PdfFile,它重写了 sync_to_disk_sync:懒加载 reportlab,用 SimpleDocTemplate 配 letter 页面尺寸,按行扫描内容,# 开头映射到 Title 样式、## 到 Heading1、### 到 Heading2,其余走 Normal,空行插一个高度 6 的 Spacer,最后 doc.build(story)。源码注释交代了这套朴素实现的动机——把内容当纯文本处理,以此规避 AGPL 许可证问题。异步版本把这个同步函数丢进 ThreadPoolExecutor 执行。写文件动作的描述里也写明了这条约定:PDF 请用 markdown 格式写内容,会自动转换。DocxFile 是同构的做法,换成 python-docx 的 add_heading 和 add_paragraph。reportlab、python-docx、pypdf 都在 pyproject.toml 的主依赖里,不需要另外装。
第二条是 save_as_pdf 动作,它导出的是当前网页本身,跟内存文件对象没有关系。实现里拿到 CDP 会话后调 Page.printToPDF,参数由 SaveAsPdfAction 描述:file_name、print_background、landscape、scale(限定 0.1 到 2.0)、paper_format(Letter、Legal、A4、A3、Tabloid),以及页眉页脚三兄弟 display_header_footer、header_template、footer_template。纸张尺寸在动作内部换算成英寸的宽高传给 CDP,不认识的取值回退成 letter;同时固定传了 preferCSSPageSize: True。默认开启页眉页脚,模板常量上方那段注释是这一块最实用的知识:Chrome 靠 date、title、url、pageNumber、totalPages 这几个魔法 class 注入值,而且必须显式设置字号,否则 Chrome 把页眉页脚文字默认成 0px,渲染出来是空白。开启页眉页脚时还会补上四个方向的页边距,注释说明 preferCSSPageSize 只管页面尺寸不管边距,不留出垂直空间 Chrome 会把页眉页脚裁掉。文件名的处理是:没给就取页面标题清洗后截断,取不到退回 page,缺 .pdf 补上,再过一遍 FileSystem.sanitize_filename;重名不覆盖,而是往后数 (1)、(2)。字节用二进制方式直接写进 file_system.get_dir(),整个 CDP 调用套了 30 秒超时。
下载文件这条路最长,也最不受框架控制。 落点由 browser_use/browser/profile.py 的 downloads_path 决定,它另接受 downloads_dir、save_downloads_path 两个别名;没配置时校验器会在系统临时目录下建一个带 8 位随机后缀的目录:
downloads_path = Path(tempfile.gettempdir()) / f'browser-use-downloads-{unique_id}'
examples/features/download_file.py 就是显式指定这个参数的最小例子,构造 Browser(downloads_path='~/Downloads/tmp') 再交给 Agent,任务是去某个文件示例站下载体积最小的 doc 文件、返回后再取下一个,并给 agent.run 设了 max_steps=25。真正干活的是下载看门狗:它订阅 CDP 的下载进度事件,事件带 filePath 就直接登记,不带就退回「对比目录快照」的办法——遍历下载目录,找出不在初始快照里、且体积大于 4 字节的新文件,登记后立刻加进快照防重复;连远端浏览器的情况也留了分支,不碰本地文件系统,用下载目录拼建议文件名兜出一个路径。登记最终以 FileDownloadedEvent 的形式派发出去。
四、产物怎么交回给你
browser_use/browser/session.py 里有个私有列表 _downloaded_files,on_FileDownloadedEvent 收到事件后按路径去重追加,并打一条带累计条数的日志,对外由 downloaded_files 属性返回副本。Agent 侧的 _check_and_update_downloads 定期比对这个列表,有变化就调 _update_available_file_paths,用集合求差把新文件并进 available_file_paths,每个新文件单独打一行日志。
available_file_paths 是模型读外部文件的白名单。读文件动作会判断目标是否在这个列表里,命中就以外部文件模式读真实路径,走另一套逻辑:文本类扩展名直接异步读;docx 用 python-docx 逐段取文本;pdf 用 pypdf 抽取,设了 60000 字符上限,超限时按逆文档频率给每页打分、优先取信息独特的页面并强制保留页 1,结尾附一句说明展示了多少页、跳过了哪些;jpg、jpeg、png 读成字节转 base64 塞进结构化结果的 images 字段。系统提示词也划了权限边界:这个清单里的文件只能读或上传,没有写权限。
最后一公里在 done 动作。启用结构化输出的那个分支里,它先把模型指定的 files_to_display 逐个查一遍内容存在与否,存在才把 get_dir() 拼出的完整路径放进 attachments;接着自动追加本次会话的浏览器下载:
session_downloads = browser_session.downloaded_files
if session_downloads:
existing = set(attachments)
for file_path in session_downloads:
if file_path not in existing:
attachments.append(file_path)
注释里点明了一条边界:只自动附加 CDP 追踪到的会话内下载,不附加用户自己塞进白名单的路径。examples/features/save_as_pdf.py 的取值方式与此对应——遍历 history.action_results(),谁有 attachments 就把里面的路径打出来。所以你在代码里回收产物有两个正经入口:agent.file_system 这一侧拿内存文件对象和目录,动作结果的 attachments 这一侧拿绝对路径清单。
顺带一提抽取动作的溢出逻辑:抽到的内容超过长度阈值时不会硬塞进上下文,而是调 save_extracted_content 落成 extracted_content_0.md、extracted_content_1.md 这样的编号 markdown 文件,回给模型的记忆里只留一句「内容在某文件中」。计数器 extracted_content_count 也在序列化状态里,续跑不会撞号。
五、边界与代价:它明确不管什么
这层抽象买到了可控和可续跑,代价也很清楚。
它不做通用文件管理。 data_dir 在构造时会被清空,重建时以内存快照为准,磁盘上的手工修改不被感知。想让 Agent 读你已有的素材,路子是 available_file_paths 白名单加只读,而不是往工作目录里拷。
二进制产物基本在体系之外。 写文件动作只认那九种文本扩展名,图片和压缩包写不进去。save_as_pdf 虽然把字节写进了同一个目录,但它没有在内存的文件字典里登记这个文件——它交回的是 attachments 里的路径。浏览器下载的文件更是落在另一个目录。这意味着「模型能列出来的文件」和「磁盘上实际存在的产物」不是一回事,写回收脚本时别只看一处。
PDF 与 DOCX 的渲染很朴素。 只识别三级 markdown 标题,其余按普通段落走,表格、图片、代码块、样式一概没有对应处理。要排版精细的交付物,这条路不合适;要网页原貌就用 save_as_pdf 走浏览器打印,而不是让模型手写 markdown 再转。
CSV 的规整只保证格式合法,不保证内容正确。 重新解析再序列化能修掉引号和空行,修不掉模型把数据看错、少抓一行、把两列串位。数据准不准仍然要你自己校验。
下载这条路依赖真实浏览器的行为。 体积不大于 4 字节的文件在快照兜底分支里会被跳过;远端浏览器场景下路径是拼出来的,文件大小按 0 上报。这些都是为了在信息不全时还能有个交代,不是精确保证。
还有一类边界跟代码无关但更要紧:这类工具驱动的是真实浏览器,可能带着你的登录态在真实账号上操作,还会访问第三方站点。目标站点的使用条款是硬约束,遇到验证码和反自动化机制就该停,不要去想绕过的办法;批量操作也可能让账号被判为异常。产物侧的暴露面同样具体:抽取的内容、导出的 PDF、下载的文件都可能含敏感信息,默认落在系统临时目录;attachments 与日志里带的是绝对路径,接进 CI 或推给第三方时会一起带出去。相关的判断框架可以参考 AI 数据安全风险清单。至于模型服务商侧,抽取内容会进请求,各家的数据处理与留存规则不同且会调整,以官方最新说明为准。
六、上手与避坑清单
- 想拿到产物就显式指定
file_system_path。 不传的话工作目录落在临时目录下形如browser_use_agent_{id}_{timestamp}的位置,跑完你得翻日志找路径,容器重启还可能连目录一起没了。指定一个自己的目录,产物位置就是确定的。 - 别把已有文件放进那个工作目录。
FileSystem构造时会把browseruse_agent_data子目录整个删掉重建,你放进去的东西会消失。素材走available_file_paths白名单,只读。 - 续跑时不要同时给状态和路径。
_set_file_system对这两个参数同时出现直接抛异常,因为恢复位置会矛盾。恢复既有任务就只传状态,路径由状态里的base_dir决定。 - 回收产物要同时看两处。 模型写的文件在
agent.file_system一侧,save_as_pdf的输出和浏览器下载在动作结果的attachments一侧。只查文件字典会漏掉后两类,只查attachments会漏掉模型写的中间文件。 - 自定义 PDF 页眉页脚一定要写死字号。 源码注释直说了 Chrome 把页眉页脚字号默认成 0px,模板里不设
font-size就是一片空白,而且这种问题不报错,只有打开 PDF 才发现。不需要元数据就把display_header_footer关掉,比调模板省事。 - 要网页原貌别让模型手写 markdown。 让模型转述内容再走
PdfFile渲染,等于让它重新组织一遍事实,既丢版式又多一次出错机会。要快照就用save_as_pdf,纸张走paper_format,宽表格加上横向。 - 同名 PDF 不会被覆盖,会越攒越多。
save_as_pdf遇到重名往后数(1)、(2),长任务反复导出同一页会攒出一堆近似文件。要么每次给不同的file_name,要么在回收阶段按顺序去重。 - 短任务别启用文件系统流程。 系统提示词里那句「少于 10 步就不要用」是有代价考虑的:写文件、读文件、改待办每一步都要消耗一轮交互和上下文。任务本来三步就能完,加一层待办清单只会更慢更贵。
- 模型自己写的 CSV 别当成校验过的数据。 规整只发生在格式层。落盘之后加一道你自己的检查:行数对不对、关键列有没有空、数值范围是否离谱,比在提示词里反复叮嘱格式有用得多。
想继续往下挖,按这个顺序读最省力:先把 browser_use/filesystem/file_system.py 从头到尾过一遍,它是整套产物机制的地基;再去 browser_use/tools/service.py 里对照文件系统动作和 save_as_pdf 的注册,看动作描述是怎么把约定写给模型的;然后看 browser_use/browser/watchdogs/downloads_watchdog.py,理解下载这条异步链路——这个目录下一共 14 个看门狗,下载只是其中一个,浏览器侧的其它能力也是同一套事件模式;最后翻 browser_use/agent/system_prompts/ 下的 8 份提示词,把 <file_system> 那几段跟代码里的行为对起来。examples/ 下有 124 个文件,本篇引到的三个是产物相关最直接的入口。项目采用 MIT 许可证,逐层对照读没有障碍。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 开源项目怎么让 Agent 登你的账号 和 browser-use 本地跑不住时。