Capítulo 01 de 10 · 12 min de lectura

El problema: programar con IA sin método

La IA escribe código plausible en segundos. Que sea el que querías depende de todo lo que no le dijiste.

Funciona a la primera

Escribes una línea: hazme un acortador de URLs. Veinte segundos después tienes cien líneas de código ordenadas, con sus comentarios, sus nombres bien puestos y hasta algún detalle que no se te había ocurrido. Lo pruebas con una URL y funciona.

Ese es el problema.

No que el código sea malo: suele ser bueno. El problema es que parece el programa que querías, y no tienes forma de saber si lo es. No puedes saberlo porque nunca dijiste qué querías. Dijiste «un acortador de URLs», que es el nombre de una familia entera de programas distintos.

Para verlo no hace falta imaginar nada. Se puede hacer la prueba.

Un encargo, once programas

Entre el 1 y el 2 de octubre de 2026 le dimos el mismo encargo, palabra por palabra, a once agentes de código. Ninguno sabía nada de este tutorial ni de los otros diez. Seis usaban los cuatro modelos de un proveedor (Anthropic), del más pequeño al más capaz, con el mediano repetido tres veces. Los otros cinco, los modelos de otros dos proveedores: tres de OpenAI y dos de DeepSeek. A ninguno se le dio ocasión de preguntar, que es como se suele trabajar: pides, y te lo hace.

Abajo están nueve de los once programas, uno por modelo, ejecutándose en tu navegador tal como llegaron: no se les ha tocado una línea. El selector de arriba cambia de proveedor. Lo único añadido es un envoltorio para poder llamarlos igual, porque no se usan igual: unos son una clase y otros una función, y cada uno devuelve una cosa distinta.

El encargo«Hazme un acortador de URLs en JavaScript: un módulo sin dependencias que guarde los enlaces en memoria.»

Hazles a los programas las preguntas que el encargo no contestaba:

¿Cómo es la URL corta? ¿Se guarda la URL tal como la escribí?

Programa A modelo pequeño

Devuelve
url.short/dsLbuI
Al abrirlo lleva a
https://Ejemplo.com/Ruta

Claude Haiku 4.5 · 1 oct 2026

Programa B modelo medio

Devuelve
https://cort.o/1DK01
Al abrirlo lleva a
https://ejemplo.com/Ruta

Claude Sonnet 5.5 · 1 oct 2026

Programa C modelo grande

Devuelve
vE9pqPG
Al abrirlo lleva a
https://ejemplo.com/Ruta

Claude Opus 5.5 · 1 oct 2026

Programa D modelo superior

Devuelve
pJqyXET
Al abrirlo lleva a
https://ejemplo.com/Ruta

Claude Fable 5.1 · 2 oct 2026

El código del programa A, tal como llegó (120 líneas)
/**
 * Acortador de URLs - Módulo sin dependencias que guarda enlaces en memoria
 */

class AcortadorURL {
  constructor() {
    this.enlaces = new Map();
    this.contador = 0;
    this.caracteres = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  }

  /**
   * Genera un código corto único
   * @returns {string} Código aleatorio de 6 caracteres
   */
  generarCodigo() {
    let codigo;
    do {
      codigo = Array.from({ length: 6 }, () =>
        this.caracteres[Math.floor(Math.random() * this.caracteres.length)]
      ).join('');
    } while (this.enlaces.has(codigo));
    return codigo;
  }

  /**
   * Acorta una URL
   * @param {string} urlOriginal - La URL a acortar
   * @param {string} [codigoCustom] - Código personalizado (opcional)
   * @returns {object} Objeto con la URL acortada y datos del enlace
   * @throws {Error} Si el código personalizado ya existe o la URL es inválida
   */
  acortar(urlOriginal, codigoCustom = null) {
    if (!urlOriginal || typeof urlOriginal !== 'string') {
      throw new Error('URL inválida');
    }

    const codigo = codigoCustom || this.generarCodigo();

    if (this.enlaces.has(codigo)) {
      throw new Error(`El código "${codigo}" ya está en uso`);
    }

    const enlace = {
      codigo,
      urlOriginal,
      urlAcortada: `url.short/${codigo}`,
      creado: new Date().toISOString(),
      accesos: 0
    };

    this.enlaces.set(codigo, enlace);
    return enlace;
  }

  /**
   * Recupera una URL original por su código
   * @param {string} codigo - Código corto
   * @returns {string} La URL original
   * @throws {Error} Si el código no existe
   */
  expandir(codigo) {
    if (!this.enlaces.has(codigo)) {
      throw new Error(`Código "${codigo}" no encontrado`);
    }

    const enlace = this.enlaces.get(codigo);
    enlace.accesos += 1;
    return enlace.urlOriginal;
  }

  /**
   * Obtiene estadísticas de un enlace acortado
   * @param {string} codigo - Código corto
   * @returns {object} Información del enlace
   */
  obtenerEstadisticas(codigo) {
    if (!this.enlaces.has(codigo)) {
      throw new Error(`Código "${codigo}" no encontrado`);
    }
    return { ...this.enlaces.get(codigo) };
  }

  /**
   * Lista todos los enlaces acortados
   * @returns {array} Array de todos los enlaces
   */
  listarEnlaces() {
    return Array.from(this.enlaces.values());
  }

  /**
   * Elimina un enlace acortado
   * @param {string} codigo - Código a eliminar
   * @returns {boolean} True si se eliminó, false si no existe
   */
  eliminar(codigo) {
    return this.enlaces.delete(codigo);
  }

  /**
   * Limpia todos los enlaces
   */
  limpiar() {
    this.enlaces.clear();
  }

  /**
   * Obtiene el total de enlaces almacenados
   * @returns {number} Cantidad de enlaces
   */
  obtenerTotal() {
    return this.enlaces.size;
  }
}

// Exportar para Node.js y navegadores
if (typeof module !== 'undefined' && module.exports) {
  module.exports = AcortadorURL;
}
El código del programa B, tal como llegó (99 líneas)
'use strict';

const ALFABETO = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function codificar(n) {
  if (n === 0) return ALFABETO[0];
  let s = '';
  while (n > 0) {
    s = ALFABETO[n % 62] + s;
    n = Math.floor(n / 62);
  }
  return s;
}

function validarUrl(url) {
  if (typeof url !== 'string') throw new TypeError('La URL debe ser una cadena');
  let u;
  try {
    u = new URL(url.trim());
  } catch {
    throw new Error('URL no válida: ' + url);
  }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
    throw new Error('Solo se admiten URLs http o https');
  }
  return u.href;
}

class Acortador {
  constructor({ base = 'https://cort.o/', longitudMinima = 4 } = {}) {
    this.base = base.endsWith('/') ? base : base + '/';
    this.longitudMinima = longitudMinima;
    this.porCodigo = new Map(); // codigo -> { url, creado, visitas }
    this.porUrl = new Map(); // url -> codigo
    this.contador = 0;
  }

  _nuevoCodigo() {
    // Contador mezclado con una permutación multiplicativa para que no sea secuencial.
    let codigo;
    do {
      this.contador++;
      const n = (this.contador * 2654435761) % 14776336 + 14776336; // 62^4 .. 2*62^4
      codigo = codificar(n).padStart(this.longitudMinima, '0');
    } while (this.porCodigo.has(codigo));
    return codigo;
  }

  acortar(url, alias) {
    const normalizada = validarUrl(url);
    if (alias !== undefined) {
      if (!/^[\w-]{1,64}$/.test(alias)) throw new Error('Alias no válido');
      const existente = this.porCodigo.get(alias);
      if (existente && existente.url !== normalizada) throw new Error('El alias ya está en uso');
      if (!existente) {
        this.porCodigo.set(alias, { url: normalizada, creado: new Date(), visitas: 0 });
        if (!this.porUrl.has(normalizada)) this.porUrl.set(normalizada, alias);
      }
      return this.base + alias;
    }
    const previo = this.porUrl.get(normalizada);
    if (previo) return this.base + previo;
    const codigo = this._nuevoCodigo();
    this.porCodigo.set(codigo, { url: normalizada, creado: new Date(), visitas: 0 });
    this.porUrl.set(normalizada, codigo);
    return this.base + codigo;
  }

  _codigo(entrada) {
    return entrada.startsWith(this.base) ? entrada.slice(this.base.length) : entrada;
  }

  resolver(entrada) {
    const reg = this.porCodigo.get(this._codigo(entrada));
    if (!reg) return null;
    reg.visitas++;
    return reg.url;
  }

  estadisticas(entrada) {
    const reg = this.porCodigo.get(this._codigo(entrada));
    return reg ? { ...reg } : null;
  }

  eliminar(entrada) {
    const codigo = this._codigo(entrada);
    const reg = this.porCodigo.get(codigo);
    if (!reg) return false;
    this.porCodigo.delete(codigo);
    if (this.porUrl.get(reg.url) === codigo) this.porUrl.delete(reg.url);
    return true;
  }

  get tamaño() {
    return this.porCodigo.size;
  }
}

module.exports = { Acortador };
El código del programa C, tal como llegó (215 líneas)
'use strict';

/**
 * Acortador de URLs en memoria, sin dependencias.
 *
 * Uso:
 *   const { crearAcortador } = require('./acortador');
 *   const acortador = crearAcortador({ base: 'https://ac.or/' });
 *   const { codigo, corta } = acortador.acortar('https://ejemplo.com/una/ruta/larga');
 *   acortador.resolver(codigo); // -> 'https://ejemplo.com/una/ruta/larga'
 */

const ALFABETO = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const ALIAS_VALIDO = /^[A-Za-z0-9_-]{1,64}$/;

function bytesAleatorios(n) {
  const c = globalThis.crypto;
  if (c && typeof c.getRandomValues === 'function') {
    return c.getRandomValues(new Uint8Array(n));
  }
  const out = new Uint8Array(n);
  for (let i = 0; i < n; i++) out[i] = Math.floor(Math.random() * 256);
  return out;
}

function generarCodigo(longitud) {
  // 248 = 62 * 4: descartamos bytes >= 248 para evitar sesgo de módulo.
  let codigo = '';
  while (codigo.length < longitud) {
    for (const b of bytesAleatorios(longitud * 2)) {
      if (b < 248) codigo += ALFABETO[b % 62];
      if (codigo.length === longitud) break;
    }
  }
  return codigo;
}

function normalizarUrl(url) {
  if (typeof url !== 'string' || url.trim() === '') {
    throw new TypeError('La URL debe ser una cadena no vacía');
  }
  let parsed;
  try {
    parsed = new URL(url.trim());
  } catch {
    throw new TypeError(`URL no válida: ${url}`);
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new TypeError(`Protocolo no permitido: ${parsed.protocol}`);
  }
  return parsed.href;
}

function crearAcortador(opciones = {}) {
  const {
    base = '',
    longitud = 7,
    maxIntentos = 10,
    ahora = () => Date.now(),
  } = opciones;

  if (!Number.isInteger(longitud) || longitud < 1) {
    throw new RangeError('La longitud debe ser un entero positivo');
  }

  /** codigo -> { codigo, url, creado, expira, visitas, ultimaVisita } */
  const porCodigo = new Map();
  /** url -> codigo (solo códigos autogenerados y sin caducidad, para reutilizarlos) */
  const porUrl = new Map();

  const caducado = (e) => e.expira !== null && ahora() >= e.expira;

  function vista(e) {
    return { ...e, corta: base + e.codigo };
  }

  function eliminarEntrada(e) {
    porCodigo.delete(e.codigo);
    if (porUrl.get(e.url) === e.codigo) porUrl.delete(e.url);
  }

  function obtenerVigente(codigo) {
    const e = porCodigo.get(codigo);
    if (!e) return null;
    if (caducado(e)) {
      eliminarEntrada(e);
      return null;
    }
    return e;
  }

  /**
   * Acorta una URL.
   * @param {string} url
   * @param {{ alias?: string, ttlMs?: number }} [op]
   * @returns {{ codigo, url, corta, creado, expira, visitas, ultimaVisita }}
   */
  function acortar(url, op = {}) {
    const destino = normalizarUrl(url);
    const { alias, ttlMs } = op;

    if (ttlMs !== undefined && !(Number.isFinite(ttlMs) && ttlMs > 0)) {
      throw new RangeError('ttlMs debe ser un número positivo');
    }
    const expira = ttlMs === undefined ? null : ahora() + ttlMs;

    let codigo;
    if (alias !== undefined) {
      if (typeof alias !== 'string' || !ALIAS_VALIDO.test(alias)) {
        throw new TypeError('Alias no válido (usa 1-64 caracteres: letras, números, "_" o "-")');
      }
      const existente = obtenerVigente(alias);
      if (existente) {
        if (existente.url === destino) return vista(existente);
        throw new Error(`El alias "${alias}" ya está en uso`);
      }
      codigo = alias;
    } else {
      if (expira === null) {
        const previo = porUrl.get(destino);
        const e = previo && obtenerVigente(previo);
        if (e) return vista(e);
      }
      for (let i = 0; i < maxIntentos && !codigo; i++) {
        const candidato = generarCodigo(longitud);
        if (!obtenerVigente(candidato)) codigo = candidato;
      }
      if (!codigo) {
        throw new Error('No se pudo generar un código único; aumenta la longitud');
      }
    }

    const entrada = {
      codigo,
      url: destino,
      creado: ahora(),
      expira,
      visitas: 0,
      ultimaVisita: null,
    };
    porCodigo.set(codigo, entrada);
    if (alias === undefined && expira === null) porUrl.set(destino, codigo);
    return vista(entrada);
  }

  /**
   * Devuelve la URL original y cuenta la visita, o null si no existe o caducó.
   * @param {string} codigo
   * @returns {string|null}
   */
  function resolver(codigo) {
    const e = obtenerVigente(codigo);
    if (!e) return null;
    e.visitas++;
    e.ultimaVisita = ahora();
    return e.url;
  }

  /** Información del enlace sin contar visita, o null. */
  function info(codigo) {
    const e = obtenerVigente(codigo);
    return e ? vista(e) : null;
  }

  /** Elimina un enlace. Devuelve true si existía. */
  function eliminar(codigo) {
    const e = porCodigo.get(codigo);
    if (!e) return false;
    eliminarEntrada(e);
    return true;
  }

  /** Lista los enlaces vigentes. */
  function listar() {
    const res = [];
    for (const e of [...porCodigo.values()]) {
      if (caducado(e)) eliminarEntrada(e);
      else res.push(vista(e));
    }
    return res;
  }

  /** Purga los enlaces caducados y devuelve cuántos se eliminaron. */
  function purgar() {
    let n = 0;
    for (const e of [...porCodigo.values()]) {
      if (caducado(e)) {
        eliminarEntrada(e);
        n++;
      }
    }
    return n;
  }

  /** Vacía el almacén. */
  function limpiar() {
    porCodigo.clear();
    porUrl.clear();
  }

  return {
    acortar,
    resolver,
    info,
    eliminar,
    listar,
    purgar,
    limpiar,
    get tamano() {
      return porCodigo.size;
    },
  };
}

module.exports = { crearAcortador, normalizarUrl, generarCodigo };
El código del programa D, tal como llegó (204 líneas)
'use strict';

/**
 * Acortador de URLs en memoria, sin dependencias.
 *
 * Uso:
 *   const { crearAcortador } = require('./acortador.js');
 *   const acortador = crearAcortador({ base: 'https://ejemplo.com/' });
 *
 *   const enlace = acortador.acortar('https://www.wikipedia.org/wiki/URL');
 *   enlace.codigo;    // p. ej. 'aZ3kP0q'
 *   enlace.urlCorta;  // 'https://ejemplo.com/aZ3kP0q'
 *
 *   acortador.resolver(enlace.codigo); // 'https://www.wikipedia.org/wiki/URL'
 *
 * Los enlaces viven en un Map del proceso: se pierden al reiniciar y no se
 * comparten entre procesos. No hay límite de tamaño ni caducidad.
 */

const ALFABETO = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const PATRON_ALIAS = /^[A-Za-z0-9_-]{3,32}$/;
const PROTOCOLOS_PERMITIDOS = new Set(['http:', 'https:']);
const INTENTOS_POR_LONGITUD = 10;

/** Devuelve `cantidad` bytes aleatorios; usa Web Crypto si está disponible. */
function bytesAleatorios(cantidad) {
  const bytes = new Uint8Array(cantidad);
  const cripto = globalThis.crypto;
  if (cripto && typeof cripto.getRandomValues === 'function') {
    cripto.getRandomValues(bytes);
  } else {
    // Sin Web Crypto (Node < 19 en CommonJS): códigos válidos pero predecibles.
    for (let i = 0; i < cantidad; i++) bytes[i] = Math.floor(Math.random() * 256);
  }
  return bytes;
}

/** Genera un código base62 de la longitud pedida, sin sesgo de módulo. */
function codigoAleatorio(longitud) {
  // 248 es el mayor múltiplo de 62 que cabe en un byte: se descarta el resto.
  const limite = 256 - (256 % ALFABETO.length);
  let codigo = '';
  while (codigo.length < longitud) {
    for (const byte of bytesAleatorios(longitud * 2)) {
      if (byte >= limite) continue;
      codigo += ALFABETO[byte % ALFABETO.length];
      if (codigo.length === longitud) break;
    }
  }
  return codigo;
}

/** Valida una URL http(s) y la devuelve normalizada. Lanza TypeError si no vale. */
function normalizarUrl(url) {
  if (typeof url !== 'string' || url.trim() === '') {
    throw new TypeError('La URL debe ser una cadena no vacía');
  }
  let analizada;
  try {
    analizada = new URL(url.trim());
  } catch {
    throw new TypeError(`URL no válida: ${url}`);
  }
  if (!PROTOCOLOS_PERMITIDOS.has(analizada.protocol)) {
    throw new TypeError(`Solo se admiten URLs http y https: ${url}`);
  }
  return analizada.href;
}

/**
 * Crea un acortador independiente con su propio almacén en memoria.
 *
 * @param {object} [opciones]
 * @param {string} [opciones.base]      Prefijo de las URLs cortas, p. ej. 'https://ejemplo.com/'.
 * @param {number} [opciones.longitud]  Longitud de los códigos generados (por defecto 7).
 */
function crearAcortador(opciones = {}) {
  const { base = '', longitud = 7 } = opciones;

  if (typeof base !== 'string') {
    throw new TypeError('La opción "base" debe ser una cadena');
  }
  if (!Number.isInteger(longitud) || longitud < 1 || longitud > 64) {
    throw new RangeError('La opción "longitud" debe ser un entero entre 1 y 64');
  }

  const prefijo = base === '' || base.endsWith('/') ? base : `${base}/`;

  /** codigo -> { url, creadoEn, visitas, generado } */
  const enlaces = new Map();
  /** url normalizada -> código generado automáticamente (para no duplicar) */
  const codigoPorUrl = new Map();

  function generarCodigoLibre() {
    for (let largo = longitud; ; largo++) {
      for (let intento = 0; intento < INTENTOS_POR_LONGITUD; intento++) {
        const codigo = codigoAleatorio(largo);
        if (!enlaces.has(codigo)) return codigo;
      }
      // Demasiadas colisiones seguidas: el espacio está saturado, se alarga el código.
    }
  }

  /** Copia pública de un enlace: modificarla no altera el almacén. */
  function vista(codigo) {
    const enlace = enlaces.get(codigo);
    return {
      codigo,
      url: enlace.url,
      urlCorta: prefijo + codigo,
      creadoEn: new Date(enlace.creadoEn),
      visitas: enlace.visitas,
    };
  }

  /**
   * Acorta una URL. Acortar dos veces la misma URL sin alias devuelve el mismo código.
   *
   * @param {string} url
   * @param {object} [opcionesEnlace]
   * @param {string} [opcionesEnlace.alias]  Código elegido a mano (3-32 caracteres: letras, dígitos, _ o -).
   * @returns {{codigo: string, url: string, urlCorta: string, creadoEn: Date, visitas: number}}
   */
  function acortar(url, { alias } = {}) {
    const destino = normalizarUrl(url);

    if (alias !== undefined) {
      if (typeof alias !== 'string' || !PATRON_ALIAS.test(alias)) {
        throw new TypeError('El alias debe tener entre 3 y 32 caracteres: letras, dígitos, "_" o "-"');
      }
      const existente = enlaces.get(alias);
      if (existente) {
        if (existente.url === destino) return vista(alias);
        throw new Error(`El alias "${alias}" ya está en uso`);
      }
      enlaces.set(alias, { url: destino, creadoEn: Date.now(), visitas: 0, generado: false });
      return vista(alias);
    }

    const yaAcortada = codigoPorUrl.get(destino);
    if (yaAcortada !== undefined) return vista(yaAcortada);

    const codigo = generarCodigoLibre();
    enlaces.set(codigo, { url: destino, creadoEn: Date.now(), visitas: 0, generado: true });
    codigoPorUrl.set(destino, codigo);
    return vista(codigo);
  }

  /**
   * Devuelve la URL original de un código y cuenta la visita.
   * @returns {string|null} null si el código no existe.
   */
  function resolver(codigo) {
    const enlace = enlaces.get(codigo);
    if (!enlace) return null;
    enlace.visitas++;
    return enlace.url;
  }

  /**
   * Consulta un enlace sin contar visita.
   * @returns {object|null} null si el código no existe.
   */
  function info(codigo) {
    return enlaces.has(codigo) ? vista(codigo) : null;
  }

  /**
   * Borra un enlace.
   * @returns {boolean} true si existía.
   */
  function eliminar(codigo) {
    const enlace = enlaces.get(codigo);
    if (!enlace) return false;
    enlaces.delete(codigo);
    if (enlace.generado) codigoPorUrl.delete(enlace.url);
    return true;
  }

  /** Lista todos los enlaces, en orden de creación. */
  function listar() {
    return Array.from(enlaces.keys(), vista);
  }

  /** Borra todos los enlaces. */
  function vaciar() {
    enlaces.clear();
    codigoPorUrl.clear();
  }

  return {
    acortar,
    resolver,
    info,
    eliminar,
    listar,
    vaciar,
    get total() {
      return enlaces.size;
    },
  };
}

module.exports = { crearAcortador };

Pulsa las ocho preguntas, o prueba con tus propias URL. Fíjate en que todos pasan sin problema la primera: acortan una URL normal y te devuelven a ella. Si solo pruebas eso, todos «funcionan».

El resto del capítulo habla de los cuatro programas del primer proveedor, de la A a la D. Los de los otros dos aparecen un poco más abajo.

Ninguno está mal

Lo incómodo del experimento es que no hay un programa bueno y tres malos. Casi cada decisión que tomaron se puede defender:

La pregunta A B C D
¿La misma URL dos veces da el mismo enlace? No, dos Sí Sí Sí
¿Se guarda la URL tal como la escribí? Sí No: Ejemplo.com pasa a ejemplo.com No No
¿Vale ejemplo.com, sin https://? Sí No No No
¿Vale javascript:alert(1)? Sí No No No
¿Vale el alias Mi Oferta!? Sí No No No
¿Vale un alias de dos letras? Sí Sí Sí No: mínimo tres
¿Qué pasa con un código que no existe? Un error null null null
¿Salen siempre los mismos códigos al arrancar? No Sí No No
¿Puede caducar un enlace? No No Sí, y entonces lo borra No, y lo avisa

¿Reutilizar el enlace de una URL repetida es mejor que crear otro? Depende: si quieres contar las visitas de dos campañas por separado, es un fallo. ¿Rechazar ejemplo.com es más correcto que aceptarlo? Depende de quién lo vaya a usar. Lo de javascript: ya no depende tanto: un acortador que redirige a eso es un agujero de seguridad, y el programa A lo acepta sin pestañear. En realidad acepta cualquier texto: no valida nada.

Y luego está el programa B. Mira cómo genera los códigos:

_nuevoCodigo() {
  // Contador mezclado con una permutación multiplicativa para que no sea secuencial.
  let codigo;
  do {
    this.contador++;
    const n = (this.contador * 2654435761) % 14776336 + 14776336; // 62^4 .. 2*62^4
    codigo = codificar(n).padStart(this.longitudMinima, '0');
  } while (this.porCodigo.has(codigo));
  return codigo;
}

El comentario dice «para que no sea secuencial». Estos son los cinco primeros códigos que genera, siempre, cada vez que arranca:

1DK01 · 1hu02 · 1Ve03 · 1yY04 · 1cI05

Los dos últimos caracteres son, literalmente, el contador. Quien tenga un enlace puede probar con los siguientes y ver los de los demás. El código compila, está comentado, el comentario tranquiliza… y hace lo contrario de lo que dice. Sin algo que lo compruebe, eso llega a producción.

No es que el modelo sea caprichoso

La explicación fácil sería: «la IA es una lotería, cada vez sale una cosa». El experimento dice algo más sutil.

Las tres veces que se lo pedimos al mismo modelo salieron programas casi calcados: la misma clase Acortador, los mismos cuatro métodos, el mismo patrón para validar los alias. Solo cambió una cosa, y era justo la delicada: cómo se generan los códigos. Uno usó Math.random, otro el contador que acabas de ver y el tercero el generador criptográfico. Tres programas que parecen el mismo y no lo son en el único punto donde importa.

Más revelador es en qué coincidieron los tres sin que nadie se lo pidiera: reutilizar el enlace de una URL repetida, reescribir la URL antes de guardarla, admitir alias de hasta 64 caracteres con mayúsculas y guiones bajos. Un modelo perfectamente consistente, que diera siempre el mismo programa, seguiría teniendo el mismo problema: estaría decidiendo tu producto por ti.

¿Y con el mejor modelo?

La objeción natural es que esto se arregla usando un modelo más capaz. El programa D lo escribió el mejor de los cuatro, y se nota. Valida las URL, genera los códigos con el generador criptográfico y sus mensajes de error explican la regla que has incumplido. Es el único que empieza diciendo lo que no hace:

 * Los enlaces viven en un Map del proceso: se pierden al reiniciar y no se
 * comparten entre procesos. No hay límite de tamaño ni caducidad.

Y al entregar fue el único que dijo haberlo probado. De los otros cinco de su proveedor, cuatro avisaron de que no habían ejecutado su código.

Aun así, decidió por su cuenta lo mismo que los demás: reutiliza el enlace de una URL repetida, reescribe la URL antes de guardarla y admite Oferta y api como alias. No son fallos. Son decisiones de producto, tomadas con buen criterio por alguien que no sabe para qué quieres el acortador.

Un modelo mejor decide mejor y avisa más de lo que ha decidido. Lo que no puede hacer es saber lo que tú quieres, y la mayoría de esas decisiones siguen estando en el código y solo en el código. Para enterarte de ellas tienes dos caminos: leer las doscientas líneas buscando qué han dado por hecho, o tropezar con ellas cuando ya hay usuarios.

¿Y con otros proveedores?

Cambia el selector de la demo y repite las preguntas. Son cinco modelos más, de otras dos empresas, con el mismo encargo.

Los tres de OpenAI coinciden en algo que no hizo ninguno de los anteriores: no dejan elegir alias. Nadie lo pidió, y tampoco nadie dijo que no hiciera falta. Dos de ellos, el mediano y el superior, numeran los enlaces por orden: 1, 2, 3… Quien tenga un enlace puede recorrer todos los demás. El superior, al menos, lo avisa en la cabecera de su código:

 * Los códigos son secuenciales: no deben usarse como secretos de acceso.

El mediano hace lo mismo y no dice nada. El pequeño acepta cualquier texto como URL.

Los dos de DeepSeek van cada uno por su lado. El pequeño se parece mucho a los programas B, C y D: valida las URL, las reescribe y reutiliza el enlace de una URL repetida. El grande acepta ejemplo.com y lo guarda así, sin https://; admite cualquier texto como alias, espacios incluidos; no cuenta las visitas; y guarda todos los enlaces en una única variable global. Aquí el modelo grande es el que menos comprueba, al revés que en el primer proveedor.

El tamaño y la marca cambian el reparto, no el problema. Los nueve programas de la demo toman decisiones que nadie les pidió, y cada uno toma las suyas.

Lo que el encargo no decía

El proyecto que acompaña a este tutorial, acorta, es también un acortador de URLs, y también empezó con una línea. Esta, tal cual se escribió:

proyecto conductor: acortador de urls en go y frontend en vuejs (ver doc)

La diferencia es lo que pasó después. En vez de escribir código, el agente hizo cuatro preguntas (¿qué entra en la primera versión?, ¿dónde se guardan los enlaces?, ¿quién puede crearlos?) y, al ponerse a redactar la especificación, apuntó cada decisión que nadie había tomado. Salieron veintiuna. Estas son algunas, copiadas de la bitácora del proyecto:

La pregunta que nadie había hecho Lo que se decidió
¿Cómo son los códigos generados? 6 caracteres, minúsculas y dígitos, aleatorios (no consecutivos: no se pueden adivinar los de otros)
¿Acortar dos veces la misma URL da el mismo enlace? No: dos enlaces distintos
¿Se «arregla» la URL (minúsculas, barra final)? No. Se guarda tal cual, solo se recortan espacios
¿Oferta se convierte en oferta? No: es un error. Nada se transforma en silencio
¿Puede un alias llamarse api? No: api y assets están reservados para el propio servidor
¿Qué es exactamente una visita? Una redirección servida por GET. HEAD no cuenta. No se filtran bots
¿Qué ve quien abre un enlace caducado? Un 410 Gone, distinto del 404 de uno que no existe

Compáralas con la tabla de antes. En casi todas, la mayoría de los programas hizo lo contrario: tres de los cuatro reutilizan el enlace y reescriben la URL, los cuatro aceptan Oferta y api como alias, y solo uno pensó en que un enlace caducara. No porque estuvieran mal hechos, sino porque nadie se lo había dicho.

Ninguna de esas veintiuna respuestas es difícil. Casi todas se contestan en diez segundos. Lo difícil es darse cuenta de que la pregunta existe, y eso es justo lo que un programa que «funciona a la primera» te esconde.

Tres formas de pagarlo

Trabajar así, a base de encargos de una línea, sale gratis el primer día. Se paga después, de tres maneras.

No puedes revisar. Revisar es comparar lo que hay con lo que debería haber. Si lo que debería haber solo está en tu cabeza, y a medias, revisar se reduce a leer el código y ver si «tiene buena pinta». Y el código de una IA siempre tiene buena pinta: recuerda el comentario del programa B.

«Funciona» significa que lo probaste con un ejemplo. Los cuatro programas del experimento pasan la prueba de acortar una URL y abrirla. Sus diferencias están todas en los bordes: la URL rara, el alias con espacios, el código que no existe. Los bordes son lo que no se prueba a mano.

Cada cambio vuelve a tirar los dados. Tu única especificación era el prompt, y se perdió al cerrar el chat. Cuando mañana pidas «añade caducidad a los enlaces», el modelo volverá a decidir por su cuenta todo lo que no digas, y nada garantiza que respete lo que decidió ayer. Lo que ya funcionaba puede dejar de hacerlo sin que nadie avise.

Lo que viene

El resto del tutorial es una forma de trabajar que ataca esos tres problemas, en ese orden:

  1. La especificación convierte las decisiones en algo escrito, que vive en el repositorio y que tú has aprobado. Es contra lo que se revisa. (Capítulos 2 y 3.)
  2. Los tests convierten cada frase de la especificación en algo que se ejecuta y que falla si el código hace otra cosa. Incluidos los bordes. (Capítulos 4 y 5.)
  3. El bucle de trabajo con el agente hace que cada cambio empiece en la especificación y termine con todos los tests en verde, también los de ayer. (Capítulos 6 a 10.)

No es un método para que la IA no se equivoque. Se equivoca igual, y lo verás: en acorta hubo tests mal escritos, una especificación con huecos y un agente que ignoró una línea del plan. Es un método para enterarte.