结构化输出与 Schema 校验:JSON Mode 与手写轻量级类型验证器

本节目标

为什么自然语言文本解析是 Agent 稳定性的头号杀手?

在 Agent 系统中,模型的输出往往要直接作为下游代码的输入(例如提取任务列表、风险评分、SQL 参数)。如果只靠在 Prompt 里写「请只返回 JSON,不要输出多余文字」,模型在实际运行中仍然经常输出:

要让 Agent 达到生产级可靠性,必须组合使用三道防线:鲁棒提取器(JSON Extractor) + 运行时 Schema 校验器 + 错误反馈重试闭环。

手写零依赖 Schema 校验与 JSON Schema 生成器

下面的代码实现了一个轻量但完备的声明式 Schema 库 s(对标 Zod 的核心设计),既能一键导出给 LLM 使用的標準 JSON Schema,又能对模型返回的对象执行精确到字段路径(Path)的运行时校验:

export const s = {
  string: (opts = {}) => ({
    type: 'string',
    ...opts,
    validate(val, path = '$') {
      if (typeof val !== 'string') return [`${path}: 期望 string,实际为 ${typeof val}`]
      if (opts.enum && !opts.enum.includes(val)) {
        return [`${path}: 值 "${val}" 不在允许枚举 [${opts.enum.join(', ')}] 内`]
      }
      if (opts.minLength && val.length < opts.minLength) {
        return [`${path}: 长度不能小于 ${opts.minLength}`]
      }
      return []
    }
  }),

  number: (opts = {}) => ({
    type: 'number',
    ...opts,
    validate(val, path = '$') {
      if (typeof val !== 'number' || Number.isNaN(val)) {
        return [`${path}: 期望有效 number,实际为 ${JSON.stringify(val)}`]
      }
      if (opts.min !== undefined && val < opts.min) return [`${path}: 不能小于 ${opts.min}`]
      if (opts.max !== undefined && val > opts.max) return [`${path}: 不能大于 ${opts.max}`]
      return []
    }
  }),

  array: (itemSchema, opts = {}) => ({
    type: 'array',
    items: itemSchema,
    ...opts,
    validate(val, path = '$') {
      if (!Array.isArray(val)) return [`${path}: 期望 array,实际为 ${typeof val}`]
      return val.flatMap((item, i) => itemSchema.validate(item, `${path}[${i}]`))
    }
  }),

  object: (shape, { required = Object.keys(shape) } = {}) => ({
    type: 'object',
    properties: shape,
    required,
    additionalProperties: false,
    validate(val, path = '$') {
      if (typeof val !== 'object' || val === null || Array.isArray(val)) {
        return [`${path}: 期望非空 object`]
      }
      const errs = []
      for (const reqKey of required) {
        if (!(reqKey in val) || val[reqKey] === undefined) {
          errs.push(`${path}.${reqKey}: 缺少必填字段`)
        }
      }
      for (const [k, propSchema] of Object.entries(shape)) {
        if (val[k] !== undefined) {
          errs.push(...propSchema.validate(val[k], `${path}.${k}`))
        }
      }
      return errs
    }
  })
}

// 从夹杂 Markdown 围栏或解释文字的模型回复中安全剥离 JSON
export function extractJsonObject(rawText) {
  const text = String(rawText || '').trim()
  const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text)
  const candidate = fenced ? fenced[1].trim() : text
  const firstBrace = candidate.search(/[{[]/)
  const lastBrace = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'))
  if (firstBrace === -1 || lastBrace === -1 || lastBrace < firstBrace) {
    throw new Error('回复中未找到有效的 JSON 括号边界')
  }
  return JSON.parse(candidate.slice(firstBrace, lastBrace + 1))
}

// 带自愈反馈的结构化推理调用器
export async function generateStructured({ llm, messages, schema, maxRetries = 3 }) {
  const workingMessages = [...messages]

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const res = await llm.chat({
      messages: workingMessages,
      response_format: { type: 'json_object' }
    })
    const raw = res.message.content || ''

    try {
      const parsed = extractJsonObject(raw)
      const errors = schema.validate(parsed)
      if (errors.length === 0) return parsed

      // 校验未通过:把具体哪个字段错、期望什么类型直接反馈给模型重修
      workingMessages.push(
        { role: 'assistant', content: raw },
        {
          role: 'user',
          content: `你输出的 JSON 未通过 Schema 校验,错误如下:
${errors.join('
')}
请严格修正上述字段并重新输出合法 JSON。`
        }
      )
    } catch (parseErr) {
      workingMessages.push(
        { role: 'assistant', content: raw },
        {
          role: 'user',
          content: `JSON 解析语法错误:${parseErr.message}。请仅输出严格合法的 JSON 字符串。`
        }
      )
    }
  }
  throw new Error(`结构化输出在重试 ${maxRetries} 次后仍未通过 Schema 校验`)
}

课后练习

练习 1:当模型的 JSON 输出未能通过 schema.validate(parsed) 时,为什么我们在重试前要把模型刚才错误的输出(role: 'assistant')和具体的字段错误路径列表(如 $.steps[0].priority: 值 "urgent" 不在允许枚举内)一起追加进 workingMessages?

答案:如果只是简单地用原 Prompt 重新请求一次,模型有很大概率再次犯同样的结构错误;将它上一轮的错误输出连同精确到字段路径的校验报错发回上下文,模型就能直接定位违规字段并做靶向修正,通常只需 1 次重试即可修复。

练习 2:extractJsonObject 为什么要先尝试匹配 ```json 代码围栏并查找首尾 {} / [] 边界,而不是直接对 rawText 调用 JSON.parse?

答案:许多开源模型或未开启严格 Structured Outputs 的接口习惯在 JSON 前后附加说明性话术或 Markdown 代码围栏,直接 JSON.parse(rawText) 会因为首尾的非 JSON 字符抛出语法异常。