OpenRouter 教程:如何用大语言模型从 PDF 提取表格
How to Extract Tables from a PDF with an LLM
- 1OpenRouter 教程用一次请求提取 PDF 表格,PDF 加描述单行的严格 JSON schema 即返回带类型的行
- 2官方在 5 份 PDF 共 644 格上测试(含 2 份扫描件),能直接读 PDF 的模型全部答对
- 3测试失败全部来自解析路径,扫描件配免费解析器和长表格触顶这两次失败均无报错
- 4GPT-6 Luna 与 Claude Haiku 5.5 全对且最便宜,按测试折算每千页约 $0.77 与 $1.00
表格抽取的准确率问题在能读文件的模型上已基本解决,剩下的风险集中在解析路径和输出截断两处工程环节。
- 影响:发票、季报、货运单这类表格的录入可先按每千页 $0.77 到 $1.00 估算成本;扫描件和超长表格存在返回空结果或半张表却不报错的情况,接入自动管线前需要设防。
- 参考:可考虑选一类格式固定的 PDF 先跑几十份样本,用行数或小计核对结果并记录 usage.cost;值得在代码里保留 finish_reason 和空行两项检查。
- 依据:官方在自建的 5 份 PDF 共 644 格上测试,能读文件的模型全对,属官方口径且只代表该测试集,暂无独立验证。
要从 PDF 中用 LLM 提取表格,请把 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 密钥。本指南中的模型均为付费模型。 - 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}在 Hardware resale 行中,(3,878) 返回成了 -3878,而且两个分部名称中的脚注标记都不见了。
沿列纵向合并的单元格也是同样的道理。在我们的粗略扫描中,每个 Category 单元格跨六到九行。我们将该字段描述为“第一列中的类别。它是一个合并单元格,因此在其覆盖的每一行上都重复该值。”在我们的测试中,这样的描述就足以让所有三个能读取 PDF 的模型在全部 30 行中填入类别。
第 3 步:我该使用哪种解析器路线?
如果模型能读取文件,我们直接把 PDF 传给它。如果不能,我们就先解析 PDF,再把结果发给模型。file-parser 插件的 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 检查,因为计数因模型和架构而异。下面是限制过低时的情形。
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?
每一行都包含架构中定义的键,因此 csv.DictWriter 可以直接从架构中获取列名并按原样写入各行。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 日的我们的模型目录。这些结果仅描述这一测试集,不代表模型的整体性能。

在这一测试集上,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 价格给人的印象,因为 OCR 的每 1,000 页 2 美元费用会附加到每个请求中,你可以在 usage.cost 中看到这一点。它的两处错误是 Enterprise 和 Hardware resale 上的脚注标记,尽管架构要求将其移除,它仍然保留了这些标记。免费的解析器在全部三个数字 PDF 上都得到了正确结果,但在两份扫描件上则完全错误。
成本列是由每条路径六页的数据外推得出的。请用它来比较各条路径,然后在选择之前,使用 黄金评估集 在你自己的 PDF 样本上测量准确率、时间和成本,因为内容更密集的页面成本更高,其他版式可能表现不同。
什么时候 PDF 库就够了?
如果你的所有 PDF 都是数字版,且每个表格只有一行表头,那么像 pdfplumber 这样的免费库就能找到表格。我们在相同的文件上运行了它。它在三个数字 PDF 中找到了每个表格,并将每个单元格作为字符串返回,例如 '6,067.00' 或 '(3,878)'。季度报告的两级表头以两行的形式返回,合并单元格中为 None,而发货日志则作为两个独立的表格返回,每页一个。它在两份扫描件中什么都没找到,因为扫描件没有可供读取的文本层。
因此,当你的表格简单且 PDF 为数字版时,一个库就足够了。一旦你需要为合并表头、分页符和数字格式编写清理代码,步骤 2 中的架构维护起来就更省事。
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 作为文件部分,外加一个描述单行的严格架构。在我们的测试中,准确率来自模型直接读取 PDF,而风险则来自 PDF 所经过的路径。对扫描件使用文本解析器,以及对长表格设置 token 上限,都返回了看似正常的响应。extract_table 中的两项检查能捕捉这两种情况,因此无论你运行哪个版本的代码,都要保留它们。在确定使用某条路径之前,先用你自己的 PDF 样本跑一遍,并对比行内容、耗时和 usage.cost。
常见问题
LLM 能读取扫描版 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 调用模型时,OCR 每 1,000 页额外收费 $2。请查看当前价格,并从你自己的首批请求中读取 usage.cost,因为密集表格的费用更高。
模型会编造 PDF 中不存在的行吗?
在对五个测试 PDF 进行的 25 次提取中,我们没有看到编造的行,但这并不能排除在其他文档上出现这种情况。缺行是更大的风险,原因包括输出在 token 限制处被截断,或解析器未找到文本。请将行数或小计与原文档进行核对。
对于带简单表头的数字版 PDF,pdfplumber 免费,并且在我们数字版测试文件中找到了每一个表格。它将每个单元格作为文本返回,把多级表头拆分成单独的行,而在我们的扫描件中一无所获。Camelot 和 Tabula 是其他免费选项,但本指南未对它们进行测试。
后续步骤
-
PDF 输入 介绍通过 URL 而非 base64 发送 PDF、解析引擎及其价格,以及文件注解,它让你可以把解析结果回传,从而不必为同一个 PDF 重复付费解析。
-
结构化输出 介绍严格的 JSON schema,以及如何只路由到支持它们的提供商。
-
预设 用一个名称保存模型、系统提示词和提供商设置。把提取指令放进预设的系统提示词,并从每个服务调用
@preset/your-name,这样它们就都使用同一份配置。每次请求仍要在response_format中发送 schema,因为预设文档并未把 schema 列入预设可保存的设置之中。我们的 预设指南 逐步讲解了相关设置。 -
对于积压的 PDF,Batch API 会异步运行同样的请求,通常在 24 小时内完成。
anthropic/claude-haiku-5.5:batch和openai/gpt-6-luna:batch按标准每 token 价格的一半计费。批量请求以公共 URL 而非 base64 的形式接收 PDF,因此需要先托管文件。文件 URL 只在列出文件输入的模型上可用,所以步骤 3 中 DeepSeek 配合 mistral-ocr 的路线无法用于批量处理,而在 OpenAI 模型上文件 URL 只能通过 Responses API 使用。本指南未对批量运行进行测量。 -
如果你在 LangChain 中构建此功能,ChatOpenRouter 可以透传 PDF 输入,并通过
with_structured_output支持严格的 JSON schema。 -
如果你的源文档是图片而非 PDF,如何向 LLM 发送图片 介绍了图片内容部分。
来源:OpenRouter:Blog(Announcements) · openrouter.ai