1. 为什么我要在本地跑 SmolDocling 做文档转换
如果你手头有一堆 PDF、扫描件、技术报告,想把它们变成结构化数据,传统做法通常是拼一条流水线:OCR 模型负责认字,布局分析模型负责找表格和标题,表格识别模型再单独处理单元格合并,最后写一堆后处理逻辑把结果拼起来。这条链路能跑,但调起来很痛苦,任何一个环节出错都会往后累积,而且每个模型都要单独部署,显存和依赖加起来并不轻。
SmolDocling 吸引我的地方在于它把这件事收敛成了一个端到端模型。它是一个超紧凑的视觉语言模型,参数量只有 256M,基于 SmolVLM-256M 改造,输入一张文档页面图像,直接输出一种叫 DocTags 的结构化标记序列。DocTags 里同时编码了元素类型、页面位置和内容,表格用 OTSL 标签描述结构,公式保留 LaTeX,代码保留缩进和语言分类。换句话说,一次推理就拿到了内容、结构和布局,不需要再串多个模型。
它适合谁?我觉得三类人值得试:一是做文档解析、知识库入库的工程师,想找一个能在本地或单卡上跑的轻量方案;二是做 RAG 的开发者,需要把 PDF 转成带结构的 Markdown 或 JSON;三是想研究超紧凑视觉语言模型实际效果的人,256M 这个量级在消费级显卡甚至 CPU 上都能跑,试错成本低。
这篇我会按真实落地流程走一遍:先把模型和 ONNX 权重准备好,再给出可复制的推理配置,然后跑一张样例文档看 DocTags 输出,最后把常见的报错和排查动作列清楚。全程以本地文档解析为目标,不涉及任何网络访问工具。
2. SmolDocling 前置准备:模型权重、ONNX 会话与 DocTags 依赖
在写推理代码之前,得先把运行环境和模型文件理清楚。SmolDocling 的官方权重在 Hugging Face 上叫ds4sd/SmolDocling-256M-preview,它提供了两种用法:一种是直接用 transformers 加载 PyTorch 权重,另一种是用 ONNX Runtime 加载导出的三个 ONNX 文件。ONNX 路线在 CPU 和 GPU 上都能跑,显存占用小,我实测下来更适合本地部署,所以这篇以 ONNX 为主。
先装依赖。核心是onnxruntime(CPU)或onnxruntime-gpu(CUDA),加上transformers用来加载 processor 和 config,docling_core用来把 DocTags 转成 DoclingDocument,再导出 Markdown 或 JSON。
pip install torch pip install --upgrade transformers pip install --upgrade docling_core pip install onnxruntime # CPU 版本 # 或者,有 NVIDIA 显卡时用: pip install onnxruntime-gpu然后是三个 ONNX 权重文件,它们分别对应视觉编码器、token 嵌入和合并后的解码器:
wget https://huggingface.co/ds4sd/SmolDocling-256M-preview/resolve/main/onnx/vision_encoder.onnx wget https://huggingface.co/ds4sd/SmolDocling-256M-preview/resolve/main/onnx/embed_tokens.onnx wget https://huggingface.co/ds4sd/SmolDocling-256M-preview/resolve/main/onnx/decoder_model_merged.onnx下载完把这三个文件放到同一个目录,比如/content/或你本地的models/smoldocling/。注意decoder_model_merged.onnx是合并了 KV cache 的版本,推理循环里要手动维护past_key_values,这也是后面代码里那段循环的由来。
processor 和 config 仍然从 Hugging Face 拉,因为 ONNX 只导出了计算图,分词和图像预处理逻辑还在 transformers 里:
from transformers import AutoConfig, AutoProcessor model_id = "ds4sd/SmolDocling-256M-preview" config = AutoConfig.from_pretrained(model_id) processor = AutoProcessor.from_pretrained(model_id)这里有个容易忽略的点:config.text_config里藏着推理循环需要的几个关键参数,比如num_key_value_heads、head_dim、num_hidden_layers、eos_token_id。另外image_token_id和<end_of_utterance>的 token id 也要提前取出来,前者用来把图像特征塞回嵌入序列,后者是生成结束的标志之一。
num_key_value_heads = config.text_config.num_key_value_heads head_dim = config.text_config.head_dim num_hidden_layers = config.text_config.num_hidden_layers eos_token_id = config.text_config.eos_token_id image_token_id = config.image_token_id end_of_utterance_id = processor.tokenizer.convert_tokens_to_ids("<end_of_utterance>")环境准备好之后,目录结构大概是这样:三个.onnx文件、一个测试图片目录、一个输出目录。测试图片建议先用一张清晰的单页文档,比如技术报告或带表格的 PDF 转成的 PNG,分辨率别太低,否则小字和表格线容易糊。
注意:ONNX 路线不需要 GPU 也能跑,只是慢一些。如果你用 CUDAExecutionProvider,记得确认 onnxruntime-gpu 的版本和本机 CUDA 匹配,否则会回退到 CPU 或者直接报 provider 找不到。
3. 可复制配置:SmolDocling 推理脚本与 DocTags 字段映射
这一节给出完整的推理脚本,分两个版本:一个导出 Markdown,一个导出 JSON。两者前半部分完全一样,区别只在最后怎么处理 DocTags。
先看公共部分。加载三个 ONNX 会话,CPU 和 GPU 只差 providers 参数:
import os import numpy as np import onnxruntime from transformers import AutoConfig, AutoProcessor from transformers.image_utils import load_image from docling_core.types.doc.document import DoclingDocument, DocTagsDocument os.environ["OMP_NUM_THREADS"] = "1" os.environ["ORT_CUDA_USE_MAX_WORKSPACE"] = "1" model_id = "ds4sd/SmolDocling-256M-preview" config = AutoConfig.from_pretrained(model_id) processor = AutoProcessor.from_pretrained(model_id) # CPU 版本 # vision_session = onnxruntime.InferenceSession("vision_encoder.onnx") # embed_session = onnxruntime.InferenceSession("embed_tokens.onnx") # decoder_session = onnxruntime.InferenceSession("decoder_model_merged.onnx") # CUDA 版本 vision_session = onnxruntime.InferenceSession( "/content/vision_encoder.onnx", providers=["CUDAExecutionProvider"]) embed_session = onnxruntime.InferenceSession( "/content/embed_tokens.onnx", providers=["CUDAExecutionProvider"]) decoder_session = onnxruntime.InferenceSession( "/content/decoder_model_merged.onnx", providers=["CUDAExecutionProvider"])然后是输入构造。SmolDocling 的提示词很固定,就是一句Convert this page to docling.,配合图像一起送进 processor:
messages = [ { "role": "user", "content": [ {"type": "image"}, {"type": "text", "text": "Convert this page to docling."} ] }, ] image = load_image(image_path) prompt = processor.apply_chat_template(messages, add_generation_prompt=True) inputs = processor(text=prompt, images=[image], return_tensors="np")接下来是自回归生成循环。这段是整个脚本的核心,逻辑是:先用 embed 会话把 input_ids 转成嵌入,如果是第一步就把视觉特征算出来并替换掉图像占位 token 的嵌入,然后送进 decoder 拿到 logits 和新的 KV cache,取最后一个 token 作为下一步输入,循环直到遇到 eos 或 end_of_utterance。
batch_size = inputs['input_ids'].shape[0] past_key_values = { f'past_key_values.{layer}.{kv}': np.zeros( [batch_size, num_key_value_heads, 0, head_dim], dtype=np.float32) for layer in range(num_hidden_layers) for kv in ('key', 'value') } image_features = None input_ids = inputs['input_ids'] attention_mask = inputs['attention_mask'] position_ids = np.cumsum(inputs['attention_mask'], axis=-1) max_new_tokens = 8192 generated_tokens = np.array([[]], dtype=np.int64) for i in range(max_new_tokens): inputs_embeds = embed_session.run(None, {'input_ids': input_ids})[0] if image_features is None: image_features = vision_session.run( ['image_features'], { 'pixel_values': inputs['pixel_values'], 'pixel_attention_mask': inputs['pixel_attention_mask'].astype(np.bool_) } )[0] inputs_embeds[inputs['input_ids'] == image_token_id] = \ image_features.reshape(-1, image_features.shape[-1]) logits, *present_key_values = decoder_session.run(None, dict( inputs_embeds=inputs_embeds, attention_mask=attention_mask, position_ids=position_ids, **past_key_values, )) input_ids = logits[:, -1].argmax(-1, keepdims=True) attention_mask = np.ones_like(input_ids) position_ids = position_ids[:, -1:] + 1 for j, key in enumerate(past_key_values): past_key_values[key] = present_key_values[j] generated_tokens = np.concatenate([generated_tokens, input_ids], axis=-1) if (input_ids == eos_token_id).all() or (input_ids == end_of_utterance_id).all(): break doctags = processor.batch_decode(generated_tokens, skip_special_tokens=False)[0].lstrip()拿到doctags字符串之后,用DocTagsDocument.from_doctags_and_image_pairs把它和原图配对,再load_from_doctags装进DoclingDocument。导出 Markdown 就一行:
doctags_doc = DocTagsDocument.from_doctags_and_image_pairs([doctags], [image]) doc = DoclingDocument(name="Document") doc.load_from_doctags(doctags_doc) result = str(doc.export_to_markdown())导出 JSON 则用doc.save_as_json(out_file),它会保留 DocTags 里的层级和位置信息。
DocTags 的字段映射值得单独说一下,因为后面验证结果时要对着看。文档块类型里,<text>是普通文本,<title>和<section_header>是标题,<list_item>配合<ordered_list>或<unordered_list>表示列表,<code>带<_programming-language_>分类,<formula>里是 LaTeX,<otsl>是表格结构。位置标签<loc_x1><loc_y1><loc_x2><loc_y2>给出边界框。表格内部用<fcel>表示有内容单元格、<ecel>空单元格、<ched>列标题、<rhed>行标题、<srow>表格行。图片用<picture>加<image_class>,比如pie_chart、bar_chart、natural_image。
如果你要把 DocTags 转成别的格式,映射关系大致是:<formula>转 LaTeX,<otsl>转 HTML 表格,<text>、<list_item>、<title>转 Markdown,化学分子结构可以转 SMILES。docling_core已经帮你做了大部分转换,所以直接用export_to_markdown或save_as_json就行。
4. 验证请求:用样例文档对比 SmolDocling 转换结果
配置写好了,得跑一张真实文档看效果。我用的是一张带标题、段落、一个表格和一段代码的技术文档截图,分辨率 1600 左右。把图片路径填进main函数,运行后会在当前目录生成同名.txt或.json。
先看 Markdown 输出。理想情况下,标题会变成#或##,段落是普通文本,表格会渲染成 Markdown 表格,代码块保留缩进。我实测下来,标题和段落的还原度不错,表格结构基本能对上,代码缩进也保住了。但有几个地方要留意:如果表格有合并单元格,OTSL 的<ched>和<rhed>能表达,但转成 Markdown 时可能丢一部分语义,这时候 JSON 输出更完整。
再看 JSON 输出。save_as_json出来的结构里,每个元素带label、text和prov(位置信息)。你可以用下面这段快速检查关键字段:
import json with open("test.json", "r", encoding="utf-8") as f: data = json.load(f) for item in data.get("texts", [])[:5]: print(item.get("label"), item.get("text", "")[:60])如果label里出现section_header、table、code这些,说明 DocTags 的类型识别生效了。位置信息在prov里,能看到bbox和page_no,这对做版面还原很有用。
验证的时候我建议做三组对比:第一组是纯文本页,看 OCR 准确率和换行;第二组是带表格的页,看 OTSL 是否正确区分了表头和数据行;第三组是带公式或代码的页,看 LaTeX 和缩进有没有丢。每组都同时导出 Markdown 和 JSON,Markdown 看可读性,JSON 看结构完整性。
有个细节:生成循环里max_new_tokens设的是 8192,长文档可能不够,如果发现输出被截断,可以调大这个值,但要注意显存和耗时。另外skip_special_tokens=False是为了保留 DocTags 标签,如果你只想看纯文本,可以改成True,但那样就丢了结构信息。
提示:第一次跑建议用 CPU 版本确认流程通不通,再切 GPU 提速。ONNX 的 CUDA provider 在首次加载时会有一定初始化开销,之后单页推理会快很多。
5. 本篇常见错排查:401、local proxy failed 与 reading choices 报错
跑 SmolDocling 的过程中,我踩过几个典型的坑,这里按报错现象列出来,方便你对照排查。
第一个是401 Unauthorized或Cannot access gated repo。这通常发生在从 Hugging Face 拉ds4sd/SmolDocling-256M-preview的 config 或 processor 时。虽然这个模型本身是公开的,但如果你本地配置了需要鉴权的镜像或代理,就可能返回 401。排查动作:先确认AutoConfig.from_pretrained和AutoProcessor.from_pretrained能单独跑通,如果报 401,检查环境变量里有没有残留的HF_ENDPOINT或 token 配置,清掉再试。ONNX 文件是直接 wget 的,不走鉴权,所以如果只有 config 报错,问题就在 transformers 这一侧。
第二个是local proxy failed或Connection error。这个报错一般出现在下载 ONNX 权重或拉 processor 的时候。如果你在受限网络环境里,wget 和 transformers 的请求可能都出不去。排查动作:先确认三个.onnx文件是否已经完整下载到本地,如果已经下载好,就把代码里的路径改成绝对路径,避免运行时再去联网。processor 和 config 如果拉不下来,可以提前在能联网的机器上缓存好,再把缓存目录拷过来,用cache_dir参数指定。
第三个是reading choices相关的报错,比如KeyError: 'choices'或IndexError: list index out of range。这个多半不是模型本身的问题,而是你在用某个 API 封装层调用时,返回结构和你预期的不一样。SmolDocling 的 ONNX 推理是本地计算,不涉及 API 返回,所以如果你看到choices字样,说明代码里混进了别的调用逻辑。排查动作:检查你的脚本里有没有多余的 HTTP 请求或 SDK 调用,把推理路径收敛到本文给的 ONNX 循环上。
第四个是OAuth或token invalid。如果你在别的地方配置过需要 OAuth 的服务,环境变量可能被污染。排查动作:在脚本开头打印os.environ里和 token、auth 相关的键,确认没有意外注入的值。
第五个是生成结果为空或只有几个 token。这通常是image_token_id没对上,导致图像特征没塞进嵌入序列。排查动作:打印image_token_id和inputs['input_ids'],确认图像占位 token 确实出现在序列里,并且inputs_embeds[inputs['input_ids'] == image_token_id]这行的替换形状能对上。
第六个是 CUDA provider 报NotFound。这说明 onnxruntime-gpu 没装好或 CUDA 版本不匹配。排查动作:先pip show onnxruntime-gpu看版本,再确认onnxruntime.get_available_providers()里有没有CUDAExecutionProvider,没有就回退到 CPU 版本先跑通。
把这几类报错对照一遍,基本能覆盖本地部署 SmolDocling 时八成以上的问题。剩下的多半是图片质量或max_new_tokens设置导致的输出异常,调这两个参数通常能解决。
6. 从本地推理到稳定调用:SmolDocling 的接入与验证路径
本地把 SmolDocling 跑通之后,下一步通常是把它接进你的文档处理流程。如果你只是偶尔转几页,直接跑脚本就够了;但如果要做批量入库或在线服务,就需要考虑调用方式和密钥管理。
一个务实的做法是:本地 ONNX 推理负责重活,把 DocTags 或 Markdown 结果缓存下来;需要调用云端模型做补充识别或对比时,再走 API。TaoToken 在这里可以作为一个统一的模型调用入口,它的 API 地址是 https://taotoken.net/api,你可以在控制台里创建密钥,然后按文档接入。模型对话入口适合做单页验证和效果对比,Coding Plan 适合把文档转换接进长期的编码或 Agent 流程,API Keys 页面用来管理你的调用凭证。
具体操作上,先在 API Keys 页面生成一个 key,然后参考接入文档把 Base URL 配成https://taotoken.net/api,Model ID 按你实际要调的模型填。如果你用的是 Claude Code 这类工具做文档润色或结构化后处理,可以在配置里把 Base URL、Key 和 Model ID 三件套写全,避免只填一半导致鉴权失败。验证的时候,先用模型对话入口发一张测试图或一段 DocTags 文本,确认返回正常,再切到批量流程。
我自己的习惯是:SmolDocling 本地跑出 DocTags 之后,把结构化的 JSON 存下来,需要进一步理解或摘要时再走 API。这样既保留了本地推理的低成本和隐私优势,又能在需要更强语义能力时补上云端模型。整个链路里,本地模型负责“看清结构和内容”,云端模型负责“理解和再加工”,分工明确,排查也容易。
如果你要长期跑文档转换任务,建议把 ONNX 会话做成常驻进程,避免每次请求都重新加载模型。三个 ONNX 文件加载一次大概几百 MB 内存,常驻之后单页推理的延迟会稳定很多。批量处理时按页拆分,每页独立生成 DocTags,再统一转成 Markdown 或 JSON 入库,这样单页失败不会影响整批。