每个月末,财务或行政的工位上总会上演同样的画面:下载文件夹里堆着几十张来自不同供应商的电子发票。打开PDF,复制发票号,切到Excel粘贴;再复制开票日期,粘贴……一下午过去,腰酸背痛,还容易把“8”敲成“6”。
为了拯救自己的时间和颈椎,我花了一个周末,用 Python + 大语言模型(LLM)搭了一套自动提取电子发票信息的管道。今天把完整的实现思路和踩坑记录分享出来,希望能帮到有同样需求的朋友。
一、 为什么我放弃了 OCR,选择直接解析文本?
说到发票识别,很多人的第一反应是上 OCR(光学字符识别)。但实际测试下来,针对电子发票(PDF格式) ,OCR 其实是个坑:
- 速度慢:OCR 需要先渲染图像,再做目标检测和文字识别,一张图平均要 3-5 秒。
- 精度受图像质量影响:哪怕稍微有一点倾斜、或者盖章遮盖,都会导致识别错乱。
- 无视了 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 来做最后一道防线。
在写校验层时,我踩了三个大坑:
- 折扣行负数报错:我最初用了
Field(ge=0)(要求大于等于0),结果遇到带有折扣红字发票(数量 -2,金额 -17000)时直接抛出异常。解决:允许负数。 - 税率格式混乱:模型有时输出
13%,有时输出13,有时输出0.13。解决:用 Validator 强行统一转为小数。 - 商品名称带乱码:发票上的明细往往带有税收分类编码前缀(如
*信息技术服务*软件开发服务)。解决:写一个清洗函数,利用*拆分,只保留真正的商品名。
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 负数和税率格式的坑。