LLM 输出不是可信的程序内部对象,而是一段来自外部系统、可能随时偏离约定的文本。本文用 Node.js 与 Zod 建立明确的解析边界,并把重试、错误分类和降级纳入同一条可观测流程。
为什么模型输出需要输入验证
即使提示词明确要求“只返回 JSON”,线上仍会遇到多种偏差:模型可能遗漏字段,把数字写成字符串,在 JSON 前后添加解释文字,或者因超时、长度限制而只返回半个对象。模型升级、提示词调整和上下文变化也可能引入新的类型漂移。
常见故障可以归纳如下:
| 故障 | 示例 | 处理方式 |
|---|---|---|
| 字段缺失 | 没有 confidence | Schema 校验失败 |
| 类型漂移 | 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 校验之后仍要执行权限、真实性和业务规则检查。
这套边界不会让模型永远正确,但能让错误在进入核心业务前被发现、分类并控制。
评论