用 LLM 从 PDF 里抽表格:5 份文档、644 个单元格,我的四次翻车复盘

举报
Daniel777 发表于 2026/10/29 08:50:17 2026/10/29
【摘要】 这个活我一开始以为半小时能收工。pdfplumber 装上、写 20 行、跑完 5 个文件。结果第一轮就崩了:3 份找到表但结构错,2 份扫描件一个字都读不出来。换 LLM 之后也没顺利——我踩了两个不报错的坑:一次返回 0 行、HTTP 200、没有任何异常;一次 58 行的表只回来 5 行,JSON 还是合法的。这篇是我的完整复盘:每次我以为的解法、实际翻车的原因、修法。末尾有可以直接抄...

这个活我一开始以为半小时能收工。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 后缀是标准价一半。但有两个限制必须知道:

  1. 要求公网可访问的 URL,不能传 base64。 所以本地文件得先上传到某处。
  2. 该路由不支持 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 条实践清单

按"最容易被忽略"排序,前四条是我实际踩过的坑:

  1. 永远拒绝空 rows。 扫描件走了文本解析器时,返回的是 {"rows": []} + HTTP 200 + finish_reason: stop,没有任何异常。
  2. 永远检查 finish_reason == "length"。 开 Response Healing 之后,截断的输出会变成合法 JSON,只有 5 行却看起来成功。
  3. additionalProperties: false 要设两处——每一行(内层)和整个对象(外层)。只设一处,模型就会塞额外字段或改顶层结构。
  4. 数据清洗写进 description,不写进后处理。 括号负数、千分位、货币符、脚注标记、日期格式、合并格重复,六件事六句描述。模型执行得比你的正则稳。
  5. 跨页表必须靠提示词补一句 “Include rows from every page.” schema 表达不了"跨页"这个语义。
  6. 再加一句 “Return only rows that are in the document.” 防模型"补全"缺失行。
  7. max_tokens ≈ 150 × 预期行数,实测再调。每行实测消耗 43–133 输出 token。
  8. 路由按 PDF 形态选:能读文件就直读;全数字版用免费解析器;有扫描件用 mistral-ocr。
  9. engine="native" 只能给能读 PDF 文件的模型。 给只吃图片的模型(如 DeepSeek V4.1 Flash)设 native,会随机报 provider 错误。
  10. 自建 golden eval set。 我没信任何公开数据,是人工把 644 个格子对了一遍。选定路由前的唯一办法就是自己测。
  11. 校验完整性。 拿行数或某个小计跟文档对一遍。这一步能抓住所有漏行。
  12. 避开重复解析成本。 有 file annotations 机制可以回传解析结果,避免同一份 PDF 二次付费。

两个规模化的补充:

  • 存量跑批用 Batch API(:batch 后缀,半价),但要求公网 URL 而非 base64,且不支持 DeepSeek + OCR 组合。
  • 配置用 Presets 存(模型、系统提示、供应商设置),但 schema 每次请求还得在 response_format 里发——它不跟着 preset 走。这个坑很容易在重构时踩。

最后一句

这次复盘里最贵的两个教训都不是"哪个模型更好",而是两个沉默的失败:

一个返回了合法的空数组,一个返回了合法的残缺 JSON。它们都没报错、都没让代码崩溃、都让流水线"跑通了"。如果不是我人工对了一遍 644 个格子,这两个问题会一直躺在数据里。

做数据管道,最该警惕的不是报错,是"看起来成功了"。


【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。