Poproś model o odpowiedź w JSON i w większości przypadków json.loads()
działa bez problemu. Problemy zaczynają się tam, gdzie kosztują cię sesję
debugowania: jednoliniowy komentarz w środku obiektu, markdown code fence
owijający całość, klucz zapisany jako userEmail, gdy kod oczekuje
user_email. Bardziej rygorystyczny prompt (“odpowiedz WYŁĄCZNIE obiektem
JSON, bez żadnego innego tekstu”) sprawia, że takie przypadki są rzadsze.
Nie eliminuje ich całkowicie, bo model nadal może zignorować prośbę.
Ustrukturyzowane wyjście usuwa prośbę z równania: przekazujesz API JSON
Schema, który ogranicza samplowanie tokenów samego modelu tak, że odpowiedź
musi do niego pasować.
JSON mode kontra ustrukturyzowane wyjście: co gwarantuje każde z nich
“JSON mode” (response_format: {"type": "json_object"} w starszym API
OpenAI i odpowiednik u większości innych dostawców) gwarantuje jedną rzecz:
wyjście da się sparsować jako JSON. Nie mówi niczego o tym, jakie klucze
istnieją, jaki typ ma każdy z nich, czy pole, na którym się opierasz,
rzeczywiście jest obecne. Model w JSON mode może zwrócić {"result": "yes"}, gdy kod oczekuje {"status": "approved", "confidence": 0.91}, i
nic nie zasygnalizuje, że coś poszło nie tak.
Ustrukturyzowane wyjście (OpenAI nazywa je Structured Outputs, Anthropic JSON outputs) to inna gwarancja: odpowiedź odpowiada JSON Schema, który podajesz, z wymaganymi kluczami obecnymi i prawidłowym typem dla każdego. Function calling rozwiązuje sąsiedni problem — model prosi twój kod o wykonanie czegoś. Te dwa mechanizmy łatwo pomylić, bo oba przekazują schemat. Różnica jest w tym, co ten schemat formuje:
| Funkcja | Co gwarantuje | Do czego służy |
|---|---|---|
JSON mode (json_object) | Tylko poprawną składnię JSON | Fallback, brak schematu do wymuszenia |
| Ustrukturyzowane wyjście (strict schema) | JSON dokładnie zgodny z twoim schematem | Nadawanie formy odpowiedzi dla użytkownika lub kodu |
| Function calling / tool use | Nazwa i argumenty tool zgodne z jego schematem | Model decyduje o wywołaniu twojego kodu |
Pipeline produkcyjny często łączy te dwa ostatnie. Strict tool use nie dopuszcza do zniekształconych wywołań funkcji agenta, a strict schema na finalnej odpowiedzi typuje podsumowanie zwracane użytkownikowi.
Jak constrained decoding wymusza zgodność
Model nigdy nie porównuje swojej odpowiedzi ze schematem po jej wygenerowaniu. Schemat zmienia to, co model może wygenerować, token po tokenie.
Warstwa serwująca kompiluje twój JSON Schema w grammar: maszynę stanów, która na każdej pozycji wyjścia śledzi, które tokeny wciąż prowadziłyby do czegoś poprawnego. Przed samplowaniem grammar sprawdza cały słownik: token wykluczony na danej pozycji dostaje logit przesunięty do minus nieskończoności, więc po softmax ma zerowe prawdopodobieństwo. Model samplowuje z tego, co zostało, a względne prawdopodobieństwa między pozostałymi tokenami są zachowane. Błędnie zapisany klucz, nawias klamrowy zamykający w złym miejscu, string, gdzie schemat wymaga liczby: gdy grammar je zabroni, te tokeny po prostu przestają być opcją.
Ten etap kompilacji ma swoją cenę. Zbudowanie grammar dla nowego schematu wymaga realnej pracy, więc pierwsze żądanie wobec tego schematu jest wolniejsze od następnych. API Anthropic cache’uje skompilowany grammar na 24 godziny od ostatniego użycia, a kolejne wywołania z tym samym schematem pomijają kompilację.
Mechanizm wyjaśnia też ograniczenie. Constrained decoding ustala tylko
formę, nic więcej. Grammar wie, które tokeny są składniowo legalne, nie
który z nich jest prawdziwy — model może więc wypełnić poprawne pole
"email" wymyślonym adresem.
Jak uzyskać JSON zgodny ze schematem z OpenAI, Claude i Ollama
Responses API OpenAI przyjmuje schemat w text.format, z włączonym
strict:
| |
Claude przyjmuje schemat w output_config.format, nie w definicji tool:
| |
JSON wraca w treści tekstowej odpowiedzi, nie w bloku tool call. Stary workaround (zdefiniować fałszywy tool, zmusić model do jego wywołania, odczytać argumenty) wciąż działa na modelach wspierających tylko tool use. Tam, gdzie istnieje natywne ustrukturyzowane wyjście, nie jest potrzebny: decoder wymusza schemat wprost, zamiast liczyć na to, że argumenty tool przypadkiem się zgodzą.
Model uruchomiony lokalnie z Ollama
otrzymuje schemat przez pole format w /api/chat:
| |
Mechanizm jest ten sam. Ollama korzysta z runtime llama.cpp, który
budowuje grammar na podstawie schematu i maskuje tokeny tak samo jak
hostowane API.
Jakie funkcje JSON Schema wspierają dostawcy
Każdy dostawca wymusza podzbiór JSON Schema, a te podzbiory różnią się wystarczająco, by złamać schemat skopiowany z jednego API do drugiego.
OpenAI wymaga, by każdy obiekt miał "additionalProperties": false, a
każda jego właściwość była wymieniona w required — nie ma sposobu, by
oznaczyć pole jako faktycznie opcjonalne, więc modeluj je jako required i
nullable. Anthropic jest bardziej liberalne: właściwość nieujęta w
required jest naprawdę opcjonalna, a właściwości required pojawiają się w
wyjściu najpierw, potem te opcjonalne, które są obecne. Anthropic wspiera
lokalne $ref/$defs, anyOf i const, ale odrzuca schematy rekurencyjne,
liczbowe granice jak minimum i maximum oraz ograniczenia długości
stringa jak maxLength. Przykłady OpenAI zawierają rekurencyjne
$ref: "#" dla wyjścia w formie drzewa, co dokumentacja Anthropic
wyraźnie wyklucza.
Gdy twój schemat potrzebuje ograniczenia, którego dostawca nie wspiera,
przenieś je do description pola (“musi być dodatnią liczbą całkowitą
mniejszą niż 100”) i sprawdź je sam po parsowaniu. Helpery SDK na obu
platformach robią to automatycznie, gdy budujesz schemat z modelu Pydantic
albo Zod, a nie piszesz surowego JSON Schema ręcznie.
Jak walidować ustrukturyzowane wyjście po parsowaniu
Poprawna forma to nie poprawna odpowiedź. Schemat gwarantuje, że severity
to jedna z czterech wartości enum. Czy “blocking” jest właściwą oceną dla
tego ticketu, to osobne pytanie. Traktuj sparsowany obiekt tak, jak
traktowałbyś wysłany formularz: dane wejściowe do zwalidowania, nie ustaloną
prawdę.
| |
Instructor owija OpenAI, Anthropic i
większość innych dostawców pętlą walidacji i ponownych prób. Przekazujesz
mu model Pydantic. Przy błędzie walidacji oddaje błąd modelowi i prosi
jeszcze raz, do limitu prób, który sam ustalisz, zamiast zgłaszać
ValidationError już przy pierwszym niepowodzeniu. To też praktyczny
sposób na zdobycie gwarancji schematu u dostawcy lub w starszym modelu bez
natywnego strict mode: pętla retry zastępuje wymuszanie na poziomie
decodera, a każde niepowodzenie kosztuje dodatkową rundę.
Walidatory pól wykrywają błędne wartości w pojedynczej odpowiedzi. Aby sprawdzić, czy odpowiedzi są poprawne na setkach danych wejściowych, i czy zmiana promptu je pogorszyła, potrzebujesz eval suite.
Kiedy ustrukturyzowane wyjście i tak zawodzi
Strict mode nie obejmuje tych przypadków. Sprawdź je, zanim zaczniesz szukać błędu we własnym kodzie:
- Refusal. Model może odmówić odpowiedzi, a tekst odmowy nie jest
zgodny z twoim schematem. Sprawdź
stop_reason(lub jego odpowiednik) przed parsowaniem, nie po tym, jak parsowanie zgłosi błąd. - Obcięcie. Osiągnięcie
max_tokensw połowie obiektu daje niepełny JSON. Grammar kontroluje, który token przyjdzie następnie, nie ile miejsca zostało, by dokończyć. Zwiększ limit albo skróć schemat. - Niewymuszone ścieżki. JSON mode, biblioteka retry na modelu bez
strict mode, albo dostawca, który po cichu ignoruje nieobsługiwane słowo
kluczowe schematu, mogą wszystkie zwrócić
"Blocking", gdy twój enum mówi"blocking". Jeśli jakieś żądanie w twoim systemie przechodzi jedną z tych ścieżek, normalizuj wartości na wejściu. - Rozmiar schematu. Duży schemat z wieloma rozgałęzieniami
anyOfdodaje tokeny do każdego żądania, oprócz opóźnienia kompilacji dla nowego schematu. Trzymaj schematy tak płaskie i małe, jak pozwala na to zadanie.
Gwarancja dostawcy jest realna, ale twój kod parsujący nie powinien zakładać, że zadziałała.
JSON mode, strict schema czy function calling: co wybrać
Używaj prostego JSON mode tylko wobec modelu lub ścieżki SDK bez wsparcia dla schematów, jako fallback, nie jako domyślny wybór. Używaj strict JSON Schema, gdy nadajesz formę odpowiedzi dla własnego kodu lub użytkownika: ekstrakcja, klasyfikacja, typowane podsumowanie. Używaj function calling, gdy model decyduje, czy coś wykonać. Włącz strict mode też dla definicji tool, żeby zniekształcone wywołanie zawiodło na poziomie API, a nie w twoim kodzie.
Jeśli potrzebujesz tylko etykiety z krótkiego enum, porównaj też model system one. Gdy wyjście jest ograniczone do znanego zbioru odpowiedzi, model może wybrać jedną z gotowych opcji, a nie generować ją od zera, a kompromisy w latencji i koszcie różnią się na tyle, by zmierzyć je na własnym ruchu.
Dla wszystkiego innego domyślny wybór to strict schema plus walidator na sparsowanym wyniku. Schemat utrzymuje poprawną formę. Sprawdzenie, czy treść jest poprawna, wciąż jest twoim zadaniem.