PDF分页读取+AI逐页处理:Python开源脚本实现结构化JSON输出 PDF 文档处理这件事说简单也简单说坑也真不少。很多人第一次尝试把一份两三百页的技术手册、行业报告或者产品说明书丢给大模型期待它一口气总结出重点结果要么是接口直接报错要么是模型只读了前几页就开始胡编要么是上下文塞爆之后回答质量断崖式下跌。这个开源脚本要解决的就是这个最朴素也最要命的问题别让 AI 一口吞下一整本 PDF而是一页一页喂给它读完一页消化一页最后拼成完整的结构化结果。关键词里出现的 PDF、AI、开源脚本、Python、JSON基本勾勒出了它的全貌——用 Python 写一个分页读取的调度器把 PDF 逐页转成文本逐页调用 AI 接口把每页的返回结果落成 JSON最后合并输出。适合谁看如果你手头有大量 PDF 需要做摘要、信息抽取、字段结构化又不想被上下文长度卡死那这套思路值得你花时间吃透。1. 为什么整本 PDF 直接丢给 AI 基本行不通1.1 上下文窗口不是无限大的容器很多人对大模型的第一印象是它什么都能读但实际用起来会发现模型能接收的 token 数量是有硬上限的。一份 300 页的 PDF按每页 500 到 800 个中文字符估算纯文本就有 15 万到 24 万字换算成 token 轻松突破 20 万。这个量级已经超过了不少模型的单次输入上限即便某些模型宣称支持超长上下文实际使用中也会遇到两个问题一是费用按 token 计费一次性塞进去成本高得离谱二是模型在超长上下文里的注意力会衰减中间部分的信息容易被忽略也就是常说的lost in the middle现象。我实测过把一份 180 页的产品手册整段丢进去问它第 90 页附近的一个参数表模型要么答非所问要么把前后页的内容混在一起编。这不是模型笨是输入方式本身就不合理。分页读取的核心逻辑就是把一次性大输入拆成多次小输入每次只让模型专注处理一页内容注意力集中准确率自然上去了。1.2 PDF 本身就不是纯文本解析这一步就有坑PDF 格式的设计初衷是所见即所得的排版固化它记录的是每个字符、每条线、每张图在页面上的坐标位置而不是像 Word 那样有清晰的段落和标题结构。这就导致直接用文本提取库读出来的内容经常出现段落断裂、表格错位、页眉页脚混入正文、多栏排版被读成一行等问题。举个典型场景一份双栏排版的学术论文用普通提取方式读出来左右两栏的文字会交替穿插读起来前言不搭后语。再比如带表格的财务报表提取后数字和表头完全对不上。所以分页读取脚本里PDF 解析这一环必须选对工具并且针对不同版式做预处理。常见的组合是pdfplumber处理表格和复杂版式PyMuPDF也就是 fitz处理速度快、对中文支持好pypdf则适合做页面级的拆分和合并。选哪个取决于你的 PDF 类型后面会详细对比。1.3 分页读取到底解决了什么把这两个问题合起来看分页读取脚本的价值就清晰了它把解析和理解两个阶段解耦。解析阶段负责把 PDF 每一页可靠地转成文本理解阶段负责把每一页文本交给 AI 处理并拿到结构化结果。两个阶段各自独立任何一环出问题都能单独排查而不是像整本丢进去那样出了错根本不知道是解析错了还是模型理解错了。更重要的是分页之后每页的结果可以单独存成 JSON这意味着你可以断点续跑。跑到第 150 页程序崩了不用从头再来直接从第 151 页继续。对于动辄几百页的文档这个特性省下的时间和接口费用是实打实的。2. 脚本的整体架构解析、调度、落盘三段式2.1 三个模块各管一摊这套脚本我习惯拆成三个独立模块彼此通过中间数据衔接耦合度低改起来方便。第一个是解析模块输入是 PDF 文件路径输出是一个列表列表里每个元素是一页的文本内容同时带上页码。这个模块只关心把 PDF 变成干净的文本不碰任何 AI 相关的东西。第二个是调度模块它拿着解析出来的页面列表逐页构造提示词调用 AI 接口拿到返回结果。这里要处理重试、限速、超时、失败记录等一堆工程问题。第三个是落盘模块把每页的 AI 返回结果写进 JSON 文件一页一条记录包含页码、原始文本摘要、AI 输出、处理状态、时间戳等字段。最后再有一个合并步骤把所有单页结果拼成一份完整文档。这样拆的好处是解析模块可以单独测试你拿一份 PDF 跑一下人工翻几页看看提取质量没问题再往下走。调度模块可以先用假数据跑通流程确认重试和落盘逻辑没问题再接真实接口。工程上这种先打通管道再灌数据的思路能帮你省掉大量调试时间。2.2 数据在模块之间怎么流动用一个具体的数据结构来说明会更清楚。解析模块输出的每一页我建议用这样的字典结构{ page_num: 12, text: 第十二页的完整文本内容……, char_count: 634, has_table: False }page_num是页码从 1 开始方便和原文档对照。text是提取出来的正文。char_count记录字符数用来判断这一页是不是空页或者图片页。has_table是个标记位如果这一页检测到表格调度模块可以换用不同的提示词让 AI 按表格结构来解析。调度模块处理完一页后输出变成{ page_num: 12, status: success, ai_output: { summary: 本页主要讲……, key_points: [要点一, 要点二], entities: [实体A, 实体B] }, raw_response: 模型原始返回文本, retry_count: 0, timestamp: 2025-01-15T10:23:45 }status标记这一页是成功、失败还是跳过。ai_output是解析后的结构化结果raw_response保留原始返回方便出问题时回溯。retry_count记录重试了几次。这套字段设计看起来啰嗦但真到了排查阶段每一个字段都可能救你一命。2.3 为什么用 JSON 而不是数据库有人会问几百页的数据为什么不直接存数据库。我的经验是对于这种一次性或低频的文档处理任务JSON 文件比数据库更合适。原因有三一是零依赖不用装数据库、不用建表、不用管连接二是可读性强出问题直接打开文件就能看不用写查询语句三是便于版本管理和分享处理结果可以直接发给同事或者存档。当然如果你要处理成百上千份 PDF那确实该上数据库。但单份文档、几十到几百页这个量级JSON 完全够用而且更轻便。落盘时建议一个文档一个目录目录里放一个pages/子目录存单页结果再加一个merged.json存合并后的完整结果结构清晰。3. PDF 解析工具的选型对比与实操3.1 三款主流库的适用场景Python 生态里处理 PDF 的库不少但真正能打的就那几个。我把常用的三款拉出来做个对比方便你按自己的文档类型选。工具库安装包名中文支持表格处理速度适用场景PyMuPDFpymupdf优秀一般极快纯文本为主、页数多、追求速度pdfplumberpdfplumber良好优秀较慢表格多、版式复杂、精度优先pypdfpypdf一般弱快页面拆分合并、简单文本提取选型的核心判断标准是你的 PDF 里表格多不多、版式复杂不复杂。如果是一份纯文字的报告PyMuPDF 是最优解速度快到飞起几百页几秒钟就提取完了。如果是一份满是财务报表的文档那 pdfplumber 的表格识别能力值得你忍受它慢一些的速度。pypdf 我一般只用来做页面级的操作比如把一份大 PDF 拆成单页文件或者合并多个 PDF。3.2 PyMuPDF 提取中文文本的实操细节PyMuPDF 的安装很简单pip install pymupdf提取单页文本的核心代码是这样import fitz def extract_page_text(pdf_path, page_num): doc fitz.open(pdf_path) page doc[page_num] text page.get_text(text) doc.close() return text这里有个细节要注意get_text()的参数有几种模式text是纯文本blocks会返回带坐标的文本块dict返回最详细的结构信息。如果你只是要文本内容用text就够了。但如果发现提取出来的文字顺序乱了比如双栏排版读串了那就得用blocks模式拿到每个文本块的坐标按坐标重新排序。我处理过一份双栏排版的行业报告直接用text模式提取左右栏内容交替出现完全没法读。后来改用blocks模式按文本块的 x 坐标判断它属于左栏还是右栏先读左栏再读右栏问题就解决了。这个排序逻辑大概是这样def extract_two_column_text(page): blocks page.get_text(blocks) # blocks 里每个元素是 (x0, y0, x1, y1, text, block_no, block_type) mid_x page.rect.width / 2 left_blocks [b for b in blocks if b[0] mid_x] right_blocks [b for b in blocks if b[0] mid_x] left_blocks.sort(keylambda b: b[1]) right_blocks.sort(keylambda b: b[1]) left_text \n.join(b[4] for b in left_blocks) right_text \n.join(b[4] for b in right_blocks) return left_text \n right_text这段代码先按 x 坐标把文本块分成左右两组再各自按 y 坐标从上到下排序最后拼接。实测下来双栏文档的可读性提升非常明显。3.3 表格页面的特殊处理表格是 PDF 解析里最难啃的骨头。PyMuPDF 对表格的支持一般提取出来经常是错位的。这时候 pdfplumber 就派上用场了import pdfplumber def extract_tables(pdf_path, page_num): with pdfplumber.open(pdf_path) as pdf: page pdf.pages[page_num] tables page.extract_tables() return tablesextract_tables()返回的是一个嵌套列表每个表格是一个二维数组行和列都对齐好了。拿到这个结构后你可以把它转成 Markdown 表格或者 JSON再交给 AI 处理。AI 对结构化的表格数据理解能力比一堆错位的文本强得多。我的做法是先用 PyMuPDF 快速提取全文同时用 pdfplumber 检测哪些页面有表格。对于有表格的页面用 pdfplumber 单独提取表格数据替换掉 PyMuPDF 提取的那部分文本。这样兼顾了速度和精度。注意pdfplumber 处理大文件时内存占用较高建议逐页打开而不是一次性加载整个文档。处理完一页就释放避免几百页的文档把内存吃满。4. 逐页调用 AI 的调度逻辑与提示词设计4.1 调度器的核心循环调度模块的骨架就是一个循环遍历每一页调用 AI处理返回落盘。听起来简单但工程细节全在异常处理上。下面是我常用的调度循环结构import json import time from pathlib import Path def process_pages(pages, output_dir, ai_client, max_retries3): output_dir Path(output_dir) output_dir.mkdir(parentsTrue, exist_okTrue) for page in pages: page_num page[page_num] result_file output_dir / fpage_{page_num:04d}.json # 断点续跑已处理过的页面直接跳过 if result_file.exists(): print(f第 {page_num} 页已处理跳过) continue result process_single_page(page, ai_client, max_retries) with open(result_file, w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2) # 限速避免触发接口频率限制 time.sleep(1)这里有几个关键设计。第一是断点续跑每页处理完立刻落盘下次运行先检查文件是否存在存在就跳过。这个逻辑让脚本可以随时中断随时恢复处理几百页文档时特别有用。第二是限速time.sleep(1)是保守做法具体间隔取决于你用的接口的频率限制有的接口允许每秒多次调用有的每分钟才几次得按实际情况调。第三是单页独立每一页的处理完全独立一页失败不影响其他页失败的那页单独重试就行。4.2 提示词要针对单页场景设计整本丢给 AI 和单页丢给 AI提示词的写法完全不同。整本处理时你会说总结这份文档的核心内容但单页处理时模型只看得到一页你得告诉它这一页在整份文档里的位置以及你要它输出什么结构。我常用的单页提示词模板是这样的你正在处理一份 PDF 文档的第 {page_num} 页共 {total_pages} 页。 请阅读以下这一页的内容完成三件事 1. 用一句话概括本页主题 2. 提取本页的 3 到 5 个关键要点 3. 列出本页出现的所有专有名词、数字、日期等实体信息。 请严格按以下 JSON 格式返回不要添加任何额外说明 { summary: 本页主题, key_points: [要点1, 要点2], entities: [实体1, 实体2] } 本页内容如下 {page_text}这个模板有几个讲究。明确页码和总页数让模型知道自己在文档中的位置有助于它判断内容的上下文关系。限定输出结构用 JSON 格式约束方便后续程序解析。明确任务数量比如3 到 5 个关键要点避免模型输出过多或过少。强调不要额外说明因为很多模型喜欢在 JSON 前后加一句好的以下是结果这会破坏 JSON 的可解析性。4.3 让模型返回稳定 JSON 的几个技巧让模型稳定输出可解析的 JSON是这套脚本能不能跑通的关键。我踩过的坑包括模型返回的 JSON 里带了注释、用了单引号、字段名大小写不一致、字符串里没转义引号等等。解决办法有这么几个。第一在提示词里明确要求只返回 JSON不要用 markdown 代码块包裹。有些模型默认会把 JSON 放在json里你得明确禁止。第二拿到返回后做一次清洗。用正则把可能的代码块标记去掉再尝试解析import re import json def parse_ai_json(raw_text): # 去掉可能的 markdown 代码块标记 cleaned re.sub(rjson\s*|\s*, , raw_text).strip() try: return json.loads(cleaned) except json.JSONDecodeError: # 尝试提取第一个完整的 JSON 对象 match re.search(r\{.*\}, cleaned, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: return None return None第三如果解析失败把原始返回存下来标记这一页为解析失败后续可以人工检查或者换个提示词重试。千万不要因为一页解析失败就让整个脚本崩掉。第四如果用的是支持结构化输出的接口优先用接口层面的 JSON 模式比靠提示词约束可靠得多。不同接口的开启方式不一样具体看文档。5. 结果合并与 JSON 数据的后续利用5.1 把单页结果拼成完整文档所有页面处理完后合并步骤就是把pages/目录下的单页 JSON 按页码顺序读出来拼成一个完整的结构def merge_results(output_dir): output_dir Path(output_dir) page_files sorted(output_dir.glob(page_*.json)) merged { total_pages: len(page_files), pages: [] } for pf in page_files: with open(pf, r, encodingutf-8) as f: merged[pages].append(json.load(f)) with open(output_dir / merged.json, w, encodingutf-8) as f: json.dump(merged, f, ensure_asciiFalse, indent2) return merged合并后的merged.json是一个包含所有页面结果的数组每页一条记录。这个结构的好处是你可以很方便地做后续处理比如按关键词搜索、按实体聚合、生成全文摘要等。5.2 JSON 结果的几种典型用法拿到结构化的 JSON 之后能做的事情就多了。第一种是全文摘要把所有页的summary字段拼起来再交给 AI 做一次汇总得到整份文档的摘要。因为每页摘要已经很精炼了这次汇总的输入量很小不会超上下文。第二种是信息抽取比如你要从一份合同 PDF 里提取所有金额和日期那就遍历所有页的entities字段筛选出数字和日期类型汇总成一张表。第三种是全文检索把每页的key_points建成索引用户搜索某个关键词时直接定位到相关页面不用重新读 PDF。第四种是数据校验对比不同页的实体信息检查有没有前后矛盾的地方。比如第 10 页说合同金额是 100 万第 50 页说 120 万这种矛盾在人工阅读时容易漏掉但程序一对比就出来了。5.3 JSON 文件的打开与查看很多人拿到 JSON 文件不知道怎么打开。其实 JSON 就是纯文本用任何文本编辑器都能看只是格式不直观。推荐几个查看方式VS Code 打开后按ShiftAltF格式化瞬间变清晰或者用在线的 JSON 格式化工具粘贴进去就能看树形结构再或者用 Python 的json.tool模块python -m json.tool merged.json这个命令会把 JSON 格式化后输出到终端适合快速查看。如果 JSON 文件很大终端刷屏可以重定向到文件python -m json.tool merged.json formatted.json。6. 实测中踩过的坑与性能优化6.1 空页和图片页的处理不是每一页都有文字。扫描件 PDF 的每一页都是图片纯文本提取出来是空的。带大量图表的页面文字可能只有图注。这些页面如果直接丢给 AI模型会收到空内容或者极少内容返回的结果要么是本页无内容要么是胡乱编造。我的处理方式是在解析阶段就统计每页的字符数低于某个阈值比如 50 个字符的页面标记为低内容页。对于这类页面有两种策略一是直接跳过在结果里标记为跳过二是如果这一页确实重要比如是关键的图表页那就走 OCR 流程把图片转成文字再处理。OCR 这块如果 PDF 是扫描件可以用pytesseract配合pdf2image先把 PDF 页面转成图片再对图片做 OCR。中文 OCR 需要装中文语言包识别率取决于扫描质量。这一步比较重建议只对必要的页面做不要全篇 OCR。6.2 接口限速与并发控制处理几百页文档如果串行调用 AI一页一秒三百页就是五分钟这还是理想情况。如果接口有频率限制比如每分钟 20 次那三百页得跑十五分钟。想加速就得用并发但并发又会触发限速。我的经验是先摸清接口的限制。大多数接口会在文档里写明每分钟或每秒的调用上限。然后设置一个略低于上限的并发数比如限制是每分钟 60 次那就设成每秒 1 次留点余量。用concurrent.futures的线程池来控制并发from concurrent.futures import ThreadPoolExecutor, as_completed def process_with_concurrency(pages, ai_client, max_workers3): results [] with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {executor.submit(process_single_page, p, ai_client): p for p in pages} for future in as_completed(futures): page futures[future] try: result future.result() results.append(result) except Exception as e: print(f第 {page[page_num]} 页处理失败{e}) return resultsmax_workers设成 3 到 5 比较稳妥太高容易触发限速反而拖慢整体进度。而且并发写文件要注意加锁或者干脆让每个线程写自己的文件最后统一合并避免文件写入冲突。6.3 成本控制的几个实用手段按 token 计费的接口处理几百页文档的成本不容忽视。控制成本有几个手段。第一解析阶段就过滤掉无意义的页面比如目录页、版权页、空白页这些页面内容少处理它们纯属浪费。第二压缩提示词提示词本身也占 token能精简就精简但别精简到影响输出质量。第三用便宜的模型做初筛贵的模型做精处理比如先用小模型判断这一页值不值得深入处理值得的再交给大模型。第四缓存结果同一份文档不要重复处理断点续跑机制本身就是一种缓存。我算过一笔账一份 300 页的文档每页平均 600 字加上提示词大概 800 token总共 24 万 token。如果用中等价位的模型成本大概在几块到十几块之间。如果整本一次性丢进去光是输入就 20 多万 token而且效果还不好。分页处理虽然调用次数多但每次输入小总 token 量其实差不多甚至因为过滤了无用页面而更少。6.4 处理失败页面的重试策略失败是常态关键是怎么优雅地处理失败。我的策略是分级重试第一次失败后等 2 秒重试第二次失败后等 5 秒重试第三次失败后等 10 秒重试。三次都失败就把这一页标记为最终失败记录下来等所有页面处理完后单独把失败页面列出来人工检查原因。失败的原因通常有几类网络超时、接口返回错误、返回内容无法解析成 JSON、内容触发了安全过滤。前两类重试通常能解决后两类重试也没用得换策略。返回无法解析的可以换个提示词重试触发安全过滤的得看看内容是不是有什么敏感表述这个要具体分析。def process_single_page(page, ai_client, max_retries3): delays [2, 5, 10] for attempt in range(max_retries): try: response ai_client.chat(page[text]) parsed parse_ai_json(response) if parsed: return { page_num: page[page_num], status: success, ai_output: parsed, raw_response: response, retry_count: attempt } except Exception as e: print(f第 {page[page_num]} 页第 {attempt1} 次尝试失败{e}) if attempt max_retries - 1: time.sleep(delays[attempt]) return { page_num: page[page_num], status: failed, ai_output: None, raw_response: None, retry_count: max_retries }这段代码把重试逻辑封装在单页处理函数里调度层不用关心重试细节职责清晰。7. 从单文档到批量处理的扩展思路7.1 批量处理时的目录结构设计当你需要处理几十上百份 PDF 时目录结构就得重新设计。我的做法是按文档分目录每个文档目录下再分pages/和merged.jsonoutput/ ├── doc_001/ │ ├── pages/ │ │ ├── page_0001.json │ │ └── ... │ └── merged.json ├── doc_002/ │ └── ... └── index.jsonindex.json记录所有文档的处理状态包括文档名、总页数、已处理页数、成功页数、失败页数、处理时间等。这样一眼就能看出哪些文档处理完了哪些还在跑哪些有问题。7.2 增量处理与去重批量处理时最怕重复处理已经处理过的文档。解决办法是给每份文档算一个哈希值基于文件内容算记录在index.json里。处理前先查哈希如果这份文档处理过且内容没变直接跳过。如果内容变了比如更新了版本那就重新处理。import hashlib def file_hash(file_path): h hashlib.md5() with open(file_path, rb) as f: for chunk in iter(lambda: f.read(8192), b): h.update(chunk) return h.hexdigest()用 MD5 就够了不需要 SHA256这里只是做去重判断不涉及安全场景。7.3 结果的可视化与检索处理完一批文档后JSON 结果散落在各个目录里想找某个信息就得一个个翻。这时候可以做一个简单的检索层把所有merged.json里的key_points和entities抽出来建一个倒排索引记录每个词出现在哪些文档的哪些页面。用 Python 的字典就能实现不需要上 Elasticsearch 这种重型工具。def build_index(output_root): index {} for merged_file in Path(output_root).glob(*/merged.json): doc_name merged_file.parent.name with open(merged_file, r, encodingutf-8) as f: data json.load(f) for page in data[pages]: if page[status] ! success: continue for point in page[ai_output].get(key_points, []): for word in extract_keywords(point): index.setdefault(word, []).append({ doc: doc_name, page: page[page_num] }) return indexextract_keywords可以用简单的分词也可以用关键词提取算法。有了这个索引搜索某个词就能立刻定位到相关文档和页面比重新读 PDF 快无数倍。7.4 这套思路还能用在哪些场景分页读取加结构化落盘的思路不只适用于 PDF 摘要。任何长文档 AI 处理的场景都能套用。比如处理长篇合同做条款抽取处理学术论文集做文献综述处理产品手册做 FAQ 生成处理会议纪要做行动项提取。核心逻辑都是一样的把长文档拆成可管理的单元逐个处理结构化落盘最后合并。甚至不限于 PDFWord 文档、网页内容、邮件列表只要是长文本都能用这套分页或分段处理的框架。区别只在于解析模块换一下调度和落盘逻辑基本可以复用。我在实际使用中最大的体会是不要指望一次调用解决所有问题把任务拆小、把流程拆开、把结果落盘才是稳定可靠的工程做法。分页读取脚本看起来朴素但它解决的是最本质的问题让 AI 在能力范围内工作而不是逼它做做不到的事。最后再分享一个小技巧处理前先用脚本统计一下 PDF 的总页数和每页字符数分布如果发现某些页面字符数异常少提前标记出来能省下不少接口调用。