用 LLM 做数据提取踩了 6 个坑,我加了 6 层防御——15000 张发票的实战总结

背景

我给一家公司做财务发票对账系统,核心任务是批量提取非结构化票据字段(金额、税号、开票日期等),然后做跨单据的语义比对。

系统上线后累计处理了 15000 张发票,消耗约 1 亿 token

过程中踩了 6 个坑,每踩一个加一层防御。回头看,架构就是这样长出来的。

这篇文章是写给同样想用 LLM 做数据提取的人的------不是教你怎么设计,是告诉你会在哪里摔跤。


核心前提:把 LLM 当作不可信组件

LLM 本质上是一个根据上下文猜下一个 token 的机器,它不保证输出稳定

这句话说起来简单,但真正把它内化为工程决策,需要亲自踩坑。

我踩完之后的结论是:用 LLM 做提取任务,你的代码要为它的每一种失败模式准备出路。

以下是 6 层防御,按我踩坑的顺序来的。


第 1 层:输出内容不可信 → 让代码算,别让 LLM 算

:我让 LLM 直接输出含税金额、不含税金额、税额,然后发现三个数字加起来经常对不上。

原因:LLM 在 JSON 里的几个 token 里做算术,精度和稳定性都不可靠。

防御:只让 LLM 提取原始字段(字符串形式),数值计算由我的代码完成。降低 LLM 的任务负担,把"计算"这件事还给确定性代码。

python 复制代码
# ❌ 让 LLM 直接算
{"含税金额": 113.0, "不含税金额": 100.0, "税额": 13.0}

# ✅ 只让 LLM 提取原始值,代码来算
{"含税金额_str": "113.00", "税率_str": "13%"}
tax_amount = calculate_tax(含税金额_str, 税率_str)  # 代码算

第 2 层:输出结构不可信 → 用 Pydantic 锁死 Schema

:LLM 返回的 JSON 字段名会漂移------有时是 invoice_date,有时是 date,有时干脆没有这个字段。我写了一堆 if key in response 的校验代码,冗余且脆弱。

防御:全面换用 Pydantic。Schema 即契约,结构在定义时就锁死,不符合的直接报错,不给它漂移的空间。

python 复制代码
from pydantic import BaseModel
from datetime import date

class InvoiceExtraction(BaseModel):
    invoice_code: str
    invoice_date: date
    amount_str: str
    tax_rate_str: str

比手写 JSON 校验少写 80% 的代码,还更可靠。


第 3 层:输出值域不可信 → 在 Validator 里写断言

:结构对了,但值不对。税率出现过 "130%",金额出现过负数,日期出现过 "2099-01-01"

防御 :在 Pydantic 的 @validator 里直接写业务断言。通不过测试,实例就不生成。

python 复制代码
from pydantic import validator

class InvoiceExtraction(BaseModel):
    tax_rate_str: str
    amount_str: str

    @validator("tax_rate_str")
    def validate_tax_rate(cls, v):
        rate = float(v.strip("%")) / 100
        assert 0 < rate <= 0.3, f"税率异常: {v}"
        return v

    @validator("amount_str")
    def validate_amount(cls, v):
        amount = float(v.replace(",", ""))
        assert amount > 0, f"金额不能为负: {v}"
        return v

Validator 本质上是把业务规则写进类型系统,比写在外面的 if 判断更不容易被绕过。


第 4 层:输出错了 → 把错误喂回去,让它重试

:上面的 Validator 报错了,然后呢?直接丢掉这张发票损失太大。

防御:把报错信息提取出来,塞回 Prompt,让 LLM 再来一次。

python 复制代码
def extract_with_retry(llm_client, invoice_text: str, max_retries=3):
    error_context = ""
    for attempt in range(max_retries):
        prompt = build_prompt(invoice_text, error_context)
        raw = llm_client.extract(prompt)
        try:
            return InvoiceExtraction.parse_raw(raw)
        except Exception as e:
            error_context = f"上次输出有误,错误信息:{e},请修正后重新输出。"
    return None  # 进入第 5 层

实测大部分结构错误在第 2 次重试时能自我修正,成本增加有限,准确率提升明显。


第 5 层:实在不行 → 不静默失败,打标记交人工

:有些发票版面复杂,重试 3 次还是过不了 Validator。如果静默丢弃,下游对账就会有漏洞,而且你不知道漏在哪。

防御 :最后一次失败,把 LLM 原始输出保留下来,打上 需人工复核 标记,进入人工队列。

css 复制代码
if result is None:
    fallback = {
        "status": "需人工复核",
        "raw_llm_output": last_raw_output,
        "invoice_id": invoice_id,
    }
    human_review_queue.append(fallback)

不输出比输出错更危险。 让系统知道自己不知道,比系统假装知道要安全得多。


第 6 层:不要设计太复杂的架构 → 架构是跑出来的

这是最后一条,也是我觉得最反直觉的。

我的性格是"想不清楚不动手"。但这个系统我是直接跑起来的:

复制代码
发现数字不对   →  加第 1 层(代码算)
发现字段漂移   →  加第 2 层(Pydantic)
发现值域异常   →  加第 3 层(Validator)
发现结构报错   →  加第 4 层(错误重试)
发现静默丢失   →  加第 5 层(打标记)

回头看,这 5 层防御覆盖了 LLM 输出失败的所有主要模式。但我在开始之前完全没有预见到这个架构------它是被坑"逼"出来的。

对于 LLM 工程,"先跑起来发现坑,再加防御"比"先设计再执行"更有效。 因为 LLM 的失败模式在真实数据上才会暴露,沙盘推演推不出来。


总结

防御目标 工具
1 内容不可信 代码算,LLM 只提取
2 结构不可信 Pydantic Schema
3 值域不可信 Pydantic Validator
4 偶发出错 错误回注重试
5 兜底失败 Fail-loud + 人工标记
6 架构过设计 跑起来再加层

如果你也在用 LLM 做数据提取,这套框架可以直接拿去用。有问题欢迎评论区讨论。


目前在做 AI Agent 工程方向,专注 LLM 落地与 Harness 设计,欢迎同方向的朋友交流。

相关推荐
武子康2 小时前
调查研究-198 Agent 到底该记住什么?读懂《What Must Generalist Agents Remember?》
人工智能·openai·agent
aqi003 小时前
15天学会AI应用开发(九)利用Chroma持久化向量数据
人工智能·python·大模型·ai编程·ai应用
武子康4 小时前
调查研究-197 FAISS vs Elasticsearch 全面对比:从向量检索、全文搜索到 RAG 选型指南
人工智能·elasticsearch·agent
青禾网络4 小时前
Web 前端如何接入 AI 音效生成:从零到可用的完整方案
人工智能·设计模式
用户252736278144 小时前
【技术实战】用 Spring Boot + Vue3 + LM Studio 在本地跑通 RAG 知识库
人工智能
用户5191495848454 小时前
VBScript随机数生成器内部机制:从时间种子到密码令牌破解
人工智能·aigc
米小虾5 小时前
Context Engineering —— 知识与记忆的窗口
人工智能·agent
IT_陈寒5 小时前
Python里这个赋值坑,连老司机都能翻车
前端·人工智能·后端
Shockang15 小时前
AI 设计工作流全景拆解:Figma MCP / Claude Design / Codex / Google Stitch
人工智能