# 结构化输出与校验

## 为什么“返回 JSON”仍不够

你准备把用户聊天转成工单，前端希望直接展示“分类、摘要、优先级”。如果只让模型自由回答，再用正则找字段，就会把排版变化误当成协议变化。结构化输出用于让模型遵循明确的数据形状；本章的目标，是使你能定义一个稳定合同，并知道合同保证到哪里为止。

前置是 JSON、TypeScript 类型以及上一章的提示构造。需要特别改变一个习惯：TypeScript 类型断言不会验证运行时数据。把 JSON.parse 的结果写成 Ticket，并不能阻止模型返回未知分类、字符串金额或越权客户编号。外部输入必须当 unknown 处理，经过实际检查后才能进入业务层。

## 四层保证不能相互代替

第一层是语法：字符串能否被 JSON.parse 解析。代码围栏、尾逗号或解释性前缀都会让它失败。第二层是结构：字段是否存在、类型是否正确、枚举是否允许、是否包含未知属性。第三层是业务语义：金额非负是否够用，退款金额是否超过原订单，日期范围是否合理。第四层是权限：当前用户是否可以修改该工单或查看该客户。

结构化生成主要改善前两层，不能自动证明后两层。模型可以返回完全符合 schema 的“退款金额一百万元”，仍与真实订单不符。即使输出一个存在的客户 ID，也不能证明用户有权操作。数据库事实、业务约束和授权检查必须在执行前完成，不能因为模型声称“已确认”而跳过。

OpenAI Responses 的结构化格式通过 text.format 配置，使用 type: json_schema、name、schema 和 strict。它支持 JSON Schema 的一个子集；严格模式下应按官方要求描述 required 和 additionalProperties。可空字段可以显式允许 null，表示信息未知，避免诱导模型为了填字段而编造。[官方结构化输出指南](https://developers.openai.com/api/docs/guides/structured-outputs)

## schema 应服务于产品语义

先定义状态，再定义字段，比一口气列几十个属性更稳妥。例如工单草稿需要 summary 与 category，而涉及金额的问题还需要 amountCents。若用户没有说金额，它应是 null。这里的 null 是“尚未知晓”，零则是“确定金额为零”，空字符串又是另一个状态。混用会让表单默认值和后端更新语义出现歧义。

枚举要稳定且适合代码使用，显示文案交给界面映射。模型返回 billing，前端展示“账务问题”，比让模型自行选择“收费”“账单”“付款异常”更易维护。未知分类不是随意加一个新枚举，而应有明确的 other 及后续处理规则。

输出合同还应有版本。新增必填字段可能使旧客户端无法展示旧记录；收紧枚举可能导致历史数据验证失败。实践中保存 schemaVersion，读取历史数据时按版本迁移，生成和执行使用一致版本。不要仅改提示而忘了前端表单与服务端验证器。

## 完整示例：专用验证器与三组 fixture

环境：Node.js 22；文件 `structured-output.mjs`；无依赖；运行 `node structured-output.mjs`。代码中的 schema 可以用于构造 OpenAI Responses 请求，但本例只校验静态字符串，不调用模型。validateDraft 是这个工单合同的专用验证器，不冒充完整 JSON Schema 引擎。

```js
const schema = {
  type: 'object',
  properties: {
    summary: { type: 'string' },
    category: { type: 'string', enum: ['billing', 'technical', 'other'] },
    amountCents: { type: ['integer', 'null'] },
  },
  required: ['summary', 'category', 'amountCents'],
  additionalProperties: false,
};

const textFormat = {
  format: { type: 'json_schema', name: 'ticket_draft', strict: true, schema },
};

function validateDraft(raw, orderAmountCents) {
  let value;
  try { value = JSON.parse(raw); }
  catch { return { ok: false, stage: 'syntax', reason: '不是有效 JSON' }; }
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    return { ok: false, stage: 'schema', reason: '根节点必须是对象' };
  }
  const expected = ['summary', 'category', 'amountCents'];
  if (Object.keys(value).length !== expected.length ||
      !expected.every(key => Object.hasOwn(value, key))) {
    return { ok: false, stage: 'schema', reason: '字段缺失或含未知字段' };
  }
  if (typeof value.summary !== 'string' ||
      !schema.properties.category.enum.includes(value.category) ||
      !(value.amountCents === null || Number.isSafeInteger(value.amountCents))) {
    return { ok: false, stage: 'schema', reason: '字段类型或枚举错误' };
  }
  // 这里的订单金额应来自已经鉴权的业务查询，不能由模型自报。
  if (!value.summary.trim() || value.summary.length > 120 ||
      (value.amountCents !== null &&
       (value.amountCents < 0 || value.amountCents > orderAmountCents))) {
    return { ok: false, stage: 'business', reason: '摘要或金额违反业务规则' };
  }
  return { ok: true, value };
}

const fixtures = [
  '{"summary":"重复扣费","category":"billing","amountCents":500}',
  '{"summary":"申请退款","category":"billing","amountCents":999999}',
  '这是一段解释，不是 JSON',
];
for (const raw of fixtures) {
  console.log(JSON.stringify(validateDraft(raw, 1000)));
}
console.log('format=' + textFormat.format.type);
```

预期第一条 ok 为 true，第二条 stage 为 business，第三条 stage 为 syntax，最后打印 format=json_schema。把 amountCents 改为字符串“500”会得到 schema 错误；增加 ownerId 字段也会被拒绝。程序没有自动把字符串转成数字，因为悄悄修复类型容易掩盖契约漂移。

代码先解析，再检查根节点与字段集合，最后执行业务规则。这样的顺序让错误可定位：上游没生成 JSON、结构不一致、事实不满足条件，是三种不同问题。orderAmountCents 在函数参数中单独提供，表达它来自可信业务查询，而不是被模型一起返回。

## 失败时应该怎样恢复

模型有可能拒绝生成，或者因输出预算耗尽而只产生部分内容。不要对拒绝文本强行 JSON.parse，也不要通过补一个右括号假装截断结果完整。应先读取响应状态与内容类型，再决定是否存在可解析的结构化候选；拒绝和截断应有各自业务状态。

如果结构验证失败，可以进行一次受限修复，提供简短的字段错误说明并要求重新输出；修复仍需再次验证。不要把完整数据库错误、内部路径或其他客户信息回传给模型。对于业务错误，通常应该重新获取事实或要求用户补充，而不是不断让模型猜一个能通过校验的值。

允许模型直接控制界面也有边界。例如让模型生成任意 HTML 或任意组件 props，会引入脚本注入、事件执行及越权按钮的问题。更稳妥的是让模型选择有限的语义组件与受限属性，前端通过已注册组件渲染，链接协议和文本长度也要校验。结构化输出是数据合同，不是安全渲染许可证。

前端可以把已校验的结果作为“草稿”填入表单，显示需要用户核对的字段。保存按钮仍调用正常业务 API。模型建议的优先级可以解释给用户，但不应被当成不可修改的权威判断。对高影响操作，先预览实际差异，再用业务服务确认执行。

监控也要分层。语法错误率高可能说明接入方式或格式设置有问题；schema 错误率高可能是版本不一致；业务错误率高可能是材料缺失或任务本身不适合自动完成。把三者合并成“模型错误率”会丢掉改进方向。

## 从字段集合走向明确的业务状态

当模型需要返回“已提取的退款草稿”或“信息不足，请补金额”时，一个全是可空字段的对象很容易产生矛盾。例如 status 是 ready，amountCents 却是 null；又或者已经给出完整金额，同时 questions 里还在询问金额。问题不在 JSON 格式，而在合同没有表达哪些字段可以同时成立。应该先定义业务状态，再检查每个状态允许的数据组合。

在后端内部可以使用有判别字段的联合类型：ready 携带完整草稿，needs_input 携带待补充字段，rejected 携带原因。供应商支持的 JSON Schema 子集不一定允许你把内部联合类型原样放在根节点，因此生成合同可以采用固定外层对象，内部用字段约束表示分支，再在本地验证跨字段关系。不要看到 TypeScript 能表达某类型，就假设供应商可以约束同一形状。

“字段必须存在”和“字段值不能未知”是两个不同要求。某些严格生成合同要求全部字段列入 required，但字段类型可以允许 null；这并不是要求模型编造未知值。以发票抬头为例，title: null 表示材料未提供，title: "" 表示提供了空字符串，两者都可能不能提交，但界面应显示不同的解释。只要约定清晰，可空字段反而能降低模型硬凑答案的倾向。

更新接口还存在第三种语义：不修改。对 PATCH 来说，字段缺席可能表示保留原值，null 可能表示清空，字符串表示设置新值。如果直接把“提取不到所以返回 null”的模型对象作为 PATCH 请求，就可能清空用户原来的信息。生成草稿与提交命令最好采用不同类型，由用户确认或业务映射明确产生 changes，而不是让两个看似同名的对象隐式兼容。

数值应贴合业务单位。金额使用最小货币单位整数可避免十进制浮点误差，但仍需检查安全整数和币种；“100”不能自动解释成一百元或一百分。百分比可以选择整数基点或约定小数区间，日期可以选择明确的日历日期而非本地时区时间戳。单位与时区属于合同的一部分，不能依靠提示中偶尔出现的一句说明维持一致。

## 示例二：把模型结果转换为待确认草稿

环境为 Node.js 22，无需安装依赖。保存为 `draft-boundary.mjs`，执行 `node draft-boundary.mjs`，预期输出 ready 与 needs_input 两种结果，并通过越权、未知字段和矛盾状态断言。这里的候选字符串是固定 fixture，订单金额来自本地可信业务对象；没有调用真实模型，也没有提交退款。

```js draft-boundary.mjs
import assert from 'node:assert/strict';

function parseCandidate(raw, { actorId, order }) {
  if (order.ownerId !== actorId) throw new Error('FORBIDDEN');
  let value;
  try { value = JSON.parse(raw); }
  catch { throw new Error('INVALID_JSON'); }
  if (!value || Array.isArray(value) || typeof value !== 'object') {
    throw new Error('INVALID_OBJECT');
  }
  const keys = ['status', 'amountCents', 'reason'];
  if (Object.keys(value).length !== keys.length ||
      !keys.every(key => Object.hasOwn(value, key))) {
    throw new Error('UNKNOWN_OR_MISSING_FIELD');
  }
  if (!['ready', 'needs_input'].includes(value.status) ||
      typeof value.reason !== 'string' || !value.reason.trim() ||
      value.reason.length > 200) throw new Error('INVALID_FIELD');
  if (value.status === 'needs_input') {
    if (value.amountCents !== null) throw new Error('CONTRADICTORY_STATE');
    return { status: 'needs_input', field: 'amountCents',
      question: '请确认要申请的退款金额。' };
  }
  if (!Number.isSafeInteger(value.amountCents) ||
      value.amountCents <= 0 || value.amountCents > order.paidCents) {
    throw new Error('INVALID_AMOUNT');
  }
  // 模型只建议内容，归属和版本由服务端追加，不能由模型指定。
  return { status: 'ready', draft: {
    orderId: order.id, expectedVersion: order.version,
    amountCents: value.amountCents, reason: value.reason.trim()
  }, requiresConfirmation: true };
}

const context = { actorId: 'u1',
  order: { id: 'O1', ownerId: 'u1', paidCents: 1000, version: 4 } };
const ready = parseCandidate(
  '{"status":"ready","amountCents":500,"reason":"重复扣费"}', context);
const missing = parseCandidate(
  '{"status":"needs_input","amountCents":null,"reason":"未说明金额"}', context);
assert.equal(ready.draft.expectedVersion, 4);
assert.equal(ready.requiresConfirmation, true);
assert.equal(missing.field, 'amountCents');
assert.throws(() => parseCandidate(
  '{"status":"ready","amountCents":null,"reason":"申请退款"}', context),
  /INVALID_AMOUNT/);
assert.throws(() => parseCandidate(
  '{"status":"needs_input","amountCents":500,"reason":"金额不明"}', context),
  /CONTRADICTORY_STATE/);
assert.throws(() => parseCandidate(
  '{"status":"ready","amountCents":500,"reason":"退款","ownerId":"admin"}',
  context), /UNKNOWN_OR_MISSING_FIELD/);
assert.throws(() => parseCandidate('{}', { ...context, actorId: 'u2' }),
  /FORBIDDEN/);
console.log({ ready: ready.status, missing: missing.status });
```

程序首先确认当前用户拥有订单，避免在未经授权的对象上开展后续业务处理。然后执行 JSON 语法和精确字段检查，最后检查分支语义。先后顺序让错误原因稳定，也避免从错误消息中暴露不必要的订单事实。示例直接抛出错误便于观察，真实 HTTP 层应把这些内部代码映射成适当的用户提示和状态码。

ready 的返回值含 requiresConfirmation，表达“形成了一个有效草稿”，而不是“退款已经完成”。expectedVersion 用于后续提交时检查订单是否发生变化。如果用户停留在预览页期间订单又退过一次款，保存接口必须重新读取当前可退金额并比较版本。生成时通过的业务校验没有永久有效期，这与前端表单在提交时仍需服务端校验是同一个道理。

needs_input 的 question 是应用生成的固定文案，故意没有直接把模型 reason 作为任意界面内容。实际产品可以展示经过文本处理的模型说明，但关键交互应来自稳定状态码。这样能翻译文案、统计漏字段原因，并避免模型错误地向用户索取不需要的信息。对 Vue 页面来说，一个清晰的状态联合通常比一个包含所有可能字段的大对象更容易正确渲染。

## 拒绝、截断与修复应使用不同的状态机

结构化输出的解析入口不能只接受一个字符串。调用层应先把供应商响应分类为完成且有候选文本、拒绝、未完成和调用失败。只有第一种进入 JSON 解析。拒绝可能由专门的内容项表达，不能假设总会符合你要求的业务 schema；未完成可能带不完整原因，也不能把局部 JSON 合并到上一轮结果继续使用。

对一个截断的退款对象补右括号特别危险。它可能刚输出 amountCents，还没有输出限制条件；你得到的语法正确对象会让下游误以为整个合同已经完成。正确处理是丢弃这次未完成候选，记录输出预算与截断原因，再决定缩小任务或在预算允许时重新生成。若重新生成，仍是一份新的候选，需要从头校验。

修复可以解决表达层错误，却不该替代查事实。模型把金额写成字符串，可给出“amountCents 必须为整数或 null”的有限反馈；模型写出超出订单总额的退款金额，应先确认用户意图和真实剩余额度。反复把业务错误回传给模型，直到它猜中一个合法值，会把“符合验证器”误当成“符合用户真实需求”。

修复循环还要计入总调用预算。初次生成最多一次修复，是应用策略，不是结构化输出接口的默认行为。应保存原始失败类型、修复次数以及最终结果，避免只记录修复成功的样本而高估质量。失败内容可能包含用户数据，日志宜记录字段路径、错误代码和必要片段，不必保留整个敏感请求体。

## 练习：实现带路径错误的版本二验证器

现在为工单草稿增加 priority，允许 low、normal、high；金额保持可空；摘要需去掉首尾空白后为一到一百二十个字符。业务规则规定 technical 且摘要含“无法登录”时，不能自动分为 low。验证器必须区分 syntax、schema、business 三阶段，返回具体字段路径，不得静默删除未知字段，也不得把字符串金额转换成数字。

提示：先从根对象和字段集合开始，再统一收集字段错误。只有 schema 阶段完全通过才进入业务阶段，否则比较字符串与数字可能产生隐式转换。不要写一个声称支持任意 JSON Schema 的半成品；本练习只实现这一个明确合同，让范围和测试一致。

<details><summary>参考答案：完整验证器与边界样本</summary>

Node.js 22，无依赖。保存为 `validate-v2.mjs`，执行 `node validate-v2.mjs`，预期输出 `v2 validation checks passed`。这是一份独立程序，不依赖前面的函数或全局变量。

```js validate-v2.mjs
import assert from 'node:assert/strict';

function validate(raw, paidCents) {
  const fail = (stage, errors) => ({ ok: false, stage, errors });
  if (!Number.isSafeInteger(paidCents) || paidCents < 0) {
    throw new Error('INVALID_TRUSTED_ORDER');
  }
  let x;
  try { x = JSON.parse(raw); }
  catch { return fail('syntax', [{ path: '$', code: 'invalid_json' }]); }
  if (!x || typeof x !== 'object' || Array.isArray(x)) {
    return fail('schema', [{ path: '$', code: 'expected_object' }]);
  }
  const fields = ['summary', 'category', 'amountCents', 'priority'];
  const errors = [];
  for (const field of fields) {
    if (!Object.hasOwn(x, field)) errors.push({ path: field, code: 'required' });
  }
  for (const key of Object.keys(x)) {
    if (!fields.includes(key)) errors.push({ path: key, code: 'unknown_field' });
  }
  if (typeof x.summary !== 'string') {
    errors.push({ path: 'summary', code: 'expected_string' });
  }
  if (!['billing', 'technical', 'other'].includes(x.category)) {
    errors.push({ path: 'category', code: 'invalid_enum' });
  }
  if (!['low', 'normal', 'high'].includes(x.priority)) {
    errors.push({ path: 'priority', code: 'invalid_enum' });
  }
  if (!(x.amountCents === null || Number.isSafeInteger(x.amountCents))) {
    errors.push({ path: 'amountCents', code: 'expected_integer_or_null' });
  }
  if (errors.length) return fail('schema', errors);
  const summary = x.summary.trim();
  if (summary.length < 1 || summary.length > 120) {
    errors.push({ path: 'summary', code: 'invalid_length' });
  }
  if (x.amountCents !== null &&
      (x.amountCents < 0 || x.amountCents > paidCents)) {
    errors.push({ path: 'amountCents', code: 'exceeds_order' });
  }
  if (x.category === 'technical' && summary.includes('无法登录') &&
      x.priority === 'low') {
    errors.push({ path: 'priority', code: 'login_cannot_be_low' });
  }
  return errors.length ? fail('business', errors) :
    { ok: true, value: { ...x, summary }, schemaVersion: 2 };
}
const good = { summary: ' 无法登录 ', category: 'technical',
  amountCents: null, priority: 'normal' };
assert.equal(validate(JSON.stringify(good), 1000).value.summary, '无法登录');
assert.equal(validate('not json', 1000).stage, 'syntax');
assert.equal(validate(JSON.stringify({ ...good, ownerId: 'u1' }), 1000)
  .errors[0].code, 'unknown_field');
assert.equal(validate(JSON.stringify({ ...good, amountCents: '500' }), 1000)
  .stage, 'schema');
assert.equal(validate(JSON.stringify({ ...good, priority: 'low' }), 1000)
  .errors[0].code, 'login_cannot_be_low');
assert.equal(validate(JSON.stringify({ ...good, amountCents: 1001 }), 1000)
  .stage, 'business');
const { priority, ...old } = good;
assert.ok(validate(JSON.stringify(old), 1000).errors
  .some(e => e.path === 'priority' && e.code === 'required'));
console.log('v2 validation checks passed');
```

</details>

返回路径让前端能把错误落到对应字段，用户不必面对整段“校验失败”。但错误路径本身也来自验证器，不应直接执行成对象写入路径。若未来支持嵌套字段，要使用经过约束的路径表示和安全访问方式，避免把外部字符串当成任意属性表达式。当前示例使用固定字段名，正是为了保持接口简单且可审核。

示例允许金额零，因为这里沿用“工单描述金额”的合同；前面的退款申请示例要求金额大于零，因为那是“准备退款”的合同。两个校验器看起来相似，却服务于不同业务。复制校验代码时必须连同语义一起检查，不能把一个通用 positiveNumber 工具当成所有金额字段的规则。

## 为什么日期和规范化需要额外说明

日期字符串符合某种外观并不等于日期真实存在。正则可以确认四位年份、两位月份、两位日期，却可能接受二月三十日。若业务只需要日历日期，应把年、月、日解析成整数，再按日历规则校验或使用经过验证的日期库；若需要时间点，就同时定义时区与偏移。不要让模型根据服务器时区猜测用户所说“明早九点”发生在什么地区。

跨字段日期关系通常比单字段更重要。开始日期和结束日期都合法，但结束早于开始仍应拒绝；有效期已过的优惠券不能因为日期格式正确就可使用。这类规则依赖当前时间和业务事实，测试时应注入固定时钟，避免今天通过的样例明天自动失败。示例的金额上限同理，来自已经读取的订单快照，必须说明快照何时失效。

规范化需要保留原值及原因。例如把摘要首尾空白去掉通常不会改变事实；把货币符号去掉再转成数字则可能丢失币种；把全角字符转半角可能便于匹配编号，却可能改变需要原样保留的法律名称。可以先保存原始候选，再生成明确的规范化字段，提交时使用已经验证的规范化值。不要让一组隐蔽的字符串替换成为实际业务合同。

长度限制也应说明计量单位。JavaScript 的 length 计算 UTF-16 代码单元，包含表情符号的文字可能占两个单位；界面显示字数、数据库存储字节数和模型 token 数都不是同一个指标。本章示例选择 length 是为了给固定合同一个可运行规则，真实产品若按用户感知字符限制，应统一采用适合该需求的分段方式，并让前端提示与后端验证采用同一规则。

## 校验结果应该如何成为质量证据

每次生成可以记录模型配置版本、提示版本、合同版本和验证阶段。只保存最终的 ok 会把三个不同问题混在一起：模型没有遵守输出格式，应用接收了错误版本，或者材料不足导致业务字段无法确定。按这些维度回看失败样本，可以发现某次发布只影响金额字段，或某类截图输入特别容易遗漏日期，从而选择针对性的修复。

也要观察“全部验证通过但用户改了很多”的情况。格式验证只能证明输出满足合同，不能证明抽取忠于原文。对于摘要与分类，仍需人工标注样本比较准确率；对于金额与日期，可与原始来源逐字段核对；对于生成建议，可统计用户接受率和修改原因，但不能把接受率当成事实正确率。用户可能没有注意错误，也可能因其他业务偏好修改一个正确结果。

当团队引入通用验证库时，本章专用校验器可以被替换，但错误分类与语义边界应保留。确认库是否默认进行类型转换、注入默认值或移除未知属性，这些选项会改变你看到的原始合同问题。若选择开启，必须明确这是应用的兼容策略，保留适当审计信息，并用失败样本证明不会把危险输入悄悄变成可执行命令。

## 合同升级与前端集成

版本二新增 priority 后，旧草稿读取时缺字段是预期情况，不应全部被归因于模型失败。可以定义显式迁移：旧版草稿缺 priority 时按产品约定补 normal，标明 migratedFrom: 1，并要求关键操作再次确认。这种迁移只用于已识别的历史版本；刚生成的新版本结果缺字段仍应报错，不能让兼容逻辑掩盖接入故障。

前后端共用 schema 能减少漂移，但不能让浏览器校验成为唯一防线。浏览器负责及时提示，后端负责强制合同、权限及当前业务事实。模型输出展示到页面时，文本使用正常转义，枚举映射到已知组件，链接经过协议和目标检查。即使对象百分之百符合 schema，也不意味着其中的 HTML、URL 或模板表达式适合直接执行。

最后要决定失败是否阻断用户工作。提取失败时可以保留用户原始输入并打开普通表单；未知金额可以让用户补充；拒绝生成可以解释不能完成的部分；权限失败则不应继续展示敏感候选。好的结构化输出流程不是让每次模型响应都成功，而是让每个状态都有确定、可测试、不会改变用户意图的去向。

## 验收与三个自测

验收时至少覆盖合法数据、无法解析、字段缺失、未知枚举、金额越界和 null 金额。能解释为什么“验证通过”不等于“允许执行”，才算掌握本章。完整 JSON Schema 引擎、真实模型拒绝与线上重试尚未运行，不属于本例的验证范围。

1. 问：为什么 null 与零不同？答：前者表示没有信息，后者表示已知数值；它们影响默认值和更新语义。
2. 问：strict 能保证订单金额真实存在吗？答：不能，它限制输出结构；事实必须与可信系统核对。
3. 问：TypeScript 的 as Ticket 有何限制？答：它只影响静态类型检查，不会运行验证，也不会修复恶意或错误数据。

## 本章示例验证记录

已离线运行三个专用验证程序，覆盖 JSON 语法、未知字段、空值、矛盾状态、金额越界、优先级规则及字段路径。未运行真实结构化生成或完整 JSON Schema 引擎，供应商拒绝与未完成响应仍需接入后单独验证。

## 官方资料

阅读 [OpenAI Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs)、[JSON Schema 对象约束](https://json-schema.org/understanding-json-schema/reference/object) 和 [JSON Schema 空值类型](https://json-schema.org/understanding-json-schema/reference/null)。跨供应商迁移时需重新验证支持的 schema 子集。