Structured Outputs 与 JSON 模式:结构正确为何仍可能出错

以虚构发票为例,分清 JSON 语法、模式约束与金额真实性;用可运行的校验关处理缺字段、错误金额、拒绝、未完成响应和采购单权限。

A schema gate accepting a complete structured record and rejecting fragments

发票解析器即使输出了完全合法的 JSON,也可能把 19.00 美元读成 119.00 美元。接入方该问的不是“模型会不会吐出 JSON”,而是“这一环节检查了什么,单据无法支持某个值时会怎样停下来”。以下用一张虚构发票贯穿说明:Invoice INV-1042; PO PO-778; Total USD 19.00. 应用只打算据此向既有采购单提出应付建议,并不授权模型付款。

先选交接处需要的约束

在普通提示词里写“请用 JSON 回答”,只是格式要求。JSON 模式约束的是 JSON 语法;{"invoice_id":"INV-1042","total":"19.00"} 虽可解析,却少了应用必需的 po_id 和 currency。没有可用的模式约束输出时,JSON 模式仍有用,但调用方必须自行校验并拒绝不合格记录。按 OpenAI 的说明,还须明确要求输出 JSON;响应被截断时,拿到的片段也未必可解析,不能视为完整记录。OpenAI:JSON 模式

本例是在提取发票数据,适合让模型返回结构化响应,供应用检查。OpenAI 的 Structured Outputs 在受支持的模型、接口配置与 JSON Schema 子集内,可约束输出遵循给定模式。如果模型要请求应用执行某项功能,则用带严格模式的函数工具约束参数;工具调用只是向代码提出请求,不等于获得执行权限。应用仍须先校验、再鉴权。模式不合要求或超出支持范围时,请求可能直接报错;部署前要核对具体模型、API 接口和模式,而非把任意 JSON Schema 都当作受支持的保证。OpenAI:Structured Outputs;OpenAI:函数调用的严格模式

把“未知”和“未完成”分开

为这个示例规定 invoice_extract_v1:七个键 version、status、invoice_id、po_id、total、currency、evidence 都必须出现,不接受额外键。状态只有 ready 与 needs_review;四项发票数据及原文片段 evidence 可以是字符串或 null。ready 时值和证据必须齐全;单据缺字段或互相矛盾时,未解决字段设为 null 并进入 needs_review,不要把未知金额编成零,也不要猜币种。金额以十进制字符串表示,避免二进制浮点数的误差。这是本文的应用层约定,提交给服务商前还要转换为该接口支持的模式。JSON Schema 的 required 只规定键是否存在;允许字段为 null 须在类型里写明,键存在也不意味着值真实。JSON Schema:必需属性

下面的简化模式能约束键、类型与状态枚举,却不负责“ready 必须有全部值”或“值与原单据一致”。所用关键字在严格输出中较常见,但正式接入仍须以选定 API 与模型的支持情况为准。

{"type":"object","properties":{
  "version":{"type":"string","enum":["invoice_extract_v1"]},
  "status":{"type":"string","enum":["ready","needs_review"]},
  "invoice_id":{"type":["string","null"]},
  "po_id":{"type":["string","null"]},
  "total":{"type":["string","null"]},
  "currency":{"type":["string","null"]},
  "evidence":{"type":["string","null"]}
},"required":["version","status","invoice_id","po_id","total","currency","evidence"],"additionalProperties":false}

服务商返回的拒绝信号不属于 needs_review,不能硬当发票数据解析。因输出上限等原因未完成的响应也不是完整提取:先检查原因,只有适合限次重试时才重试。传输错误和模式请求错误同样各走各的分支。只有已完成且未拒绝的响应才交给以下提取校验器;严格输出也不能省去这道状态判断。OpenAI:拒绝与未完成响应

形状正确,不代表读数正确

对上面的虚构发票,下面这条记录有全套键与合适的类型:

{"version":"invoice_extract_v1","status":"ready","invoice_id":"INV-1042","po_id":"PO-778","total":"119.00","currency":"USD","evidence":"Invoice INV-1042; PO PO-778; Total USD 19.00."}

但 total 写成了 119.00,而原文只有 19.00。模式可规定它是字符串,却不能证明这个金额来自发票。即使提取金额与单据吻合,也可能与受信任系统中的采购单金额不符;应用还得比对采购单,并确认请求者有权操作这张采购单。模型引用的证据片段方便复核,并不因被引用就自动可信。

运行一道下游校验关

下面的纯标准库演示接收已完成、未拒绝的提取结果。它不是 OpenAI API 模拟器,也不是通用 JSON Schema 校验器;它只检查本文七字段约定、固定格式的示例原文、外部传入的采购单金额和授权集合。保存为 invoice_gate.py 后运行 python invoice_gate.py。正式系统仍需使用合适的模式校验器与可信单据、订单来源,不能拿这段简单的原文匹配当防欺诈机制。

import json
import re
from decimal import Decimal, InvalidOperation

SOURCE = "Invoice INV-1042; PO PO-778; Total USD 19.00."
KEYS = {"version", "status", "invoice_id", "po_id", "total", "currency", "evidence"}
GOOD = {"version": "invoice_extract_v1", "status": "ready",
        "invoice_id": "INV-1042", "po_id": "PO-778", "total": "19.00",
        "currency": "USD", "evidence": SOURCE}


def gate(raw, source, order_totals, authorized_pos):
    def reject(reason):
        return "REJECT: " + reason

    try:
        record = json.loads(raw)
    except json.JSONDecodeError:
        return reject("invalid JSON")
    if not isinstance(record, dict) or set(record) != KEYS:
        return reject("contract keys")
    if record["version"] != "invoice_extract_v1":
        return reject("unknown version")
    if record["status"] not in ("ready", "needs_review"):
        return reject("status")
    fields = ("invoice_id", "po_id", "total", "currency", "evidence")
    if any(value is not None and not isinstance(value, str)
           for value in (record[key] for key in fields)):
        return reject("field type")
    if record["status"] == "needs_review":
        return "REVIEW: unresolved extraction; no action"
    if any(not record[key] for key in fields):
        return reject("ready requires all values")
    if record["evidence"] not in source:
        return reject("evidence not in source")
    # Demonstration grammar for this one sample, not general invoice OCR.
    match = re.fullmatch(
        r"Invoice (INV-\d+); PO (PO-\d+); Total (USD) (\d+\.\d{2})\.",
        record["evidence"])
    if not match or tuple(record[k] for k in
                          ("invoice_id", "po_id", "currency", "total")) != match.groups():
        return reject("values do not match source")
    try:
        amount = Decimal(record["total"])
    except InvalidOperation:
        return reject("amount")
    po = record["po_id"]
    if po not in order_totals or (record["currency"], amount) != order_totals[po]:
        return reject("order amount/currency mismatch")
    if po not in authorized_pos:
        return reject("user not authorized for order")
    return "PASS: proposal may enter approval; do not pay automatically"


def run(label, record, authorized={"PO-778"}):
    print(label, gate(json.dumps(record), SOURCE,
                      {"PO-778": ("USD", Decimal("19.00"))}, authorized))


if __name__ == "__main__":
    run("correct", GOOD)
    run("missing key", {k: v for k, v in GOOD.items() if k != "currency"})
    run("wrong total", {**GOOD, "total": "119.00"})
    run("unauthorized", GOOD, set())
    run("unknown", {**GOOD, "status": "needs_review", "total": None})

结果应当是:正确记录 PASS,缺键、错误金额和无权限分别 REJECT,未知金额走 REVIEW。即使通过,代码也只是允许进入审批流程,并不会自动付款。

按失败原因分流,再做回归测试

观察到的状态调用方处理
临时传输故障有上限地重试,并做幂等保护,避免重复动作。
已完成响应却不是合法 JSON,或缺少约定键拒绝;至多尝试一次纠正性提取,再转人工。若使用严格输出,还要排查配置。
服务商拒绝或响应未完成走 API 对应分支,不作为完整发票反序列化;只有中断原因可修复才考虑重试。
原单据缺字段或内容冲突索取更清楚的单据或人工核对,不让模型反复猜。
与原文或采购单不符、无采购单权限阻断动作并升级处理;重新生成答案不能赋予权限。
严格模式不支持该模式或请求报错修正模式或更换受支持配置,不原样反复请求。

把 invoice_extract_v1 与模型标识、提示词版本、校验器版本一并记录;遇到未知版本直接拒绝,不猜字段含义。回归集至少包含代码里的五种结果,并加入缺币种、矛盾金额、其他币种、重复发票号,以及在 API 边界模拟的截断与拒绝。按类别统计失败,扩大自动化之前核对真实单据证据。若要进一步考虑自动化购物中的付款权限,可参阅 Meydo Journal 关于 AI 代理购物与付款控制的文章。