logo

结构化生成的终极实践:JSON Schema 与类型安全

Published on

结构化生成的终极实践:JSON Schema 与类型安全

“请只输出 JSON,不要解释。”这是很多 LLM 项目的第一版方案。刚开始测试时,它通常能工作;模型返回一个对象,前端 JSON.parse,业务继续往下走。等输入变复杂,问题就出现了:多了一段 Markdown,字段名拼错,数字变成字符串,数组里混进了说明文字,或者模型在 JSON 前面说了一句“当然可以”。

更麻烦的是,模型偶尔成功并不能让系统可靠。真正的生产代码必须假设输出会不符合预期,然后明确怎么识别、怎么修复、怎么降级。

我后来把结构化输出看成一份 API 协议。模型是一个不完全可靠的服务端,Schema 是接口契约,运行时校验是边界防线,版本字段负责兼容,错误处理负责把坏结果挡在业务逻辑之外。

一、先设计数据契约,再写 Prompt

假设我们要从客服文本中抽取订单信息:

type OrderIntent = {
  intent: '查询物流' | '申请退款' | '修改地址' | '其他'
  orderId: string | null
  urgency: 'low' | 'normal' | 'high'
  needHuman: boolean
}

这个类型只是开发阶段的提示,不能直接相信模型返回的数据。要把它变成运行时 Schema,并明确必填、可空、枚举、长度和额外字段策略:

const orderIntentSchema = {
  type: 'object',
  additionalProperties: false,
  required: ['intent', 'orderId', 'urgency', 'needHuman'],
  properties: {
    intent: {
      type: 'string',
      enum: ['查询物流', '申请退款', '修改地址', '其他'],
    },
    orderId: { type: ['string', 'null'] },
    urgency: { type: 'string', enum: ['low', 'normal', 'high'] },
    needHuman: { type: 'boolean' },
  },
}

additionalProperties: false 很有价值。它能阻止模型偷偷增加业务没有处理的字段,也能更早发现 Prompt 或模型版本发生了漂移。

二、TypeScript 类型和运行时校验是两回事

TypeScript 只在编译期检查开发者写的代码,网络返回的 JSON 在运行时仍然是未知数据:

const raw: unknown = await response.json()
const result = parseOrderIntent(raw)

解析函数必须先验证,再转换成业务类型:

function parseOrderIntent(value: unknown): OrderIntent {
  if (!isRecord(value)) throw new InvalidModelOutputError()
  if (!intentValues.includes(value.intent)) {
    throw new InvalidModelOutputError('intent 不合法')
  }
  if (value.orderId !== null && typeof value.orderId !== 'string') {
    throw new InvalidModelOutputError('orderId 类型错误')
  }
  if (typeof value.needHuman !== 'boolean') {
    throw new InvalidModelOutputError('needHuman 类型错误')
  }
  return value as OrderIntent
}

可以使用 Zod、Ajv 或其他 Schema 库减少重复代码,但原则不变:来自模型、用户、第三方 API 的数据,在进入业务逻辑前都必须经过运行时校验。

三、输出协议要包含版本和状态

对于长期运行的系统,可以给结构化结果加上协议版本:

{
  "schemaVersion": "order-intent-v2",
  "status": "ok",
  "data": {
    "intent": "查询物流",
    "orderId": "A1001",
    "urgency": "normal",
    "needHuman": false
  }
}

status 不要只用 HTTP 状态代替。模型可能响应成功,但内容无法解析;也可能解析成功,却明确表示证据不足。把 okneeds_reviewno_evidenceinvalid 区分开,调用方才知道下一步该做什么。

Schema 版本升级要考虑向后兼容。新增可选字段通常比较安全,改变枚举含义、删除必填字段或修改数字单位则需要新版本和迁移逻辑。不要让前端和后端各自猜版本含义。

四、Prompt 仍然重要,但不再承担全部责任

Schema 能约束形状,却不能保证事实正确。Prompt 需要说明字段含义、来源和不确定时的行为:

只根据输入文本抽取订单信息。
找不到订单号时返回 null,不要猜测。
无法判断意图时返回“其他”,并将 needHuman 设为 true。
只输出符合 order-intent-v2 的 JSON。

这类指令帮助模型理解业务,但最终仍要由代码校验。orderId 符合字符串类型,不代表它真的存在;needHuman 是布尔值,也不代表模型正确判断了风险。

五、解析失败时要有分层修复策略

第一层是直接解析和 Schema 校验;第二层是安全的格式清理,例如去除 Markdown 代码围栏;第三层可以请求模型只修复格式,不重新生成业务内容;仍然失败就降级或转人工。

async function parseWithRepair(raw: string) {
  try {
    return validate(JSON.parse(raw))
  } catch (error) {
    const cleaned = stripCodeFence(raw)
    try {
      return validate(JSON.parse(cleaned))
    } catch {
      const repaired = await repairJson(cleaned, orderIntentSchema)
      return validate(JSON.parse(repaired))
    }
  }
}

修复请求必须有次数和 Token 上限。不要因为模型输出坏了,就无限让它重写;如果业务字段已经混乱,重新格式化并不能创造缺失事实。高风险结果宁可失败,也不要“修”成一个看起来合法的错误对象。

六、语义校验比形状校验更难

下面这个结果完全符合 Schema:

{
  "intent": "申请退款",
  "orderId": "A1001",
  "urgency": "low",
  "needHuman": false
}

但用户可能只是询问退款规则,并没有提出退款申请。Schema 只能判断形状,语义还需要业务规则、检索证据或第二步判断。

可以把校验分成三层:

形状:字段、类型、枚举、必填
业务:订单是否存在、状态是否允许退款
证据:模型结论是否被输入或检索内容支持

每一层失败都应该有不同错误类型。形状失败可能重试格式,业务失败应返回业务提示,证据不足则应拒答或请求人工。

七、批量处理要记录坏样本

结构化抽取通常会批量处理大量文档。不能因为一个样本失败就让整批任务中断,也不能静默跳过。每条记录要有状态和错误原因:

type ExtractionResult = {
  inputId: string
  status: 'succeeded' | 'invalid' | 'needs_review' | 'failed'
  data?: OrderIntent
  errorCode?: string
  modelVersion: string
  schemaVersion: string
}

失败样本要脱敏保存,方便分析是某种输入格式、某个模型版本还是 Schema 变更导致的。可以对高频失败做专项样本集,加入 Prompt 回归和发布门禁。

八、从 Schema 生成类型和文档

Schema 只有一份来源,才能避免类型和协议漂移。可以从 JSON Schema 生成 TypeScript 类型、API 文档和测试样例:

Schema
  ├─ TypeScript 类型
  ├─ 运行时校验器
  ├─ Prompt 输出约束
  ├─ API 文档
  └─ 回归测试样例

生成工具可以按团队技术栈选择,但不要手工维护五份近似定义。重复定义越多,越容易出现“后端认为字段叫 orderId,前端却等着 order_id”的问题。

九、评测结构化生成要看失败类型

不要只统计“JSON 解析成功率”。至少记录:

  • 解析成功率;
  • Schema 校验通过率;
  • 字段完整率;
  • 字段值准确率;
  • 业务规则通过率;
  • 高风险错误率;
  • 平均修复次数和成本。

一个模型解析率 99.9%,但金额字段准确率只有 90%,仍然不能用于财务任务。不同字段的风险不同,关键字段应该有更严格的门槛和人工抽查。

总结:结构化生成是协议设计,不是格式许愿

我现在看到“请输出 JSON”这句话,会继续问:Schema 在哪里?运行时谁校验?字段错了怎么办?版本怎么升级?没有证据时如何表达?坏结果会不会进入数据库或触发工具?

Prompt 可以告诉模型希望得到什么,Schema 可以描述结果长什么样,运行时校验可以挡住不合法数据,业务规则可以判断是否真的可用,回归测试则帮助我们知道修改以后有没有退化。

结构化输出的终点不是让模型每次都返回一个漂亮对象,而是让系统在面对漂亮对象和坏对象时都能做出正确动作。能解析的结果进入下一步,缺证据的结果明确拒答,高风险的结果交给人,未知字段和版本变化被及时发现。

当模型输出真正成为一份有版本、有校验、有降级路径的协议,LLM 应用才不再依赖运气。我们不是要求机器永远不犯错,而是让错误在越过业务边界之前被看见、被分类、被安全地处理。

🤪 您也可以编辑此页: