Código de programação colorido em tela representando structured outputs com JSON Schema para eliminar alucinações de formato em IA

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.

Código Python em tela de computador representando structured outputs e validação JSON Schema em projetos de IA

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:

  1. Formato: A resposta SEMPRE é JSON válido. Sem markdown wrapper, sem texto antes/depois.
  2. Schema: Os campos, tipos e estrutura seguem exatamente o que você definiu. Strings são strings, ints são ints.
  3. 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 required vai estar presente.
  • Nada extra: "additionalProperties": False impede 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)
  • patternProperties
  • oneOf com mais de 2 opções em alguns casos
  • if/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)"
}

Desenvolvimento de software com código em tela escura ilustrando debugging de structured outputs e JSON Schema

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 com Field(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.

Posts Similares