主题
文件上传与文档转换
本章目标:
- 讲清 DeerFlow 为什么要把上传文档转成 Markdown、为什么按线程隔离存储,以及自动转换默认关闭的安全取舍。
- 拆解 uploads router、
UploadsMiddleware、deerflow/uploads与file_conversion四个组件的职责与关键函数。- 走通一次上传的完整链路:拒目录 → 写盘 → 重名
_N改名 → markitdown 转换 → 线程隔离存储 → 中间件注入对话。
TL;DR
DeerFlow 通过 POST /api/threads/{id}/uploads 接收多文件,先做线程 ID 与文件名安全校验、按 _N 后缀规避同请求重名,再用 O_NOFOLLOW 防符号链接的方式写入 用户/线程 隔离目录。若运维显式开启 auto_convert_documents,PDF/PPT/Excel/Word 会经 pymupdf4llm 或 markitdown 转成同名 .md。下一轮对话时 UploadsMiddleware 扫描该目录,把新文件与历史文件清单(含从 .md 抽取的提纲)注入到最后一条用户消息,让模型知道有哪些文件可读。嵌入式 DeerFlowClient.upload_files 走同一套 manager 逻辑,但拒绝目录输入,并在事件循环内复用单 worker 做转换。
Overview
为什么上传文档要先转成 Markdown,而不是直接把原始二进制丢给模型?核心原因有三:
- 模型读不了二进制结构。PDF、PPTX、XLSX 是压缩/二进制容器,LLM 无法直接理解。转成 Markdown 后,文本、标题层级被保留为纯文本,模型可以用
read_file/grep按行检索 file_conversion.py:1-15。 - 提纲驱动按需读取。转换后的
.md会被extract_outline抽出标题与行号,中间件把提纲注入对话,模型据此用read_file定位章节而非全文塞进上下文,避免长文档撑爆 token file_conversion.py:228-288。 - 线程隔离是安全边界。每个线程的上传文件落在
用户/线程维度的独立目录,线程 ID 必须匹配^[a-zA-Z0-9._-]+$,杜绝路径穿越导致跨线程/跨用户读取 manager.py:30-43。
与之配套的是一个明确的安全取舍:自动文档转换会在沙箱隔离生效之前于网关宿主机解析不可信文件,因此默认关闭,需运维在 config.yaml 显式开启 uploads.py:155-168。
Architecture
文件上传跨越 App 层(网关路由)与 Harness 层(uploads 管理、转换、中间件)。App 层只负责 HTTP/鉴权与限额,所有"纯业务逻辑"下沉到 deerflow.uploads.manager,因此 Gateway 与嵌入式 DeerFlowClient 共用同一套写盘/校验/列举/删除函数 manager.py:1-5。
Source 列表:
| 层 | 文件 | 角色 |
|---|---|---|
| App | backend/app/gateway/routers/uploads.py | HTTP 路由、限额、鉴权、流式写入、触发转换 |
| Harness | backend/packages/harness/deerflow/uploads/manager.py | 纯业务逻辑:线程ID/文件名校验、重名 _N、防符号链接写入、列举、删除 |
| Harness | backend/packages/harness/deerflow/uploads/__init__.py | 模块对外导出面 |
| Harness | backend/packages/harness/deerflow/utils/file_conversion.py | PDF/Office → Markdown、提纲抽取、pdf_converter 配置读取 |
| Harness | backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py | before_agent 把上传清单+提纲注入最后一条用户消息 |
| Harness | backend/packages/harness/deerflow/client.py | 嵌入式 upload_files:拒目录、事件循环内复用单 worker |
| Harness | backend/packages/harness/deerflow/config/paths.py | sandbox_uploads_dir 解析线程隔离路径 |
Components / Subsystems
uploads router(backend/app/gateway/routers/uploads.py)
职责:挂载 /api/threads/{thread_id}/uploads 前缀,提供上传、限额查询、列举、删除四个端点,负责鉴权、限额校验、流式写入与转换触发。
关键函数:
upload_files(POST""):带@require_permission("threads", "write", owner_check=True),逐文件归一化文件名、claim_unique_filename去重、分块写入并按单文件/总量限额校验,可转换时调用转换 uploads.py:170-293。_write_upload_file_with_limits:用open_upload_file_no_symlink拿到文件句柄后按UPLOAD_CHUNK_SIZE=8192流式读写,超限抛 413 并清理半成品 uploads.py:123-152。_auto_convert_documents_enabled:从uploads.auto_convert_documents读开关,默认False,字符串1/true/yes/on视为真 uploads.py:155-167。list_uploaded_files(GET/list)与delete_uploaded_file(DELETE/{filename}):分别委托list_files_in_dir+enrich_file_listing与delete_file_safe,删除时按CONVERTIBLE_EXTENSIONS连带清理伴生.mduploads.py:307-342。
UploadsMiddleware(agents/middlewares/uploads_middleware.py)
职责:在 before_agent 阶段,把"本轮新上传文件"与"历史上传文件"清单(含提纲或预览)拼成 <uploaded_files> 块,前置到最后一条 HumanMessage,让模型知道有哪些文件可读 uploads_middleware.py:66-72。
关键函数:
before_agent:解析thread_id→sandbox_uploads_dir得到物理目录;新文件来自消息additional_kwargs.files,历史文件来自扫描目录(排除新文件);拼好files_message后保留原additional_kwargs重建消息 uploads_middleware.py:187-295。_files_from_kwargs:从additional_kwargs.files读前端上报的元数据,过滤掉Path(filename).name != filename的非法名以及物理上已不存在的文件 uploads_middleware.py:149-185。_extract_outline_for_file:查找同名<stem>.md,调用extract_outline;若无标题则读前 5 行非空内容作为预览锚点 uploads_middleware.py:22-57。
转换与存储模块(deerflow/uploads + utils/file_conversion)
职责:uploads.manager 承担一切无 HTTP 依赖的纯逻辑;file_conversion 负责文档→Markdown 与提纲抽取。
关键函数:
validate_thread_id/normalize_filename:线程 ID 必须匹配_SAFE_THREAD_ID;文件名取Path(filename).name,拒./../反斜杠,UTF-8 字节超 255 报错 manager.py:30-78。claim_unique_filename:同一请求内重名时追加_N后缀,并把结果加入seen集合 manager.py:81-103。open_upload_file_no_symlink:POSIX 用O_NOFOLLOW拒符号链接目标,Windows 用双重lstat+fstat收窄 TOCTOU 窗口,叠加validate_path_traversal防逃逸 manager.py:118-209。list_files_in_dir:目录不存在时返回空列表,只列常规文件(follow_symlinks=False),不递归子目录 manager.py:220-250。convert_file_to_markdown:PDF 走 pymupdf4llm→markitdown 两段策略,>1 MB 经asyncio.to_thread卸载,产物写同名.mdfile_conversion.py:138-167。DeerFlowClient.upload_files:校验路径存在且is_file()(目录被ValueError拒绝),已在事件循环内时复用max_workers=1的单ThreadPoolExecutor做转换 client.py:1130-1210。
Data Flow
Implementation Details
同请求重名 _N 改名
claim_unique_filename 用一个 seen 集合在单次上传请求内追踪已占用的文件名:首次出现直接占用,重复时按 stem_1.ext、stem_2.ext 递增直到不冲突,并把结果回写 seen。这样多个同名 form part 不会互相覆盖截断,而对磁盘上已存在的旧文件仍保留单次替换的覆盖语义 manager.py:81-103:
python
def claim_unique_filename(name: str, seen: set[str]) -> str:
if name not in seen:
seen.add(name)
return name
stem, suffix = Path(name).stem, Path(name).suffix
counter = 1
candidate = f"{stem}_{counter}{suffix}"
while candidate in seen:
counter += 1
candidate = f"{stem}_{counter}{suffix}"
seen.add(candidate)
return candidate解读:重名后缀只作用于"同一请求内"的 seen_filenames,在 router 里每次上传新建一个空集合 uploads.py:199;改名后若 safe_filename != original_filename,响应里会带上 original_filename 供前端展示 uploads.py:245-246。
markitdown / pymupdf4llm 两段转换
PDF 默认 auto 模式:先试 pymupdf4llm,若输出"每页字符数"过低(疑似图片型 PDF)再回退 markitdown;非 PDF 直接走 markitdown file_conversion.py:105-135。markitdown 适配所有支持类型:
python
def _convert_with_markitdown(file_path: Path) -> str:
from markitdown import MarkItDown
md = MarkItDown()
return md.convert(str(file_path)).text_content解读:转换失败时 convert_file_to_markdown 捕获异常返回 None,不会让整个上传请求失败——原文件仍正常保存,只是没有伴生 .md file_conversion.py:165-167。
速查表
| 维度 | 内容 | Source |
|---|---|---|
| 可转换扩展名 | .pdf .ppt .pptx .xls .xlsx .doc .docx | file_conversion.py:27-35 |
| PDF 转换器 | auto(pymupdf4llm→markitdown)/ pymupdf4llm / markitdown | file_conversion.py:204 |
| 非 PDF 转换器 | markitdown(MarkItDown().convert) | file_conversion.py:97-102 |
POST "" | 多文件上传 + 可选转换 | uploads.py:170-178 |
GET /limits | 返回限额给前端 | uploads.py:296-304 |
GET /list | 列举线程上传文件 | uploads.py:307-323 |
DELETE /{filename} | 删除文件 + 连带 .md | uploads.py:326-342 |
| 异步转换阈值 | >1 MB 经 asyncio.to_thread 卸载 | file_conversion.py:40 |
| 提纲上限 | 最多注入 50 条标题(超出加 truncated 哨兵) | file_conversion.py:200-202 |
Configuration
config.yaml → uploads 段(参见 config.example.yaml):
| 配置项 | 默认值 | 含义 | Source |
|---|---|---|---|
uploads.max_files | 10 | 单次最大文件数,超出 413 | config.example.yaml:542 |
uploads.max_file_size | 52428800(50 MiB) | 单文件大小上限 | config.example.yaml:543 |
uploads.max_total_size | 104857600(100 MiB) | 单请求总大小上限 | config.example.yaml:544 |
uploads.auto_convert_documents | false | 是否在宿主机自动转 Markdown(默认关闭,安全) | config.example.yaml:548 |
uploads.pdf_converter | auto | PDF 转换器选择 | config.example.yaml:557 |
限额读取兼容旧键名:max_files 回退 max_file_count,max_file_size 回退 max_single_file_size;非法值会告警并回落默认 uploads.py:89-110。
Common Pitfalls / Tips
- 重名只在同请求内截断。
claim_unique_filename的_N改名只作用于单次请求的seen集合;对磁盘上已存在的旧文件,新上传同名文件仍是直接覆盖(单次替换语义),不会自动_1uploads.py:196-199。 - 目录输入会被拒绝(嵌入式)。
DeerFlowClient.upload_files在复制前对每个路径做p.is_file(),目录抛ValueError("Path is not a file"),且校验在循环前完成以保证 all-or-nothing,不产生半成品 client.py:1132-1145。 - 自动转换默认关闭。未开
auto_convert_documents时上传只保留原文件,模型读不到 Markdown 提纲;此时中间件提示用grep关键词检索原始文件 uploads_middleware.py:100-106。 - 不安全文件名被静默跳过。
normalize_filename抛ValueError的文件(空名、..、含反斜杠、超长)会被 router 记日志后continue,不计入成功列表 uploads.py:215-220。 - 符号链接目标会被拒。沙箱进程可能在未来上传名处预埋符号链接,
open_upload_file_no_symlink用O_NOFOLLOW/fstat拒绝,该文件进入skipped_files且响应success=Falseuploads.py:270-293。 - 事件循环内复用单 worker。
DeerFlowClient已在运行的事件循环内做转换时,会复用一个max_workers=1的ThreadPoolExecutor而非每文件新建,结束后shutdown(wait=True)client.py:1150-1204。 - 删除连带清理
.md。删除可转换类型文件时,伴生的同名.md也会被unlink(missing_ok=True)清掉,避免悬空提纲 manager.py:280-284。
References
- backend/app/gateway/routers/uploads.py — 上传/列举/删除 HTTP 路由与限额逻辑
- backend/packages/harness/deerflow/uploads/manager.py — 纯业务逻辑:校验、重名、防符号链接写入
- backend/packages/harness/deerflow/utils/file_conversion.py — 文档转 Markdown 与提纲抽取
- backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py — 上传清单注入对话
- backend/packages/harness/deerflow/client.py — 嵌入式
upload_files/list_uploads/delete_upload - backend/packages/harness/deerflow/config/paths.py — 线程隔离上传目录解析
- config.example.yaml —
uploads配置段
Related Pages
| 章节 | 关系 |
|---|---|
| ./11-ThreadState与状态管理.md | uploaded_files 是 ThreadState 字段,本章中间件向其写入新上传清单 |
| ./12-中间件链机制.md | UploadsMiddleware 在中间件链第 2 位,before_agent 注入上传上下文 |
| ./13-Gateway-API与路由体系.md | uploads router 是 Gateway 路由体系的一员,共享鉴权/CSRF 机制 |
| ./18-沙箱工具与路径映射.md | 上传目录映射为沙箱内 /mnt/user-data/uploads,模型经此读取文件 |
| ./16-持久化与存储层.md | 上传文件落在 用户/线程 隔离目录,与持久化存储层共用 Paths 解析 |