Pular para o conteúdo
iauaiiauai — portal de tecnologia, IA e Cloud
IAIntermediário

Guardrails: validando a saída do LLM

Implemente guardrails: valide JSON com schema, detecte PII, rode retry automático com feedback e fallback. Código com Pydantic e regex.

Por Equipe iauai · 10 de agosto de 2026 · 11 min de leitura

Nesta página

O problema real

Você pede ao LLM para extrair informações de um PDF e retornar JSON. A primeira vez, vem perfeito. A segunda vez, vem quebrado — a chave é preço em vez de preco, ou falta um campo. Você coloca "sempre retorne JSON válido" no prompt. Melhora 80%. Mas aquele 20% continua quebrando. Ou pior: o modelo entra em um documento confidencial e extrai um CPF ou número de cartão, que você depois loga ou envia pro usuário. A auditoria de LGPD cobra.

Guardrails são camadas de validação que você coloca depois que o LLM responde, mas antes de usar: schema validation, detecção de dados sensíveis, retry automático com feedback. Não é uma "guarniça" — é crítico em produção.

Como validar saída com schema e retry?

Há duas abordagens principais:

  1. Structured output nativo (OpenAI): O modelo garante retornar JSON válido.
  2. Validação + retry própria (portátil, funciona em qualquer modelo): Você valida, e realimenta erro ao modelo.

Vamos mostrar ambas.

Mão na massa

1. Structured output nativo (OpenAI)

from openai import OpenAI
from pydantic import BaseModel, Field

client = OpenAI()

# Define schema com Pydantic
class Invoice(BaseModel):
    invoice_id: str = Field(..., description="ID único da nota")
    amount: float = Field(..., description="Valor em reais")
    due_date: str = Field(..., description="Data de vencimento (YYYY-MM-DD)")
    supplier_name: str = Field(..., description="Nome do fornecedor")

def extract_invoice_structured(text: str) -> Invoice:
    """Extrai informações de nota usando structured output."""
    
    response = client.beta.messages.create(
        model="gpt-4-2024-08-06",  # Modelo com suporte a structured output
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": f"Extraia os dados da nota fiscal:\n\n{text}"
        }],
        temperature=0,  # Sempre 0 para determinismo
        betas=["interop-2024-12-06"],
        response_model=Invoice  # O SDK garante JSON válido
    )
    
    # Retorna objeto Pydantic validado
    return response.parsed

# Teste
sample_invoice = """
Nota Fiscal #NF-2024-001234
Fornecedor: Distribuidora ABC Ltda
Data de Vencimento: 15 de agosto de 2024
Valor Total: R$ 1.234,56
"""

invoice = extract_invoice_structured(sample_invoice)
print(f"Extracted: {invoice}")
print(f"Amount: R$ {invoice.amount:.2f}")

Vantagem: 100% garantido válido. Sem retry loop. Desvantagem: Só funciona com alguns modelos OpenAI. Mais caro (30% token overhead).

2. Validação + retry manual (portátil)

Funciona em qualquer modelo, local ou cloud.

import json
import re
from typing import Optional
from pydantic import BaseModel, ValidationError, Field

class Invoice(BaseModel):
    invoice_id: str = Field(..., description="ID único")
    amount: float = Field(..., description="Valor em reais")
    due_date: str = Field(..., description="YYYY-MM-DD")
    supplier_name: str = Field(..., description="Nome fornecedor")
    
    class Config:
        json_schema_extra = {
            "example": {
                "invoice_id": "NF-001",
                "amount": 1234.56,
                "due_date": "2024-08-15",
                "supplier_name": "ABC Ltda"
            }
        }

def extract_with_retry(
    text: str,
    max_retries: int = 3,
    model: str = "gpt-4"
) -> Optional[Invoice]:
    """Extrai com validação + retry automático."""
    
    client = OpenAI()
    
    system_prompt = f"""Você é um extrator de notas fiscais. Retorne SEMPRE um JSON válido com exatamente estes campos:
{Invoice.model_json_schema()}

Se algum campo não tiver, use null. Valide o JSON antes de responder."""
    
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": f"Extraia desta nota:\n\n{text}"}
    ]
    
    for attempt in range(max_retries):
        print(f"Tentativa {attempt + 1}/{max_retries}...")
        
        response = client.chat.completions.create(
            model=model,
            messages=messages,
            temperature=0
        )
        
        assistant_message = response.choices[0].message.content
        
        # Tenta extrair JSON
        try:
            # Procura por JSON no response (pode ter texto antes/depois)
            json_match = re.search(r'\{.*\}', assistant_message, re.DOTALL)
            if not json_match:
                raise ValueError("Nenhum JSON encontrado na resposta")
            
            json_str = json_match.group(0)
            data = json.loads(json_str)
            
            # Valida com Pydantic
            invoice = Invoice(**data)
            print(f"✓ Validação bem-sucedida na tentativa {attempt + 1}")
            return invoice
        
        except (json.JSONDecodeError, ValidationError) as e:
            print(f"✗ Erro: {str(e)}")
            
            if attempt < max_retries - 1:
                # Realimenta erro ao modelo
                messages.append({"role": "assistant", "content": assistant_message})
                messages.append({
                    "role": "user",
                    "content": f"""Erro na resposta anterior: {str(e)}
                    
Por favor, corrija o JSON e retorne VÁLIDO. Verifique:
1. Tipos de dados (amount é float, not string)
2. Format de data (YYYY-MM-DD)
3. Todas as aspas estão corretas
4. Sem comma extra no final

Tente novamente:"""
                })
            else:
                print(f"✗ Falhou após {max_retries} tentativas")
                return None
    
    return None

# Teste
sample = "Nota #NF-001, Fornecedor: ABC, Vencimento: 15/08/2024, Total: R$ 1.234,56"
invoice = extract_with_retry(sample, max_retries=3)
if invoice:
    print(f"Resultado: {invoice}")
else:
    print("Falhou em extrair")

Output esperado:

Tentativa 1/3...
✗ Erro: JSON inválido
Tentativa 2/3...
✓ Validação bem-sucedida na tentativa 2
Resultado: Invoice(invoice_id='NF-001', amount=1234.56, due_date='2024-08-15', supplier_name='ABC Ltda')

3. Detecção de dados sensíveis

import re

class SensitiveDataDetector:
    """Detecta CPF, cartão, email, etc. na saída."""
    
    patterns = {
        "cpf": r"\d{3}\.\d{3}\.\d{3}-\d{2}",
        "cnpj": r"\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}",
        "credit_card": r"\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}",
        "email": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
        "phone": r"\(\d{2}\)\s?\d{4,5}-\d{4}",
    }
    
    @classmethod
    def find_sensitive(cls, text: str) -> dict:
        """Encontra dados sensíveis no texto."""
        
        findings = {}
        for data_type, pattern in cls.patterns.items():
            matches = re.findall(pattern, text)
            if matches:
                findings[data_type] = matches
        
        return findings
    
    @classmethod
    def redact(cls, text: str) -> str:
        """Substitui dados sensíveis por ****."""
        
        redacted = text
        for data_type, pattern in cls.patterns.items():
            redacted = re.sub(pattern, f"[{data_type.upper()}]", redacted)
        
        return redacted

# Teste
text = "Cliente: João Silva, CPF: 123.456.789-00, Email: joao@example.com, Cartão: 4111 1111 1111 1111"

findings = SensitiveDataDetector.find_sensitive(text)
print(f"Dados sensíveis encontrados: {findings}")

redacted = SensitiveDataDetector.redact(text)
print(f"Redacted: {redacted}")

Output:

Dados sensíveis encontrados: {
    'cpf': ['123.456.789-00'],
    'email': ['joao@example.com'],
    'credit_card': ['4111 1111 1111 1111']
}
Redacted: Cliente: João Silva, CPF: [CPF], Email: [EMAIL], Cartão: [CREDIT_CARD]

4. Fallback quando modelo insiste em errar

def extract_with_fallback(
    text: str,
    fallback_strategy: str = "regex"
) -> Optional[Invoice]:
    """Tenta LLM, cai para regex se falhar."""
    
    # Tenta com LLM
    invoice = extract_with_retry(text, max_retries=2)
    if invoice:
        return invoice
    
    print("Fallback para regex...")
    
    if fallback_strategy == "regex":
        # Extrai com padrões regex
        id_match = re.search(r"NF[- ]?(\d+)", text)
        amount_match = re.search(r"R\$\s?([\d.]+(?:,\d{2})?)", text)
        date_match = re.search(r"(\d{1,2})[/-](\d{1,2})[/-](\d{4})", text)
        supplier_match = re.search(r"Fornecedor:\s*(.+?)(?:\n|$)", text)
        
        if id_match and amount_match:
            try:
                # Converte para formato esperado
                return Invoice(
                    invoice_id=id_match.group(1),
                    amount=float(amount_match.group(1).replace(".", "").replace(",", ".")),
                    due_date=f"{date_match.group(3)}-{date_match.group(2).zfill(2)}-{date_match.group(1).zfill(2)}",
                    supplier_name=supplier_match.group(1).strip() if supplier_match else "Unknown"
                )
            except:
                pass
    
    print("Fallback também falhou")
    return None

# Teste
sample = "Nota #NF-2024-001, Fornecedor: ABC, Vencimento: 15/08/2024, Total: R$ 1.234,56"
invoice = extract_with_fallback(sample)
print(f"Resultado: {invoice}")

Armadilhas comuns

Armadilha 1: Confiar em "peça JSON no prompt"

Isso funciona em 85% dos casos.

# ❌ Insuficiente
prompt = "Retorne JSON com esses campos: id, name, email"

# ✅ Melhor (mas ainda não 100%)
prompt = """Retorne SEMPRE um JSON válido com exatamente estes campos:
{"id": "string", "name": "string", "email": "string"}
Valide o JSON antes de responder. Se faltar um campo, use null."""

Ainda precisa validação. A validação é seu guardrail — o prompt é só sugestão.

Armadilha 2: Não limpar dados sensíveis antes de logar

# ❌ Perigoso — loga CPF
logger.info(f"Extracted: {invoice}")

# ✅ Seguro
redacted = SensitiveDataDetector.redact(str(invoice))
logger.info(f"Extracted: {redacted}")

Todo log é auditável (LGPD, SOX, etc.). Nunca loga dados sensíveis.

Armadilha 3: Retry infinito

Se definir max_retries=100 e o modelo não conseguir, fica travado. Use limite sensato (2-3).

Armadilha 4: Fallback mais complexo que LLM

Se escrever um fallback em regex com 500 linhas, fica unmaintainable. Mantenha simples — ou regex muito básico, ou chama API diferente.

Quando não usar guardrails agressivos

  • Tarefas open-ended: Se pede um ensaio, não valida schema rígido. Seria contraproducente.
  • Latência crítica: Cada retry adiciona 1-2 segundos. Se precisa < 500ms, use structured output ou não valida.
  • Modelo já é confiável: Se usa GPT-4 em tarefa simples, 99%+ válido. Validação é overhead.
  • Custo é crítico: Retry = tokens duplicados. Se cada token custa, isso fica caro (3× mais custoso em caso de falhas).

Próximos passos

  1. Integre com function calling na prática — tool calls também precisam validação.
  2. Leia segurança em aplicações com LLM para sanitização em profundidade.
  3. Teste com laboratório regex para refinar padrões de detecção.

Resumo prático

Técnica Complexidade Confiabilidade Custo
Structured output Baixa 99,9% Alto (30% overhead)
Validação + retry Média 95-98% Médio (retry custa)
Regex fallback Média 80-90% Baixo (local)
Detecção de PII Baixa 90% (false positives) Baixo (local)

Recomendação: Use structured output para tarefas críticas. Combine validação + retry para modelos menores. Sempre roda detecção de dados sensíveis antes de logar ou expor. Em 90% dos casos, validação + retry em 2-3 tentativas resolve o problema sem custo proibitivo.

Aprenda jogando

Firewall Regex

Bloqueie payloads maliciosos sem barrar usuário legítimo. Falso positivo custa caro.

Jogar Firewall Regex

Continue lendo