Você colocou em produção uma feature que chama o GPT-4 para extrair dados estruturados de notas fiscais enviadas por usuários. Em desenvolvimento funciona perfeitamente — o modelo retorna um objeto JSON limpo toda vez. Aí em produção, às 2 da manhã, chega um alerta do Sentry: JSON.parse: unexpected token. O modelo resolveu prefaciar sua resposta com "Claro! Aqui está o JSON que você pediu:" antes do payload real. Uma semana depois, mesma feature, bug diferente: o modelo retorna totalAmount em vez de total_amount, e a gravação no banco a jusante silenciosamente perde o campo. Se você vem dando um jeito via prompts para tentar ter confiabilidade no output do LLM, OpenAI Structured Outputs é a correção que você estava esperando.

Structured Outputs, lançado pela OpenAI em agosto de 2024, permite que você forneça um JSON Schema via o parâmetro response_format e receba uma resposta garantidamente válida que casa com esse schema exatamente. Isso é diferente do modo JSON antigo ({"type": "json_object"}), que só garantia que a saída era JSON válido — não que ela batia com um formato específico. Também é distinto do function calling, que roteia a saída do modelo para uma chamada de ferramenta mas adiciona sua própria cerimônia. Structured Outputs é o caminho mais limpo: descreva o formato que você quer, receba exatamente esse formato, toda vez. Por baixo dos panos, a OpenAI usa constrained decoding — a amostragem de tokens do modelo é guiada pelo seu schema então ele literalmente não pode produzir uma resposta inválida.

Seu primeiro 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}

Três coisas para notar aqui. Primeiro, o modelo é gpt-4o-2024-08-06 — Structured Outputs exige um modelo que explicitamente suporte (o snapshot -2024-08-06 ou posterior do GPT-4o, ou gpt-4o-mini). Segundo, response_format.type é "json_schema", não "json_object". Terceiro, "strict": True é o que te dá a garantia — sem isso você volta para território de melhor-esforço. O campo name é um rótulo que o modelo vê; não tem efeito no parse mas deixa seus logs da API legíveis.

Desenhando o JSON Schema

Aqui está um schema mais realista para uma tarefa de extração de catálogo de produtos — do tipo que você usaria para extrair dados estruturados de descrições de produto não estruturadas, listagens de e-commerce, ou datasheets em PDF. Use o JSON Schema Generator para construir e validar seu schema visualmente antes de plugá-lo nas chamadas da API.

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
}
  • Todas as propriedades devem estar em required no modo strict. Você não pode ter campos opcionais. Se um campo pode não existir nos dados de origem, use um tipo união: {"type": ["string", "null"]} e sempre inclua em required.
  • additionalProperties deve ser false em todo nível de objeto. Isso se aplica recursivamente — seus objetos aninhados também precisam, não só a raiz.
  • Tipos suportados no modo strict: string, number, integer, boolean, null, array, object. Uniões de tipo (["string", "null"]) são permitidas.
  • Sem $ref ou schemas recursivos no modo strict. Tudo tem que estar inline. Se precisar de uma definição reutilizável, copie.
  • Adicione campos description generosamente. O modelo lê eles. Dizer "Preço em dólares americanos, apenas numérico — não inclua símbolos de moeda" te dá uma saída mais limpa do que torcer para o modelo adivinhar certo.
  • Enums funcionam. {"type": "string", "enum": ["pending", "shipped", "delivered"]} é totalmente suportado e o modelo só vai emitir um desses três valores.

Modo strict vs não-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
    }
}

Com strict: True, a OpenAI pré-processa seu schema na primeira vez em que é usado e cacheia o decoder restrito. A primeira chamada com um schema novo demora um pouco mais; chamadas subsequentes com o mesmo schema são rápidas. O que você ganha em troca: a saída do modelo é estruturalmente garantida — você pode chamar json.loads() e depois acessar os campos direto sem checagens defensivas. O que você abre mão: $ref, anyOf entre variantes estruturais, e schemas recursivos não são suportados. O modo não-strict aceita uma gama maior de recursos de JSON Schema mas cai para melhor-esforço — o modelo tenta seguir o schema mas não é restringido no nível do token. Para pipelines de extração em produção, sempre use o modo strict. As restrições de schema são gerenciáveis uma vez que você entende.

Objetos e arrays aninhados

Estruturas aninhadas funcionam bem, mas cada objeto aninhado precisa do seu próprio "additionalProperties": false e do seu próprio array "required" listando todas as propriedades. Um erro comum é aplicar regras strict ao objeto raiz e esquecer dos filhos — a OpenAI vai rejeitar o schema com um erro de validação.

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

Lidando com recusas

Mesmo com Structured Outputs, o modelo pode se recusar a responder — tipicamente quando o prompt dispara uma política de conteúdo (pedindo para ele extrair dados de algo nocivo). Quando isso acontece, finish_reason é "stop" mas message.content é null e message.refusal contém o texto da recusa. Se você não checa isso, você vai levar um AttributeError quando tentar chamar json.loads(None). Também fique atento a finish_reason == "length" — se a resposta foi cortada por causa de max_tokens, o JSON vai estar incompleto e não parseável independente de 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"])

Usando o mesmo schema para validação

Um padrão pouco usado: use o mesmo JSON Schema que você passa para a OpenAI também para validar dados que chegam de outras fontes — webhooks, uploads de arquivo, APIs de terceiros. Isso te dá uma única fonte da verdade para o formato dos seus dados. Em Python, use a biblioteca jsonschema. Em Node.js, use Ajv. Você também pode colar seu schema no JSON Validator para fazer uma checagem manual rápida sem escrever código.

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']}")
Monte seu schema mais rápido: use o JSON Schema Generator para criar e refinar seu schema visualmente — cole um objeto JSON de exemplo e ele gera um schema inicial automaticamente. Copie o resultado direto para o response_format da OpenAI.

Versão em JavaScript / Node.js

O SDK Node.js da OpenAI espelha a API Python quase exatamente. A diferença principal é que strict fica dentro do objeto json_schema do mesmo jeito, e você parseia a resposta com JSON.parse(). Validação com Ajv é o equivalente Node.js da biblioteca jsonschema do Python — é mais rápida e tem excelente suporte a TypeScript.

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

Encerrando

Structured Outputs elimina uma categoria inteira de bugs de produção — aquele tipo em que o modelo retorna JSON quase-certo que quebra seu parser às 2 da manhã. O fluxo é direto: desenhe seu schema com cuidado (cada objeto aninhado precisa de additionalProperties: false e um array required completo), defina strict: true, e trate recusas e truncamento explicitamente. Uma vez que o schema está no lugar, você pode reutilizá-lo na stack toda — na chamada OpenAI, na validação de webhook, nas suas fixtures de teste — com bibliotecas como jsonschema (Python) ou Ajv (Node.js). Se você está começando do zero num schema, o JSON Schema Generator é o jeito mais rápido de chegar num schema-base funcional a partir de um payload de exemplo. Os dias de engenharia de prompt para conseguir saída JSON confiável acabaram — use a ferramenta que foi feita para isso.