# 别再手动敲发票了:Python + LLM 实现电子发票 PDF 结构化解析

3 阅读5分钟

每个月末,财务或行政的工位上总会上演同样的画面:下载文件夹里堆着几十张来自不同供应商的电子发票。打开PDF,复制发票号,切到Excel粘贴;再复制开票日期,粘贴……一下午过去,腰酸背痛,还容易把“8”敲成“6”。

为了拯救自己的时间和颈椎,我花了一个周末,用 Python + 大语言模型(LLM)搭了一套自动提取电子发票信息的管道。今天把完整的实现思路和踩坑记录分享出来,希望能帮到有同样需求的朋友。

一、 为什么我放弃了 OCR,选择直接解析文本?

说到发票识别,很多人的第一反应是上 OCR(光学字符识别)。但实际测试下来,针对电子发票(PDF格式) ,OCR 其实是个坑:

  1. 速度慢:OCR 需要先渲染图像,再做目标检测和文字识别,一张图平均要 3-5 秒。
  2. 精度受图像质量影响:哪怕稍微有一点倾斜、或者盖章遮盖,都会导致识别错乱。
  3. 无视了 PDF 本身的优势:从税务局下载的电子发票,PDF 里天然自带完整的文字层(Text Layer) ,直接提取文字不仅毫秒级完成,且准确率 100%。

所以,我的技术选型是:PyMuPDF 提取文本 + LLM 结构化抽取 + Pydantic 校验清洗。

二、 技术实战:三步搭建解析管道

第一步:PyMuPDF 提取文字层

PyMuPDF(包名 fitz)是 Python 里极快的一个 PDF 处理库。提取电子发票文字只需要几行代码:

python

import pymupdf

def extract_text(pdf_path: str) -> str:
    doc = pymupdf.open(pdf_path)
    text = ""
    for page in doc:
        text += page.get_text()
    doc.close()
    return text.strip()

# 测试提取
# raw_text = extract_text("invoice.pdf")

第二步:LLM 结构化抽取

拿到纯文本后,如果自己写正则表达式去抠字段,不仅代码极其臃肿,而且发票版式一变就全盘崩溃。这里交给大模型(比如 DeepSeek、通义千问),通过 Prompt 约束它输出 JSON。

核心技巧:一定要在 Prompt 中定义好严格的 JSON Schema,并明确告诉模型“不要输出 Markdown 标记”、“金额只要数字”。

python

import json
from openai import OpenAI

client = OpenAI(api_key="你的LLM_API_KEY", base_url="https://api.deepseek.com")

SYSTEM_PROMPT = """
你是一个专业的发票信息提取助手。请从用户提供的发票文本中提取结构化信息。
严格按照以下 JSON 格式输出,不要包含任何其他文本:
{
  "invoice_number": "发票号码",
  "invoice_date": "开票日期,格式 YYYY-MM-DD",
  "vendor_name": "销售方名称",
  "vendor_tax_id": "销售方纳税人识别号",
  "buyer_name": "购买方名称",
  "buyer_tax_id": "购买方纳税人识别号",
  "total_amount": 价税合计金额(数字),
  "tax_amount": 税额(数字),
  "line_items": [
    {
      "description": "商品或服务名称",
      "quantity": 数量(数字),
      "unit_price": 单价(数字),
      "total": 金额(数字),
      "tax_rate": 税率(小数,如0.13)
    }
  ]
}
注意:折扣行(负数)必须保留;金额只输出数字,不要带货币符号。
"""

def extract_json_from_llm(text: str):
    response = client.chat.completions.create(
        model="deepseek-chat",
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": text}
        ]
    )
    return json.loads(response.choices[0].message.content)

第三步:Pydantic 数据校验与清洗(踩坑重灾区)

大模型的输出并不总是完美的,有时日期格式不对,有时把金额输出成了字符串。我们需要用 Pydantic 来做最后一道防线。

在写校验层时,我踩了三个大坑:

  1. 折扣行负数报错:我最初用了 Field(ge=0)(要求大于等于0),结果遇到带有折扣红字发票(数量 -2,金额 -17000)时直接抛出异常。解决:允许负数。
  2. 税率格式混乱:模型有时输出 13%,有时输出 13,有时输出 0.13。解决:用 Validator 强行统一转为小数。
  3. 商品名称带乱码:发票上的明细往往带有税收分类编码前缀(如 *信息技术服务*软件开发服务)。解决:写一个清洗函数,利用 * 拆分,只保留真正的商品名。

python

from pydantic import BaseModel, Field, field_validator
from typing import Optional, List

class LineItem(BaseModel):
    description: str
    quantity: Optional[float] = None
    unit_price: Optional[float] = None
    total: float
    tax_rate: Optional[float] = None

    @field_validator("description")
    @classmethod
    def clean_description(cls, v: str) -> str:
        # 清洗 *信息技术服务*软件开发服务 -> 软件开发服务
        v = v.strip()
        if v.startswith("*") and v.count("*") >= 2:
            parts = v.split("*")
            if len(parts) >= 3:
                return parts[-1].strip()
        return v

class Invoice(BaseModel):
    invoice_number: str
    invoice_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
    vendor_name: Optional[str] = None
    vendor_tax_id: Optional[str] = None
    buyer_name: Optional[str] = None
    buyer_tax_id: Optional[str] = None
    total_amount: float
    tax_amount: Optional[float] = None
    line_items: List[LineItem] = []

三、 最终效果

经过这三步,一张包含多行明细、混合税率的复杂电子专票,在 2-5 秒内就能变成如下的干净 JSON,直接推入数据库或写入 Excel:

json

{
  "invoice_number": "26372000000999998888",
  "invoice_date": "2026-03-15",
  "vendor_name": "上海云海电子设备有限公司",
  "total_amount": 103452.0,
  "line_items": [
    {
      "description": "笔记本电脑",
      "quantity": 2.0,
      "total": 17000.0,
      "tax_rate": 0.13
    }
  ]
}

四、 关于工程化落地

上面这套代码逻辑,本地跑一跑没问题。但如果要在真实业务里用(比如对接财务系统、或者给非技术人员使用),你还得处理:API 服务封装、并发控制、密钥鉴权、服务防崩溃等等。这些我断断续续又折腾了一阵子。

后来为了自己用着方便,我干脆把这套管道封装成了一个标准的 Web API。只要发一个 POST 请求,上传 PDF 文件,直接返回上面这种 JSON。现在不管是在手机端还是电脑端,只要有 PDF,随时都能提取。

如果你不想重复造轮子写代码,或者不懂技术只是想找个工具自动提取发票,可以直接去体验一下我搭好的在线版。
体验地址及接口文档:见评论区置顶或私信我获取(避免被系统误判为广告)。

如果你打算自己写,希望这篇踩坑记录能帮你避开 Pydantic 负数和税率格式的坑。