讓 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 來建構語法遮罩。在生產環境中你會用更快的引擎,比如 outlinesguidancellama.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++ 層面強制執行。這是本地推理的最快選項。

jsonformerinstructor 是專門針對 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。選擇你的推理引擎支援的格式。