OnDevice Kit

Swift と GBNF 文法でローカル LLM に必ず正しい JSON を出力させる

2B モデルに JSON を頼むと、たいていはうまくいきます。文法を使えば、毎回うまくいきます。llama.cpp が形を崩すトークンをすべてマスクするので、回答は必ず Codable 型にパースできます。

小さなモデルは読解は得意ですが、記号の扱いは苦手です。LFM2 2.6B や Qwen3 4B に「JSON だけで答えて」と頼むと、たいていは正しい JSON が返ってきますが、ときどき波かっこが抜けていたり、末尾にコメントが付いていたり、キーが別の言語になっていたり、オブジェクトの前に愛想のよい一文が入っていたりします。リトライのループや正規表現による修復は問題を覆い隠すだけで、スマートフォンでは数秒を無駄にします。

llama.cpp にはもっと良い手段があります。GBNF 文法です。文法は、許可される文字列を記述します。各ステップで、サンプラーは正しい文字列を続けられないトークンをすべて除外し、モデルは残ったものから選びます。回答は指定した形にしかなりません。

目標:手紙を JSON に

この記事を通して使う例では、紙の手紙を読み取り、それに対して何をすべきかを返します。

Swift
struct Letter: Codable {
    var sender: String
    var action: String      // "none", "pay", "reply" or "sign"
    var urgency: Int        // 0...3
    var deadline: String    // "YYYY-MM-DD" or ""
    var steps: [String]     // at most 4
}

正しい回答は次のようになります。

JSON
{"sender": "City Energy", "action": "pay", "urgency": 2, "deadline": "2026-10-30",
 "steps": ["Open your banking app", "Transfer 84.20 EUR", "Use reference 4711"]}

文法を 1 ルールずつ解説

letter.gbnf
root ::= "{" ws "\"sender\":" ws str "," ws "\"action\":" ws action "," ws "\"urgency\":" ws urgency "," ws "\"deadline\":" ws date "," ws "\"steps\":" ws steps ws "}"
action ::= "\"none\"" | "\"pay\"" | "\"reply\"" | "\"sign\""
urgency ::= [0-3]
steps ::= "[" ws "]" | "[" ws str ( ws "," ws str ){0,3} ws "]"
str ::= "\"" chr* "\""
chr ::= [^"\\\x00-\x1f] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}
date ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\""
ws ::= [ \t\n]{0,8}
  • root は llama.cpp が解析を開始する場所です。すべてのキーを順番どおりに記述しています。モデルはキーを飛ばすことも、新たに作ることも、並べ替えることもできません。キーはリテラルなので、モデルはそこで選択肢を消費することすらありません。{ が書かれた時点で、続けられるのは "sender": だけです。
  • action は列挙型です。4 つの選択肢があり、それ以外はありません。文法が最も効果を発揮するのはここです。「Pay」も「payment」も「pay the bill」も出てきません。
  • urgency は 0〜3 の 1 桁の数字です。小さな整数の範囲を制限するには、文字クラスが最も簡単です。
  • steps は最大 4 項目のリストです。1 項目に続けて、{0,3} で最大 3 項目を追加できます。スマートフォンでは繰り返しに上限を設けることが重要です。上限のないリストでは、モデルが maxTokens に達するまで延々と書き続けかねません。
  • str と chr は JSON の文字列です。引用符、バックスラッシュ、制御文字以外の任意の文字、または正しいエスケープシーケンスを許可します。文字列内の改行は除外しているので、値は 1 行に収まります。
  • date は空文字列か "YYYY-MM-DD" のどちらかです。空の選択肢があることで、モデルは期限をでっち上げずに「期限はない」と正直に答えられます。
  • ws は最大 8 文字の空白です。上限のない [ \t\n]* だと、改行を延々と出力し続けるモデルがあります。上限を設ければそれを防げます。

Swift から文法を生成する

手書きの文法は、いずれ Codable 型との間にずれが生じます。形を一度だけ記述し、そこから文法を組み立てましょう。

Grammar.swift
indirect enum Field {
    case string
    case choice([String])
    case digit(ClosedRange<Int>)       // a single digit, e.g. 0...3
    case date
    case list(Field, max: Int)
}

struct Key {
    let name: String                   // letters and digits only: it doubles as a rule name
    let field: Field
    let hint: String                   // goes into the prompt, not the grammar
}

func gbnf(_ keys: [Key]) -> String {
    var rules: [String] = []

    func rule(_ field: Field, _ name: String) -> String {
        switch field {
        case .string: return "str"
        case .date: return "date"
        case .digit(let r):
            rules.append("\(name) ::= [\(r.lowerBound)-\(r.upperBound)]")
            return name
        case .choice(let values):
            let alternatives = values.map { #""\"\#($0)\"""# }.joined(separator: " | ")
            rules.append("\(name) ::= \(alternatives)")
            return name
        case .list(let item, let max):
            let i = rule(item, name + "-item")
            rules.append(#"\#(name) ::= "[" ws "]" | "[" ws \#(i) ( ws "," ws \#(i) ){0,\#(max - 1)} ws "]""#)
            return name
        }
    }

    let fields = keys.map { k in #""\"\#(k.name)\":" ws "# + rule(k.field, k.name) }
    let root = #"root ::= "{" ws "# + fields.joined(separator: #" "," ws "#) + #" ws "}""#
    let shared = [
        #"str ::= "\"" chr* "\"""#,
        #"chr ::= [^"\\\x00-\x1f] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}"#,
        #"date ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\"""#,
        #"ws ::= [ \t\n]{0,8}"#,
    ]
    return ([root] + rules + shared).joined(separator: "\n")
}

let letterKeys = [
    Key(name: "sender", field: .string, hint: "Who sent the letter, short"),
    Key(name: "action", field: .choice(["none", "pay", "reply", "sign"]), hint: "What the reader has to do"),
    Key(name: "urgency", field: .digit(0...3), hint: "0 only information, 3 deadline soon"),
    Key(name: "deadline", field: .date, hint: "Only if the letter names one"),
    Key(name: "steps", field: .list(.string, max: 4), hint: "Tiny steps, each starting with a verb"),
]

Swift の raw 文字列(#"..."#)を使うと、バックスラッシュが読みやすくなります。その中では \" はリテラルのバックスラッシュと引用符で、まさに GBNF が求めるものです。また \#(...) で文字列補間ができます。gbnf(letterKeys) は上記の文法を生成します。GBNF のルール名には英字、数字、ハイフンを使えるので、キー名はシンプルにするか、安全な名前に対応付けてください。このコードは、選択肢の値に引用符やバックスラッシュが含まれないことを前提としています。含まれる可能性がある場合はエスケープしてください。

キーの意味をモデルに伝える

文法が強制するのは形だけで、意味については何も伝えません。urgency を知らないモデルでも正しい数字は埋めますが、それが役に立つ値とは限りません。各キーの短い説明をシステムプロンプトに入れておきます。

Swift
func describe(_ keys: [Key]) -> String {
    keys.map { "- \($0.name): \($0.hint)" }.joined(separator: "\n")
}

let system = """
    You help people deal with paper mail. Use only facts from the letter.
    Answer only with JSON with these keys:
    \(describe(letterKeys))
    """

入力例とその理想的な回答を 1 組、直前のユーザーとアシスタントのターンとして与えると、何ページものルールよりも多くのことを 2B モデルに教えられます。プロンプト内のトークンはすべてコンテキストを消費するので、短くまとめてください(プロンプトが長くなると何が起きるかは n_batch のガイドを参照)。

文法をサンプラーチェーンに組み込む

llama.cpp では、文法はサンプラーの一種です。まず文法を追加し、その後にトークンを選ぶサンプラーを追加します。文法は不正なトークンの確率をすべてゼロにし、greedy は残りの中から最も確率の高いものを選びます。

Swift
guard let sampler = llama_sampler_chain_init(llama_sampler_chain_default_params()) else { throw LLMError.decodeFailed }
defer { llama_sampler_free(sampler) }

guard let grammar = llama_sampler_init_grammar(vocab, gbnf(letterKeys), "root") else {
    throw LLMError.decodeFailed            // the grammar text does not parse
}
llama_sampler_chain_add(sampler, grammar)
llama_sampler_chain_add(sampler, llama_sampler_init_greedy())

生成ループは iPhone セットアップガイドと同じです。llama_sampler_sample は、選ばれたトークンをチェーン内のすべてのサンプラーに通知するので、文法は追加の呼び出しなしに状態を進めます。閉じ波かっこが書かれると、文法は生成終了トークンしか許可しなくなり、ループは自然に終了します。

そのガイドの LLM actor をベースにしている場合は、generate に grammar: String? パラメータを追加し、文法が渡されたときはサンプラーの設定部分を上のブロックに置き換えます。あとはいつもどおりデコードします。

Swift
let raw = try await llm.generate(messages, grammar: gbnf(letterKeys), maxTokens: 1024)
let letter = try JSONDecoder().decode(Letter.self, from: Data(raw.utf8))

抽出タスクでは temperature 0 を使ってください。文法とランダムサンプリングを組み合わせても正しい JSON は得られますが、実行のたびに値が変わるのは、フォームに求める挙動ではないことがほとんどです。

注意点

  • 保証されるのは形であり、正しさではありません。文法は deadline が日付の形をしていることを保証しますが、それが正しい日付であることは保証しません。重要な値は検証し、ユーザーに表示して確認してもらってください。
  • maxTokens によって回答が途中で切れることはあります。モデルが非常に長い文字列を書いてトークン上限に達すると、JSON は未完成のままになります。リストには上限を設け、maxTokens はスキーマに対して余裕を持たせ、デコードエラーはクラッシュではなく「次の手段を試す」合図として扱ってください。
  • 順序が重要です。モデルは文法の順序どおりにキーを書きます。簡単で根拠となるフィールド(sender、action)を先に、派生的なフィールド(steps)を最後に置きます。後のフィールドは前のフィールドを踏まえて書かれます。
  • Codable 型との同期を保ちます。1 つの記述から文法を生成すれば、ずれを防げます。さらに一歩進めると、同じ記述から Apple の DynamicGenerationSchema も生成できます。その方法は Foundation Models フォールバックのガイドで紹介しています。

速度はどうでしょうか。私たちの計測では、M シリーズの Mac で LFM2 2.6B Q4_K_M が 6 フィールドの手紙スキーマを約 6.5 秒で埋めました。文法チェックによってトークンごとに多少の処理が加わりますが、リトライが不要になるため、全体としてはたいていこちらのほうが速くなります。