AI HOT 精选

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

7.1内容质量

TL;DR · AI 摘要

OpenRouter 教程:用 LLM 从 PDF ä¸­æŠ½å–è¡¨æ ¼ · AIHOT OpenRouter:Announcements · 2026-10-29 08:00 38 分钟前...

核心要点

  • 主题聚焦:OpenRouter 教程:用 LLM 从 PDF 中抽取表格
  • 来源:AI HOT 精选,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#前端#后端#云计算
打开原文

OpenRouter 教程:用 LLM 从 PDF ä¸­æŠ½å–è¡¨æ ¼ · AIHOT

OpenRouter:Announcements

·

2026-10-29 08:00

38 分钟前

精选

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ï¼Œå› æ­¤æ¯ä¸€è¡Œéƒ½æœ‰æ¯ä¸ªå­—æ®µä¸”ç±»åž‹æ­£ç¡®ã€‚æ”¯æŒæƒ å†µå› æä¾›å•†è€Œå¼‚ï¼Œç»“æž„åŒ–è¾“å‡ºé¡µé¢å±•ç¤ºäº†å¦‚ä½•ä» è·¯ç”±åˆ°æ”¯æŒå®ƒçš„æä¾›å•†ã€‚

code
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 , 用量统计 ä¼šåœ¨æ¯ä¸ªå“åº”ä¸­åŒ å«å®ƒã€‚

在发票上运行该函数。

code
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
}
"
)
code
{'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ï¼‰ä¸Šæœ‰è„šæ³¨æ ‡è®°ã€‚å­—æ®µæè¿°å¤„ç†æ‹¬å·å’Œè„šæ³¨æ ‡è®°ã€‚è·¨åˆ—è¡¨å¤´ä¸éœ€è¦æè¿°ï¼Œå› ä¸ºå­—æ®µåå·²ç»è¯´æ˜Žäº†æ¯ä¸ªå€¼å±žäºŽå“ªä¸ªå­£åº¦ã€‚

code
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)
code
{'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,再把结果发给模型。文件解析器插件的 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),在模型 token 费用之外,每 1,000 页收费 $2。
  • 不能读取 PDF çš„æ¨¡åž‹ï¼Œé åˆå è´¹è§£æžå™¨ã€‚ 只有当每个 PDF 都有文本层时才使用 engine="cloudflare-ai" 。导出的发票和报告有文本层。扫描件没有。

下面是把扫描的采购订单分别用每种解析器发送给 deepseek/deepseek-v4.1-flash 的结果。该模型接受图像但不接受文件,所以 PDF å¿ é¡»ç»è¿‡è§£æžå™¨ã€‚

code
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"
)
code
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 分。

另外两种路线错误会大声失败。拼错的引擎名称会返回这个错误。

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

原生引擎只适用于能读取文件的模型。当我们还是在 DeepSeek V4.1 Flash 上设置 engine="native" æ—¶ï¼Œè¯·æ±‚æˆåŠŸäº†ä¸€æ¬¡ï¼Œç„¶åŽä¸¤æ¬¡å› æä¾›å•†é”™è¯¯è€Œå¤±è´¥ã€‚

code
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 è€Œå¼‚ã€‚ä¸‹é¢æ˜¯é™åˆ¶å¤ªä½Žæ—¶ä¼šå‘ç”Ÿçš„æƒ å†µã€‚

code
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
)
code
ValueError: Output stopped at max_tokens=600. Raise it or split the PDF.

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

code
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 可以直接打开结果。

code
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
}
)"
)
code
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 ä»·æ ¼æ‰€æš—ç¤ºçš„æ°´å¹³ï¼Œå› ä¸ºæ¯ 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 运行它。

code
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);
code
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 而非 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 而非 base64 接收 PDFï¼Œå› æ­¤éœ€è¦å ˆæ‰˜ç®¡æ–‡ä»¶ã€‚æ–‡ä»¶ URL ä» åœ¨åˆ—å‡ºæ–‡ä»¶è¾“å ¥çš„æ¨¡åž‹ä¸Šå¯ç”¨ï¼Œå› æ­¤ç¬¬ 3 步中 DeepSeek é åˆ mistral-ocr 的方案在批处理中不可用,而在 OpenAI 模型上只能通过 Responses API ä½¿ç”¨ã€‚æˆ‘ä»¬åœ¨æœ¬æŒ‡å—ä¸­æ²¡æœ‰æµ‹é‡æ‰¹å¤„ç†è¿è¡Œæƒ å†µã€‚
  • å¦‚æžœä½ åœ¨ LangChain 中构建此功能, ChatOpenRouter ä¼šé€ä¼ PDF è¾“å ¥ï¼Œå¹¶æ”¯æŒä½¿ç”¨ with_structured_output çš„ä¸¥æ ¼ JSON schema。
  • å¦‚æžœä½ çš„æºæ–‡æ¡£æ˜¯å›¾ç‰‡è€Œéž PDF, 如何向 LLM 发送图片 ä»‹ç»äº†å›¾ç‰‡å† å®¹éƒ¨åˆ†ã€‚

来源: OpenRouter:Announcements · openrouter.ai

OpenRouter

多模态

教程实践

#

教程/实践