Schema、validator、静态类型三合一,避免手工维护 JSON Schema 时的版本漂移问题,并指出 strict mode 下的兼容陷阱。
用 Pydantic 或 Zod 定义数据结构而不是直接写 JSON Schema,原因不在于人体工学,而在于:你发送给 API 的 schema、你运行的验证器、以及你的编辑器认识的静态类型,三者其实是同一个对象——它们不可能在无人察觉的情况下发生漂移。
一份定义,三重职责
class Invoice(BaseModel) / const Invoice = z.object({...})
|
+-------------+-------------+
| | |
JSON Schema validator static type
(the request) (the parse) (your editor)
手写的话,这三份东西是三份文件,此刻它们是一致的。但如果从同一份定义派生出来,不一致就根本不可能发生——这在有人添加一个字段时最为关键,因为 schema、解析器和类型三者会一起变化,否则构建就会失败。
问题在于图的中间部分。两个库生成的 JSON Schema 都不是严格模式要求的方言,而那些差异恰恰就是导致 400 错误的根源。
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class Invoice(BaseModel):
reasoning: str = Field(description="Where on the page you found these. Two sentences.")
invoice_number: str
total_including_tax: float = Field(description="Grand total, not the subtotal.")
currency: Literal["EUR", "GBP", "USD", "OTHER"]
customer_name: str | None = Field(description="null if the document names no customer.")
@field_validator("total_including_tax")
@classmethod
def sane(cls, v: float) -> float:
if v < 0 or v > 10_000_000:
raise ValueError("total outside plausible range")
return v
schema = Invoice.model_json_schema()
关于 model_json_schema() 返回的结果,有三点需要注意。嵌套模型会变成 $defs 加上 $ref,这正是 strict 模式接受的形式。str | None 会变成 anyOf 包含 {"type":"string"} 和 {"type":"null"},而不是类型数组的形式——通常没问题,但当错误信息指向一个你根本没写过的 anyOf 时,需要知道这正是原因所在。此外,带有默认值的字段会变成 optional 并带上 default 键,这正是 strict 模式最常拒绝的原因:默认值是 Python 的概念,但请求 schema 里没有地方存放它。
注意 reasoning 字段在最前面,还有它的验证器。类中的字段顺序就是 schema 中的字段顺序,也就是生成时的顺序。验证器在后续运行,执行的是一个 strict schema 完全无法表达的数值范围检查。
The strict-schema walker
这就是缺失的那一步。它遍历生成的 schema 并应用 strict 模式要求的三种转换,包括在 $defs 内部——这正是手写修复总是漏掉一个的地方:
# Drop keywords hosted strict modes do not accept, force every property
# required, and forbid extra properties on every object -- everywhere.
DROP = {
"default", "examples", "$comment", "readOnly", "writeOnly", "deprecated",
"minLength", "maxLength", "pattern", "format",
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
"minItems", "maxItems", "uniqueItems",
"minProperties", "maxProperties", "patternProperties",
}
def strictify(node):
if isinstance(node, list):
return [strictify(n) for n in node]
if not isinstance(node, dict):
return node
out = {k: strictify(v) for k, v in node.items() if k not in DROP}
if out.get("type") == "object" or "properties" in out:
props = out.setdefault("properties", {})
out["additionalProperties"] = False
out["required"] = list(props.keys()) # every property, no exceptions
return out
schema = strictify(Invoice.model_json_schema())
Two warnings. Forcing every property into required is correct only if every optional field is genuinely nullable in your model — run it over a model with a non-nullable defaulted field and you have told the API a field is required that your own type says can be absent, and the mismatch shows up as a validation error on a response that was fine. Fix the model, not the walker. Second, DROP is a snapshot; the supported keyword sets differ per provider and change. Check yours rather than trusting this list.
DROP 中所有承载语义的项——范围、模式、长度下限——都必须有个去处。去处就是模型的字段描述和你的验证器。从 schema 中删除它不等于删除这个约束。
值得明确说明,因为在写完 walker 之后很容易忽略:类型定义仍然是严格的那一个。你的 Pydantic 模型或 Zod 对象仍然携带范围检查、模式和长度约束,并在响应返回时仍然执行它们。strictify 产生的是该定义的一个有损投影,仅用于传输,仅用于传输。如果你发现自己为了使生成的 schema 通过而削弱了模型,那你把依赖关系搞反了——请求 schema 派生自你的类型,而不是反过来。
import { z } from "zod";
export const Invoice = z.object({
reasoning: z.string().describe("Where on the page you found these."),
invoice_number: z.string(),
total_including_tax: z.number().describe("Grand total, not the subtotal."),
currency: z.enum(["EUR", "GBP", "USD", "OTHER"]),
customer_name: z.string().nullable().describe("null if none is named."),
});
export type Invoice = z.infer<typeof Invoice>; // the static type, free
const parsed = Invoice.safeParse(JSON.parse(raw));
if (!parsed.success) {
// parsed.error.issues[] has { path, code, message } -- the path is what
// you put in a repair prompt.
return handleInvalid(parsed.error.issues);
}
const invoice: Invoice = parsed.data; // typed from here down
用 .nullable(),永远不要用 .optional(),原因与 Python 相同:optional 产生一个可能不存在的键,而 strict 模式不会接受。优先使用 provider 提供的 SDK helper 来将 Zod 对象转换为请求 schema,而不是通用转换器,因为 helper 会跟踪 API 当前需要的方言。
两个库特性值得主动掌握而非事后才发现。两个生态系统都有鉴别联合构造——Zod 的 z.discriminatedUnion,以及 Pydantic 中 Field(discriminator=...) 联合——两者都编译成 tagged anyOf 配合 const,约束解码处理得最好,所以使用它们能获得正确的 schema 形状而无需手写。此外,两个库都允许向发出的 schema 附加任意键:Zod 中是 describe(),Pydantic 中是 Field(description=...) 或 json_schema_extra。由于描述是模型最仔细读取的 schema 部分,将其放在类型定义上意味着指令和验证规则在同一个地方编辑——这其实就是这种方法的全部论点。
三个地方链条会泄漏
验证不是验证。parsed.data 类型是 Invoice。但它不是因此就是一个正确的 invoice。静态类型描述形状;每个语义检查——总额相加、日期在范围内、源中存在引号——都是你自己写的验证器。把它们放在模型里,它们随类型一起流动。
解析边界是真实的边界。类型断言在运行时什么都不做。JSON.parse(raw) as Invoice 是一个编译器无法捕获的谎言,而且是有类型管道最终让无类型数据流过它的最常见方式。始终用 safeParse。在 Python 中,始终用 model_validate,从不用裸露的 cast。
每个响应仍然可能是拒绝或截断。两者都是格式良好的 HTTP 200。在到达解析器之前检查 finish_reason 和任何拒绝字段,否则它们会作为令人困惑的验证错误出现在远离起因的地方。
可选字段、Null 和联合:Schema 失效的地方
设计 LLM 能正确填充的 JSON Schema
测试结构化输出:测试套件设计