Structured Outputs com JSON Schema: Como Eliminar Alucinações de Formato em IA de Produção
Eu gastava 40% do meu tempo de desenvolvimento não escrevendo lógica, mas parseando saída de IA. Regex para extrair JSON de dentro de markdown. Try/catch em cima de campos que deveriam ser números mas vinham como strings. Validação manual de schema que ninguém atualizava quando o modelo mudava de comportamento.
Foi quando descobri que a maioria das APIs modernas suporta structured outputs com JSON Schema — e que eu estava resolvendo o problema errado esse tempo todo. Não era sobre parsear melhor. Era sobre garantir que a saída já viesse no formato certo.
Neste post, vou te mostrar como implementar structured outputs do zero, sem framework, usando a API da OpenAI e a API do Google Gemini. Vou incluir os erros que cometi, os gotchas que ninguém conta, e o pipeline de validação que finalmente me deixou dormir tranquilo.

O Problema: Sua IA é um FML (Formato Livre Mente)
LLMs são treinados para gerar texto natural. O que significa que, por padrão, eles produzem prosa. Não objetos. Não arrays. Não tipos validados.
Quando você pede “retorne um JSON com nome e idade”, você recebe algo como:
{"nome": "Maria", "idade": 28}
Mas também pode receber:
Aqui está o JSON solicitado:
```json
{"nome": "Maria", "idade": 28}
```
Espero que isso ajude!
Ou pior:
{"nome": "Maria", "idade": "vinte e oito anos"}
O segundo caso é o mais perigoso. Porque parece JSON. Passa no json.loads(). Mas explode no seu sistema quando tenta fazer int(idade) em produção, às 3 da manhã, num endpoint que ninguém monitora direito.
🔥 Box Perrengue: Eu tinha um agente que classificava tickets de suporte em categorias. Funcionou perfeito por 2 semanas. No dia 15, o modelo decidiu que a categoria “Bug Crítico” deveria ser “bug-crítico” (com hífen). Meu pipeline de roteamento não encontrou a categoria, jogou tudo pra fila geral, e o cliente esperou 6 horas por uma resposta sobre um servidor fora do ar. Structured outputs teriam evitado isso em 1 linha de código.
O que são Structured Outputs (E Por que Você Precisa)
Structured outputs é a capacidade de uma API de LLM garantir que a resposta vai seguir um schema JSON pré-definido. Não “provavelmente seguir”. Não “na maioria das vezes”. Garantir.
Isso resolve três problemas de uma vez:
- Formato: A resposta SEMPRE é JSON válido. Sem markdown wrapper, sem texto antes/depois.
- Schema: Os campos, tipos e estrutura seguem exatamente o que você definiu. Strings são strings, ints são ints.
- Enum: Valores categóricos são restritos a opções pré-definidas. Acabou o “bug-crítico” surpresa.
A OpenAI implementa isso via response_format com type: "json_schema". O Google Gemini usa responseMimeType: "application/json" com responseSchema. Vou mostrar ambos.
Implementação com OpenAI: O Caminho Completo
Passo 1: Definir o Schema
Primeiro, você define o JSON Schema que descreve exatamente o que quer. Vamos usar um exemplo prático: um classificador de sentimento para reviews de produto.
import json
from openai import OpenAI
client = OpenAI()
# O schema que define a estrutura da resposta
sentiment_schema = {
"type": "json_schema",
"json_schema": {
"name": "sentiment_analysis",
"strict": True, # IMPORTANTE: garante aderência total ao schema
"schema": {
"type": "object",
"properties": {
"sentimento": {
"type": "string",
"enum": ["positivo", "negativo", "neutro", "misto"]
},
"confianca": {
"type": "number",
"description": "Nível de confiança entre 0.0 e 1.0"
},
"aspectos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"aspecto": {
"type": "string"
},
"opiniao": {
"type": "string",
"enum": ["bom", "ruim", "neutro"]
}
},
"required": ["aspecto", "opiniao"],
"additionalProperties": False
}
},
"resumo": {
"type": "string",
"description": "Resumo em uma frase do sentimento geral"
}
},
"required": ["sentimento", "confianca", "aspectos", "resumo"],
"additionalProperties": False
}
}
}
Passo 2: Fazer a Chamada
response = client.chat.completions.create(
model="gpt-4o-2024-08-06", # Modelo que suporta structured outputs
messages=[
{
"role": "system",
"content": "Você é um analisador de sentimento de reviews de produtos. Analise o review e retorne a análise estruturada."
},
{
"role": "user",
"content": "O celular tem uma câmera excelente, melhor que qualquer concorrente nesse preço. Mas a bateria é uma piada — dura menos de 4 horas com uso moderado. O design é ok, nada especial. No geral, mistura de alegria e frustração."
}
],
response_format=sentiment_schema
)
resultado = json.loads(response.choices[0].message.content)
print(json.dumps(resultado, indent=2, ensure_ascii=False))
A resposta garantidamente vai ser:
{
"sentimento": "misto",
"confianca": 0.92,
"aspectos": [
{"aspecto": "câmera", "opiniao": "bom"},
{"aspecto": "bateria", "opiniao": "ruim"},
{"aspecto": "design", "opiniao": "neutro"}
],
"resumo": "Review misto com câmera elogiada e bateria criticada."
}
Notou? Não tem try/catch para parsing. Não tem regex. Não tem fallback. O JSON já vem válido e aderente ao schema. Isso é structured outputs funcionando.
O Parâmetro “strict”: O Segredo que Poucos Conhecem
O campo "strict": True no schema é o que ativa o modo constrained decoding. Sem ele, o modelo tenta seguir o schema mas pode desviar. Com ele, a API restringe o espaço de tokens para só gerar o que é válido segundo o schema.
Isso tem implicações importantes:
- Enums são garantidos: Se o enum diz
["positivo", "negativo"], o modelo fisicamente não pode gerar “positiva” ou “Positivo”. - Tipos são garantidos: Um campo
"type": "integer"nunca vai vir como string. - Campos obrigatórios são garantidos: Tudo no
requiredvai estar presente. - Nada extra:
"additionalProperties": Falseimpede campos surpresa.
O custo? Uma leve redução na “criatividade” do modelo — que é exatamente o que você quer quando precisa de dados estruturados em produção.
Gotchas que Me Custaram Horas de Debug
1. Nem Todos os Modelos Suportam
strict: True com json_schema só funciona em modelos específicos. Na OpenAI: gpt-4o e gpt-4o-mini (e suas versões datadas). Se tentar com gpt-3.5-turbo, você recebe um erro genérico que não explica nada.
2. O Schema Tem Que Ser “Simples”
Structured outputs não suporta todos os recursos do JSON Schema. Não suporta:
$ref(referências circulares)patternPropertiesoneOfcom mais de 2 opções em alguns casosif/then/else
Se o schema for complexo demais, a API retorna um erro 400 com uma mensagem críptica. A solução é achatar o schema — usar objects aninhados em vez de composição.
3. Arrays Vazios Podem Quebrar
# Isso pode causar problemas se o modelo não tiver nada pra preencher:
"aspectos": {
"type": "array",
"items": {...},
"minItems": 1 # Use isso para evitar arrays vazios inesperados
}
4. Descrições Importam (Mesmo Com Strict)
Com strict: True, o modelo ainda usa as description dos campos para entender o que preencher. Descrições vagas = respostas vagas.
# Ruim:
"confianca": {"type": "number"}
# Bom:
"confianca": {
"type": "number",
"description": "Nível de confiança da classificação entre 0.0 (sem confiança) e 1.0 (certeza absoluta)"
}

Implementação com Google Gemini: A Alternativa
O Google Gemini tem uma abordagem diferente para structured outputs. Em vez de response_format, você usa a generative AI configuration com response_mime_type e response_schema.
import google.generativeai as genai
import json
genai.configure(api_key="SUA_API_KEY")
# Define o schema (formato simplificado do Gemini)
response_schema = {
"type": "object",
"properties": {
"sentimento": {
"type": "string",
"enum": ["positivo", "negativo", "neutro", "misto"]
},
"confianca": {
"type": "number"
},
"aspectos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"aspecto": {"type": "string"},
"opiniao": {
"type": "string",
"enum": ["bom", "ruim", "neutro"]
}
},
"required": ["aspecto", "opiniao"]
}
},
"resumo": {"type": "string"}
},
"required": ["sentimento", "confianca", "aspectos", "resumo"]
}
model = genai.GenerativeModel(
model_name="gemini-1.5-pro",
generation_config=genai.GenerationConfig(
response_mime_type="application/json",
response_schema=response_schema
)
)
response = model.generate_content(
"Analise o sentimento deste review de produto: "
"'O celular tem uma câmera excelente, mas a bateria é péssima.'"
)
resultado = json.loads(response.text)
print(json.dumps(resultado, indent=2, ensure_ascii=False))
A abordagem do Gemini é mais limpa na configuração, mas tem uma limitação: não tem o equivalente ao strict: True. O modelo “tenta muito” seguir o schema, mas em casos limítrofes pode desviar. Para produção, eu recomendo uma camada de validação Pydantic por cima — que vou mostrar adiante.
O Pipeline de Validação Dupla (Para Produção Real)
Mesmo com structured outputs, eu nunca confio cegamente na API. Em produção, uso um pipeline de validação dupla:
from pydantic import BaseModel, Field, field_validator
from typing import Literal, List
import json
# 1. Modelo Pydantic que espelha o schema
class Aspecto(BaseModel):
aspecto: str = Field(min_length=1, max_length=100)
opiniao: Literal["bom", "ruim", "neutro"]
class AnaliseSentimento(BaseModel):
sentimento: Literal["positivo", "negativo", "neutro", "misto"]
confianca: float = Field(ge=0.0, le=1.0)
aspectos: List[Aspecto] = Field(min_length=1)
resumo: str = Field(min_length=10, max_length=300)
@field_validator('confianca')
@classmethod
def arredonda_confianca(cls, v):
return round(v, 2)
# 2. Função de chamada com validação
def analisar_com_validacao(texto_review: str) -> AnaliseSentimento:
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Analise o sentimento do review."},
{"role": "user", "content": texto_review}
],
response_format=sentiment_schema
)
raw = json.loads(response.choices[0].message.content)
# Validação Pydantic — segunda camada de segurança
try:
return AnaliseSentimento(**raw)
except Exception as e:
# Log estruturado do erro para debugging
print(f"Validação falhou: {e}")
print(f"Raw response: {json.dumps(raw, indent=2)}")
raise ValueError(f"Schema inválido após structured output: {e}")
# Uso
resultado = analisar_com_validacao(
"Produto excelente, mas entrega atrasou 2 semanas."
)
print(resultado.model_dump_json(indent=2))
Esse padrão — structured output da API + validação Pydantic — é o que uso em produção. A API garante 99.9% de aderência. O Pydantic garante os 0.1% restantes.
🔥 Box Perrengue #2: Uma vez o modelo retornou
"confianca": 1.0000001. Sim, maior que 1.0. Com structured outputs. O valor veio de um edge case que ninguém testou. O Pydantic capturou comField(le=1.0). Desde então, nunca mais publiquei sem validação Pydantic.
Casos de Uso Reais (Não Só Sentimento)
Structured outputs brilha em qualquer cenário onde a IA precisa retornar dados processados, não prosa. Aqui vão os que mais uso:
Extração de Entidades
entity_schema = {
"type": "json_schema",
"json_schema": {
"name": "entity_extraction",
"strict": True,
"schema": {
"type": "object",
"properties": {
"pessoas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"nome": {"type": "string"},
"cargo": {"type": "string"},
"organizacao": {"type": "string"}
},
"required": ["nome"],
"additionalProperties": False
}
},
"datas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"data_original": {"type": "string"},
"data_iso": {"type": "string"}
},
"required": ["data_original", "data_iso"],
"additionalProperties": False
}
},
"valores": {
"type": "array",
"items": {
"type": "object",
"properties": {
"valor": {"type": "number"},
"moeda": {"type": "string"},
"contexto": {"type": "string"}
},
"required": ["valor", "contexto"],
"additionalProperties": False
}
}
},
"required": ["pessoas", "datas", "valores"],
"additionalProperties": False
}
}
}
Geração de Código Estruturado
code_schema = {
"type": "json_schema",
"json_schema": {
"name": "code_generation",
"strict": True,
"schema": {
"type": "object",
"properties": {
"linguagem": {
"type": "string",
"enum": ["python", "javascript", "typescript", "go", "rust"]
},
"codigo": {"type": "string"},
"explicacao": {"type": "string"},
"dependencias": {
"type": "array",
"items": {"type": "string"}
},
"complexidade": {
"type": "string",
"enum": ["O(1)", "O(log n)", "O(n)", "O(n log n)", "O(n²)"]
}
},
"required": ["linguagem", "codigo", "explicacao", "dependencias", "complexidade"],
"additionalProperties": False
}
}
}
Classificação Multi-Rótulo
classification_schema = {
"type": "json_schema",
"json_schema": {
"name": "ticket_classification",
"strict": True,
"schema": {
"type": "object",
"properties": {
"categoria": {
"type": "string",
"enum": ["bug", "feature", "duvida", "cancelamento", "elogio", "outro"]
},
"prioridade": {
"type": "string",
"enum": ["critica", "alta", "media", "baixa"]
},
"tags": {
"type": "array",
"items": {"type": "string"}
},
"encaminhar_para": {
"type": "string",
"enum": ["engenharia", "suporte", "vendas", "produto", "financeiro"]
},
"resposta_sugerida": {"type": "string"}
},
"required": ["categoria", "prioridade", "tags", "encaminhar_para"],
"additionalProperties": False
}
}
}
Este último é exatamente o padrão que teria evitado meu perrengue dos tickets. Com enum no campo categoria, o modelo nunca teria gerado “bug-crítico” em vez de “bug”.
Performance e Custo: O Que Esperar
Structured outputs tem implicações de performance que ninguém menciona:
- Latência: ~10-20% mais lento que chamadas normais (o modelo precisa calcular a máscara de tokens válidos a cada step). Imperceptível na maioria dos casos.
- Custo: Mesmo pricing por token. Sem surpresas aqui.
- Tokens de schema: O schema conta como tokens de input. Schemas complexos = mais input tokens. Um schema como o de entity extraction acima consome ~300 tokens de input.
- Caching: Se o schema é o mesmo entre chamadas, a API faz cache da árvore de restrições. Chamadas consecutivas com o mesmo schema são mais rápidas.
Na prática, o overhead é mínimo e compensa absurdamente pela eliminação de código de parsing e validação.
Quando NÃO Usar Structured Outputs
Nem tudo é prego para esse martelo. Evite structured outputs quando:
- A resposta precisa ser criativa: Geração de textos, histórias, copy. Structured outputs limita a criatividade (que é o objetivo, mas nem sempre desejado).
- O schema muda toda hora: Se a estrutura da resposta varia por request, o overhead de construir schemas dinâmicos pode não compensar.
- Você precisa de reasoning tokens visíveis: Com structured outputs, o chain-of-thought fica oculto. Se você precisa ver o raciocínio, use modo normal + parsing.
- Streaming com schema complexo: O suporte a streaming é limitado para schemas complexos. O JSON parcial pode não fazer sentido até estar completo.
O Padrão Final: Meu Template de Produção
Depois de meses testando, cheguei neste template que uso em todo projeto:
# template_structured_output.py
from pydantic import BaseModel
from openai import OpenAI
from typing import Type, TypeVar
import json
T = TypeVar('T', bound=BaseModel)
client = OpenAI()
def structured_call(
prompt: str,
system: str,
response_model: Type[T],
model: str = "gpt-4o-2024-08-06",
temperature: float = 0.1,
max_retries: int = 2
) -> T:
"""
Chamada de LLM com structured output + validação Pydantic.
Retry automático em caso de falha de validação.
"""
# Converte modelo Pydantic em JSON Schema para a API
schema = response_model.model_json_schema()
# Adapta para o formato da OpenAI
api_schema = {
"type": "json_schema",
"json_schema": {
"name": response_model.__name__,
"strict": True,
"schema": {
**schema,
"additionalProperties": False
}
}
}
for attempt in range(max_retries + 1):
response = client.chat.completions.create(
model=model,
temperature=temperature,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": prompt}
],
response_format=api_schema
)
raw = json.loads(response.choices[0].message.content)
try:
return response_model(**raw)
except Exception as e:
if attempt == max_retries:
raise
# Adiciona erro no contexto para a próxima tentativa
system += f"\n\nTentativa anterior falhou: {e}. Corrija."
raise RuntimeError("Não deveria chegar aqui")
# USO:
class MinhaAnalise(BaseModel):
tema: str
relevancia: int # 1-10
pontos_chave: list[str]
resultado = structured_call(
prompt="Analise este artigo sobre structured outputs...",
system="Você é um analista técnico.",
response_model=MinhaAnalise
)
print(resultado.model_dump_json(indent=2))
Esse template me poupa horas toda semana. Um único arquivo, reutilizável em qualquer projeto, com validação automática e retry inteligente.
Conclusão: Pare de Parsear, Comece a Especificar
A transição de “parsear saída de IA” para “especificar o que eu quero” foi uma das maiores evoluções na minha forma de construir com IA. Não é só sobre confiabilidade — é sobre mentalidade.
Em vez de escrever código defensivo para lidar com tudo que pode dar errado, eu defino o que deve dar certo. E a API garante que acontece.
Os números falam por si: nos projetos onde migrei para structured outputs, o código de integração com IA encolheu em média 60%. O número de bugs relacionados a parsing caiu para zero. E o tempo de desenvolvimento de novos endpoints com IA caiu pela metade.
Se você ainda está usando json.loads() com try/catch e rezando pra dar certo, é hora de parar. Structured outputs é a ferramenta que faltava para levar integração com IA de “funciona na demos” para “funciona em produção”.
E aí, qual automação com IA você quer ver estruturada aqui no blog? Comenta aí — leio todo mundo e uso como inspiração para os próximos posts.
Se curtiu esse conteúdo, compartilha com aquele colega que ainda gasta metade do sprint parseando JSON de IA. Aqui no AutoMente tem mais — confere os posts de Mente Binária e Produtividade Aumentada.
