用 LLM 从 PDF 里抽表格:5 份文档、644 个单元格,我的四次翻车复盘
这个活我一开始以为半小时能收工。pdfplumber 装上、写 20 行、跑完 5 个文件。结果第一轮就崩了:3 份找到表但结构错,2 份扫描件一个字都读不出来。
换 LLM 之后也没顺利——我踩了两个不报错的坑:一次返回 0 行、HTTP 200、没有任何异常;一次 58 行的表只回来 5 行,JSON 还是合法的。
这篇是我的完整复盘:每次我以为的解法、实际翻车的原因、修法。末尾有可以直接抄的代码、12 条实践清单,和一张路由决策树。
需求:五份形态完全不同的 PDF
需求很朴素——把一批 PDF 里的表格变成 CSV,后续要进数据库做对账。样本是 5 份,覆盖了这个需求会遇到的全部形态:
| 文件 | 类型 | 难点 | 行数 |
|---|---|---|---|
invoice.pdf |
数字版(系统导出) | 金额带千分位和货币符 | 10 行 |
quarterly.pdf |
数字版 | 表头跨 4 列合并(“Fiscal 2026” 压着 Q1–Q4);括号表示负数;行名带脚注标记 | 4 个业务分部 |
shipments.pdf |
数字版 | 表格跨两页,第二页重复表头 | 58 行 |
scanned_po.pdf |
扫描件 | 无文字层 | 6 行 |
rough_scan.pdf |
粗糙扫描件 | 无文字层 + 纵向合并单元格(Category 跨 6–9 行) | 30 行 |
合计 6 页,644 个单元格。我把源数据人工整理了一份作为 golden 标准,逐格对照——不逐格对比的准确率都是自我安慰。
第一次翻车:pdfplumber 的三种失败方式
我的第一版方案是每个做数据的人都写过的:pdfplumber.extract_tables()。选它没错,数字版 PDF 的文本层是现成的,纯 CPU、免费、毫秒级。
结果是三份数字版全部找到了表,但没有一份是对的:
| 文件 | pdfplumber 的结果 |
|---|---|
invoice.pdf |
✅ 基本正确,金额是字符串 6,067.00,要自己转 |
quarterly.pdf |
⚠ 表找到了,但二级表头被拆成两行,跨列合并格返回 None。数据结构直接错位 |
shipments.pdf |
⚠ 表找到了,但被拆成两张——每页一张,各带一个表头 |
scanned_po.pdf |
❌ 找不到任何表(无文字层) |
rough_scan.pdf |
❌ 找不到任何表 |
这时候有两个选择:
选择 A:继续修 pdfplumber。 加逻辑检测"这一行像不像表头"、加逻辑猜"这张表是不是上一张的续、加逻辑回填合并格。
选择 B:换方案。
我算了一下 A 的成本:合并表头和跨页续表的判断逻辑写出来大概 100 行,而且每遇到一种新模板就要加一段特判。5 份样本能调对,来第 20 份客户模板时大概率又崩。这是个会持续消耗维护成本的无底洞。
而 “上模型” 的成本,从后面跑出来的结果看是 $0.77 / 1000 页。
这个数字决定了选择 B。
这里有一条比代码更重要的判断:pdfplumber 这类库擅长的是"有多少文字、在哪"——它是几何提取。而你要的东西是"这一行哪个数字属于哪个列、合并格该填什么"——这是语义理解。后者用几何规则硬凑,注定是特判堆特判。
换路线:让模型直接读 PDF,而不是先 OCR 再读
换成 LLM 之后,第一个岔路口是:要不要先把 PDF 转成图片或文字?
我一开始的想法是"肯定要转,模型又不认 PDF"。这个想法已经过时了。现在有三款模型能直接把 PDF 当文件输入读:Claude Haiku 5.5、GPT-6 Luna、Gemini 3.8 Flash。
于是我把四条路由全跑了一遍:
| 路由 | 怎么做的 | 成本 | 结果 |
|---|---|---|---|
| A. 模型直读文件 | PDF 作为 file part 直接送模型 | GPT-6 Luna ≈ $0.77/1000 页;Claude Haiku 5.5 ≈ $1.00/1000 页 | 5 份全部 644 格 100% 正确,含两份扫描件 |
| B. OCR 预处理 | engine="mistral-ocr" 先逐页 OCR |
额外 +$2/1000 页,再叠加模型 token | 基本正确,但2 处脚注标记没删(Enterprise / Hardware resale) |
| C. 免费文本解析器 | engine="cloudflare-ai",只吃文字层 |
免费 | 3 份数字版全对;2 份扫描件全空 |
D. engine="native" |
给 DeepSeek V4.1 Flash 设 native | — | 1 次成功、2 次 provider 报错 |
路由 A 的结果出乎我意料——三款模型不是"勉强能用",是 644 个单元格全对,包括跨页表、合并单元格、括号负数、脚注标记。
但请注意路由 D 的翻车。 engine="native" 是"让模型自己读文件"的开关,而 DeepSeek V4.1 Flash 不能读 PDF 文件(它只吃图片)。给一个不支持文件输入的模型设 native,结果是随机报错——有时成功有时失败,因为它取决于路由到了哪个 provider。
这条我写进清单里了:native 只能给能读文件的模型用;不能读文件的模型必须走 file-parser。
另外一个便宜的坑:引擎名拼错,返回的不是"未知引擎",而是:
400 {"error":{"message":"plugins.0.pdf.engine: Invalid input","code":400}}
这个报错里没有提示有哪些合法值。合法值是:不设(模型直读)、mistral-ocr、cloudflare-ai、native。
第二次尝试:不写严格 schema 会怎样
一开始我很自然地想:“提示词里说清楚要什么字段就行了。” 结果是典型的看起来对、细看全是坑:
| 提示词写法 | 模型的实际输出 |
|---|---|
| “把表格抽成 JSON” | 有时候返回 [...] 数组,有时候返回 {"data": [...]},有时候返回 {"rows": [...]} |
| “金额字段” | "$6,067.00" / 6067.0 / 6067 / "6067.00" 随机出现 |
| “季度数据” | 括号负数 (3,878) 有 30% 概率保留成字符串 "(3,878)" |
| “分部名称” | 脚注标记 Enterprise① 被一起带出来 |
| 行数 | 某次漏了一行,没报错 |
问题的本质:提示词是"软约束",JSON mode 只保证"是合法 JSON",不保证"是你想要的结构"。 合法 JSON 可以是任何形状。
所以第二步是上 json_schema + strict: true。这才是硬约束。
写 schema 的时候有两处细节,是我踩完才明白的:
细节一:additionalProperties: false 要设两处,不是一处。
def rows_schema(fields):
return {
"type": "object",
"properties": {"rows": {"type": "array", "items": {
"type": "object",
"properties": fields,
"required": list(fields),
"additionalProperties": False, # ← 内层:每一行
}}},
"required": ["rows"],
"additionalProperties": False, # ← 外层:整个对象
}
只设外层,模型可以往每一行里塞额外字段(比如自己加个 confidence、加个 notes),你不处理就是脏数据。只设内层,模型可以在顶层多返回一个 {"rows": [...], "summary": "..."},你 json.loads(...)["rows"] 能跑通,但换个模型可能就变成 {"data": {...}, "rows": [...]}。
细节二:required 要列全部字段,并且"数据清理"要写进 description。
这是整套方案里最关键的一个思路转换:
description不是给人看的注释,它是清洗规则。 你能在description里写清楚的格式规约,模型就会执行;写不清楚的,就得靠后处理正则。
我最终的三个 schema 的 description 是这么写的:
# 金额:去掉货币符和千分位,负数用括号
MONEY = {
"type": "number",
"description": "USD, no currency symbol or thousands separator"
}
# 千分位整数 + 括号负数(两件事必须写在一条里,分开写模型会只顾一个)
THOUSANDS = {
"type": "integer",
"description": "Thousands of USD. A value in parentheses, "
"like (1,204), is negative: -1204."
}
# 脚注标记
{"type": "string", "description": "Segment name without footnote markers"}
# 合并单元格
{"type": "string",
"description": "The category in the first column. It is a merged cell, "
"so repeat it on every row it covers."}
# 日期
{"type": "string", "description": "YYYY-MM-DD"}
验证效果:
- 括号负数:
(3,878)→-3878,全对 - 千分位 + 货币符:
6,067.00→6067.0,全对 - 合并单元格:
rough_scan.pdf的 30 行,Category 列全部填满(三款模型都是)。这一步如果靠后处理"遇到 None 就取上一行的值",你得先判断"这个 None 是合并格还是真的空值"——而模型不需要判断,它直接看懂了 - 脚注标记:直读路由全对;OCR 路由漏了 2 处(Enterprise / Hardware resale 后面的标记没删)
最后一条值得记:同一个 schema,不同路由的清洗执行度不一样。 OCR 路由因为中间多了一层文字重建,脚注标记被当成了正常字符粘在词尾,模型没意识到要删。这不是 schema 的问题,是路由的问题。
有一个东西 schema 管不了:跨页续表。 这个必须靠提示词补:
Extract every row of the table in this PDF.
Include rows from every page.
Return only rows that are in the document.
三句话,各管一件事:
- 第一句:抽表里的每一行
- 第二句:每一页的都要(这句是跨页表的命门)
- 第三句:只返回文档里真有的行(这句是防编造)
第三句我原本觉得多余,跑完 25 次抽取后确认它不冗余——25 次里没出现编造行,但这类任务的失败模式恰恰是"补全":表里最后一行是汇总行,模型容易自己推算出缺失的中间行。加了这句是保险。
第三次翻车:静默失败 ①,返回 0 行但一切正常
这是我这次踩得最狠的坑,因为它不报错。
用 cloudflare-ai 免费解析器跑 rough_scan.pdf(粗糙扫描件,30 行)。返回:
- HTTP 200 OK
finish_reason: "stop"- 内容:
{"rows": []} - 没有任何异常
模型没撒谎。扫描件没有文字层,解析器给了它空文本,它老老实实返回"这里面没有行"。它在逻辑上完全正确,而我的流水线会存 0 行然后继续跑下一份。
这就是静默失败的定义:响应合法、语义错误、无异常。
如果没有检查,这 30 行就永久丢了,而且你不会知道。等到下游对账发现少了 30 行,你会去查数据库、查导入脚本、查字段映射——唯独不会想到是两个月前抽取时返回了空数组。
修法是一行检查,加在 return 之前:
rows = json.loads(choice["message"]["content"])["rows"]
if not rows:
raise ValueError(
"No rows came back. If this PDF is a scan, use a model "
"that reads PDFs or engine='mistral-ocr'."
)
注意这个报错文案——它不是写给我看的,是写给半年后的我看的。它直接告诉了我原因(可能是扫描件)和两个解法(换能读 PDF 的模型 / 换 mistral-ocr)。写报错文案这件事,收益比你想象的高。
底层原则:任何"合法的空结果"都必须当作异常处理。空数组、空字符串、null——只要它在业务上不应该出现,就不能放过。前端接口里这叫"空状态",数据管道里这叫"丢数据"。
第四次翻车:静默失败 ②,58 行只回来 5 行
这个更阴。shipments.pdf 58 行,跨两页。跑完看结果——只有 5 行。
但这次我开了 Response Healing 插件(会自动修复被截断的 JSON)。它的作用是:JSON 被截断时,自动补齐括号让它变成合法 JSON。
于是拿到了:
finish_reason: "length"- JSON 合法可解析
- 里面只有 5 行
- 没有异常
如果我不看 finish_reason,这 53 行就没了。而且比上一个坑更危险——上一个坑返回空,肉眼能看出不对;这个坑返回非空,看起来"跑通了"。
修法:
if choice["finish_reason"] == "length":
raise ValueError(
f"Output stopped at max_tokens={max_tokens}. "
"Raise it or split the PDF."
)
这里有一个反直觉的结论,我建议单独记下来:
schema 防不住截断,healing 也补不回来。 schema 保证的是"格式和类型对",
strict: true保证的是"字段齐全"。但当输出被max_tokens砍断时,模型根本没机会生成完整数据——补全括号只是让它"看起来完整",缺的行不会自己长回来。
所以这两项检查必须都在,而且都在 return 之前:
| 检查 | 防的是什么 | 不检查的后果 |
|---|---|---|
if not rows |
扫描件走了文本解析器 | 永久丢全部行,无感知 |
if finish_reason == "length" |
长表被 max_tokens 截断 | 永久丢部分行,且看起来成功 |
怎么设 max_tokens? 我实测的输出 token 消耗是每行 43–133 个(58 行的表吃了 2,500–7,700 输出 token)。所以起步估算按:
max_tokens ≈ 150 × 预期行数
150 而不是 133,留 12% 余量。然后实测再调。行数波动大的批次(有的表列多,有的列少),宁可多给。
五份 PDF 的最终账
跑完之后我把成本也记下来了,因为"能不能用"和"用得起吗"是两个独立问题:
| 文件 | 行数 | 单次调用成本 | 备注 |
|---|---|---|---|
invoice.pdf |
10 | $0.00067,约 6 秒 | 最便宜的常规场景 |
shipments.pdf |
58 | $0.00130 | 长表,输出 token 是大头 |
scanned_po.pdf |
6 | $0.00211 | 走了 mistral-ocr,OCR 费占了大半 |
| 外推 / 1000 页 | — | GPT-6 Luna ≈ $0.77、Claude Haiku 5.5 ≈ $1.00 | 牌价均 $0.10/百万输入 token |
两个对比要放进来:
- Gemini 3.8 Flash 精度一样(644 格全对),但贵 8–10 倍($1.50/百万输入 token,10 月 21 日起折扣到 $0.75)。精度相同的时候,选便宜的那个,没有悬念。
- OCR 预处理额外 +$2/1000 页。所以"先 OCR 再读"这个看起来更稳妥的方案,实际成本是"模型直读"的 3 倍以上($2.77 vs $0.77)。而且清洗执行度还更差(漏了 2 处脚注标记)。
这是一个反直觉的结论:很多人(包括我)的直觉是"OCR 是传统技术,一定更便宜"。在 2026 年的价格结构下,反了。OCR 现在是额外的一道工序,而模型直读 PDF 是一道工序。
如果要跑存量(比如历史归档的几千份 PDF),还有一招:Batch API,:batch 后缀是标准价一半。但有两个限制必须知道:
- 要求公网可访问的 URL,不能传 base64。 所以本地文件得先上传到某处。
- 该路由不支持 DeepSeek + OCR 的组合。 如果你走 OCR 路线又想吃半价,这条路走不通。
路由决策树:按 PDF 形态选,不要按习惯选
跑完五份之后,我整理了一条按形态分流的决策路径。这个顺序很重要,第一问就能砍掉 80% 的纠结:
第一问:模型能直接读 PDF 文件吗?
├─ 能(Claude Haiku 5.5 / GPT-6 Luna / Gemini 3.8 Flash)
│ → 直读,engine 不设。数字版和扫描件通吃,$0.77–1.00/1000 页
│
└─ 不能(如 DeepSeek V4.1 Flash,只吃图片)
↓
第二问:这些 PDF 有文字层吗?
├─ 全部有(系统导出的发票、报表)
│ → engine="cloudflare-ai",免费
│ ⚠ 必须加空结果检查,否则扫描件会静默返回 0 行
│
└─ 有扫描件 / 不确定
→ engine="mistral-ocr",+$2/1000 页
⚠ 脚注标记可能漏删,需要在 description 里强调或后处理
还有一条不该被忘掉的判断:什么时候根本不该上模型。
| 情况 | 用什么 |
|---|---|
| 单页、只有一张表、表头单行、数字版 | pdfplumber 就够,毫秒级、零成本 |
| 有合并表头 / 跨页 / 合并单元格 / 扫描件 | 上 schema 方案 |
| 只是要"这一页有没有表" | 不用模型,判断线框数量即可 |
我这份复盘的价值不在于"LLM 比 pdfplumber 好",而在于它好在哪:好在这几类结构性难题上,不好在简单表格上。一张规规矩矩的单页表,用模型抽是 100 倍的成本换 0 的体验提升。
可以直接抄的代码
Python(核心函数)
import base64, json, os, requests
# 端点地址见官方文档,此处用变量避免硬编码
API = os.environ["OPENROUTER_BASE_URL"] + "/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"}
def rows_schema(fields):
"""把"一行"的字段包成严格 schema:{"rows": [...]}"""
return {
"type": "object",
"properties": {"rows": {"type": "array", "items": {
"type": "object",
"properties": fields,
"required": list(fields),
"additionalProperties": False,
}}},
"required": ["rows"],
"additionalProperties": False,
}
def extract_table(pdf_path, schema, model="anthropic/claude-haiku-5.5",
engine=None, max_tokens=8000):
with open(pdf_path, "rb") as f:
data_url = ("data:application/pdf;base64,"
+ base64.b64encode(f.read()).decode())
body = {
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": [
{"type": "text", "text":
"Extract every row of the table in this PDF. "
"Include rows from every page. "
"Return only rows that are in the document."},
{"type": "file", "file": {
"filename": os.path.basename(pdf_path),
"file_data": data_url}},
]}],
"response_format": {"type": "json_schema",
"json_schema": {"name": "table",
"strict": True,
"schema": schema}},
}
if engine:
body["plugins"] = [{"id": "file-parser", "pdf": {"engine": engine}}]
resp = requests.post(API, headers=HEADERS, json=body, timeout=300)
resp.raise_for_status()
data = resp.json()
choice = data["choices"][0]
# 检查 1:截断(必须)
if choice["finish_reason"] == "length":
raise ValueError(f"Output stopped at max_tokens={max_tokens}. "
"Raise it or split the PDF.")
rows = json.loads(choice["message"]["content"])["rows"]
# 检查 2:空结果(必须)
if not rows:
raise ValueError("No rows came back. If this PDF is a scan, use a "
"model that reads PDFs or engine='mistral-ocr'.")
return rows, data["usage"]["cost"]
# 三个常用 schema
LINE_ITEMS = rows_schema({
"sku": {"type": "string"},
"description": {"type": "string"},
"qty": {"type": "integer"},
"unit_price": {"type": "number",
"description": "USD, no currency symbol or thousands separator"},
"amount": {"type": "number",
"description": "USD, no currency symbol or thousands separator"},
})
THOUSANDS = {"type": "integer",
"description": "Thousands of USD. A value in parentheses, "
"like (1,204), is negative: -1204."}
QUARTERLY = rows_schema({
"segment": {"type": "string",
"description": "Segment name without footnote markers"},
"q1": THOUSANDS, "q2": THOUSANDS, "q3": THOUSANDS,
"q4": THOUSANDS, "fy_total": THOUSANDS,
})
SHIPMENTS = rows_schema({
"date": {"type": "string", "description": "YYYY-MM-DD"},
"shipment_id": {"type": "string"},
"origin": {"type": "string"},
"destination": {"type": "string"},
"weight_kg": {"type": "number", "description": "No thousands separator"},
"status": {"type": "string"},
})
写 CSV(拿 schema 里的字段名做列头,不用手抄)
import csv
rows, cost = extract_table("shipments.pdf", SHIPMENTS, model="openai/gpt-6-luna")
fieldnames = list(SHIPMENTS["properties"]["rows"]["items"]["properties"])
with open("shipments.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames)
w.writeheader()
w.writerows(rows)
print(f"Wrote {len(rows)} rows to shipments.csv (${cost:.5f})")
TypeScript 版(只留最关键的差异部分)
const choice = data.choices[0];
if (choice.finish_reason !== "stop") throw new Error(
`Unexpected finish_reason: ${choice.finish_reason}`);
const { rows } = JSON.parse(choice.message.content);
if (rows.length === 0) throw new Error("No rows came back.");
console.log(`${rows.length} rows, $${data.usage.cost}`);
注意 TS 版我用的是 finish_reason !== "stop" 而不是 === "length"。这比 Python 版更严格——它把 content_filter 之类的其他非 stop 状态也拦住了。如果你的输入来源不可控,建议用这个写法。
12 条实践清单
按"最容易被忽略"排序,前四条是我实际踩过的坑:
- 永远拒绝空
rows。 扫描件走了文本解析器时,返回的是{"rows": []}+ HTTP 200 +finish_reason: stop,没有任何异常。 - 永远检查
finish_reason == "length"。 开 Response Healing 之后,截断的输出会变成合法 JSON,只有 5 行却看起来成功。 additionalProperties: false要设两处——每一行(内层)和整个对象(外层)。只设一处,模型就会塞额外字段或改顶层结构。- 数据清洗写进
description,不写进后处理。 括号负数、千分位、货币符、脚注标记、日期格式、合并格重复,六件事六句描述。模型执行得比你的正则稳。 - 跨页表必须靠提示词补一句 “Include rows from every page.” schema 表达不了"跨页"这个语义。
- 再加一句 “Return only rows that are in the document.” 防模型"补全"缺失行。
max_tokens ≈ 150 × 预期行数,实测再调。每行实测消耗 43–133 输出 token。- 路由按 PDF 形态选:能读文件就直读;全数字版用免费解析器;有扫描件用
mistral-ocr。 engine="native"只能给能读 PDF 文件的模型。 给只吃图片的模型(如 DeepSeek V4.1 Flash)设 native,会随机报 provider 错误。- 自建 golden eval set。 我没信任何公开数据,是人工把 644 个格子对了一遍。选定路由前的唯一办法就是自己测。
- 校验完整性。 拿行数或某个小计跟文档对一遍。这一步能抓住所有漏行。
- 避开重复解析成本。 有 file annotations 机制可以回传解析结果,避免同一份 PDF 二次付费。
两个规模化的补充:
- 存量跑批用 Batch API(
:batch后缀,半价),但要求公网 URL 而非 base64,且不支持 DeepSeek + OCR 组合。 - 配置用 Presets 存(模型、系统提示、供应商设置),但 schema 每次请求还得在
response_format里发——它不跟着 preset 走。这个坑很容易在重构时踩。
最后一句
这次复盘里最贵的两个教训都不是"哪个模型更好",而是两个沉默的失败:
一个返回了合法的空数组,一个返回了合法的残缺 JSON。它们都没报错、都没让代码崩溃、都让流水线"跑通了"。如果不是我人工对了一遍 644 个格子,这两个问题会一直躺在数据里。
做数据管道,最该警惕的不是报错,是"看起来成功了"。
- 点赞
- 收藏
- 关注作者
评论(0)