Zod .refine() 为何不会进入 JSON Schema:LLM 结构化输出的隐性陷阱

Zod 的 .refine() 在转换为 JSON Schema 时被静默丢弃,模型从未收到这些约束。
文章指出,Zod 4 中每个 `.refine()` 在内部被存为名为 `custom` 的 check,而 `z.toJSONSchema()` 的 contributors 映射只覆盖 13 个内置校验键,不包含 `custom`,导致 refine 规则被静默丢弃。OpenAI Node SDK 与 Vercel AI SDK 都依赖这份 JSON Schema 约束模型,因此字段级 refine、对象级 refine 全部失效,`.transform()` 则要么让模型只看到字符串类型(如 transform(Number) 造成 NaN 静默通过),要么直接报 unrepresentable 错误。更糟的是各供应商还会再剥一层:Anthropic 把数值和长度约束降级为描述文本,OpenAI 不支持 allOf/not/if-then-else。务实做法是把规则写进 `.describe()`,让 refine 退居本地第二道防线,并用 safeParse 回传错误重试一次。
把 Zod 4 的 schema 直接交给 OpenAI Node SDK 或 Vercel AI SDK 的结构化输出工具,你写下的每一个 .refine() 都会在抵达模型之前被静默丢弃。模型看到的并不是你写的那份 schema,而是 z.toJSONSchema() 转换后的一份子集。这条链路中丢失的规则,往往正是业务上最关键的约束。

一条被跳过的分支
问题出在 Zod 的 JSON Schema 转换器里。根据作者对 Zod v4.6.7(2026 年 10 月发布)源码的分析,json-schema-processors.ts 中有一行代码决定哪些校验规则会被写进 schema:
for (const ch of list) contributors[ch._zod.def.check]?.(agg, ch._zod.def);
contributors 映射只有 13 个键:greater_than、less_than、multiple_of、min_length、string_format 等。而你写的每一个 .refine() 在 Zod 内部被存为一个名为 custom 的 check。custom 不在键列表里,可选调用找不到对应处理器,规则被直接跳过。
OpenAI Node SDK 和 Vercel AI SDK 都调用 z.toJSONSchema(),而这份 JSON 才是真正约束模型的产物。.refine() 依然会在响应解析阶段运行,只是模型从未收到过这条规则。
什么是 JSON Schema
JSON Schema 是一份用 JSON 写成的、描述 JSON 数据结构与约束的规范,由 IETF 以 draft 系列文档标准化(文章里 SDK 使用的 draft-7 就是其中一版)。它能表达的能力是有边界的:类型、枚举、正则 pattern、日期 format、数值上下界、必填字段、数组长度这类「结构 + 简单值约束」可以描述;而任意自定义函数(例如「note 里不能出现网址」)、跨字段比较(例如「退款日期不能早于下单日期」)、以及会改变数据形状的转换(例如把字符串转成数字)都超出了它的表达范围。也正因如此,JSON Schema 通常以「子集」的形式被各家模型 API 支持:OpenAI、Anthropic 等只接受其中一部分关键字,其余约束要么被忽略、要么被降级成自然语言描述。理解这个能力边界,是理解本文痛点——Zod 的 .refine() 本质上是一段任意的 TypeScript 函数,天然无法被序列化进 JSON Schema——的前提。
一份退款抽取 schema 的实地对照
作者用一个退款信息抽取 schema 做了演示,包含四类规则:字段级 regex、positive()、z.enum、ISO 日期格式,以及 note 上的 .refine()、跨字段的对象级 .refine()、sku 上的 .transform()。
把 z.toJSONSchema(Refund, { io: "input" }) 的输出与 Zod 源码逐行对照,结果如下:
| 字段 | Zod 规则 | 模型看到的内容 |
|---|---|---|
orderId | .regex(...) | pattern |
amount | .positive() | exclusiveMinimum: 0 |
currency | z.enum | enum |
orderDate/refundDate | z.iso.date() | format: "date" 加 pattern |
note | .refine(禁止链接) | 只剩 "type": "string" |
sku | .transform(...) | input 模式为普通字符串;output 模式报错 |
| 整个对象 | .refine(退款日期晚于下单日期) | 什么都没有 |
Zod 的测试文件佐证了 note 这一行:把两个 .refine() 后的字符串联合起来,快照会把两个分支都塌缩成裸的 "type": "string"——只有两个 refine 都没进入输出,才可能被这样去重。对象级 refine 同理,.superRefine() 和对象级 .refine() 同样以 custom check 落地。
另一个细节来自 Zod JSON Schema 文档:input 模式下对象不会设置 additionalProperties,AI SDK 用自己的 addAdditionalPropertiesToJsonSchema 补回;默认 output 模式下 Zod 会自己写 additionalProperties: false。
z.toJSONSchema() 与 io 选项
z.toJSONSchema(schema, options) 是 Zod 4 提供的转换入口,它把 Zod schema 编译成一份标准 JSON Schema,供模型的结构化输出接口消费。其中最关键的一个选项是 io。Zod schema 天然是双向的:z.string().transform(Number) 的输入是字符串、输出是数字,两者并不相同。io: "input" 要求生成的 schema 描述「进入 Zod 之前的形状」,io: "output"(默认值)描述「经过 Zod 解析之后的形状」。这个区分直接决定了转换器会取 transform 的哪一侧、以及对象上是否写入 additionalProperties: false。文章里 sku 在两种模式下表现不同、OpenAI helper 默认不传 io 而在 output 模式下报错,根源都在这里。因此当你把 schema 交给模型时,需要先想清楚:模型负责产出的是原始输入,也就是应当用 io: "input" 的那一份。
transform 的两种命运:报错或说谎
.transform() 的行为取决于 io 选项。字符串上的 transform 本质是管道,processor 用 ctx.io === "input" ? (inIsTransform ? def.out : def.in) : def.out 选边。
input 模式下取字符串一侧,模型看到 { "type": "string" },转换在拿到数据后发生。对于 sku 这类大小写转换是无害的。但换成 z.string().transform(Number) 性质就变了:模型只被告知"这是字符串",于是它可以自信地写出 "twelve" 或 "12 EUR",Number() 悄悄交给业务代码一个 NaN,没有任何校验错误。
output 模式(默认)下,转换器会直接撞上 transform 本身,调用 handleUnrepresentable 报出"Transforms cannot be represented in JSON Schema"。文档说除非传 unrepresentable: "any",否则 Zod 会抛错。OpenAI helper 调用 z4.toJSONSchema(schema, { target: 'draft-7', ... }) 时没有改动 io 或 unrepresentable,所以在构造 format 阶段就会失败——比静默传错更早、更响。
结构化输出(Structured Outputs)的机制
结构化输出是 OpenAI 等厂商提供的能力:调用方在请求里附带一份 JSON Schema,服务端在解码阶段就强制模型的输出满足该 schema,而不是只在事后校验。实现上通常采用受限解码(constrained / grammar-based decoding),把 schema 编译成一套语法规则,逐 token 屏蔽掉会导致违规的候选词,因此模型「在物理上无法」输出不合规的字段名、类型或枚举值。这也解释了它的能力边界:凡是能被编译成语法规则的约束(类型、必填、枚举、pattern 等)都能在解码期生效;凡是需要任意计算或跨字段推理才能判定的约束,无法编成语法,厂商只能选择忽略、抛错,或像 Anthropic 那样把它挪进字段的 description 文本里,寄希望于模型「读懂并自觉遵守」——这已从硬约束退化为软提示。
供应商还会再剥一层
规则进入 JSON Schema 不等于模型会被它约束,每个 API 只支持一个子集。
Anthropic 的结构化输出文档把 minimum、maximum、multipleOf 以及 minLength、maxLength 列为不支持。他们的 SDK helper 会把这些约束移除,追加到字段 description 的文本里,再拿原始 schema 校验响应。.positive() 产出的 exclusiveMinimum 属于同一类数值约束,因此 amount > 0 很可能只是作为描述文本传给 Claude,而不是解码期约束。
OpenAI 支持 pattern、format、数值边界和 anyOf,把 allOf、not、if/then/else 列为不支持,并要求每个字段都进 required,可选字段写成与 null 的联合。联合类型另有一个坑:Zod 的 union processor 对可辨识联合产出 oneOf,OpenAI helper 的 override 回调会在发送前把它改写为 anyOf;如果你自己调 z.toJSONSchema() 直接发给 API,这些修补一个都拿不到。
用法:把规则写进描述,把校验留在本地
既然 refine 无法变成可表示的 JSON Schema 关键字,工作就分成两处:用自然语言告诉模型规则,然后接住它依然违反规则的那次响应。
第一步是 .describe()。Zod 会把 metadata 复制进生成的 JSON Schema,所以描述会跟随字段一起到达模型。注意把它放在链式调用的最后——metadata 属于某个 schema 实例,每调用一个方法都会返回新实例,.refine() 之后再取 .meta() 会得到 undefined。
export const Refund = z
.object({
orderId: z.string().regex(/^ORD-\d{6}$/).describe("Order id, e.g. ORD-123456"),
amount: z.number().positive().describe("Greater than 0"),
currency: z.enum(["EUR", "USD"]),
orderDate: z.iso.date(),
refundDate: z.iso.date().describe("ISO date, on or after orderDate"),
note: z
.string()
.refine((s) => !/https?:\/\//.test(s), { message: "note must not contain links" })
.describe("Plain text. No URLs."),
sku: z.string().describe("SKU exactly as written"),
})
.refine((r) => r.refundDate >= r.orderDate, {
message: "refundDate is before orderDate",
path: ["refundDate"],
});
refine 依然保留,但它们从唯一防线变成了第二道防线。transform 则应该移出发给模型的 schema,在解析完成后重塑数据。
第二步是自己校验响应,并把 Zod 错误回传给模型重试一次。OpenAI SDK 的 responses.parse 确实会调用 schema 的 parse,但它失败时直接抛错;改用 responses.create 加 safeParse,你就拿到了可以回传的错误对象。
export async function extract<T extends z.ZodType>(
schema: T,
name: string,
prompt: string,
): Promise<z.infer<T>> {
const input: Msg[] = [{ role: "user", content: prompt }];
for (let attempt = 0; attempt < 2; attempt++) {
const res = await openai.responses.create({
model: "gpt-6-luna",
input,
text: { format: zodTextFormat(schema, name) },
});
const raw = res.output_text;
const result = schema.safeParse(JSON.parse(raw));
if (result.success) return result.data;
input.push(
{ role: "assistant", content: raw },
{
role: "user",
content:
"That JSON failed validation:\n" +
z.prettifyError(result.error) +
"\nReturn the corrected JSON only.",
},
);
}
throw new Error(`${name}: failed validation twice`);
}
z.prettifyError() 会把每条 issue 转成带 ✖ 的行,附上 → at <path>;你在 .refine() 里设置的 path 决定了字段名能否出现在这条消息里,所以对象级规则应当总是设置 path。
重试控制在一次以内:如果带着错误信息仍然连续两次违反同一规则,第三次尝试基本只是成本。抛错、记录原始输出,然后回头判断这条规则究竟该进提示词还是该留在代码里。另外 JSON.parse 对截断或拒答的响应也会抛错,生产环境应当在解析前检查响应状态。
.describe() 与 metadata 的传递
.describe(text) 会为当前 schema 实例附加一段人类可读的说明,存放在 Zod 的 metadata 中。关键在于 Zod 的绝大多数方法都是不可变的,每次调用返回一个全新的实例:.refine() 之后拿到的已经不是你写 .describe() 的那个对象,因此元数据会「跟丢」,之前链上的描述也就取不回来了。这就是文章强调把 .describe() 放在链尾的原因。而它之所以值得放进发送给模型的 schema,是因为转换器会把 metadata 一并复制进生成的 JSON Schema,大多数厂商的 API 也会把 description 字段透传给模型;于是那些无法用关键字表达的规则(不许有链接、日期先后顺序),就能以自然语言的形式随字段抵达模型,成为提示词之外的又一层约束。它是软性提示,不是保证,所以仍需要本地 .safeParse() 兜底。
结语:schema 写的是什么并不等于模型看到了什么
从 demo 到真正被人依赖的功能,"我写的 schema"与"模型看到的 schema"之间的落差是最先咬人的问题之一。这不是 Zod 或某个 SDK 的 bug,而是 JSON Schema 表达能力、各 API 支持子集与 Zod 校验语义三者之间固有的边界。把人类可读的规则放进描述、把机器可信的校验留在解析之后,才是这段链路上更稳固的分工。
相关推荐

AI数字分身实战:用DOT自动追踪Bug与用户反馈
DOTS 团队工程师演示如何用 AI 数字分身 DOT 跨平台收集用户反馈、自动归类问题主题、开线程调查 Bug 根因并直接提交 PR,展示 AI 代理参与缺陷分诊的完整工作流与可审计设计。

晶体管为何不再扁平?从平面到环绕栅极的演进
晶体管从平面到FinFET再到环绕栅极的演进:为何栅极需要从三面包裹到四面环绕,以抑制短沟道效应和泄漏电流,延续摩尔定律。

蜂群思维=单个RL智能体?多臂老虎机等价性证明
一篇论文证明蜂群通过局部模仿进行的集体决策,在数学上等价于单智能体强化学习。N个盲目模仿者相当于N个并行环境采样,有效学习率为1/N。本文解读Maynard-Cross Learning算法的推导与影响。