✨ Świeża dostawa•nowe kody na kinetyka.pl: Gemini Pro 18 mies. 170 zł•Cursor, ElevenLabs, Lovable i więcej, roczne plany AI w ułamku ceny Zobacz →
Przejdź do treści
Artykuły

Structured output: jak zmusić AI do odpowiedzi w JSON

Jak dostać od modelu AI poprawny JSON zamiast tekstu: porównanie prośby w prompcie i trybu ze schematem, dane z faktury, analiza opinii przez .parse() i pułapki, na które trafisz.

9 min czytania
Klamry { } i napis Structured output - odpowiedź AI w JSON ze schematem

👁 112 przeczytań

Structured output to tryb API, w którym model musi odpowiedzieć JSON-em zgodnym z Twoim schematem: z dokładnie tymi polami, typami i dozwolonymi wartościami. Bez niego prosisz „odpowiedz w JSON” i liczysz na szczęście, a parser w Pythonie co jakiś czas się wywraca. Poniżej pokazuję oba podejścia na prawdziwych uruchomieniach, schemat generowany z Pydantic, walidację wyniku i pułapki, na które trafiłem. To część ścieżki API AI od zera.

W skrócie (stan na 4 października 2026)

  • Jak: response_format={"type": "json_schema", "json_schema": {..., "strict": true}} albo metoda .parse() z biblioteki openai i klasa Pydantic.
  • Gdzie działa: GPT-6, Claude, Gemini, DeepSeek, Mistral i wiele modeli otwartych; na OpenRouter dodaj require_parameters, żeby trafić do dostawcy, który to obsługuje.
  • Czego nie załatwia: poprawności treści. Schemat gwarantuje format, nie prawdę - wynik i tak trzeba sprawdzić.

Problem: „odpowiedz w JSON” nie wystarcza

Najpierw sprawdziłem, co się dzieje, gdy po prostu poproszę o JSON słowami, bez żadnego trybu:

"""Dla porównania: prośba o JSON tylko słowami, bez response_format. Tak NIE robić w produkcji."""
import os, sys, json
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])
MODEL = sys.argv[1] if len(sys.argv) > 1 else "mistralai/mistral-small-2603"

r = client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content":
               "Podaj w formacie JSON trzy największe miasta Polski z liczbą mieszkańców. "
               "Pola: miasto, mieszkancy."}],
    temperature=0.7,
)
tekst = r.choices[0].message.content
print("Odpowiedź modelu:\n" + tekst)
try:
    print("\njson.loads OK:", json.loads(tekst))
except json.JSONDecodeError as e:
    print(f"\njson.loads NIE DZIAŁA: {e}")
Odpowiedź modelu:
```json
[
  {"miasto": "Warszawa", "mieszkancy": 1860281},
  {"miasto": "Kraków", "mieszkancy": 804237},
  {"miasto": "Wrocław", "mieszkancy": 674132}
]
```

json.loads NIE DZIAŁA: Expecting value: line 1 column 1 (char 0)

Model zrobił dokładnie to, o co prosiłem, tylko „po ludzku”: owinął JSON w blok kodu z trzema odwrotnymi apostrofami, bo tak formatuje odpowiedzi w czacie. Dla człowieka to czytelne, dla json.loads - błąd w pierwszym znaku. Inne typowe usterki z mojej praktyki: zdanie wstępu przed JSON-em („Oto dane:”), nazwy pól po angielsku zamiast po polsku, liczby jako tekst („1 860 281”), brakujące pola. Pięć takich przypadków rozebrałem w tekście Model obiecał JSON, parser zwrócił null.

Są trzy poziomy wymuszania formatu:

  1. Prośba w prompcie - działa zwykle, ale bez gwarancji (jak wyżej).
  2. Tryb JSON ({"type": "json_object"}) - model zwróci poprawny składniowo JSON, ale z dowolnymi polami.
  3. Structured output ze schematem (json_schema + strict: true) - poprawny JSON z dokładnie Twoimi polami i typami. Tego używam.

Structured output krok po kroku: dane z faktury

Zadanie: z tekstu faktury wyciągnąć numer, NIP, datę, pozycje i kwotę. Schemat opisuję klasą Pydantic, a Pydantic sam zamienia ją na JSON Schema - nie piszę go ręcznie. Po odpowiedzi ta sama klasa sprawdza wynik.

"""Structured output: model musi zwrócić JSON zgodny ze schematem, a Pydantic to sprawdza."""
import os, sys
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, ConfigDict, Field, ValidationError

load_dotenv()
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])
MODEL = sys.argv[1] if len(sys.argv) > 1 else os.getenv("MODEL", "openai/gpt-6-luna")


class Pozycja(BaseModel):
    model_config = ConfigDict(extra="forbid")  # w schemacie: additionalProperties=false
    nazwa: str
    ilosc: int
    cena_netto_pln: float


class Faktura(BaseModel):
    model_config = ConfigDict(extra="forbid")
    numer: str
    nip_sprzedawcy: str = Field(description="10 cyfr bez kresek")
    data_wystawienia: str = Field(description="format RRRR-MM-DD")
    pozycje: list[Pozycja]
    do_zaplaty_brutto_pln: float


TEKST = """Faktura nr FV/09/2026/114 z dnia 30 września 2026.
Sprzedawca: Kawa i Kod sp. z o.o., NIP 525-281-44-07.
1) Szkolenie z API AI, 1 szt., 1800,00 zł netto
2) Konsultacja online, 3 godz. po 250,00 zł netto
Razem do zapłaty: 3136,50 zł brutto (VAT 23%)."""

# Schemat JSON generuje Pydantic - nie trzeba go pisać ręcznie.
schemat = Faktura.model_json_schema()

r = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Wyciągasz dane z faktur. Zwracasz wyłącznie JSON."},
        {"role": "user", "content": TEKST},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {"name": "faktura", "strict": True, "schema": schemat},
    },
    # tylko dostawcy, którzy obsługują json_schema
    extra_body={"provider": {"require_parameters": True}},
)

surowy = r.choices[0].message.content
print("Surowa odpowiedź:\n" + surowy)

try:
    faktura = Faktura.model_validate_json(surowy)
except ValidationError as e:
    sys.exit(f"JSON nie przeszedł walidacji:\n{e}")

# Walidacja typów to nie wszystko - sprawdzam też sens danych
suma_netto = sum(p.ilosc * p.cena_netto_pln for p in faktura.pozycje)
print("\nPo walidacji:")
print(" numer:", faktura.numer)
print(" NIP:", faktura.nip_sprzedawcy, "(10 cyfr)" if faktura.nip_sprzedawcy.isdigit() and len(faktura.nip_sprzedawcy) == 10 else "(ZŁY FORMAT)")
print(" data:", faktura.data_wystawienia)
print(f" suma netto z pozycji: {suma_netto:.2f} zł")
print(f" brutto z faktury: {faktura.do_zaplaty_brutto_pln:.2f} zł")
print(" zgadza się z VAT 23%:", round(suma_netto * 1.23, 2) == round(faktura.do_zaplaty_brutto_pln, 2))

Wynik dla GPT-6 Luna:

Surowa odpowiedź:
{"numer":"FV/09/2026/114","nip_sprzedawcy":"5252814407","data_wystawienia":"2026-09-30","pozycje":[{"nazwa":"Szkolenie z API AI","ilosc":1,"cena_netto_pln":1800.00},{"nazwa":"Konsultacja online","ilosc":3,"cena_netto_pln":250.00}],"do_zaplaty_brutto_pln":3136.50}

Po walidacji:
 numer: FV/09/2026/114
 NIP: 5252814407 (10 cyfr)
 data: 2026-09-30
 suma netto z pozycji: 2550.00 zł
 brutto z faktury: 3136.50 zł
 zgadza się z VAT 23%: True

Model sam zamienił „525-281-44-07” na 10 cyfr, „30 września 2026” na 2026-09-30, rozbił „3 godz. po 250,00 zł” na ilość i cenę jednostkową i przepisał kwoty z przecinkiem na liczby z kropką. Ten sam skrypt z Gemini 3.8 Flash (python 05_structured_output.py google/gemini-3.8-flash) dał identyczne dane; różnica była tylko w zapisie liczb (1800.0 zamiast 1800.00), co dla Pythona nie ma znaczenia.

Zwróć uwagę na dwa poziomy sprawdzania:

  • Walidacja schematu (Faktura.model_validate_json) - czy są wszystkie pola i mają dobre typy. Przy strict: true powinna przechodzić zawsze, ale zostawiam ją, bo nie każdy dostawca egzekwuje schemat tak samo.
  • Walidacja sensu - czy NIP ma 10 cyfr, czy suma pozycji z VAT zgadza się z kwotą do zapłaty. Tego schemat nie sprawdzi, a to tu wychodzą pomyłki modelu.

Krócej: .parse() i klasa Pydantic

Biblioteka openai ma metodę .parse(), która robi schemat z klasy i od razu zwraca gotowy obiekt zamiast tekstu. Działa też przez OpenRouter. Przykład: analiza opinii klientów z polem o ograniczonych wartościach (Enum), listą i wartością logiczną.

"""Krócej: metoda .parse() z biblioteki openai sama robi schemat z klasy Pydantic i sama waliduje wynik."""
import os, sys
from enum import Enum
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel

load_dotenv()
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])
MODEL = sys.argv[1] if len(sys.argv) > 1 else os.getenv("MODEL", "openai/gpt-6-luna")


class Nastroj(str, Enum):
    pozytywny = "pozytywny"
    neutralny = "neutralny"
    negatywny = "negatywny"


class Opinia(BaseModel):
    nastroj: Nastroj
    produkt: str
    problemy: list[str]
    czy_odpisac: bool


OPINIE = [
    "Kurier rzucił paczkę pod bramę, a ekspres do kawy ma pęknięty zbiornik. Żądam zwrotu!",
    "Słuchawki grają super, bateria trzyma 3 dni. Polecam.",
    "Lampka OK, choć kabel mógłby być dłuższy.",
]

for tekst in OPINIE:
    r = client.chat.completions.parse(
        model=MODEL,
        messages=[
            {"role": "system", "content": "Analizujesz opinie klientów sklepu internetowego."},
            {"role": "user", "content": tekst},
        ],
        response_format=Opinia,
        extra_body={"provider": {"require_parameters": True}},
    )
    opinia = r.choices[0].message.parsed  # gotowy obiekt Opinia, a nie tekst
    print(f"{opinia.nastroj.value:10} | {opinia.produkt:22} | odpisać: {'tak' if opinia.czy_odpisac else 'nie':3} | {opinia.problemy}")

GPT-6 Luna:

negatywny  | ekspres do kawy        | odpisać: tak | ['Kurier rzucił paczkę pod bramę', 'Pęknięty zbiornik na wodę', 'Klient żąda zwrotu pieniędzy']
pozytywny  | słuchawki              | odpisać: nie | []
pozytywny  | lampka                 | odpisać: nie | ['Kabel mógłby być dłuższy.']

Claude Haiku 4.5 (ten sam skrypt, inny model w argumencie):

negatywny  | ekspres do kawy        | odpisać: tak | ['uszkodzenie podczas dostawy', 'pęknięty zbiornik', 'niedopatrzenie kuriera']
pozytywny  | Słuchawki              | odpisać: nie | []
neutralny  | Lampka                 | odpisać: nie | ['za krótki kabel']

Format w obu przypadkach idealny: nastrój zawsze jedną z trzech dozwolonych wartości, typy się zgadzają. Ale przy trzeciej opinii („Lampka OK, choć kabel mógłby być dłuższy”) Luna uznała ją za pozytywną, a Haiku za neutralną. To właśnie granica structured output: schemat gwarantuje format, nie ocenę. Przy klasyfikacji przygotuj 20-50 przykładów z poprawną odpowiedzią i sprawdź, który model i jaki opis kategorii daje najmniej pomyłek.

Pułapki, na które trafisz

Mam to spisane w całości

Zebrałem te rzeczy w ebookach, które możesz pobrać za darmo - bez zapisu na listę, bez haczyka.

Pobierz ebooki

  1. W trybie strict każde pole musi być wymagane, a additionalProperties ustawione na false. Pydantic dodaje to, gdy w klasie ustawisz model_config = ConfigDict(extra="forbid"), a .parse() robi to sam. Pole opcjonalne zapisujesz jako str | None - model wpisze wtedy null.
  2. Nie każdy dostawca obsługuje schemat. Na OpenRouter bez "provider": {"require_parameters": true} zapytanie może trafić do dostawcy, który pominie response_format, i dostaniesz zwykły tekst.
  3. Ucięta odpowiedź to zepsuty JSON. Gdy model trafi na max_tokens (finish_reason=length), JSON urywa się w połowie. W modelach rozumujących limit obejmuje też myślenie, więc ustawiaj go z dużym zapasem.
  4. Odmowa. Gdy model odmówi odpowiedzi (np. z powodów bezpieczeństwa), zamiast danych dostaniesz pole refusal. Sprawdzaj je przed parsowaniem.
  5. Zbyt rozbudowany schemat. Dostawcy mają limity zagnieżdżenia i liczby pól. Przy kilkudziesięciu polach lepiej podzielić zadanie na dwa zapytania.
  6. Opisy pól pomagają. Field(description="format RRRR-MM-DD") trafia do schematu i model go czyta. To tańsze niż dopisywanie instrukcji do promptu.

Structured output a function calling

Function calling (wywoływanie narzędzi) używa tego samego mechanizmu schematów, ale w innym celu. Structured output to „odpowiedz w tym formacie”. Function calling to „jeśli potrzebujesz, poproś mnie o wykonanie tej funkcji z takimi argumentami” - na tym zbudowani są agenci AI. Gdy opanujesz schematy tutaj, przejście do narzędzi będzie proste. Jak działają agenci i gdzie się psują, opisałem w tekście Agent AI: co to jest.

Do czego używam structured output

  • wyciąganie danych z maili, faktur, ogłoszeń i CV do arkusza albo bazy;
  • klasyfikacja zgłoszeń (temat, pilność, dział) w automatyzacjach, np. w n8n;
  • generowanie treści w stałym układzie (tytuł, lead, lista punktów) do szablonu strony;
  • ocena tekstów według kryteriów z liczbową skalą;
  • każdy krok automatyzacji, po którym kolejny program musi odczytać wynik - opisuję je w tekście Automatyzacja z AI: od czego zacząć.

Najczęstsze pytania

Co to jest structured output?

Tryb API, w którym podajesz schemat JSON, a model generuje odpowiedź, która musi mu odpowiadać: te same pola, typy i dozwolone wartości. Dostawca pilnuje tego już podczas generowania, a nie dopiero po fakcie.

Czym różni się JSON mode od structured output?

JSON mode gwarantuje tylko poprawny składniowo JSON, bez kontroli pól. Structured output ze schematem i strict: true gwarantuje dokładnie Twoją strukturę.

Czy Claude obsługuje structured output?

Tak. W API Anthropic służy do tego parametr formatu wyjścia ze schematem JSON, a przez OpenRouter działa ten sam response_format co dla OpenAI - sprawdziłem to na Claude Haiku 4.5.

Czy mogę użyć structured output w modelach lokalnych?

Tak, Ollama przyjmuje schemat JSON w parametrze format, a llama.cpp ma ograniczanie wyjścia gramatyką. Jakość zależy od wielkości modelu.

Dlaczego dostaję zwykły tekst mimo response_format?

Model albo dostawca nie obsługuje tego parametru i po cichu go pominął. Na OpenRouter dodaj "provider": {"require_parameters": true} - wtedy zamiast cichego pominięcia dostaniesz błąd albo dostawcę, który schemat obsługuje.

Google · Twoje źródłaPromptowy wyżej w Twoim Google - jednym kliknięciemDodaj do preferowanych źródeł →

Źródła i data sprawdzenia

Skrypty uruchomiłem 4 października 2026 na Windows 11, Python 3.13.15, openai 3.24.0, pydantic 2.13.5, przez OpenRouter. Koszt wszystkich zapytań z tego tekstu: około 0,003 USD.

// Newsletter

Cały tydzień w AI, w jednym mailu

Wybrane premiery, narzędzia i analizy. Raz w tygodniu, prosto do skrzynki.

Zapisz się za darmo →
Za darmo. Wypisujesz się jednym kliknięciem.
// czytaj też

Podobne tematy na Promptowym

Piotr Olszewski

Piotr Olszewski

AUTOR I WYDAWCA

Piotr Olszewski - twórca i autor Promptowego, polskiego serwisu o sztucznej inteligencji. Codziennie śledzi premiery modeli, narzędzia i regulacje AI, i tłumaczy je prostym, konkretnym językiem.

// mapa strony

🛒 Sklep Kinetyka Google AI Gemini Pro 170 zł CapCut Pro 460 zł/rok Lovable Pro 400 zł/rok Zobacz wszystko →
promptowy w liczbach 0tekstów w archiwum0newsów z ostatnich 7 dni0modeli wideo w obserwatorium0zagadek w grach
× ‹ powiększenie ›