别信模型说的“这是合法 JSON”:结构化输出的前端校验与自动修复
别信模型说的“这是合法 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) Promisestring; } export class StructuredOutputGuard { private schema: object; private maxRetries: number; private regenerate?: (feedback: string) Promisestring; // ajv 编译后的校验函数复用避免重复编译开销 private validate: ReturnTypeAjv[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): Promisestring | null { if (!this.regenerate) return null; try { return await this.regenerate(feedback); } catch { // 重生成本身失败也兜底不让链路中断 return null; } } }关键点在于三处。其一分层处理语法层用 jsonrepairschema 层用 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 应用场景下能跑通回报是值得的。