结构化输出中列表截断最常见,但原因分四种:max_tokens 截断、上下文边界漏页、minItems 约束被静默忽略、模型真正漏记。诊断顺序按成本从低到高排列。
你请求提取发票上的每一行项目。共有十行。你得到了七行,JSON 验证通过,没有任何地方报错。这是结构化输出中被报告最多的一类 bug,而且至少有一半的时候模型并不是根因。
按这个顺序诊断,因为前三种排查成本低,第四种修复成本高。每次提取调用都记录 finish_reason、usage.completion_tokens 和你发送的文本长度,前两个问题自己就会浮出水面。
minItems 和 maxItems 位于托管 strict 模式文档支持关键字集之外。在不同的技术栈下,发送它们要么得到一个指出该关键字的 400——很好,你立即就知道了;要么得到一个 200,但关键字被悄悄丢弃了。
第二种情况才是陷阱,因为你的 schema 现在变成了一条注释。你以为底数被强制执行了,API 返回了成功,数组却很短。没有哪个日志说这个约束从未被应用。先确认你的端点属于哪一种,再依赖它——无论哪种情况,都要在数组的 description 里写上"每一项都要,不要总结或跳过",因为这会传达给模型,无论该关键字是否存活。
没有计数器。每个 token 从上下文生成,而上下文包含已经输出的项——所以"我是否得到了全部"不是一次查找,而是模型在每个数组元素处根据它能看到的内容重新做出的一次判断。两个结构性后果:
相似项互相竞争。六个只在数字上不同的近邻行是最难的情况,因为已输出的前缀看起来就像是剩余工作的延续。这就是为什么带有重复结构的表格数据是这类 bug 集中的地方。
停止也是一个普通的 token。关闭数组是一个与继续输出竞争的决定。任何让文档感觉已经完成的东西——摘要行、总计、分页符——都会提高输出闭括号的概率。
Schema 设计的推论:永远不要在数组之前请求总数。先输出的 total_items 字段会迫使模型做出一个它随后必须满足的承诺,而它会产生一个与数组不一致的数字。如果在数组之后输出,同样的字段没有任何成本,还给你一个免费的内部一致性检查——len(items) != total_items 是一个真实的信号,表明模型知道自己已经跟丢了。
最强修复把一个开放式召回问题转化为逐项决策。给输入中的候选行编号,并要求输出把编号带回来:
user message:
Extract every line item. The document lines are numbered.
Return one object per line that is a line item, carrying its line number.
Do not skip lines. If a line is not a line item, omit it.
[L001] Widget, large 2 12.50 25.00
[L002] Bolt M6 40 0.15 6.00
[L003] --- subtotal --- 31.00
[L004] Delivery 1 4.95 4.95
schema: items[]: { source_line: string, description: string, qty: number,
unit_price: number, line_total: number }
三件事同时改变。模型现在要四次判断"L003 是行项目吗",这比一次判断"我找到全部了吗"要容易得多。输出可以在没有标准答案的情况下与输入对照检验,因为你知道哪些行号存在。而且排除变得可见了:被正确省略的小计行在外观上与漏掉的行完全相同,直到行号告诉你发生了什么。
def reconcile(sent_lines: list[str], items: list[dict]) -> dict:
"""sent_lines: the [Lnnn] labels you put in the prompt, in order."""
expected = set(sent_lines)
got = [i["source_line"] for i in items]
return {
"missing": [l for l in sent_lines if l not in set(got)], # unexplained
"hallucinated": [g for g in got if g not in expected], # not sent
"duplicated": [g for g in set(got) if got.count(g) > 1],
"coverage": round(len(set(got) & expected) / max(len(expected), 1), 3),
}
# Then, in the pipeline:
r = reconcile(sent, items)
if r["hallucinated"]:
fail(record, "cited lines that were not in the input") # never retry into this
if r["coverage"] < 0.80:
retry_smaller_chunks(record) # a chunking problem
# "missing" is expected and informative: those are the lines the model
# judged not to be items. Sample ten of them by hand once a week.
注意 missing 不是什么。它不是错误列表——小计行应该 missing。它是一个审查队列,是你唯一能看到的模型排除决策的窗口,否则它们完全不可见。一个悄悄丢弃每一条折扣行的模型会出现在这里,不会出现在任何其他地方。
如果因为输入没有天然的行而无法枚举,备选方案是两次 pass:一次调用只列出标识符——输出短,截断风险低;然后每个标识符一次调用提取其字段。这需要更多调用,但对长文档来说显著更可靠,因为两个 pass 都不需要同时做召回和提取。
还有两个小效应来兜底。顺序不保证,你不应该依赖它:要求模型按文档顺序输出项通常会照做,但没有任何东西强制执行它,所以如果顺序重要,就提取排序键并在代码中排序。而重复与遗漏表现相反——uniqueItems 和 minItems 一样位于支持关键字集之外,所以重复项也无法被阻止。重叠的分块使重复成为预期结果而非异常,这就是为什么去重键属于你的合并步骤,而不是 schema。
最后,抵制通过调高 temperature 或在 prompt 里加"要彻底"来修复短数组的冲动。这两个都不针对四个原因中的任何一个;前者用虚构的项换遗漏的项,后者是那种在你测试的三份文档上看起来有效的改动。给四个原因上仪器,找到你遇到的是哪一个,然后修复那个。
Extracting Structured Data From Messy Documents
Optional Fields, Nulls and Unions: Where Schemas Break
Validation and Repair: What to Do With Malformed Output