Structured outputs e JSON mode: garantindo que IA retorne dados utilizáveis

Como usar structured outputs e JSON mode para garantir que LLMs retornem dados no formato esperado: comparação de abordagens, validação com Pydantic/Zod e exemplos práticos.

Um desenvolvedor de uma fintech me mostrou este código numa terça-feira:

# Extração de dados de nota fiscal com GPT-4

prompt = "Extraia CNPJ, valor total e data da nota fiscal abaixo: [texto da NF]"
resposta = openai.chat.completions.create(model="gpt-4", messages=[...])

print(resposta)

Output:

"O CNPJ é 12.345.678/0001-99, o valor total foi de R$ 1.234,56 e a data é 15/03/2024."

Ele olhou para mim frustrado:

“Como eu faço parse disso? Às vezes vem ‘R$ 1.234,56’, às vezes ‘1234.56’, às vezes ‘mil duzentos e trinta e quatro reais’. Formato muda toda hora. Preciso de JSON consistente para inserir no banco de dados. Mas o modelo não obedece.”

Este é o problema número 1 de usar LLMs em produção: outputs inconsistentes.

Você precisa de:

{
  "cnpj": "12.345.678/0001-99",
  "valor_total": 1234.56,
  "data_emissao": "2024-03-15"
}

Mas recebe:

  • Às vezes JSON válido
  • Às vezes texto com JSON dentro
  • Às vezes JSON com campos faltando
  • Às vezes JSON com tipos errados (string ao invés de number)

Structured outputs e JSON mode resolvem isso: garantem que LLM retorne sempre dados estruturados e validados no formato que você especificou.

O problema: LLMs são probabilísticos, não determinísticos

Por que LLMs são inconsistentes?

LLMs geram texto token por token, baseado em probabilidades.

Quando você pede “retorne em JSON”, o modelo tenta gerar JSON, mas:

  • Pode adicionar texto antes/depois do JSON (“Claro! Aqui está: …”)
  • Pode usar nomes de campos diferentes (cnpj vs CNPJ vs cnpj_empresa)
  • Pode omitir campos opcionais (ou inventar campos extras)
  • Pode usar tipos errados (string ao invés de number)
  • Pode gerar JSON inválido (vírgula faltando, chave sem fechar)

Exemplo real:

Você pede 100 vezes a mesma extração de dados. Resultados:

Request 1:
{"cnpj": "12.345.678/0001-99", "valor": 1234.56, "data": "2024-03-15"}

Request 2:
{"CNPJ": "12.345.678/0001-99", "valor_total": "1234,56", "data_emissao": "15/03/2024"}

Request 3:
Claro! Aqui estão os dados extraídos:
{"cnpj": "12.345.678/0001-99", "valor": 1234.56}
(faltou "data")

Request 4:
{"cnpj": "12.345.678/0001-99", "valor": 1234.56, "data": "15 de março de 2024", "observacao": "Nota fiscal válida"}
(adicionou campo extra não pedido)

Request 5:
{"cnpj": "12.345.678/0001-99", "valor": "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"}
(valor como texto por extenso)

5 requests, 5 formatos diferentes. Impossível fazer parsing confiável.

Abordagens tradicionais (e por que falham)

Tentativa 1: Pedir “gentilmente” no prompt

prompt = """
Extraia CNPJ, valor total e data.
IMPORTANTE: Retorne APENAS JSON válido, sem texto adicional.
Formato:
{
  "cnpj": "string",
  "valor_total": number,
  "data_emissao": "YYYY-MM-DD"
}
"""

Taxa de sucesso: 85-92% (melhora, mas não é 100%)

Falhas típicas:

  • 5-8%: adiciona texto antes/depois do JSON
  • 3-5%: usa formato de data errado (DD/MM/YYYY ao invés de YYYY-MM-DD)
  • 2-3%: JSON inválido (erro de sintaxe)

Tentativa 2: Few-shot examples

prompt = """
Extraia dados de notas fiscais.

Exemplo 1:
Input: [nota fiscal]
Output: {"cnpj": "11.222.333/0001-44", "valor_total": 580.90, "data_emissao": "2024-01-10"}

Exemplo 2:
Input: [nota fiscal]
Output: {"cnpj": "55.666.777/0001-88", "valor_total": 1205.50, "data_emissao": "2024-02-20"}

Agora extraia desta nota fiscal:
[texto]
"""

Taxa de sucesso: 90-95% (melhor, mas ainda não é 100%)

Tentativa 3: Validação e retry

def extrair_com_retry(texto, max_tentativas=3):
    for tentativa in range(max_tentativas):
        resposta = chamar_llm(texto)
        try:
            dados = json.loads(resposta)
            validar_schema(dados)  # Pydantic
            return dados
        except:
            continue  # tenta de novo

    raise Exception("Falhou após 3 tentativas")

Taxa de sucesso: 98-99% (quase lá, mas custa 3x mais e é mais lento)

As três soluções: JSON mode, Structured Outputs e Function Calling

Solução 1: JSON mode (OpenAI)

O que é:

Modo especial que garante que o output é JSON válido sintaticamente.

Como usar:

from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",  # ou gpt-4o-mini
    messages=[
        {"role": "system", "content": "Você extrai dados de notas fiscais e retorna JSON."},
        {"role": "user", "content": f"Extraia CNPJ, valor e data: {texto_nf}"}
    ],
    response_format={"type": "json_object"}  # ← JSON mode ativado
)

dados = json.loads(response.choices[0].message.content)

Garantias:

  • ✅ Sempre retorna JSON sintaticamente válido
  • ✅ Não adiciona texto antes/depois
  • ❌ Não garante schema (nomes de campos, tipos)
  • ❌ Não garante presença de campos obrigatórios

Exemplo de outputs possíveis:

// Pode retornar:
{"cnpj": "12.345.678/0001-99", "valor": 1234.56, "data": "2024-03-15"}

// Ou pode retornar (schema diferente):
{"CNPJ_EMPRESA": "12.345.678/0001-99", "total": "R$ 1.234,56", "emissao": "15/03/2024"}

// Ou pode retornar (campos faltando):
{"cnpj": "12.345.678/0001-99", "valor": 1234.56}

JSON é válido, mas schema é inconsistente.

Quando usar:

  • Você só precisa que seja JSON (não importa o schema exato)
  • Você vai validar o schema manualmente depois
  • Uso exploratório / prototipagem

Solução 2: Structured Outputs (OpenAI)

O que é:

Extensão do JSON mode que garante conformidade com JSON Schema definido.

Como usar:

from openai import OpenAI
from pydantic import BaseModel

# 1. Definir schema com Pydantic
class NotaFiscal(BaseModel):
    cnpj: str
    valor_total: float
    data_emissao: str  # formato: YYYY-MM-DD

client = OpenAI()

# 2. Passar schema para o modelo
response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "Extraia dados de notas fiscais."},
        {"role": "user", "content": f"Extraia os dados: {texto_nf}"}
    ],
    response_format=NotaFiscal  # ← schema definido
)

# 3. Output é garantido estar conforme schema
dados = response.choices[0].message.parsed  # já é objeto Pydantic
print(dados.cnpj)         # "12.345.678/0001-99"
print(dados.valor_total)  # 1234.56 (float)
print(dados.data_emissao) # "2024-03-15" (string)

Garantias:

  • ✅ JSON sintaticamente válido
  • ✅ Conforma com schema (campos exatos)
  • ✅ Tipos corretos (string, float, int, etc.)
  • ✅ Campos obrigatórios sempre presentes
  • ✅ Parse automático para objeto Pydantic

Quando usar:

  • Produção (confiabilidade é crítica)
  • Integração com sistemas (precisa de schema fixo)
  • Validação de dados (Pydantic valida automaticamente)

Solução 3: Function Calling (OpenAI e Anthropic)

O que é:

LLM “chama uma função” com parâmetros estruturados. Você define os parâmetros (JSON Schema) e o modelo retorna argumentos no formato correto.

Como usar (OpenAI):

from openai import OpenAI

client = OpenAI()

# 1. Definir "função" (na verdade é schema)
tools = [{
    "type": "function",
    "function": {
        "name": "extrair_nota_fiscal",
        "description": "Extrai dados estruturados de nota fiscal",
        "parameters": {
            "type": "object",
            "properties": {
                "cnpj": {
                    "type": "string",
                    "description": "CNPJ do emissor no formato XX.XXX.XXX/XXXX-XX"
                },
                "valor_total": {
                    "type": "number",
                    "description": "Valor total da nota fiscal em reais"
                },
                "data_emissao": {
                    "type": "string",
                    "description": "Data de emissão no formato YYYY-MM-DD"
                }
            },
            "required": ["cnpj", "valor_total", "data_emissao"]
        }
    }
}]

# 2. Chamar modelo
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": f"Extraia dados desta NF: {texto_nf}"}],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "extrair_nota_fiscal"}}
)

# 3. Extrair argumentos da "chamada de função"
tool_call = response.choices[0].message.tool_calls[0]
argumentos = json.loads(tool_call.function.arguments)

print(argumentos)
# {"cnpj": "12.345.678/0001-99", "valor_total": 1234.56, "data_emissao": "2024-03-15"}

Como usar (Anthropic Claude):

import anthropic

client = anthropic.Anthropic()

# 1. Definir tool
tools = [{
    "name": "extrair_nota_fiscal",
    "description": "Extrai dados de nota fiscal",
    "input_schema": {
        "type": "object",
        "properties": {
            "cnpj": {"type": "string", "description": "CNPJ do emissor"},
            "valor_total": {"type": "number", "description": "Valor total em reais"},
            "data_emissao": {"type": "string", "description": "Data no formato YYYY-MM-DD"}
        },
        "required": ["cnpj", "valor_total", "data_emissao"]
    }
}]

# 2. Chamar Claude
response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": f"Extraia dados: {texto_nf}"}]
)

# 3. Extrair input da tool
tool_use = response.content[0]
argumentos = tool_use.input

print(argumentos)
# {"cnpj": "12.345.678/0001-99", "valor_total": 1234.56, "data_emissao": "2024-03-15"}

Garantias:

  • ✅ JSON válido
  • ✅ Schema garantido
  • ✅ Tipos corretos
  • ✅ Funciona com OpenAI e Anthropic

Quando usar:

  • Quando precisa que LLM “aja” (não só retorne dados)
  • Workflow com múltiplas etapas (LLM decide qual função chamar)
  • Agentes (LLM escolhe entre várias ferramentas)

Comparação detalhada: qual abordagem escolher?

Tabela comparativa

CritérioPrompt tradicionalJSON modeStructured OutputsFunction Calling
Garantia de JSON válido90-95%100%100%100%
Garantia de schema
Campos obrigatórios
Tipos validados
Facilidade de uso⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Integração PydanticManualManualNativaManual
Suporte multi-modelo✅ TodosOpenAIOpenAIOpenAI + Anthropic
CustoPadrãoPadrãoPadrãoPadrão
LatênciaPadrãoPadrãoPadrãoPadrão

Quando usar cada abordagem?

Use Prompt tradicional quando:

  • Prototipagem rápida
  • Output é para humanos (não para parse automático)
  • Não importa pequenas inconsistências

Use JSON mode quando:

  • Precisa de JSON válido, mas schema pode variar
  • Vai validar manualmente depois
  • Quer flexibilidade no formato de output

Use Structured Outputs quando:

  • Produção (confiabilidade crítica)
  • Schema fixo e bem definido
  • Integração com sistemas (APIs, banco de dados)
  • Usa Pydantic para validação
  • OpenAI é seu provider

Use Function Calling quando:

  • LLM precisa “agir” (não apenas retornar dados)
  • Agentes (LLM escolhe entre múltiplas ferramentas)
  • Workflow complexo com decisões
  • Quer compatibilidade OpenAI + Anthropic

Validação com Pydantic e Zod

Pydantic (Python)

Setup básico:

from pydantic import BaseModel, Field, validator
from typing import Optional
from datetime import date

class NotaFiscal(BaseModel):
    cnpj: str = Field(..., regex=r'^\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}$')
    valor_total: float = Field(..., gt=0)  # maior que 0
    data_emissao: date
    descricao: Optional[str] = None

    @validator('cnpj')
    def validar_cnpj(cls, v):
        # Lógica de validação de CNPJ (digito verificador)
        if not verificar_cnpj_valido(v):
            raise ValueError('CNPJ inválido')
        return v

# Uso:
try:
    nf = NotaFiscal(
        cnpj="12.345.678/0001-99",
        valor_total=1234.56,
        data_emissao="2024-03-15"
    )
    print("Válido!")
except ValidationError as e:
    print("Inválido:", e)

Features úteis:

1. Validação de tipos:

class Produto(BaseModel):
    codigo: int  # auto-converte string para int se possível
    preco: float
    ativo: bool

# Aceita:
Produto(codigo="123", preco="45.50", ativo="true")
# Converte para:
# codigo=123, preco=45.5, ativo=True

2. Valores padrão:

class Config(BaseModel):
    timeout: int = 30  # padrão: 30
    retry: bool = True
    max_items: int = Field(default=100, ge=1, le=1000)

3. Validadores customizados:

from pydantic import validator

class Usuario(BaseModel):
    email: str
    idade: int

    @validator('email')
    def validar_email(cls, v):
        if '@' not in v:
            raise ValueError('Email inválido')
        return v.lower()  # normaliza para minúsculas

    @validator('idade')
    def validar_idade(cls, v):
        if v menos de 18:
            raise ValueError('Usuário deve ser maior de idade')
        return v

4. Modelos aninhados:

class Endereco(BaseModel):
    rua: str
    cidade: str
    cep: str

class Cliente(BaseModel):
    nome: str
    endereco: Endereco  # modelo aninhado

# Uso:
cliente = Cliente(
    nome="João Silva",
    endereco={
        "rua": "Av. Paulista, 1000",
        "cidade": "São Paulo",
        "cep": "01310-100"
    }
)

5. Listas e tipos complexos:

from typing import List

class Pedido(BaseModel):
    numero: int
    itens: List[str]  # lista de strings
    valores: List[float]

pedido = Pedido(
    numero=123,
    itens=["Item A", "Item B"],
    valores=[10.50, 25.00]
)

Zod (TypeScript / JavaScript)

Setup básico:

import { z } from 'zod';

const NotaFiscalSchema = z.object({
  cnpj: z.string().regex(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/),
  valorTotal: z.number().positive(),
  dataEmissao: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  descricao: z.string().optional()
});

// Validar:
try {
  const nf = NotaFiscalSchema.parse({
    cnpj: "12.345.678/0001-99",
    valorTotal: 1234.56,
    dataEmissao: "2024-03-15"
  });
  console.log("Válido!");
} catch (e) {
  console.log("Inválido:", e);
}

Features úteis:

1. Transformações:

const schema = z.object({
  email: z.string().email().toLowerCase(),  // transforma para minúsculas
  idade: z.number().int().positive()
});

2. Validações customizadas:

const cnpjSchema = z.string().refine(
  (val) => validarCNPJ(val),  // função customizada
  { message: "CNPJ inválido" }
);

3. Schemas aninhados:

const EnderecoSchema = z.object({
  rua: z.string(),
  cidade: z.string(),
  cep: z.string()
});

const ClienteSchema = z.object({
  nome: z.string(),
  endereco: EnderecoSchema
});

Exemplos práticos completos

Exemplo 1: Extração de dados de contratos (Structured Outputs)

Caso de uso: extrair cláusulas importantes de contratos B2B.

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List
from datetime import date

# 1. Definir schema detalhado
class Clausula(BaseModel):
    tipo: str = Field(..., description="Tipo da cláusula (ex: prazo, pagamento, rescisão)")
    conteudo: str = Field(..., description="Texto da cláusula")
    pagina: int = Field(..., description="Número da página onde aparece")

class ContratoAnalisado(BaseModel):
    partes: List[str] = Field(..., description="Nome das partes (contratante e contratado)")
    valor_total: float = Field(..., description="Valor total do contrato em reais")
    prazo_meses: int = Field(..., description="Prazo de vigência em meses")
    data_inicio: date = Field(..., description="Data de início de vigência")
    clausulas_importantes: List[Clausula] = Field(..., description="Lista de cláusulas relevantes")
    riscos_identificados: List[str] = Field(default=[], description="Possíveis riscos ou pontos de atenção")

# 2. Chamar modelo
client = OpenAI()

texto_contrato = """
[... texto de 30 páginas de contrato ...]
"""

response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {
            "role": "system",
            "content": "Você é um especialista jurídico que analisa contratos B2B."
        },
        {
            "role": "user",
            "content": f"Analise este contrato e extraia informações estruturadas:\n\n{texto_contrato}"
        }
    ],
    response_format=ContratoAnalisado
)

# 3. Output garantido conforme schema
contrato = response.choices[0].message.parsed

print(f"Partes: {contrato.partes}")
print(f"Valor: R$ {contrato.valor_total:,.2f}")
print(f"Prazo: {contrato.prazo_meses} meses")
print(f"Início: {contrato.data_inicio}")

for clausula in contrato.clausulas_importantes:
    print(f"\n{clausula.tipo.upper()} (página {clausula.pagina})")
    print(f"  {clausula.conteudo}")

if contrato.riscos_identificados:
    print("\n⚠️ RISCOS IDENTIFICADOS:")
    for risco in contrato.riscos_identificados:
        print(f"  • {risco}")

Output garantido:

{
  "partes": ["Empresa ABC Ltda", "Fornecedor XYZ S/A"],
  "valor_total": 450000.00,
  "prazo_meses": 24,
  "data_inicio": "2024-04-01",
  "clausulas_importantes": [
    {
      "tipo": "pagamento",
      "conteudo": "Pagamento em 12 parcelas mensais de R$ 37.500,00",
      "pagina": 3
    },
    {
      "tipo": "rescisão",
      "conteudo": "Multa de 30% do valor restante em caso de rescisão antecipada pelo contratante",
      "pagina": 8
    }
  ],
  "riscos_identificados": [
    "Multa rescisória alta (30%) pode tornar saída onerosa",
    "Não há cláusula de reajuste de preços (risco inflacionário)"
  ]
}

Benefícios:

  • ✅ Campos sempre presentes
  • ✅ Tipos corretos (float, int, date)
  • ✅ Listas com schema definido
  • ✅ Validação automática com Pydantic

Exemplo 2: Classificação multi-label (Function Calling)

Caso de uso: classificar tickets de suporte em múltiplas categorias.

import anthropic

client = anthropic.Anthropic()

# 1. Definir tool
tools = [{
    "name": "classificar_ticket",
    "description": "Classifica ticket de suporte em categorias predefinidas",
    "input_schema": {
        "type": "object",
        "properties": {
            "categoria_principal": {
                "type": "string",
                "enum": ["tecnico", "financeiro", "comercial", "duvida"],
                "description": "Categoria principal do ticket"
            },
            "subcategorias": {
                "type": "array",
                "items": {
                    "type": "string",
                    "enum": ["bug", "feature_request", "integracao", "pagamento", "fatura", "contrato", "uso_geral"]
                },
                "description": "Subcategorias aplicáveis (pode ser múltiplas)"
            },
            "urgencia": {
                "type": "string",
                "enum": ["baixa", "media", "alta", "critica"],
                "description": "Nível de urgência"
            },
            "requer_especialista": {
                "type": "boolean",
                "description": "Se requer atenção de especialista técnico"
            },
            "resumo": {
                "type": "string",
                "description": "Resumo de 1 linha do problema"
            }
        },
        "required": ["categoria_principal", "subcategorias", "urgencia", "requer_especialista", "resumo"]
    }
}]

# 2. Classificar ticket
ticket = """
Bom dia,

Estou tentando integrar o sistema de vocês com nosso ERP via API,
mas estou recebendo erro 401 mesmo usando a chave de API correta.
Já tentei regenerar a chave, mas o erro persiste.

Isso está travando nosso processo de faturamento e precisamos
resolver URGENTE pois temos fechamento hoje.

Aguardo retorno.
"""

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=1024,
    tools=tools,
    messages=[{
        "role": "user",
        "content": f"Classifique este ticket:\n\n{ticket}"
    }]
)

# 3. Extrair classificação
classificacao = response.content[0].input

print(classificacao)

Output:

{
  "categoria_principal": "tecnico",
  "subcategorias": ["integracao", "bug"],
  "urgencia": "alta",
  "requer_especialista": true,
  "resumo": "Erro 401 na integração API com ERP bloqueando faturamento"
}

Uso prático:

# Rotear ticket automaticamente baseado na classificação

if classificacao["urgencia"] in ["alta", "critica"]:
    notificar_equipe_urgente(classificacao)

if classificacao["requer_especialista"]:
    atribuir_para_especialista(ticket, classificacao["categoria_principal"])
else:
    atribuir_para_suporte_nivel_1(ticket)

registrar_no_banco(
    ticket_id=ticket.id,
    categoria=classificacao["categoria_principal"],
    subcategorias=classificacao["subcategorias"],
    urgencia=classificacao["urgencia"],
    resumo=classificacao["resumo"]
)

Exemplo 3: Pipeline completo com validação

Caso de uso: extração de dados + validação + retry + fallback.

from openai import OpenAI
from pydantic import BaseModel, Field, validator
from typing import Optional
import json

# 1. Schema com validações rigorosas
class DadosBancarios(BaseModel):
    banco: str = Field(..., min_length=3)
    agencia: str = Field(..., regex=r'^\d{4}$')
    conta: str = Field(..., regex=r'^\d{5,10}-\d$')
    titular: str = Field(..., min_length=5)
    cpf_cnpj: str = Field(..., regex=r'(^\d{3}\.\d{3}\.\d{3}-\d{2}$)|(^\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}$)')

    @validator('cpf_cnpj')
    def validar_documento(cls, v):
        # Validação de CPF ou CNPJ (dígitos verificadores)
        if not validar_cpf_ou_cnpj(v):
            raise ValueError('CPF/CNPJ inválido')
        return v

# 2. Função de extração com retry e fallback
def extrair_dados_bancarios(
    texto: str,
    max_tentativas: int = 3,
    fallback_para_json_mode: bool = True
) -> Optional[DadosBancarios]:

    client = OpenAI()

    # Tentativa 1-N: Structured Outputs (mais confiável)
    for tentativa in range(max_tentativas):
        try:
            response = client.beta.chat.completions.parse(
                model="gpt-4o-2024-08-06",
                messages=[
                    {
                        "role": "system",
                        "content": "Você extrai dados bancários de documentos. Seja preciso."
                    },
                    {
                        "role": "user",
                        "content": f"Extraia dados bancários:\n\n{texto}"
                    }
                ],
                response_format=DadosBancarios
            )

            # Parse e validação automática
            dados = response.choices[0].message.parsed
            return dados  # Sucesso!

        except Exception as e:
            print(f"Tentativa {tentativa + 1} falhou: {e}")
            if tentativa < max_tentativas - 1:
                continue  # Tenta de novo
            else:
                # Última tentativa falhou
                if not fallback_para_json_mode:
                    raise

    # Fallback: JSON mode (menos confiável, mas pode funcionar)
    if fallback_para_json_mode:
        try:
            response = client.chat.completions.create(
                model="gpt-4o",
                messages=[
                    {
                        "role": "system",
                        "content": """Extraia dados bancários e retorne JSON com:
                        - banco (nome do banco)
                        - agencia (4 dígitos)
                        - conta (formato: XXXXX-X)
                        - titular (nome completo)
                        - cpf_cnpj (com pontuação)"""
                    },
                    {
                        "role": "user",
                        "content": texto
                    }
                ],
                response_format={"type": "json_object"}
            )

            # Parse manual e validação com Pydantic
            dados_raw = json.loads(response.choices[0].message.content)
            dados = DadosBancarios(**dados_raw)  # valida com Pydantic
            return dados

        except Exception as e:
            print(f"Fallback também falhou: {e}")
            return None

    return None

# 3. Uso
texto_documento = """
Dados para depósito:
Banco: Itaú
Agência: 1234
Conta: 54321-0
Titular: João Silva
CPF: 123.456.789-00
"""

dados = extrair_dados_bancarios(texto_documento)

if dados:
    print("✅ Extração bem-sucedida!")
    print(dados.dict())
else:
    print("❌ Falha na extração")

Features deste pipeline:

  • ✅ Structured Outputs como abordagem principal (mais confiável)
  • ✅ Retry automático (até 3 tentativas)
  • ✅ Fallback para JSON mode se Structured Outputs falhar
  • ✅ Validação rigorosa com Pydantic (regex, validadores customizados)
  • ✅ Tratamento gracioso de erros

Erros comuns e como evitar

1. Achar que JSON mode garante schema

ERRADO:

# JSON mode não garante campos específicos
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    response_format={"type": "json_object"}
)

dados = json.loads(response.choices[0].message.content)
print(dados["cnpj"])  # ❌ Pode não existir!

CORRETO:

# Use Structured Outputs para garantir schema
response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[...],
    response_format=NotaFiscal  # schema definido
)

dados = response.choices[0].message.parsed
print(dados.cnpj)  # ✅ Garantido existir

2. Não validar tipos mesmo com schema

ERRADO:

class Produto(BaseModel):
    preco: float

# Confiar cegamente
produto = Produto(preco="mil reais")  # ❌ ValueError!

CORRETO:

try:
    produto = Produto(preco="1000.50")  # ✅ Converte string para float
    print(produto.preco)  # 1000.5
except ValidationError as e:
    print(f"Erro de validação: {e}")

3. Schemas muito complexos (LLM não consegue seguir)

EVITE:

class SchemaMuitoComplexo(BaseModel):
    campo1: str
    campo2: int
    campo3: List[str]
    campo4: Dict[str, List[Dict[str, int]]]  # ❌ Muito aninhado
    campo5: Optional[float]
    # ... 20 campos mais

PREFIRA:

# Quebrar em schemas menores
class Item(BaseModel):
    nome: str
    quantidade: int

class Pedido(BaseModel):
    itens: List[Item]  # ✅ Mais simples

4. Não ter retry/fallback

ERRADO:

# Uma tentativa só
dados = extrair_dados(texto)  # ❌ Se falhar, quebra tudo

CORRETO:

# Retry + fallback
dados = extrair_com_retry(texto, max_tentativas=3)
if not dados:
    dados = extrair_com_fallback_json_mode(texto)
if not dados:
    log_erro_e_notificar_equipe(texto)

Quando usar cada abordagem: framework de decisão

┌─ Precisa de JSON? ─────────────┐
│                                 │
│ NÃO → Prompt tradicional        │
│                                 │
│ SIM                             │
│  ↓                              │
│  Schema fixo?                   │
│                                 │
│  NÃO → JSON mode                │
│                                 │
│  SIM                            │
│   ↓                             │
│   LLM precisa "agir"?           │
│                                 │
│   SIM → Function Calling        │
│                                 │
│   NÃO                           │
│    ↓                            │
│    Provider?                    │
│                                 │
│    OpenAI → Structured Outputs  │
│    Anthropic → Function Calling │
│    Outros → JSON mode + validação manual │
└─────────────────────────────────┘

Conclusão

Structured outputs e JSON mode transformam LLMs de “geradores de texto” em “APIs confiáveis”.

Os benefícios:

  • 100% de conformidade com schema (vs 85-95% com prompts)
  • Zero parsing manual (dados já vêm estruturados)
  • Integração nativa com Pydantic/Zod
  • Código mais limpo e manutenível
  • Menos bugs em produção

Recomendação:

  • Produção: sempre use Structured Outputs ou Function Calling
  • Prototipagem: JSON mode é suficiente
  • Exploração: prompts tradicionais são OK

Seu sistema de IA pode continuar retornando dados inconsistentes que quebram em produção. Ou pode usar structured outputs e ter confiabilidade de API tradicional.

A escolha é sua.


Quer implementar extração de dados com IA na sua empresa?

Agende diagnóstico gratuito de 30min →

Pronto para sair do manual?

Agende o diagnóstico gratuito. Vamos mapear o gargalo, estimar o impacto e definir o primeiro resultado mensurável.

Você sai com clareza — não com um pitch de vendas.