Wypuściłeś feature, który woła GPT-4, żeby wyciągnąć strukturalne dane z przesłanych przez użytkowników faktur.
W dev działa idealnie — model zwraca czysty obiekt JSON za każdym razem. Potem na produkcji,
o 2 w nocy, dostajesz alert z Sentry: JSON.parse: unexpected token. Model zdecydował się poprzedzić swoją
odpowiedź tekstem "Sure! Here's the JSON you asked for:" przed właściwym payloadem. Tydzień później, ten sam feature,
inny bug: model zwraca totalAmount zamiast total_amount, a twój
downstreamowy zapis do bazy po cichu gubi pole. Jeśli próbowałeś radzić sobie z niezawodnością wyjścia LLM
przez kombinacje w promptach, OpenAI Structured Outputs
to fix, na który czekałeś.
Structured Outputs, wydane przez OpenAI w sierpniu 2024, pozwala ci podać
JSON Schema
przez parametr response_format i otrzymać gwarantowanie poprawną odpowiedź, która
dokładnie pasuje do tej schemy. To coś innego niż starszy tryb JSON ({"type": "json_object"}),
który gwarantował tylko, że wyjście to poprawny JSON — nie że pasuje do jakiegoś konkretnego kształtu. To też coś innego
niż function calling, które kieruje wyjście modelu do wywołania narzędzia, ale dokłada własną warstwę ceregieli.
Structured Outputs to najczystsza droga: opisz kształt, który chcesz, dostaniesz dokładnie ten kształt, za każdym razem.
Pod maską OpenAI używa constrained decoding — próbkowanie tokenów modelu jest prowadzone przez twoją schemę, więc
model dosłownie nie może wyprodukować niepoprawnej odpowiedzi.
Twoje pierwsze Structured Output
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "user",
"content": "Extract the vendor name, invoice number, and total amount from this text: "
"Invoice #INV-2024-0892 from Acme Supplies Ltd. Total due: $1,450.00"
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
}
}
)
data = json.loads(response.choices[0].message.content)
print(data)
# {"vendor_name": "Acme Supplies Ltd", "invoice_number": "INV-2024-0892", "total_amount": 1450.0}Trzy rzeczy warte uwagi. Po pierwsze, model to gpt-4o-2024-08-06 — Structured Outputs
wymaga modelu, który to explicite wspiera (snapshot -2024-08-06 lub nowszy dla GPT-4o, albo
gpt-4o-mini). Po drugie, response_format.type to "json_schema", a nie
"json_object". Po trzecie, "strict": True to to, co daje ci gwarancję —
bez tego wracasz do trybu best-effort. Pole name to etykieta, którą widzi model;
nie wpływa na parsowanie, ale sprawia, że twoje logi API są czytelne.
Projektowanie schemy JSON
Oto bardziej realistyczna schema dla zadania ekstrakcji katalogu produktów — takiego, jakiego użyłbyś do wyciągania strukturalnych danych z niestrukturalnych opisów produktów, ogłoszeń e-commerce albo datasheetów PDF. Użyj Generatora JSON Schema, żeby zbudować i zwalidować schemę wizualnie przed wpięciem jej w swoje wywołania API.
{
"type": "object",
"properties": {
"product_name": {
"type": "string",
"description": "The full commercial name of the product"
},
"sku": {
"type": "string",
"description": "Stock keeping unit identifier"
},
"price_usd": {
"type": "number",
"description": "Price in US dollars, numeric only"
},
"in_stock": {
"type": "boolean"
},
"categories": {
"type": "array",
"items": { "type": "string" }
},
"dimensions": {
"type": "object",
"properties": {
"width_cm": { "type": "number" },
"height_cm": { "type": "number" },
"depth_cm": { "type": "number" }
},
"required": ["width_cm", "height_cm", "depth_cm"],
"additionalProperties": false
}
},
"required": [
"product_name", "sku", "price_usd",
"in_stock", "categories", "dimensions"
],
"additionalProperties": false
}- Wszystkie właściwości muszą być w
requiredw trybie strict. Nie możesz mieć opcjonalnych pól. Jeśli pole może nie istnieć w źródłowych danych, użyj typu unii:{"type": ["string", "null"]}i zawsze umieść je wrequired. additionalPropertiesmusi byćfalsena każdym poziomie obiektu. To stosuje się rekurencyjnie — twoje zagnieżdżone obiekty też tego potrzebują, nie tylko root.- Typy wspierane w trybie strict:
string,number,integer,boolean,null,array,object. Unie typów (["string", "null"]) są dozwolone. - Brak
$refani schem rekurencyjnych w trybie strict. Wszystko musi być inline. Jeśli potrzebujesz definicji do ponownego użycia, skopiuj ją. - Dodawaj pola
descriptionhojnie. Model je czyta. Napisanie "Price in US dollars, numeric only — do not include currency symbols" daje ci czystsze wyjście niż liczenie na to, że model zgadnie. - Enumy działają.
{"type": "string", "enum": ["pending", "shipped", "delivered"]}jest w pełni wspierane, a model wyemituje tylko jedną z tych trzech wartości.
Tryb strict vs non-strict
# Strict mode — guaranteed conformance, tighter schema rules
response_format_strict = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": True, # <-- the key flag
"schema": product_schema
}
}
# Non-strict — more schema flexibility, best-effort conformance
response_format_lenient = {
"type": "json_schema",
"json_schema": {
"name": "product_extract",
"strict": False,
"schema": product_schema
}
}Przy strict: True OpenAI preprocessuje twoją schemę przy pierwszym użyciu
i cachuje constrained decoder. Pierwsze wywołanie z nową schemą trwa trochę dłużej; kolejne
wywołania z tą samą schemą są szybkie. Co dostajesz w zamian: wyjście modelu jest strukturalnie
gwarantowane — możesz wywołać json.loads(), a potem od razu dostać się do pól bez
defensywnych checków. Co oddajesz: $ref, anyOf między wariantami strukturalnymi,
i schemy rekurencyjne nie są wspierane. Tryb non-strict akceptuje szerszy zakres featerów
JSON Schema,
ale cofa się do best-effort — model próbuje trzymać się schemy, ale nie jest ograniczony
na poziomie tokenów. Dla produkcyjnych pipeline'ów ekstrakcji zawsze używaj trybu strict. Ograniczenia schemy
są do opanowania, kiedy je zrozumiesz.
Zagnieżdżone obiekty i tablice
Zagnieżdżone struktury działają dobrze, ale każdy zagnieżdżony obiekt potrzebuje własnego "additionalProperties": false
i własnej tablicy "required" listującej wszystkie właściwości. Częsty błąd to zastosowanie strict rules
do root obiektu i zapomnienie o dzieciach — OpenAI odrzuci schemę z błędem walidacji.
from openai import OpenAI
import json
client = OpenAI()
order_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"customer": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["name", "email"],
"additionalProperties": False # required on nested objects too
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "integer"},
"unit_price": {"type": "number"}
},
"required": ["description", "quantity", "unit_price"],
"additionalProperties": False # required on array item schemas too
}
},
"total_usd": {"type": "number"}
},
"required": ["order_id", "customer", "line_items", "total_usd"],
"additionalProperties": False
}
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{
"role": "user",
"content": (
"Parse this order: Order #ORD-5531 for Jane Smith ([email protected]). "
"2x Wireless Keyboard at $49.99 each, 1x USB Hub at $29.99. Total: $129.97"
)
}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "order_extract",
"strict": True,
"schema": order_schema
}
}
)
order = json.loads(response.choices[0].message.content)
for item in order["line_items"]:
print(f"${item['unit_price']:.2f} x{item['quantity']} {item['description']}")Obsługa refusali
Nawet ze Structured Outputs model może odmówić odpowiedzi — typowo, kiedy prompt
triggeruje content policy (prośba o wyciągnięcie danych z czegoś szkodliwego). Kiedy tak się stanie,
finish_reason to "stop", ale message.content to null,
a message.refusal zawiera tekst odmowy. Jeśli na to nie sprawdzisz, dostaniesz
AttributeError, kiedy spróbujesz wywołać json.loads(None). Uważaj też na
finish_reason == "length" — jeśli odpowiedź została ucięta przez max_tokens,
JSON będzie niekompletny i nieparsowalny niezależnie od Structured Outputs.
import json
from openai import OpenAI
client = OpenAI()
def extract_invoice(raw_text: str) -> dict | None:
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": f"Extract invoice fields: {raw_text}"}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice_extract",
"strict": True,
"schema": invoice_schema
}
},
max_tokens=1024
)
choice = response.choices[0]
if choice.finish_reason == "length":
raise ValueError("Response truncated — increase max_tokens or simplify your schema")
if choice.message.refusal:
# Model refused to answer — log and return None rather than crashing
print(f"Model refused: {choice.message.refusal}")
return None
return json.loads(choice.message.content)
result = extract_invoice("Invoice #2024-441 from BuildRight Inc., due $3,200 by Dec 15")
if result:
print(result["vendor_name"], result["total_amount"])Użycie tej samej schemy do walidacji
Jeden niedoceniany wzorzec: użyj tej samej JSON Schema,
którą przekazujesz do OpenAI, żeby także walidować dane wchodzące z innych źródeł — webhooków, uploadów plików,
API third-party. Daje ci to pojedyncze źródło prawdy dla kształtu twoich danych. W Pythonie użyj
biblioteki jsonschema. W Node.js użyj Ajv.
Możesz też wkleić swoją schemę do Walidatora JSON, żeby zrobić szybki
ręczny sanity check bez pisania kodu.
import json
import jsonschema
from jsonschema import validate, ValidationError
# The same schema used in your OpenAI call
invoice_schema = {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"invoice_number": {"type": "string"},
"total_amount": {"type": "number"}
},
"required": ["vendor_name", "invoice_number", "total_amount"],
"additionalProperties": False
}
def validate_invoice(data: dict) -> bool:
try:
validate(instance=data, schema=invoice_schema)
return True
except ValidationError as e:
print(f"Validation failed: {e.message}")
print(f" Path: {' -> '.join(str(p) for p in e.path)}")
return False
# Validate a payload from a webhook — same schema, zero extra work
webhook_payload = json.loads(request_body)
if not validate_invoice(webhook_payload):
return HTTPResponse(status=400, body="Invalid invoice payload")
# Validate the OpenAI output too, for belt-and-suspenders safety
llm_output = json.loads(openai_response.choices[0].message.content)
assert validate_invoice(llm_output), "LLM output failed schema validation — check schema definition"
print(f"Processing invoice {llm_output['invoice_number']} for ${llm_output['total_amount']}")response_format w OpenAI.Wersja JavaScript / Node.js
SDK OpenAI dla Node.js
lustruje API Pythona niemal dokładnie. Główna różnica to to, że strict siedzi wewnątrz
obiektu json_schema w ten sam sposób, a odpowiedź parsujesz przez JSON.parse().
Walidacja z Ajv to node'owy
odpowiednik pythonowego jsonschema — jest szybsza i ma świetne wsparcie TypeScriptu.
import OpenAI from "openai";
import Ajv from "ajv";
const client = new OpenAI();
const ajv = new Ajv();
const invoiceSchema = {
type: "object",
properties: {
vendor_name: { type: "string" },
invoice_number: { type: "string" },
total_amount: { type: "number" },
line_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
amount: { type: "number" }
},
required: ["description", "amount"],
additionalProperties: false
}
}
},
required: ["vendor_name", "invoice_number", "total_amount", "line_items"],
additionalProperties: false
};
const validateInvoice = ajv.compile(invoiceSchema);
async function extractInvoice(rawText) {
const response = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [{ role: "user", content: `Extract invoice fields: ${rawText}` }],
response_format: {
type: "json_schema",
json_schema: {
name: "invoice_extract",
strict: true,
schema: invoiceSchema
}
}
});
const choice = response.choices[0];
if (choice.message.refusal) {
throw new Error(`Model refused: ${choice.message.refusal}`);
}
const data = JSON.parse(choice.message.content);
// Validate even though strict mode guarantees structure —
// useful for catching schema drift between environments
if (!validateInvoice(data)) {
console.error("Schema validation errors:", validateInvoice.errors);
throw new Error("Output failed schema validation");
}
return data;
}
const invoice = await extractInvoice(
"Invoice #INV-881 from Nordic Parts AS. " +
"3x Brake Pads at $28.50 each. Total: $85.50"
);
console.log(`${invoice.vendor_name} — Invoice ${invoice.invoice_number}`);
invoice.line_items.forEach(item =>
console.log(` ${item.description}: $${item.amount}`)
);Podsumowanie
Structured Outputs eliminuje całą kategorię bugów produkcyjnych — te, w których model
zwraca prawie-poprawny JSON, który wysypuje twój parser o 2 w nocy. Workflow jest prosty: zaprojektuj swoją
schemę starannie (każdy zagnieżdżony obiekt potrzebuje additionalProperties: false i pełnej
tablicy required), ustaw strict: true i obsługuj refusale oraz truncation explicite.
Kiedy schema jest na miejscu, możesz jej reużyć przez cały stack — w wywołaniu OpenAI, w walidacji webhooków,
w swoich test fixturkach — z bibliotekami jak jsonschema (Python) albo
Ajv (Node.js). Jeśli zaczynasz od zera
ze schemą, Generator JSON Schema to najszybszy sposób, żeby
dostać działającą schemę bazową z przykładowego payloadu. Dni prompt-engineeringu w drodze do niezawodnego
wyjścia JSON się skończyły — użyj narzędzia, które zostało do tego zbudowane.