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
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.
{
"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
requiredno 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 emrequired. additionalPropertiesdeve serfalseem 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
$refou schemas recursivos no modo strict. Tudo tem que estar inline. Se precisar de uma definição reutilizável, copie. - Adicione campos
descriptiongenerosamente. 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
# 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.
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.
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.
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 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.
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.