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