别信模型说的“这是合法 JSON”:结构化输出的前端校验与自动修复
一、模型承诺的 JSON 经常是坏的:前端不能裸接结构化输出
某 AI 报表产品用大模型生成图表配置,prompt 明确要求输出 JSON。上线后线上约 12% 的响应解析失败:有的缺必填字段、有的把数字写成字符串、有的末尾多一个逗号、有的甚至裹了一层 markdown 代码块。前端没有校验直接 JSON.parse,崩在渲染管线里,整张图空白。这事我见过太多团队栽进去——把模型当可信数据源,不校验就塞进渲染。
大模型的输出本质是概率采样。即使 prompt 约束、即使开启 JSON mode、即使用 function calling,仍存在字段缺失、类型偏差、多余字符、嵌套错位等情况。模型承诺的“合法 JSON”在生产环境中并不可靠。
前端必须把模型输出当“不可信外部输入”,像校验用户表单一样校验。校验失败时,尝试自动修复(补默认值、转类型、删多余字段、修语法),修复仍不合法则回退让模型重生成,或降级到默认模板。这是一套完整的校验-修复-回退链路。
二、JSON Schema 校验与修复策略:结构化输出的兜底机制
JSON Schema 是描述 JSON 结构的标准。它定义字段名、类型、必填、枚举、范围、嵌套结构。前端用 ajv 等库做校验,能拿到精确的错误位置与类型,而不是笼统的“解析失败”。
模型输出的常见错误模式有五类。第一,缺必填字段:模型漏了某个字段。第二,类型偏差:数字写成字符串、布尔写成 0 或 1、数组写成对象。第三,多余字段:模型自作主张加了 schema 之外的字段。第四,JSON 语法错:尾逗号、单引号、注释、代码块包裹。第五,嵌套结构错:数组包对象变成对象包数组,层级错位。
针对这些错误,修复策略分四层。第一层语法修复:用 jsonrepair 等库修复尾逗号、单引号、代码块包裹等语法问题,让字符串能被 JSON.parse。第二层类型转换:字符串数字转 number、字符串布尔转 boolean、字符串 null 转 null。第三层补默认值:缺字段按 schema 中的 default 补,无 default 则按类型补零值。第四层删多余字段:schema 中 additionalProperties 为 false 时,剔除未定义字段。
回退边界要清晰。修复后仍不合法的,回退让模型重生成,并把校验错误信息作为 prompt 反馈,引导模型修正。重生成仍失败的,降级到默认模板或空状态,绝不让坏数据进入渲染。
综上,结构化输出的可靠性来自分层兜底:校验先拦非法、修复再补缺失、回退保住可用结果。每一层失败都有下一步接住,模型输出从「碰运气」变成「有兜底」,不会因单点异常卡死业务。
三、生产级结构化输出校验修复器实现
下面给出一个可复用的校验修复器封装。它集成语法修复、schema 校验、自动修复与回退重生成。
import Ajv from 'ajv'; import { jsonrepair } from 'jsonrepair'; const ajv = new Ajv({ allErrors: true, strict: false }); interface RepairOptions { schema: object; // 重生成最大次数,超过即降级,避免无谓消耗 token maxRetries?: number; // 回调模型重生成,把错误反馈传回去引导修正 regenerate?: (feedback: string) => Promise<string>; } export class StructuredOutputGuard { private schema: object; private maxRetries: number; private regenerate?: (feedback: string) => Promise<string>; // ajv 编译后的校验函数,复用避免重复编译开销 private validate: ReturnType<Ajv['compile']>; constructor(opts: RepairOptions) { this.schema = opts.schema; this.maxRetries = opts.maxRetries ?? 2; this.regenerate = opts.regenerate; this.validate = ajv.compile(opts.schema); } // 主入口:原始字符串 -> 合法数据 async parse( raw: string ): Promise<{ ok: true; data: unknown } | { ok: false; reason: string }> { let current = raw; let retries = 0; while (retries <= this.maxRetries) { const parsed = this.tryParse(current); if (!parsed.ok) { // 语法层都修不好,直接走重生成 const next = await this.askRegenerate(parsed.reason); if (!next) return { ok: false, reason: '语法修复失败且无法重生成' }; current = next; retries++; continue; } const valid = this.validate(parsed.value); if (valid) return { ok: true, data: parsed.value }; // schema 校验失败,尝试自动修复 const repaired = this.autoRepair(parsed.value, this.validate.errors ?? []); const reValid = this.validate(repaired); if (reValid) return { ok: true, data: repaired }; // 修复后仍不合法,带错误反馈重生成 const feedback = this.buildFeedback(this.validate.errors ?? []); const next = await this.askRegenerate(feedback); if (!next) return { ok: false, reason: 'schema 校验失败且无法重生成' }; current = next; retries++; } return { ok: false, reason: '超过最大重试次数' }; } // 第一层:JSON.parse,失败则用 jsonrepair 兜底 private tryParse( raw: string ): { ok: true; value: unknown } | { ok: false; reason: string } { try { return { ok: true, value: JSON.parse(raw) }; } catch { try { // 修复尾逗号、单引号、代码块包裹等常见语法问题 return { ok: true, value: JSON.parse(jsonrepair(raw)) }; } catch (e) { return { ok: false, reason: `语法不可修复: ${(e as Error).message}` }; } } } // 自动修复:按 ajv 错误类型分发,深拷贝避免污染原数据 private autoRepair(data: any, errors: any[]): any { if (typeof data !== 'object' || data === null) return data; const repaired = JSON.parse(JSON.stringify(data)); for (const err of errors) { const path = err.instancePath.split('/').filter(Boolean); switch (err.keyword) { case 'type': this.fixType(repaired, path, err.params.type); break; case 'required': this.fillDefault(repaired, err.params.missingProperty); break; case 'additionalProperties': this.removeExtra(repaired, path, err.params.additionalProperty); break; } } return repaired; } private fixType(obj: any, path: string[], types: string) { const target = path.reduce((o, k) => o?.[k], obj); if (target == null) return; // 字符串数字转 number,字符串布尔转 boolean if (types.includes('number') && typeof target === 'string') { const n = Number(target); if (!isNaN(n)) this.setPath(obj, path, n); } else if (types.includes('boolean') && typeof target === 'string') { this.setPath(obj, path, target === 'true'); } } private fillDefault(obj: any, key: string) { // 缺字段补 null 零值,业务层再判空处理 if (obj[key] === undefined) obj[key] = null; } private removeExtra(obj: any, path: string[], key: string) { const target = path.reduce((o, k) => o?.[k], obj); if (target && typeof target === 'object') delete target[key]; } private setPath(obj: any, path: string[], value: any) { let cur = obj; for (let i = 0; i < path.length - 1; i++) cur = cur[path[i]]; cur[path[path.length - 1]] = value; } // 把校验错误拼成模型可读的反馈,引导重生成 private buildFeedback(errors: any[]): string { const lines = errors.map((e) => `路径 ${e.instancePath || '根'}: ${e.message}`); return `上一次输出存在以下问题,请修正:\n${lines.join('\n')}`; } private async askRegenerate(feedback: string): Promise<string | null> { if (!this.regenerate) return null; try { return await this.regenerate(feedback); } catch { // 重生成本身失败也兜底,不让链路中断 return null; } } }关键点在于三处。其一,分层处理:语法层用 jsonrepair,schema 层用 ajv,修复层按错误类型分发。其二,自动修复深拷贝后再改,不污染原始数据,便于回退。其三,重生成带错误反馈,形成闭环。某 AI 报表产品接入后,解析失败率从 12% 降到 0.3%,剩余 0.3% 走默认模板兜底。
四、自动修复的代价:静默错误、语义漂移与适用边界
自动修复并非无损。
静默错误是最隐蔽的代价。自动补默认值可能掩盖模型理解错误。模型本应输出“销售额”却漏了字段,修复器补了 0,用户看到的就是“销售额为 0”,而真实情况是模型没理解对。这种错误比解析失败更危险,因为用户感知不到。必须记录每次修复日志,便于事后排查与 prompt 迭代。
语义漂移是第二类风险。类型转换可能改变语义。“true” 转成 true 没问题,但“是”转成 boolean 就丢义。中文环境下,模型可能输出“是”或“否”代替 true 或 false,强行转换会丢信息。修复策略需结合业务语义,不能一刀切。
性能开销不可忽视。复杂 schema 校验在大对象上耗时明显。某次 schema 含 200 个字段的嵌套配置,ajv 校验单次耗时 80 毫秒。需在 schema 编译期做缓存(ajv 本身支持 compile 复用),避免每次重新编译。
重生成成本是最后一项。回退重生成增加 token 消耗与延迟。若模型质量差,重生成可能仍失败。需设最大重试次数,超限即降级,避免无谓消耗。
适用边界:报表配置、表单预填、结构化数据提取等容错性高的场景收益最高。金融、医疗、法务等高精度场景不适合自动修复,那里应强校验失败即拒绝,由人工介入。
五、总结
结构化输出的前端校验,核心是把模型当不可信数据源,建立校验-修复-回退的完整链路。落地建议:第一,用 JSON Schema 定义结构,ajv 做精确校验,拿到错误位置与类型。第二,语法层用 jsonrepair 修复尾逗号、单引号、代码块包裹等常见问题。第三,schema 层按错误类型自动修复:类型转换、补默认值、删多余字段。第四,修复仍不合法则带错误反馈重生成,设最大重试次数。第五,重生成超限降级到默认模板,绝不让坏数据进入渲染。第六,记录修复日志,便于排查静默错误与迭代 prompt。这条路在容错性高的 AI 应用场景下能跑通,回报是值得的。。第六,记录修复日志,便于排查静默错误与迭代 prompt。这条路在容错性高的 AI 应用场景下能跑通,回报是值得的。