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.

👁 115 przeczytań
- Aby zmusić AI do odpowiedzi w JSON, używa się parametru response_format z json_schema i strict: true albo metody .parse() z klasą Pydantic.
- Structured output działa z GPT-6, Claude, Gemini, DeepSeek, Mistral i wieloma modelami otwartymi - na OpenRouter trzeba dodać require_parameters.
- Schemat gwarantuje wyłącznie poprawny format, a nie prawdziwość treści - sens danych, jak zgodność sumy netto z VAT, trzeba sprawdzić osobno.
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:
- Prośba w prompcie - działa zwykle, ale bez gwarancji (jak wyżej).
- Tryb JSON (
{"type": "json_object"}) - model zwróci poprawny składniowo JSON, ale z dowolnymi polami. - 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%: TrueModel 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. Przystrict: truepowinna 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.
- W trybie strict każde pole musi być wymagane, a
additionalPropertiesustawione na false. Pydantic dodaje to, gdy w klasie ustawiszmodel_config = ConfigDict(extra="forbid"), a.parse()robi to sam. Pole opcjonalne zapisujesz jakostr | None- model wpisze wtedynull. - Nie każdy dostawca obsługuje schemat. Na OpenRouter bez
"provider": {"require_parameters": true}zapytanie może trafić do dostawcy, który pominieresponse_format, i dostaniesz zwykły tekst. - 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. - Odmowa. Gdy model odmówi odpowiedzi (np. z powodów bezpieczeństwa), zamiast danych dostaniesz pole
refusal. Sprawdzaj je przed parsowaniem. - Zbyt rozbudowany schemat. Dostawcy mają limity zagnieżdżenia i liczby pól. Przy kilkudziesięciu polach lepiej podzielić zadanie na dwa zapytania.
- 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: Function calling krok po kroku, a potem agent AI w Pythonie. 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.
Ź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.
- OpenRouter - structured outputs
- OpenAI - structured outputs
- Anthropic - structured outputs
- Google - structured output w Gemini API
- Pydantic - JSON Schema
Cały tydzień w AI, w jednym mailu
Wybrane premiery, narzędzia i analizy. Raz w tygodniu, prosto do skrzynki.
Zapisz się za darmo →
