Na semana passada contei como minha IA inventou uma estrutura JSON completa e a envolveu em DTOs, fixtures e testes que passavam. 90 testes verdes. Tudo mentira.
Esse post era o diagnóstico. Este é o tratamento.
Depois de descobrir o desastre, fiz o que qualquer engenheiro com o orgulho ferido faz: pesquisar obsessivamente por dias para que não aconteça novamente. Li papers, testei ferramentas, analisei dados reais das minhas APIs, e construí um sistema de defesas para minha aplicação.
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, qualquer coisa)
- O discriminador tenta detectar se o conteúdo é real ou falso
O sistema melhora porque ambos se pressionam 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 despentear. Você revisa 10, se fatiga, e o arquivo 11 passa sem que você o veja.
É a mesma fadiga de autorização que contei com 1Password pedindo Touch ID 47 vezes por 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:
| Fronteira | Pergunta chave |
|---|---|
| APIs externas | Os campos do DTO existem na API real? |
| Pacotes | A dependência existe e se chama assim? |
| Schemas de BD | A tabela tem realmente essas colunas? |
| URLs/endpoints | O 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 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 um que diz “acho que” têm exatamente a mesma probabilidade de estarem 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 isso 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.
- Os melhores métodos atuais para localizar alucinações em código conseguem 22-33% de precisão. Falando claro: detectamos uma a cada quatro.
- Um pesquisador enviou um pacote vazio com um nome que os LLMs alucinavam frequentemente. 30.000 downloads em 3 meses. Chamam isso de slopsquatting.
E há uma taxonomia formal. O paper CodeHalu (AAAI 2025) define 4 categorias de alucinações de código:
| Categoria | O que é | Exemplo real |
|---|---|---|
| Mapping | Campos mapeados incorretamente | Confundir user_id com account_id |
| Naming | Nomes inventados | response.quota.percentage quando é response.utilization |
| Resource | Recursos que não existem | Campo active_flags em uma API que não o tem |
| Logic | Lógica plausível mas incorreta | isPaid = !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:
| |
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. Execute em cada push.
Contras
- Precisa da spec da API. Se a API não tem OpenAPI spec (como a do Claude), você tem que capturar respostas manualmente.
- Não detecta naming incorreto. Se o campo existe mas se chama diferente (
active_flagsvscapabilities), não pega automaticamente. - Requer credenciais. Para capturar a resposta real você precisa de uma sessão válida.
Ferramentas por stack
| Stack | Ferramenta | Abordagem |
|---|---|---|
| Python | Pydantic extra='forbid' | Rejeita campos JSON não declarados no modelo |
| TypeScript | Zod .strict() | Mesmo conceito, rejeita extras |
| Swift | Decoder customizado ou comparação manual de keys | Codable ignora chaves desconhecidas por padrão |
| Dart | json_serializable + disallowUnrecognizedKeys | Rejeita campos não declarados |
| Agnóstico | oasdiff, Specmatic | Comparam specs OpenAPI |
O que implementei
Na minha aplicação (Swift/SPM) não há OpenAPI spec da API do Claude. Então construí uma validação bidirecional manual:
make capturebaixa respostas reais de todas as APIs e as salva como fixtures emFixtures/real/SchemaValidationTestscompara asCodingKeys.allCasesde cada DTO contra as keys do fixture real- 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 common, 0 phantom, 8 unconsumed
✅ UsageResponse: 9 common, 0 phantom, 1 unconsumed
⚠️ StatsCache: PHANTOM field 'totalSpeculationTimeSaved' — not in real data
Os campos intencionalmente não consumidos vão em uma allowlist documentada com o motivo. Se amanhã aparece um campo novo na API, o teste falha com UNCONSUMED e fico sabendo.
Veredicto: 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 manualmente 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 em um post de fevereiro de 2025:
“Os testes não validam que o software atende 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.
| Stack | Ferramenta |
|---|---|
| Python | VCR.py, pytest-recording |
| TypeScript | Polly.js (Netflix), MSW |
| Swift | Replay (mattt) |
| Agnóstico | Hoverfly |
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.
- É commitado no 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 conseguir chamar a API para gravar.
- Não escala para todas as variações. 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 da 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 de verdade?” 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.
Veredicto: essencial como complemento do contract testing. Sozinha 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 como boa uma mudança, fazer uma chamada real à API e verificar que seus DTOs fazem parse da resposta sem perda silenciosa.
Como funciona
| |
É make capture + make test em um 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 no 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 no 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 aplicação
Para as APIs que não posso chamar no CI, o truque é salvar o resultado do doctor como fixture real que vai para o 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 entries novas, também. A ideia é que a aplicação avise se o formato mudou, mesmo que não quebre (porque a graceful degradation pode ocultar o problema).
Veredicto: 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 são populados com dados reais e quais são sempre nil. Um campo que leva 50 parses seguidos sendo nil é suspeito de ter sido 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 eliminação.
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 aplicação 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
- 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: nullna minha API é normal se não uso Opus essa semana). - Implementação manual. Não há ferramenta que você pluga. Tem que escrever o monitor.
- Em aplicações cliente, sem APM. Num backend com Datadog ou Sentry, você emite métricas customizadas. Numa aplicação macOS de menu bar, está sozinho.
O que implementei
Parcialmente. Não tenho um monitor formal de campos-sempre-nil, mas tenho os sinais precoces nos logs:
| |
É a versão low-tech de anomaly detection. Não conta por campo, mas detecta o caso gordo: “estou lendo dados mas não sai nada útil”.
Veredicto: ú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, procurando campos ou estruturas que não consegue verificar contra documentação conhecida.
O estado da arte
Há ferramentas sérias trabalhando nisso:
| Ferramenta | O que faz |
|---|---|
| VERDICT (Haize Labs) | Pipeline modular: verificação + debate + agregação |
| DeepEval | Framework tipo pytest com HallucinationMetric |
| Patronus Lynx | Modelo SOTA detecção alucinações, open-source |
| Vectara HHEM | Modelo + API, reduz alucinações para ~0.9% em enterprise |
E a opção caseira: pedir ao GPT-4o para gerar 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. Coloca no 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 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 a cada quatro. Isso não é uma defesa, é um sorteio.
- Custo. Cada camada multiplica chamadas para LLM. Você está pagando por um detector que acerta um terço das vezes.
- 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.
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 uma 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.
Veredicto: 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
| Medida | Confiabilidade | Custo | Implementada? | Por quê? |
|---|---|---|---|---|
| 1. Contract testing | Alta | Médio | Sim | Detecta phantom fields mecanicamente |
| 2. Fixture validation | Alta | Baixo | Sim | Fixtures reais eliminam ficção-valida-ficção |
3. Smoke tests (make doctor) | Alta | Baixo | Sim | 30 segundos, máximo valor |
| 4. Anomaly detection | Média | Baixo | Parcial | Sinais nos logs, não monitor formal |
| 5. LLM-as-Judge | Baixa | Alto | Não | 22-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 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, é o trabalho dele
- 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, um sistema determinístico faz isso
É 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 verdes e estar vivendo na 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.