Skip to content

文件上传与文档转换

本章目标:

  • 讲清 DeerFlow 为什么要把上传文档转成 Markdown、为什么按线程隔离存储,以及自动转换默认关闭的安全取舍。
  • 拆解 uploads router、UploadsMiddlewaredeerflow/uploadsfile_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,而不是直接把原始二进制丢给模型?核心原因有三:

  1. 模型读不了二进制结构。PDF、PPTX、XLSX 是压缩/二进制容器,LLM 无法直接理解。转成 Markdown 后,文本、标题层级被保留为纯文本,模型可以用 read_file/grep 按行检索 file_conversion.py:1-15
  2. 提纲驱动按需读取。转换后的 .md 会被 extract_outline 抽出标题与行号,中间件把提纲注入对话,模型据此用 read_file 定位章节而非全文塞进上下文,避免长文档撑爆 token file_conversion.py:228-288
  3. 线程隔离是安全边界。每个线程的上传文件落在 用户/线程 维度的独立目录,线程 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 列表:

文件角色
Appbackend/app/gateway/routers/uploads.pyHTTP 路由、限额、鉴权、流式写入、触发转换
Harnessbackend/packages/harness/deerflow/uploads/manager.py纯业务逻辑:线程ID/文件名校验、重名 _N、防符号链接写入、列举、删除
Harnessbackend/packages/harness/deerflow/uploads/__init__.py模块对外导出面
Harnessbackend/packages/harness/deerflow/utils/file_conversion.pyPDF/Office → Markdown、提纲抽取、pdf_converter 配置读取
Harnessbackend/packages/harness/deerflow/agents/middlewares/uploads_middleware.pybefore_agent 把上传清单+提纲注入最后一条用户消息
Harnessbackend/packages/harness/deerflow/client.py嵌入式 upload_files:拒目录、事件循环内复用单 worker
Harnessbackend/packages/harness/deerflow/config/paths.pysandbox_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_listingdelete_file_safe,删除时按 CONVERTIBLE_EXTENSIONS 连带清理伴生 .md uploads.py:307-342

UploadsMiddleware(agents/middlewares/uploads_middleware.py)

职责:在 before_agent 阶段,把"本轮新上传文件"与"历史上传文件"清单(含提纲或预览)拼成 <uploaded_files> 块,前置到最后一条 HumanMessage,让模型知道有哪些文件可读 uploads_middleware.py:66-72

关键函数:

  • before_agent:解析 thread_idsandbox_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 卸载,产物写同名 .md file_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.extstem_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 .docxfile_conversion.py:27-35
PDF 转换器auto(pymupdf4llm→markitdown)/ pymupdf4llm / markitdownfile_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}删除文件 + 连带 .mduploads.py:326-342
异步转换阈值>1 MB 经 asyncio.to_thread 卸载file_conversion.py:40
提纲上限最多注入 50 条标题(超出加 truncated 哨兵)file_conversion.py:200-202

Configuration

config.yamluploads 段(参见 config.example.yaml):

配置项默认值含义Source
uploads.max_files10单次最大文件数,超出 413config.example.yaml:542
uploads.max_file_size52428800(50 MiB)单文件大小上限config.example.yaml:543
uploads.max_total_size104857600(100 MiB)单请求总大小上限config.example.yaml:544
uploads.auto_convert_documentsfalse是否在宿主机自动转 Markdown(默认关闭,安全)config.example.yaml:548
uploads.pdf_converterautoPDF 转换器选择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 集合;对磁盘上已存在的旧文件,新上传同名文件仍是直接覆盖(单次替换语义),不会自动 _1 uploads.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_filenameValueError 的文件(空名、..、含反斜杠、超长)会被 router 记日志后 continue,不计入成功列表 uploads.py:215-220
  • 符号链接目标会被拒。沙箱进程可能在未来上传名处预埋符号链接,open_upload_file_no_symlinkO_NOFOLLOW/fstat 拒绝,该文件进入 skipped_files 且响应 success=False uploads.py:270-293
  • 事件循环内复用单 workerDeerFlowClient 已在运行的事件循环内做转换时,会复用一个 max_workers=1ThreadPoolExecutor 而非每文件新建,结束后 shutdown(wait=True) client.py:1150-1204
  • 删除连带清理 .md。删除可转换类型文件时,伴生的同名 .md 也会被 unlink(missing_ok=True) 清掉,避免悬空提纲 manager.py:280-284

References

章节关系
./11-ThreadState与状态管理.mduploaded_files 是 ThreadState 字段,本章中间件向其写入新上传清单
./12-中间件链机制.mdUploadsMiddleware 在中间件链第 2 位,before_agent 注入上传上下文
./13-Gateway-API与路由体系.mduploads router 是 Gateway 路由体系的一员,共享鉴权/CSRF 机制
./18-沙箱工具与路径映射.md上传目录映射为沙箱内 /mnt/user-data/uploads,模型经此读取文件
./16-持久化与存储层.md上传文件落在 用户/线程 隔离目录,与持久化存储层共用 Paths 解析

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析