Índice
7 min de lectura

570.000 líneas de código generado por un LLM. Compilaba perfecto. Era 20.171 veces más lento que SQLite.

Alguien benchmarkeó una reimplementación de SQLite en Rust escrita íntegramente por un LLM. La brecha entre código que parece correcto y código que realmente lo es resultó ser de cinco órdenes de magnitud.

Una reimplementación de SQLite en Rust, escrita íntegramente por un LLM, fue sometida recientemente a un benchmark. Compilaba. Los tests pasaban. El código era limpio, bien estructurado e idiomático en Rust. En una búsqueda básica por clave primaria, era 20.171 veces más lento que SQLite.

Ese número me hizo detenerme. No porque que el código generado por LLMs sea lento resulte sorprendente, sino por el origen de esa lentitud. El código no era incorrecto en ninguna forma que un compilador o una suite de tests pudiera detectar. El B-tree estaba correctamente implementado. El query planner existía. El motor de almacenamiento funcionaba. Cada pieza era individualmente defendible. El sistema en su conjunto era prácticamente inutilizable.

Dediqué tiempo a leer el análisis del benchmark y el código fuente. Los patrones que encontré aparecen repetidamente en proyectos generados por LLMs, y creo que apuntan a algo fundamental sobre cómo estos modelos escriben código.

El B-tree estaba ahí. El query planner lo ignoraba.

En SQLite, una búsqueda por PRIMARY KEY sigue el camino del B-tree y termina en tiempo O(log n). Cuatro líneas en where.c comprueban iPKey y redirigen la consulta directamente al árbol. Es una de esas micro-optimizaciones que solo tiene sentido si entiendes cómo encaja el sistema completo.

La versión generada por el LLM también tenía una implementación de B-tree. Funcionaba correctamente de forma aislada. El problema era que el query planner nunca lo invocaba para búsquedas por clave primaria. La función is_rowid_ref() solo reconocía tres strings literales: "rowid", "_rowid_" y "oid". Si declarabas una columna como id INTEGER PRIMARY KEY, el planner no la reconocía como alias de rowid. Cada consulta terminaba en un full table scan.

La aritmética es demoledora. Para 100 filas consultadas 100 veces, el camino por B-tree requiere aproximadamente 700 comparaciones. El camino por full scan, más de 10.000. Pero el daño real viene de la complejidad algorítmica: O(log n) por búsqueda pasa a ser O(n), y a lo largo de toda la suite de benchmarks eso se acumula hasta generar la brecha de 20.171x.

Es el tipo de bug que ningún test unitario detecta a menos que escribas un benchmark específicamente para eso. El B-tree funciona. El scan funciona. El planner elige el incorrecto. Todo pasa.

Los defaults seguros se acumulan como el interés compuesto

Lo que hace este caso más interesante que un simple fallo de enrutamiento es lo siguiente: incluso después de corregir el problema del query planner, la reimplementación seguía siendo unas 2.900 veces más lenta. La brecha restante provenía de una serie de decisiones individualmente razonables.

Cada ejecución de consulta clonaba el AST completo y lo recompilaba a bytecode. SQLite reutiliza los handles de prepared statements. Ambos enfoques son válidos, pero clonar el AST en cada ejecución es caro a escala.

Cada lectura de página reservaba un buffer de 4KB nuevo en el heap. La page cache de SQLite devuelve un puntero directo a memoria ya cargada. La versión del LLM eligió el camino seguro y obvio: reservar, leer, devolver. Funciona. Pero es órdenes de magnitud más lento cuando lees miles de páginas por consulta.

Cada commit reconstruía el schema completo desde cero. SQLite compara un único entero, el cookie value. Si el cookie no ha cambiado, el schema sigue siendo válido. La reimplementación no tenía este concepto, así que hacía el trabajo completo cada vez.

Cada statement disparaba una llamada a sync_all() para vaciar todos los metadatos de fichero a disco. SQLite usa fdatasync(), que solo vacía los datos del fichero y omite la sincronización de metadatos. La diferencia es enorme en workloads con muchas escrituras.

Podría llamar a esto el efecto acumulativo de los defaults defensivos. Cada decisión tomada de forma aislada tiene una justificación razonable. Clonar el AST evita la complejidad del ownership en Rust. Reservar buffers nuevos previene bugs de use-after-free. Reconstruir el schema evita problemas de caché obsoleta. Llamar a sync_all() ofrece la garantía de durabilidad más sólida.

Pero los costes de rendimiento se multiplican, no se suman. Cuando cuatro penalizaciones de 10x se apilan, no obtienes 40x más lento. Obtienes 10.000x más lento. Un LLM no razona sobre este efecto acumulativo porque genera cada función de forma relativamente aislada. Optimiza localmente y paga el precio globalmente.

82.000 líneas para reemplazar un one-liner de cron

El otro proyecto generado por LLM del mismo desarrollador mostraba el mismo patrón de forma diferente. El problema: los artefactos de compilación en el directorio target/ de Rust consumen espacio en disco con el tiempo. La solución del LLM: un daemon en Rust de 82.000 líneas con siete dashboards y un motor de puntuación bayesiano para decidir qué artefactos eliminar.

La solución existente es find ./target -type f -atime +30 -delete, una sola línea en un cron job. Cero dependencias. O cargo-sweep, una herramienta oficial de la comunidad que ya existe y gestiona casos extremos que el daemon no contempla.

El proyecto generado por el LLM incorporaba 192 dependencias. Como referencia, ripgrep, una de las herramientas de búsqueda más sofisticadas del ecosistema Rust, usa 61.

Es un patrón que sigo viendo: los LLMs construyen lo que pides, no lo que necesitas. Si el prompt dice “construye un sistema que gestione inteligentemente los artefactos de compilación de Rust con monitorización y puntuación”, obtienes exactamente eso. El modelo no tiene mecanismo para dar un paso atrás y preguntarse si el problema requiere un sistema en absoluto. No sabe que el tamaño del directorio target/ es una queja recurrente en la comunidad Rust con soluciones bien conocidas. No considera el coste de mantenimiento de 192 dependencias frente a cero.

La investigación apunta en la misma dirección

Tenía curiosidad por saber si estos dos proyectos eran casos atípicos, así que revisé la investigación más amplia. No lo son.

METR realizó un ensayo controlado aleatorizado con 16 desarrolladores experimentados de open source. El grupo que usaba herramientas de IA completó las tareas un 19% más lento que el grupo de control. Lo que me llamó la atención: al terminar el experimento, el grupo con IA creía haber sido un 20% más rápido. La experiencia subjetiva de productividad era la inversa de la realidad medida.

GitClear analizó 210 millones de líneas de código y encontró que el código copiado y pegado superó por primera vez al código refactorizado. La tendencia correlaciona directamente con la adopción de herramientas de codificación con IA. El código se añade más rápido de lo que se mejora.

El informe DORA 2024 de Google encontró que un aumento del 25% en la adopción de IA correlacionaba con una caída del 7,2% en la estabilidad de los despliegues. Más código generado por IA llegando a producción, más incidentes saliendo.

El benchmark Mercury de NeurIPS 2024 añadió métricas de eficiencia a los benchmarks de codificación estándar. Cuando mides no solo “¿produce la salida correcta?” sino “¿produce la salida correcta sin desperdiciar recursos?”, las tasas de éxito cayeron por debajo del 50%.

Nada de esto significa que los LLMs sean inútiles para programar. Yo los uso constantemente. Pero sí significa que “compila y pasa los tests” es un listón peligrosamente bajo. La brecha entre código plausible y código correcto es donde ocurre la ingeniería real.

Lo que esto exige realmente de los desarrolladores

El problema central no es que los LLMs escriban código malo. Escriben código que es localmente coherente y globalmente incoherente. Cada función tiene sentido. El sistema no. Este es exactamente el modo de fallo que los tests tradicionales no detectan, porque los tests verifican el comportamiento local.

Lo que hace falta es una evaluación que apunte a esas brechas. Benchmarks, no solo tests. Presupuestos de rendimiento en el pipeline de CI, no solo comprobaciones de corrección. Revisión arquitectónica que pregunte “¿por qué existe este módulo?” antes de comprobar si funciona. Auditorías de dependencias que comparen la complejidad de la solución con la complejidad del problema.

La pregunta no es “¿este código parece correcto?”. Es “¿cómo demostramos que es correcto?”. Y demostrarlo requiere el tipo de pensamiento a nivel de sistema del que los LLMs carecen actualmente.

La brecha entre lo que pediste y lo que exige producción es donde vive el criterio de ingeniería. Sin medición, la generación de código es solo generación de tokens.

Únete al boletín

Recibe actualizaciones sobre mis últimos proyectos, artículos y experimentos con IA y desarrollo web.