用 API 呼叫模型的時候,常常會希望它回傳 JSON,我們就可以讓程式直接讀取、操作。只在 prompt 裡寫「請用 JSON 格式回答」,大部分時候是可以的,但偶爾前面會多一句說明、少了一個括號或是多了個符號,就會解析失敗。所以現在很多 API 和推論引擎都有 JSON mode 或結構化輸出(structured output)的選項,讓回來的內容通常照著格式處理。

模型產生文字的方式,是每一步替詞彙表裡所有的 token 計算分數,再從裡面挑出下一個 token。constrained decoding 則是在選擇挑取之前先把「選了就會讓格式壞掉」的 token 分數改成負無限大,這些 token 就不可能被選到,模型就被限縮在剩下的選項中挑選。

參考之前寫的相關文章:大語言模型 LLM:其實做的事情比你想像中更單純

剩下幾個 token

xgrammar 是專門做這件事的開源函式庫,而且可以拿 tokenizer 來跑,不需要載入模型,因為要劃掉哪些 token 只跟 schema、詞彙表和目前進度到哪裡有關,跟模型的權重無關(感覺很反直覺)。下面用 Qwen2.5-0.5B-Instruct 的 tokenizer 來測試。

import json
import torch
import xgrammar as xgr
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
info = xgr.TokenizerInfo.from_huggingface(tok)
schema = {
    "type": "object",
    "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
    "required": ["name", "age"],
    "additionalProperties": False,
}
compiled = xgr.GrammarCompiler(info).compile_json_schema(json.dumps(schema), any_whitespace=False)
matcher = xgr.GrammarMatcher(compiled)
bitmask = xgr.allocate_token_bitmask(1, info.vocab_size)

print("vocab_size", info.vocab_size)
for t in tok.encode('{"name": "Ada", "age": 36}', add_special_tokens=False):
    matcher.fill_next_token_bitmask(bitmask)          # 算出還能用的 token
    logits = torch.zeros(info.vocab_size)
    xgr.apply_token_bitmask_inplace(logits, bitmask)  # 不能用的分數改成 -inf
    print(f"{tok.decode([t])!r:8} {int(torch.isfinite(logits).sum()):>7}")
    matcher.accept_token(t)

環境是 Python 3.12.13、xgrammar 0.2.8、torch 2.14.1、transformers 5.19.0。左邊是送進去的 token,右邊是當下還可以使用的 token 數量:

vocab_size 151665
'{"'           2
'name'         4
'":'           2
' "'         121
'Ada'     147057
'",'      147057
' "'           2
'age'          3
'":'           2
' '            2
'3'           11
'6'           11
'}'           11

一開始只能從兩個 token 裡選,{ 或是 {",詞彙表裡其他選擇都被無視,因為 JSON 物件只能用左大括號開頭。接下來是 key,xgrammar 會照 schema 裡欄位寫的順序產生,我們看到第一個是 name,所以剩下的只有 n、na、nam、name 這四種組合;拼寫處理完之後是 " 或 ":,此時又回到兩個選擇。可以看到在這一階段,模型其實沒有什麼需要決定的,也沒有甚麼可選的,因為不管怎麼走,最後都會是 {"name":。

但接下來到了 name 的值,可選的數字跳到 147,057 個,瞬間變多了,也就代表詞彙表裡大部分的 token 都可以用。而被劃掉的四千多個裡面,其中多數是含有換行或 tab 的 token。也就是說,這裡要填 Ada、Bob 還是一整句中文,都是模型自行挑選的,也不會被劃掉。後面 age 的位置,能用的只剩 0 到 9 的數字和負號,但到底要寫 36 還是 360,對規則來說都一樣。所以仔細看看可以發現,schema 負責規範的是格式,括號、引號、欄位名稱和型別。

schema 和 prompt

那模型怎麼知道 name 要填名字、age 要填年紀?從剛剛過程來看,處理的都是劃掉不合格式的 token,並沒有告訴模型每個欄位是什麼意思。llama.cpp 的 grammar 文件寫到,JSON schema 只用來限制輸出,不會放進 prompt,模型本身看不到 schema;但工具呼叫(tool calling)則是例外,schema 會被放進 prompt。

因此在用這類功能的時候,還是記得要在 prompt 裡把每個欄位的意思寫清楚,像是 age 是實際年齡、單位是年,不然模型也會照格式輸出整數,只是這個數字你也不知道可不可信。至於其他服務會不會另外把 schema 放進 prompt,當成一併處理的資訊,就要看各自的文件以及其發展。

不過還是說明現今的AI模型發展很快,每家廠商的做法和服務都不一樣,可能這個資訊很快就會過時了,就當作一個簡易分享囉。