Skip to main content

结构化输出:让模型返回 schema,而不是散文

陈亚历2026/9/299 min read
结构化输出JSON-Schema数据抽取LLM工程文档处理

模型把文档读对了。它找到了地址、面积、登记号。然后它把这些值放在了 locationareareg_no 这几个键下面——而你的流水线在找 legal_addressarea_sqmregistration_number,于是渲染出三个空字段。

大多数 LLM 抽取失败都是这个样子。不是理解问题,是格式问题。模型知道答案,并用自己的措辞描述了它;而你的代码无从判断 location 指的就是 legal_address

本文按成本递增的顺序讲三层防御,以及每一层在什么时候不再值得。

格式失败的三种方式

自创键名。 遥遥领先的最常见问题。你的提示词用散文描述字段——「抽取法定地址、以平方米计的面积」——模型就自行挑选看起来合理的 JSON 键。它们从不重复两次,也从不与你的模板期待的名字一致。

近似 JSON。 多余的逗号、误入的 markdown 代码围栏、注释,或者因为预算耗尽而被截断的对象。解析器抛异常,重试又给出一个带着另一种瑕疵的近似 JSON。

悄无声息缺失的字段。 模型直接省略某个键,而不是报告「文档里没有这个值」。在下游看来,键不存在和值为空长得一模一样,于是没人能区分真正的空白和一次遗漏。

三者都是格式问题,而格式问题有格式解法。

第一层:在提示词里钉死键名

这是最便宜、收益也最高的修复。别再用散文描述字段,开始列出确切的键名。

对我们奏效的做法,是在提示词已有内容之后追加一份明确的键名契约:

请返回一个 JSON 对象,使用以下**确切**的键名
(不要重命名、不要翻译、不要缩写):
legal_address, area_sqm, registration_number, cadastral_value。

即使文档中该值的标签写法不同,也请严格按上表拼写每个键
(使用表中的键,而不是同义词)。只有当文档确实没有对应值时,
才可以省略某个键。

有三处细节比看上去更重要。

「不要翻译」。 抽取处理的是某种语言的文档,输出的是 JSON;一个在读俄语表格的模型,若不加说明,会很自然地给出俄语键名。

「即使文档中该值的标签写法不同」。 正是这句话终结了同义词自创。没有它,模型会合理地认为:表格里标着「Местоположение」的字段,键就该叫 location——因为它读的是文档,不是你的 schema。

「只有确实没有值时才省略」。 这把一次静默遗漏变成了一次诚实的缺席,也让二者在校验时得以区分。

这份清单要从你的 schema 生成,而不是手写。我们的清单由输出最终要映射到的那份 field_definitions 生成,这意味着无论提示词里人写的那部分怎么措辞,提示词都不会与流水线的期待发生漂移。

对数组字段,也要把元素内部的键钉死,否则重复行会填得参差不齐:

数组字段:
  - "items" 是数组;每个元素必须使用**确切**这些键:
    description, quantity, unit_price, total_price

第二层:供应商原生的结构化输出

多数供应商现在支持随请求附带 JSON schema,并约束解码使响应符合该 schema。可用时,它能彻底消灭「近似 JSON」这一类问题——不是靠事后校验,而是让格式错误的输出根本无法产生。

支持程度参差不齐,值得实测而非假设。在我们的测试中,OpenAI 和 Anthropic 都能正确遵守 schema;DeepSeek 的支持较弱。如果你要从扫描件里抽取,有一点值得知道:schema 约束可以和图像输入并存,所以视觉抽取并不被排除在这一层之外。

除非你确定要这么做,否则不要给值定类型。

陷阱就在这里,我们花了一个认真的下午才看清楚。我们自己的字段定义把 unit_pricetotal_price 声明为 number。而我们的提示词要求它们是字符串——"49.0000",明确不是 49.0——因为在报关单里,末尾的零是数值的一部分,而 JSON 数字会悄悄丢掉它们。49.0000 变成 49.0,一个必须与纸质表单精确到小数点后四位的数字,就不再吻合了。

同样的冲突出现在 quantity 上:它被声明为标量,而提示词要求它是字符串数组、且允许重复,因为表格中的一个单元格确实可能装着三行内容。

所以我们的 schema 只钉死键名和对象形状,不给值定类型。所有标量都以字符串形式传输。两个测试守着这条线:生成的 schema 中任何位置都不得出现数字类型,且每个标量位置也必须接受字符串数组。放松其中任何一条,都是一条通往金额被破坏的直路。

一般性原则:schema 是格式契约,不是校验层。用它保证形状由你自己校验,因为只有你能决定遇到坏值时该怎么办。

第三层:只对失败的字段重新提问

即便有了前两层,仍会有文档返回时缺少某个字段或格式不对。本能反应是重跑整份抽取。这在成本和质量上都是错的:你为整份文档再付一次钱,而在推理模型上盲目重试,通常会撞上第一次撞的同一堵墙。

更好的做法:校验结果,收集具体失败的字段,只针对它们提问。

在你上一次的回答中,以下必填字段缺失或无效:
  - export_date(必填,未出现)
  - total_value(出现了,但不是字符串)

请重新查看文档,返回一个仅包含这些键的 JSON 对象。如果文档中确实没有
某个字段的值,请为它返回 null,而不要猜测。

最后一句必不可少。没有它的重新提问,等于在邀请模型编造:你告诉了模型它做错了,而阻力最小的路径就是编一个看起来合理的答案。明确允许回答「没有这个值」,才让第二遍变得安全。

然后按一条你在跑之前就写下来的规则做合并。我们的规则是:只有当错误答案不增加、缺失答案减少时,才接受合并。一次用一个遗漏换来一个错误的第二遍不是改进——而在没有明文规则时,人很容易说服自己那就是改进。

先测量,再打开

我们把这个回路建好了,然后测量了它,而测量的结论是:还不到时候。

在我们唯一能拿到的那份文档上,基线抽取已经是 85 个叶子值中的 85 个全对——两个公司代码、一个含重复行的三行数量单元格、每一个带末尾零的价格。验证回合给出的还是同样的 85。它标记了一个字段 export_date,而表单上对应的那一栏确实是空的。回路问了它唯一的那个问题,得到了正确的沉默,什么也没有改动。多了一次调用,零处变更。

按我们自己的合并规则,这一支不通过。所以代码写好了、测试覆盖了,并用开关关着,直到我们拿到带有真实遗漏的文档来测量它为止。

从中值得带走两点。

确立基线花了四轮清点,而每一处不一致最后都被证明是我们参照数据的错误,而不是抽取的错误。我们弄错了字段对应表单上的哪一栏;没注意到某个代码位于同一单元格的第二行;漏看了一个印刷前缀。审视自己的尺子,要和审视被测之物一样严格。

校验器给出的数量是上界,不是估计值。 我们的校验器报告 27 份已存文档中有 10 份存在缺失值——但没有源文件时,真正的遗漏与表单上的空白栏无法区分。而我们唯一能对着源文件核对的那一例,结果是空白栏。

要点回顾

  • 抽取失败在格式,不在理解。 模型通常找到了值,只是把它归档在了你的代码不认识的名字下面。
  • 在提示词里钉死确切键名,并由 schema 生成这份清单。 这是最便宜的一层,消除的失败类别也最大。
  • 供应商 schema 用来管形状,不要用来管类型。 把一个值声明为 number,会悄悄吃掉那些让报关金额与纸质表单吻合的末尾零。
  • 只对失败的字段重新提问,并明确允许「没有这个值」。 盲目的整体重试更贵,还会诱发编造。
  • 在测量之前写下合并规则,并先检查你的参照数据。 我们最初的四处不一致,四次都是参照数据的错误,而非抽取的错误。

常见问题

有了 JSON schema,还需要在提示词里钉死键名吗?

需要,两者都要留。schema 并非处处支持,支持质量也参差不齐,而提示词里的那条指令几乎是免费的。两者同时存在时互相加强;schema 不可用时,提示词就是你仅有的手段。

为什么不干脆把数值字段声明为数字类型?

因为 JSON 数字会丢掉文档视为有意义的格式。49.0000 变成 49.0,一个必须与表单精确到四位小数的数字就不再吻合。请以字符串承载数值,并自己做校验。

响应里缺了某个字段,该怎么办?

先判断它是不是在文档里就没有。这是两个不同的事实,而只有其中一个是缺陷。请指示模型在确实缺失时返回 null,好让你的校验器能区分二者。

验证回合值得多花一次调用吗?

只有在你测量出它确实有帮助时才值得。我们的验证回合在一份基线已然完美的文档上给出了零处修正,所以它保持关闭。请在带有真实遗漏的文档上测量——并确认那是真的遗漏,而不是空白栏。

如何在不为每次运行付费的情况下测试抽取?

把同样的页面图像和同样的提示词落到磁盘上,从那里驱动各个分支,而不是走线上 API。我们的测试台会存下生产调用本该发送的内容,这让对比可复现,也把付费调用压缩到真正能确立结论的那几次。

结语

结构化抽取在很大程度上是一个契约问题。模型通常在正确地读文档;出问题的是「它返回什么」与「你的代码期待什么」之间的那次握手。

按各层的成本顺序修这次握手:先用 schema 生成并钉死键名;在供应商支持的地方加上原生 schema 来约束形状;只有在测量出它确实能挽回真实损失之后,才加上定向的重新提问。而无论你构建什么,先诚实地确立基线——我们调查过的最昂贵的抽取缺陷,连着四次都是我们自己数错了。

KTTC 从报关单、登记簿摘录和民事记录中抽取字段,在那里丢失一个代码就意味着一份被驳回的文件。试用 KTTC,看看从你自己的表单里能返回什么。

We use cookies to improve your experience. Learn more in our Cookie Policy.