LLM结构化提取的陷阱:引用对了,值却放错了位置

引用全对、Schema通过,值却系统性错位一列——LLM提取的隐蔽盲区。
一位开发者用本地LLM解析空调电费账单,初版提示词下24个值与4个引用全部正确。但在对输入文本做空格规范化后,值评分暴跌至6/24,引用却仍保持4/4全对。根因是规范化抹掉了PDF文本中的制表符,模型失去列边界判断依据,每个金额向右错位一列。案例揭示JSON Schema只保证结构、引用只保证值存在,二者都无法保证字段与值的映射正确;引用验证尤其容易制造虚假安全感。作者提出三条原则:保持模型输入原始格式、用确定性交叉计算作为第三道防线、每次改动都用已知真实值重新评分。
一个看似完美的LLM提取结果
一位开发者用本地LLM(gemma4:e2b,通过Ollama在Mac上运行)构建了一个PHP包,用于解析空调电费账单。流程设计很标准:给模型一个提示词、账单PDF的线性化文本、以及JSON Schema(通过format参数传入),要求模型对每一行数据给出一个引用——从账单原文中逐字复制的行,作为提取值的来源证明。
第一次用初版提示词,模型提取的值几乎全对,但引用只填了标签(如“ACS”),而非完整行。开发者调整提示词,明确“引用是表格的完整行,按打印原样,单位是价格的单位(€/L)而非数量的单位”。结果令人振奋:24个值全部正确(6个字段×4行),4个引用也全部正确。看起来大功告成。

灾难降临:引用全对,值却错位
问题出在后续的“空格规范化”步骤。开发者将提取的文本做归一化处理后喂给LLM,结果值评分从24/24暴跌至6/24——而引用依然保持4/4全对。
所有值都存在于文档中,但被放进了错误的字段。以ACS行为例:
| 字段 | 期望值 | 模型输出 |
|---|---|---|
| quantity | 2.475,00 | 2.475,00 ✓ |
| price | 0,00740931 | 0,00740931 ✓ |
| vat_percent | 21,00 | 0.00000000 ✗ |
| base | 18,34 | 21,00 ✗ |
| taxes | 3,85 | 18,34 ✗ |
| subtotal | 22,19 | 3,85 ✗ |
每个金额都向右滑动了一列。引用证明了“这些值确实存在于账单上”,却无法证明“每个值被放进了正确的字段”。
LLM结构化提取中的“引用”机制指的是要求模型在输出每个提取字段时,同时给出该值在原始文档中的逐字来源片段。这种做法的理论依据是:如果模型能准确复制来源文本,说明它确实“看到”了该值,而非凭空生成,从而降低幻觉风险。引用验证通常只需检查引用片段是否存在于原文中,实现成本低、可解释性强,因此在文档解析流水线中被广泛用作质量门禁。然而,引用验证的本质是存在性检验——它只回答“这个值是否出现在文档某处”,并不回答“这个值是否来自正确的行列交叉点”。当文档是扁平文本且丢失了列分隔信息时,多个值可能同时存在于同一行,引用片段依然能匹配成功,但字段映射已经错位。这就是引用验证容易制造虚假安全感的根源。
根因:制表符被规范化抹掉了
PDF解析器输出的原始文本使用制表符分隔列:
Concepto → Cant. → Tarifa → %IVA → Base → Impuestos → Subtotal
ACS → 2.475,00 → 0,00740931 €/L → 21,00 → 18,34 € → 3,85 € → 22,19 €
箭头代表制表符,7列表头对应7个数据单元格。模型只需数制表符即可定位列边界。但开发者的规范化处理将所有字符变成空格后:
Concepto Cant. Tarifa %IVA Base Impuestos Subtotal
ACS 2.475,00 0,00740931 €/L 21,00 18,34 € 3,85 € 22,19 €
数据行从7个元素膨胀为11个,模型失去可靠的单元格边界判断依据,很可能在计数时出错,导致每个值错位一列。
PDF文本提取中的制表符与列边界:PDF本身并不存储“表格”结构,只存储字符的坐标位置。解析器通常根据字符间距推断列分隔,并用制表符(\t)或连续空格来近似表示。对于账单、发票这类多列表格,制表符是模型判断“第几个单元格对应第几个字段”的唯一低成本线索。一旦规范化步骤(如统一空白、去除多余空格)将制表符替换为单个空格,原本7列的数据行会因字段值内部本身含有空格(如“0,00740931 €/L”)而膨胀为更多token,模型无法再可靠地数出列边界。更隐蔽的是,这种破坏发生在预处理阶段,提示词和Schema都保持不变,引用验证依然通过,因此问题不会在日志中暴露。保持模型输入的原始制表符结构,或在规范化时保留列分隔标记,是避免此类错位的关键。
三层验证各有所长,也各有所短
这个案例揭示了LLM结构化提取中三种常见验证手段的盲区:
| 检查手段 | 能证明什么 | 会漏掉什么 |
|---|---|---|
| JSON Schema | 字段存在、类型正确 | 值被放进错误字段 |
| 引用(Citation) | 值存在于文档的对应行 | 值位于错误的列 |
| 确定性交叉计算 | 值之间数学一致 | 保持计算正确的错误(罕见但可能) |
Schema保证结构,引用保证存在性,但两者都无法保证位置正确性。引用验证尤其容易制造虚假安全感——4/4的引用通过率掩盖了18个值错误。
JSON Schema的format参数与结构化输出:在Ollama等本地推理框架中,format参数允许传入JSON Schema,约束模型输出必须符合指定的字段名、类型和嵌套结构。这能有效防止模型自由发挥、遗漏字段或返回不可解析的文本,是现代LLM提取流水线的标配。但Schema的约束是语法层的:它只检查vat_percent是否为数字、base是否存在,不检查base里的数字是否来自账单的Base列。因此,当模型把21.00放入base、把18.34放入taxes时,Schema完全无法察觉——两个字段都是合法的浮点数。Schema与引用验证的组合覆盖了“结构正确”和“值存在”两个维度,却恰好留下“位置正确”这一盲区。理解每种验证手段的证明边界,比堆叠更多检查更重要。
生产环境的隐形风险
开发者能发现这个问题,是因为本地有一份已知正确答案的测试文件。但在生产环境中,这种对照并不存在。同样的错位会在供应商更换账单版式、引入OCR、或更换PDF提取器时无声无息地发生——输入变了,结构消失了,而引用依然全部通过。
这是一个比“幻觉”更隐蔽的问题:模型没有编造不存在的值(引用验证通过),却系统性地放错了位置。仅依赖引用作为质量门禁的流水线会直接放行错误数据。
三条可落地的改进原则
- 不改动模型输入。 发给LLM的文本保持原始制表符格式。规范化只用于比较环节,且作用于副本。
- 引用不是充分证据。 它只能证明值存在于文档中。需要增加连接值之间关系的确定性检查:数量×价格=base,各行总和=total。
- 先测量再信任。 每次改动提示词、模型或PDF提取器,都用已知真实值的账单重新跑分。没有这个评分机制,4/4的引用通过率会让人误以为一切正常。
交叉计算在这里展现出独特价值:对ACS行,数量×价格=2475×0.00740931=18.34,而模型将21.00放入了base字段——计算失败,提取被拒绝。这种确定性检查不依赖模型自证,是引用和Schema之外不可或缺的第三道防线。
确定性交叉计算作为第三道防线:交叉计算指利用领域内已知的数学关系(如数量×单价=base、base+taxes=subtotal、各行subtotal之和=total)对提取结果做一致性校验。它的优势在于不依赖模型自证,也不依赖原始文档的格式信息——只要数值关系成立,就能反向确认字段映射是否正确。在上面的案例中,若模型将21.00放入base,则数量×价格=18.34≠21.00,校验立即失败并拒绝该行。交叉计算的局限在于它只能覆盖存在数学关系的字段,且对“保持计算正确的错误”(如整行数值被同步缩放)无能为力,但对于列错位这类系统性错误极为有效。实践中应优先识别账单中可靠的恒等关系,将其作为提取后的硬性门禁,而不是仅依赖引用通过率。
留给所有做LLM提取的人
当你的流水线显示“引用验证4/4通过”时,值得追问一句:引用证明的到底是什么?它证明值在文档中,不证明值在正确的字段里。结构化提取的可靠性,最终取决于你如何验证字段与值的映射关系,而非值是否存在。
相关推荐

花旗14000名工程师用Devin,技术外包账单砍掉30%
花旗集团14000名工程师使用AI编程智能体Devin,一年内将技术供应商账单削减30%,产品发布提速30-40%。但30%仅限单一成本项,摩根大通同期表示AI收益尚未体现在财报中,揭示企业AI落地的真实分化。

AI写代码,程序员要失业?Devin自主编程真相
Devin等自主AI编程代理正改变软件开发:从Copilot辅助编码到全自动工程代理,程序员角色从写代码转向系统设计与监督。本文分析AI编程对就业、代码安全、初级开发者培养的影响,以及效率悖论下程序员需求的真实走向。

AGENTS.md 实战指南:让编程 Agent 真正好用
AGENTS.md 和 CLAUDE.md 能显著提升编程 Agent 表现,但研究发现用错反而让成本上升 20%。本文详解该写什么、该删什么、200 行与 32KB 的硬边界、Monorepo 嵌套规则及安全提醒。