JSON 工具箱

additionalProperties 不关,Schema 校验等于没校验

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

进阶约 8 分钟发布于 2026-11-02更新于 2026-11-02
一份带多余字段的 JSON 通过校验的示意封面

结论:默认情况下,主流校验器对未声明字段的态度是「无视」而不是「拒绝」。不写 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。校验策略有三种,选哪种都行,但要显式选并写进测试:

  1. 严格模式:类型不对就拒绝(Pydantic strict=True、Zod 默认不开 coerce、Ajv 本身不强转);
  2. 受控强转:只允许明确的转换(如 Zod z.coerce.number()),并限定范围 .positive().max(10000);
  3. 完全放行:仅用于内部已知可控的数据源。

对 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。没有这行的,逐个评估要不要补。