Aqui você vai aprender a colocar uma camada de validação entre o LLM e o resto do seu sistema: conferir o formato com Pydantic, tentar de novo com o erro como feedback, bloquear vazamento de dados pessoais e cair num caminho seguro quando nada funciona.
O problema real
Você pede ao modelo para extrair os dados de uma nota fiscal e devolver JSON. Na primeira vez, perfeito. Na segunda, a chave vem como preço em vez de preco. Na terceira, o valor vem como "R$ 1.234,56" em texto e seu cálculo quebra. Você escreve "retorne SEMPRE JSON válido" em maiúsculas e melhora, mas não resolve.
O prompt é um pedido. Quem garante é o código. Guardrails de saída são verificações que rodam depois que o modelo responde e antes de você usar a resposta.
Três camadas
- Formato: a resposta tem o esquema esperado? (Pydantic, saída estruturada.)
- Conteúdo: contém algo que não pode sair, como CPF ou cartão?
- Recuperação: se falhou, tentar de novo com feedback ou cair num fallback.
1. Saída estruturada do provedor
Vários provedores já impõem um esquema durante a geração, o que elimina quase todo JSON quebrado. Com o SDK da OpenAI, por exemplo, você passa um modelo Pydantic e recebe o objeto já parseado:
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class NotaFiscal(BaseModel):
numero: str
valor: float
vencimento: str # AAAA-MM-DD
fornecedor: str
def extrair_nota(texto: str, modelo: str) -> NotaFiscal:
resposta = client.chat.completions.parse(
model=modelo, # use um modelo com suporte a saída estruturada
messages=[
{"role": "system", "content": "Extraia os dados da nota fiscal. Datas em AAAA-MM-DD, valor como número."},
{"role": "user", "content": texto},
],
response_format=NotaFiscal,
)
return resposta.choices[0].message.parsed
Outros provedores têm recursos equivalentes (esquema JSON na requisição ou tool use forçado), com nomes e limites diferentes, então confira a documentação do seu. Mesmo com saída estruturada, o esquema só garante a forma: um valor negativo ou uma data no passado distante continuam válidos para ele. Por isso a validação de negócio no seu código continua necessária.
2. Validação e retry portáteis
Quando o provedor não impõe esquema, ou você usa um modelo local, valide por conta própria e devolva o erro ao modelo. O exemplo usa o SDK da Anthropic, mas o padrão é o mesmo em qualquer um.
import json
import os
import re
from datetime import date
import anthropic
from pydantic import BaseModel, Field, ValidationError, field_validator
client = anthropic.Anthropic()
MODELO = os.environ["EXTRATOR_MODEL"]
class NotaFiscal(BaseModel):
numero: str
valor: float = Field(gt=0)
vencimento: date
fornecedor: str = Field(min_length=2)
@field_validator("vencimento")
@classmethod
def vencimento_plausivel(cls, v: date) -> date:
if v.year < 2000 or v.year > date.today().year + 5:
raise ValueError("vencimento fora de uma faixa plausível")
return v
SISTEMA = (
"Extraia os dados da nota fiscal e responda só com JSON neste formato: "
'{"numero": "texto", "valor": 0.0, "vencimento": "AAAA-MM-DD", "fornecedor": "texto"}. '
"Use ponto como separador decimal. Não escreva nada fora do JSON."
)
def extrair_com_retry(texto: str, tentativas: int = 3) -> NotaFiscal | None:
mensagens = [{"role": "user", "content": texto}]
for _ in range(tentativas):
r = client.messages.create(
model=MODELO, max_tokens=400, system=SISTEMA, messages=mensagens
)
bruto = r.content[0].text
try:
achado = re.search(r"\{.*\}", bruto, re.DOTALL)
if not achado:
raise ValueError("nenhum JSON encontrado")
return NotaFiscal(**json.loads(achado.group(0)))
except (ValueError, ValidationError, TypeError) as erro:
mensagens += [
{"role": "assistant", "content": bruto},
{"role": "user", "content": f"A resposta falhou na validação: {erro}. Corrija e responda só com o JSON."},
]
return None
Note que json.JSONDecodeError e ValidationError herdam de ValueError, então o except cobre os dois. O limite de tentativas importa: cada retry repete o histórico inteiro, então o custo cresce. Duas ou três tentativas bastam; se o modelo não acerta por três vezes, o problema costuma estar no prompt ou na entrada.
3. Dados pessoais na saída
Antes de exibir ou registrar a resposta, procure o que nunca deveria aparecer. Expressões regulares pegam o formato, mas formato não é prova: validar o dígito verificador do CPF e o algoritmo de Luhn do cartão derruba muitos falsos positivos.
import re
CPF = re.compile(r"\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b")
CARTAO = re.compile(r"\b(?:\d[ -]?){13,19}\b")
EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
def cpf_valido(texto: str) -> bool:
d = [int(c) for c in texto if c.isdigit()]
if len(d) != 11 or len(set(d)) == 1:
return False
for n in (9, 10):
soma = sum(d[i] * (n + 1 - i) for i in range(n))
if (soma * 10 % 11) % 10 != d[n]:
return False
return True
def luhn_valido(texto: str) -> bool:
d = [int(c) for c in texto if c.isdigit()][::-1]
total = sum(d[0::2]) + sum(sum(divmod(x * 2, 10)) for x in d[1::2])
return len(d) >= 13 and total % 10 == 0
def mascarar(texto: str) -> str:
texto = CPF.sub(lambda m: "[CPF]" if cpf_valido(m.group()) else m.group(), texto)
texto = CARTAO.sub(lambda m: "[CARTAO]" if luhn_valido(m.group()) else m.group(), texto)
return EMAIL.sub("[EMAIL]", texto)
print(mascarar("CPF 529.982.247-25, cartão 4111 1111 1111 1111, [email protected]"))
# CPF [CPF], cartão [CARTAO], [EMAIL]
Essa checagem é uma rede de proteção, não a defesa principal. Nomes, endereços e dados em texto livre passam por ela. A defesa principal é não colocar o dado no contexto do modelo sem necessidade, tema de segurança em aplicações com LLM. E vale lembrar da LGPD: o log com dado pessoal sem máscara também é tratamento de dados.
4. Fallback
Se as tentativas acabaram, decida de antemão o que acontece. Opções, da mais simples à mais cara:
- Devolver um erro claro ao chamador e registrar o caso para revisão.
- Mandar o item para uma fila de revisão humana.
- Usar um extrator mais simples (regex para o campo mais importante), aceitando que cobre menos casos.
Evite fallbacks elaborados. Um regex de 300 linhas para imitar o modelo vira outro sistema para manter. Na maioria dos casos, fila de revisão humana com o motivo da falha é a opção mais sensata.
Armadilhas comuns
- Achar que o prompt é a validação. Ele melhora a taxa de acerto, mas só o código garante.
- Retry sem limite. Duas ou três tentativas, depois fallback.
- Logar a saída crua antes de mascarar.
- Validar só o formato. Um JSON perfeito pode trazer valor absurdo, e por isso os validadores de negócio (
gt=0, faixa de data) existem. - Aplicar a mesma rigidez a texto livre. Em respostas abertas, valide o que importa (tamanho, termos proibidos, dados pessoais), não um esquema.
Quando relaxar
Em tarefas abertas como rascunho de texto, regras demais atrapalham. Se a latência é crítica, prefira saída estruturada nativa e um único passo de validação, sem retry. E meça com evals quanto a camada realmente reduz de erro antes de aumentar a complexidade.
Próximos passos
Para dar ferramentas ao modelo com argumentos validados, veja function calling na prática. Para treinar a escrita de padrões de detecção, use o laboratório de regex e o jogo Firewall Regex.
Quer aplicar isso na sua empresa? Marque uma conversa de 45 minutos em https://iauaicloud.com.br/consultoria