讓 LLM 產生一個 JSON 物件,它最終一定會輸出一個末尾逗號、字串裡未轉義的換行符,或者在一個本該有引號鍵的位置輸出 bare word。這個錯誤不是模型的 bug。它是自迴歸採樣工作方式的必然結果:在每一步,模型都會把自己的詞表裡每個 token 都當作候選,包括那些會讓部分輸出在語法上 invalid 的 token。
你可以透過約束 token 詞表本身來修復這個問題。與其讓模型從三萬多個 token 中選擇,不如只給它那個能把部分解析保持為合法狀態的子集。這項技術叫做 grammar-constrained decoding,它把 LLM 從機率文字產生器變成語法感知的程式碼合成器。
為什麼提示工程無法解決語法正確性
大多數開發者都會從顯而易見的辦法入手:在系統提示裡加上 “Output valid JSON only”,在使用者訊息裡重複一遍 schema,然後祈禱。這有一定幫助,但不是保證。
模型在產生過程中並不會解析自己的輸出。它基於以提示和已產生內容為條件的、整個詞表上的機率分佈來預測下一個 token。像 , 這樣的 token 在右花括號之後可能有很高的機率,因為訓練資料裡逗號經常出現在這個位置。但這個逗號在這個特定 JSON 物件的這個精確位置上是否語法合法,並不在模型的決策過程之中。
即便是經過 RLHF 微調過的模型,也只是把機率質量向通常正確的模式偏移。它們並沒有消除非法路徑。如果你要的是保證,而不是高機率,那就必須改變採樣機制本身。
語法約束解碼如何運作
核心思路很簡單:在 LLM 產生的同時維護一個parser狀態,在每個 token 位置,把會把parser帶入錯誤狀態的 token 全部遮蔽掉。
在步驟 t,模型輸出一個覆蓋整個詞表的 logits 向量。通常你會施加 temperature scaling 然後採樣。使用約束解碼時,你先問語法引擎:給定目前已產生的 token,接下來哪些 token 是合法的?引擎回傳一個詞表上的 bitmask。你把非法 token 的 logits 設為負無窮,在剩餘部分上做 softmax,然後從過濾後的分佈中採樣。
語法引擎不需要在產生結束後重新解析完整輸出。它是增量解析的。每接受一個 token,就更新內部的 state machine。當parser到達接受狀態時,產生可以停止;當處於非接受狀態時,產生繼續。
這意味著模型仍然可以在所有語法合法的續寫中自由選擇。它在語法內部保留創造力,只是不能越界。
實作長什麼樣
下面是一個簡化的 Python 草圖,使用現代解析庫 lark 來建構語法遮罩。在生產環境中你會用更快的引擎,比如 outlines、guidance 或 llama.cpp 內建的 GBNF 支援,但原理完全相同。
from lark import Lark, Token
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
# Define a tiny grammar for a simple config DSL.
grammar = r"""
start: pair+
pair: KEY "=" VALUE
KEY: /[a-z_]+/
VALUE: /"[^"]*"/
"""
parser = Lark(grammar, parser="lalr")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
def legal_next_tokens(partial_text: str) -> set[int]:
"""Return token IDs that do not cause a parse error."""
legal = set()
for token_id in range(tokenizer.vocab_size):
candidate = tokenizer.decode([token_id], skip_special_tokens=True)
try:
# Try parsing partial_text + candidate as a prefix.
parser.parse(partial_text + candidate)
legal.add(token_id)
except Exception:
# Some exceptions are expected mid-parse.
# A production engine tracks parser state, not exceptions.
pass
return legal
# Generate one token at a time with grammar masking.
prompt = "Generate a config: "
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated = input_ids.clone()
for _ in range(50):
outputs = model(generated)
logits = outputs.logits[:, -1, :]
partial = tokenizer.decode(generated[0], skip_special_tokens=True)
legal_ids = legal_next_tokens(partial)
mask = torch.full_like(logits, float("-inf"))
for tid in legal_ids:
mask[0, tid] = logits[0, tid]
next_token = torch.argmax(mask, dim=-1).unsqueeze(0)
generated = torch.cat([generated, next_token], dim=-1)
# Stop if the parser is in an accepting state.
if parser.parse(partial):
break
print(tokenizer.decode(generated[0], skip_special_tokens=True))
這是一個樸素實作。真實系統不會在每一步都遍歷整個詞表。它會提前從語法建構有限狀態自動機,並預先計算從每個自動機狀態出發哪些詞表 token 是合法的。產生時,遮罩就是一次查表。
約束解碼在有界上下文中的位置
領域驅動設計中的有界上下文由它的通用語言定義。這種語言通常被形式化為一種小型 DSL:查詢語法、規則語法、配置格式或表示式語言。當你要求 LLM 在該上下文內產生或編輯工件時,你希望它正確地講這門語言。
語法約束強制執行邊界。LLM 不能發明 DSL 中不存在的欄位,不能輸出不匹配的括號,也不能產生允許列舉集合之外的值。語法變成編譯期保證,而不是執行期祈禱。
這一點在 LLM 輸出直接餵給parser或直譯器時尤其有價值。如果你的 DSL 由嚴格的遞迴下降parser來解析,一個 unexpected token 就會讓整個管線崩潰。約束解碼消除了這種失敗模式。
你應該了解的權衡
約束解碼不是免費的。開銷取決於你如何實作語法引擎。
吞吐量。 每次前向傳播都建構遮罩會增加延遲。outlines 或支援 GBNF 的 llama.cpp 等快速引擎會預先計算 token 到狀態的對應,把每步開銷降到大約 5-10%。每一步都對每個候選 token 重新解析的樸素實作會讓產生速度慢上好幾個數量級。
語法表達力。 並不是每種語法都能輕鬆表達為能乾淨對應到 token 遮罩的語法。上下文敏感規則,比如”這個識別項必須在作用域中更早宣告”,標準上下文無關文法無法捕捉。你可以用文法約束語法,但沒有額外機制就約束不了語意。
模型相容性。 某些推理 API,尤其是託管雲 API,不暴露 logits,也不允許自訂遮罩。OpenAI 的 API 提供 JSON mode,這是專門針對 JSON 的硬編碼語法約束,但你不能自帶文法。對於自訂 DSL,通常需要本地推理,或者使用暴露 logits 的框架。
部分 token 問題。 token 邊界並不總是與語法邊界對齊。語法可能期望一個引號字串,但下一個 token 可能是 "hel 後接 lo"。語法引擎必須能對部分 token 匹配進行推理,而不僅僅是完整 token。生產庫透過把 token 對應到字首並檢查字首對語法的合法性來處理這個問題。
今天如何真正落地實作
你不需要從零寫語法引擎。好幾個庫已經處理了難點。
outlines 對 Python 使用者最友善。你定義一個 Pydantic 模型或正規表示式,它就把約束編譯成高效的有限狀態自動機,與 Hugging Face transformers 和 vLLM 整合。
from outlines import models, generate
model = models.transformers("microsoft/Phi-3-mini-4k-instruct")
generator = generate.regex(model, r'\{[a-z_]+\}')
result = generator("Extract the key: ")
guidance 來自微軟,提供更豐富的模板系統。你把語法約束和提示模板交錯在一起,它內部處理遮罩。
llama.cpp 支援 GBNF(GGML BNF),一種類 BNF 的語法格式。你在推理時傳入一個 .gbnf 檔案,引擎在 C++ 層面強制執行。這是本地推理的最快選項。
jsonformer 和 instructor 是專門針對 JSON 的輕量級替代方案。它們使用類語法約束,但僅限於 JSON schema。如果你的有界上下文 DSL 恰好是 JSON 形狀,它們是最簡單的起點。
約束解碼修不了什麼
受語法約束的 LLM 仍然可能產生語意錯誤的輸出。它可以 emit 一條引用了不存在的表的有效 SQL 查詢,或者一條設定了無效連接埠號的有效配置。語法是必要護欄,但不是充分護欄。
你仍然需要在 LLM 之下保留驗證層。解析輸出,針對領域模型做型別檢查,如果語意錯誤就拒絕或重試。約束解碼把失敗模式從”語法錯誤”收窄到”邏輯錯誤”。這是很大進步,但不是免死金牌。
而且,語法約束並不會讓模型更聰明。如果語法太寬鬆,模型仍然可能遊蕩到無意義但語法合法的領域。如果語法太嚴格,模型沒有合法路徑來表達正確答案,你就會得到低機率垃圾或重複迴圈。設計語法是工程工作的一部分。
從輸出格式開始,而不是從模型開始
在你微調提示詞或者換更大的模型之前,先問清楚問題到底是推理還是語法。如果 LLM 理解你想要什麼,只是偶爾格式不對,語法約束就是正確的修復方案。它比微調便宜,比提示工程可靠,而且能給出純採樣給不了的保證。
定義好你的有界上下文 DSL 的語法,把它接入約束解碼器,讓模型在框框裡產生。輸出偶爾還是會讓你意外,但永遠不會是語法錯誤。
FAQ
什麼是語法約束解碼?
語法約束解碼是一種在每一步產生中使用形式文法過濾 LLM 詞表的技術。只考慮保持部分輸出語法合法的 token,從而保證最終輸出符合文法。
約束解碼會降低輸出品質嗎?
對於受語法約束的任務不會。模型仍然可以在所有語法合法的續寫中自由選擇。對於開放式創意寫作,約束會損害品質。對於程式碼、配置或 DSL 產生,約束同時提高正確性和可靠性。
我能在 OpenAI 或 Claude API 上使用這個嗎?
OpenAI 提供 JSON mode,這是專門針對 JSON 的內建語法約束,不能自帶文法。Anthropic 目前不暴露語法約束。對於自訂 DSL,通常需要本地推理,使用 vLLM、llama.cpp 或類似引擎。
支援哪些語法格式?
常見格式包括 EBNF、BNF、PEG 和 GBNF。outlines 等庫接受正規表示式和 Pydantic 模型。llama.cpp 使用 GBNF。選擇你的推理引擎支援的格式。