背景
我给一家公司做财务发票对账系统,核心任务是批量提取非结构化票据字段(金额、税号、开票日期等),然后做跨单据的语义比对。
系统上线后累计处理了 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 设计,欢迎同方向的朋友交流。