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:

FunkcjaCo gwarantujeDo czego służy
JSON mode (json_object)Tylko poprawną składnię JSONFallback, brak schematu do wymuszenia
Ustrukturyzowane wyjście (strict schema)JSON dokładnie zgodny z twoim schematemNadawanie formy odpowiedzi dla użytkownika lub kodu
Function calling / tool useNazwa i argumenty tool zgodne z jego schematemModel 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-6-astra",
    input="Extract the ticket details: 'Login fails with a 500 after the "
          "password reset flow, seen first on build 4.12, blocking release.'",
    text={
        "format": {
            "type": "json_schema",
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "summary": {"type": "string"},
                    "build": {"type": "string"},
                    "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]},
                },
                "required": ["summary", "build", "severity"],
                "additionalProperties": False,
            },
        }
    },
)
print(response.output_text)

Claude przyjmuje schemat w output_config.format, nie w definicji tool:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -d '{
    "model": "claude-sonnet-5-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
    "output_config": {
      "format": {
        "type": "json_schema",
        "schema": {
          "type": "object",
          "properties": {
            "summary": {"type": "string"},
            "build": {"type": "string"},
            "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
          },
          "required": ["summary", "build", "severity"],
          "additionalProperties": false
        }
      }
    }
  }'

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "Extract the ticket details: ..."}],
  "format": {
    "type": "object",
    "properties": {
      "summary": {"type": "string"},
      "build": {"type": "string"},
      "severity": {"type": "string", "enum": ["low", "medium", "high", "blocking"]}
    },
    "required": ["summary", "build", "severity"]
  },
  "stream": false
}'

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ę.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from pydantic import BaseModel, field_validator

class Ticket(BaseModel):
    summary: str
    build: str
    severity: str

    @field_validator("build")
    @classmethod
    def must_look_like_a_build_number(cls, v: str) -> str:
        if not v.replace(".", "").isdigit():
            raise ValueError(f"'{v}' doesn't look like a build number")
        return v

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_tokens w 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 anyOf dodaje 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.

Powiązane artykuły