Skip to content

vlm-engine 后端版面识别输出乱码缺陷问题说明 #5267

Description

@star20245

🔎 Search before asking | 提交之前请先搜索

  • I have searched the MinerU Readme and found no similar bug report.
  • I have searched the MinerU Issues and found no similar bug report.
  • I have searched the MinerU Discussions and found no similar bug report.

🤖 Consult the online AI assistant for assistance | 在线 AI 助手咨询

Description of the bug | 错误描述

vlm-engine 后端版面识别输出乱码缺陷报告

发现日期: 2026-07-14
影响范围: vlm-engine / hybrid-engine 后端的 VLM 版面识别全流程
严重程度: P0 - 致命(该后端下所有任务 100% 失效)
根因层级: mineru_vl_utilsvllm 0.21.0 的多模态预处理机制不兼容


一、问题现象

使用 vlm-engine 后端(worker 进程内 vllm.LLM 直接推理)解析任意 PDF/图片时,
版面识别第一步(Layout Detection)输出残缺的特殊 token 片段,无法解析出任何 block,
任务产出空结果。同一文件用 vlm-http-client 后端(手动启动 mineru-openai-server
则完全正常。

1.1 对比日志

情况一:vlm-http-client(正常)

手动启动 vllm server:

CUDA_VISIBLE_DEVICES=0 mineru-openai-server \
  --engine vllm --port 30000 --host 0.0.0.0 --gpu-memory-utilization 0.8

worker 走 HTTP 调用,layout 输出规范:

2026-07-14 06:02:29.385 | DEBUG | Layout raw output: <|box_start|>056 192 072 221<|box_end|><|ref_start|>page_number<|ref_end|><|rotate_right|> <|box_start|>081 149 918 409<|box_end|><|ref_start|>table<|ref_end|><|rotate_up|>

情况二:vlm-engine(异常)

worker 进程内 vllm.LLM(**kwargs) 自动推理,layout 输出乱码:

2026-07-14 03:30:31.737 | DEBUG | Layout raw output: ]]<|><|><|><|>
2026-07-14 03:30:31.739 | WARNING | Layout output does not match expected format: ]]<|><|><|><|>
2026-07-14 03:30:31.741 | DEBUG | Layout raw output: ]]<|><|><|><|>
2026-07-14 03:30:31.743 | WARNING | Layout output does not match expected format: ]]<|><|><|><|>

1.2 关键特征

特征 观察
输出内容 `]]<
finish_reason stop(模型"正常"停了,不抛异常)
推理速度 3.922 page/s,远快于正常的 0.426 page/s(只生成几个 token 就停)
block 数量 Page 0: 0 blocks(无任何识别结果)
影响范围 第一步 Layout Detection 即乱码,后续 content extract 不执行
触发条件 VllmEngineVlmClient 路径即复现,与文件内容无关(合成图也复现)

二、复现步骤

2.1 环境前提

  • 容器:mineru-worker 镜像(Linux, Python 3.12)
  • vllm:0.21.0
  • mineru_vl_utils:随 mineru 安装的版本
  • GPU:compute_capability 8.6
  • 模型:OpenDataLab/MinerU2.5-Pro-2605-1.2B(Qwen2VL 架构)

2.2 最小复现脚本

以下脚本在 worker 容器内执行,用一张 PIL 合成的矩形图即可 100% 复现,
无需真实 PDF:

# /app/repro.py
import os
os.environ.setdefault("OMP_NUM_THREADS", "1")

def main():
    from mineru.utils.models_download_utils import auto_download_and_get_model_root_path
    model_path = auto_download_and_get_model_root_path("/", "vlm")
    import vllm
    from mineru_vl_utils.vlm_client.vllm_engine_client import VllmEngineVlmClient
    from mineru_vl_utils.vlm_client.base_client import SamplingParams
    from PIL import Image, ImageDraw

    vllm_llm = vllm.LLM(
        model=model_path,
        gpu_memory_utilization=0.5,
        limit_mm_per_prompt={"image": 1},
    )
    client = VllmEngineVlmClient(vllm_llm=vllm_llm)

    img = Image.new("RGB", (1036, 1036), (255, 255, 255))
    draw = ImageDraw.Draw(img)
    draw.rectangle([50, 50, 900, 900], outline=(0, 0, 0), width=2)
    draw.text((100, 100), "Cell A1", fill=(0, 0, 0))

    sp = SamplingParams(temperature=0, top_p=0.01, top_k=1)
    out = client.batch_predict(
        images=[img],
        prompts=["\nLayout Detection:"],
        sampling_params=[sp],
    )
    print("输出:", repr(out[0][:120]))

if __name__ == "__main__":
    main()

运行:

python3 /app/repro.py

预期(异常)输出

输出: ']]<|><|><|><|>'

2.3 对照实验(正常路径)

把上面脚本的 client.batch_predict(...) 换成直接 vllm_llm.generate(...)

tokenizer = vllm_llm.get_tokenizer()
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": [
        {"type": "image"},
        {"type": "text", "text": "\nLayout Detection:"},
    ]},
]
chat_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
vsp = vllm.SamplingParams(temperature=0, top_p=0.01, top_k=1, max_tokens=512, skip_special_tokens=False)
out = vllm_llm.generate(
    prompts=[{"prompt": chat_prompt, "multi_modal_data": {"image": [img]}}],
    sampling_params=vsp,
    use_tqdm=False,
)
print("输出:", repr(out[0].outputs[0].text[:120]))

正常输出

输出: '<|box_start|>094 094 131 110<|box_end|><|ref_start|>text<|ref_end|><|rotate_up|>'

核心差异:两条路径用相同的模型、相同的图、相同的 prompt、相同的采样参数,
唯一区别是调用方式——VllmEngineVlmClient.batch_predict vs 直接 vllm_llm.generate


三、根因分析

3.1 调用路径差异

直接 vllm_llm.generate(正常路径)

vllm_llm.generate(
    prompts=[{"prompt": chat_prompt, "multi_modal_data": {"image": [img]}}],
    ...
)

vllm 0.21.0 的 LLM.generate 内部一次性完成:

  1. renderer 预处理(计算 mm_hashesmm_placeholdersmm_kwargs
  2. mm_receiver_cache 按hash缓存多模态特征
  3. EngineCore 取 cache 中的特征 + 推理

三步在同一次调用内完成,cache 状态一致。

VllmEngineVlmClient._predict_one_batch(异常路径)

def _predict_one_batch(self, image_lists, chat_prompts, vllm_sampling_params):
    raw_prompts = [
        {"prompt": chat_prompt, **({"multi_modal_data": {"image": image}} if image else {})}
        for chat_prompt, image in zip(chat_prompts, image_lists)
    ]
    vllm_prompts = self._render_vllm_cmpl_inputs(raw_prompts)  # ← 关键:客户端预渲染
    outputs = self.vllm_llm.generate(prompts=vllm_prompts, ...)
    return [self.get_output_content(output) for output in outputs]

其中 _render_vllm_cmpl_inputs 的实现:

def _render_vllm_cmpl_inputs(self, raw_prompts):
    """优先使用新版 vLLM Renderer 预处理 prompt,旧版无 renderer 时保持原输入。"""
    renderer = getattr(self.vllm_llm, "renderer", None)
    render_cmpl = getattr(renderer, "render_cmpl", None) if renderer is not None else None
    if callable(render_cmpl):
        return render_cmpl(raw_prompts)  # vllm 0.21.0 走这条
    return raw_prompts

vllm 0.21.0 的 LLM.renderer.render_cmpl 把原始 dict 预渲染成 TokensPrompt

原始: {"prompt": str, "multi_modal_data": {"image": [PIL]}}
预渲染后: {
    "type": "...",
    "prompt_token_ids": [...],
    "mm_kwargs": <MultiModalKwargsItems>,
    "mm_hashes": [...],
    "mm_placeholders": {"image": [PlaceholderRange(offset=15, length=1369, ...)]},
    "prompt": str,
    "arrival_time": ...
}

3.2 cache 脱节机制

vllm 0.21.0 引入了 mm_receiver_cachevllm/multimodal/cache.py),要求:

预渲染产生的 mm_hash 对应的多模态特征必须缓存在 EngineCore 内
generate 内部用同一个 mm_hash 取回特征。

_render_vllm_cmpl_inputs客户端进程预渲染后,把 TokensPrompt(含 mm_hashes
传给 LLM.generategenerate 内部又会再次预处理。两次预处理的 cache 状态脱节:

  1. _render_vllm_cmpl_inputs 预渲染 → 生成 mm_hash='c5f00baa...',但特征没进 EngineCore cache
  2. LLM.generate 收到 TokensPrompt(已有 mm_hashes)→ 跳过自己的预渲染
  3. EngineCore 用 mm_hashmm_receiver_cache.get_and_update_features 取特征
  4. cache 里找不到 → mm_features = None
  5. 模型收到的图片特征是空的,只能吐出 ]]<|><|><|><|> 这种残缺片段后 stop

3.3 直接证据

_predict_one_batch 内部调用的 vllm_llm.generate 做 spy,确认它收到的是
预渲染后的 TokensPrompt(含 mm_placeholders),而非原始 dict:

[SPY generate] p keys: ['type', 'prompt_token_ids', 'mm_kwargs', 'mm_hashes',
                        'mm_placeholders', 'prompt', 'arrival_time']
[SPY generate] mm_placeholders: {'image': [PlaceholderRange(offset=15, length=1369, is_embed=None)]}
[SPY generate] out[0] text: ']]<|><|><|><|>'

对照实验中,手动绕过 _render_vllm_cmpl_inputs,把原始 dict 直接传给 generate
vllm 内部一次性预处理,输出正常:

=== 修复方案: patch _predict_one_batch 跳过 _render ===
  text: '<|box_start|>094 094 131 110<|box_end|><|ref_start|>text<|ref_end|><|rotate_up|>'

3.4 为什么 http-client 正常

HttpVlmClienthttp_client.py)走 HTTP /v1/chat/completions,vllm server 端
自己做完整的多模态预处理(renderer + cache 在同一次调用内完成),不存在
客户端预渲染 + 服务端再处理的 cache 脱节问题。

3.5 诊断过程中的排除项

以下假设在诊断中被逐一排除,记录于此避免重复排查:

假设 排除依据
worker CLI 的 --backend 参数未生效 worker 用 message 的 backend 字段(processor.py:34),CLI 参数只进启动日志
enable_custom_logits_processors() 返回 False 容器内实测返回 True,logits processor 已注册
MinerULogitsProcessor 的 ban-token 逻辑有 bug no_repeat_ngram_size=0 禁用后仍乱码(diag_vlm9
采样参数 top_p/top_k/temp/max_tokens 差异 vllm 0.21.0 在 temperature=0 时重置 top_p=1.0, top_k=0,但用相同参数直接 generate 正常(diag_vlm19
sampling_params 传 list vs 单个对象 4 种组合都正常(diag_vlm18
image_to_obj_list 转换了图片 实测 is img: True,同一对象(diag_vlm22
真实 PDF 图片触发的特定问题 合成 PIL 矩形图也复现(diag_vlm8
VllmEngineVlmClient.__init__ 污染全局状态 创建 client 前后直接 generate 都正常(diag_vlm17
表格图片复杂度触发 简单矩形图、复杂表格图都复现,与内容无关

四、修复方案

方案1(推荐):Monkey-patch 跳过 _render_vllm_cmpl_inputs

原理:patch VllmEngineVlmClient._predict_one_batch,跳过 _render_vllm_cmpl_inputs
预渲染步骤,把原始 {"prompt": str, "multi_modal_data": {"image": [PIL]}} 直接交给
LLM.generate(),让 vllm 0.21.0 自己在内部一次性完成 renderer + cache + 推理。

优点

  • 不改 mineru_vl_utils 源码,风险最低
  • 已通过合成图验证有效(diag_vlm27
  • 对旧版 vllm 无副作用(旧版 _render_vllm_cmpl_inputs 本就是 no-op,跳过等价)
  • 不影响 http-client 后端

实现

新建 mineru/utils/vllm_compat_patch.py

# Copyright (c) Opendatalab. All rights reserved.
"""vLLM 0.21.0 兼容补丁:绕过 VllmEngineVlmClient 的客户端预渲染。

背景见 docs/bugs/2026-07-14-vlm-engine-layout-garbled-output.md
"""
from loguru import logger

_patch_applied = False


def apply_vllm_engine_patch() -> bool:
    """Monkey-patch VllmEngineVlmClient._predict_one_batch。

    Returns:
        True  - 本次应用了 patch
        False - 已应用过 / 无法导入目标类 / 跳过
    """
    global _patch_applied
    if _patch_applied:
        return False

    try:
        from mineru_vl_utils.vlm_client.vllm_engine_client import VllmEngineVlmClient
    except ImportError:
        # vllm 未安装(CPU 环境)或 mineru_vl_utils 结构变化,静默跳过
        return False

    original = getattr(VllmEngineVlmClient, "_predict_one_batch", None)
    if original is None:
        return False
    # 已 patch 过(幂等保护)
    if getattr(original, "_mineru_skip_render_patch", False):
        _patch_applied = True
        return False

    def patched_predict_one_batch(self, image_lists, chat_prompts, vllm_sampling_params):
        # 与原始实现一致,唯一区别:不调 self._render_vllm_cmpl_inputs,
        # 把原始 {"prompt": str, "multi_modal_data": {"image": [PIL]}} 直接
        # 交给 LLM.generate(),由 vLLM 内部一次性完成 renderer + cache。
        vllm_prompts = [
            {"prompt": chat_prompt, **({"multi_modal_data": {"image": image}} if image else {})}
            for chat_prompt, image in zip(chat_prompts, image_lists)
        ]
        outputs = self.vllm_llm.generate(
            prompts=vllm_prompts,  # type: ignore
            sampling_params=vllm_sampling_params,
            use_tqdm=self.use_tqdm,
        )
        return [self.get_output_content(output) for output in outputs]

    patched_predict_one_batch._mineru_skip_render_patch = True
    VllmEngineVlmClient._predict_one_batch = patched_predict_one_batch
    _patch_applied = True
    logger.info(
        "Applied VllmEngineVlmClient._predict_one_batch patch: "
        "bypass _render_vllm_cmpl_inputs for vLLM mm_receiver_cache compatibility"
    )
    return True

mineru/backend/vlm/vlm_analyze.py 顶部,from mineru_vl_utils import MinerUClient
之后注入:

from mineru_vl_utils import MinerUClient
from packaging import version

# vLLM 0.21.0 兼容补丁:必须在 VllmEngineVlmClient 实例化前应用
from mineru.utils.vllm_compat_patch import apply_vllm_engine_patch
apply_vllm_engine_patch()

为什么注入在 vlm_analyze.py
所有 vlm-engine 路径(CLI do_parse、worker process_task、gradio)都会 import
vlm_analyze,patch 在此处应用可覆盖全部入口,且早于 VllmEngineVlmClient 实例化。

方案2(最干净):升级或降级 vllm

  • 降级到 vllm==0.10.x:没有 LLM.renderer.render_cmpl
    _render_vllm_cmpl_inputsreturn raw_prompts 分支(no-op),问题消失。
  • mineru_vl_utils 上游修复:让 _render_vllm_cmpl_inputs 对 vllm 0.21.0 的
    mm_receiver_cache 机制做兼容(预渲染时同步填充 cache,或直接不预渲染)。

方案3(绕过):继续用 vlm-http-client

手动启动 mineru-openai-server,worker 用 vlm-http-client 后端。已验证可用,
但失去了 worker 进程内推理的便利性。


五、验证清单

修复后需确认以下场景:

  • vlm-engine 后端解析合成 PIL 图,layout 输出 <|box_start|>...<|box_end|> 规范格式
  • vlm-engine 后端解析真实 PDF,layout 正常识别 block
  • vlm-engine 后端完整跑通 batch_two_step_extract(layout + content extract)
  • vlm-http-client 后端行为不受影响(patch 只动 VllmEngineVlmClient
  • hybrid-engine 后端正常(同样走 VllmEngineVlmClient
  • 多次 import vlm_analyze 不重复 patch(幂等)
  • 旧版 vllm(无 renderer.render_cmpl)下 patch 无副作用

六、诊断过程时间线

完整诊断历经 27 个实验脚本,关键转折点记录如下:

脚本 发现
diag_vlm.py LLM.generateLLM.chat 在合成图上都正常,排除"直接调用 vllm 坏了"
diag_vlm4 MinerULogitsProcessor 后直接 generate 仍正常,排除 logits processor
diag_vlm6 复杂表格图直接 generate 正常,排除"图片复杂度"
diag_vlm8 MinerUClient.batch_two_step_extract 合成图也乱码,锁定封装层
diag_vlm9 no_repeat_ngram_size=0 禁用 logits processor 仍乱码,排除 ban-token 逻辑
diag_vlm13 vllm 0.21.0 在 temperature=0 时重置 top_p=1.0, top_k=0(假线索)
diag_vlm14 temperature=0.01 保留 top_p/top_k 后仍乱码,排除采样参数重置
diag_vlm18/19 所有采样参数组合直接 generate 都正常,彻底排除采样参数
diag_vlm23 client.vllm_llm.generate 直接调用正常,client._predict_one_batch 乱码
diag_vlm24 spy 确认 _predict_one_batch 传给 generate 的是预渲染后的 TokensPrompt
diag_vlm26 源码确认 _predict_one_batch_render_vllm_cmpl_inputs;手动构造原始 dict 调 generatemm_hash cache miss
diag_vlm27 patch 跳过 _render_vllm_cmpl_inputs 后输出正常,确认修复方向

七、相关文件与代码位置

文件 位置 说明
mineru_vl_utils/vlm_client/vllm_engine_client.py _predict_one_batch _render_vllm_cmpl_inputs 预渲染,根因所在
mineru_vl_utils/vlm_client/vllm_engine_client.py _render_vllm_cmpl_inputs vllm 0.21.0 走 renderer.render_cmpl 预渲染
mineru_vl_utils/mineru_client.py MinerUClient.batch_two_step_extract 入口,走 batching_mode="stepping"_predict_one_batch
mineru/backend/vlm/vlm_analyze.py ModelSingleton.get_model 构造 MinerUClient(backend="vllm-engine")
mineru/cli/common.py:725-740 do_parse vlm 分支 vlm-engineget_vlm_engine("auto")_process_vlm
mineru/worker/processor.py:34 process_task 用 message 的 backend 字段,非 CLI 参数
vllm/multimodal/cache.py:660 get_and_update_item mm_receiver_cache 的 assert miss 触发点
mineru/utils/vllm_compat_patch.py 新增 方案1 的 patch 实现

How to reproduce the bug | 如何复现

1

Operating System Mode | 操作系统类型

No response

Operating System Version| 操作系统版本

1

Python version | Python 版本

No response

Software version | 软件版本 (mineru --version)

No response

Backend name | 解析后端

No response

Device mode | 设备模式

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions