用Zod schema同时约束LLM输出格式、运行时验证和TypeScript类型,实现「提示词约束+返回校验+错误修复」一体化。
模型返回一个字符串,你的组件需要一个对象。这两句话之间的所有环节,正是 AI 功能在生产环境中容易出问题的地方。而一张 Zod schema 可以统一掌管这一切:发送给模型的约束、返回结果的验证、驱动修复的错误文本,以及组件消费的 TypeScript 类型。
一张 schema,四项工作
// schema.ts
import { z } from "zod";
export const Ticket = z.object({
title: z.string().min(3).max(120),
severity: z.enum(["low", "medium", "high", "critical"]),
component: z.enum(["auth", "billing", "search", "api", "ui", "other"]),
steps_to_reproduce: z.array(z.string()).min(1).max(8),
affects_users: z.boolean(),
// 用 nullable 而不是 optional:模型不确定时应显式返回 null。
// 否则「没有回答」和「回答了空值」看起来一模一样,
// 你无法区分这两种情况。
estimated_hours: z.number().min(0).max(200).nullable(),
});
export type Ticket = z.infer<typeof Ticket>;
这个声明现在同时承担四项工作。它是运行时验证器。通过 z.infer,它也是编译时类型——这样类型和检查永远不会漂移,这也是 Zod 相比手写 interface 加大手写 guard 的核心论点。它生成你发送给模型作为输出约束的 JSON Schema。当验证失败时,它会产生结构化的错误信息,精确到足以作为修复指令反馈回去。
Zod 4 内置了 z.toJSONSchema(schema)。Zod 3 没有,通常的解决方案是独立的 zod-to-json-schema 包。先检查一下 package.json 里是哪个主版本:两套版本之间迁移了不少表面变更,包括 string format validators 所在的位置。这是本文唯一需要检查版本的一行代码。
import { z } from "zod";
import { Ticket } from "./schema";
// Zod 4:
const jsonSchema = z.toJSONSchema(Ticket);
const body = {
model: "openai/gpt-4o-mini",
messages: [
{ role: "system", content: "Extract a bug report. Use null where unknown." },
{ role: "user", content: report },
],
response_format: {
type: "json_schema",
json_schema: { name: "ticket", schema: jsonSchema, strict: true },
},
};
response_format 字段是 provider 相关的,其结构在不同 provider 和版本之间有所差异——这是本文第二处需要对照当前文档而不是直接复制的地方。原则是不变的:只要 provider 支持基于 schema 的约束解码,就用它,因为它使畸形 JSON 变得不可能(而不是不太可能)。本文其余部分讨论的是那些只能做到「不太可能」出错的模型。
在边界处解析
这里存在两种失败,需要不同的处理方式:字符串不是 JSON,或者它是 JSON 但不匹配。混淆两者意味着修复 prompt 告诉模型错误的信息。
// parse.ts
import type { ZodType } from "zod";
export type ParseResult<T> =
| { ok: true; value: T }
| { ok: false; kind: "not-json" | "schema"; detail: string };
/** 模型把 JSON 包在正文或代码围栏里,这种情况比任何人承认的都要频繁。 */
function extractJson(raw: string): string {
const trimmed = raw.trim();
const fence = trimmed.match(/```
(?:json)?\s*([\s\S]*?)
```/);
if (fence) return fence[1].trim();
// 否则取最外层的大括号或方括号。
const first = trimmed.search(/[{[]/);
if (first === -1) return trimmed;
const open = trimmed[first];
const close = open === "{" ? "}" : "]";
const last = trimmed.lastIndexOf(close);
return last > first ? trimmed.slice(first, last + 1) : trimmed;
}
export function parseAgainst<T>(schema: ZodType<T>, raw: string): ParseResult<T> {
let json: unknown;
try {
json = JSON.parse(extractJson(raw));
} catch (err) {
return { ok: false, kind: "not-json", detail: (err as Error).message };
}
const result = schema.safeParse(json);
if (result.success) return { ok: true, value: result.data };
// 每个问题一行,格式让模型可以直接操作。
const detail = result.error.issues
.map((i) => i.path.join(".") + ": " + i.message)
.join("\n");
return { ok: false, kind: "schema", detail };
}
故意使用 safeParse 而不是 parse:在路由处理器中抛出 ZodError 会变成 500 和堆栈跟踪,而实际上你面对的是一个可恢复的、已知下一步操作的情况。把 error.issues 展平为 path: message 行不是装饰性的——这段文字是修复轮次发回去的内容,所以值得让它可读。
修复:将错误反馈回去
一个输出了近乎正确 JSON 的模型,在被精确告知哪里出了问题之后,通常会产出正确的 JSON。修复轮次很便宜:它只重新发送损坏的输出和错误信息,不发送原始文档,所以输入只有几百个 token 而不是几千个。
// repair.ts
import type { ZodType } from "zod";
import { parseAgainst, type ParseResult } from "./parse";
export async function repairOnce<T>(
schema: ZodType<T>,
broken: string,
detail: string,
call: (messages: { role: string; content: string }[]) => Promise<string>,
): Promise<ParseResult<T>> {
const raw = await call([
{
role: "system",
content:
"You fix malformed JSON. Return only the corrected JSON object. " +
"No prose, no code fence, no explanation.",
},
{ role: "user", content: "This output was rejected:\n\n" + broken },
{ role: "user", content: "The validator reported:\n\n" + detail },
]);
return parseAgainst(schema, raw);
}
一次修复尝试,而不是循环。同一个显式错误信息下失败两次,几乎不可能在第三次成功:模型并非对格式有困惑,而是不同意这个 schema,或者源文档根本不包含所需信息。在这种情况下循环只会把快速失败变成昂贵又慢的失败——这正是重试代价的一般形态。
成本封顶的升级阶梯
// extract.ts
import type { ZodType } from "zod";
import { parseAgainst } from "./parse";
import { repairOnce } from "./repair";
type Call = (
messages: { role: string; content: string }[],
model: string,
) => Promise<string>;
export type Extracted<T> =
| { value: T; attempts: number }
| { value: null; attempts: number; reason: string };
export async function extract<T>(
schema: ZodType<T>,
messages: { role: string; content: string }[],
call: Call,
opts = { cheap: "openai/gpt-4o-mini", strong: "openai/gpt-4o" },
): Promise<Extracted<T>> {
// 1. 便宜模型,在 provider 支持的地方使用 schema 约束。
const raw = await call(messages, opts.cheap);
const first = parseAgainst(schema, raw);
if (first.ok) return { value: first.value, attempts: 1 };
// 2. 在便宜模型上做一次修复轮次。小输入,小输出。
const repaired = await repairOnce(schema, raw, first.detail, (m) =>
call(m, opts.cheap),
);
if (repaired.ok) return { value: repaired.value, attempts: 2 };
// 3. 在更强模型上尝试一次。这是最后一次花费。
const strong = parseAgainst(schema, await call(messages, opts.strong));
if (strong.ok) return { value: strong.value, attempts: 3 };
// 4. 停止。三次尝试是上限;第四次就该提工单了。
return { value: null, attempts: 3, reason: strong.detail };
}
上限是这个函数的意义所在。没有它,一份模型确实无法提取的文档——粘贴成乱码的扫描件、源数据中不存在的字段——就会变成对付费 API 的无限重试循环,而第一个症状是账单。三次尝试加硬性停止,把最坏情况的成本封顶在大约最好情况的三倍——这是一个你可以做预算的数字。
返回 null 而不是抛出异常。调用方有一个真实的决策要做:加入人工审核队列、展示部分结果,或者让用户重新表述。抛出异常会把三种选择强行塞进同一个 catch。
模型实际能完成的 Schema
一半的 schema 失败是 schema 自身的问题。模型可靠满足的约束和可靠不满足的约束,界限是分明的。
关于 schema 设计本身的更多内容,见模型能填入的 Schema 及边缘情况。
流式输出时的部分对象
流式结构化输出有一个内在矛盾:部分 JSON 字符串不是有效 JSON,所以要求每个字段都存在的 schema 在最后一个花括号到达之前什么都验证不了。两种诚实的选择。
不要流式传输对象;流式传输状态。显示"正在读取文档",然后"正在提取字段",等验证通过再渲染结果。对于表单填充功能,这比看着花括号一个个出现要好得多,而且代码也少得多。
每块数据验证一个宽松的 schema。导出一个部分版本——每个字段都是可选的——只渲染已到达且有效的字段,然后在结束时用严格 schema 再验证一次。这需要一个容忍的增量 JSON 解析器,这是一个真实的依赖项,也是真实的有趣 bug 来源;流式 JSON 解析覆盖了这方面的成本。
// 从严格 schema 派生的宽松 twin,这样它们不会漂移。
export const PartialTicket = Ticket.partial();
export type PartialTicket = z.infer<typeof PartialTicket>;
// 渲染已到达的内容;严格 parse 仍然是任何持久化或花费操作的门槛。
const view = PartialTicket.safeParse(partialJson);
if (view.success) setDraft(view.data);
无论选择哪种,严格 schema 始终是任何持久化或花费操作的门槛。宽松 schema 只是渲染上的便利,绝不是验证边界。
面向任意模型 Provider 的类型化客户端
Server Actions 和 AI:适合什么、不适合什么
你在 TypeScript 中的第一次 LLM 调用