Generative AI Design Patterns #1a: Logits Masking i Grammar, czyli gwarancja formy, nie treści
Pierwsze dwa wzorce z „Generative AI Design Patterns" — Logits Masking i Grammar — odpowiadają na najczęstszy problem z modelem w produkcji: nie trzyma formatu. Oba działają tak samo: zerują prawdopodobieństwo tokenów, które łamią regułę, więc gwarantują kształt odpowiedzi. Autorzy uczciwie dodają, że kształt to nie treść, i radzą zostawić modelowi furtkę Unknown. Sprawdziłem to na dwóch lokalnych modelach na CPU. Furtka jest potrzebna, ale nie działa tak, jak się zakłada: w moim teście faktura w złotówkach ze schematem bez PLN wychodziła u Qwena 30B jako USD w 10 na 10 prób, także wtedy, gdy model miał do dyspozycji UNKNOWN i instrukcję, kiedy go użyć.
To drugi wpis serii o książce Valliappy Lakshmanana i Hannesa Hapkego. Recenzja i mapa całej serii są we wpisie 0. Cytuję po rozdziale i numerze wzorca, bo PDF nie ma stron druku.
Teza
Prośba „zwróć JSON" w prompcie nie jest tanim szczeblem drabiny kosztów, tylko jego brakiem. Ograniczenie wyjścia na poziomie dekodowania daje prawdziwą gwarancję — ale gwarancję formy. Treść, która nie mieści się w formie, nie znika z hukiem, tylko zostaje po cichu zastąpiona czymś, co się mieści. Dlatego sam schemat z wariantem „nie wiem" nie wystarcza; trzeba tak zaprojektować wyjście, żeby model w ogóle nie musiał wybierać z zamkniętej listy, gdy prawdziwa odpowiedź leży poza nią.
Problem: posłuszeństwo modelu jako kontrakt
Autorzy nazywają poleganie na tym, że model wykona instrukcję formatu, antywzorcem i podają dobre kryterium: „What makes this an antipattern is that every consumer of the LLM call has to guard against the LLM potentially failing to follow instructions" (rozdz. 2, Pattern 2). Instrukcja jest krucha (zmienia się z wersją modelu), zawodna (generowanie jest stochastyczne) i droga (lepiej słuchają większe modele).
Naturalny odruch — wygeneruj, sprawdź, powtórz — autorzy nazywają try-and-try-again i liczą, kiedy jest dopuszczalny. Liczba prób do pierwszego sukcesu ma rozkład geometryczny, więc da się to policzyć. Przeliczyłem ich wzór sam:
| Odsetek udanych generacji | Średnia liczba prób | 99. percentyl prób |
|---|---|---|
| 99% | 1,01 | 1 |
| 90% | 1,11 | 2 |
| 70% | 1,43 | 4 |
| 50% | 2,00 | 7 |
| 30% | 3,33 | 13 |
Dla 90% i 30% wychodzi dokładnie to, co w książce (2 i 13 prób na 99. percentylu, rozdz. 2, Pattern 1). Wniosek autorów: ponawianie ma sens tylko wtedy, gdy regeneracji wymaga mniej niż ok. 10% odpowiedzi. Powyżej opóźnienie ogona rośnie szybciej niż średnia.
Pattern 1: Logits Masking
Problem. Tekst ma spełniać reguły, których nie da się opisać formatem danych: słowa zakazane przez regulamin sklepu, nazwy konkurentów klienta, akrostych. Autorzy pokazują opis produktu, w którym prompt zero-shot przemyca trzy słowa zwiększające ryzyko odrzucenia przez platformę e-commerce.
Rozwiązanie. Przechwycić generowanie na etapie próbkowania: w każdym kroku dostać kandydatów, wyzerować (logit = −∞) tych, którzy łamią reguły, i generować dalej. W prostym wariancie wybiera się najlepszą z dozwolonych kontynuacji (sequence selection). W trudniejszym — gdy reguły mogą odciąć wszystkie opcje — trzeba się cofnąć i generować od wcześniejszego punktu (sequence regeneration). W przykładzie z akrostychem Llama 3.2 bez maskowania próbowała ułożyć „POWER", a wyszło „PORE"; z maskowaniem i cofaniem powstały poprawne „BOLD" i „SWIFT" (rozdz. 2, Pattern 1).
Kiedy nie używać. Autorzy wymieniają trzy ograniczenia i każde jest konkretne:
- Dostęp do logitów. W czerwcu 2025 Claude ich nie udostępniał, OpenAI dawał odczyt logprobs, Gemini tylko w wersji Flash; zapis logitów wymaga praktycznie modelu hostowanego samodzielnie.
- Opóźnienie. Każdy krok generowania oznacza wymianę danych między modelem a kodem klienta, więc poza modelem lokalnym albo kodem uruchomionym obok modelu koszt bywa nie do przyjęcia.
- Brak kontynuacji. Jeśli żadna sekwencja nie spełnia reguł, nie ma czego wygenerować; prościej niż cofać się jest zwrócić błąd albo odmowę.
Jeśli reguły da się zapisać jako format danych, maskowanie można oddać dostawcy — to jest Pattern 2.
Trwałość (moja ocena): mieszana. Mechanizm jest trwały, bo wynika z tego, jak model generuje tekst. Datowana jest dostępność: kto wystawia logity i kto pozwala je modyfikować, to decyzja biznesowa dostawcy, a nie własność modeli.
Starszy krewny. Autorzy wskazują invalid action masking z uczenia ze wzmocnieniem — zerowanie niedozwolonych ruchów, pierwszy raz opisane przy StarCraft II (rozdz. 2, Pattern 1, References).
Pattern 2: Grammar
Problem. Wynik ma trafić do programu: lista CSV, rekord, poprawne zapytanie SQL. Parser po drugiej stronie nie toleruje „Oto Twój JSON:".
Rozwiązanie. Opisać dozwolony kształt formalnie i pozwolić frameworkowi maskować tokeny za nas. Autorzy pokazują trzy poziomy, które w praktyce ograniczają co innego:
| Poziom | Co ogranicza | Czego nie ogranicza |
|---|---|---|
gramatyka BNF (np. transformers-cfg, GBNF w llama.cpp) | alfabet i strukturę, znak po znaku | sensu wartości |
tryb JSON (response_format={"type": "json_object"}) | parsowalność | nazw pól, typów, alfabetu |
| schemat / dataclass / Pydantic (structured outputs) | pola, typy, Enum | czegokolwiek ponad Enum bez własnego walidatora |
O trybie JSON autorzy piszą wprost: „The JSON mode doesn't constrain whether the author's name contains accent marks—sometimes it will, sometimes it won't" (rozdz. 2, Pattern 2).
Najcenniejsza uwaga dotyczy nazewnictwa: etykieta „structured outputs" nie mówi, czy gwarancja istnieje. W czerwcu 2025 LangGraph realizował ją dodatkowym wywołaniem LLM-a, który przerabiał odpowiedź na żądany format — autorzy nazywają to „more wasteful, more expensive, and less reliable" (rozdz. 2, Pattern 2, Caveats). W przypisie odnotowują też, że Claude w lipcu 2025 „seems to not employ constrained decoding" — wnioskowali to z dokumentacji, która zalecała prefill odpowiedzi. To się od tego czasu zmieniło: Anthropic wprowadził structured outputs w publicznej becie w listopadzie 2025 (tessl.io), a dokumentacja opisuje je jako „constrained sampling with compiled grammar artifacts" (platform.claude.com). Ta sama dokumentacja wymienia jednak przypadki, w których wynik może nie pasować do schematu: odmowa ze względów bezpieczeństwa, ucięcie na max_tokens i wielkość liter w wartościach enum. Gwarancja ma przypisy — także u dostawcy.
Kiedy nie używać. Gdy reguła jest logiką, a nie reprezentacją: zależy od treści, pochodzi z bazy reguł, od użytkownika albo z zewnętrznego API — wtedy wracamy do Logits Masking (rozdz. 2, Pattern 2, Alternatives). A w sekcji Caveats autorzy opisują trzy tryby awarii: nieskończone białe znaki, rosnący odsetek odmów przy zagnieżdżonych strukturach i niedokładne wyniki przy zbyt restrykcyjnej gramatyce. Na ten ostatni radzą furtkę: currency_rate: float | Literal["Unknown"].
To zalecenie sprawdziłem.
Sprawdziłem to na dwóch lokalnych modelach
Środowisko: ThinkPad L16 Gen 2 z Ryzenem AI 5 PRO 340, sam CPU (to ten sam laptop, co we wpisie o lokalnych LLM), llama.cpp b6153 (llama-server, pakiet Fedory). Dwa modele: granite-4.0-h-tiny (Q4_K_M, ok. 1 mld aktywnych parametrów) i Qwen3-Coder-30B-A3B-Instruct (UD-Q3_K_XL, ok. 3 mld aktywnych). Gramatyki przepisałem z książki i notebooków autorów; llama.cpp przyjmuje je jako GBNF, a schemat JSON zamienia na gramatykę sam. Wszystkie liczby z tej sekcji pochodzą z moich uruchomień z 28 września 2026; skryptów nie publikuję, ale każdy wariant to jedno wywołanie /v1/chat/completions z polem grammar albo response_format.
Uwaga praktyczna: build llama.cpp z HIP przy -ngl 0 generował bełkot na obu modelach, choć krótkie prompty wyglądały poprawnie. Pomogło dopiero całkowite ukrycie GPU (HIP_VISIBLE_DEVICES=-1 llama-server --device none …). Wyniki sprzed tej zmiany odrzuciłem.
1. Arytmetyka: wydruk w książce jest czystszy niż notebook
Autorzy pokazują gramatykę, która dopuszcza tylko wyrażenia z =, i pytanie, na które poprawna odpowiedź wymaga > („Do Bill and Mae have more apples than oranges?"). W książce model zwraca dwie linie, 3+2=5 i 2+4=6. W notebooku 02_grammar/1_applying_grammar.ipynb z repozytorium autorów zapisany wynik (Phi-3-mini) wygląda inaczej: 1 +2 = 3, 2 +4 = 6, a potem 3 = 6 powtórzone aż do limitu tokenów, z komentarzem „Obviously, this is a problem if your grammar is too limited".
U mnie oba modele doszły do limitu 256 tokenów (finish_reason=length) w obu pytaniach, także w tym, na które odpowiedź mieści się w gramatyce. Granite napisał 3 -2 = 1, 2 -4 = 2 i potem w kółko 3 -3 = 0. Qwen zrobił coś ciekawszego: gramatyka dopuszcza identyfikatory [a-z][a-z0-9_]*, więc przemycił w nich rozumowanie — letmecalculate, totalapples=3, totaloranges=2 na zmianę bez końca. Po zmianie korzenia na jedno równanie zamiast (…)+ granite zakończył pierwsze pytanie poprawnie (3 +2 = 5, 7 tokenów), a w drugim wpadł w nieskończone białe znaki — dokładnie pierwszy tryb awarii z Caveats.
Wniosek: pętla nie jest egzotyką, tylko typowym zachowaniem, gdy gramatyka nie wymusza końca. Limit max_tokens jest częścią gramatyki, nie jej dodatkiem.
2. Alfabet: gramatyka zmienia dane
Gramatyka author ::= [a-zA-Z ]* | unk z książki ma chronić system, który rozumie tylko 7-bitowe ASCII, i dla „Gabriel García Márquez" daje ładne „Gabriel Garcia Marquez". Po polsku wygląda to gorzej. Temperatura 0, jeden przebieg na przypadek:
| Wejście | Bez gramatyki (granite) | Z gramatyką: granite | Z gramatyką: Qwen 30B |
|---|---|---|---|
| „Przedwiośnie" Stefana Żeromskiego, 1924 | Stefan Żeromski | Przedwiośnie | 1924 | Stefan Zeromski | Przedwiozie |NULL | Stefan Zeromski | Przedwionie |1924 |
| „Ludzie na moście" Wisławy Szymborskiej, 1986 | — | Wislawa Szymborska | Ludzie na mo Bridge |NULL | Wisawia Szymborska | Ludzie na moscie |1986 |
Model nie transliteruje konsekwentnie — zgaduje, jaki dozwolony token jest najbardziej prawdopodobny w miejscu zakazanego. „ś" czasem staje się „s" (moscie), a czasem znika albo ciągnie za sobą inne litery (Przedwiozie, Przedwionie, mo Bridge). „Wisawia Szymborska" z modelu 30B przeszłaby każdą walidację formatu.
NULL w kolumnie roku u granite ma osobną przyczynę. Gramatyka z książki nie dopuszcza spacji po |, a model chce wstawić rok jako token ze spacją na początku. Po zmianie separatora na " "? "|" " "? granite podał 1985, 1924 i 1986 poprawnie. Qwen tego problemu nie miał, więc to zależy od tokenizera — ale pokazuje coś ważnego: NULL z gramatyki nie znaczy „nie wiem", tylko „nie dało się tego zapisać". Z zewnątrz obu przypadków nie da się odróżnić.
3. Faktura w złotówkach i furtka, która nie działa
Przykład z książki: dataclass Invoice z polem currency typu Enum o wartościach USD, UKP, INR, EUR (na marginesie: kod ISO funta to GBP, nie UKP). Podałem polską fakturę: „Proszę o zwrot za taksówkę na lotnisko. Zapłaciłem 124,50 zł." System prompt i instrukcje po angielsku, jak w książce, tylko treść faktury po polsku. Temperatura 0,8, schemat JSON w trybie strict:
| Wariant | granite (20 prób) | Qwen 30B (10 prób) |
|---|---|---|
tylko prompt: currency (one of: USD, UKP, INR, EUR) | JSON parsowalny 20/20; INR 11, PLN 9 | PLN 10/10 |
schemat, Enum z książki | USD 20/20 | USD 10/10 |
schemat, Enum + UNKNOWN | USD 18, EUR 2 | USD 10/10 |
to samo + instrukcja If the currency is not one of the allowed values, use UNKNOWN. | INR 9, USD 6, UNKNOWN 4, EUR 1 | USD 10/10 |
kontrola: faktura w USD, Enum + UNKNOWN + instrukcja | USD 15, UNKNOWN 5 | USD 10/10 |
kontrola: Enum z PLN | PLN 20/20 | PLN 10/10 |
Kwota (124,5) była poprawna we wszystkich 40 próbach granite ze schematem, w których ją zapisywałem (w pozostałych seriach liczyłem tylko walutę). Model wie, że „zł" to PLN — pokazuje to kontrola. Bez schematu Qwen łamał instrukcję, żeby powiedzieć prawdę; ze schematem nie mógł, więc skłamał, i to zawsze tak samo. Furtka UNKNOWN u silniejszego modelu nie zadziałała ani razu, a u słabszego zadziałała w 4 na 20 prób, za to w 5 na 20 fałszywie odrzucała poprawną fakturę w dolarach.
Sprawdziłem dwie hipotezy na Qwenie. Zamiana UNKNOWN na OTHER (inna pierwsza litera niż USD i UKP): nadal USD 10/10. Dodanie przed currency pola tekstowego currency_in_text: model wpisał tam poprawne "zł" 10 razy na 10 — i w następnym polu wybrał USD 10 razy na 10.
Prawdopodobne wyjaśnienie pokazują logprobs z llama-server (temperatura 0; serwer raportuje prawdopodobieństwa modelu sprzed nałożenia gramatyki). W miejscu wartości waluty model wstawiłby token PL z logprob −0,0, czyli z prawdopodobieństwem praktycznie 1. Następne były pl (−11,5) i USD (−14,0). Żaden token prowadzący do UNKNOWN nie zmieścił się w ośmiu najbardziej prawdopodobnych. Gramatyka wycina PL, a to, co zostaje, jest renormalizowane — więc wygrywa USD z prawdopodobieństwem rzędu 10⁻⁶, bo to najbardziej „walutowy" z dozwolonych tokenów. Gramatyka UNKNOWN dopuszcza (przy instrukcji Always set currency to UNKNOWN. model go wpisał). Problem nie leży w gramatyce, tylko w tym, że maskowanie nie wybiera najuczciwszej dozwolonej odpowiedzi, lecz najbardziej prawdopodobną, a dla modelu „nie wiem" nie jest bliskim zamiennikiem „PLN".
Zastrzeżenia: dwa modele, jedna faktura, lokalne kwantyzacje, próby liczone w dziesiątkach. Logprobs pochodzą z jednego przebiegu Qwena przy temperaturze 0, a tabela była liczona przy 0,8. To pokazuje mechanizm, a nie odsetek, którego należy się spodziewać u komercyjnego dostawcy; jak próbkują dostawcy, nie sprawdzałem.
Furtka jest konieczna, ale nie wystarczy
Autorzy wracają do furtki w rozdziale o zabezpieczeniach i przedstawiają ją jako prostszą od Self-Check alternatywę: „A simpler method than Self-Check, and quite an effective one in many situations, is to explicitly provide the model an out" (rozdz. 9, Pattern 31). Tam też podają przykład, który mój test dobrze ilustruje: jeśli wymusisz, że odpowiedzią jest liczba, a obraz jest rozmazany, „you'll get back a hallucinated number". Ta rada jest słuszna, ale mój pomiar mówi, że furtka w schemacie to warunek konieczny, nie wystarczający. Co z tego wynika w praktyce:
- Zamknięty zbiór tylko tam, gdzie dziedzina naprawdę jest zamknięta. Waluta nie jest zbiorem czterech wartości. Jeśli lista jest niepełna, schemat zamienia każdy brak na najbliższy dozwolony błąd.
- Wyciągaj to, co jest w tekście, a mapuj w kodzie. Pole
currency_in_textbyło poprawne w 10 na 10 prób. Zamiana"zł"naPLNalbo na jawną gałąź „nieobsługiwana waluta" to kilka linii deterministycznego kodu. To ten sam ruch, który we wpisie 0 nazwałem przesuwaniem niedeterminizmu poza moment, w którym błąd kosztuje: model robi to, w czym jest dobry (czyta), a decyzję podejmuje kod. - „Nie wiem" jako osobny typ, nie jako wartość z dziedziny. Przykład z książki zwraca w JSON-ie
"year": "NULL"— napis w polu, które w innych rekordach jest liczbą. Dan Vanderkam w Effective TypeScript opisuje to jako wartość specjalną z dziedziny zwykłego wyniku (jak-1zindexOf), która wyłącza sprawdzanie przypadku, bo dla każdego narzędzia jest tylko jeden przypadek (s. 162–164). Unia z jawnym wariantem jest lepsza, ale Vanderkam dokłada drugą połowę: nowy wariant trzeba obsłużyć w każdym konsumencie, a jego pominięcie nie daje żadnego błędu (s. 262) — więcUnknownbez wymuszonej obsługi (exhaustiveness check, lint) zostanie po cichu potraktowany jak zwykła wartość. - Mierz odsetek furtki, nie tylko parsowalność. Granite fałszywie wybierał
UNKNOWNdla poprawnej faktury w dolarach w 5 na 20 prób. Furtka bez ewaluacji zamienia jeden cichy błąd na inny.
Ten sam problem pojawia się poza ekstrakcją. Dominik Polzer w RAG with Python Cookbook pokazuje router zapytań ze schematem data_source: Literal[...] z trzema źródłami i bez wariantu „żadne", więc pytanie spoza wszystkich trzech i tak trafi do któregoś z nich (rec. 7.4, s. 194). A w recepturze o ekstrakcji faktury pisze, że walidacja Pydantic „catches type mismatches or missing fields before they propagate downstream" (s. 43) — co jest prawdą o typach i niczego nie mówi o tym, czy total_due jest prawdziwe. Sam zresztą w rozdziale 11 dopisuje, że produkcja potrzebuje sprawdzania zakresów i kolejki ręcznej weryfikacji (s. 341).
Jedno zastrzeżenie z innej strony: jawny sygnał „nie wiem", zwłaszcza z dołączoną liczbą pewności, jest informacją, którą napastnik może wykorzystać do sondowania granicy decyzyjnej modelu — Philip A. Dursey w Red Teaming AI opisuje ataki oparte na zwracanej modelowi pewności (s. 47, 139); przeniesienie tego na samą etykietę „nie wiem" to mój wniosek, bo ataki na gołą etykietę Dursey uznaje za trudniejsze. Dla wewnętrznej ekstrakcji faktur to nie ma znaczenia; dla publicznego API — tak. Unknown powinien sterować logiką po stronie serwera, a nie trafiać do klienta z dodatkowymi metadanymi.
Kiedy nie używać — zestawienie
| Sytuacja | Zamiast tego |
|---|---|
| ponad 90% odpowiedzi jest poprawnych bez ograniczeń | try-and-try-again z walidacją (2 próby na 99. percentylu) |
| reguła zależy od treści, klienta, bazy reguł | Logits Masking z własnym procesorem |
| brak dostępu do logitów, model w chmurze | Grammar przez schemat dostawcy |
| walidator zwraca pomocny komunikat błędu | Reflection (Pattern 18) — błąd wraca do promptu |
| dziedzina wartości jest otwarta (waluty, nazwiska, tytuły) | pole tekstowe „jak w źródle" + mapowanie w kodzie |
| tekst w języku z diakrytykami, gramatyka na ASCII | nie ograniczać alfabetu gramatyką; transliterować w kodzie |
Ocena trwałości
Moja ocena, nie autorów. Grammar jest trwały — schemat egzekwowany przy dekodowaniu mają OpenAI i Gemini (według książki) oraz Claude (według dokumentacji Anthropic), więc przypis z lipca 2025 o Claudzie już się zdezaktualizował. Datowane są szczegóły: które funkcje „structured outputs" naprawdę maskują tokeny, a które poprawiają wynik kolejnym wywołaniem, oraz lista wyjątków od gwarancji. Logits Masking jest trwały jako mechanizm, datowany jako dostępność — zależy od tego, czy dostawca wystawi logity, i praktycznie oznacza model lokalny. Moja hipoteza: sam mechanizm renormalizacji po wycięciu tokenów nie zależy od dostawcy i nie zniknie z kolejną wersją modelu. To, czy po wycięciu wygra furtka, czy najbardziej prawdopodobny błąd, zależy już od modelu — granite wybrał UNKNOWN w 4 na 20 prób, Qwen ani razu.
Starszy krewny: parse, don't validate
Grammar to parser postawiony przed generatorem zamiast za nim. Najbliższy krewny spoza AI to zasada „parse, don't validate" Alexis King (lexi-lambda.github.io): zamiast sprawdzać dane i dalej przekazywać surowy tekst, zamień je od razu w typ, który nie dopuszcza stanów niemożliwych. Vanderkam ujmuje to jako model danych „not admitting invalid states" (s. 139). Mój pomiar dopisuje do tej zasady zastrzeżenie specyficzne dla modeli: typ, który nie dopuszcza stanu niemożliwego, przy generatorze probabilistycznym zamienia go na najbliższy możliwy. Parser odrzuca złe wejście; gramatyka narzucona generatorowi nie ma czego odrzucić, więc produkuje coś dozwolonego. Dlatego typ ze stanem „nie wiem" musi być zaprojektowany tak, żeby ten stan był dla modelu łatwo osiągalny — albo decyzję trzeba zabrać modelowi i oddać kodowi.
Dalej w serii
Następny wpis (1b) to pozostałe wzorce rozdziału 2: Style Transfer, Reverse Neutralization i Content Optimization — czyli kontrola stylu tam, gdzie nie da się go zapisać gramatyką. Mapa całej serii jest we wpisie 0.