跳到正文
OpenRouter:Announcements·· 17 小时前精选AI 评分76

OpenRouter 教程:用 LLM 从 PDF 中抽取表格

How to Extract Tables from a PDF with an LLM

AI 导读

OpenRouter 发布教程,讲解如何把 PDF 以文件形式连同严格 JSON schema 发给能读文件的模型,让表格以带类型的行返回,括号亏损、千分位和脚注标记等清理都写进字段描述。

推荐理由

OpenRouter 用五份 PDF 实测了多条抽取路由,给出严格 JSON schema 写法、两类静默失败的检查和各路由成本对比。

正文 · AI 翻译

要用 LLM 从 PDF 中提取表格,先把 PDF 发给能读取文件的模型,同时附上一份严格的 JSON schema,用它描述表格中的一行。模型会返回带类型的行:数量以整数返回,写成 (3,878) 的亏损返回 -3878,跨两页的表格则返回成一个列表。

我们在五份 PDF 上做了测试,其中两份是扫描件。所有直接读取 PDF 的模型都把 644 个单元格全部答对。测试中的失败都出在 PDF 到达模型所走的路径上,其中两次还返回了正常响应、没有报错。

要点速览

  • 为一行数据定义严格的 JSON schema,就能把 PDF 表格转成带类型的行;字段描述则负责清理括号负数、脚注标记这类内容。
  • 在我们的测试中,能读取 PDF 的模型既能处理数字文件,也能处理扫描件。不能读取 PDF 的模型处理扫描件时需要 mistral-ocr 解析器。
  • 扫描件经免费的 cloudflare-ai 解析器处理后返回零行且不报错,所以要拒绝空结果。
  • 长表格可能因输出 token 上限而中途截断,因此凡是 finish_reason 为“length”的响应都要拒绝。

开始之前

运行示例需要准备以下内容。

  • 一个已充值的 OpenRouter 账户,以及存放在 OPENROUTER_API_KEY 环境变量中的 API key。本指南用到的模型都是付费的。
  • Python 3.10 或更高版本,以及 requests 包(pip install requests)。
  • Node.js 18 或更高版本,但只有文末的 TypeScript 版本需要。
  • 一份包含表格的 PDF,可以是数字导出的,也可以是扫描件。

如果你之前没调用过这个 API,快速入门里演示了第一次请求。

代码对数字版 PDF 和扫描件都能用,但结果取决于 PDF 是否带文本层、扫描质量、解析路径和模型。示例用了五份测试 PDF,它们都由已知数据生成,因此每个单元格都能与源数据核对。这五份分别是:一张发票、一份季度报告、一份 58 行、跨到第二页的出货记录、一份扫描的采购订单,以及一份故意做得很粗糙、带合并单元格的扫描件。

第 1 步:如何发送 PDF 并拿回行数据?

把下面的代码保存为 extract_tables.py。它把 PDF 作为文件内容部分发送(做法见我们的 PDF 输入页面),并通过结构化输出要求返回符合你 schema 的 JSON。把 strict 设为 true 后,支持严格模式的提供商会让输出严格遵循 schema,于是每一行都包含全部字段且类型正确。各提供商的支持情况不一,结构化输出页面说明了如何只路由到支持该功能的提供商。

import base64
import json
import os

import requests

API = "https://openrouter.ai/api/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"}

def rows_schema(fields):
    """Wrap the fields of one row in a strict schema for {"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}}]

    response = requests.post(API, headers=HEADERS, json=body, timeout=300)
    response.raise_for_status()
    data = response.json()
    choice = data["choices"][0]
    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"]
    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"]

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"},
})

return 之前的两个检查用来捕获第 3 步和第 4 步中提到的失败情况,请保留它们。费用来自 usage.cost,用量统计会在每个响应中给出。

在发票上运行这个函数。

from extract_tables import extract_table, LINE_ITEMS

rows, cost = extract_table("invoice.pdf", LINE_ITEMS)
for row in rows[:3]:
    print(row)
print(f"{len(rows)} rows, ${cost:.5f}")
{'sku': 'NW-4100', 'description': 'Hex bolt M8x40', 'qty': 100, 'unit_price': 60.67, 'amount': 6067.0}
{'sku': 'NW-4107', 'description': 'Flange nut M8', 'qty': 2, 'unit_price': 41.72, 'amount': 83.44}
{'sku': 'NW-4114', 'description': 'Bearing 6204-2RS', 'qty': 10, 'unit_price': 52.59, 'amount': 525.9}
10 rows, $0.00067

这次调用耗时约六秒。PDF 上显示的是 6,067.00,而返回的行里是 6067.0,这个数字不用先清理就能直接相加。去掉千位分隔符的正是字段描述。

第 2 步:如何处理合并单元格、括号和脚注?

PDF 表格所需的大部分清理工作都可以写进 schema 的字段描述里。我们的季度报告中有一个“Fiscal 2026”表头,横跨四个季度列;亏损用括号表示;还有两个业务板块名称(Enterprise 和 Hardware resale)带脚注标记。字段描述负责处理括号和脚注标记。跨列表头不需要描述,因为字段名已经说明了每个值属于哪个季度。

from extract_tables import extract_table, rows_schema

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,
})

rows, cost = extract_table("quarterly.pdf", QUARTERLY)
for row in rows:
    print(row)
{'segment': 'Inference API', 'q1': 1813, 'q2': 7299, 'q3': 1612, 'q4': 4422, 'fy_total': 15146}
{'segment': 'Enterprise', 'q1': 1563, 'q2': 2981, 'q3': 5544, 'q4': 7667, 'fy_total': 17755}
{'segment': 'Data products', 'q1': 3163, 'q2': 9658, 'q3': 2729, 'q4': 5854, 'fy_total': 21404}
{'segment': 'Hardware resale', 'q1': 3761, 'q2': -3878, 'q3': -9774, 'q4': -1776, 'fy_total': -11667}
{'segment': 'Professional services', 'q1': 8933, 'q2': 9511, 'q3': 7805, 'q4': 5946, 'fy_total': 32195}
{'segment': 'Total operating income', 'q1': 19233, 'q2': 25571, 'q3': 7916, 'q4': 22113, 'fy_total': 74833}

在“硬件转售”这一行里,(3,878) 返回成了 -3878,而且两个片段名称里的脚注标记都不见了。

纵向合并的单元格也是同样的道理。在我们粗略的扫描中,每个 Category 单元格跨六到九行。我们对这个字段的描述是:“第一列中的类别。它是合并单元格,因此要在它覆盖的每一行上重复填写。”在我们的测试中,这一句说明就足以让三个能读 PDF 的模型在全部 30 行上都填好类别。

第 3 步:我该用哪条解析器路线?

如果模型能读文件,我们就把 PDF 直接交给它。如果模型不能读文件,我们先解析 PDF,再把结果发给模型。文件解析器插件的 engine 设置决定用哪个解析器。在不能读文件的模型上不设置它时,我们会使用 mistral-ocr,正如 PDF 输入 页面所述。根据你的 PDF 长什么样来选择路线。

  • 能读 PDF 的模型。 不设置 engine。Claude Haiku 5.5、GPT-6 Luna 和 Gemini 3.8 Flash 都能读文件,三个模型也都处理了我们那两份扫描件。PDF 按输入 token 计费。
  • 不能读 PDF 的模型,配合 OCR。 使用 engine="mistral-ocr",或者不设置它。它会先对每一页做光学字符识别(OCR),费用为每 1,000 页 $2,另加模型的 token 费用。
  • 不能读 PDF 的模型,配合免费解析器。 只有当每个 PDF 都有文本层时才使用 engine="cloudflare-ai"。导出的发票和报告有文本层,扫描件没有。

下面是把扫描版采购订单分别交给各解析器后、再发给 deepseek/deepseek-v4.1-flash 的结果。该模型接受图片但不接受文件,所以 PDF 必须先经过解析器。

from extract_tables import extract_table, LINE_ITEMS

# A model that can't read PDFs, with OCR in front of it
rows, cost = extract_table("scanned.pdf", LINE_ITEMS,
                           model="deepseek/deepseek-v4.1-flash", engine="mistral-ocr")
print(f"mistral-ocr: {len(rows)} rows, ${cost:.5f}")

# The same model with the free text parser
rows, cost = extract_table("scanned.pdf", LINE_ITEMS,
                           model="deepseek/deepseek-v4.1-flash", engine="cloudflare-ai")
mistral-ocr: 6 rows, $0.00211
ValueError: No rows came back. If this PDF is a scan, use a model that reads PDFs or engine='mistral-ocr'.

这个 ValueError 来自第 1 步的空行检查。没有这项检查,第二次调用会返回 200 OK,同时给出 finish_reason: "stop" 和 {"rows": []}。解析器在扫描件上没找到文字,模型也正确地报告了空表格。整个过程不会抛出任何错误,所以没有这项检查的流水线会存入零行数据,然后继续处理下一个文件。这是两种静默失败中的第一种。我们的粗略扫描表现相同,30 行里得了 0 分。

另外两种路线错误则会明确报错。引擎名称拼错会返回这个错误。

400 {"error":{"message":"plugins.0.pdf.engine: Invalid input","code":400}}

原生引擎只适用于能读文件的模型。当我们还是在 DeepSeek V4.1 Flash 上设置了 engine="native" 时,请求成功了一次,随后两次因提供商错误而失败。

400 Provider returned error: "The request was rejected. Possible causes: input exceeds the model's maximum context length, or the request contains invalid parameters."

在同一个模型上不设置 engine 时,我们试了两次都正常,所以不要在不能读文件的模型上设置 native。

第 4 步:我的长表格为什么返回时被截断了?

模型写出的每一行都要消耗输出 token,所以长表格需要较高的 max_tokens。我们那份 58 行的发货日志根据模型不同,用了 2,500 到 7,700 个输出 token,这让最啰嗦的模型在 extract_table 默认的 8,000 之下几乎没有余量。折算下来是每行 43 到 133 个 token。对于长表格,可以按每个预期行约 150 个 token 起步。然后测量你自己数据的每行用量,并保留 finish_reason 检查,因为具体数量会因模型和 schema 而异。下面是上限设得太低时会发生的情况。

from extract_tables import extract_table, rows_schema

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"},
})

rows, cost = extract_table("shipments.pdf", SHIPMENTS, max_tokens=600)
ValueError: Output stopped at max_tokens=600. Raise it or split the PDF.

没有 finish_reason 检查时,这个请求会以两种方式之一失败。普通 JSON 解析会在被截断的数组上抛出错误。

JSONDecodeError: Unterminated string starting at: line 1 column 627 (char 626)

第二种方式就是另一种静默失败。开启 Response Healing 插件后,JSON 能解析,你会得到 58 行中的 5 行,且不抛异常。Healing 修复的是输出的语法,而它的文档警告说,它无法恢复被 max_tokens 截断的输出,所以它补不出模型根本没写的那些行。finish_reason 检查能同时抓住这两种情况。

第 5 步:如何把行写入 CSV?

每一行都带有 schema 中定义的键,因此 csv.DictWriter 可以直接从 schema 取列名,并按原样写入各行。Excel 能直接打开结果文件。

import csv

from extract_tables import extract_table
# Define SHIPMENTS here, copied from step 4

rows, cost = extract_table("shipments.pdf", SHIPMENTS, model="openai/gpt-6-luna")
with open("shipments.csv", "w", newline="") as f:
    fieldnames = list(SHIPMENTS["properties"]["rows"]["items"]["properties"])
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(rows)
print(f"Wrote {len(rows)} rows to shipments.csv (${cost:.5f})")
Wrote 58 rows to shipments.csv ($0.00130)

date,shipment_id,origin,destination,weight_kg,status
2026-09-01,SHP-88200,Savannah,Valencia,8754.6,In transit
2026-09-01,SHP-88213,Busan,Los Angeles,2074.7,Customs hold

表格延伸到了第二页,表头也在那里重复出现。而 CSV 只有一个表头,包含全部 58 行。

该用哪个模型?

我们对每条路线都在全部五份 PDF 上各跑了一次,共六页、644 个单元格,并把每个单元格与生成这些 PDF 时所用的原始数据逐一比对。每次运行都使用上文中的 extract_table 函数和提示词,并采用我们的默认提供商路由。每 1,000 页的成本是按这六页的总 usage.cost 折算出来的。模型和价格来自我们截至 2026 年 10 月 8 日的模型目录。这些结果只描述本测试集,不代表模型的普遍表现。

Chart of five PDF table extraction routes on the same five PDFs, six pages and 644 cells. GPT-6 Luna: 644 of 644 cells correct, $0.77 per 1,000 pages, 10.1 seconds per PDF. Claude Haiku 5.5: 644, $1.00, 8.4 s. Gemini 3.8 Flash: 644, $8.04, 16.4 s. DeepSeek V4.1 Flash with mistral-ocr: 642, $3.09, 25.0 s. DeepSeek V4.1 Flash with cloudflare-ai: 434, $1.92, 21.3 s.

在这个测试集上,GPT-6 Luna 和 Claude Haiku 5.5 是成本最低、且每个单元格都正确的路线,连那张粗糙的扫描件也不例外,两者的标价都是每百万输入 token 0.10 美元。因此它们很适合作为在你自己的 PDF 上做的第一轮测试。Gemini 3.8 Flash 的准确率同样高,但在相同文件上每页成本要贵八到十倍。它的标价是每百万输入 token 1.50 美元,2026 年 10 月 21 日起打折至 0.75 美元。

OCR 路线的成本比 DeepSeek 的低 token 价格所暗示的要高,因为每 1,000 页 2 美元的 OCR 费用会加到每次请求上,这一点在 usage.cost 中可以看到。它的两处错误是 Enterprise 和 Hardware resale 上的脚注标记,尽管 schema 要求删除这些标记,它还是保留了。免费解析器把三份数字版 PDF 全部处理正确,但两份扫描件完全错误。

成本一列是按每条路线六页推算的。可以用它来比较各条路线,但在选定之前,请先用一个黄金评测集在你自己的 PDF 样本上测量准确率、时间和成本,因为页面越密集成本越高,其他版式也可能表现不同。

什么时候一个 PDF 库就够了?

如果你的 PDF 全是数字版,且每张表只有一个表头行,那么像 pdfplumber 这样的免费库就能找到表格。我们在同样的文件上运行了它。它找到了三份数字版 PDF 中的每一张表,并把每个单元格作为字符串返回,例如 ‘6,067.00’ 或 ‘(3,878)’。季度报告的两级表头返回成了两行,合并单元格里是 None;发货日志返回成了两张独立的表,每页一张。它在两份扫描件里什么都没找到,因为扫描件没有可读取的文本层。

所以,当你的表格简单、PDF 是数字版时,一个库就够了。一旦你要为合并表头、分页和数字格式编写清理代码,第 2 步的 schema 维护起来反而更省事。

TypeScript 中的同一请求

请求体在任何语言里都一样。把它保存为 extract.mjs,然后在 Node.js 18 或更高版本上用 node extract.mjs invoice.pdf 运行。

import { readFileSync } from "node:fs";
import { basename } from "node:path";

const path = process.argv[2];
const fileData = `data:application/pdf;base64,${readFileSync(path).toString("base64")}`;

const money = { type: "number", description: "USD, no currency symbol or thousands separator" };
const schema = {
  type: "object",
  properties: { rows: { type: "array", items: {
    type: "object",
    properties: { sku: { type: "string" }, description: { type: "string" },
                  qty: { type: "integer" }, unit_price: money, amount: money },
    required: ["sku", "description", "qty", "unit_price", "amount"],
    additionalProperties: false } } },
  required: ["rows"],
  additionalProperties: false,
};

const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
             "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "openai/gpt-6-luna",
    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: basename(path), file_data: fileData } },
    ] }],
    response_format: { type: "json_schema", json_schema: { name: "table", strict: true, schema } },
  }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
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, finish_reason=" + choice.finish_reason + ", $" + data.usage.cost);
10 rows, finish_reason=stop, $0.00020275

脚本会检查 HTTP 状态,然后执行与 Python 版本相同的两项检查。除非 finish_reason 为 “stop” 且 rows 至少有一行,否则它会中止。

结论

用 LLM 从 PDF 中提取表格只需一次请求,把 PDF 作为文件部分,并配上一个描述单行的严格 schema。在我们的测试中,准确率来自模型直接读取 PDF,而风险来自 PDF 所走的路线。对扫描件使用文本解析器、对长表格触及 token 上限,这两种情况返回的响应看起来都很正常。extract_table 中的两项检查能同时捕获这两种情况,所以无论你运行哪个版本的代码,都要保留它们。在确定一条路线之前,先用你自己的 PDF 样本跑一遍,比较行、时间和 usage.cost。

常见问题

大语言模型能读取扫描版 PDF 吗?

可以,前提是模型直接读取 PDF,或者你把它交给 mistral-ocr 解析器处理。三个能读 PDF 的模型都正确识别了我们提供的两份扫描件,DeepSeek V4.1 Flash 搭配 mistral-ocr 也一样。而像 cloudflare-ai 这样的文本解析器遇到扫描件会返回空结果,而且不报错,因为扫描件没有文本层。

在我们测试的六个页面上,按 2026 年 10 月的价格计算,GPT-6 Luna 折算下来约为每 1,000 页 0.77 美元,Claude Haiku 5.5 为 1.00 美元。经由 mistral-ocr 处理的模型还要额外支付每 1,000 页 2 美元的 OCR 费用。请查询当前价格,并根据自己最初的几次请求来阅读 usage.cost,因为表格密集的页面成本更高。

模型会编造出 PDF 里没有的行吗?

在我们对五份测试 PDF 做的 25 次提取中,没有发现编造出来的行,但这并不能排除在其他文档上出现这种情况。更大的风险是漏行,原因可能是输出在 token 上限处被截断,或者解析器没有找到文本。请把行数或小计与文档本身核对一下。

对于表头简单的数字版 PDF,pdfplumber 免费,而且在我们测试的数字版文件中找到了每一张表格。它把每个单元格作为文本返回,会把多级表头拆成单独的行,但在我们的扫描件中什么也没找到。Camelot 和 Tabula 是另外两个免费选项,不过本指南没有测试它们。

后续步骤

  • PDF 输入介绍了如何用 URL 而不是 base64 发送 PDF、解析器引擎及其价格,以及文件注解——它让你可以把解析结果回传,这样就不必为同一份 PDF 重复支付解析费用。

  • 结构化输出讲解了严格的 JSON schema,以及如何只路由到支持它们的提供商。

  • 预设把模型、系统提示词和提供商设置保存在同一个名称下。把提取指令写进预设的系统提示词,并在每个服务中调用 @preset/your-name,这样它们就都使用同一套配置。每次请求时仍要在 response_format 中发送 schema,因为预设文档并没有把它列入预设所保存的设置项。我们的预设指南详细介绍了配置步骤。

  • 如果要处理积压的 PDF,Batch API 会以异步方式运行同样的请求,通常 24 小时内完成。anthropic/claude-haiku-5.5:batch 和 openai/gpt-6-luna:batch 按标准每 token 价格的一半计费。批处理请求只接受以公开 URL 形式提供的 PDF,不接受 base64,所以要先托管文件。文件 URL 只在支持文件输入的模型上可用,因此第 3 步中 DeepSeek 搭配 mistral-ocr 的方案在批处理中不可用;在 OpenAI 模型上,文件 URL 只能通过 Responses API 使用。本指南没有对批处理运行进行测量。

  • 如果你在 LangChain 里构建这套流程,ChatOpenRouter 可以透传 PDF 输入,并支持配合 with_structured_output 使用严格的 JSON schema。

  • 如果你的源文档是图片而不是 PDF,如何向大语言模型发送图片介绍了图片内容部分。

来源:OpenRouter:Announcements · openrouter.ai