additionalProperties 不关,Schema 校验等于没校验
结论:校验通过不代表数据干净。Pydantic 默认忽略未知字段,Zod 默认剥离,Ajv 默认放行——模型编造的参数、拼错的键都会静默穿过。本文用实测说明静默放行的三种形态,并给出关闭对象、控制强转、逐层收口的完整写法。

结论:默认情况下,主流校验器对未声明字段的态度是「无视」而不是「拒绝」。不写 additionalProperties: false,校验通过只说明「声明的字段没问题」,不说明「数据里没有多余的东西」。
校验器最危险的行为不是报错,而是不报错。一个拼错的键、一个模型编造的参数,从校验层静默穿过,到业务层才炸,那时离出错源头已经隔了好几层。
一、先看一次「通过」的校验
假设有一份工具调用的入参契约,只声明两个字段:
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"amount": { "type": "number" }
},
"required": ["order_id", "amount"]
}
现在传入 {"order_id": "A1", "amount": 50, "reasn_code": "damaged"}——注意第三个键拼错了,本意是 reason_code。用 Ajv 默认配置校验,结果是通过。多出来的 reasn_code 既不报错也不被剔除,原样进入业务逻辑。
这不是 Ajv 的 bug。JSON Schema 规范里 additionalProperties 默认值就是 true:未声明字段允许出现。你声明了「有哪些字段」,但没有声明「只能有这些字段」,校验器按规范办事。
二、三种运行时的「静默」形态
同一个缺口,在不同语言里表现不同,这是最容易踩坑的地方:
| 运行时 | 未声明字段的默认行为 | 后果 |
|---|---|---|
| Ajv(JS,默认配置) | 原样放行 | 脏数据进入业务层 |
Zod z.object(TS) |
剥离(strip) | 字段凭空消失,下游拿到的对象变短 |
| Pydantic v2(Python) | 忽略(extra="ignore") |
同上,且日志里没有任何痕迹 |
剥离比放行更隐蔽。放行至少能让后续代码看到这个字段;剥离之后,调用方以为传了 reason_code,处理函数却收到一份更短的载荷,中间没有任何报错。dev.to 上那篇 Pydantic 与 Zod 的对比文章把这一点讲得很透:两个库开箱即用时的失败方式一模一样——静默。
三、LLM 工具调用把这个问题放大了
手写调用方时,多余字段通常是拼写失误,概率低、影响小。但 LLM 的工具调用参数是模型生成的,它会自信地编造字段名、把 "3" 当数字传、给必填字段塞一个语义含糊的值。OpenAI、Anthropic 等平台都把参数定义成 JSON Schema,但默认并不强制你的 schema 收口——strict 模式要显式开启,而且平台侧的语法约束只管模型直接输出,不管你本地执行时的入参。
所以防线必须建在本地。一个会编造字段的真实场景:
{
"order_id": 4411,
"refund_amount_usd": 50,
"reason_code": "damaged",
"urgent": "yes"
}
你的 schema 只声明了前两个字段。没关对象时,后两个键一路绿灯;关掉之后,reason_code 与 urgent 都会被拦下,报错信息直接指向 /reason_code 路径。
四、怎么关:三种写法对照
Ajv:在对象节点上加 additionalProperties: false。
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
const schema = {
type: "object",
properties: { order_id: { type: "string" }, amount: { type: "number" } },
required: ["order_id", "amount"],
additionalProperties: false,
};
const validate = ajv.compile(schema);
validate({ order_id: "A1", amount: 50, reasn_code: "x" }); // false
validate.errors[0].instancePath; // "/reasn_code"
Zod:把 z.object 换成 z.strictObject,或者链式加 .strict()。
Pydantic:模型配置 ConfigDict(extra="forbid")。
三者的共同点是:关对象是逐层的。外层关了,嵌套的对象字段不会自动继承;每个嵌套对象都要各自加 additionalProperties: false(Pydantic 里是每个嵌套模型都配 extra="forbid")。漏掉一层,那层就是新的缺口。
五、顺手把强转也定了:三选一策略
关掉多余字段只是第一步,还有一个同样安静的缺口——类型强转。Pydantic 宽松模式会把 "5" 转成 5,YAML 系解析器会把 yes 变成 true。校验策略有三种,选哪种都行,但要显式选并写进测试:
- 严格模式:类型不对就拒绝(Pydantic
strict=True、Zod 默认不开 coerce、Ajv 本身不强转); - 受控强转:只允许明确的转换(如 Zod
z.coerce.number()),并限定范围.positive().max(10000); - 完全放行:仅用于内部已知可控的数据源。
对 LLM 参数这类不可信输入,选 1 或 2。字段语义含糊的(比如不带单位的金额),把单位写进字段名 refund_amount_usd,比写在注释里可靠。
六、嵌套与列表:逐层收口检查清单
| 检查点 | 做法 |
|---|---|
| 顶层对象 | additionalProperties: false |
| 每个嵌套 object | 同样关闭,逐层检查 |
| 数组元素 | items 里的对象也要关 |
| 枚举与范围 | enum、minimum/maximum 补上,拦住语义漂移 |
| 生成器产物 | 手工补关键字,别信默认输出 |
第 4 条尤其重要:声明了 properties 不等于声明了 required,声明了类型不等于限制了取值。生成器产出的 schema 这些键常常缺省,相关机制在《自动生成的 JSON Schema 为什么总在拒绝真实数据》里有完整实测,本文不重复。
七、校验报错之后:让错误指回字段
关掉对象后,拒绝是主动行为,错误信息质量决定排查速度。Ajv 开 allErrors: true 能一次报出所有越界字段而不是第一个;配合 verbose: true 能拿到具体路径。把 instancePath 转成用户能看懂的字段名,做法在《JSON Schema 报错看不懂:Ajv 错误定位与错误信息治理》一文里已经成体系。
语法层面的问题(多余逗号、引号错误)在 schema 校验之前就应该被 /parse 这类工具拦住,两层校验各管各的。
八、常见问题
additionalProperties: false 会不会太严,导致正常数据被拒?
看契约本身。如果是自己定义的入参,字段清单你说了算,该关就关;如果是接收第三方的松散数据(比如带扩展位的配置),可以只对核心对象关、对扩展区留 additionalProperties 开放,但要划清哪一层是扩展区。
Zod 剥离未知键不是更安全吗,为什么也算坑?
剥离保护的是下游代码,伤害的是调用方。模型或上游以为传了某个字段,实际被丢掉了,整条链路没有报错,问题要等到业务结果异常才会暴露。校验的第一原则是让错误尽早显式出现,剥离恰好相反。
平台的 strict 模式开了还需要本地校验吗?
需要。strict 模式约束的是模型输出符合 schema,但你的 schema 本身可能没收口(additionalProperties 默认 true),而且某些平台的语法约束只覆盖模型直接输出、不覆盖工具入参。本地用 Ajv/Pydantic/Zod 再校验一次,成本极低。
关掉 additionalProperties 后老数据批量报错怎么办?
说明老数据里确实有未声明的字段,这正是要暴露的问题。先用 allErrors 跑一遍盘点哪些字段越界,确认无害的补进 properties,有害的修数据,别为了跑通把开关又打开。
一行代码能兜底吗?
不能,但有一个最小动作:全局搜索项目里所有 schema 定义,确认每个 type: "object" 都跟着一行 additionalProperties: false。没有这行的,逐个评估要不要补。