Na semana passada contei como minha IA inventou uma estrutura JSON completa e a envolveu em DTOs, fixtures e testes que passaram. 90 testes no verde. Tudo mentira.

Esse post foi o diagnóstico. Este é o tratamento.

Depois de descobrir o desastre, fiz o que faz qualquer engenheiro com orgulho ferido: investigar obsessivamente durante dias para que não volte a acontecer. Li papers, testei ferramentas, analisei dados reais das minhas APIs, e construí um sistema de defesas para minha app.

O que encontrei me surpreendeu. Das 5 medidas reativas que identifiquei, apenas 3 funcionam de verdade. As outras duas são, no mínimo, teatro com boas intenções.

O modelo mental: você contra a IA (literalmente)

Antes de entrar nas medidas, você precisa entender o framework. E a melhor analogia que encontrei vem do deep learning.

Em uma GAN (Generative Adversarial Network) há duas redes neurais que competem:

  • O gerador produz conteúdo (imagens, texto, o que for)
  • O discriminador tenta detectar se o conteúdo é real ou falso

O sistema melhora porque ambos se empurram mutuamente. O gerador aprende a enganar melhor. O discriminador aprende a detectar melhor.

Quando você programa com um LLM, está em uma GAN involuntária:

  • O LLM é o gerador. Produz código, DTOs, testes, fixtures.
  • Você é o discriminador. Deve detectar o que é real e o que é inventado.

Mas há uma assimetria brutal: o gerador é incansável e você se cansa. O LLM pode gerar 50 arquivos sem se despenteiar. Você revisa 10, se fatiga, e o arquivo 11 passa sem que o olhe.

É a mesma fadiga de autorização que contei com 1Password pidindo Touch ID 47 vezes ao dia. A segurança que depende de um humano estar permanentemente alerta é segurança de papelão.

O que o discriminador deve vigiar

Você não pode (nem deve) revisar cada linha. O que tem que vigiar são as fronteiras — onde seu código toca o mundo exterior:

FronteiraPergunta chave
APIs externasOs campos do DTO existem na API real?
PacotesA dependência existe e se chama assim?
Schemas de BDA tabela tem realmente essas colunas?
URLs/endpointsO endpoint existe e responde o que esperamos?

Regra: tudo o que o LLM declara sobre o mundo exterior é suspeito até verificação. Que diga com confiança não é evidência. A Anthropic reconhece isso em sua própria documentação:

“Claude can sometimes generate responses that contain fabricated information… presented in a confident, authoritative manner.”

Um LLM que diz “tenho certeza” e outro que diz “acho que” têm exatamente a mesma probabilidade de estar errados.

Automatizar o discriminador

O objetivo final é deixar de depender da sua disciplina e automatizar a verificação:

ANTES:
  LLM gera → Você revisa (às vezes) → Merge

DEPOIS:
  LLM gera → CI verifica contra dados reais → Você revisa discrepâncias → Merge

As 5 medidas que vêm são formas de automatizar partes desse papel de discriminador. Algumas funcionam. Outras nem tanto.

Os dados duros (para os céticos)

Antes que pense “comigo não acontece”, aqui vão números de estudos reais:

  • 21.7% dos pacotes recomendados por LLMs open-source são inventados. Em modelos comerciais cai para 5.2%, que ainda é um pacote a cada 20.
  • GPT-4o só consegue 38.58% de invocações válidas para APIs pouco frequentes. Menos de 40%. Jogue uma moeda para o ar.
  • Os melhores métodos atuais para localizar alucinações em código conseguem 22-33% de precisão. Falando claro: detectamos uma em cada quatro.
  • Um pesquisador subiu um pacote vazio com um nome que os LLMs alucinavam frequentemente. 30.000 downloads em 3 meses. Chamam de slopsquatting.

E há uma taxonomia formal. O paper CodeHalu (AAAI 2025) define 4 categorias de alucinações de código:

CategoriaO que éExemplo real
MappingCampos mapeados incorretamenteConfundir user_id com account_id
NamingNomes inventadosresponse.quota.percentage quando é response.utilization
ResourceRecursos que não existemCampo active_flags numa API que não o tem
LogicLógica plausível mas incorretaisPaid = !activeFlags.isEmpty com um campo sempre vazio

Meu caso foi um Resource que derivou em um Logic. O campo não existia, e a lógica que dependia dele parecia perfeita. Ficção coerente de livro.

Medida 1: Contract testing contra APIs reais

A ideia

Definir um “contrato” do que a API retorna e verificar automaticamente que seu código é compatível. Se seu DTO tem campos que o contrato não define: alarme.

Como funciona

Imagine que você tem um DTO assim:

struct OrganizationInfo: Decodable {
    let uuid: String
    let name: String
    let activeFlags: [String]  // ← Isso existe mesmo?
}

Um contract test pega a resposta real da API, extrai as keys do JSON, e as compara com as CodingKeys do seu DTO. Se seu DTO tem um campo que a API não retorna, é um PHANTOM — um campo fantasma, possivelmente inventado.

Keys na API real:     {uuid, name, capabilities, billing_type}
Keys no DTO:          {uuid, name, activeFlags}

PHANTOM: activeFlags  ← No DTO mas NÃO na API. Alucinado?
UNCONSUMED: capabilities, billing_type ← Na API mas não no DTO.

Prós

  • Determinístico. Não depende de outro LLM nem do seu faro. Se o campo não está na API, dispara.
  • Elimina phantom fields por construção. É impossível que um campo inventado passe.
  • Automatizável em CI. Você executa em cada push.

Contras

  • Você precisa da spec da API. Se a API não tem OpenAPI spec (como a do Claude), tem que capturar respostas manualmente.
  • Não detecta naming incorreto. Se o campo existe mas se chama diferente (active_flags vs capabilities), não pega automaticamente.
  • Requer credenciais. Para capturar a resposta real você precisa de uma sessão válida.

Ferramentas por stack

StackFerramentaAbordagem
PythonPydantic extra='forbid'Rejeita campos JSON não declarados no modelo
TypeScriptZod .strict()Mesmo conceito, rejeita extras
SwiftDecoder custom ou comparação manual de keysCodable ignora chaves desconhecidas por padrão
Dartjson_serializable + disallowUnrecognizedKeysRejeita campos não declarados
Agnósticooasdiff, SpecmaticComparam specs OpenAPI

O que implementei

Na minha app (Swift/SPM) não há OpenAPI spec da API do Claude. Então construí uma validação bidirecional na mão:

  1. make capture baixa respostas reais de todas as APIs e as salva como fixtures em Fixtures/real/
  2. SchemaValidationTests compara as CodingKeys.allCases de cada DTO contra as keys do fixture real
  3. Se há discrepância → PHANTOM (campo no DTO mas não na API) ou UNCONSUMED (campo na API que não consumimos)
$ make doctor
✅ OrganizationInfo: 4 comum, 0 phantom, 8 não consumido
✅ UsageResponse: 9 comum, 0 phantom, 1 não consumido
⚠️  StatsCache: campo PHANTOM 'totalSpeculationTimeSaved' — não está nos dados reais

Os campos intencionalmente não consumidos vão numa allowlist documentada com a razão. Se amanhã aparecer um campo novo na API, o teste falha com UNCONSUMED e fico sabendo.

Veredito: a medida mais importante. Se só implementar uma, que seja esta.

Medida 2: Fixture validation (fixtures reais, não inventados)

A ideia

Os fixtures de teste devem vir de dados reais capturados, não escritos à mão pelo LLM. Se o LLM gera o fixture, você está validando ficção contra ficção.

O problema que resolve

George Tsiokos acertou em cheio num post de fevereiro de 2025:

“Os testes não validam que o software cumpre necessidades de negócio — simplesmente confirmam que o código faz exatamente o que foi escrito para fazer, incluindo bugs.”

Quando o LLM gera o código E os testes E os fixtures:

LLM inventa campo → LLM escreve fixture com esse campo → LLM escreve teste
→ Teste passa ✅ → Ninguém verificou contra a realidade ❌

A solução: record-replay

Os frameworks record-replay gravam respostas HTTP reais e as reproduzem em testes. Não há possibilidade de invenção porque o fixture vem da API, não do modelo.

StackFerramenta
PythonVCR.py, pytest-recording
TypeScriptPolly.js (Netflix), MSW
SwiftReplay (mattt)
AgnósticoHoverfly

Prós

  • Impossível inventar. O fixture vem da rede, não do modelo.
  • Inclui metadados. URL, timestamp, status code. Você pode rastrear de onde veio.
  • Se commita ao repo. Os reviewers veem exatamente o que a API retornou.

Contras

  • O fixture envelhece. Se a API muda, o fixture capturado já não é representativo.
  • Credenciais em CI. Você precisa poder chamar a API para gravar.
  • Não escala para todas as variações. Você captura uma resposta, mas a API pode retornar muitas formas diferentes.

O que implementei

Duas camadas de fixtures:

Tests/Fixtures/          ← Estáticos, escritos pelo LLM
                           Usados para testes unitários de decode
                           PODEM conter erros (isso é aceitável)

Tests/Fixtures/real/     ← Capturados por make capture
                           Com arquivo .meta (timestamp de captura)
                           Fonte de verdade para validação de schema

Os fixtures estáticos são úteis para testar edge cases (JSON truncado, campos vazios, formatos estranhos). Mas a validação de “esses campos existem mesmo?” sempre usa o fixture real.

Cada fixture real tem um arquivo .meta com o timestamp de captura. Se um fixture tem mais de 30 dias, você já sabe que é hora de renovar.

Veredito: essencial como complemento do contract testing. Sozinho não basta (você precisa da comparação da Medida 1), mas sem fixtures reais a Medida 1 não tem contra o que comparar.

Medida 3: Smoke tests com dados reais (make doctor)

A ideia

Antes de dar por boa uma mudança, fazer uma chamada real à API e verificar que seus DTOs fazem o parse da resposta sem perda silenciosa.

Como funciona

$ make doctor
Capturando /api/organizations... OK (2 orgs)
Capturando /api/organizations/{id}/usage... OK (9 janelas)
Capturando ~/.claude/stats-cache.json... OK (115 sessões)
Capturando session JSONL... OK (847 entradas)

Validando schemas...
✅ OrganizationInfo: OK
✅ UsageResponse: OK
✅ StatsCache: OK
✅ SessionEntry: OK

0 campos phantom, 0 novos campos não consumidos

É make capture + make test num só passo. Captura dados frescos de produção e os cruza contra os DTOs.

Prós

  • A defesa mais honesta. Dados reais, comparação direta, resultado inequívoco.
  • Rápido. 30 segundos em local.
  • Detecta drift. Se a API adiciona ou remove campos, você sabe imediatamente.

Contras

  • Requer sessão ativa. Você precisa estar logado para capturar.
  • Não vai em CI (no meu caso). A API do Claude não tem credenciais de serviço, só cookies de sessão.
  • É manual. Depende de você se lembrar de executar.

O que implementei

make doctor é o comando mais importante do meu projeto. Executo:

  • Depois de cada mudança em DTOs
  • Uma vez por semana como rotina
  • Quando algo “cheira estranho” na app

Para as APIs que não posso chamar em CI, o truque é salvar o resultado do doctor como fixture real que vai ao repo. O CI valida contra esse fixture. Não é tempo real, mas é melhor que nada.

Além disso, o sistema emite sinais precoces em runtime: se o SessionFileReader lê linhas do tipo assistant sem campo usage, loga um .notice. Se o SessionTokenService lê arquivos mas encontra 0 entradas novas, também. A ideia é que a app avise se o formato mudou, mesmo que não quebre (porque a graceful degradation pode ocultar o problema).

Veredito: a medida mais prática. Baixo custo, alto valor. Se você tem 30 segundos, tem make doctor.

Medida 4: Anomaly detection em parsing (campos sempre null)

A ideia

Monitorar em runtime quais campos dos seus modelos se populam com dados reais e quais são sempre nil. Um campo que leva 50 parsings seguidos sendo nil é suspeito de ser inventado.

O modelo mental

GraphQL tem isso resolvido. Ferramentas como Apollo GraphOS reportam uso por campo: quantas vezes foi solicitado, quantas retornaram dados, primeira e última vez que foi usado. Os campos com 0% de uso são marcados para eliminar.

Para REST, não existe equivalente. Você tem que construir.

Prós

  • Detecta em produção. Não precisa capturar manualmente; o próprio uso da app gera os dados.
  • Complementa as outras medidas. Um campo que passa no contract test (existe na API) mas sempre é null na prática ainda é suspeito.

Contras

  • Você precisa de volume. Com 5 chamadas não pode concluir nada. Precisa de centenas.
  • Falsos positivos. Um campo pode ser legitimamente null 95% do tempo (ex. seven_day_opus: null na minha API é normal se não usar Opus nessa semana).
  • Implementação manual. Não há ferramenta que você conecte. Tem que escrever o monitor.
  • Em apps cliente, sem APM. Num backend com Datadog ou Sentry, você emite métricas customizadas. Numa app macOS de menu bar, você está sozinho.

O que implementei

Parcialmente. Não tenho um monitor formal de campos-sempre-nil, mas tenho os sinais precoces nos logs:

// SessionTokenService.swift
if totalFilesRead > 0 && totalNewEntries == 0 {
    logger.notice("li \(totalFilesRead) arquivos mas 0 entradas novas — possível mudança de formato")
}

É a versão low-tech de anomaly detection. Não conta por campo, mas detecta o caso grande: “estou lendo dados mas não sai nada útil”.

Veredito: útil como sinal de alerta, mas não como defesa primária. É um canário na mina, não um muro.

Medida 5: Diff semântico pós-geração (LLM-as-Judge)

A ideia

Usar um segundo LLM (ou o mesmo com um prompt diferente) para auditar o código gerado, buscando campos ou estruturas que não consegue verificar contra documentação conhecida.

O estado da arte

Há ferramentas sérias trabalhando nisso:

FerramentaO que faz
VERDICT (Haize Labs)Pipeline modular: verificação + debate + agregação
DeepEvalFramework tipo pytest com HallucinationMetric
Patronus LynxModelo SOTA detecção alucinações, open-source
Vectara HHEMModelo + API, reduz alucinações a ~0.9% em enterprise

E a opção caseira: pedir ao GPT-4o que gere DTOs para a mesma API sem ver seu código, e comparar:

Claude diz:   activeFlags: [String]
GPT-4o diz:   capabilities: [String]
→ DISCREPÂNCIA: pelo menos um alucina. Verificar contra API real.

Prós

  • Escala sem esforço manual. Você coloca em CI e executa sozinho.
  • Detecta padrões sutis. Um segundo modelo pode notar coisas que você não.

Contras

E aqui é onde a coisa fica feia.

  • O juiz pode alucinar também. Se o segundo LLM não conhece a API, pode “confirmar” campos inventados.
  • Alucinações sistemáticas. Se ambos os modelos foram treinados com dados similares, podem compartilhar a mesma invenção. SelfCheckGPT (Cambridge, EMNLP 2023) demonstrou que a consistência multi-amostra não detecta alucinações sistemáticas.
  • Precisão deplorável. Collu-Bench: os melhores métodos conseguem 22-33% de precisão localizando alucinações de código. Você detecta uma em cada quatro. Isso não é uma defesa, é um sorteio.
  • Custo. Cada camada multiplica chamadas ao LLM. Você está pagando por um detector que acerta um terço do tempo.
  • Viés de posição. Os LLMs juízes preferem respostas mais longas e as que aparecem primeiro. Não julgam; têm preferências estéticas.

A Evidently AI resumiu com uma pergunta demolidora:

“Como você monitora um sistema que ocasionalmente alucina com outro sistema que ocasionalmente alucina?”

O que implementei

Nada. Zero.

E é uma decisão consciente. As medidas determinísticas (1, 2 e 3) me dão detecção confiável, reproduzível, sem falsos positivos nem custos por chamada. Colocar um LLM para vigiar outro LLM é como colocar um estagiário para supervisionar outro estagiário. Melhor colocar uma câmera.

Veredito: pesquisa interessante, produção prematura. Quando a precisão passar de 33% para 90%, conversamos. Hoje, é teatro com orçamento de P&D.

O placar final

MedidaConfiabilidadeCusto¿Implementada?Por quê?
1. Contract testingAltaMédioSimDetecta phantom fields mecanicamente
2. Fixture validationAltaBaixoSimFixtures reais eliminam ficção-valida-ficção
3. Smoke tests (make doctor)AltaBaixoSim30 segundos, máximo valor
4. Anomaly detectionMédiaBaixoParcialSinais em logs, não monitor formal
5. LLM-as-JudgeBaixaAltoNão22-33% precisão = sorteio

As medidas 1, 2 e 3 formam um tripé. Cada uma cobre um ângulo diferente:

  • Contract testing responde: “esses campos existem?”
  • Fixture validation responde: “esses dados são reais?”
  • Smoke tests responde: “isso funciona agora mesmo?”

Juntas, fazem com que um campo inventado tenha que sobreviver a três filtros independentes. Não é impossível, mas é muito mais difícil que enganar um teste unitário com um fixture inventado.

A regra de ouro

Quero terminar com a regra mais importante que tirei de tudo isso:

O sistema de verificação deve ser externo ao gerador.

Se o LLM gera:

  • O código → OK, é seu trabalho
  • Os testes de lógica → OK, verificam comportamento
  • Os fixtures → NÃO, devem vir de dados reais
  • Os schemas → NÃO, devem vir da spec da API
  • A validação de que os dados estão corretos → NÃO, faz um sistema determinístico

É a separação de poderes aplicada ao desenvolvimento. Quem escreve a lei não pode ser quem a julga. Quem gera o código não pode ser quem verifica que está correto.

Você pode ter 200 testes no verde e estar vivendo no Matrix. Ou pode ter um make doctor que em 30 segundos te diz se seus dados são reais ou ficção.

Eu prefiro a pílula vermelha.


Série completa: Este post é o quarto capítulo de uma série involuntária sobre falhas de IA em produção. Primeiro foram os 44 emails inventados (a IA que age sem permissão). Depois MEMORY.md (a IA que esquece). Em seguida o silent failure (a IA que inventa e passa nos testes). E agora, as defesas. Cada falha diferente, um denominador comum: precisamos de sistemas mecânicos, não promessas de bom comportamento.

Este artigo foi escrito originalmente em espanhol e traduzido com a ajuda de IA.