换模型后 JSON 出问题,先检查响应是否完整,再检查字段结构,最后核对业务事实。 JSON.parse 成功只说明文本能被解析;它不能证明金额、币种和订单号来自原文,也不能替你决定是否允许写入业务系统。
本文解决模型或供应商切换后的结构化输出验收,官方资料复核日期为 2026-10-08。报价和响应样本均为人工构造,没有执行付费模型比较,不提供模型准确率排名。与 供应商锁定 和 失败重试 不同,这里重点检查“返回的内容能否交给下游”。
先确认实际端点提供什么约束
将模型名、具体供应商、API 路径、SDK 版本、请求参数和 schema 版本一起记录。只把 model 改成新名字,不能证明其他条件保持一致。
| 方式 | 可以用来做什么 | 仍需要自己检查什么 |
|---|---|---|
| 提示词要求输出 JSON | 描述期望格式 | 是否夹带解释、字段缺失、类型错误 |
| JSON 模式 | 请求可解析的 JSON | 是否符合业务 schema、内容是否完整 |
| JSON Schema 结构化输出 | 在支持的接口上施加结构约束 | 该端点支持哪些约束、异常分支、业务事实 |
| 工具调用参数 | 接收待执行动作的参数 | 工具名、权限、参数与业务规则;生成参数不等于允许执行 |
OpenRouter 文档 当前明确说明,支持情况按端点区分,strict 的执行方式也因供应商而异。其结构化输出请求使用 response_format.type: "json_schema",可配合 provider.require_parameters: true 筛选支持所需参数的端点。这个筛选不能代替应用验收。
迁移时保存服务端实际发送的脱敏请求,检查 SDK 是否改写 schema、网关是否移除参数、失败后是否换到另一条路由。不支持时应明确失败或进入事先验收过的降级流程,不能静默退回普通文本后继续自动入库。
固定一份能区分缺失与零值的合同
用一个窄场景开始:从报价文本提取编号、币种、未税额、税额和总额。示例仅接受 CNY、USD,以分为单位的非负整数;退款、负数和其他币种不在本合同内。
示例原文:报价 Q-17,币种 CNY,未税额 100.00,税额 6.00,总额 106.00。
期望结果:
{
"quote_id": "Q-17",
"currency": "CNY",
"subtotal_minor": 10000,
"tax_minor": 600,
"total_minor": 10600,
"status": "complete"
}
应用合同要求六个键都存在。原文没有的信息用 null,并进入 needs_review,不得用零、空字符串或推算出的值补齐。这里的 status 只是模型提出的状态,下游校验器必须独立判断。
下面是应用侧 JSON Schema;完整 schema 文件 声明 Draft 7。它不是可以原样发送给每个供应商的通用请求体:API 只支持部分 schema 特性时,需要明确记录适配,并保留完整的应用侧校验。
{
"type": "object",
"properties": {
"quote_id": { "type": ["string", "null"] },
"currency": { "enum": ["CNY", "USD", null] },
"subtotal_minor": { "type": ["integer", "null"], "minimum": 0 },
"tax_minor": { "type": ["integer", "null"], "minimum": 0 },
"total_minor": { "type": ["integer", "null"], "minimum": 0 },
"status": { "enum": ["complete", "needs_review"] }
},
"required": ["quote_id", "currency", "subtotal_minor", "tax_minor", "total_minor", "status"],
"additionalProperties": false
}
required 控制键是否必须存在,允许 null 控制值可否未知;两者并不冲突。JSON Schema 的对象说明 区分了缺少属性与属性值为 null,也说明单列 properties 不会自动使字段必填。
用同一组负例检查新旧链路
下载 10 个合成验收样本,先检查自己的校验器,再将对应原文作为新旧模型的同题输入。文件中的 output 是手写测试输入,不是模型已经生成过的结果。
| 样本变化 | schema 应否通过 | 应用应如何处理 |
|---|---|---|
| 全部字段符合示例原文 | 通过 | 业务校验也通过后接受 |
删除 tax_minor |
不通过 | 记录缺字段,不默认填 0 |
tax_minor: null 但标记 complete |
通过 | 业务校验拒绝完成状态 |
总额写成字符串 "10600" |
不通过 | 不静默转换类型掩盖兼容问题 |
多出 approved: true |
不通过 | 不接受未知授权字段 |
| 币种为 EUR | 不通过 | 超出本示例支持范围 |
| 总额写成 10700 | 通过 | 金额不相等且不符原文,拒绝 |
| 币种改成 USD | 通过 | 枚举合法但原文为 CNY,拒绝 |
| 税额为负数 | 不通过 | 不符合这个非负报价合同 |
| 原文没写税额,输出 null 且待复核 | 通过 | 保留未知,进入复核,不自动发布 |
业务层还要检查编号非空且与原文对应、金额单位正确,以及 subtotal_minor + tax_minor = total_minor。上述等式只在三项都已知时计算。不能因为等式能反推税额,就把原文未提供的数据伪装成提取结果。
“结构正确但事实错误”必须独立计数。先用人工标注的原文与期望值验收;生产中无法可靠自动核对的事实,需要复核流程。仅让另一个模型回答“是否正确”,不能充当确定性金额校验。
拒答和截断先于字段校验处理
不要把所有响应都当作业务对象解析。先由服务适配层检查 HTTP/API 错误、拒答、结束原因、空内容和流是否完整,再抽取最终数据块交给解析器。
例如 Claude 结构化输出文档 说明,stop_reason: "refusal" 可以伴随 HTTP 200,输出未必符合 schema;max_tokens 表示内容可能被截断。这些字段名属于该接口,换供应商后需要按各自响应契约映射,不能只复制字符串判断。
| 响应状态 | 应用分支 | 不应做的事 |
|---|---|---|
| API 或参数不支持错误 | 配置失败,停止原样重试 | 删除约束后冒充验收通过 |
| 明确拒答 | 独立拒答状态,提供适当提示 | 填造业务对象绕过拒答 |
| 达到输出上限 / 流中断 | 未完成,保留原因 | 给半截 JSON 补括号后写库 |
| 空内容或 JSON 无法解析 | 解析失败 | 使用上一任务的缓存当作新结果 |
| schema 不符合 | 记录字段路径与规则 | 静默删字段、补默认值 |
| 业务事实不符合 | 复核或失败 | 仅凭模型的 complete 放行 |
在测试适配器时,可以构造拒答、截断和空内容响应;这些检查证明的是应用分支行为,不能推导真实模型拒答率。任何有界修复或重试都应保留原结果与修复记录,重新通过全部检查,并计入 统一重试预算。
按失败类别做迁移记录
冻结原文、schema、提示词和验收规则,一次只换一个变量。模型必须使用不同参数时,记录它为实际迁移配置,避免称作完全同条件比较。样本需覆盖正常、信息不足、矛盾字段、长输入和任务外文本;不要只选新模型容易通过的材料。
建议每次记录:样本ID / 模型与端点 / SDK与配置版本 / schema版本 / 请求ID / 结束状态 / JSON解析 / schema校验 / 事实校验 / 最终去向 / 耗时 / 已确认用量。重试后通过要另列,不能算作首次通过。
| 观察指标 | 分母与解释 |
|---|---|
| 首次业务验收通过数 | 除以全部提交样本;拒答和截断不能悄悄排除 |
| 解析与 schema 失败数 | 同时记录尚未进入这两步的响应状态 |
| schema 通过但业务失败数 | 揭示格式正确造成的虚假安全感 |
| 待复核数、拒答数、未完成数 | 分开统计,按业务决定可接受比例 |
| 重试后通过与费用未知数 | 保留恢复成本,不能用未知费用填零 |
这 10 个合成样本是回归检查起点,不是统计意义上的准确率测评。上线前预先约定阻断条件,例如任何错误金额进入“自动发布”分支都阻断迁移;低风险提取场景可以保留人工复核通道。先小范围使用、保留旧配置与回退入口,再根据真实失败记录扩大范围。
如果只改变提示词,想设计一份更通用的小型对照集,可接着看十例提示词对照验收法;那篇不替代本页的 schema 和业务事实检查。