Znasz ten moment. Piszesz w prompcie wielkimi literami „ODPOWIEDZ WYŁĄCZNIE POPRAWNYM JSON-em, BEZ ŻADNEGO KOMENTARZA", dorzucasz przykład, błagalnie dodajesz „no proszę", a model i tak zaczyna od „Oczywiście! Oto Twój JSON:", opakowuje go w blok ```json, gdzieś w środku gubi cudzysłów, a na końcu dorzuca jeszcze zdanie, że gdyby coś, to chętnie pomoże dalej. Twój json.loads() wybucha, a ty siedzisz i parsujesz to regexpem jak jaskiniowiec. To częsty punkt zapalny — rozjechany nawias, ucięty cudzysłów i podpis w stylu „przecież prosiłem grzecznie".

No i tu jest cały myk: prośba w prompcie to prośba. Model może ją spełnić albo nie, bo prompt to tylko sugestia w tekście. A gramatyka GBNF to coś zupełnie innego — to kaganiec założony na sampler. Nie mówisz modelowi „bądź tak miły i zwróć JSON". Ty mu fizycznie nie pozwalasz wygenerować tokenu, który by JSON-a psuł. Różnica jak między „proszę, nie wychodź poza linie" a kartką do kolorowania, w której poza liniami po prostu nie ma gdzie pociągnąć kredką.

O co chodzi z GBNF

GBNF (GGML BNF) to format gramatyki formalnej, którym llama.cpp ogranicza — po angielsku constrain — to, co model może wygenerować.1 Nazwa zdradza rodowód: to wariant BNF-a, tej samej notacji, którą od dekad opisuje się składnię języków programowania. Piszesz zestaw reguł „co po czym może stać", a silnik pilnuje, żeby wyjście trzymało się tych reguł znak po znaku, token po tokenie.

W praktyce masz dwie drogi do tego samego celu. Albo piszesz gramatykę GBNF ręcznie i podajesz ją flagą --grammar (wprost jako string) lub --grammar-file (z pliku).2 Albo, jeśli myślisz kategoriami JSON Schema, podajesz schemat flagą -j / --json-schema — a llama.cpp sam przemieli go wewnętrznie na GBNF funkcją json_schema_to_grammar.2 Do tego jest bliźniacza para -jf / --json-schema-file, gdy schemat wolisz trzymać w pliku. Efekt końcowy ten sam: pod spodem i tak rządzi gramatyka.

Dlaczego to ważne dla lokalnego LLM

Najważniejsza rzecz, którą trzeba zrozumieć, i zarazem najczęstszy mit: schemat ani gramatyka nie trafiają do promptu. Model nie widzi w kontekście ani jednej reguły — nie dostaje few-shota, nie dostaje instrukcji „pamiętaj o cudzysłowach". Ograniczenie działa wyłącznie na poziomie samplera, przy każdym kolejnym generowanym tokenie.1 To dlatego działa nawet na małym modelu, który normalnie olewa prośby o format: nie prosisz go o współpracę, tylko zamykasz mu wszystkie złe drzwi.

Jak to w ogóle działa pod maską?

Skoro model niczego nie widzi w prompcie, to skąd wie, że ma trzymać się formatu? Nie wie. I to jest piękne. Cała robota dzieje się piętro niżej, w samplerze — komponencie, który po każdym przejściu przez sieć wybiera następny token z rozkładu prawdopodobieństw.

Przypomnij sobie, jak model generuje. Sieć wypluwa logity — po jednej liczbie na każdy token ze słownika, im wyższa, tym „chętniej" model by go postawił. Normalnie sampler bierze tę tablicę kandydatów (llama_token_data_array) i losuje z niej według reguł temperatury, top-p i reszty pokręteł, które rozgryzaliśmy tutaj. Gramatyka wciska się dokładnie w to miejsce, tuż przed losowaniem. Funkcja llama_grammar_apply_impl przechodzi po całej tablicy kandydatów i każdemu tokenowi, który nie pasuje do gramatyki, ustawia logit na -inf.3 Minus nieskończoność to wyrok: taki token ma po softmaksie prawdopodobieństwo zero, więc sampler nie ma prawa go wybrać, choćby model bardzo chciał. To właśnie maskowanie logitów — zerowanie szans tokenów niezgodnych z regułami.

A skąd gramatyka wie, co „pasuje" akurat teraz? Trzyma stan. I to nie jeden stan, tylko cały zbiór jednoczesnych stosów — klasyczny automat ze stosem (pushdown automaton). Każdy stos to jedna możliwa ścieżka przez gramatykę, zgodna z tym, co model wygenerował do tej pory. Token przeżywa tylko wtedy, gdy pasuje do terminala na szczycie któregokolwiek z aktywnych stosów — jeśli nie pasuje do żadnego, dostaje -inf i wypada.3 Kiedy token już zostanie wybrany, llama_grammar_accept przesuwa stan gramatyki po zaakceptowanych znakach, stosy się aktualizują, część ścieżek umiera, część zostaje — i cała zabawa leci od nowa przy następnym tokenie.3

Wyobraź sobie to jak korytarz z bramkami. Model na każdym kroku pcha się we wszystkie drzwi naraz, a gramatyka trzyma przy każdych strażnika, który przepuszcza tylko wtedy, gdy za drzwiami jest legalna kontynuacja. Model nie musi znać planu budynku — i tak nie skręci w ślepy zaułek, bo tamtędy fizycznie nie da się przejść.

Składnia w pigułce

GBNF wygląda znajomo dla każdego, kto liznął kiedyś opis gramatyki. Reguła to nonterminal ::= sekwencja. Klocki, z których się to składa:1

  • Terminale — literały w cudzysłowach ("1", "true") albo zakresy znaków w nawiasach kwadratowych ([0-9], [a-zA-Z]), z negacją przez ^ ([^"] = cokolwiek poza cudzysłowem).
  • Operatory powtórzeń* (zero lub więcej), + (jeden lub więcej), ? (opcjonalnie), oraz precyzyjne {m}, {m,}, {m,n} (dokładnie / co najmniej / od–do).
  • Alternatywy przez | i grupowanie przez ( ) — dokładnie jak w regexpach.
  • Unicode w komplecie, z ucieczkami \xXX (8-bit), \uXXXX (16-bit) i \UXXXXXXXX (32-bit), więc polskie znaki czy emoji nie są problemem.

Jest też smaczek, którego regexp nie ma: GBNF potrafi dopasowywać po konkretnych tokenach tokenizera, nie po znakach. <[1000]> to token o ID 1000, <think> to token o dokładnym tekście think, a !<...> to negacja.1 Przydaje się, gdy chcesz np. sterować sekcjami <think>...</think> u modeli rozumujących — operujesz wtedy na realnych tokenach, a nie zgadujesz, jak model potnie tekst.

Jedna pułapka na start: nazwy nieterminali piszemy małymi literami z myślnikami (item-name-kv), nie camelCase i nie z podkreślnikami. Tak jest w oficjalnych przykładach i tego się trzymaj.1

Mały przykład od zera

Powiedzmy, że chcesz z modelu wyciągnąć obiekt z polem name (tekst) i age (liczba). Minimalna gramatyka, w duchu przykładu z oficjalnego README:1

root   ::= "{" ws "\"name\":" ws string "," ws "\"age\":" ws number ws "}"
string ::= "\"" char{1,100} "\""
char   ::= [^"\\]
number ::= [0-9]+
ws     ::= [ \t\n]*

Czyta się to prawie jak zdanie: korzeń to klamra, w środku pole name ze stringiem od 1 do 100 znaków, przecinek, pole age z liczbą, klamra zamykająca — a ws to opcjonalne białe znaki, żeby model mógł sobie ładnie wciąć. Podajesz to modelowi i już na poziomie samplera nie ma opcji, żeby zaczął od „Oto JSON:". Pierwszy token, który wolno postawić, to {. Kropka. Cała reszta korytarza jest zamknięta.

Odpalasz to najprościej tak — gramatyka wprost w linii poleceń:

llama-cli -m qwen2.5-7b-instruct-q4_k_m.gguf \
  --grammar 'root ::= "{" "\"ok\":" ("true" | "false") "}"' \
  -p "Czy 7 jest liczbą pierwszą? Odpowiedz." -no-cnv

Albo, gdy myślisz schematami, oddajesz robotę konwerterowi. --json-schema '{}' przyjmie dowolny JSON, a węższy schemat zawęzi wyjście:2

llama-cli -m qwen2.5-7b-instruct-q4_k_m.gguf \
  --json-schema '{"type":"object","properties":{"pierwsza":{"type":"boolean"}},"required":["pierwsza"]}' \
  -p "Czy 7 jest liczbą pierwszą?" -no-cnv

--grammar czy --json-schema? To nie to samo

Łatwo je pomylić, bo cel jest jeden, ale wejście zupełnie inne. Ściągawka:

--grammar / --grammar-file-j / --json-schema (+ -jf z pliku)
Co podajeszgotowy GBNF, napisany ręcznieschemat JSON Schema (json-schema.org)
Kto pisze gramatykętyjson_schema_to_grammar, automatycznie
Kiedy sięgaćdowolna struktura, nie tylko JSONmasz już schemat, myślisz „typami"
Złożone $refogarnia, bo piszesz wprostpatrz niżej — jest haczyk

Mechanizm wykonawczy w obu wypadkach ten sam: pod spodem i tak powstaje GBNF, który maskuje logity. To dwa różne wejścia do tej samej maszyny.

I ten haczyk z $ref: jeśli twój schemat JSON Schema wciąga zewnętrzne referencje ($ref do innych plików), --json-schema może się na tym wyłożyć. Oficjalna dokumentacja radzi wtedy wygenerować GBNF offline skryptem examples/json_schema_to_grammar.py, zapisać do pliku i podać go przez --grammar-file.4 Rozdzielasz konwersję od inferencji — najpierw w spokoju robisz gramatykę, potem karmisz nią model.

Gramatyka pilnuje składni, nie sensu

Zanim uwierzysz, że rozwiązałeś wszystkie problemy świata — jedno trzeźwiące zastrzeżenie. Gramatyka gwarantuje ci poprawną składnię, nie semantykę. Wymusisz, że age to liczba — ale nic nie broni modelowi wpisać tam 999, jeśli akurat tak mu wyjdzie z rozkładu. Wymusisz strukturę odpowiedzi RAG-owej — ale nie to, że treść w polach będzie prawdziwa; o samym łączeniu wyszukiwania z generacją pisaliśmy tutaj. GBNF ogranicza dopuszczalne sekwencje tokenów, a nie sens tego, co w nich siedzi. To kaganiec na formę, nie na głupoty. Walidację wartości i logikę trzymaj po swojej stronie, tak jak trzymałeś ją zawsze.

Zrób to sam: przez serwer

Na co dzień najwygodniej pchać to przez llama-server. Endpoint /completion przyjmuje w body te same dwa parametry co CLI: grammar (tekst GBNF) oraz json_schema (schemat JSON), oba domyślnie puste.5 Odpalasz serwer raz:

llama-server -m qwen2.5-7b-instruct-q4_k_m.gguf -ngl all -c 8192 --port 8080

i strzelasz do niego z gramatyką w środku:

curl -s http://localhost:8080/completion -d '{
  "prompt": "Wygeneruj kartę postaci.",
  "grammar": "root ::= \"{\" \"\\\"imie\\\":\" \"\\\"\" [a-zA-Z]+ \"\\\"\" \"}\""
}'

Jak wsadzisz gramatykę z błędem składni, serwer nie udaje, że jest dobrze — odbija żądanie z HTTP 400 i komunikatem Failed to parse grammar.5 To dobra wiadomość: literówkę w regule wyłapiesz od razu, na etapie parsowania, a nie po godzinie zastanawiania się, czemu wyjście wygląda dziwnie. Gotowe przykłady GBNF — w tym pełną gramatykę dla tablicy obiektów JSON — znajdziesz w katalogu grammars/ w repo ggml-org/llama.cpp.1

Bo cała ta zabawa sprowadza się do jednej zmiany perspektywy. Przestajesz błagać model o dobry format i zaczynasz mu ten format wytyczać — nie słowami w prompcie, których i tak może nie usłuchać, tylko ścianami korytarza, którymi go prowadzisz. Model dalej myśli, co chce. Po prostu wyjściem z tego myślenia jest już tylko ta jedna furtka, którą sam mu zostawiłeś otwartą.


  1. Pełna składnia GBNF (reguły nonterminal ::=, terminale, zakresy znaków, operatory powtórzeń, dopasowanie po tokenach <[...]>), przykładowa gramatyka JSON oraz uwaga, że schemat/gramatyka nie trafiają do promptu — grammars/README.md, ggml-org/llama.cpp↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. Definicje i teksty pomocy flag --grammar, --grammar-file, -j/--json-schema, -jf/--json-schema-file oraz konwersja przez json_schema_to_grammarcommon/arg.cpp, ggml-org/llama.cpp↩︎ ↩︎ ↩︎

  3. Sygnatury llama_grammar_apply_impl (nakłada ograniczenia gramatyki na tablicę kandydatów tokenów / logity) i llama_grammar_accept (przesuwa stan po zaakceptowanym znaku), potwierdzające mechanizm stosów pushdown i maskowanie logitów — src/llama-grammar.h, ggml-org/llama.cpp↩︎ ↩︎ ↩︎

  4. Opis flag --grammar/--grammar-file/--json-schema w narzędziu CLI oraz rekomendacja --grammar + examples/json_schema_to_grammar.py dla schematów z zewnętrznymi $reftools/cli/README.md, ggml-org/llama.cpp↩︎

  5. Parametry grammar i json_schema w body żądania /completion (domyślnie puste) oraz zachowanie przy błędnej gramatyce (HTTP 400, Failed to parse grammar) — tools/server/README.md, ggml-org/llama.cpp↩︎ ↩︎