跳到正文
跟我做
开发者 AI 实战5 分钟Node.js

AI 返回的 JSON 不稳:加一层校验和重试

模型返回 200 不代表字段可用。用一个不依赖第三方库的 Node 脚本校验字段类型、数值范围和枚举,失败自动重试,仍失败就落盘人工看。

约 5 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 先定 schema
  3. 写脚本
  4. 看真实输出
  5. 重试怎么接
  6. 提示词也要配合
  7. 批量任务里怎么用
  8. 常见坑
  9. 1. 只校验顶层
  10. 2. 无限重试
  11. 3. 不记录原始返回
  12. 4. schema 和提示词不一致
  13. 下一步读什么

30 秒速查

  • 别只 JSON.parse:先校验字段类型和范围
  • 提示词里给出完整字段清单,要求只输出 JSON
  • 失败重试 2 次,把错误回传给模型
  • 跑 node resources/json-guard/guard.mjs response.json

这篇教程解决什么问题

模型返回了 200,JSON.parse 也过了,但 tags 是字符串不是数组,score 是 120 不是 0–100。批量跑完才发现,后面的排序、入库全乱了。

这篇加一层校验:字段类型、数值范围、枚举值。解析失败或校验失败都算失败,交给 guard 有限重试;仍失败就停下,让人看原始返回。

模型返回原文的五行:第一行是「好的,以下是提取结果:」,第三行是 ```json 代码块开头,第四行才是合法 JSON,最后一行是收尾的 ```;下面终端里 guard.mjs 报 JSON 解析失败,错误停在第一个字
① JSON 之外多了一句解释 ② 代码块也一起返回了 ③ 解析器的报错原文(坏法按真实情况手写,报错是本机真跑;卡片界面为重画)

先定 schema

只校验你真正会用的字段:

title: string
tags:  array<string>
score: number,0 到 100

对应脚本里的 ARTICLE_SCHEMA。字段越少越稳;不要一上来校验十几个字段。

写脚本

保存为 resources/json-guard/guard.mjs:

import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";

// 校验模型返回的 JSON:字段类型、数值范围、枚举。
// 解析失败或校验失败都当作失败,交给 guard 做有限重试。

export const ARTICLE_SCHEMA = {
  title: "string",
  tags: { type: "array", items: "string" },
  score: { type: "number", min: 0, max: 100 },
};

function typeOf(value) {
  if (Array.isArray(value)) return "array";
  if (value === null) return "null";
  return typeof value;
}

function check(value, rule, at, errors) {
  if (typeof rule === "string") {
    const actual = typeOf(value);
    if (actual !== rule) errors.push(`${at}: 需要 ${rule},实际 ${actual}`);
    return;
  }

  if (rule.enum && !rule.enum.includes(value)) {
    errors.push(`${at}: 只能是 ${rule.enum.join(" / ")}`);
    return;
  }

  const actual = typeOf(value);
  if (rule.type && actual !== rule.type) {
    errors.push(`${at}: 需要 ${rule.type},实际 ${actual}`);
    return;
  }

  if (rule.type === "array" && rule.items) {
    value.forEach((item, index) => check(item, rule.items, `${at}[${index}]`, errors));
  }

  if (rule.type === "number") {
    if (rule.min !== undefined && value < rule.min) {
      errors.push(`${at}: 不能小于 ${rule.min}`);
    }
    if (rule.max !== undefined && value > rule.max) {
      errors.push(`${at}: 不能大于 ${rule.max}`);
    }
  }
}

export function validate(value, schema, at = "root") {
  const errors = [];
  const actual = typeOf(value);

  if (actual !== "object") {
    return [`${at}: 需要 object,实际 ${actual}`];
  }

  for (const [key, rule] of Object.entries(schema)) {
    if (!(key in value)) {
      errors.push(`${at}.${key}: 缺少字段`);
      continue;
    }
    check(value[key], rule, `${at}.${key}`, errors);
  }

  return errors;
}

export function parseAndValidate(text, schema) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    return { ok: false, data: null, errors: [`JSON 解析失败:${error.message}`] };
  }

  const errors = validate(data, schema);
  return { ok: errors.length === 0, data, errors };
}

export async function guard({ generate, schema, attempts = 2, onError } = {}) {
  let lastErrors = [];

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    const text = await generate(attempt);
    const result = parseAndValidate(text, schema);

    if (result.ok) return { data: result.data, attempts: attempt };

    lastErrors = result.errors;
    onError?.(attempt, result.errors);
  }

  throw new Error(`连续 ${attempts} 次未通过:${lastErrors.join(";")}`);
}

const isDirectRun =
  process.argv[1] &&
  pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;

if (isDirectRun) {
  const args = process.argv.slice(2);
  const file = args.find((arg) => !arg.startsWith("--"));

  if (args.includes("--demo")) {
    const bad = JSON.stringify({ title: "示例", tags: "不是数组", score: 120 });
    const good = JSON.stringify({ title: "示例", tags: ["标签"], score: 80 });

    const result = await guard({
      schema: ARTICLE_SCHEMA,
      attempts: 2,
      generate: (attempt) => (attempt === 1 ? bad : good),
      onError: (attempt, errors) => {
        console.log(`第 ${attempt} 次:未通过(${errors[0]})`);
      },
    });

    console.log(`第 ${result.attempts} 次:通过`);
  } else if (!file) {
    console.error("用法:node guard.mjs <response.json> 或 node guard.mjs --demo");
    process.exitCode = 1;
  } else {
    const text = await readFile(file, "utf8");
    const result = parseAndValidate(text, ARTICLE_SCHEMA);

    if (result.ok) {
      console.log(`校验通过:${Object.keys(result.data).join(", ")}`);
    } else {
      console.error(`校验失败(${result.errors.length} 处):`);
      result.errors.forEach((error) => console.error(`- ${error}`));
      process.exitCode = 1;
    }
  }
}

三个函数各管一件事:

  • parseAndValidate:先 JSON.parse,再校验;解析失败也返回错误,不抛异常。
  • guard:最多试 attempts 次,成功就返回;失败把错误交给 onError。
  • validate:递归检查类型、数组元素、数值范围和枚举。

看真实输出

先用 --demo 跑一次重试:第一次 tags 类型错,第二次通过。

node resources/json-guard/guard.mjs --demo
第 1 次:未通过(root.tags: 需要 array,实际 string)
第 2 次:通过
终端里 guard.mjs --demo 输出「第 1 次:未通过(root.tags: 需要 array,实际 string)」和「第 2 次:通过」;下面两张卡片分别是第 1 次和第 2 次返回的 JSON,右卡片标着校验通过
① 第 1 次未通过 ② 第 2 次通过 ③ 最终拿到的合法 JSON(本机真跑;卡片界面为重画)

再校验一个真实返回文件 bad.json:

{"title":"示例","tags":"不是数组","score":120}
node resources/json-guard/guard.mjs bad.json
校验失败(2 处):
- root.tags: 需要 array,实际 string
- root.score: 不能大于 100

换成 good.json:

校验通过:title, tags, score
终端里三条返回依次过 guard.mjs:r1 校验通过,r2 报 JSON 解析失败,r3 报 tags 类型和 score 范围两处错误
① 第 1 条通过 ② 第 2 条解析阶段就失败 ③ 第 3 条类型和范围各一处(三份样本按真实坏法手写,输出是本机真跑)

失败时退出码是 1,可以直接接进批处理脚本。

重试怎么接

把模型调用包成一个 generate(attempt) 函数,返回原始文本:

const result = await guard({
  schema: ARTICLE_SCHEMA,
  attempts: 2,
  generate: async () => callModel(prompt),
  onError: (attempt, errors) => log(attempt, errors),
});

重试前最好把 errors 拼进提示词,例如「上次返回的 tags 不是数组,请只输出 JSON 数组」。这比原样重发有效。

提示词也要配合

  • 明确写「只输出 JSON,不要 Markdown 代码块」。
  • 给出字段名、类型和范围,附一个最小示例。
  • 不要让它同时解释理由,解释文字会破坏 JSON。

批量任务里怎么用

  • 每条返回先 parseAndValidate,通过再写结果。
  • 失败条目把原文和错误写进 failed.jsonl,不要丢。
  • 跑完统计通过率;低于 90% 先改提示词,不要靠重试硬扛。

常见坑

1. 只校验顶层

嵌套对象和数组元素也要校验。脚本对 array 会逐个检查 items,对象字段可以继续拆 schema。

2. 无限重试

模型连续两次给错,第三次大概率还是错。设上限,把原始返回和错误落盘,人工看一眼提示词。

3. 不记录原始返回

校验失败时只存错误,不存原文,后面没法判断是模型问题还是解析问题。批量任务里把原文一起写进 failed.jsonl。

4. schema 和提示词不一致

提示词要的字段、脚本校验的字段、下游用的字段,三处必须一致。改一处就三处一起改。

下一步读什么

校验之前,先保证请求本身不会因为一次 429 中断:能重试的 AI API 客户端 管网络层,这篇管返回内容,两层接起来才完整。

这条路线收在批量任务可靠性。

常见问题

为什么不能只写 JSON.parse?

parse 只保证语法合法,不保证字段对。模型可能返回字符串形式的数组、超出范围的分数,或缺字段;这些都会在后续批量处理时变成脏数据。

重试几次合适?

默认 2 次。重试前把校验错误回传给模型,比原样重发更有效;连续失败就落盘人工看,不要无限重试。

需要引入 Zod 吗?

字段少时不必。这个脚本用一个小 schema 覆盖类型、范围和枚举;字段多、嵌套深时再换 Zod 或 JSON Schema。

校验失败的数据去哪了?

脚本只返回错误;批量任务里建议把原始返回和错误一起写进 failed.jsonl,方便回看模型到底错在哪。

每周一张复习表

每周分享一张实用复习表,不收钱不打扰,想走随时走

邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。

同方向继续

全部教程