Strukturirani izlazi su mehanizam koji garantira da odgovor LLM-a odgovara unaprijed definiranoj JSON shemi. I tu je ključ: ne samo da je riječ o valjanom JSON-u, nego o shema-valjanom JSON-u — s točnim poljima, tipovima i ograničenjima koja ste vi specificirali.
Razlika između JSON Mode-a i Structured Outputs-a kritična je za produkciju:
- JSON Mode (legacy): jamči samo sintaktički ispravan JSON. Polje koje ste očekivali kao
integer može biti string, polja mogu biti potpuno izostavljena, model može izmisliti dodatna polja. Lijepo, ali nepouzdano.
- Structured Outputs (strict mode): koristi constrained decoding. Pri svakom koraku generiranja tokena, samo tokeni koji vode kroz valjani put kroz konačni automat (FSM) imaju pozitivnu vjerojatnost. Nevaljali tokeni dobivaju logits postavljen na minus beskonačno. Drugim riječima, model fizički ne može generirati izlaz koji ne odgovara shemi.
U praksi to znači razliku između 2-3% stope grešaka (s JSON Mode-om i regex parsiranjem) i 0% strukturalnih grešaka (sa strict mode-om). Za sustav koji obrađuje 100.000 zahtjeva dnevno, to je 2-3 tisuće propalih poziva na koje više ne morate odgovarati retry logikom. Drugim riječima, manje pejdžera u 3 ujutro.
Stanje pružatelja u 2026
U 2026. ekosustav je konvergirao — sva tri velika pružatelja podržavaju nativne strukturirane izlaze, ali sa značajno različitim API-jima:
- OpenAI:
response_format s json_schema i strict: true. Najzreliji API, sa SDK helperima poput client.beta.chat.completions.parse() koji prihvaća Pydantic klasu izravno.
- Anthropic Claude: strukturirani izlazi se ostvaruju kroz Tool Use. Definirate "alat" čija je shema vaš ciljani objekt, a model vraća
tool_use blok s validiranim argumentima.
- Google Gemini: parametar
responseSchema u konfiguraciji generiranja, prima JSON shemu izravno.
Pydantic kao temelj definicije sheme
Pydantic je u Pythonu de facto standard za definiranje shema strukturiranih izlaza, i razlog je očit: Pydantic klase su istovremeno schema definition, type contract i runtime validator. Tri stvari u jednom — to je rijetkost u softveru koji vrijedi.
Druga prednost je da svi veliki SDK-jevi danas izravno prihvaćaju Pydantic klase i interno generiraju JSON Schemu, pa ne morate ručno održavati paralelne strukture.
Osnovni primjer:
from pydantic import BaseModel, Field
from typing import Literal
from enum import Enum
class Sentiment(str, Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
NEUTRAL = "neutral"
class ReviewAnalysis(BaseModel):
reasoning: str = Field(
description="Korak po korak analiza recenzije prije zaključka"
)
sentiment: Sentiment = Field(
description="Ukupni sentiment recenzije"
)
confidence: float = Field(
ge=0.0, le=1.0,
description="Pouzdanost klasifikacije (0-1)"
)
key_phrases: list[str] = Field(
max_length=5,
description="Do 5 ključnih fraza iz recenzije"
)
Tri ključne stvari koje obavezno morate uočiti:
- Opisi nisu samo dokumentacija — oni postaju dio JSON Sheme i izravno utječu na ono što model generira. Smatrajte ih prompt engineeringom unutar sheme.
- Enum-i ograničavaju kategorije — bez njih model svaki put izmišlja nove vrijednosti, što je iznenađujuće zabavno dok ne pošaljete tu vrijednost u bazu.
- Polje
reasoning dolazi prvo. LLM-ovi generiraju lijevo-na-desno, pa stavljanjem rezoniranja prije odgovora ugrađujete chain-of-thought direktno u shemu.
OpenAI strict mode s Pydanticom: praktični primjer
S najnovijim OpenAI Python SDK-om, koristiti strukturirane izlaze je gotovo trivijalno:
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Ti si analitičar recenzija proizvoda."},
{"role": "user", "content": "Telefon je solidan, ali baterija traje samo 4 sata."}
],
response_format=ReviewAnalysis,
)
result: ReviewAnalysis = response.choices[0].message.parsed
print(result.sentiment) # Sentiment.NEGATIVE
print(result.confidence) # 0.85
print(result.key_phrases) # ['solidan telefon', 'baterija 4 sata', ...]
Što SDK zapravo radi pod haubom:
- Pretvara Pydantic klasu u JSON Schema-u, automatski postavlja
additionalProperties: false i sva polja u required.
- Šalje shemu uz
strict: true.
- OpenAI server kompajlira shemu u FSM (jednom — pa se kešira po hash-u).
- Tijekom generiranja, samo tokeni koji vode kroz valjani put imaju nenultu vjerojatnost.
- Nakon generiranja, SDK parsira JSON i instancira Pydantic objekt — koji još jednom validira semantiku.
Česte zamke s Pydanticom u strict mode-u
Strict mode ne podržava sve što Pydantic dopušta. Ovo je popis stvari na kojima ćete se vjerojatno spotaknuti barem jednom:
Optional[X] u korijenu modela ne radi — sva polja u strict mode-u moraju biti required. Zaobilazak: koristite X | None i postavite default na None, ali polje uvijek navedite u required.
Union[str, int, float] (anyOf) nije podržan — sužavajte tipove.
- Plain
dict bez specificiranog tipa ključeva i vrijednosti će pasti — koristite dict[str, str] ili definirajte ugniježđeni model.
- Maksimalno 5 razina ugnježđivanja i 100 svojstava ukupno (što zvuči kao puno, ali stigne se brže nego što mislite).
Anthropic Claude: strukturirani izlazi kroz Tool Use
Anthropic ne nudi izravan response_format parametar. Umjesto toga, svaki "alat" ima ulaznu shemu, pa definiranjem alata čija je shema vaš ciljani objekt prisiljavate Claude da pozove taj alat s validiranim argumentima. Trik je elegantan kad ga shvatite.
import anthropic
import json
client = anthropic.Anthropic()
review_tool = {
"name": "submit_review_analysis",
"description": "Pošalji strukturiranu analizu recenzije",
"input_schema": ReviewAnalysis.model_json_schema()
}
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=[review_tool],
tool_choice={"type": "tool", "name": "submit_review_analysis"},
messages=[
{"role": "user", "content": "Telefon je solidan, ali baterija traje samo 4 sata."}
]
)
tool_use = next(b for b in response.content if b.type == "tool_use")
result = ReviewAnalysis(**tool_use.input)
print(result.sentiment)
Ključ je tool_choice={"type": "tool", "name": "..."} — to prisiljava Claude da pozove točno taj alat. Bez toga, model može odlučiti odgovoriti tekstualno, što ne želite kad gradite produkcijski pipeline.
Petlja validacije i ponovnog pokušaja
Strukturirani izlazi sprečavaju strukturalne greške, ali ne i semantičke. Model i dalje može vratiti sintaktički ispravan JSON s besmislenim vrijednostima — primjerice confidence: 1.5 kad ste rekli da mora biti između 0 i 1, ili datum prije početka projekta.
I tu na scenu stupaju Pydantic validatori:
from pydantic import BaseModel, field_validator
from datetime import date
class ProjectMilestone(BaseModel):
project_start: date
milestone_date: date
description: str
@field_validator("milestone_date")
@classmethod
def must_be_after_start(cls, v, info):
start = info.data.get("project_start")
if start and v < start:
raise ValueError(
f"milestone_date {v} mora biti nakon project_start {start}"
)
return v
Kada validacija padne, vraćate poruku o grešci modelu i tražite ispravak. U praksi, ovaj retry pattern u produkciji povećava end-to-end pouzdanost s ~92% na preko 99%:
from pydantic import ValidationError
def extract_with_retry(messages, schema, max_retries=2):
for attempt in range(max_retries + 1):
response = client.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=messages,
response_format=schema,
)
try:
return response.choices[0].message.parsed
except ValidationError as e:
if attempt == max_retries:
raise
messages.append({"role": "assistant", "content": response.choices[0].message.content})
messages.append({
"role": "user",
"content": f"Validacija je pala s greskom: {e}. Ispravi i pokusaj ponovno."
})
raise RuntimeError("nedohvatljivo")
Instructor: standard za strukturirane izlaze u 2026
Instructor (preko 11 tisuća zvjezdica na GitHubu, oko 3 milijuna preuzimanja mjesečno) je biblioteka koja "patcha" standardne SDK-jeve i dodaje im response_model argument.
Glavne prednosti? Automatski retry s feedbackom validacije, jedinstveni API kroz 15+ pružatelja, te potpora za streaming Pydantic objekata. Iskreno, kad sam prvi put vidio koliko je trivijalno prebaciti se s OpenAI-ja na Anthropic, pomislio sam da je previše dobro da bi bilo istinito. Nije.
import instructor
from openai import OpenAI
client = instructor.from_openai(OpenAI())
result = client.chat.completions.create(
model="gpt-4o-2024-08-06",
response_model=ReviewAnalysis,
max_retries=3,
messages=[
{"role": "user", "content": "Telefon je solidan, ali baterija traje samo 4 sata."}
]
)
print(result.sentiment)
Promjena pružatelja je trivijalna — samo zamijenite klijent:
import instructor
import anthropic
client = instructor.from_anthropic(anthropic.Anthropic())
result = client.messages.create(
model="claude-sonnet-4-6",
response_model=ReviewAnalysis,
max_retries=3,
max_tokens=1024,
messages=[
{"role": "user", "content": "Telefon je solidan, ali baterija traje samo 4 sata."}
]
)
Streaming strukturiranih objekata
Instructor podržava parcijalno streamanje Pydantic objekata — kako tokeni stižu, dobivate parcijalne instance koje se postupno popunjavaju. Korisno za UI gdje korisnik vidi rezultate u realnom vremenu (i, da budem iskren, izgleda poprilično spektakularno na demo prezentacijama):
from instructor import Partial
stream = client.chat.completions.create(
model="gpt-4o-2024-08-06",
response_model=Partial[ReviewAnalysis],
stream=True,
messages=[{"role": "user", "content": review_text}]
)
for partial in stream:
print(partial.model_dump())
Najbolje prakse dizajna shema
1. Rezoniranje prvo
Stavite polje reasoning ili analysis prije konačnog odgovora. LLM-ovi generiraju lijevo-na-desno; ako prvo dolazi category, model bira kategoriju i tek onda izmišlja opravdanje. Ako prvo dolazi reasoning, model prvo razmišlja, a tek onda zaključuje. To je chain-of-thought ugrađen u shemu, bez dodatnih API poziva. Jeftino i moćno.
2. Opisi su prompt
Polje description u Pydanticu završava u JSON Schemi koja se šalje modelu. Iskoristite to:
class Invoice(BaseModel):
total: float = Field(
description=(
"Ukupni iznos racuna u eurima, bez PDV-a. "
"Ako je iznos u drugoj valuti, konvertiraj koristeci tecaj iz teksta."
)
)
3. Enum-i, ne stringovi
Nikada ne tražite "kategoriju" kao slobodni string. Model će izmisliti nove kategorije svaki put, garantirano. Koristite Enum ili Literal:
from typing import Literal
class Ticket(BaseModel):
priority: Literal["low", "medium", "high", "critical"]
department: Literal["billing", "technical", "sales", "other"]
4. Ograničite ugnježđivanje
Maksimalno 2-3 razine. Duboko ugniježđene sheme povećavaju stopu grešaka i usporavaju kompilaciju FSM-a na strani pružatelja. Nema potrebe za drvetom dubine 6 — to nije Backend-ova osvjeta nad Frontend developerima.
5. Razdvojite ekstrakciju i kategorizaciju
Umjesto jedne velike sheme s 30 polja, podijelite u dvije faze: prvo izvucite sirove podatke, zatim ih klasificirajte zasebnim pozivom. Manje šanse za halucinaciju, jeftinije retry-eve, lakše debugiranje. Probao sam oba pristupa na ekstrakciji ugovora — dvofazni je pobijedio za otprilike 8 postotnih bodova točnosti.
Produkcijski obrasci
Promatranje i metrike
U produkciji morate pratiti barem ovo:
- Stopu validacijskih grešaka po shemi — porast signalizira degradaciju modela ili promjenu distribucije ulaza.
- Broj retry-a po pozivu — više od 1 prosječno znači da shema ne odgovara stvarnim podacima.
- Latenciju — strukturirani izlazi dodaju 50-200 ms na prvi token zbog kompilacije FSM-a (kešira se nakon prvog poziva).
- Trošak po pozivu — opisi i validatori su besplatni, ali prevelike sheme troše izlazne tokene.
Refusals kao prvoklasna greška
OpenAI može vratiti refusal objekt umjesto JSON-a kada model odbije odgovoriti zbog sigurnosnih razloga. Provjerite to prije parsiranja — inače ćete loviti čudne bugove tjednima:
message = response.choices[0].message
if message.refusal:
log_refusal(message.refusal)
raise ContentPolicyError(message.refusal)
result = message.parsed
Verzioniranje shema
Sheme su API ugovor s podacima. Promjena imena polja ili tipa je breaking change, koliko god to izgledalo nedužno. Verzionirajte sheme i čuvajte stare verzije za backfill historijskih podataka:
class ReviewAnalysisV1(BaseModel):
sentiment: Sentiment
confidence: float
class ReviewAnalysisV2(BaseModel):
reasoning: str
sentiment: Sentiment
confidence: float
key_phrases: list[str]
Strukturirani izlazi u agentskim tijekovima
U LangGraph i sličnim okvirima, strukturirani izlazi su kritični za pouzdane tranzicije stanja. Bez stroge sheme između čvorova, jedan nedostajući ključ može srušiti lanac od deset agenata. (Da, prošao sam to. Ne preporučam.) Strukturirani izlazi eliminiraju cijelu klasu bugova gdje agenti generiraju sintaktički ispravno, ali semantički nevaljano stanje.
PydanticAI (od istog tima koji održava Pydantic) ide korak dalje — tretira agente kao Pydantic aplikacije sa sigurnim tipovima na ulazu i izlazu, dependency injectionom i nativnom potporom za strict mode.
from pydantic_ai import Agent
agent = Agent(
"openai:gpt-4o-2024-08-06",
result_type=ReviewAnalysis,
system_prompt="Ti si analiticar recenzija proizvoda."
)
result = agent.run_sync("Telefon je solidan, ali baterija traje samo 4 sata.")
print(result.data.sentiment)
Kontrolni popis za produkciju u 2026
- Koristite strict mode (OpenAI) ili Tool Use s
tool_choice (Anthropic) — nikada plain JSON Mode.
- Definirajte sheme u Pydanticu (Python) ili Zod (TypeScript). Tip je dokument.
- Stavite
reasoning polje prije odlučujućih polja.
- Koristite
Literal i Enum za sve kategorije s fiksnim skupom vrijednosti.
- Dodajte semantičke validatore (
field_validator, model_validator).
- Implementirajte retry s feedbackom validacijskih grešaka — 2-3 pokušaja podiže pouzdanost preko 99%.
- Tretirajte refusals kao posebnu klasu grešaka.
- Pratite stopu validacijskih grešaka po shemi kao SLO metriku.
- Verzionirajte sheme; nikada ne mijenjajte postojeću u backwards-incompatible smjeru.
Često postavljana pitanja (FAQ)
Smanjuje li strict mode kvalitetu odgovora?
Mjerenja iz 2026. pokazuju da strict mode ne degradira kvalitetu na zadacima koji su prirodno strukturirani (ekstrakcija, klasifikacija, generiranje strukturiranih artefakata). Na zadacima slobodnog rezoniranja može biti blagi pad ako shema nije dobro dizajnirana — primjerice ako prisilite kratak summary string bez prethodnog reasoning polja. Rješenje je dizajn sheme, ne odustajanje od strict mode-a.
Kada koristiti Instructor, a kada nativni SDK?
Ako koristite jedan pružatelj i sheme su jednostavne, nativni SDK parse() je dovoljan i ima manje ovisnosti. Prijeđite na Instructor kada trebate automatske retry-eve na validacijske greške, kada radite s više pružatelja, ili kada želite parcijalno streamanje Pydantic objekata. Pravilo je jednostavno: započnite s nativnim SDK-om, pređite na Instructor kada je kompleksnost opravdana.
Rade li strukturirani izlazi s lokalnim modelima?
Da, kroz biblioteke kao što su Outlines, XGrammar i llama.cpp grammar mode. One implementiraju constrained decoding na klijentskoj strani — svaki token koji ne vodi kroz valjani put kroz gramatiku ima maskiranu vjerojatnost. Performanse su nešto sporije od cloud rješenja jer se FSM kompajlira lokalno, ali pouzdanost je ista.
Što je s podrškom za nullable polja u strict mode-u?
OpenAI strict mode zahtijeva da sva polja budu u required popisu. Da biste imali "opcionalno" polje, morate eksplicitno dopustiti null kao tip: field: str | None = Field(default=None). Pydantic će ovo prevesti u {"type": ["string", "null"]}, a polje ostaje u required. Model može vratiti null kada nema vrijednosti, što semantički odgovara opcionalnosti.
Koliko košta strukturirani izlaz u odnosu na običan?
Tokenski trošak je gotovo identičan — JSON Schema se ne broji u inputu jer se kešira po hash-u. Mali dodatni izlazni trošak dolazi od JSON ključeva i interpunkcije (oko 10-15% više izlaznih tokena nego ekvivalent u prostom tekstu). U usporedbi s troškom retry-a kod neispravnog izlaza (cijeli novi poziv), strukturirani izlazi u praksi smanjuju ukupni trošak za 20-40%. Win-win, kako se to kaže.