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.
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 podajesz | gotowy GBNF, napisany ręcznie | schemat JSON Schema (json-schema.org) |
| Kto pisze gramatykę | ty | json_schema_to_grammar, automatycznie |
| Kiedy sięgać | dowolna struktura, nie tylko JSON | masz już schemat, myślisz „typami" |
Złożone $ref | ogarnia, bo piszesz wprost | patrz 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ą.
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. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎Definicje i teksty pomocy flag
--grammar,--grammar-file,-j/--json-schema,-jf/--json-schema-fileoraz konwersja przezjson_schema_to_grammar— common/arg.cpp, ggml-org/llama.cpp. ↩︎ ↩︎ ↩︎Sygnatury
llama_grammar_apply_impl(nakłada ograniczenia gramatyki na tablicę kandydatów tokenów / logity) illama_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. ↩︎ ↩︎ ↩︎Opis flag
--grammar/--grammar-file/--json-schemaw narzędziu CLI oraz rekomendacja--grammar+examples/json_schema_to_grammar.pydla schematów z zewnętrznymi$ref— tools/cli/README.md, ggml-org/llama.cpp. ↩︎Parametry
grammarijson_schemaw 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. ↩︎ ↩︎