跳到主要内容

入口与编排:从字节到多路输出

30 秒导读: 这一章讲 MinerU 的"外壳"——命令行和服务入口如何把一个 PDF/图片/Office 文件收进来、选好用哪个解析后端、做完预处理,再把解析器吐出的同一份中间结果扇出成一堆 磁盘文件(Markdown、两版 content_list、middle.json、model.json、原文件、可视化 bbox PDF)。 不进模型内部——模型怎么解析是 pipeline 后端vlm 后端hybrid 后端 的事。

本章的目标很具体:读完你能自己追一次调用,从 mineru -p x.pdf -o out/ 一路追到 out/ 里 出现的每一个文件是谁写的、为什么。


1. 这是什么(零基础也能懂)

一句话定义: MinerU 的入口层是一套"文档解析编排器"——你给它一个文件(或一整个目录), 它负责把文件读成字节、决定用哪种解析方式、调度解析、最后把结果写成一组结构化产物。

它自己不解析。 真正把 PDF 变成文字的是三种后端(传统多模型流水线 / 视觉语言模型 / 二者混合)。 入口层做的是秘书的活:收件、分派、盯进度、归档。把这一层单独看清楚,后面看每个后端才不乱。

用起来什么样。 最小的一次真实调用就是一行命令:

mineru -p demo.pdf -o output/ -b pipeline

跑完 output/demo/auto/ 下会出现这些文件(名字里的 demo 是文件 stem,auto 是解析方法):

产物文件是什么
demo.md渲染好的 Markdown 正文(默认多模态版,图片用相对路径引用)
demo_content_list.json内容块的扁平列表(v1)
demo_content_list_v2.json内容块列表的新版结构(v2)
demo_middle.json统一中间表示,后端无关的核心数据(见 第 4 章)
demo_model.json模型原始输出(pipeline 是各模型结果,vlm 是推理结果)
demo_origin.pdf原始输入文件的副本
demo_layout.pdf / demo_span.pdf在原图上画了检测框的可视化 PDF
images/抠出来的图片/表格/公式截图

一句话直觉: 把入口层想成快递分拣中心——它不生产包裹,只负责"收进来 → 贴标签选传送带 → 分拣 → 打包成多种规格发出去"。这一章讲的就是这条传送带。


2. 顶层全景(它大概怎么转)

2.1 有哪些入口

MinerU 对外有好几个入口,但它们最后都汇到同一个函数 do_parse(见 §4)。先认清谁是谁:

入口文件一句话职责
CLI minerumineru/cli/client.py命令行主入口。注意:它不直接调 do_parse,而是起一个本地 API 服务,再当 HTTP 客户端去调它(见 §2.2)
FastAPI 服务mineru/cli/fast_api.pymineru-api,把 do_parse/aio_do_parse 包成 HTTP 接口,client.py 和外部都靠它
Gradio 界面mineru/cli/gradio_app.pymineru-gradio,网页版可视化 demo,同样走 API 客户端
多进程路由mineru/cli/router.py多 GPU 场景:探测本地设备、拉起多个 worker 子进程做负载均衡
API 客户端库mineru/cli/api_client.py一堆 HTTP 交互原语(提交任务、轮询状态、下载 zip、健康检查),client.py 里几乎所有网络函数都是它的薄封装

本章重点讲 CLI + do_parse 这条主线,其余入口一句话带过即可——它们只是"喂给 do_parse 的不同前门"。

2.2 CLI 的关键设计:自己当自己的客户端

client.py 有个容易让人愣一下的设计:命令行不在本进程里解析文档。它在 run_orchestrated_cli 里做的是——如果你没给 --api-url,它就起一个临时的本地 mineru-api 服务,然后把自己当成 HTTP 客户端,把任务提交过去、轮询、下载结果 zip、解压落盘。

$ mineru -p x.pdf -o out/


client.py: main() ──asyncio.run──► run_orchestrated_cli() client.py:910

├─ 没 --api-url? ──► 拉起本地 LocalAPIServer(= fast_api 进程)
│ └─ 等它 /health 就绪

├─ 收集输入文档、装箱成 PlannedTask

├─ 对每个任务:HTTP 提交 ─► 轮询状态 ─► 下载结果 zip ─► 解压到 out/

└─ 结果落盘后,另起进程画可视化 bbox

(API 进程内部) ▼
fast_api ──► do_parse / aio_do_parse common.py:668 / :760

├─ normalize_backend (选传送带)
├─ 抽走 Office 文件单独处理
├─ _prepare_pdf_bytes (预处理字节)
└─ 按 backend 分派:
pipeline → _process_pipeline
vlm-* → _process_vlm
hybrid-* → _process_hybrid


_process_output common.py:259 (扇出落盘)

怎么读这张图: 上半段(命令行 ↔ 本地 API)全是 client.py 干的网络编排;下半段(API 进程内 从 do_parse_process_output)全是 common.py 干的解析编排。本章两大主角就是这两个文件。

为什么绕这一圈?因为服务模式(fast_api)天然支持并发、排队、多 GPU worker;CLI 复用它就白 得了这些能力,不必自己再写一套。CLI 只保留一件事是本地做的——可视化 bbox 的绘制(见 §5.4)。


3. CLI 层:命令行怎么被收拾干净

这一节讲 client.py 顶部那些"看起来琐碎、实则很重要"的入口处理:参数归一、输入收集、任务装箱。

3.1 main:一个 click 命令,十几个选项

CLI 是单个 click.command,入口函数是 main(mineru/cli/client.py:1185),它几乎不干活,只把 参数原样转交给 run_orchestrated_cli(client.py:910)并用 asyncio.run 跑起来。真正的编排在后者。

选项里几个要记住的:-p/--path 输入、-o/--output 输出、-b/--backend 选后端、-m/--method 选解析方法(auto/txt/ocr)、--effort 控制 hybrid 的力度、--api-url 指向一个已有的远程服务 (给了就不起本地服务)。

3.2 参数归一:旧名字自动翻译成新名字

MinerU 的后端名改过版,CLI 用 click callback 在参数一进来就把旧名归一。--backend 选项 (client.py:1079 起的装饰器)挂了 callback=normalize_backend_option:

# client.py:97 normalize_backend_option —— 真实源码
def normalize_backend_option(ctx, param, value: str) -> str:
"""将 CLI 输入的旧 backend 名称规范为当前公开名称。"""
try:
return normalize_backend(value)
except ValueError as exc:
raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc

它把真正的归一逻辑委托给 normalize_backend,并把内部的 ValueError 翻译成 click 能漂亮打印的 BadParameter。同样套路的还有 normalize_effort_option(client.py:109)和 normalize_ocr_lang_option(client.py:121)。

别名归一的真源在 backend_options.py normalize_backend(backend_options.py:39)先查一张 旧名映射表,再校验结果是否在公开清单里:

# backend_options.py:25 别名表 + :39 normalize_backend —— 真实源码(节选)
LEGACY_BACKEND_ALIASES = {
"vlm-auto-engine": BACKEND_VLM_ENGINE, # 旧名 → 新公开名
"hybrid-auto-engine": BACKEND_HYBRID_ENGINE,
}

def normalize_backend(backend: str) -> str:
normalized_backend = LEGACY_BACKEND_ALIASES.get(backend, backend)
if normalized_backend not in PUBLIC_BACKEND_CHOICES:
raise ValueError(f"Invalid backend. Allowed values: ...")
return normalized_backend

公开后端一共五个(backend_options.py:13-22):

backend含义深入章节
pipeline传统多模型流水线,通用02
vlm-engine单个视觉语言模型,本地算力,高精度03
vlm-http-client同上但走远程 OpenAI 兼容服务03
hybrid-engine新一代混合方案,本地算力(默认后端,见 DEFAULT_BACKEND,backend_options.py:11)05
hybrid-http-client混合方案,主要算力在远程05

为什么归一要在两个层次都做? click callback 是给兜底(命令行敲了旧名也能跑);而 do_parse 内部开头又调了一次 normalize_backend(common.py:693),是给程序化调用兜底 (别人直接 import do_parse 传旧名也不崩)。归一是幂等的,重复调用无害。

3.3 从输入到"任务":收集与装箱

run_orchestrated_cli 拿到参数后,先把输入变成一串结构化文档,再把文档打包成任务:

  • collect_input_documents(client.py:544):目录就遍历,单文件就单个;按后缀过滤,只留 PDF/图片/Office;对 PDF 还会真去数有效页数(probe_pdf_effective_pages,client.py:521), 为后面装箱算权重;最后用 uniquify_task_stems 给重名文件去重(否则输出目录会互相覆盖)。
  • plan_tasks(client.py:654):只有 pipeline 后端会做"装箱"——plan_pipeline_tasks (client.py:609)把多个小文件按页数打包进同一个批次(经典的 bin-packing,尽量填满 processing_window_size),让流水线一次多吃几页;其它后端一个文件一个任务。

装箱后,execute_planned_tasks(client.py:781)用一个 asyncio 队列 + 若干 worker 协程并发跑, 并发度由服务端和本地配置协商(resolve_submit_concurrency,client.py:765)。每个任务真正的 执行体是 run_planned_task(client.py:819):提交 → 轮询 → 下载 zip → 解压 → 排可视化任务。

这一段是纯网络编排,不碰解析。真正把字节喂给模型的一切,发生在 API 进程里的 do_parse


4. do_parse:分派中枢

这是全章的心脏——所有入口最终都汇到 do_parse(mineru/cli/common.py:668)或它的异步孪生 aio_do_parse(common.py:760)。它要解决的小问题是:"手里有一堆文件字节和一个 backend 名, 该怎么把它们送对地方、并保证输出统一?"

4.1 分派前的三步预处理

do_parse 在真正分派前,依次做三件事:

① 归一后端名。 开头 backend = normalize_backend(backend)(common.py:693),理由见 §3.2。

② 把 Office 文件先抽出来单独走。 _process_office_doc(common.py:618)扫一遍字节,凡是 docx/pptx/xlsx 的当场解析并落盘,然后返回这些文件的下标;do_parse 据此把它们从后续列表里 del 掉(common.py:705-707)。Office 直解走的是完全不同的路径,详见 第 5 章

③ 预处理 PDF 字节。 剩下的 PDF/图片字节过一遍 _prepare_pdf_bytes(common.py:250),它对每个 文件调 convert_pdf_bytes_to_bytes(common.py:194)。这一步很关键——它把 PDF 用 pdfium 重写一遍, 按 start_page_id/end_page_id 裁页,并容错处理坏页:

convert_pdf_bytes_to_bytes common.py:194

├─ 试:rewrite_pdf_bytes_with_pdfium(整份重写、裁页)
│ └─ 成功且非空 ─► 返回重写后的字节 ✅

├─ 失败/空? ─► 探测哪些页能加载(get_loadable_pdfium_page_indices)
│ ├─ 有坏页 ─► 打日志列出被跳过的页码
│ ├─ 一页都加载不了 ─► 退回用原始字节
│ └─ 只用能加载的页重写一遍

└─ 还不行 ─► 兜底:返回原始 pdf_bytes(绝不让整个任务崩在坏页上)

怎么读: 从上到下是降级顺序,命中即返回。这体现了一个反复出现的设计取向——宁可少解析 几页,也不让一个坏页让整份文档失败。字节层面的读取本身则由 read_fn(common.py:171)完成: 它按后缀判断,图片会先被 images_bytes_to_pdf_bytes 转成 PDF 字节,PDF/Office 原样返回。

4.2 目录约定:prepare_env

每个文件落盘前,prepare_env(common.py:186)先把输出目录建好。它的路径拼法就是你在 §1 看到的 那个结构:

# common.py:186 prepare_env —— 真实源码
def prepare_env(output_dir, pdf_file_name, parse_method):
local_md_dir = str(os.path.join(output_dir, pdf_file_name, parse_method))
local_image_dir = os.path.join(str(local_md_dir), "images")
os.makedirs(local_image_dir, exist_ok=True)
os.makedirs(local_md_dir, exist_ok=True)
return local_image_dir, local_md_dir

<output>/<文件名>/<parse_method>/,图片再往下放 images/parse_method 对 pipeline 是 auto/txt/ocr,对 vlm 固定是 "vlm",对 hybrid 是 "hybrid_<method>",对 Office 是 "office"

4.3 依赖门禁:ensure_backend_dependencies

hybrid 后端需要本地 torchensure_backend_dependencies(common.py:69)在分派前(或 CLI 侧的 run_orchestrated_cli 里)先探测,缺依赖就抛一个带清晰安装提示的错误,而不是等到深处才因 ImportError 炸掉:

# common.py:69 ensure_backend_dependencies —— 真实源码
def ensure_backend_dependencies(backend: str) -> None:
if not backend.startswith("hybrid-"):
return
if importlib.util.find_spec("torch") is None:
raise HybridDependencyError(build_hybrid_dependency_error_message(backend))

4.4 三条分派分支

预处理完,do_parse 按 backend 前缀分派(common.py:716-757)。注意它分派前会把前缀切掉, 把"公开名"降成"引擎名"再传下去:

backend 前缀处理函数切前缀后process_mode
pipeline_process_pipeline(common.py:351)不切"pipeline"
vlm-*_process_vlm(common.py:467)去掉 vlm-(common.py:726)"vlm"
hybrid-*_process_hybrid(common.py:508)去掉 hybrid-(common.py:743)"vlm"(复用 vlm 的产物构造)

其中 engine 这个字面量会被 get_vlm_engine(...)(common.py:729/:746)进一步解析成实际推理引擎名。 分派时还顺手把 formula/table 开关塞进环境变量(如 MINERU_VLM_TABLE_ENABLE,common.py:731-732), 供下游模型读取——这是 MinerU 里"用环境变量传配置"的一个典型点。

aio_do_parse(common.py:760)是同一套逻辑的异步版,差别是:Office 解析是同步且耗时的,它用 asyncio.to_thread 丢到线程里(common.py:787)避免阻塞事件循环;vlm/hybrid 走各自的 _async_* 变体;pipeline 目前不支持异步,异步入口里也是直接调同步的 _process_pipeline(common.py:812, 代码注释明说)。


5. _process_output:同一份结果,扇出成多路产物

三条分派分支各自跑完解析后,都会汇到同一个落盘函数 _process_output (mineru/cli/common.py:259)。这是"多路输出"真正发生的地方。它要解决的问题是:"手里有一份 后端无关的 middle_json,怎么按后端类型和一堆开关,把它渲染成正确格式并写到磁盘?"

5.1 先按 process_modeunion_make

不同后端的中间结构略有差异,所以 Markdown/content_list 的构造函数是分家的。_process_output 一进来就用 process_mode 挑对应的 union_make(common.py:279-286):

# common.py:279 —— 真实源码(节选)
if process_mode == "pipeline":
make_func = pipeline_union_make
elif process_mode == "vlm":
make_func = vlm_union_make
elif process_mode in office_suffixes: # docx/pptx/xlsx
make_func = office_union_make
else:
raise Exception(f"Unknown process_mode: {process_mode}")

union_make 是"从 middle_json 生成某种格式"的统一入口,它按传入的 MakeMode 决定产出什么—— 详见 第 4 章。这里只需知道:同一个 make_func 被复用来产出 Markdown、content_list v1、content_list v2,只是 mode 参数不同。

5.2 一组 f_dump_* 开关控制落哪几路

_process_output 收一堆布尔开关,每个对应一路产物。默认全开(见 do_parse 的默认参数, common.py:678-685)。逐路看它写了什么:

开关落盘文件怎么生成(common.py)
f_dump_md<name>.mdmake_func(pdf_info, f_make_md_mode, image_dir),默认 mode 是 MM_MD(多模态 Markdown),:314-319
f_dump_content_list<name>_content_list.jsonmake_func(..., MakeMode.CONTENT_LIST, ...),:323
f_dump_content_list(同一开关)<name>_content_list_v2.jsonmake_func(..., MakeMode.CONTENT_LIST_V2, ...),:329
f_dump_middle_json<name>_middle.json直接 json.dumps(middle_json),:336-340
f_dump_model_output<name>_model.json直接 dump 模型原始输出 model_output,:342-346
f_dump_orig_pdf<name>_origin.pdf_origin.<office后缀>原字节写回,Office 用原后缀,:300-310
f_draw_layout_bbox<name>_layout.pdfdraw_layout_bbox(...) 在原图上画版面框,:288-292
f_draw_span_bbox<name>_span.pdfdraw_span_bbox(...) 画更细的 span 框,:294-298

注意一个细节:content_list 的两个版本共用 f_dump_content_list 一个开关,v1、v2 会一起写。 MakeMode 的取值定义在 mineru/utils/enum_class.py:89(MM_MD='mm_markdown'NLP_MD='nlp_markdown'CONTENT_LISTCONTENT_LIST_V2)。

5.3 可视化 bbox 为什么只是"尽力而为"

画框那两步(common.py:288-298)都各自套在 try/except 里,失败只 logger.warning 跳过,不影响 其它产物。这呼应了 §4.1 的取向:可视化是锦上添花,绝不能因为画框失败拖垮正文输出。 另外, span bbox 只对 pipeline 有意义——vlm/hybrid 的处理函数进 _process_output 前会把 f_draw_span_bbox 强制设为 False(如 common.py:444:485:553)。

5.4 CLI 侧的可视化:另起进程

前面 §5.2 的画框是在 API 进程内、拿得到 pdf_info 时顺手画的。但 CLI 走的是 HTTP 客户端, 结果是解压出来的 zip,并没有活的 pdf_info。所以 client.py 另起一个进程池专门重画: create_visualization_context(client.py:390)用 spawn 上下文起一个 ProcessPoolExecutor, run_planned_task 落盘后调 queue_visualization_jobs(client.py:446)把每个文档排进去,由 run_visualization_job 在子进程里读磁盘产物、重绘 bbox。draw_span 只对 pipeline 打开 (build_visualization_jobs,client.py:411draw_span = backend.startswith("pipeline"))。


6. pipeline 的流式输出设计(值得单独看)

前面 vlm/hybrid 的处理函数都是"一个文档解析完 → 立刻 _process_output"的直筒子。pipeline 不一样, 它是流式的,这是入口层里最精巧的一处编排,值得单独讲。

它要解决的问题: pipeline 一次可能吃一批文档(§3.3 的装箱),模型是逐个文档产出结果的。如果 "全部解析完再统一落盘",内存和延迟都吃亏。理想是**"哪个文档先解析好,就先把它的产物写出去"**—— 解析(GPU/CPU 重活)和落盘(IO 活)重叠起来。

_process_pipeline(common.py:351)就是这么设计的:它开一个单 worker 的 ThreadPoolExecutor (common.py:402),定义一个 on_doc_ready 回调(common.py:403),把回调交给流式解析函数 doc_analyze_streaming(在 common.py:410 调用,实现见 第 2 章):

# common.py:402 _process_pipeline —— 真实源码(节选)
with ThreadPoolExecutor(max_workers=1) as output_executor:
def on_doc_ready(doc_index, model_list, middle_json, ocr_enable):
# 每当一个文档解析完,就把它的落盘任务提交到线程池
future = output_executor.submit(run_output_task, doc_index, middle_json, model_list)
output_futures.append(future)

pipeline_doc_analyze_streaming( # 解析边跑边回调
pdf_bytes_list, image_writer_list, p_lang_list, on_doc_ready,
parse_method=parse_method, formula_enable=..., table_enable=..., ...
)

for future in output_futures: # 等所有落盘完成
future.result()

怎么读这段设计:

  1. doc_analyze_streaming生产者——每解析完一个文档,回调一次 on_doc_ready,把 (下标, model_list, middle_json) 递出来。
  2. on_doc_ready 不亲自落盘,而是把落盘任务 submit 给线程池,立刻返回,让解析线程继续下一个 文档——解析和落盘就此并行。
  3. 落盘的实际工作在 run_output_task(common.py:385)里,它就是调 §5 那个 _process_output(..., process_mode="pipeline")
  4. 单 worker 线程池是刻意的:落盘任务串行执行、互不打架,但仍与主解析线程重叠。
  5. 最后 future.result() 逐个等,把子线程里的异常抛回主线程(run_output_taskexcept 会 re-raise,common.py:398-400),不会静默丢失失败。

这就是 pipeline 后端"边解析边出结果"的编排骨架。vlm/hybrid 因为通常一次一个文档,没走这套流式, 直接同步/异步逐个处理即可。


7. 边界与局限(诚实)

  • CLI 不在本进程解析。 若你只想在自己代码里同步跑一次解析,别学 CLI 起服务——直接 import do_parse(common.py:668)传字节最省事。CLI 那套 HTTP 往返是为了复用服务端的并发与多 GPU 能力。
  • pipeline 没有异步实现。 aio_do_parse 里 pipeline 分支实际调的是同步 _process_pipeline (common.py:811-812 有注释明说)。异步只对 vlm/hybrid 生效。
  • content_list v1/v2 绑同一开关。 想只要其中一个,当前 _process_output 不给单独控制,会一起写。
  • 坏页是"跳过"不是"报错"。 convert_pdf_bytes_to_bytes 遇到坏页会静默跳过并只打 warning (§4.1),产物里可能少页而不报错——依赖完整性的场景要自己查日志。
  • Office 走完全独立的路径。 它在分派前就被 _process_office_doc 抽走处理,不经过 PDF 字节 预处理和三大后端分支,细节在 第 5 章

8. 代码地图(导航索引)

按符号名 grep 比按行号更抗漂移(as-of commit d14a6e37)。

主题文件路径符号名
CLI 主命令入口mineru/cli/client.pymain
CLI 顶层编排(起服务/提交/落盘)mineru/cli/client.pyrun_orchestrated_cli
backend 参数 click callbackmineru/cli/client.pynormalize_backend_option
单个任务执行体mineru/cli/client.pyrun_planned_task
并发调度mineru/cli/client.pyexecute_planned_tasks
输入收集与去重mineru/cli/client.pycollect_input_documents
pipeline 任务装箱mineru/cli/client.pyplan_pipeline_tasks
CLI 侧可视化进程池mineru/cli/client.pycreate_visualization_context / queue_visualization_jobs
backend 别名归一 + 公开清单mineru/cli/backend_options.pynormalize_backend / LEGACY_BACKEND_ALIASES / PUBLIC_BACKEND_CHOICES
分派中枢(同步)mineru/cli/common.pydo_parse
分派中枢(异步)mineru/cli/common.pyaio_do_parse
字节读取mineru/cli/common.pyread_fn
PDF 字节预处理/裁页/坏页容错mineru/cli/common.pyconvert_pdf_bytes_to_bytes / _prepare_pdf_bytes
输出目录约定mineru/cli/common.pyprepare_env
hybrid 依赖门禁mineru/cli/common.pyensure_backend_dependencies
多路输出扇出落盘mineru/cli/common.py_process_output
pipeline 流式输出编排mineru/cli/common.py_process_pipeline / on_doc_ready
Office 抽取处理mineru/cli/common.py_process_office_doc
MakeMode 枚举mineru/utils/enum_class.pyMakeMode
FastAPI 服务入口mineru/cli/fast_api.pyparse_pdf / run_parse_job
多 GPU 路由mineru/cli/router.pydetect_visible_local_devices
HTTP 客户端原语mineru/cli/api_client.pysubmit_parse_task / wait_for_task_result

同组其它章: index · 02 pipeline 后端 · 03 vlm 后端 · 04 middle_json 与 Markdown · 05 hybrid 与 Office