数据截至 (上游 commit 4fe4bde114a2)
vlm 后端:单个视觉语言模型端到端解析
30 秒导读: pipeline 后端靠一串专用模型接力(布局→OCR→公式→表格)。vlm 后端把这一切压成一个视觉语言大模型:喂它一张整页图片,它直接吐出一串「这块是标题、在这个坐标、内容是这些字」的结构化块。本章讲清这个「一个大模型直接吐结构化块」的路径怎么装配模型、怎么分窗推理、怎么把模型输出流式接进统一的
middle_json。
本章聚焦目录 mineru/backend/vlm/(分析编排层),并牵出 mineru/model/vlm/ 与 mineru/cli/vlm_server.py(自托管推理服务)。总体入口与多后端选择见 01-orchestration.md;输出的 middle_json 结构与 Markdown 渲染见 04-middle-json-and-markdown.md。
1. 这是什么(零基础也能懂)
一句话定义: vlm 后端用一个能看图的大模型(Vision-Language Model,视觉语言模型) ,一次读一整页文档图片,直接输出这一页由哪些「块」组成、每块是什么类型、在页面的什么位置、内容是什么。
它和流水线的根本差异,一句话:
- pipeline 后端 = 一条装配线:布局检测模型找框 → OCR 模型认字 → 公式模型认公式 → 表格模型认表格,每一步一个专用小模型,各管一段。
- vlm 后端 = 一个通才:把整页图片丢给一个大模型,让它「一口气」把版面理解 + 文字识别 + 类型判断全做了,直接产出结构化结果。
给谁用 / 解决什么问题: 你想解析 PDF、扫描件、复杂排版(多栏、图文混排、公式表格穿插),又不想维护一堆专用模型的拼接逻辑。VLM 把「理解版面」和「识别内容」融进同一次推理,排版越怪,端到端模型往往越稳。
用起来什么样: 调用方(mineru/cli/common.py:453)只需一句:
# 示意,来自 cli/common.py 的异步 VLM 路径
middle_json, infer_result = await aio_vlm_doc_analyze(
pdf_bytes, # 一份 PDF 的原始字节
image_writer=image_writer,
backend=backend, # transformers / vllm-engine / http-client ...
server_url=server_url, # 用远程推理服务时才需要
)
# middle_json:统一中间表示(下游渲染 Markdown/JSON 用)
# infer_result:每页的原始 VLM 块列表
一句话直觉: 把 pipeline 想成「四个专家轮流看一页」;把 vlm 想成「一个全能专家一眼看完整页,直接写下结构化笔记」。本章讲这个全能专家怎么被喂图、怎么被装配、它的笔记怎么被整理归档。
2. 顶层全景(它大概怎么转)
vlm 后端的主函数是 doc_analyze(同步,vlm_analyze.py:423)和 aio_doc_analyze(异步,vlm_analyze.py:523),两者结构几乎镜像。一次调用干三件事:装配模型 → 分窗推理 → 流式归并。
怎么读下面这张图: 从上到下是一次 doc_analyze 的时间顺序;中间的「分窗循环」会对一份长 PDF 重复多次。
输入:pdf_bytes
│
▼
┌──────────────────────────────────────────────┐
│ ① 拿模型 ModelSingleton().get_model(...) │ 按 (backend,path,url) 缓存
│ 按 backend 装配某种 runtime,包成 │ 一个 MinerUClient(predictor)
│ 统一的 MinerUClient │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ ② init_middle_json() 建空的统一中间表示 │
└──────────────────────────────────────────────┘
│
▼
分窗循环(每窗 ≤ window_size 页)─────────────┐
│ a. load_images_from_pdf_doc 这一窗渲染成图 │
│ b. predictor.batch_two_step_extract(images) │ ← 大模型在这里干活
│ └ 返回 window_results:逐页的块列表 │
│ c. append_page_blocks_to_middle_json(...) │ ← 流式并入
└───────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ ③ finalize_middle_json(pdf_info) │ 合并段落/跨页表格/标题分级
└──────────────────────────────────────────────┘
│
▼
输出:(middle_json, results)
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
doc_analyze / aio_doc_analyze | VLM 后端主编排:装配、分窗、归并 | vlm_analyze.py:423 / :523 |
ModelSingleton.get_model | 按 backend 装配具体 runtime,缓存并包成 MinerUClient | vlm_analyze.py:54 |
MinerUClient(外部库 mineru_vl_utils) | 统一各 runtime 的推理接口,提供 batch_two_step_extract | 第三方包,vlm_analyze.py:39 导入 |
get_processing_window_size | 决定每窗多少页 | utils/config_reader.py:158 |
append_page_blocks_to_middle_json | 把 VLM 块流式接进 middle_json | model_output_to_middle_json.py:83 |
finalize_middle_json | 全文级后处理(段落/跨页表/标题) | model_output_to_middle_json.py:106 |
MagicModel | 把一页原始块解析成分类好的块 | vlm_magic_model.py:29(详见 04) |
主线走一遍(高层): pdf_bytes 进来 → 打开成 pdfium 文档 → 按窗口把页渲染成 PIL 图片 → 大模型 batch_two_step_extract 逐页吐块 → 每窗结果立刻并入 middle_json → 全部处理完做一次全文 finalize → 返回。
3. 核心原理(逐个机制,由浅入深)
3.1 two-step extract:先版面块,再逐块识别
它要解决的小问题: 一整页丢给模型「你把这页转成 Markdown」听起来简单,但一页可能有几十个元素(标题、正文、表格、公式、图注……)。让模型一次生成全部内容,长文本容易漏、容易乱序、坐标也说不准。
思路/直觉: 拆成两步(这就是「two-step」):
- 第一步:只找版面块。 让模型先扫一遍整页,输出「这页有哪些块、每块什么类型、bbox(bounding box,边界框坐标)在哪」——先只画框、贴标签,不急着认全部内容。
- 第二步:逐块识别内容。 拿着第一步的每个框,再让模型针对性地识别这个框里的内容(正文文字、表格 HTML、公式 LaTeX 等)。
分两步的好处:定位和识别解耦,版面顺序由第一步定死,内容识别聚焦单块、更准。这套逻辑封装在第三方库 mineru_vl_utils.MinerUClient 里,MinerU 侧只调它的入口方法。
真实实现: 分窗循环里的一行就是全部触发点——
# vlm_analyze.py:479 同步路径
window_results = predictor.batch_two_step_extract(
images=images_pil_list,
image_analysis=image_analysis,
)
异步路径对应 predictor.aio_batch_two_step_extract(...)(vlm_analyze.py:578)。batch_ 前缀表示一次传入一整窗的多页图片做批量推理。
image_analysis 开关是什么: 这个布尔控制第二步里对「图 / 图表」是否做进一步的视觉分析(例如给 chart 打子类型)。它一路从 CLI(cli/client.py:1199)透传到这里,默认 True(doc_analyze 签名默认值,vlm_analyze.py:430)。装配 MinerUClient 时也把 image_analysis=True 作为默认能力打开(vlm_analyze.py:237)。
关键细节: 每页 VLM 输出的块用的是归一化坐标(0~1 的比例),并非像素。归一化 bbox 到像素的换算在
MagicModel.__init__里做:int(x1 * width)(vlm_magic_model.py:40-45)。这让同一份块列表能适配任意渲染分辨率。
3.2 processing-window:为什么要分窗
它要解决的小问题: 一份 PDF 可能几百页。若一次性把所有页都渲染成高清 PIL 图再全塞进模型,内存会爆,显存排队也难调度。
思路/直觉: 把文档切成固定大小的处理窗口(processing window),一窗一窗地「渲染图 → 推理 → 并入结果 → 释放图」。任一时刻内存里只有一窗的图片,处理完立刻 _close_images 释放(vlm_analyze.py:503),内存占用与文档长度解耦。
窗口大小怎么定: 由环境变量 MINERU_PROCESSING_WINDOW_SIZE 控制,缺省 64 页(config_reader.py:158,get_processing_window_size)。主编排里再对总页数取 min,避免窗口比文档还大:
# vlm_analyze.py:446
configured_window_size = get_processing_window_size(default=64)
effective_window_size = min(page_count, configured_window_size) if page_count else 0
分窗循环怎么走: range(0, page_count, effective_window_size) 逐窗推进,每窗只渲染 [window_start, window_end] 这段页:
# vlm_analyze.py:462 同步路径(异步在 :562,用 aio_load_images_from_pdf_bytes_range)
for window_index, window_start in enumerate(range(0, page_count, effective_window_size or 1)):
window_end = min(page_count - 1, window_start + effective_window_size - 1)
images_list = load_images_from_pdf_doc(
pdf_doc, start_page_id=window_start, end_page_id=window_end,
image_type=ImageType.PIL, pdf_bytes=pdf_bytes,
)
同步 vs 异步的图片来源差异: 同步用 load_images_from_pdf_doc(复用已打开的 pdf_doc);异步用 aio_load_images_from_pdf_bytes_range(从字节区间独立渲染,便于放进线程/协程)。这是两条路径唯一实质区别之一。
易读小表:分窗涉及的量
量 含义 来源 page_count文档总页数 get_pdfium_document_page_countconfigured_window_size配置的每窗页数(默认 64) MINERU_PROCESSING_WINDOW_SIZEeffective_window_sizemin(总页数, 配置值)vlm_analyze.py:447total_windows向上取整的窗口总数 vlm_analyze.py:448
3.3 ModelSingleton:一个客户端,接六种 runtime
它要解决的小问题: 「运行这个 VLM」在不同硬件/部署下差别巨大:本机用 transformers、追吞吐用 vllm、Mac 上用 mlx、或者干脆连一个远程推理服务。上层编排不想关心这些差异。
思路/直觉: 用一个单例 + 缓存(ModelSingleton,vlm_analyze.py:43)把「装配某种 runtime」这件脏活收拢,最后统一包成一个 MinerUClient(predictor)。上层只认 predictor.batch_two_step_extract,不管底下是哪种引擎。
缓存键: (backend, model_path, server_url)(vlm_analyze.py:61)。同样三元组只装配一次,_lock(RLock)保证多线程下装配串行、不重复加载大模型。
六种 runtime 分支,各装配什么:
| backend | 装配了什么 | 关键代码 |
|---|---|---|
transformers | Qwen2VLForConditionalGeneration + AutoProcessor,本机推理;按显存定 batch_size | vlm_analyze.py:82 |
vllm-engine | 同步 vllm.LLM(**kwargs),高吞吐 | vlm_analyze.py:118 |
vllm-async-engine | 异步 AsyncLLM.from_engine_args(...) | vlm_analyze.py:143 |
lmdeploy-engine | VLAsyncEngine,可选 pytorch/turbomind 后端、多种设备 | vlm_analyze.py:175 |
mlx-engine | load_mlx_model,仅 Apple Silicon + macOS 13.5+ | vlm_analyze.py:108 |
http-client | 不装配本地模型,只连远程 server_url(无 model_path 分支,见 vlm_analyze.py:80) | 走 MinerUClient 的 server_url |
装配完统一收口成一个 MinerUClient:
# vlm_analyze.py:222 无论上面走哪支,都汇到这里
predictor = MinerUClient(
backend=backend, model=model, processor=processor,
lmdeploy_engine=lmdeploy_engine, vllm_llm=vllm_llm,
vllm_async_llm=vllm_async_llm, server_url=server_url,
...
image_analysis=True,
enable_cross_page_table_merge=True,
)
MinerULogitsProcessor 是什么: 两个 vllm 分支在满足条件时会挂一个自定义 logits processor(vlm_analyze.py:138-140 与 :170-172)。它约束模型解码,让输出更贴合 MinerU 期望的结构化块格式(是否启用由 enable_custom_logits_processors() 按 CUDA 算力、vllm 版本、VLLM_USE_V1 综合判断,utils.py:12)。
http-client 的意义: 它把「模型在哪跑」和「谁来编排」彻底分开——推理服务可以是另一台机器上常驻的 vLLM/LMDeploy 服务(见 §4),本地进程只当轻量客户端。装配前会跳过模型下载(vlm_analyze.py:80 的 if backend not in ["http-client"])。
3.4 生命周期:串行化守卫与优雅关停
VLM runtime 是重资源(占显存/内存),需要两类保护。
① 串行化守卫(针对 MLX): MLX 后端不是线程安全的并发推理,于是给它的 predictor 挂一把锁。_maybe_enable_serial_execution(vlm_analyze.py:379)检测到 MLX 就设 _mineru_execution_lock;推理时用上下文管理器 predictor_execution_guard(vlm_analyze.py:391,异步版 aio_predictor_execution_guard,:401)把 batch_two_step_extract 那一段包起来:
# vlm_analyze.py:478
with predictor_execution_guard(predictor):
window_results = predictor.batch_two_step_extract(...)
对非 MLX 后端,锁为 None,守卫直接 yield,零开销(vlm_analyze.py:392-395)——即「只在需要时串行,其余并发」。
② 优雅关停: 大模型持有的显存/句柄要能被回收。shutdown_cached_models(vlm_analyze.py:365)调 ModelSingleton().shutdown(),遍历所有缓存的 predictor,尝试各种 runtime 的关停方法(shutdown/close/engine.shutdown/llm_engine.model_executor.shutdown …,一串候选路径见 _shutdown_runtime_handle,vlm_analyze.py:325),再清引用、gc.collect()。它通过 atexit.register(vlm_analyze.py:369)在进程退出时自动触发。
巧妙处: 关停用「多候选方法路径逐个试」而非硬编码某个 API(
_call_nested_shutdown,vlm_analyze.py:303)。因为 vllm/lmdeploy 版本更新常改关停接口,逐个尝试 + 静默失败让 MinerU 不必跟着每次版本变动改代码。
3.5 从 VLM 输出到 middle_json:流式并入
这是「大模型吐的块」变成「统一中间表示」的桥。三个函数分工清晰(都在 model_output_to_middle_json.py)。
① init_middle_json(:79)——建空壳:
# model_output_to_middle_json.py:79
def init_middle_json():
return {"pdf_info": [], "_backend": "vlm", "_version_name": __version__}
打上 _backend: "vlm" 标记,pdf_info 是空列表,等着一页页塞进来。
② append_page_blocks_to_middle_json(:83)——逐页并入: 这是「流式」的核心。每处理完一窗,就把这一窗的逐页块列表 zip 上对应的页面图片,一页一页转成 page_info 追加进 pdf_info:
# model_output_to_middle_json.py:92
for offset, (page_blocks, image_dict) in enumerate(zip(model_output_blocks_list, images_list)):
page_index = page_start_index + offset # 全局页号 = 窗起点 + 窗内偏移
...
page_info = blocks_to_page_info(page_blocks, image_dict, page, image_writer, page_index)
middle_json["pdf_info"].append(page_info)
if progress_bar is not None:
progress_bar.update(1)
page_start_index 由主编排按窗传入(vlm_analyze.py:498 的 page_start_index=window_start),保证跨窗页号连续。这就是「流式」:不等全文推理完,每窗一到就并入并更新进度条。
单页的转换 blocks_to_page_info(:23)交给 MagicModel(vlm_magic_model.py:29)把原始块分门别类——取出 image/table/chart/title/text/公式/list/discarded 等各类块,对图表/表格/公式类 span 做截图落盘(cut_image_and_table,:50),最后按 index 排序拼成 preproc_blocks(:68)。各块类型的解析细节见 04-middle-json-and-markdown.md。
③ finalize_middle_json(:106)——全文级收尾: 所有页都并入后做一次跨页处理:
# model_output_to_middle_json.py:106
def finalize_middle_json(pdf_info_list):
build_para_blocks_from_preproc(pdf_info_list) # preproc 块 → 段落块
merge_para_text_blocks(pdf_info_list) # 合并相邻文本段
table_enable = get_table_enable(...)
if table_enable:
cross_page_table_merge(pdf_info_list) # 跨页表格拼接
apply_title_leveling_to_pdf_info(pdf_info_list) # 标题分级(H1/H2..)
cleanup_internal_para_block_metadata(pdf_info_list)
为什么 finalize 要放最后、不能逐窗做: 段落合并、跨页表格拼接、标题分级都是跨页/全局判断——一个表格可能跨两窗、标题层级要看全文才定得准。所以推理和「逐页并入」流式进行,但结构化收尾必须等全部到齐。
关键开关: 主编排里有个
client_side_output_generation(vlm_analyze.py:433)。若为真,则跳过 finalize(vlm_analyze.py:513),把这步留给客户端做——服务端只吐原始块流,减轻服务端负担。异步路径把 finalize 丢进线程执行(asyncio.to_thread,vlm_analyze.py:613),避免阻塞事件循环。
4. 自托管推理服务(把模型跑成独立服务)
§3.3 的 http-client backend 需要一个远程推理服务。MinerU 自带两个「启动器」把 vLLM / LMDeploy 拉起成 OpenAI 兼容的 HTTP 服务,供 http-client 连。
统一入口 openai_server(cli/vlm_server.py:28): 一个 click 命令,--engine auto|vllm|lmdeploy。auto 时先试 import vllm,失败再退回 lmdeploy,都没有就报错退出(cli/vlm_server.py:30-44)。它把命令行多余参数透传给底层启动器。
vLLM 启动器 model/vlm/vllm_server.py:12: 本质是给官方 vllm serve 补默认参数再转发:
- 没给
--port就补30000(:45); - 没给
--gpu-memory-utilization就按显存算一个默认值(:47,set_default_gpu_memory_utilization); - 满足条件时补
--logits-processors mineru_vl_utils:MinerULogitsProcessor(:52)——和进程内 vllm 分支挂的是同一个约束解码器; - 最后重排成
serve <model_path> <args>调vllm_main()(:58)。
LMDeploy 启动器 model/vlm/lmdeploy_server.py:11: 同思路但差异在细节——端口参数是 --server-port(:24),补 --cache-max-entry-count 0.5(:53);根据设备类型与算力自动选 pytorch/turbomind 后端(set_lmdeploy_backend,utils.py:60);最终用 os.system("lmdeploy serve api_server ...") 起服务(:90)。
预加载 cli/vlm_preload.py:61: preload_vlm_model 在服务启动时提前调 ModelSingleton().get_model(...) 把模型加载进显存(:68),避免首个请求现加载的冷启动延迟。由 --enable-vlm-preload 开关控制(build_local_api_cli_args,:26)。
一句话看清三层部署形态:
| 形态 | backend | 模型在哪 |
|---|---|---|
| 进程内本机 | transformers/mlx-engine/vllm-engine/lmdeploy-engine | 和编排同进程,ModelSingleton 直接持有 |
| 进程内 + 远程服务 | http-client | 另起的 vLLM/LMDeploy 服务,本地只当客户端 |
| 独立服务进程 | 由 openai_server 拉起 | 常驻服务,多个客户端共享 |
5. 巧妙之处(可借鉴的技术)
-
一个模型顶一条流水线。 vlm 后端用一次
batch_two_step_extract(vlm_analyze.py:479)覆盖了 pipeline 后端整条「布局→OCR→公式→表格」的多模型接力——把系统复杂度从「拼接与调度多个模型」压到「装配一个模型 + 整理其输出」。这是本章的核心 takeaway。 -
归一化坐标解耦分辨率。 VLM 吐 0~1 的相对 bbox,像素换算延后到
MagicModel(vlm_magic_model.py:40)。同一份块列表适配任意渲染 DPI,模型输出与渲染分辨率互不绑定。 -
流式并入 + 全局 finalize 分离。 逐页
append(model_output_to_middle_json.py:83)让内存与进度随处理推进,而跨页的段落/表格/标题收尾(finalize_middle_json,:106)单独放最后——把「能局部做的」和「必须全局做的」干净切开。 -
多候选关停路径。
_shutdown_runtime_handle(vlm_analyze.py:325)不硬编码某个引擎的关停 API,而是逐个试一串候选方法并静默容错,让 MinerU 免于跟随 vllm/lmdeploy 版本改动而频繁维护。 -
按需串行化。 MLX 挂锁、其余零开销
yield(predictor_execution_guard,vlm_analyze.py:391)——只对确有并发问题的后端付出串行化代价。
6. 边界与局限(诚实)
-
两步识别的「两步」在库外。
batch_two_step_extract的真正实现在第三方包mineru_vl_utils.MinerUClient里(本仓只 import,vlm_analyze.py:39),本仓源码里看不到第一步/第二步 prompt 的具体形态;本章对 two-step 内部机理的描述基于方法名与调用契约(inferred),非本仓代码直接所述。 -
窗口大小是内存/吞吐权衡,不是精度旋钮。 分窗只影响一次装多少页进内存与批推理粒度(
vlm_analyze.py:446),不改变每页的识别结果。 -
runtime 依赖各自的重型包。 每个 backend 分支都在用到时才 import(
transformers/vllm/lmdeploy/mlx),缺包即抛ImportError(如vlm_analyze.py:121)。mlx-engine硬性要求 macOS 13.5+ Apple Silicon(vlm_analyze.py:109)。 -
http-client把可用性外包给远程服务。 本地只是客户端,服务不可达/超时的处理依赖MinerUClient的max_retries/http_timeout(vlm_analyze.py:232-235),本仓不含服务端健康逻辑。
7. 横向对比(同组兄弟章)
| 后端 | 模型数 | 核心机制 | 章节 |
|---|---|---|---|
| pipeline | 多个专用模型接力 | 布局→OCR→公式→表格 流水线 | 02-pipeline-backend.md |
| vlm | 一个 VLM | two-step extract + 分窗 + 流式并入 | 本章 |
| hybrid | pipeline + VLM 混合 | 按块类型/难度分派,effort 调节 | 05-hybrid-and-office.md |
三条后端殊途同归:都产出同一个 middle_json(本章 init_middle_json 打 _backend: "vlm" 标记,model_output_to_middle_json.py:80),交由统一渲染层出 Markdown/JSON —— 见 04-middle-json-and-markdown.md。值得注意:hybrid 后端复用了本章同名的 batch_two_step_extract 与 image_analysis(hybrid_analyze.py:1008),可视为 vlm 路径的一个变体。整体入口与后端选择见 01-orchestration.md。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| VLM 后端主编排(同步/异步) | mineru/backend/vlm/vlm_analyze.py | doc_analyze / aio_doc_analyze |
| 模型单例 + 多 runtime 装配 | mineru/backend/vlm/vlm_analyze.py | ModelSingleton.get_model |
| 两步识别调用点 | mineru/backend/vlm/vlm_analyze.py | batch_two_step_extract / aio_batch_two_step_extract |
| 约束解码器(vllm) | mineru/backend/vlm/vlm_analyze.py | MinerULogitsProcessor(import)/ enable_custom_logits_processors |
| 串行化守卫 | mineru/backend/vlm/vlm_analyze.py | predictor_execution_guard / _maybe_enable_serial_execution |
| 关停与生命周期 | mineru/backend/vlm/vlm_analyze.py | shutdown_cached_models / _shutdown_runtime_handle |
| 窗口大小配置 | mineru/utils/config_reader.py | get_processing_window_size |
| 空 middle_json | mineru/backend/vlm/model_output_to_middle_json.py | init_middle_json |
| 流式并入逐页块 | mineru/backend/vlm/model_output_to_middle_json.py | append_page_blocks_to_middle_json / blocks_to_page_info |
| 全文级 finalize | mineru/backend/vlm/model_output_to_middle_json.py | finalize_middle_json |
| 一页块的分类解析 | mineru/backend/vlm/vlm_magic_model.py | MagicModel |
| 统一入口拉起服务 | mineru/cli/vlm_server.py | openai_server |
| vLLM 服务启动器 | mineru/model/vlm/vllm_server.py | main |
| LMDeploy 服务启动器 | mineru/model/vlm/lmdeploy_server.py | main |
| 服务端模型预加载 | mineru/cli/vlm_preload.py | preload_vlm_model |
| 上层调用点 | mineru/cli/common.py | aio_vlm_doc_analyze(_process_vlm 路径) |