返回文章列表
AI 实践

合法 JSON 之后:我如何用四层校验逼近可交付的结构化输出

合法 JSON 只能证明模型输出可以被程序读取;要让内容接近可交付,还必须继续验证字段结构、业务质量和事实依据。

合法 JSON 只是结构化输出的起点

第一周的学习 中,我拆解了 my-world 个人网站里真实存在的 AI 调用链路:业务层准备输入,Provider 调用百炼的 OpenAI-compatible API,再把归一化后的模型响应交回业务逻辑。

第二周,我开始尝试让模型生成项目摘要。第一反应是要求它“只返回 JSON”。相比一段自然语言,后端确实可以直接读取 projectNamesummarytechStack 等字段,也更容易入库和展示。

但真正运行失败用例后,我发现“返回 JSON”其实包含了几个完全不同的问题:

  • 模型可能返回 Markdown 代码块或额外解释,导致 JSON.parse 失败。
  • JSON 可能合法,但缺少必填字段,或者把数组返回成字符串。
  • 字段和类型可能全部正确,但内容只有“功能丰富”“体验良好”。
  • 内容可能足够具体,却加入了输入资料和真实项目都无法证明的结论。

前三类问题可以通过程序逐层识别。第四类问题最危险,因为它看起来最像一份已经完成的内容。

图:前三层由程序自动校验并触发定向重试,第四层通过人工事实审核决定内容是否能进入可发布候选。

先定义业务真正需要的 ProjectSummary

这次 Demo 的目标不是生成任意 JSON,而是一份用于项目展示的 ProjectSummary

我把第一版字段分成两类。必填字段决定摘要是否还能被正确理解:

  • projectName: string
  • summary: string
  • role: string
  • techStack: string[]
  • highlights: string[]

另外三个字段用于补充项目信息,但缺失时不应该浪费一次模型重试:

  • problemsSolved: string[],默认值为 []
  • businessValue: string,默认值为 ""
  • riskNotes: string[],默认值为 []

对应的 Zod Schema 可以把字段类型、必填项和数组数量变成明确契约:

TypeScript
const ProjectSummarySchema = z.object({
  projectName: z.string().min(1),
  summary: z.string().min(1),
  role: z.string().min(1),
  techStack: z.array(z.string().min(1)).min(1),
  highlights: z.array(z.string().min(1)).min(2),
  problemsSolved: z.array(z.string()).default([]),
  businessValue: z.string().default(""),
  riskNotes: z.array(z.string()).default([]),
})

这里有一个重要取舍:可选字段缺失不等于生成失败。

如果 businessValue 没有生成,应用层可以安全补成空字符串;如果 techStack 被返回成 "Next.js, Payload CMS, PostgreSQL",也可以先按确定性分隔符转换成数组。能够可靠修复的问题,没有必要立刻再次调用模型。

第一层:先判断输出能不能被解析

拿到 Provider 返回的模型文本后,第一步是 JSON.parse

这一层只回答一个问题:当前文本是不是合法 JSON。它不关心字段是否齐全,也不判断内容好不好。

如果模型返回自然语言、Markdown 代码块或带有解释文字的 JSON,错误会被记录为 layer=parse。此时还没有 JavaScript 对象,不能把问题交给 Zod,也无法精确定位到某个字段。

对应的 retryPrompt 会重新给出完整目标结构,并明确要求:

  • 只返回一个合法 JSON 对象
  • 不返回 Markdown 或代码块
  • 不添加解释、前缀、后缀和注释

这比尝试从自然语言中硬拆 JSON 更稳定。后者会不断积累脆弱的字符串规则,而且无法覆盖模型表达方式的变化。

第二层:用 Schema 判断字段是否可信

解析成功后,结果才会进入 Zod Schema。

这一层负责识别必填字段缺失、类型错误、数组结构错误等问题。例如缺少 role 时,错误会被记录为 layer=schemafield=role;如果 techStack 无法通过确定性规则转换为数组,也会在这里失败。

Schema 层的重试提示可以精确到字段,告诉模型哪个字段错误、实际类型和期望类型分别是什么,并要求重新返回完整 JSON,而不是只返回一个字段。

即便提示模型保留其他已合格字段,下一轮仍然要重新执行完整解析和校验。模型可能在修复一个字段时改动其他内容,后端不能因为上一轮部分正确就跳过复查。

第三层:类型正确,不代表内容可用

Zod 能判断 summary 是不是字符串,却无法判断它有没有信息量。

例如下面的内容在类型上完全合法:

JSON
{
  "summary": "这是一个很棒的项目,功能丰富,体验良好。",
  "highlights": ["功能丰富", "体验良好"]
}

如果这种结果进入个人网站,结构虽然稳定,内容却没有展示价值。因此我在 Schema 之后增加了业务质量校验:

  • summary 至少说明项目功能、处理的问题和面向的用户
  • 摘要不能少于规定长度
  • highlights 至少两项
  • 数组项不能只有“功能丰富”等空泛表达
  • 内容需要出现具体功能、流程、技术或业务对象

质量失败会被记录为 layer=quality,并精确到 summaryhighlights.0。重试提示不再泛泛要求“提高质量”,而是告诉模型哪个字段为什么不可用、缺少什么信息。

整个生成流程的上限设为 3 次模型调用。每次失败都会保留错误层级、字段和原因,再据此生成下一轮 retryPrompt。达到上限后仍未通过,就返回 STRUCTURED_OUTPUT_FAILED,而不是继续消耗模型额度。

28 个 mock 用例证明了代码路径,但没有证明内容真实

为了验证这些分支,我先跑了完整 mock 回归。

测试覆盖正常 JSON、非 JSON、必填字段缺失、数组字段兜底、内容空泛、多轮重试成功、连续三次失败、必填输入缺失和可选字段默认值等场景。最终结果是:

  • passed=28
  • failed=0
  • total=28

其中,缺少必填输入的场景会在调用模型前直接失败,Provider 调用次数和 token 消耗都是 0。可选字段缺失则使用默认值,不会被误判为需要重试。

这组测试证明了解析、Schema、质量校验和重试流程能够按设计执行。但 mock 输出是预先构造的,它无法证明真实模型会不会稳定遵守结构,更无法证明模型生成的项目描述是否符合项目事实。

qwen3-max 连续 10 次全部首次通过

下一步,我通过 aliyun-bailian Provider 调用 qwen3-max,使用同一份 My World 项目资料连续生成 10 次。

程序统计结果非常整齐:

指标
结果
总任务数
10
最终成功
10/10
首次成功
10/10
发生重试
0
平均耗时
5150.1 ms
总 token
5344
平均每次 token
534.4

10 份输出全部通过 JSON 解析、Zod Schema 和业务质量校验。项目名、技术栈、数组字段和可选字段默认值也保持稳定。

如果只看程序结果,这套结构化输出已经达到 100% 成功率。

但人工审核改变了结论。

第四层:内容具体,也可能没有事实依据

我逐条对照 my-world 的真实项目资料、页面实现和文档,审核 10 份被程序判定为成功的摘要。

其中,“My World 是个人品牌网站”“使用 Next.js、Payload CMS、PostgreSQL 和阿里云百炼”“展示项目、文章、工具和合作入口”等描述有明确依据,可以标记为 supported

问题出现在那些听起来合理、表达也足够具体的内容上:

模型声明
审核结果
原因
构建静态站点
false
项目中多个页面使用 force-dynamic
SEO 友好
false
项目文档明确 SEO 不是核心目标
高性能内容展示
unsupported
没有性能指标或 Web Vitals 数据支撑
提升合作转化效率
needs_review
这是产品目标,不是已验证结果
减少重复沟通
needs_review
缺少真实使用和效果数据

这些内容都能通过前三层校验。它们是合法字符串,字段位置正确,表达也不空泛。问题在于,程序没有判断声明是否来自输入资料,也不知道项目代码和文档能不能支撑这些结论。

这就是事实一致性第四层需要处理的问题:不是判断一句话写得像不像真的,而是判断它有没有证据。

当前这层仍然依赖人工审核。supported 可以保留;unsupported 应删除或等待补充证据;false 必须删除;needs_review 只能改写为目标或意图,不能作为已经发生的结果发布。

因此,这次真实模型测试同时产生了两个结论:

  • 从调用和结构稳定性看,10/10 首次成功。
  • 从内容发布标准看,10 份结果都不应该未经审核直接发布。

两者并不矛盾。它们衡量的是不同阶段。

ok: true 不是发布许可

经过这轮测试,我对结构化输出的判断标准发生了变化。

JSON.parse 成功,说明程序能够读取结果。

Zod 通过,说明字段符合约定。

业务质量校验通过,说明内容不是空话。

只有事实一致性审核通过,才能说明内容具备发布条件。

因此,我最终没有把项目摘要生成能力直接开放给访客,也没有让生成结果自动覆盖个人网站上的项目介绍。它目前只保留为 my-world 内部 Demo,用来继续验证输入、模型输出、重试和审核流程。

后续生产化还需要补上后台人工审核、摘要版本、activeSummaryIdstale 状态和事实来源记录。输入资料更新后,旧摘要需要被标记为过期;新摘要只有在重新生成并审核通过后,才能成为前端和 AI 助手使用的有效版本。

这些机制目前还是设计方向,不是已经完成的功能。

这次实践最终证明的不是“模型已经可以稳定替我写项目介绍”,而是另一件更具体的事:我已经能把一次看似成功的模型输出拆成四个不同问题,并知道每一层能证明什么、不能证明什么。

让模型返回合法 JSON,是结构化输出的开始。

让程序、业务规则和真实项目事实共同对结果负责,才是它接近可交付的条件。

附录:一次程序成功输出的人工事实审核

下面选取的是 10 次真实模型测试中的第 3 次结果。调用记录没有持久化供应商返回的原始文本,只保存了经过确定性标准化后的 normalized 数据,因此这里展示的是标准化后的真实模型输出,而不是未经处理的原始响应。

为避免暴露内部调用标识,原始 requestId 已替换为示例编号,完整 Prompt 和供应商请求信息不在本文展示。

测试信息

项目
内容
示例编号
real-output-03
Provider
aliyun-bailian
Model
qwen3-max
Prompt 版本
project-summary-stability-v1
Schema 版本
project-summary-v1
调用次数
1
本次 token
538
三层自动校验
通过

标准化后的模型输出

JSON
{
  "projectName": "My World",
  "summary": "开发了一个个人品牌网站,用于集中展示项目作品、技术文章、AI 工具及合作入口,解决了个人内容分散、缺乏统一展示平台的问题,面向访客、潜在合作者及技术同行。",
  "role": "全栈开发、内容系统、AI 助手接入",
  "techStack": [
    "Next.js",
    "Payload CMS",
    "PostgreSQL",
    "阿里云百炼"
  ],
  "highlights": [
    "基于 Next.js 构建静态站点,集成 Payload CMS 实现内容的可视化管理与动态更新",
    "接入阿里云百炼平台,嵌入 AI 助手功能,提供智能交互入口"
  ],
  "problemsSolved": [],
  "businessValue": "",
  "riskNotes": []
}


从程序角度看,这份结果没有问题:JSON 可以解析,字段和类型符合 Schema,摘要与亮点也足够具体。但把句子拆成可独立核验的声明后,审核结果并不一致。

声明级人工审核

模型生成的声明
事实依据
审核状态
处理方式
My World 是个人品牌网站
项目 README、项目概览和现有页面结构均能证明
supported
保留
集中展示项目、技术文章、AI 工具和合作入口
项目、文章、工具和联系页面均已存在
supported
保留
面向访客、潜在合作者和技术同行
项目概览中有明确目标用户说明
supported
保留
已解决个人内容分散、缺乏统一展示平台的问题
项目资料能证明这是建设目标,但不能证明问题已经产生可量化改善
needs_review
改为“用于集中整合个人内容和展示入口”
使用 Next.js、Payload CMS、PostgreSQL 和阿里云百炼
项目依赖、Payload 配置、数据库适配和 Provider 实现均能证明
supported
保留
基于 Next.js 构建静态站点
多个前台页面明确使用 force-dynamic
false
必须删除,并加入后续回归测试
Payload CMS 支持内容的可视化管理与动态更新
Payload Admin、Projects 和 Articles 等 collection 已实现
supported
可保留,避免扩大为所有内容均由后台管理
已接入百炼 AI 助手并提供交互入口
聊天组件、后端接口和百炼 Provider 均已实现
supported
保留

这份输出在第 1 次调用就通过了 JSON 解析、Schema 和业务质量校验,却仍然包含一条与项目实现冲突的声明,以及一条把建设目标写成既成效果的声明。

因此,前三层的 ok: true 只能证明内容满足程序规则,不能代表整份摘要已经具备发布资格。事实审核必须继续精确到可独立验证的声明,不能因为同一个 summaryhighlights 中存在正确内容,就让错误内容一起通过。

有想法或建议? 联系我