LLM 输出不是可信的程序内部对象,而是一段来自外部系统、可能随时偏离约定的文本。本文用 Node.js 与 Zod 建立明确的解析边界,并把重试、错误分类和降级纳入同一条可观测流程。

为什么模型输出需要输入验证

即使提示词明确要求“只返回 JSON”,线上仍会遇到多种偏差:模型可能遗漏字段,把数字写成字符串,在 JSON 前后添加解释文字,或者因超时、长度限制而只返回半个对象。模型升级、提示词调整和上下文变化也可能引入新的类型漂移。

常见故障可以归纳如下:

故障示例处理方式
字段缺失没有 confidenceSchema 校验失败
类型漂移confidence: "0.8"拒绝隐式转换
响应截断{"version":1,"items":[分类为截断并有限重试
Markdown 包裹```json ... ```仅移除完整围栏
混入说明文字结果如下:{...}拒绝整个响应
多余字段出现未约定的 debug严格对象校验

不建议用类似 /\{.*\}/s 的正则从文本中“捞出”JSON。字符串内容本身可能包含大括号,响应也可能含有多个对象;正则提取还会掩盖模型违反协议的事实。更可靠的边界是:只接受原始 JSON,或接受被一个完整 Markdown 围栏包裹的 JSON,然后依次执行 JSON.parse 与 Schema 校验。

用 Zod 定义稳定的数据契约

下面假设模型需要返回摘要、行动项和置信度。每个对象都使用 .strict(),因此未知字段不会被静默丢弃,而是产生校验错误。字符串长度、数组规模和数值范围也在边界处限制。

import { z } from "zod";

const OutputSchema = z.object({
  version: z.literal(1),
  summary: z.string().min(1).max(500),
  items: z.array(
    z.object({
      title: z.string().min(1).max(100),
      priority: z.enum(["low", "medium", "high"])
    }).strict()
  ).max(20),
  confidence: z.number().min(0).max(1)
}).strict();

这里有意不使用 z.coerce.number()。如果协议规定 confidence 是数字,那么字符串就是协议错误。自动转换虽然方便,却可能让上游格式漂移长期不被发现。只有业务确实允许多种输入表示时,才应显式加入转换规则。

version 字段也很重要。将来结构升级时,可以为不同版本维护独立 Schema,而不是猜测某个字段为何突然消失。

实现可运行的解析边界

创建一个新目录并安装 Zod:

npm init -y
npm pkg set type=module
npm install zod

将以下内容保存为 index.mjs。示例使用模拟模型函数,因此可以直接运行;接入具体模型 SDK 时,只需替换 callModel,解析层不依赖任何供应商 API。

import { z } from "zod";

const OutputSchema = z.object({
  version: z.literal(1),
  summary: z.string().min(1).max(500),
  items: z.array(z.object({
    title: z.string().min(1).max(100),
    priority: z.enum(["low", "medium", "high"])
  }).strict()).max(20),
  confidence: z.number().min(0).max(1)
}).strict();

class OutputError extends Error {
  constructor(category, message, details = []) {
    super(message);
    this.name = "OutputError";
    this.category = category;
    this.details = details;
  }
}

function unwrapMarkdownFence(input) {
  const text = input.replace(/^\uFEFF/, "").trim();
  if (!text) throw new OutputError("EMPTY_RESPONSE", "模型返回为空");
  if (!text.startsWith("```")) return text;

  const lines = text.split("\n");
  const opening = lines[0].trim().toLowerCase();
  const closing = lines.at(-1).trim();
  if (!["```", "```json"].includes(opening) || closing !== "```") {
    throw new OutputError("TRUNCATED_RESPONSE", "Markdown 围栏不完整");
  }
  return lines.slice(1, -1).join("\n").trim();
}

function looksTruncated(text) {
  const stack = [];
  let inString = false;
  let escaped = false;

  for (const char of text) {
    if (inString) {
      if (escaped) escaped = false;
      else if (char === "\\") escaped = true;
      else if (char === "\"") inString = false;
      continue;
    }
    if (char === "\"") inString = true;
    else if (char === "{" || char === "[") stack.push(char);
    else if (char === "}" || char === "]") stack.pop();
  }
  return inString || stack.length > 0;
}

function parseModelOutput(raw) {
  const candidate = unwrapMarkdownFence(raw);
  if (!candidate.startsWith("{")) {
    throw new OutputError("INVALID_WRAPPER", "响应不是单个 JSON 对象");
  }

  let value;
  try {
    value = JSON.parse(candidate);
  } catch {
    const category = looksTruncated(candidate)
      ? "TRUNCATED_RESPONSE"
      : "INVALID_JSON";
    throw new OutputError(category, "JSON 解析失败");
  }

  const result = OutputSchema.safeParse(value);
  if (!result.success) {
    const details = result.error.issues.map((issue) => ({
      path: issue.path.join("."),
      code: issue.code,
      message: issue.message
    }));
    throw new OutputError("SCHEMA_MISMATCH", "响应不符合 Schema", details);
  }
  return result.data;
}

function repairInstruction(error) {
  const paths = error.details.map((item) => item.path).filter(Boolean);
  const suffix = paths.length ? `错误字段:${paths.join(", ")}。` : "";
  return `上一轮输出无效,类别为 ${error.category}。${suffix}` +
    "请重新生成,只返回一个符合约定的 JSON 对象,不要添加解释文字。";
}

async function generateStructured(callModel, maxAttempts = 3) {
  const errors = [];
  let feedback = "请返回约定的 JSON 对象。";

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    let raw;
    try {
      raw = await callModel(feedback);
    } catch {
      const error = new OutputError("TRANSPORT_ERROR", "模型调用失败");
      errors.push({ attempt, category: error.category });
      feedback = repairInstruction(error);
      continue;
    }

    try {
      return { ok: true, data: parseModelOutput(raw), errors };
    } catch (cause) {
      if (!(cause instanceof OutputError)) throw cause;
      errors.push({
        attempt,
        category: cause.category,
        details: cause.details
      });
      feedback = repairInstruction(cause);
    }
  }

  const fallback = OutputSchema.parse({
    version: 1,
    summary: "暂时无法生成结构化结果,请稍后重试。",
    items: [],
    confidence: 0
  });
  return { ok: false, data: fallback, errors };
}

const responses = [
  "结果如下:{\"version\":1}",
  JSON.stringify({
    version: 1,
    summary: "先验证模型输出,再进入业务逻辑。",
    items: [{ title: "增加 Zod 校验", priority: "high" }],
    confidence: 0.86
  })
];

const callModel = async (feedback) => {
  console.log("发送给模型的修复提示:", feedback);
  return responses.shift() ?? "";
};

console.dir(await generateStructured(callModel), { depth: null });

运行命令为:

node index.mjs

第一次响应混入说明文字,会被归类为 INVALID_WRAPPER;第二次响应通过校验。代码不会尝试从第一段文本中提取看似正确的局部 JSON。

有限重试、错误分类与降级

重试应有明确上限。无限重试既不能保证修复格式,还会持续消耗时间和调用额度。示例最多尝试三次,并把错误类别及字段路径反馈给下一轮,但不回传整段原始响应,避免提示词膨胀或把不必要的敏感内容再次发送给模型。

错误分类的价值不只是生成修复提示,也方便监控:

  • TRANSPORT_ERROR 表示调用链路失败,应结合超时和上游状态处理。
  • TRUNCATED_RESPONSE 常与长度限制、流式中断或不完整围栏有关。
  • INVALID_JSON 表示文本外形接近 JSON,但语法不合法。
  • INVALID_WRAPPER 表示混入说明文字或返回了错误的根类型。
  • SCHEMA_MISMATCH 表示 JSON 合法,但字段、类型或约束不符合契约。

耗尽尝试次数后,示例返回一个同样经过 Schema 验证的降级对象,并用 ok: false 告知调用方结果并非模型正常产物。实际业务也可以改为抛出领域错误、进入人工队列,或返回缓存结果;关键是降级路径必须显式,不能让半合法数据继续流入数据库或自动化操作。

生产环境中的边界细节

解析边界之外还应设置响应字节数上限和模型调用超时,防止异常大文本占用内存。日志建议记录请求 ID、尝试次数、错误类别和 Schema 路径,不要默认保存完整模型输出,因为其中可能包含用户数据。

如果使用流式输出,应等待流正常结束后再解析;连接中断时直接记录传输或截断错误,不要把半段 JSON 当成可恢复对象。对于会触发支付、删除或发信等副作用的数据,还要继续执行权限校验、资源存在性检查和幂等控制。Zod 只能证明结构符合约定,不能证明内容真实,也不能代替业务授权。

所谓 JSON 修复库同样要谨慎使用。自动补引号或逗号可能改变模型原意。对于只读摘要可以评估宽松策略,但对高风险操作,更适合拒绝、有限重试或转人工。

总结

把 LLM 输出当作不可信输入,意味着沿用成熟的输入验证思路,而不是为模型建立例外:

  • 用 Zod 明确定义字段、类型、范围、数量和版本。
  • 只做有限且确定的 Markdown 围栏清理,不用正则提取局部 JSON。
  • 将 JSON 语法错误、截断、包装错误、Schema 错误和传输错误分别记录。
  • 设置有限重试,并向下一轮提供简短、结构化的修复信息。
  • 重试耗尽后进入经过校验的降级路径,不传播半合法数据。
  • Schema 校验之后仍要执行权限、真实性和业务规则检查。

这套边界不会让模型永远正确,但能让错误在进入核心业务前被发现、分类并控制。