构建自己的PDF转换智能体
现实世界的文档可能混乱且难以处理。它们通常有多栏布局、扫描页面、手写笔记、图表和深度嵌套的表格。固定的处理流水线无法处理每个边缘情况。
最近,PDF转Markdown转换工具采用了AI智能体的概念。示例包括LandingAI的ADE和LlamaParse的智能体层级。关键思想是将现有的文档处理工具与AI智能体的灵活推理能力相结合。
为AI智能体提供正确的工具并让LLM决定如何进行,可以提高灵活性,从而改善生成的Markdown的准确性和质量。
在本文中,与其付费使用LandingAI或LlamaIndex,我们将创建自己的智能体PDF转Markdown流水线。
首先,我们将使用Docling提取PDF结构和初始Markdown。然后,我们将使用CrewAI构建一个AI智能体来改进Docling的输出。这个项目受到我最近关于LandingAI的ADE [1]的文章启发。
1、Docling的PDF流水线
Docling是一个开源文档转换工具包。它可用于将PDF转换为Markdown。Docling通过运行包含多个步骤和专门AI模型的PDF流水线来实现这一点。
该流水线包括文本的光学字符识别(OCR)、文档布局分析和表格结构识别 [2]。
作为替代方案,你可以尝试PaddleOCR 3的PP-StructureV3而不是Docling。
2、将Docling的原始输出蒸馏为要点
原始的DoclingDocument包含大量数据,因此我编写了一个实用函数,只保留最基本的信息:元素type、page、confidence和bbox。
from docling.document_converter import ConversionResult, DocumentConverter
def reduce_docling_doc(doc: ConversionResult) -> list[dict]:
"""Extract important elements from a DoclingDocument."""
elements = []
for page in doc.pages:
page_no = page.page_no
assembled = getattr(page, "assembled", None)
if not assembled:
continue
for el in assembled.elements:
item = {}
item["id"] = f"p{page_no}_id{el.id}"
item["type"] = str(getattr(el.label, "value", el.label)).lower()
item["page"] = page_no
cluster = getattr(el, "cluster", None)
item["confidence"] = getattr(cluster, "confidence", None)
bbox = getattr(cluster, "bbox", None)
if bbox:
item["bbox"] = [
round(bbox.l, 2),
round(bbox.t, 2),
round(bbox.r, 2),
round(bbox.b, 2),
]
coord_origin = getattr(bbox, "coord_origin", None)
item["coord_origin"] = getattr(coord_origin, "value", coord_origin)
text = getattr(el, "text", None) or " ".join(
c.text for c in getattr(cluster, "cells", []) if c.text
)
item["text"] = (text or "").strip()
elements.append(item)
return elements
# converter = DocumentConverter()
# doc = converter.convert("/path/to/document.pdf")
# result = reduce_docling_doc(doc)
文档布局分析步骤的结果是检测文档中的感兴趣区域及其边界框并进行分类。有了这些信息,我们稍后可以"放大"重要区域。
对于我的测试文档,我将使用一个简单的PDF,我之前也用它来对其他PDF转Markdown工具进行基准测试 [3]。
3、检查Docling的布局分析输出
下面是当type、confidence和bbox变量在PDF页面上可视化时,PDF文件的布局分析结果示例。
总体而言,Docling做得不错,但有一些错误。
首先,第一页上的代码块错误地将Python和Bash代码合并在一起。在第二页上,两个表格被错误地检测为一个块。
4、审查Docling的初始Markdown输出
这是我们运行Docling的doc.document.export_to_markdown()函数时得到的Markdown输出:
## Lorem Ipsum
Lorem ipsum dolor sit amet.
Consetetur sadipscing elitr.
Sed diam nonumy eirmod tempor.
## Lorem Ipsum
Lorem ipsum . Lorem ipsum . Lorem ipsum . Lorem ipsum.
## Lorem Ipsum
Lorem.
Lorem ipsum.
Lorem ipsum dolor.
Lorem ipsum.
1. Lorem
3. Lorem ipsum dolor
2. Lorem ipsum
- Lorem
- Lorem ipsum dolor
- Lorem ipsum
Before something
After something
Test: click.
Another test: mail@example.com
Let's try this:
def add_integer(a: int, b: int) -> int: return a + b And also this: curl -o thatpage.html http://www.example.com/
Let's try this:
| | Hello | To | Medium |
|----------------|---------|-------|----------|
| | Q | W | E |
| | A | S | D |
| | Y | X | C |
| And also this: | | | |
| | | Hello | World |
| | Q | W | E |
| | A | S | D |
And a final test:
<!-- image -->
这是一个很好的起点。让我们看看AI智能体是否能纠正这些错误。
5、如何构建智能体来改进PDF转Markdown输出
我们将遵循一些智能体设计模式来遵循最佳实践 [4]:
- 规划:给定Docling的初始输出,我们可以先让LLM制定结构化计划。
- 反思:我们将设置一个生成器和一个批评者智能体。批评者将执行质量控制并提供反馈。与尝试一步生成最终Markdown文档相比,这种方法应该能带来迭代改进。
- 多智能体:CrewAI围绕多个智能体组成团队的概念设计。生成器和批评者是两个具有不同配置的顺序智能体。理想情况下,它们使用不同的LLM。
- 工具使用:生成器智能体将可以访问一个自定义工具,允许它"放大"感兴趣区域或查看整个PDF页面。为此,我们将编写一个将PDF页面转换为图像的自定义工具。
我们将使用CrewAI的Flow功能来实现迭代的"反思"设计模式:
首先,我们使用Docling从布局分析中提取边界框和文档的Markdown。
接下来,生成器智能体生成Markdown,批评者智能体审查其格式质量。
根据批评者的反馈和批准,我们要么生成下一轮迭代的Markdown,要么完成工作流。
对于这个项目,我们需要安装以下Python包:
pip install -U crewai==1.14.3 pymupdf docling==2.91 langchain-google-genai==4.2.2 python-dotenv
5.1 构建AI视觉工具将PDF图像转换为Markdown
首先,我们将创建一个名为PDFPageToMarkdownTool的自定义工具,可用于将整页或页面上的裁剪边界框可视转换为Markdown。为此,我们将使用可以处理图像输入的视觉语言模型(VLM)。
analyze_image()函数提示多模态Gemini LLM将图像(base64编码字符串)转换为Markdown文本。
from langchain.messages import HumanMessage, SystemMessage
from langchain_google_genai import ChatGoogleGenerativeAI
def analyze_image(image_data: str, model: ChatGoogleGenerativeAI) -> str:
"""Analyze a base64 encoded image using a multimodal Google Gemini model and return Markdown."""
system_msg = SystemMessage(
content="""
Convert the provided PDF image to clean GitHub Flavored Markdown.
Preserve structure and formatting.
Use HTML for complex tables if needed.
RReplace visuals (figures, images, charts) with very detailed alt text: <:: alt text : image::>
Output only valid Markdown (no explanations).
""".strip()
)
image = {
"type": "image",
"base64": image_data,
"mime_type": "image/png",
}
human_msg = HumanMessage(content=[image])
messages = [system_msg, human_msg]
response = model.invoke(messages)
return response.content[-1].get("text", "")
现在,我们可以将该函数包装在一个工具类中交给智能体。
PDFPageToMarkdownTool类将PDF页面转换为图像,进行缩放,可选裁剪,并使用图像数据调用analyze_image()函数。
我们的智能体可以在整个PDF页面上使用此工具,或者用于放大图表、复杂表格、代码块、手写等内容。
import base64
from typing import Literal, Optional, Type
import fitz
from crewai.tools import BaseTool
from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel, Field
class BoundingBox(BaseModel):
l: float = Field(..., description="Left coordinate")
t: float = Field(..., description="Top coordinate")
r: float = Field(..., description="Right coordinate")
b: float = Field(..., description="Bottom coordinate")
coord_origin: Literal["TOPLEFT", "BOTTOMLEFT"] = Field(
"TOPLEFT",
description="Origin of the coordinate system for bbox. ",
)
class PDFPageToMarkdownInput(BaseModel):
page_number: int = Field(..., ge=1, description="1-based page index.")
dpi: int = Field(
150,
le=300,
ge=72,
description="Rendering resolution in DPI. Higher values improve accuracy for small text and tables but increase cost.",
)
bbox: Optional[BoundingBox] = Field(
None,
description="Optional bounding box coordinates to crop the page",
)
class PDFPageToMarkdownTool(BaseTool):
name: str = "pdf_page_to_markdown"
description: str = """Extract high-fidelity Markdown from a PDF page or region.
Use this tool whenever confidence is low or when handling tables,
images, figures, diagrams, equations, code, or complex layouts.
Accepts page_number, dpi, and optional bbox for cropping.""".strip()
args_schema: Type[BaseModel] = PDFPageToMarkdownInput
pdf_path: str = None
model: ChatGoogleGenerativeAI = None
def _run(
self,
page_number: int,
dpi: int = 150,
bbox: Optional[BoundingBox] = None,
) -> str:
with fitz.open(self.pdf_path) as doc:
if page_number < 1 or page_number > len(doc):
raise ValueError(f"page_number must be between 1 and {len(doc)}")
page = doc.load_page(page_number - 1)
clip_rect = page.rect
if bbox is not None:
if isinstance(bbox, dict): # Accept both dict and BoundingBox
bbox = BoundingBox(**bbox)
l, t, r, b = bbox.l, bbox.t, bbox.r, bbox.b
# Convert BOTTOMLEFT coordinates to TOPLEFT system
if bbox.coord_origin == "BOTTOMLEFT":
page_height = page.rect.height
t, b = page_height - b, page_height - t
# Create rect with proper min/max ordering
clip_rect = fitz.Rect(l, min(t, b), r, max(t, b))
mat = fitz.Matrix(dpi / 72.0, dpi / 72.0)
pix = page.get_pixmap(matrix=mat, clip=clip_rect)
# Convert to base64 string
img_bytes = pix.tobytes("png")
img_str = base64.b64encode(img_bytes).decode("utf-8")
try:
markdown = analyze_image(img_str, self.model)
except Exception as e:
print(f"Image analysis failed: {e}")
markdown = ""
return markdown
对于这个工具,我使用gemini-3.1-flash-lite-preview作为VLM。
此代码假设你在同一文件夹中有一个包含API密钥的.env文件。你可以在Google AI Studio中创建一个用于开发目的的免费API密钥。
import os
from dotenv import load_dotenv
load_dotenv()
assert os.getenv("GOOGLE_API_KEY"), "GOOGLE_API_KEY not found in environment"
# Create the custom tool with the specified model and PDF path
tool_model = ChatGoogleGenerativeAI(
model="gemini-3.1-flash-lite-preview",
temperature=1.0,
max_tokens=None,
timeout=None,
max_retries=1,
thinking_level="low",
)
pdf_path = "/path/to/bench_pdf_v2.pdf"
tool = PDFPageToMarkdownTool(pdf_path=pdf_path, model=tool_model)
接下来,我们将开始定义实际的AI智能体。在CrewAI中,智能体和任务使用YAML配置文件定义。
5.2 组装AI智能体团队
在agents.yaml文件中,我们定义两个角色:一个执行主要工作的生成器,以及一个执行质量控制并向生成器提供反馈的批评者。
generator:
role: >
PDF-to-Markdown Converter
goal: >
Convert PDFs into clean, structured Markdown.
backstory: >
Expert in PDF parsing using Docling and vision tools. Handles tables, images, and complex layouts accurately.
critic:
role: >
QA Reviewer
goal: >
Validate and improve Markdown formatting quality.
backstory: >
Detail-oriented reviewer specializing in GitHub Flavored Markdown syntax. Provides precise, actionable feedback.
在tasks.yaml文件中,我们为每个智能体分配一个任务。一个任务是生成Markdown,另一个是审查它。
pdf_to_markdown_task:
description: |
Convert a PDF into clean, structured GitHub Flavored Markdown.
Here is the Docling JSON representation of the PDF:
{doc}
Your job is to reconstruct this document faithfully.
IMPORTANT: You MUST call the `pdf_page_to_markdown` tool whenever:
- There are low-confidence elements in the Docling JSON that may be inaccurate or incomplete
- The structure is unclear, incomplete, or ambiguous
- The content includes pictures, formulas, charts, handwritten text, code, or complex tables
- Text appears broken, misaligned, or visually complex
- You need detailed alt text for a visual element
- You need to confirm layout, indentation, or multi-column structure
You may call the tool multiple times.
Markdown rules:
- Follow the original structure closely
- Replace visuals with: <:: detailed alt text : image::>
- HTML tables can be used for complex tables
- Code blocks are fenced with triple backticks and language hints
- Remove unneeded headers, footers, page numbers, and noise
Here is the Markdown from the previous iteration:
```markdown
{markdown}
```
And here is the feedback from the critic from the previous iteration:
{feedback}
Output only valid Markdown. No explanations.
expected_output: >
Clean, structured Markdown.
agent: generator
output_file: output.md
quality_control_task:
description: |
Review the following GitHub Flavored Markdown syntax and structure only.
What to check:
- The Markdown syntax is valid and the structure is reasonable.
- Visuals are replaced with descriptive <:: alt text : image::>
- Alt text is not a placeholder but a detailed description of the visual element.
- Tables are properly formatted, using HTML tables if complex
- Code blocks are fenced with triple backticks and language hints
- There are no unneeded headers, footers, page numbers, etc.
Rules:
- approved = True if no significant formatting issues exist
- approved = False if fixes are needed
- feedback must be concise and actionable
Markdown to review:
```markdown
{markdown}
```
expected_output: >
A boolean for "approved" and a string for "feedback".
agent: critic
我们现在构建AgentCrew,使用YAML配置文件定义两个智能体和两个任务。
from typing import List
from crewai import Agent, Crew, LLM, Task
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.project import CrewBase, agent, task
# Use CrewAI's built-in Gemini support
llm = LLM(
model="gemini/gemini-3.1-flash-lite-preview",
temperature=1.0, # Use the Gemini 3 recommended temperature
)
class CriticOutput(BaseModel):
approved: bool
feedback: str
@CrewBase
class AgentCrew:
"""Agent crew for PDF-to-Markdown conversion"""
agents: List[BaseAgent]
tasks: List[Task]
@agent
def generator(self) -> Agent:
return Agent(
config=self.agents_config["generator"],
verbose=True,
llm=llm,
tools=[tool],
reasoning=False, # True = Use planning
max_reasoning_attempts=1,
max_rpm=15,
)
@agent
def critic(self) -> Agent:
return Agent(
config=self.agents_config["critic"],
verbose=True,
llm=llm,
max_rpm=15,
)
@task
def quality_control_task(self) -> Task:
return Task(
config=self.tasks_config["quality_control_task"],
output_pydantic=CriticOutput,
)
@task
def pdf_to_markdown_task(self) -> Task:
return Task(config=self.tasks_config["pdf_to_markdown_task"])
在生成器智能体上设置reasoning = True会激活规划模式。这会提示LLM创建一个计划,应该能改善工具使用和Markdown输出。但是,我停用它以节省时间和token。
5.3 使用CrewAI Flow编排工作流
为了实现生成器-批评者循环,我们使用CrewAI相对较新的功能"Flow"。Flow类似于LangGraph,它让你将智能体工作流创建为带有节点和边的图。
from typing import Dict, List, Literal, Optional
from crewai.flow import Flow, listen, router, start
from docling.document_converter import DocumentConverter
from pydantic import BaseModel
MAX_RETRIES = 3
class PDFState(BaseModel):
pdf_path: Optional[str] = None
doc: Optional[List[Dict]] = None
markdown: str = ""
feedback: str = ""
approved: bool = False
retries: int = 0
class PDFToMarkdownFlow(Flow[PDFState]):
def __init__(self):
super().__init__()
self.agent_crew = AgentCrew()
def docling_converter(self):
"""Convert PDF to initial markdown and structured doc."""
converter = DocumentConverter()
try:
docling_doc = converter.convert(self.state.pdf_path)
self.state.markdown = docling_doc.document.export_to_markdown()
self.state.doc = reduce_docling_doc(docling_doc)
except Exception as e:
print(f"Docling reduction failed: {e}")
self.state.doc = None
@start("generate")
def generate_markdown(self):
"""Generate improved Markdown using the generator agent."""
print(f"Starting iteration {self.state.retries}")
if self.state.retries == 0:
# run docling converter in the first iteration
self.docling_converter()
agent = self.agent_crew.generator()
task = self.agent_crew.pdf_to_markdown_task()
crew = Crew(agents=[agent], tasks=[task], verbose=True)
result = crew.kickoff(
inputs={
"doc": self.state.doc,
"markdown": self.state.markdown,
"feedback": self.state.feedback,
}
)
self.state.markdown = result.raw
@listen(generate_markdown)
def review_markdown(self):
"""Review generated markdown using critic agent."""
agent = self.agent_crew.critic()
task = self.agent_crew.quality_control_task()
crew = Crew(agents=[agent], tasks=[task], verbose=True)
result = crew.kickoff(inputs={"markdown": self.state.markdown})
parsed = result.pydantic
if parsed is None:
parsed = CriticOutput(
approved=False, feedback=f"Failed to parse critic output: {result.raw}"
)
self.state.feedback = parsed.feedback
self.state.approved = parsed.approved
@router(review_markdown)
def decide(self) -> Literal["generate", "finish"]:
"""Decide whether to retry or finish."""
if self.state.approved:
return "finish"
if self.state.retries < MAX_RETRIES:
self.state.retries += 1
return "generate"
return "finish"
@listen("finish")
def end(self):
"""Return final result."""
return {
"markdown": self.state.markdown,
"approved": self.state.approved,
"retries": self.state.retries,
"feedback": self.state.feedback,
}
流程从@start装饰器开始,即generate_markdown节点。第一次运行时,执行docling_convert()方法,该方法运行Docling获取布局分析结果和初始Markdown内容。
之后,生成器智能体开始工作。希望它能更新和改进Docling的初始Markdown结果。
接下来,执行review_markdown()方法。在这里,批评者在feedback变量中设置approved布尔值并提供文本反馈。
@router装饰器创建一个决策:要么循环回生成器以根据反馈修复Markdown,要么在Markdown被批准或达到最大迭代次数时完成流程。
我们可以使用flow.kickoff()方法运行流程,以PDF文件路径作为输入。
flow = PDFToMarkdownFlow()
result = flow.kickoff(inputs={"pdf_path": pdf_path})
print(result)
此实现的一个限制是它尝试一次转换整个文档。对于较长的文档,这可能不适用。对于这些文档,我们需要转换每一页并将输出合并为一个Markdown文件。
5.4 结果:智能体AI改进了多少PDF转Markdown输出?
我的流程运行了两次迭代。在第一次迭代期间,生成器使用pdf_page_to_markdown工具专门检查了第2页上的表格。这纠正了Docling的Markdown中将两个表格合并为一个的错误。
批评者发现了一个错误,Docling将Python代码块和curl命令合并在一起。由于批评者设置了approved = False,生成器有机会在第二次迭代中修复代码块。
最终结果如下:
## Lorem Ipsum
Lorem ipsum dolor sit amet.
Consetetur sadipscing elitr.
Sed diam nonumy eirmod tempor.
### Lorem Ipsum
Lorem ipsum . Lorem ipsum . Lorem ipsum . Lorem ipsum.
### Lorem Ipsum
Lorem.
Lorem ipsum.
Lorem ipsum dolor.
Lorem ipsum.
1. Lorem
2. Lorem ipsum
3. Lorem ipsum dolor
* Lorem
* Lorem ipsum
* Lorem ipsum dolor
Before something
After something
Test: click.
Another test: mail@example.com
Let's try this:
```python
def add_integer(a: int, b: int) -> int:
return a + b
```
```bash
curl -o thatpage.html http://www.example.com/
```
Let's try this:
| | Hello | To | Medium |
| :--- | :--- | :--- | :--- |
| | Q | W | E |
| | A | S | D |
| | Y | X | C |
And also this:
| | Hello | World |
| :--- | :--- | :--- |
| **Q** | W | E |
| **A** | S | D |
And a final test:
<:: A visual element or diagram representing a chart or graphic : image::>虽然还不完美,但相比Docling的初始Markdown输出有了改进。
6、结束语
PDF转Markdown工具不可避免地会犯错误。这是有问题的,因为下游任务(如RAG)将使用这些有缺陷的数据,导致错误传播。
本文提出了一种使用智能体AI改进初始PDF转Markdown转换的潜在解决方案。智能体反思设计模式充当质量控制门,可以自动检测和修复错误。
我使用Docling进行布局分析和初始Markdown转换,但其他选项也可用,如PaddleOCR 3。
我仍然有一个悬而未决的问题是,是否将带有边界框的布局分析添加到生成器提示中能提高性能。也许我们可以消除这种复杂性,只让LLM检查整个页面。
我使用Google的Gemini 3.1 Flash Lite Preview作为我选择的LLM,但本地模型通常更可取。如果你有足够的GPU VRAM,试试本地模型,比如Gemma 4或Qwen 3。使用本地模型意味着你不必将私有文档上传到第三方服务。
原文链接: How To Build Your Own Agentic PDF-to-Markdown Converter
汇智网翻译整理,转载请标明出处