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。沒有這行的,逐個評估要不要補。