每次拿到一堆PDF、Word或者扫描件,里面有大量表格数据需要录入系统,我都觉得是体力活。直到我把千问大模型接进来,让它把文档里的表格信息直接吐成JSON,整个流程才真正跑通。这篇文章就想把我在这个项目里的完整思路、踩过的坑、写好的Prompt和能直接用的代码分享出来。
先交代一下背景:我手里的文档来源很杂,有供应商发货单、质检报告、合同附件,格式五花八门。需求很明确——把表格内容提取出来,转成结构化JSON,方便入库和后续做统计分析。传统OCR方案我试过几套,对小表格还行,遇到合并单元格、多级表头、跨页表格,输出的结果基本没法直接用,还得人工校对半天。后面转向千问大模型来兜底,效果出乎意料地好,但也踩了不少坑。这篇主要写给正在做文档结构化、表格信息抽取的开发者,不管你是用Python还是Java,只要懂HTTP调用和基础JSON操作,都能跟着复现。
1. 方案整体设计与模型选型
1.1 表格识别的痛点:为什么OCR不够用
表格数据提取这件事,本质上不是“把字认出来”,而是“把表格的二维结构还原出来”。传统OCR擅长的是字符识别的层面:图片里有一行字,它把字给你认出来,但对于“这个单元格跨了几列”、“这个数字对应的是哪个月份”、“上级表头是怎么分组的”这类结构信息,常规OCR就抓瞎了。
举个例子,一张发货单里表格的表头可能是两级的,第一行是“2025年一季度”,下面拆成“一月份”、“二月份”、“三月份”,每一列下面再对应数量和金额。传统OCR输出往往是一堆文本块,你得自己写规则去对齐、去推断层级。而大模型天生能理解语义和结构,只要把表格内容以文本或图片形式喂进去,它能在理解表格含义的基础上直接输出结构化数据。
我用千问的实测感受是:它对“表格结构”的理解能力远超预期,尤其是带视觉能力的模型,能直接看图里的单元格边界,合并单元格它也能通过语义判断出来。不过模型能力再强,也不能直接把原始文档扔进去求输出,需要在前置和后置环节做好设计。
1.2 千问系列模型怎么选
千问系列模型并不是只有一款,选型直接决定了效果天花板。我这边按表格识别场景整理了三个可用的方向:
| 模型方向 | 输入类型 | 适用场景 | 我的推荐 |
|---|---|---|---|
| 纯文本模型 | 文本 | 文档能直接提取出文字内容,且表格结构简单 | 处理干净的电子表格类PDF |
| 视觉模型 | 文本+图片 | 扫描件、图片型PDF、复杂表格 | 手写体之外的绝大多数场景 |
| 长文本模型 | 文本 | 超长文档、多页表格,需要一次性读完 | 合同、报告类长文档 |
实际操作下来,我主力用的是视觉模型,因为很多文档的排版一旦转成文本再恢复表格结构,信息就已经丢了。视觉模型直接看图片,对单元格位置的感知更准确。如果文档是原生可复制文字的PDF,我会先尝试提取文本,再决定要不要走图片识别路线。
选模型时还有个性能权衡点:视觉模型处理单张图片的速度明显比纯文本慢,成本也更高。所以我的策略是“能转文本的先转文本,文本提取出来的表格结构乱了,再升级到视觉模型”。这种方式既控制了成本,又保证了准确率。
1.3 整体流程设计
整个方案我拆成了五个环节:文档解析 -> 内容切分 -> 构造Prompt -> 模型调用 -> JSON校验与修正。下面把每个环节的职责说清楚。
文档解析环节解决的是“怎么把PDF、Word、图片变成模型能理解的内容”,包括提取文本、渲染成图片、做预处理。内容切分环节解决的是“文档太长,模型一次吃不下怎么办”,核心是让每个请求保持合适的上下文长度。构造Prompt环节解决的是“怎么让模型输出可靠JSON”,这是整个项目最核心的部分,后面有专门一节展开讲。模型调用环节就很简单了,把请求发出去拿结果。最后一个校验环节最容易被忽略,大模型输出有概率性,不能假设它100%稳定,要做JSON格式校验、字段完整性校验、内容合理性校验,不合格就自动重跑。
这五个环节里,前两个环节出错会导致后续全部白费,所以我会在文档解析之后先做一个人工抽检,确认输入没问题再继续往下跑。
2. 文档预处理与关键细节
2.1 PDF要不要转图片
很多人第一步就卡住了:PDF到底直接转文本还是转图片给模型?我的判断标准是这样的:先看PDF是“文字型”还是“图片型”。文字型PDF可以直接提取文本,Ctrl+A能选中的那种就是;图片型PDF本质是扫描图,要么靠OCR,要么直接把整页渲染成图片喂给视觉模型。
对于文字型PDF,我通常先用文本提取方案。但这里有个陷阱:PDF里的文本流顺序不一定是视觉顺序,遇到多栏排版、文本框嵌套时,提取出来的文本顺序是乱的,表格结构自然也就歪了。这种情况我会放弃纯文本方案,把页面渲染成图片,交给视觉模型处理。
对于图片型PDF,传统做法是先OCR再拼结构,现在直接渲染成PNG给视觉模型,一步到位。千问视觉模型对中文印刷体的识别准确率非常高,省去了串联多个工具的复杂度。
2.2 表格切片与分辨率控制
文档预处理里最容易翻车的细节是图片过大或过小。图片过大,模型接收的token消耗会显著增加,而且有些API对图片尺寸有上限;图片过小,表格里的文字模糊,识别准确率断崖式下降。
我的经验是:单张图片宽度控制在1500到2000像素,输出格式用PNG,保证文字边缘清晰。过宽的表格,优先缩小尺寸而不是切图,因为切片容易切断表格线,影响结构识别。如果缩小到极限还是识别不清,那就按列做切割,但切割时必须保留表头和对应列的上下文,不能每列单独飘出去识别。
这里有个实际案例:一张A3纸大小的仓库库存表,列数非常多,缩小后字体太小看不清。我按列切割成左右两部分,每部分都包含表头行,再分别识别,最后在JSON合并阶段拼接。合并时要注意列名冲突问题,左右两部分的表头文字一样,JSON的字段名会用不同前缀区分。
2.3 调用参数的设置
模型调用参数里,最关键的是这三个:temperature、max_tokens、response_format。
temperature控制随机性,表格提取是“确定性任务”,不需要创造力,我直接拉到0。某些平台的API最低能调到0,这里建议不要调成负数(不合法),0就是最稳的。
max_tokens决定了输出上限。表格数据量大的时候,输出JSON可能超过2000个token,默认值往往不够。我的经验是把max_tokens设置成输入图片对应token消耗的2到3倍,宁可多给,避免输出被截断。
response_format这项尤其重要。千问的API支持JSON模式,在请求里显式声明输出为JSON对象,模型会更倾向于输出干净的结构化数据,而不是夹带描述性文字。声明了JSON模式也不代表一定合法,后面我会专门讲校验兜底。
3. Prompt设计与JSON结构约束
3.1 让模型稳定输出JSON的关键写法
Prompt是整条流水线的灵魂,写得好不好,效果差出一大截。我总结出一套稳定的写法模板,分几层来写:
第一层是任务描述,直接说清楚你要干什么事:“你是表格结构化助手,请从输入的表格内容中提取数据,严格按照JSON格式输出”。
第二层是输出结构定义,用代码块给出一个示例或JSON Schema。这是最关键的一层,模型看到明确的键名和值类型,输出才会稳定。比如你要提取发货单,就写:
{ "订单编号": "字符串", "发货日期": "YYYY-MM-DD", "商品明细": [ { "商品名称": "字符串", "数量": "数字", "单价": "数字", "金额": "数字" } ] }第三层是约束说明,要写清楚“只输出JSON,不要任何解释”“没有数据的字段填null而不是忽略”“数字不要带单位”。这些约束看着琐碎,但每条都能减少后续清洗的工作量。
我踩过一个很典型的坑:第一次跑的时候没写“数字不要带单位”,模型把所有价格字段都输出成了“12.5元”“100/箱”这种带单位的字符串,前端直接拿它做数值计算,全报类型错误。后来在Prompt里加了一条强约束,这个现象就基本消失了。
3.2 复杂表格的结构化规则
日常文档里最恶心的表格是合并单元格和多级表头。以多级表头为例,普通的行列对应关系已经失效了,必须让模型输出嵌套结构才能保留层级语义。
我给模型定义规则时是这么写的:上级表头作为对象字段名,下级表头作为该对象下的子字段。举例来说:
| 2025年一季度 | 2025年一季度 | 2025年二季度 |
|---|---|---|
| 一月 | 二月 | 三月 |
对应JSON结构就是:
{ "2025年一季度": { "一月": "数据", "二月": "数据", "三月": "数据" }, "2025年二季度": {} }合并单元格的处理要麻烦一点。单元格向下合并时,模型容易把空的单元格当成null,但实际上它应该继承上方单元格的值。我在Prompt里专门写了一条:“遇到纵向合并单元格,该单元格的值与上方单元格相同,请填充相同值,不要输出null”。这条规则加入后,纵向合并表格的提取准确率提升很明显。
3.3 多轮修正和自校验机制
prompt写得再好,也挡不住复杂表格的意外情况。我设计的方案是“一次识别 + 一次校验修正”。
第一次调用,模型给出JSON。我先做程序化校验,检查JSON是否可解析、必填字段是否存在、数组长度是否和数据行数匹配。如果校验失败,我把错误信息和原始表格图片一起放进第二次请求,让模型自己看看哪里出问题了,然后重新输出修正后的JSON。
这个方案在实践中有效解决了两个问题:一个是表格在图片里被截断导致的数据缺失,模型二次审视时往往能通过上下文补回来;另一个是模型偶尔会创作出表格里根本不存在的字段,校验时把Schema对不上的字段标出来,第二次模型就会收敛。
4. 核心代码实现与调通记录
4.1 环境准备与API接入
环境方面,我用的Python 3.10,安装了openai库和pymupdf库,用来处理PDF转图片。千问的API兼容OpenAI调用格式,只需要把base_url指向千问服务的兼容地址,再把api_key换成自己的就行。
pip install openai pymupdf代码里的api_key建议放在环境变量里,不要硬编码进源码。我平时是在本地调试时读取.env文件,部署到服务器时再注入环境变量。
4.2 完整的识别与解析代码
下面这段代码是实战里跑通的版本,包括PDF渲染成图片、图片转base64、调用视觉模型识别表格、取出JSON结果。核心逻辑已经注释出来了。
import os import base64 import json import fitz # PyMuPDF from openai import OpenAI client = OpenAI( api_key=os.getenv("DASHSCOPE_API_KEY"), base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" ) def pdf_page_to_base64(pdf_path, page_index): doc = fitz.open(pdf_path) page = doc[page_index] pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2倍缩放,提高清晰度 img_bytes = pix.tobytes("png") return base64.b64encode(img_bytes).decode("utf-8") def extract_table_to_json(pdf_path, page_index): img_base64 = pdf_page_to_base64(pdf_path, page_index) prompt = """你是表格结构化助手。请提取图片中的表格内容,并以JSON格式输出。 约束: 1. 只输出JSON,不要任何解释或前后缀 2. 数字字段不要带单位,纯数字 3. 合并单元格的纵向继承值,不要输出null 4. 表格为空则输出空数组 输出结构示例: {"rows": [{"列名1": "值1", "列名2": 123}]}""" response = client.chat.completions.create( model="qwen-vl-max", messages=[ {"role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_base64}"}}, {"type": "text", "text": prompt} ]} ], temperature=0, max_tokens=2048, response_format={"type": "json_object"} ) return json.loads(response.choices[0].message.content) # 使用示例 result = extract_table_to_json("发货单.pdf", 0) print(json.dumps(result, ensure_ascii=False, indent=2))跑通这段代码的前提是几点:图片不能太模糊、Prompt约束要清晰、max_tokens要给够。我最初调的时候max_tokens只给512,遇到一张数据稍微多一点的表,输出直接截断了,JSON解析直接报错。后来改成2048,问题就消失了。
这里补充一个细节:response_format={"type": "json_object"}在某些模型版本上要求Prompt里必须出现“json”这个词,否则API会报错。所以Prompt里一定要保留“以JSON格式输出”这样的字眼,这是兼容性小坑。
4.3 结果校验与异常兜底
调用模型拿到JSON只是第一步,真正的生产级代码还需要加一层校验。我最常用的校验函数长这样:
def validate_result(obj): if not isinstance(obj, dict): raise ValueError("结果不是对象") if "rows" not in obj: raise ValueError("缺少rows字段") if not isinstance(obj["rows"], list): raise ValueError("rows不是数组") if len(obj["rows"]) == 0: print("警告:表格为空,请确认文档页是否正确") # 这里可以按业务字段补充必填项检查 return True校验不通过时,我会触发重试逻辑,最多重试三次。重试时把上一次的错误信息拼进Prompt,让模型修正。重试三次后仍然失败,就把这一页记录下来,进入人工处理队列兜底。
5. 常见问题与排查经验
5.1 表格内容顺序错乱
这是最频繁遇到的问题,尤其是PDF不是标准表格排版时,模型会把数据行的顺序打乱,或者把列对应错位。
排查思路是这样的:先确定输入的图片方向是否正确,PDF渲染可能会出现页面被裁剪的情况,检查图片是否完整;再检查Prompt里是否强调了“严格按原始表格的行列顺序输出”。加了这个强调后,顺序错乱问题减少了很多。
如果还出现顺序问题,就得检查表格里是否有跨行的合并单元格,这种格子最容易让模型产生歧义。我在一张人员信息表里遇到过一个“部门”列跨了三行,模型在输出后面两行数据时,把部门字段填成了null,按前面的规则处理后恢复正常了。
5.2 JSON格式非法
API已经声明了JSON模式,但偶尔还是会出现JSON解析失败的情况,原因通常是输出里带了markdown代码块标记,比如json和,这种情况用正则剥掉就行:
import re def extract_json_str(text): match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text) if match: return match.group(1) return text.strip()另一个常见原因是输出被截断,max_tokens不够。截断时JSON到一半就断了,校验时会直接抛JSONDecodeError。这种情况除了调大max_tokens,还可以用流式输出提前判断进度,但这套方案适合服务端异步场景,本地脚本里直接调大token上限最简单。
5.3 多页表格和超大文档
单页Tables还好应对,多页连续的表格就比较棘手了。比如一份十页的采购单,表头只在第一页,后面每一页都是数据行。如果每一页单独识别,模型不知道列名是什么,出来的JSON键名就会变成“列1”“列2”。
我的处理方式是:第一页单独识别获取表头结构,后面续页把第一页输出JSON的表头拼进Prompt,让模型沿用同一个键名输出。这样整个文档的JSON结构就完全统一了。
如果是几十页的文档,逐页调用API的成本会很高,而且每页都有延迟。这边建议在文档解析环节就做表格区域定位,判断哪些页面确实包含表格,把空白页、纯文字页筛掉,从源头上减少无效调用。
6. 成本控制与后续扩展方向
6.1 成本与效率实测
用的视觉模型单张图片的token消耗大约是800到1500 token,加上输出JSON的token,每页的综合消耗在2000到3000 token之间。实际跑了一批100页的混合文档,准确率大约在90%以上,剩余少量问题集中在手写体和极度复杂的多级嵌套表头上。
成本这块,如果用更低配的模型,单页消耗能节省一半,但准确率会下降。我的建议是先跑一批测试集,计算准确率和成本之间的平衡点,再决定用什么档位的模型。生产环境最好做两层:常规表格用低成本档位,复杂表格自动升级到高配模型。
6.2 方案扩展与应用场景
这个方案不只是“识别发货单”,稍微改一下Prompt和输出结构,就能扩展到很多场景:银行对账单转为流水JSON、简历信息结构化、合同条款提取、发票全字段识别,甚至仓库的erp系统可以直接通过这个通道把纸质单据扫进数据库。
还有一个可以做的改造:把识别结果自动对比源表格,用程序做行数和关键字段的交叉校验。我在后续迭代中已经把这部分加进去了,相当于给模型输出加了一层“机器校验”,错误率进一步下降。
如果文档量再涨一个量级,可以考虑把同步调用改成异步任务队列,把整个识别过程拆成“上传文档 -> 后台解析 -> 回调结果”的标准服务,前端只需要等回调。这套架构在生产环境已经跑得很稳。
最后分享一个很小的调试技巧:千问模型对“表格标题”和“表格内容”的处理粒度不同,如果某列总是识别错,试着在表头行后面加一个“单位”注释行,比如“商品名称(品牌)”“金额(元)”,模型反而会理解得更准。这属于Prompt工程里的暗经验,多试几次就慢慢摸出门道了。