创见博客
Zod schema 进阶:可选字段与高阶用法
七崽爱吃小饼干2026/10/14阅读 0

用结构化输出时,zod schema 不只是「字段清单」——它同时决定了模型能看到什么、能生成什么。上一篇文章讲了怎么用 schema 约束输出,这篇专门讲 schema 本身的两个高频问题:字段能不能是可选的,以及有哪些高阶用法和坑。

一、Zod 是什么

Zod 是一个 TypeScript 优先的 schema 声明与校验库。你用它描述「数据长什么样」,它会同时给你三样东西:

  1. 运行时校验:parse() 检查数据是否符合声明,不符合就抛错;
  2. 静态类型推导:用 z.infer 从 schema 反推出 TS 类型;
  3. 生成 JSON Schema:z.toJSONSchema() 把 schema 转成标准 JSON Schema。
ts
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 在结构化输出里同时承担三种职责:

  1. 约束模型:类型、必填与否(required)、取值范围、枚举、嵌套结构,会以 JSON Schema 的形式发给模型,直接决定它能生成什么;
  2. 校验结果:拿到模型输出后用它做校验,不合法就报错;
  3. 类型推导:给 TypeScript 提供静态类型。

关键在于:只有「能被表达成 JSON Schema」的信息才会传给模型。有些 zod 能力(如 refine、transform)模型根本看不到,用错了会误以为在约束模型,其实没有。下面分别说明。

三、可选字段:optional vs nullable

实现「非必填」有三种写法,它们生成的 JSON Schema 完全不同:

ts
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):

json
{
  "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。

实际调用时这个差异很明显:

js
// "提取:李四,住在北京。年龄和昵称不详。"
{ "name": "李四", "age": null, "city": "北京" }
// 没有昵称 -> nickname 整个字段消失(optional)
// 年龄未知 -> age 为 null(nullable)

三条实践建议

  1. 模型未必会省略 optional 字段:它可能照样返回,甚至填空字符串。optional 只是「校验时缺失也放行」,不是「模型一定会省略」。
  2. 严格模式优先用 nullable:OpenAI 的 json_schema strict 模式要求所有属性都在 required 里,用 .optional() 移除 required 会被厂商拒绝。要表达「可空」,请用 .nullable()。
  3. 想清楚语义:「字段不存在」和「值为 null」对下游代码往往是两种含义,按需二选一,别混用。

四、高阶用法:枚举 / 数组 / 嵌套 / 联合

除了标量字段,zod 提供的这些结构都会被完整表达进 JSON Schema,从而约束模型:

ts
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:

json
{
  "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", ... } ] }
  }
}

实测调用效果:

js
// "我叫王五,管理员,爱好跑步和咖啡,住上海 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,模型看不见

ts
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():

ts
code: z.string().describe("必须是 6 位字符串")

superRefine、.email()、.regex() 等同理:它们主要作为客户端兜底校验,模型侧看不到。

坑 2:transform / pipe 无法转成 JSON Schema

ts
z.toJSONSchema(z.object({ x: z.string().transform((s) => s.length) }));
// Error: Transforms cannot be represented in JSON Schema

transform 会改变输出形状,无法用 JSON Schema 表达,直接报错。需要清洗/转换时,请在拿到结构化结果之后再手动处理,别塞进 schema。

六、顶层不要直接放联合

如果 schema 的顶层就是一个联合:

ts
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:

ts
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。

评论
0/100