跳到正文
openrouter blog(openrouter.ai)·· 4 小時前精選AI 評分75

如何使用LLM從PDF提取表格

How to Extract Tables from a PDF with an LLM

AI 導讀

LLM 直接讀取 PDF 表格並以嚴格 JSON 模式輸出,開發者可即時驗證精確度與成本。

  • 支援格式 數位 PDF 與掃描檔,掃描需加 OCR(mistral‑ocr)或使用能讀檔的模型 - 成本 GPT‑6 Luna 與 Claude Haiku 5.5 以每 1。
推薦理由

本文示範如何使用LLM直接讀取PDF表格並以嚴格JSON模式輸出,讓開發者能即時驗證精確度與成本。

正文 · AI 翻譯

要用 LLM 從 PDF 擷取表格,請將 PDF 傳送至 能讀取檔案的模型,並附上描述表格每一列的 嚴格 JSON 架構。模型回傳已型別化的列。數量以整數回傳,寫成 (3,878) 的損失則回傳 -3878,跨兩頁的表格則回傳為一個列表。

我們在五份 PDF(其中兩份為掃描檔)上測試此方法。每個直接讀取 PDF 的模型都能正確取得全部 644 個儲存格。測試失敗的原因來自 PDF 送達模型的路徑,其中兩份回傳正常但無任何錯誤。

簡而言之

  • 一個針對單列的嚴格 JSON 架構能將 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 輸入 頁面,並透過 結構化輸出 要求回傳符合您架構的 JSON。若將 strict 設為 true,支援嚴格模式的供應商將輸出限制於該架構,確保每列皆有正確型別的欄位。支援度因供應商而異,結構化輸出頁面說明如何僅路由至支援此功能的供應商。

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 表格的大部分清理工作可放入架構的欄位說明。我的季報有一個跨四個季別欄位的「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 再將結果送給模型。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": []}。解析器在掃描檔中找不到文字,模型正確回報空表格。沒有任何錯誤被丟擲,因此未做檢查的管線會存入 0 列並繼續處理下一份檔案。這是兩次靜默失敗中的第一次。我們的粗略掃描亦同樣行為,並於 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."

在同一模型上保持引擎未設定時,我們嘗試兩次皆成功,因此不要在無法讀檔的模型上設定 native。

步驟 4:為什麼我的長表格被截斷?

模型寫出的每一列都消耗輸出 token,因此長表格需要較高的 max_tokens。我們的 58 列貨運記錄根據不同模型使用 2,500 至 7,700 個輸出 token,最詳細的模型在預設 8,000 的 extract_table 下僅剩餘很少空間。這相當於每列 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 會成功解析,且不丟擲例外,得到 5 行 58 列的資料。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 函式與 prompt,並採用我們的預設供應商路由。每 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 頁 OCR 的 $2 會加到每一次請求,且可在 usage.cost 中看到。它漏掉的兩個專案是 Enterprise 與 Hardware resale 的註腳標記,儘管 schema 要求移除,但它仍保留。免費解析器正確擷取了三份數位 PDF,但兩份掃描檔案完全錯誤。

成本欄位是根據每條路徑六頁推算的。請使用它比較路徑,然後在你自己的 PDF 範例上測量準確度、時間與成本,再選擇其中之一,並使用 golden eval set,因為較密集的頁面成本更高,其他版面設計可能表現不同。

何時使用 PDF library 就足夠?

若所有 PDF 均為數位檔且每張表格只有一個標題列,像 pdfplumber 這類免費 library 就能找到表格。 我們在同一份檔案上執行它。它在三份數位 PDF 中找到了所有表格,並將每個儲存格回傳為字串,例如 ‘6,067.00’ 或 ‘(3,878)’. 季度報告的兩層標題回傳為兩行,合併儲存格中包含 None,而運送日誌則回傳兩張獨立表格,每頁一張。它在兩份掃描檔案中找不到任何表格,因為掃描檔案沒有文字層可讀。

因此,當你的表格簡單且 PDF 為數位檔時,使用 library 就足夠。當你開始為合併標題、分頁與數字格式撰寫清理程式碼時,步驟 2 的 schema 會較少維護工作。

在 TypeScript 中使用相同的請求

請求主體在任何語言中皆相同。將此儲存為 extract.mjs,並使用 node extract.mjs invoice.pdf 在 Node.js 18 或更高版本執行。

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。

常見問題解答

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 的模型會額外收取每 1,000 頁 $2 的 OCR 費。請確認目前價格並閱讀 usage.cost,因為密集表格的費用會更高。

模型會不會編造不存在於 PDF 的列?

在對五份測試 PDF 進行 25 次提取時,我們未發現有編造列,但這並不代表其他檔案不會發生。遺失列的風險更大,可能因輸出被 token 限制截斷或解析器未偵測到文字。請將列數或小計與原始檔案做比對。

對於帶簡易標頭的電子 PDF,pdfplumber 是免費工具,能在我們的電子測試檔案中找出所有表格。它會將每個儲存格回傳為文字,將多層標頭拆分成獨立列,且在掃描檔案中未發現任何表格。Camelot 與 Tabula 亦為其他免費選項,但我們未在本指南中測試它們。

下一步

  • PDF 輸入 介紹如何以 URL 方式傳送 PDF 而非 base64,解析引擎及其價格,還有檔案註解,可讓你將解析結果回傳,避免重複為同一 PDF 付費。

  • 結構化輸出說明嚴格的 JSON 模式及如何僅將請求路由至支援它們的供應商。

  • 預設值將模型、系統提示與供應商設定儲存於同一名稱。將提取指令放入預設值的系統提示,並於每項服務呼叫 @preset/your-name,以確保皆使用相同設定。請於每次請求中繼續送出 response_format 中的 schema,因為預設值檔案未將其列為設定之一。我們的 預設值指南將帶你完成設定流程。

  • 若有大量 PDF,批次 API 可非同步執行相同請求,通常於 24 小時內完成。anthropic/claude-haiku-5.5:batch 與 openai/gpt-6-luna:batch 的費率為標準每 token 價格的一半。批次請求以公開 URL 形式接收 PDF,而非 base64,故需先將檔案託管。檔案 URL 僅適用於列出檔案輸入功能的模型;因此步驟 3 的 DeepSeek 搭配 mistral-ocr 路線不支援批次,OpenAI 模型亦只能透過 Responses API 使用。此指南未測試批次執行。

  • 若在 LangChain 內實作此功能,ChatOpenRouter 會處理 PDF 輸入,並支援使用 with_structured_output 的嚴格 JSON 模式。

  • 若來原始檔為影像而非 PDF,如何將影像送給 LLM 將說明影像內容的處理方式。

來源: openrouter blog (openrouter.ai)
開啟原文