用结构化输出时,zod schema 不只是「字段清单」——它同时决定了模型能看到什么、能生成什么。上一篇文章讲了怎么用 schema 约束输出,这篇专门讲 schema 本身的两个高频问题:字段能不能是可选的,以及有哪些高阶用法和坑。
一、Zod 是什么
Zod 是一个 TypeScript 优先的 schema 声明与校验库。你用它描述「数据长什么样」,它会同时给你三样东西:
- 运行时校验:
parse()检查数据是否符合声明,不符合就抛错; - 静态类型推导:用
z.infer从 schema 反推出 TS 类型; - 生成 JSON Schema:
z.toJSONSchema()把 schema 转成标准 JSON Schema。
import { z } from "zod";
const User = z.object({
name: z.string().describe("人名"),
age: z.number().int().describe("年龄(整数)"),
});
User.parse({ name: "张三", age: 30 }); // 通过
User.parse({ name: "张三", age: "30" }); // 抛错:age 必须是 number
type User = z.infer<typeof User>; // { name: string; age: number }
z.toJSONSchema(User); // 转成 JSON Schema
在 LangChain 里,Zod 是定义工具参数和结构化输出 schema 的标准载体,相当于 Python 侧的 Pydantic。之所以能用它做结构化输出,正是因为上面第 3 点——schema 能被转成模型看得懂的 JSON Schema。
二、schema 决定了三件事
一个 schema 在结构化输出里同时承担三种职责:
- 约束模型:类型、必填与否(
required)、取值范围、枚举、嵌套结构,会以 JSON Schema 的形式发给模型,直接决定它能生成什么; - 校验结果:拿到模型输出后用它做校验,不合法就报错;
- 类型推导:给 TypeScript 提供静态类型。
关键在于:只有「能被表达成 JSON Schema」的信息才会传给模型。有些 zod 能力(如 refine、transform)模型根本看不到,用错了会误以为在约束模型,其实没有。下面分别说明。
三、可选字段:optional vs nullable
实现「非必填」有三种写法,它们生成的 JSON Schema 完全不同:
const Person = z.object({
name: z.string().describe("人名"), // 必填
nickname: z.string().optional().describe("昵称,没有就不要给"), // 可缺失
age: z.number().int().nullable().describe("年龄;不知道就填 null"), // 可空
city: z.string().describe("所在城市"),
});
生成的 JSON Schema(关注 required 和 age 的 anyOf):
{
"type": "object",
"properties": {
"name": { "type": "string" },
"nickname": { "type": "string" },
"age": { "anyOf": [{ "type": "integer" }, { "type": "null" }] },
"city": { "type": "string" }
},
"required": ["name", "age", "city"],
"additionalProperties": false
}
可以看到:
.optional()的效果是「从required里移除」——注意nickname不在required中,代表允许缺失;.nullable()的效果是「仍然必填,但允许null」——age仍在required里,只是类型变成integer | null。
实际调用时这个差异很明显:
// "提取:李四,住在北京。年龄和昵称不详。"
{ "name": "李四", "age": null, "city": "北京" }
// 没有昵称 -> nickname 整个字段消失(optional)
// 年龄未知 -> age 为 null(nullable)
三条实践建议
- 模型未必会省略 optional 字段:它可能照样返回,甚至填空字符串。
optional只是「校验时缺失也放行」,不是「模型一定会省略」。 - 严格模式优先用
nullable:OpenAI 的json_schemastrict 模式要求所有属性都在required里,用.optional()移除 required 会被厂商拒绝。要表达「可空」,请用.nullable()。 - 想清楚语义:「字段不存在」和「值为 null」对下游代码往往是两种含义,按需二选一,别混用。
四、高阶用法:枚举 / 数组 / 嵌套 / 联合
除了标量字段,zod 提供的这些结构都会被完整表达进 JSON Schema,从而约束模型:
const ContactMethod = z.discriminatedUnion("type", [
z.object({ type: z.literal("email"), address: z.string().describe("邮箱地址") }),
z.object({ type: z.literal("phone"), number: z.string().describe("电话号码") }),
]);
const Resume = z.object({
name: z.string().describe("人名"),
role: z.enum(["admin", "user", "guest"]).describe("角色,三选一"),
tags: z.array(z.string()).describe("标签列表"),
address: z
.object({
city: z.string().describe("城市"),
zip: z.string().optional().describe("邮编,没有就不给"),
})
.describe("住址"),
contact: ContactMethod.describe("首选联系方式,二选一"),
});
生成的 JSON Schema 里,你会看到 enum、items、嵌套 object、以及联合对应的 oneOf:
{
"properties": {
"role": { "type": "string", "enum": ["admin", "user", "guest"] },
"tags": { "type": "array", "items": { "type": "string" } },
"address": { "type": "object", "properties": { ... }, "required": ["city"] },
"contact": { "oneOf": [ { "type": "object", ... }, { "type": "object", ... } ] }
}
}
实测调用效果:
// "我叫王五,管理员,爱好跑步和咖啡,住上海 200000,邮箱 wang@example.com。"
{ "name": "王五", "role": "admin", "tags": ["跑步","咖啡"],
"address": { "city": "上海", "zip": "200000" },
"contact": { "type": "email", "address": "wang@example.com" } }
// "我叫赵六,普通用户,喜欢看书,住北京,电话 13800138000。"
{ "name": "赵六", "role": "user", "tags": ["看书"],
"address": { "city": "北京" },
"contact": { "type": "phone", "number": "13800138000" } }
要点:
enum/literal:把取值收窄成若干选项,强烈推荐,能有效减少模型「自由发挥」;array/record/tuple:表达集合类字段;- 嵌套
object:把复杂对象分层,模型依然能填对; discriminatedUnion:表达「多种形状」,用一个判别字段(如type)区分分支,模型会按内容自动选对分支。
五、两个必须避开的坑
坑 1:refine 不会写进 schema,模型看不见
const withRefine = z.object({
code: z.string().refine((s) => s.length === 6, "必须是 6 位"),
});
z.toJSONSchema(withRefine);
// {"type":"object","properties":{"code":{"type":"string"}},"required":["code"], ...}
生成出来的 schema 里看不到任何长度约束——refine 只在你拿到结果后做本地校验,完全约束不了模型。想让模型遵守,必须把要求写进 .describe():
code: z.string().describe("必须是 6 位字符串")
superRefine、.email()、.regex() 等同理:它们主要作为客户端兜底校验,模型侧看不到。
坑 2:transform / pipe 无法转成 JSON Schema
z.toJSONSchema(z.object({ x: z.string().transform((s) => s.length) }));
// Error: Transforms cannot be represented in JSON Schema
transform 会改变输出形状,无法用 JSON Schema 表达,直接报错。需要清洗/转换时,请在拿到结构化结果之后再手动处理,别塞进 schema。
六、顶层不要直接放联合
如果 schema 的顶层就是一个联合:
const Contact = z.discriminatedUnion("type", [ ... ]);
const extract = model.withStructuredOutput(Contact); // ❌ 可能报错
生成的 JSON Schema 顶层是 oneOf、没有 type: "object",部分厂商(如 DeepSeek)会直接拒绝:
Invalid schema for function 'extract_contact': schema must be a JSON Schema of 'type: "object"', got 'type: null'
解决办法:把联合放进一个 object 字段里,让外层始终是 object:
const Wrapper = z.object({
name: z.string(),
contact: Contact.describe("首选联系方式"),
});
七、小结
- 可选:
.optional()从required移除;.nullable()保持必填但允许 null。严格模式/跨厂商推荐用nullable;注意「缺失」和「null」语义不同。 - 高阶:
enum/literal收窄取值,array/record表达集合,嵌套object分层,discriminatedUnion表达多形状。 - 模型能看到的只有
type/required/enum/description这类结构化信息;refine只做本地校验,transform会让 schema 生成失败。 - 顶层不要直接放 union,套进一个
object字段里更兼容。
一句话:schema 里「给模型看」和「给自己校验」是两套东西——想让模型遵守,写进 description 和类型约束;想兜底校验,才用 refine。