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:
- Structured output nativo (OpenAI): O modelo garante retornar JSON válido.
- 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
- Integre com function calling na prática — tool calls também precisam validação.
- Leia segurança em aplicações com LLM para sanitização em profundidade.
- 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.