Kiedy pierwszy raz integrowałem LLM z Laravelem dla klienta, napisałem to, co pisze każdy w pierwszym podejściu - prompt typu "zwróć odpowiedź jako JSON z polami x, y, z", potem json_decode i nadzieja. Działało w 80% przypadków. Pozostałe 20% wracało jako "Oczywiście! Oto JSON: ```json {...} ```" albo z polskim cudzysłowem zamiast prostego, albo z trailing comma, albo z dodatkowym akapitem wyjaśniającym. Każdy taki przypadek lądował w logu Sentry jako wyjątek, a klient na demo widział pustą tabelę.
Po dwóch latach integrowania LLM z bazami danych i panelami Filamenta mam jasne stanowisko: jeśli wynik modelu ma trafić do insert, do API albo do logiki aplikacji, parsowanie regexem jest błędem inżynierskim. Nie dlatego, że jest brzydkie - dlatego, że istnieją lepsze mechanizmy, dojrzałe od kilkunastu miesięcy, dające failure rate poniżej 0.1%. W tym wpisie pokażę, jak twardo wymusić strukturę z LLM. W Pythonie, w PHP, z Anthropic, z OpenAI, z walidacją runtime i z retry, kiedy mimo wszystko coś pójdzie nie tak.
Dlaczego model zwraca "almost JSON"
Model nie ma świadomości, że odpowiedź ma być parsowalna. Trenowany jest na całej masie tekstów, w których JSON pojawia się w trzech kontekstach - jako blok w dokumentacji (z markdownowym ogrodzeniem), jako fragment w tutorialu (z komentarzem wyjaśniającym), albo jako surowa struktura. Bez ograniczeń, model losuje z tych trzech źródeł zgodnie ze statystyką swojego treningu.
Najczęstsze tryby porażki, które widziałem w produkcji:
- Markdown wrapper -
```json\n{...}\n```. Najbardziej znany. Łatwo zdjąć regexem, ale to leczenie objawu. - Prefix konwersacyjny - "Oczywiście, oto wynik analizy: {...}". Czasem prefix jest po polsku, czasem po angielsku, czasem zaczyna się od emoji.
- Trailing notes - JSON, a po nim akapit "Mam nadzieję, że to pomocne. Daj znać, jeśli chcesz inny format".
- Ucięte cudzysłowy - kiedy model trafia na content z cudzysłowami w środku stringa i nie escapuje ich.
- Zmienne klucze - schema mówi
customer_name, model czasem zwracacustomerName, czasemname, czasem dorzuca dodatkowefull_name. - Halucynowane typy - pole
pricejako"129,99 PLN"zamiast129.99. Poledatejako"15 marca"zamiast ISO 8601.
Wszystkie te problemy mają jedno wspólne źródło - model nie został zmuszony do trzymania się struktury. Mówisz mu "zwróć JSON", on szczerze próbuje, ale jego sampler ma swobodę generowania dowolnego tokena. I czasem ta swoboda go zgubi.
Trzy podejścia historycznie
Jest 2026 i są trzy generacje rozwiązań. Pokażę każdą, bo wciąż widzę wszystkie trzy w produkcji - czasem mieszane w jednym projekcie.
Generacja 1: parsowanie regexem
Najbardziej prymitywna obrona. Model zwraca string, my szukamy w nim JSON-a:
import re
import json
def extract_json(text: str) -> dict:
match = re.search(r'\{.*\}', text, re.DOTALL)
if not match:
raise ValueError("Brak JSON w odpowiedzi")
return json.loads(match.group())
Problem - łapie pierwszy nawias klamrowy do ostatniego, więc jeśli model napisze "wynik {a: 1} i drugi {b: 2}", dostajesz krzaki. Plus nie radzi sobie z markdown wrapperem, escapowaniem, ani z modelem, który odpowiedział samym tekstem bez JSON-a w ogóle.
Generacja 2: try-catch i instrukcja w promptcie
Krok dalej - wyjmujemy markdown, próbujemy json.loads, w razie błędu prosimy model jeszcze raz:
def parse_with_retry(client, messages, max_retries=3):
for attempt in range(max_retries):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=messages,
)
text = response.content[0].text.strip()
text = re.sub(r'^```(?:json)?\s*|\s*```$', '', text).strip()
try:
return json.loads(text)
except json.JSONDecodeError as e:
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": f"Twoja odpowiedź nie jest poprawnym JSON: {e}. Zwróć sam JSON, bez markdown, bez komentarzy."
})
raise RuntimeError("Nie udało się uzyskać poprawnego JSON po {max_retries} próbach")
Lepsze, ale wciąż drogie - każdy retry to drugi request. W skali setek zadań dziennie generuje to dodatkowe koszty i opóźnienia. I wciąż nie gwarantuje, że pola w JSON będą zgodne ze schemą.
Generacja 3: constrained decoding
Dzisiejszy standard. Sampler modelu jest ograniczony tak, że może wygenerować tylko tokeny zgodne ze schematem. Jeśli schema wymaga {"price": number}, model fizycznie nie może wyemitować "price": "129 PLN" - sampler odrzuci taki token i wybierze następny w kolejności prawdopodobieństwa, zgodny ze schemą.
Anthropic wprowadził to przez tool use, OpenAI przez response_format: json_schema. Failure rate spada do poziomu, w którym przestaje być argumentem przy projektowaniu systemu. To podejście pokażę dalej w trzech wariantach.
Anthropic - tool use jako structured output
Anthropic nie ma osobnego flag-a "structured output". Ma za to tools, które działa identycznie - definiujesz narzędzie z parametrami w JSON Schema, wymuszasz tool_choice, dostajesz strukturę zgodną ze schemą.
import anthropic
client = anthropic.Anthropic()
extract_invoice_tool = {
"name": "extract_invoice",
"description": "Wyciąga dane z treści faktury",
"input_schema": {
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"issue_date": {"type": "string", "format": "date"},
"seller_nip": {"type": "string", "pattern": "^[0-9]{10}$"},
"buyer_nip": {"type": "string", "pattern": "^[0-9]{10}$"},
"total_net": {"type": "number"},
"total_gross": {"type": "number"},
"currency": {"type": "string", "enum": ["PLN", "EUR", "USD"]},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"quantity": {"type": "number"},
"unit_price_net": {"type": "number"}
},
"required": ["name", "quantity", "unit_price_net"]
}
}
},
"required": ["invoice_number", "issue_date", "total_net", "total_gross", "currency"]
}
}
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=[extract_invoice_tool],
tool_choice={"type": "tool", "name": "extract_invoice"},
messages=[{
"role": "user",
"content": f"Wyciągnij dane z faktury:\n\n{invoice_text}"
}]
)
invoice_data = response.content[0].input
tool_choice={"type": "tool", "name": "extract_invoice"} mówi modelowi - musisz wywołać dokładnie to narzędzie, nie możesz odpowiedzieć tekstem. Wynik dostajesz jako response.content[0].input - już sparsowany słownik Pythona, zgodny ze schemą.
Ten sam wzorzec stosuję u siebie w pipeline'ie analizy webinarów inwestycyjnych. Whisper transkrybuje 230 godzin rocznie, Claude Sonnet 4.6 wyciąga z transkryptu strukturę "ticker, kierunek, teza, poziomy support/resistance, target, time horizon" - przez tool use, nie przez prompt "zwróć JSON". Failure rate w produkcji od listopada 2025: 0.04%, czyli kilka błędów na 10 tysięcy zadań. Te kilka to były długie transkrypty obcięte limitem tokenów, nie problemy z parsowaniem.
OpenAI - response_format z json_schema
OpenAI od sierpnia 2024 ma natywny structured output przez response_format z typem json_schema. Jeśli schema ma flagę strict: true, mają gwarancję 100% zgodności (na ich modelach gpt-4o-2024-08-06 i nowszych).
from openai import OpenAI
import json
client = OpenAI()
invoice_schema = {
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"issue_date": {"type": "string"},
"total_gross": {"type": "number"},
"currency": {"type": "string", "enum": ["PLN", "EUR", "USD"]}
},
"required": ["invoice_number", "issue_date", "total_gross", "currency"],
"additionalProperties": False
}
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Wyciągasz dane z faktur."},
{"role": "user", "content": invoice_text}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_data",
"strict": True,
"schema": invoice_schema
}
}
)
invoice = json.loads(response.choices[0].message.content)
Pułapka, w którą sam wpadłem - strict: true wymaga, żeby required zawierało wszystkie klucze ze schematu, a additionalProperties było false. Jeśli chcesz mieć opcjonalne pole, zrób je nullable - {"type": ["string", "null"]} i wpisz do required. To kontrintuicyjne, ale zostało zaprojektowane tak, żeby model nie miał wyboru pomijać kluczy.
Instructor + Pydantic - mój domyślny wybór w Pythonie
Instructor to biblioteka, która opakowuje Anthropic, OpenAI i innych providerów wokół Pydantic. Definiujesz model danych jako klasę Pythonową, a Instructor sam generuje schema, woła API z constrained decoding i waliduje wynik. Jeśli walidacja padnie, robi automatyczny retry z opisem błędu.
from pydantic import BaseModel, Field, field_validator
from typing import Literal
from datetime import date
import instructor
from anthropic import Anthropic
class InvoiceItem(BaseModel):
name: str
quantity: float = Field(gt=0)
unit_price_net: float = Field(ge=0)
class Invoice(BaseModel):
invoice_number: str
issue_date: date
seller_nip: str = Field(pattern=r"^[0-9]{10}$")
buyer_nip: str = Field(pattern=r"^[0-9]{10}$")
total_net: float = Field(ge=0)
total_gross: float = Field(ge=0)
currency: Literal["PLN", "EUR", "USD"]
items: list[InvoiceItem]
@field_validator("total_gross")
@classmethod
def gross_greater_than_net(cls, v, info):
if "total_net" in info.data and v < info.data["total_net"]:
raise ValueError("total_gross nie może być mniejsze od total_net")
return v
client = instructor.from_anthropic(Anthropic())
invoice = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{"role": "user", "content": invoice_text}],
response_model=Invoice,
max_retries=3,
)
Co tu się dzieje - Instructor bierze klasę Invoice, generuje JSON Schema, dorzuca jako tool do requesta, a wynik mapuje na instancję Pydantic. Jeśli walidator gross_greater_than_net rzuci błąd, Instructor wysyła nowy request z opisem "twoja poprzednia odpowiedź była niepoprawna z powodu X, popraw". Robi to do trzech razy, potem rzuca wyjątek.
To jest wzorzec, którego używam w 90% integracji Pythonowych. Daje mi typing w IDE, walidację runtime, retry za darmo, i jeden plik klasy zamiast trzech (schema, parser, walidator). U klientów też - jeśli dział księgowości potrzebuje wyciągać dane z 200 faktur miesięcznie, dostają pipeline w którym Invoice jest źródłem prawdy. Zmiana pola = zmiana w jednej klasie.
W PHP i Laravel - jak to robię u klientów
W PHP nie ma odpowiednika Instructor o tej samej dojrzałości. Jest openai-php/laravel, jest spatie/laravel-data, są próby portu Instructor (np. cognesy/instructor-php) - żaden nie jest tak ugruntowany jak ekosystem Pythona. W praktyce u klientów buduję własną cienką warstwę:
<?php
namespace App\Services\LLM;
use App\Data\InvoiceData;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\Facades\Validator;
class StructuredExtractor
{
public function __construct(
private HttpFactory $http,
private string $apiKey,
) {}
public function extractInvoice(string $invoiceText): InvoiceData
{
$tool = [
'name' => 'extract_invoice',
'description' => 'Wyciąga dane z faktury',
'input_schema' => [
'type' => 'object',
'properties' => [
'invoice_number' => ['type' => 'string'],
'issue_date' => ['type' => 'string', 'format' => 'date'],
'total_gross' => ['type' => 'number'],
'currency' => ['type' => 'string', 'enum' => ['PLN', 'EUR', 'USD']],
],
'required' => ['invoice_number', 'issue_date', 'total_gross', 'currency'],
],
];
$response = $this->http->withHeaders([
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-6',
'max_tokens' => 4096,
'tools' => [$tool],
'tool_choice' => ['type' => 'tool', 'name' => 'extract_invoice'],
'messages' => [
['role' => 'user', 'content' => $invoiceText],
],
])->throw()->json();
$payload = $response['content'][0]['input'];
$validator = Validator::make($payload, [
'invoice_number' => ['required', 'string'],
'issue_date' => ['required', 'date'],
'total_gross' => ['required', 'numeric', 'min:0'],
'currency' => ['required', 'in:PLN,EUR,USD'],
]);
$validator->validate();
return InvoiceData::from($payload);
}
}
Konwencja, którą trzymam:
- Schema JSON jest źródłem prawdy. Zapisuję ją w
config/llm/schemas/jako PHP array i serwuję do każdego requesta. - Walidacja Laravelowa jest drugą linią obrony - constrained decoding daje mi 99.9%, a walidator wyłapuje resztę i rzuca
ValidationException, który Laravel obsługuje natywnie. - Wynik mapuję na DTO przez
spatie/laravel-data. Dalsza logika aplikacji dostaje typowany obiekt, nie tablicę. - Retry robię na poziomie HTTP (Saloon albo native HTTP client Laravela z
retry()), nie na poziomie walidacji - jeśli walidacja padnie po constrained decoding, to znaczy że schema jest zła, nie że trzeba spróbować jeszcze raz.
Case z mojej praktyki - panel Filamenta dla klienta z branży medycznej, w którym lekarze wklejają opis przypadku, a system wyciąga ICD-10, kody procedur, czas trwania wizyty, leki. Schema ma 14 pól, połowa jest enumami, niektóre to nested obiekty. Failure rate od stycznia 2026: 7 błędów walidacji na 8400 wywołań. Wszystkie 7 to były przypadki, w których lekarz wpisał coś po niemiecku, a model próbował zinterpretować, ale wpadł w cykl retry.
Pułapki, które złapałem w produkcji
Nullable vs optional
Najczęstszy błąd początkujących. Schema mówi "pole jest opcjonalne", model czasem je dodaje, czasem nie. Konsumer aplikacyjny czasem dostaje null, czasem brak klucza, czasem pusty string. Trzy stany zamiast dwóch.
Mój standard: nigdy nie używam optional w schema. Wszystkie pola są w required, a "opcjonalność" wyrażam przez nullable:
{
"type": "object",
"properties": {
"discount": {"type": ["number", "null"]}
},
"required": ["discount"]
}
Konsumer wie, że klucz zawsze będzie. Wartość albo numer, albo null. Dwa stany.
Enum vs string
Jeśli pole ma skończony zbiór wartości - kategoria zgłoszenia, status, język, currency - zawsze używaj enum. Bez enum model będzie improwizował: "high_priority" zamiast "high", "po polsku" zamiast "pl", "PLN " z trailing space. Z enum sampler odrzuca tokeny spoza listy.
Deep nesting drops accuracy
Trzy poziomy zagnieżdżenia to granica, po której model zaczyna gubić się w schemach. Jeśli masz coś typu customer.addresses[0].billing.zipCode, lepiej spłaszczyć do customer_billing_zip. Sprawdziłem to empirycznie - na schemacie z 4 poziomami zagnieżdżenia accuracy spadała o 6-8% w stosunku do flat schema z tymi samymi polami.
Description nie jest ozdobą
Każde pole w schema ma opcjonalny description. To jest twoja okazja, żeby powiedzieć modelowi, czego dokładnie chcesz - nie tylko jaki typ, ale jak go interpretować. "issue_date": {"type": "string", "format": "date", "description": "Data wystawienia w formacie YYYY-MM-DD, nie data dostawy ani data płatności"}. Bez description model zgadnie - i czasem zgadnie źle.
Performance - koszt tokenów, latencja, retry
Constrained decoding nie jest darmowe. Trzy rzeczy, które warto mieć z tyłu głowy:
Schema kosztuje tokeny. Idzie do każdego requesta jako część kontekstu. Schema z 20 polami, każde z description, to około 800-1200 tokenów. W skali tysięcy wywołań miesięcznie ma to znaczenie. Prompt caching to ratuje - jeśli wołasz tę samą schemę przez cały dzień, Anthropic cache'uje ją z 90% rabatem od drugiego wywołania.
Latencja jest wyższa. Sampler musi sprawdzać każdy token przeciwko schemie. W praktyce, na Sonnet 4.6, widzę 10-15% wzrost latencji w stosunku do free-form output dla tej samej długości odpowiedzi. Dla zadań batch nie ma znaczenia. Dla synchronicznego UX (chat odpowiada w czasie rzeczywistym) - planuj.
Retry z walidacją to drugi request. Jeśli używasz Instructor z max_retries=3 i twoja schema ma walidator, który czasem rzuca błąd, w worst case płacisz 3x. Monitoruj, nie ustawiaj retry na ślepo.
Kiedy NIE używać structured output
Lista jest krótka, ale wyraźna:
- Kreatywne pisanie. Posty na bloga, copy reklamowe, eseje. Schema będzie tu więzieniem.
- Długie outputy o nieprzewidywalnej strukturze. Raport analityczny z zmienną liczbą sekcji, dyskusja z otwartym końcem. Schema da ci ramę, ale model będzie ją wypełniał na siłę.
- Pierwsza iteracja prototypu, którego struktury jeszcze nie znasz. Najpierw porozmawiaj z modelem free-form, zobacz jakie pola są naturalne, potem zamknij to w schemę.
- Zadania, w których "myślenie na głos" jest częścią wartości. Chain-of-thought reasoning u Sonnet/Opus daje lepsze wyniki, kiedy model ma swobodę. Możesz to później skomplikować i puścić drugi request, który wynik sformatuje, ale to jest osobna decyzja.
W mojej praktyce te przypadki to może 20% integracji. Reszta to "weź wejście, zwróć ustrukturyzowane wyjście, wsadź do bazy / API / pipeline". I dla tej reszty structured output jest standardem.
Co z tego zabrać
Jeśli wciąż parsujesz JSON regexem albo robisz json_decode z try/catch i nadzieją - przesiądź się. To jest niski próg wejścia, dający dramatyczną poprawę reliability. W Pythonie - Instructor + Pydantic, w PHP - własna abstrakcja na tool use Anthropica albo response_format OpenAI, plus walidacja Laravelowa jako safety net.
Schemę projektuj jak schemat bazy - wszystkie pola required, nullable zamiast optional, enum dla skończonych wartości, flat zamiast deep nested. Description nie jest komentarzem, jest częścią kontraktu z modelem.
I monitoruj failure rate. Jeśli widzisz więcej niż 0.5% failed validations po constrained decoding, problem jest w schemie, nie w modelu. Wróć do projektowania, nie ustawiaj retry na 10.
Pisałem o tym, jak strukturyzowany kontekst walczy z halucynacjami w protokole zero halucynacji - structured output jest jego mechaniczną wersją. Tam reguły, tu ograniczenia samplera. Jedno i drugie razem daje system, który nie improwizuje. A kiedy zwracany JSON ma jeszcze trafić do API albo bazy w setupie typu RAG chatbot firmowy, structured output staje się tym kawałkiem, który spina LLM ze światem deterministycznym.
Integrujesz LLM z bazą danych albo API i wciąż walczysz z parsowaniem? Napisz do mnie - pokażę wzorce structured output które działają w produkcji.