用 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 设计,欢迎同方向的朋友交流。

相关推荐
RoboWizard9 小时前
三星和金士顿内存条哪个更适合游戏超频
大数据·人工智能
深圳市恒星物联科技有限公司9 小时前
轻量MCU设备通过OpenHarmony兼容性测评的全流程关键要点与实战踩坑经验
大数据·人工智能·物联网·鸿蒙
AI闲人9 小时前
企业 AI 最大的问题,不是数据不足,而是数据没有业务语义
人工智能·数字化·企业ai落地
米小虾10 小时前
一周 AI 观察(9.14–9.18):Anthropic 自曝"AI 写了我四分之一的研发",于是这一周所有人都在买同一样东西
人工智能
米小虾10 小时前
加了 20 条示例反而变差:你的 few-shot 提升,可能只是 prompt 变长的功劳
人工智能·llm
东方-教育技术博主10 小时前
自进化智能体编码软件用法
人工智能
jimmyleeee10 小时前
大模型安全之十八:AI常见漏洞类别与缓解策略
人工智能·安全
火山引擎开发者社区10 小时前
多行业专家招募|参与线上访谈拿 2000元/h 现金报酬!
人工智能
4SAPI10 小时前
企业大模型API服务商推荐:从多模型接入到AI API Gateway的技术选型分析
人工智能·php