La semana pasada conté cómo mi IA inventó una estructura JSON completa y la envolvió en DTOs, fixtures y tests que pasaban. 90 tests en verde. Todo mentira.
Ese post era el diagnóstico. Este es el tratamiento.
Después de descubrir el desastre, hice lo que hace cualquier ingeniero con orgullo herido: investigar obsesivamente durante días para que no vuelva a pasar. Leí papers, probé herramientas, analicé datos reales de mis APIs, y construí un sistema de defensas para mi app.
Lo que encontré me sorprendió. De las 5 medidas reactivas que identifiqué, solo 3 funcionan de verdad. Las otras dos son, como mínimo, teatro con buenas intenciones.
El modelo mental: tú contra la IA (literalmente)
Antes de entrar en las medidas, necesitas entender el marco. Y la mejor analogía que encontré viene del deep learning.
En una GAN (Generative Adversarial Network) hay dos redes neuronales que compiten:
- El generador produce contenido (imágenes, texto, lo que sea)
- El discriminador intenta detectar si el contenido es real o falso
El sistema mejora porque ambos se empujan mutuamente. El generador aprende a engañar mejor. El discriminador aprende a detectar mejor.
Cuando programas con un LLM, estás en una GAN involuntaria:
- El LLM es el generador. Produce código, DTOs, tests, fixtures.
- Tú eres el discriminador. Debes detectar qué es real y qué inventado.
Pero hay una asimetría brutal: el generador es incansable y tú te cansas. El LLM puede generar 50 ficheros sin despeinarse. Tú revisas 10, te fatigas, y el fichero 11 pasa sin que lo mires.
Es la misma fatiga de autorización que conté con 1Password pidiendo Touch ID 47 veces al día. La seguridad que depende de que un humano esté permanentemente alerta es seguridad de cartón.
Lo que el discriminador debe vigilar
No puedes (ni debes) revisar cada línea. Lo que tienes que vigilar son las fronteras — donde tu código toca el mundo exterior:
| Frontera | Pregunta clave |
|---|---|
| APIs externas | ¿Los campos del DTO existen en la API real? |
| Paquetes | ¿La dependencia existe y se llama así? |
| Schemas de BD | ¿La tabla tiene realmente esas columnas? |
| URLs/endpoints | ¿El endpoint existe y responde lo que esperamos? |
Regla: todo lo que el LLM declara sobre el mundo exterior es sospechoso hasta verificación. Que lo diga con confianza no es evidencia. Anthropic lo reconoce en su propia documentación:
«Claude can sometimes generate responses that contain fabricated information… presented in a confident, authoritative manner.»
Un LLM que dice «estoy seguro» y uno que dice «creo que» tienen exactamente la misma probabilidad de estar equivocados.
Automatizar al discriminador
El objetivo final es dejar de depender de tu disciplina y automatizar la verificación:
ANTES:
LLM genera → Tú revisas (a veces) → Merge
DESPUÉS:
LLM genera → CI verifica contra datos reales → Tú revisas discrepancias → Merge
Las 5 medidas que vienen son formas de automatizar partes de ese rol de discriminador. Algunas funcionan. Otras no tanto.
Los datos duros (para los escépticos)
Antes de que pienses «a mí no me pasa», aquí van números de estudios reales:
- 21.7% de los paquetes recomendados por LLMs open-source son inventados. En modelos comerciales baja al 5.2%, que sigue siendo un paquete de cada 20.
- GPT-4o solo logra un 38.58% de invocaciones válidas para APIs poco frecuentes. Menos del 40%. Lanza una moneda al aire.
- Los mejores métodos actuales para localizar alucinaciones en código logran un 22-33% de precisión. Dicho en cristiano: detectamos una de cada cuatro.
- Un investigador subió un paquete vacío con un nombre que los LLMs alucinaban frecuentemente. 30.000 descargas en 3 meses. Lo llaman slopsquatting.
Y hay una taxonomía formal. El paper CodeHalu (AAAI 2025) define 4 categorías de alucinaciones de código:
| Categoría | Qué es | Ejemplo real |
|---|---|---|
| Mapping | Campos mapeados incorrectamente | Confundir user_id con account_id |
| Naming | Nombres inventados | response.quota.percentage cuando es response.utilization |
| Resource | Recursos que no existen | Campo active_flags en una API que no lo tiene |
| Logic | Lógica plausible pero incorrecta | isPaid = !activeFlags.isEmpty con un campo siempre vacío |
Mi caso fue un Resource que derivó en un Logic. El campo no existía, y la lógica que dependía de él parecía perfecta. Ficción coherente de libro.
Medida 1: Contract testing contra APIs reales
La idea
Definir un «contrato» de lo que la API devuelve y verificar automáticamente que tu código es compatible. Si tu DTO tiene campos que el contrato no define: alarma.
Cómo funciona
Imagina que tienes un DTO así:
struct OrganizationInfo: Decodable {
let uuid: String
let name: String
let activeFlags: [String] // ← ¿Existe esto de verdad?
}
Un contract test coge la respuesta real de la API, extrae las keys del JSON, y las compara con las CodingKeys de tu DTO. Si tu DTO tiene un campo que la API no devuelve, es un PHANTOM — un campo fantasma, posiblemente inventado.
Keys en la API real: {uuid, name, capabilities, billing_type}
Keys en el DTO: {uuid, name, activeFlags}
PHANTOM: activeFlags ← En el DTO pero NO en la API. ¿Alucinado?
UNCONSUMED: capabilities, billing_type ← En la API pero no en el DTO.
Pros
- Determinista. No depende de otro LLM ni de tu olfato. Si el campo no está en la API, salta.
- Elimina phantom fields por construcción. Es imposible que un campo inventado pase.
- Automatizable en CI. Lo ejecutas en cada push.
Contras
- Necesitas la spec de la API. Si la API no tiene OpenAPI spec (como la de Claude), tienes que capturar respuestas manualmente.
- No detecta naming incorrecto. Si el campo existe pero se llama diferente (
active_flagsvscapabilities), no lo pilla automáticamente. - Requiere credenciales. Para capturar la respuesta real necesitas una sesión válida.
Herramientas por stack
| Stack | Herramienta | Enfoque |
|---|---|---|
| Python | Pydantic extra='forbid' |
Rechaza campos JSON no declarados en el modelo |
| TypeScript | Zod .strict() |
Mismo concepto, rechaza extras |
| Swift | Decoder custom o comparación manual de keys | Codable ignora claves desconocidas por defecto |
| Dart | json_serializable + disallowUnrecognizedKeys |
Rechaza campos no declarados |
| Agnóstico | oasdiff, Specmatic | Comparan specs OpenAPI |
Lo que implementé
En mi app (Swift/SPM) no hay OpenAPI spec de la API de Claude. Así que construí una validación bidireccional a mano:
make capturedescarga respuestas reales de todas las APIs y las guarda como fixtures enFixtures/real/SchemaValidationTestscompara lasCodingKeys.allCasesde cada DTO contra las keys del fixture real- Si hay discrepancia → PHANTOM (campo en el DTO pero no en la API) o UNCONSUMED (campo en la API que no 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
Los campos intencionalmente no consumidos van en una allowlist documentada con la razón. Si mañana aparece un campo nuevo en la API, el test falla con UNCONSUMED y me entero.
Veredicto: la medida más importante. Si solo implementas una, que sea esta.
Medida 2: Fixture validation (fixtures reales, no inventados)
La idea
Los fixtures de test deben venir de datos reales capturados, no escritos a mano por el LLM. Si el LLM genera el fixture, estás validando ficción contra ficción.
El problema que resuelve
George Tsiokos lo clavó en un post de febrero de 2025:
«Los tests no validan que el software cumple necesidades de negocio — simplemente confirman que el código hace exactamente lo que fue escrito para hacer, incluyendo bugs.»
Cuando el LLM genera el código Y los tests Y los fixtures:
LLM inventa campo → LLM escribe fixture con ese campo → LLM escribe test
→ Test pasa ✅ → Nadie verificó contra la realidad ❌
La solución: record-replay
Los frameworks record-replay graban respuestas HTTP reales y las reproducen en tests. No hay posibilidad de invención porque el fixture viene de la API, no del modelo.
| Stack | Herramienta |
|---|---|
| Python | VCR.py, pytest-recording |
| TypeScript | Polly.js (Netflix), MSW |
| Swift | Replay (mattt) |
| Agnóstico | Hoverfly |
Pros
- Imposible inventar. El fixture viene de la red, no del modelo.
- Incluye metadatos. URL, timestamp, status code. Puedes trazar de dónde vino.
- Se commitea al repo. Los reviewers ven exactamente qué devolvió la API.
Contras
- El fixture envejece. Si la API cambia, el fixture capturado ya no es representativo.
- Credenciales en CI. Necesitas poder llamar a la API para grabar.
- No escala a todas las variaciones. Capturas una respuesta, pero la API puede devolver muchas formas diferentes.
Lo que implementé
Dos capas de fixtures:
Tests/Fixtures/ ← Estáticos, escritos por el LLM
Usados para tests unitarios de decode
PUEDEN contener errores (eso es aceptable)
Tests/Fixtures/real/ ← Capturados por make capture
Con fichero .meta (timestamp de captura)
Fuente de verdad para validación de schema
Los fixtures estáticos son útiles para testear edge cases (JSON truncado, campos vacíos, formatos raros). Pero la validación de «¿estos campos existen de verdad?» la hace siempre el fixture real.
Cada fixture real tiene un fichero .meta con el timestamp de captura. Si un fixture tiene más de 30 días, ya sabes que toca renovar.
Veredicto: esencial como complemento del contract testing. Sola no basta (necesitas la comparación de la Medida 1), pero sin fixtures reales la Medida 1 no tiene contra qué comparar.
Medida 3: Smoke tests con datos reales (make doctor)
La idea
Antes de dar por bueno un cambio, hacer una llamada real a la API y verificar que tus DTOs parsean la respuesta sin pérdida silenciosa.
Cómo funciona
$ make doctor
Capturando /api/organizations... OK (2 orgs)
Capturando /api/organizations/{id}/usage... OK (9 windows)
Capturando ~/.claude/stats-cache.json... OK (115 sessions)
Capturando session JSONL... OK (847 entries)
Validando schemas...
✅ OrganizationInfo: OK
✅ UsageResponse: OK
✅ StatsCache: OK
✅ SessionEntry: OK
0 phantom fields, 0 new unconsumed fields
Es make capture + make test en un solo paso. Captura datos frescos de producción y los cruza contra los DTOs.
Pros
- La defensa más honesta. Datos reales, comparación directa, resultado inequívoco.
- Rápido. 30 segundos en local.
- Detecta drift. Si la API añade o quita campos, lo sabes inmediatamente.
Contras
- Requiere sesión activa. Necesitas estar logueado para capturar.
- No va en CI (en mi caso). La API de Claude no tiene credenciales de servicio, solo cookies de sesión.
- Es manual. Depende de que te acuerdes de ejecutarlo.
Lo que implementé
make doctor es el comando más importante de mi proyecto. Lo ejecuto:
- Después de cada cambio en DTOs
- Una vez a la semana como rutina
- Cuando algo «huele raro» en la app
Para las APIs que no puedo llamar en CI, el truco es guardar el resultado del doctor como fixture real que sí va al repo. El CI valida contra ese fixture. No es tiempo real, pero es mejor que nada.
Además, el sistema emite señales tempranas en runtime: si el SessionFileReader lee líneas de tipo assistant sin campo usage, logea un .notice. Si el SessionTokenService lee ficheros pero encuentra 0 entries nuevas, también. La idea es que la app avise si el formato cambió, aunque no crashee (porque la graceful degradation puede ocultar el problema).
Veredicto: la medida más práctica. Bajo coste, alto valor. Si tienes 30 segundos, tienes make doctor.
Medida 4: Anomaly detection en parseo (campos siempre null)
La idea
Monitorizar en runtime qué campos de tus modelos se populan con datos reales y cuáles son siempre nil. Un campo que lleva 50 parseos seguidos siendo nil es sospechoso de ser inventado.
El modelo mental
GraphQL tiene esto resuelto. Herramientas como Apollo GraphOS reportan uso por campo: cuántas veces se solicitó, cuántas devolvió datos, primera y última vez que se usó. Los campos con 0% de uso se marcan para eliminar.
Para REST, no existe equivalente. Tienes que construirlo tú.
Pros
- Detecta en producción. No necesitas capturar manualmente; el propio uso de la app genera los datos.
- Complementa las otras medidas. Un campo que pasa el contract test (existe en la API) pero siempre es null en la práctica sigue siendo sospechoso.
Contras
- Necesitas volumen. Con 5 llamadas no puedes concluir nada. Necesitas cientos.
- Falsos positivos. Un campo puede ser legítimamente null el 95% del tiempo (ej.
seven_day_opus: nullen mi API es normal si no usas Opus esa semana). - Implementación manual. No hay herramienta que enchufes. Tienes que escribir el monitor.
- En apps cliente, sin APM. En un backend con Datadog o Sentry, emites métricas custom. En una app macOS de menu bar, estás solo.
Lo que implementé
Parcialmente. No tengo un monitor formal de campos-siempre-nil, pero sí tengo las señales tempranas en los logs:
// SessionTokenService.swift
if totalFilesRead > 0 && totalNewEntries == 0 {
logger.notice("read \(totalFilesRead) files but 0 new entries — possible format change")
}
Es la versión low-tech de anomaly detection. No cuenta por campo, pero detecta el caso gordo: «estoy leyendo datos pero no sale nada útil».
Veredicto: útil como señal de alerta, pero no como defensa primaria. Es un canario en la mina, no un muro.
Medida 5: Diff semántico post-generación (LLM-as-Judge)
La idea
Usar un segundo LLM (o el mismo con un prompt diferente) para auditar el código generado, buscando campos o estructuras que no puede verificar contra documentación conocida.
El estado del arte
Hay herramientas serias trabajando en esto:
| Herramienta | Qué hace |
|---|---|
| VERDICT (Haize Labs) | Pipeline modular: verificación + debate + agregación |
| DeepEval | Framework tipo pytest con HallucinationMetric |
| Patronus Lynx | Modelo SOTA detección alucinaciones, open-source |
| Vectara HHEM | Modelo + API, reduce alucinaciones a ~0.9% en enterprise |
Y la opción casera: pedir a GPT-4o que genere DTOs para la misma API sin ver tu código, y comparar:
Claude dice: activeFlags: [String]
GPT-4o dice: capabilities: [String]
→ DISCREPANCIA: al menos uno alucina. Verificar contra API real.
Pros
- Escala sin esfuerzo manual. Lo pones en CI y se ejecuta solo.
- Detecta patrones sutiles. Un segundo modelo puede notar cosas que tú no.
Contras
Y aquí es donde la cosa se pone fea.
- El juez puede alucinar también. Si el segundo LLM no conoce la API, puede «confirmar» campos inventados.
- Alucinaciones sistemáticas. Si ambos modelos fueron entrenados con datos similares, pueden compartir la misma invención. SelfCheckGPT (Cambridge, EMNLP 2023) demostró que la consistencia multi-muestra no detecta alucinaciones sistemáticas.
- Precisión deplorable. Collu-Bench: los mejores métodos logran 22-33% de precisión localizando alucinaciones de código. Detectas una de cada cuatro. Eso no es una defensa, es un sorteo.
- Coste. Cada capa multiplica llamadas a LLM. Estás pagando por un detector que acierta un tercio del tiempo.
- Sesgo de posición. Los LLMs jueces prefieren respuestas más largas y las que aparecen primero. No juzgan; tienen preferencias estéticas.
Evidently AI lo resumió con una pregunta demoledora:
«¿Cómo monitorizas un sistema que ocasionalmente alucina con otro sistema que ocasionalmente alucina?»
Lo que implementé
Nada. Cero.
Y es una decisión consciente. Las medidas deterministas (1, 2 y 3) me dan una detección fiable, reproducible, sin falsos positivos ni costes por llamada. Poner un LLM a vigilar a otro LLM es como poner a un becario a supervisar a otro becario. Mejor poner una cámara.
Veredicto: investigación interesante, producción prematura. Cuando la precisión pase del 33% al 90%, hablamos. Hoy, es teatro con presupuesto de I+D.
El marcador final
| Medida | Fiabilidad | Coste | ¿Implementada? | ¿Por qué? |
|---|---|---|---|---|
| 1. Contract testing | Alta | Medio | Sí | Detecta phantom fields mecánicamente |
| 2. Fixture validation | Alta | Bajo | Sí | Fixtures reales eliminan ficción-valida-ficción |
3. Smoke tests (make doctor) |
Alta | Bajo | Sí | 30 segundos, máximo valor |
| 4. Anomaly detection | Media | Bajo | Parcial | Señales en logs, no monitor formal |
| 5. LLM-as-Judge | Baja | Alto | No | 22-33% precisión = sorteo |
Las medidas 1, 2 y 3 forman un trípode. Cada una cubre un ángulo diferente:
- Contract testing responde: «¿estos campos existen?»
- Fixture validation responde: «¿estos datos son reales?»
- Smoke tests responde: «¿esto funciona ahora mismo?»
Juntas, hacen que un campo inventado tenga que sobrevivir tres filtros independientes. No es imposible, pero es mucho más difícil que engañar un test unitario con un fixture inventado.
La regla de oro
Quiero terminar con la regla más importante que saqué de todo esto:
El sistema de verificación debe ser externo al generador.
Si el LLM genera: – El código → OK, es su trabajo – Los tests de lógica → OK, verifican comportamiento – Los fixtures → NO, deben venir de datos reales – Los schemas → NO, deben venir del spec de la API – La validación de que los datos son correctos → NO, la hace un sistema determinista
Es la separación de poderes aplicada al desarrollo. El que escribe la ley no puede ser el que la juzga. El que genera el código no puede ser el que verifica que es correcto.
Puedes tener 200 tests en verde y estar viviendo en Matrix. O puedes tener un make doctor que en 30 segundos te dice si tus datos son reales o ficción.
Yo prefiero la pastilla roja.
Serie completa: Este post es el cuarto capítulo de una serie involuntaria sobre fallos de IA en producción. Primero fue los 44 emails inventados (la IA que actúa sin permiso). Luego MEMORY.md (la IA que olvida). Después el silent failure (la IA que inventa y pasa los tests). Y ahora, las defensas. Cada fallo diferente, un denominador común: necesitamos sistemas mecánicos, no promesas de buen comportamiento.
Read this article in English.



