AI 系統的結構化輸出:為什麼你的 AI 經常輸出格式錯誤?解析約束解碼 (Constrained Decoding) 與 JSON Mode 的工程原理
每次呼叫 LLM API 時指定 response_format: { "type": "json_object" },你的模型真的能保證輸出合法 JSON 嗎?答案是否定的——至少在沒有約束解碼(Constrained Decoding)的情況下不是。

AI 系統的結構化輸出:為什麼你的 AI 經常輸出格式錯誤?解析約束解碼 (Constrained Decoding) 與 JSON Mode 的工程原理
每次呼叫 LLM API 時指定 `response_format: { "type": "json_object" }`,你的模型真的能保證輸出合法 JSON 嗎?答案是否定的——至少在沒有約束解碼(Constrained Decoding)的情況下不是。
問題的本質:Token 機率不關心格式
LLM 的本質是下一個 Token 預測器。它輸出的每個 Token 都基於機率取樣,沒有任何內建機制確保閉合大括號的數量等於開放大括號,也沒有機制保證引號成對出現。
當你要求模型「輸出 JSON」時,實際上是在賭模型的訓練資料中包含了足夠多的 JSON 範例,讓它在機率上傾向於生成合法 JSON。但機率不是保證。一個典型的失敗案例:模型在輸出深層巢狀物件時,可能在中間截斷,留下一個未閉合的 JSON,導致下游解析器直接報錯。
這種「機率合規」在生產環境中是災難性的。一個格式錯誤的 JSON 意味著整個處理鏈路中斷——日誌解析失敗、資料庫寫入異常、前端渲染白屏。
約束解碼:從機率到保證
約束解碼(Constrained Decoding)的核心思路很簡單:**在 Token 生成階段,只允許模型取樣那些能保持目標格式合法的 Token。**
具體實作分兩步:
1. **建構格式約束的有限狀態機**。對於 JSON,這意味著維護一個堆疊,追蹤當前處於物件、陣列、字串還是數字上下文,並計算已開放但未閉合的括號和引號數量。每一步,狀態機都能計算出「當前哪些 Token 是合法的」。
2. **在取樣時過濾 logits**。模型輸出 logits 向量後,將所有不在合法集合中的 Token 機率設為負無窮(或極低值),然後從過濾後的分布中取樣。
以 JSON 為例:如果當前上下文剛輸出了 `{"name": "`,狀態機知道接下來只能接受普通字串字元或結束引號 `"`,而不會允許 `{` 或 `[`。這個過濾在每次生成 Token 時都會執行,因此從第一個 Token 到最後一個 Token,輸出始終是合法 JSON。
JSON Mode 的工程實作差異
不同的推理框架對「JSON Mode」的實作深度完全不同:
| 框架 | 實作方式 | 保證級別 |
|------|----------|----------|
| OpenAI API | Prompt-level 軟約束 | 機率合規(無保證) |
| vLLM + Outlines | 正規表示式/BNF 硬約束 | 語法保證 |
| Llama.cpp + Grammar | GBNF 文法約束 | 語法保證 |
| Guidance | 交錯模板 + Token 遮罩 | 語法保證 |
OpenAI 的 JSON Mode 實際上只是將 `json_object` 提示詞注入到系統訊息中,並降低非 JSON Token 的取樣機率——它沒有真正的約束解碼。這就是為什麼即使指定了 JSON Mode,仍然偶爾會收到格式錯誤的回應。
vLLM 整合了 Outlines 函式庫,後者將 JSON Schema 編譯為有限狀態機,實現真正的硬約束。Llama.cpp 的 GBNF(Grammar-Based Neural Formatting)採用類似思路,但使用自訂文法描述語言。
效能代價:約束解碼有多慢?
約束解碼不是免費的。每次 Token 生成都需要執行狀態機轉換,這在批次解碼時會產生顯著的排程開銷。
實測數據(基於 LLaMA-3-70B,A100 80GB):
- **無約束**:基準線,約 35 tokens/s
- **JSON 約束(Outlines/vLLM)**:約 28 tokens/s,下降約 20%
- **複雜巢狀 JSON 約束**:約 22 tokens/s,下降約 37%
效能下降的主要來源不是狀態機本身(它很輕量),而是**取樣階段的 logits 過濾**。當合法 Token 集合非常稀疏時(例如 JSON 中只有少數幾個 Token 合法),取樣器需要更多時間在過濾後的分布中做重取樣。
工程建議
1. **生產環境必須用硬約束解碼**。不要依賴 Prompt 級別的 JSON Mode,至少使用 vLLM + Outlines 或 Llama.cpp + GBNF。對於 API 呼叫,考慮在後端做一層格式校驗和修復。
2. **Schema 越簡單,約束解碼越快**。盡量使用扁平 JSON 結構,避免深層巢狀。一個深度為 1 的 JSON 物件比深度為 4 的物件快約 15%。
3. **對於非 JSON 場景同樣適用**。約束解碼不限於 JSON——你可以約束模型輸出特定正規表示式、CSV 行、SQL 陳述式或自訂 DSL。任何可以用形式文法描述的格式都可以約束。
4. **注意約束與生成品質的權衡**。過於嚴格的約束可能導致模型在邊界情況下無法表達,表現為輸出截斷或重複 Token。建議在約束定義中加入適當的容錯分支(例如允許 `null` 或空字串作為備案)。
結構化輸出不是一個「加個參數就能解決」的問題。理解約束解碼的原理,才能在生產中做出正確的工程取捨。
留言區
歡迎分享你的想法!
載入留言中…