跳到主要内容

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_analyzeVLM 后端主编排:装配、分窗、归并vlm_analyze.py:423 / :523
ModelSingleton.get_model按 backend 装配具体 runtime,缓存并包成 MinerUClientvlm_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_jsonmodel_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」):

  1. 第一步:只找版面块。 让模型先扫一遍整页,输出「这页有哪些块、每块什么类型、bbox(bounding box,边界框坐标)在哪」——先只画框、贴标签,不急着认全部内容。
  2. 第二步:逐块识别内容。 拿着第一步的每个框,再让模型针对性地识别这个框里的内容(正文文字、表格 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)透传到这里,默认 Truedoc_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:158get_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_count
configured_window_size配置的每窗页数(默认 64)MINERU_PROCESSING_WINDOW_SIZE
effective_window_sizemin(总页数, 配置值)vlm_analyze.py:447
total_windows向上取整的窗口总数vlm_analyze.py:448

3.3 ModelSingleton:一个客户端,接六种 runtime

它要解决的小问题: 「运行这个 VLM」在不同硬件/部署下差别巨大:本机用 transformers、追吞吐用 vllm、Mac 上用 mlx、或者干脆连一个远程推理服务。上层编排不想关心这些差异。

思路/直觉: 用一个单例 + 缓存ModelSingletonvlm_analyze.py:43)把「装配某种 runtime」这件脏活收拢,最后统一包成一个 MinerUClient(predictor)。上层只认 predictor.batch_two_step_extract,不管底下是哪种引擎。

缓存键: (backend, model_path, server_url)vlm_analyze.py:61)。同样三元组只装配一次,_lockRLock)保证多线程下装配串行、不重复加载大模型。

六种 runtime 分支,各装配什么:

backend装配了什么关键代码
transformersQwen2VLForConditionalGeneration + AutoProcessor,本机推理;按显存定 batch_sizevlm_analyze.py:82
vllm-engine同步 vllm.LLM(**kwargs),高吞吐vlm_analyze.py:118
vllm-async-engine异步 AsyncLLM.from_engine_args(...)vlm_analyze.py:143
lmdeploy-engineVLAsyncEngine,可选 pytorch/turbomind 后端、多种设备vlm_analyze.py:175
mlx-engineload_mlx_model,仅 Apple Silicon + macOS 13.5+vlm_analyze.py:108
http-client不装配本地模型,只连远程 server_url(无 model_path 分支,见 vlm_analyze.py:80MinerUClientserver_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:80if backend not in ["http-client"])。


3.4 生命周期:串行化守卫与优雅关停

VLM runtime 是重资源(占显存/内存),需要两类保护。

① 串行化守卫(针对 MLX): MLX 后端不是线程安全的并发推理,于是给它的 predictor 挂一把锁。_maybe_enable_serial_executionvlm_analyze.py:379)检测到 MLX 就设 _mineru_execution_lock;推理时用上下文管理器 predictor_execution_guardvlm_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_modelsvlm_analyze.py:365)调 ModelSingleton().shutdown(),遍历所有缓存的 predictor,尝试各种 runtime 的关停方法(shutdown/close/engine.shutdown/llm_engine.model_executor.shutdown …,一串候选路径见 _shutdown_runtime_handlevlm_analyze.py:325),再清引用、gc.collect()。它通过 atexit.registervlm_analyze.py:369)在进程退出时自动触发。

巧妙处: 关停用「多候选方法路径逐个试」而非硬编码某个 API(_call_nested_shutdownvlm_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:498page_start_index=window_start),保证跨窗页号连续。这就是「流式」:不等全文推理完,每窗一到就并入并更新进度条。

单页的转换 blocks_to_page_info:23)交给 MagicModelvlm_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_generationvlm_analyze.py:433)。若为真,则跳过 finalize(vlm_analyze.py:513),把这步留给客户端做——服务端只吐原始块流,减轻服务端负担。异步路径把 finalize 丢进线程执行(asyncio.to_threadvlm_analyze.py:613),避免阻塞事件循环。


4. 自托管推理服务(把模型跑成独立服务)

§3.3 的 http-client backend 需要一个远程推理服务。MinerU 自带两个「启动器」把 vLLM / LMDeploy 拉起成 OpenAI 兼容的 HTTP 服务,供 http-client 连。

统一入口 openai_servercli/vlm_server.py:28): 一个 click 命令,--engine auto|vllm|lmdeployauto 时先试 import vllm,失败再退回 lmdeploy,都没有就报错退出(cli/vlm_server.py:30-44)。它把命令行多余参数透传给底层启动器。

vLLM 启动器 model/vlm/vllm_server.py:12 本质是给官方 vllm serve 补默认参数再转发:

  • 没给 --port 就补 30000:45);
  • 没给 --gpu-memory-utilization 就按显存算一个默认值(:47set_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_backendutils.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_extractvlm_analyze.py:479)覆盖了 pipeline 后端整条「布局→OCR→公式→表格」的多模型接力——把系统复杂度从「拼接与调度多个模型」压到「装配一个模型 + 整理其输出」。这是本章的核心 takeaway。

  • 归一化坐标解耦分辨率。 VLM 吐 0~1 的相对 bbox,像素换算延后到 MagicModelvlm_magic_model.py:40)。同一份块列表适配任意渲染 DPI,模型输出与渲染分辨率互不绑定。

  • 流式并入 + 全局 finalize 分离。 逐页 appendmodel_output_to_middle_json.py:83)让内存与进度随处理推进,而跨页的段落/表格/标题收尾(finalize_middle_json:106)单独放最后——把「能局部做的」和「必须全局做的」干净切开。

  • 多候选关停路径。 _shutdown_runtime_handlevlm_analyze.py:325)不硬编码某个引擎的关停 API,而是逐个试一串候选方法并静默容错,让 MinerU 免于跟随 vllm/lmdeploy 版本改动而频繁维护。

  • 按需串行化。 MLX 挂锁、其余零开销 yieldpredictor_execution_guardvlm_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 把可用性外包给远程服务。 本地只是客户端,服务不可达/超时的处理依赖 MinerUClientmax_retries/http_timeoutvlm_analyze.py:232-235),本仓不含服务端健康逻辑。


7. 横向对比(同组兄弟章)

后端模型数核心机制章节
pipeline多个专用模型接力布局→OCR→公式→表格 流水线02-pipeline-backend.md
vlm一个 VLMtwo-step extract + 分窗 + 流式并入本章
hybridpipeline + 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_extractimage_analysishybrid_analyze.py:1008),可视为 vlm 路径的一个变体。整体入口与后端选择见 01-orchestration.md


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

主题文件路径符号名
VLM 后端主编排(同步/异步)mineru/backend/vlm/vlm_analyze.pydoc_analyze / aio_doc_analyze
模型单例 + 多 runtime 装配mineru/backend/vlm/vlm_analyze.pyModelSingleton.get_model
两步识别调用点mineru/backend/vlm/vlm_analyze.pybatch_two_step_extract / aio_batch_two_step_extract
约束解码器(vllm)mineru/backend/vlm/vlm_analyze.pyMinerULogitsProcessor(import)/ enable_custom_logits_processors
串行化守卫mineru/backend/vlm/vlm_analyze.pypredictor_execution_guard / _maybe_enable_serial_execution
关停与生命周期mineru/backend/vlm/vlm_analyze.pyshutdown_cached_models / _shutdown_runtime_handle
窗口大小配置mineru/utils/config_reader.pyget_processing_window_size
空 middle_jsonmineru/backend/vlm/model_output_to_middle_json.pyinit_middle_json
流式并入逐页块mineru/backend/vlm/model_output_to_middle_json.pyappend_page_blocks_to_middle_json / blocks_to_page_info
全文级 finalizemineru/backend/vlm/model_output_to_middle_json.pyfinalize_middle_json
一页块的分类解析mineru/backend/vlm/vlm_magic_model.pyMagicModel
统一入口拉起服务mineru/cli/vlm_server.pyopenai_server
vLLM 服务启动器mineru/model/vlm/vllm_server.pymain
LMDeploy 服务启动器mineru/model/vlm/lmdeploy_server.pymain
服务端模型预加载mineru/cli/vlm_preload.pypreload_vlm_model
上层调用点mineru/cli/common.pyaio_vlm_doc_analyze_process_vlm 路径)