Du har shippet en feature som kaller GPT-4 for å trekke ut strukturerte data fra brukerinnsendte fakturaer. I utvikling fungerer det perfekt — modellen returnerer et rent JSON-objekt hver gang. Så i produksjon, kl. 02.00, får du en Sentry-alert: JSON.parse: unexpected token. Modellen bestemte seg for å innlede svaret med "Sure! Here's the JSON you asked for:" før selve payloaden. En uke senere, samme feature, annen bug: modellen returnerer totalAmount i stedet for total_amount, og din downstream-databaseskriving slipper feltet i stillhet. Hvis du har prompt-engineert deg rundt pålitelighet i LLM-output, er OpenAI Structured Outputs fiksen du har ventet på.

Structured Outputs, lansert av OpenAI i august 2024, lar deg levere et JSON Schema via response_format-parameteren og motta et garantert gyldig svar som matcher det skjemaet nøyaktig. Dette er annerledes enn den eldre JSON-modusen ({"type": "json_object"}), som bare sikret at outputtet var gyldig JSON — ikke at det matchet en spesiell form. Det er også adskilt fra function calling, som ruter modellens output inn i et tool call, men legger til sitt eget seremoni-lag. Structured Outputs er den reneste veien: beskriv formen du vil ha, få nøyaktig den formen tilbake, hver gang. Under panseret bruker OpenAI constrained decoding — modellens token-sampling styres av skjemaet ditt, så modellen bokstavelig talt ikke kan produsere et ugyldig svar.

Ditt første Structured Output

python
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}

Tre ting å legge merke til. For det første er modellen gpt-4o-2024-08-06 — Structured Outputs krever en modell som eksplisitt støtter det (snapshot -2024-08-06 eller nyere for GPT-4o, eller gpt-4o-mini). For det andre er response_format.type "json_schema", ikke "json_object". For det tredje er "strict": True det som gir deg garantien — uten det er du tilbake i best-effort-territorium. Feltet name er en etikett modellen ser; det påvirker ikke parsing, men gjør API-loggene dine lesbare.

Designe JSON-skjemaet

Her er et mer realistisk skjema for en produktkatalog-ekstraksjonsoppgave — den typen du ville brukt til å trekke ut strukturerte data fra ustrukturerte produktbeskrivelser, e-handelsoppføringer eller PDF-datablad. Bruk JSON Schema Generator for å bygge og validere skjemaet visuelt før du kobler det inn i API-kallene dine.

json
{
  "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
}
  • Alle properties må være i required i strict mode. Du kan ikke ha valgfrie felt. Hvis et felt kanskje ikke finnes i kildedataene, bruk en type-union: {"type": ["string", "null"]} og inkluder det alltid i required.
  • additionalProperties må være false på hvert objektnivå. Dette gjelder rekursivt — de nestede objektene dine trenger det også, ikke bare roten.
  • Støttede typer i strict mode: string, number, integer, boolean, null, array, object. Type-unioner (["string", "null"]) er tillatt.
  • Ingen $ref eller rekursive skjemaer i strict mode. Alt må være inlinet. Trenger du en gjenbrukbar definisjon, kopier den.
  • Legg til description-felt rundhåndet. Modellen leser dem. Å si "Price in US dollars, numeric only — do not include currency symbols" gir deg renere output enn å håpe at modellen gjetter riktig.
  • Enums fungerer. {"type": "string", "enum": ["pending", "shipped", "delivered"]} er fullt støttet, og modellen vil kun noen gang sende ut én av disse tre verdiene.

Strict mode vs non-strict

python
# 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
    }
}

Med strict: True preprocesserer OpenAI skjemaet ditt første gang det brukes, og cacher den constrained decoderen. Det første kallet med et nytt skjema tar litt lengre tid; etterfølgende kall med samme skjema er raske. Hva du får i retur: modellens output er strukturelt garantert — du kan kalle json.loads() og deretter få tilgang til felt direkte uten defensive sjekker. Hva du gir opp: $ref, anyOf på tvers av strukturelle varianter, og rekursive skjemaer er ikke støttet. Non-strict mode aksepterer et bredere utvalg av JSON Schema- funksjoner, men faller tilbake til best-effort — modellen prøver å følge skjemaet, men er ikke begrenset på tokennivå. For produksjons-ekstraksjonspipelines, bruk alltid strict mode. Skjema-restriksjonene er håndterbare når du først forstår dem.

Nestede objekter og arrays

Nestede strukturer fungerer godt, men hvert nestede objekt trenger sin egen "additionalProperties": false og sin egen "required"-array som lister alle properties. En vanlig feil er å bruke strict-regler på rotobjektet og glemme barna — OpenAI avviser skjemaet med en valideringsfeil.

python
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']}")

Håndtering av refusals

Selv med Structured Outputs kan modellen nekte å svare — typisk når prompten trigger en content policy (å be den trekke ut data fra noe skadelig). Når det skjer, er finish_reason "stop", men message.content er null, og message.refusal inneholder avvisningsteksten. Hvis du ikke sjekker for dette, får du en AttributeError når du prøver å kalle json.loads(None). Pass også på finish_reason == "length" — hvis svaret ble kuttet på grunn av max_tokens, blir JSON-en ufullstendig og uparserbar uansett Structured Outputs.

python
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"])

Bruke samme skjema til validering

Et underutnyttet mønster: bruk samme JSON Schema som du sender til OpenAI, til også å validere data som kommer inn fra andre kilder — webhooks, filopplastninger, tredjeparts-API-er. Det gir deg én kilde til sannhet for dataformen din. I Python, bruk jsonschema-biblioteket. I Node.js, bruk Ajv. Du kan også lime skjemaet inn i JSON Validator for å gjøre en rask manuell sanity-sjekk uten å skrive kode.

python
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']}")
Bygg skjemaet ditt raskere: Bruk JSON Schema Generator for å lage og finjustere skjemaet ditt visuelt — lim inn et JSON-eksempelobjekt, og det genererer et startskjema automatisk. Kopier resultatet direkte inn i OpenAI-response_format-en din.

JavaScript / Node.js-versjon

OpenAI Node.js-SDK-et speiler Python-API-et nesten nøyaktig. Hovedforskjellen er at strict sitter inne i json_schema-objektet på samme måte, og du parser svaret med JSON.parse(). Validering med Ajv er Node.js- motstykket til Pythons jsonschema-bibliotek — det er raskere og har utmerket TypeScript-støtte.

js
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}`)
);

Oppsummering

Structured Outputs eliminerer en hel kategori produksjonsbugs — den typen der modellen returnerer nesten-riktig JSON som ødelegger parseren din kl. 02.00. Arbeidsflyten er grei: design skjemaet ditt nøye (hvert nestet objekt trenger additionalProperties: false og en full required-array), sett strict: true, og håndter refusals og truncation eksplisitt. Når skjemaet er på plass, kan du gjenbruke det på tvers av stacken — i OpenAI-kallet, i webhook-validering, i testfixturene dine — med biblioteker som jsonschema (Python) eller Ajv (Node.js). Hvis du starter fra bunnen med et skjema, er JSON Schema Generator den raskeste måten å få et fungerende grunnskjema fra en eksempel-payload. Dagene med å prompt-engineere seg til pålitelig JSON-output er forbi — bruk verktøyet som ble bygget for det.