Módulo 4: Guardrails y la frontera de confianza

La salida del modelo es no confiable

Descripción

Hay una palabra que resume el error más caro al integrar un LLM: confianza. Cuando llamas a una función normal y usas lo que devuelve sin revisarlo, esa confianza está justificada —la función es determinista y probada—. Cuando haces lo mismo con un LLM, la confianza es un regalo que le das a un componente que no se lo ha ganado. La salida de un modelo suena siempre bien: gramática correcta, tono seguro, formato razonable. Y esa fluidez es justamente la trampa, porque una salida perfectamente redactada puede estar vacía de sentido, malformada, fuera de política, o ser un objeto que ni siquiera es del tipo que esperabas. Esta lección instala el principio raíz de todo el módulo: la salida del LLM es no confiable hasta que la validas, y se trata como entrada de usuario, no como el retorno confiable de una función.

En la lección 1 viste una batería de guardrails rechazando salidas malas en la frontera. Aquí bajamos a la idea que lo justifica: por qué hace falta esa frontera. No porque el modelo sea "malo", sino porque su salida es una hipótesis, no un hecho. Vas a ver, ejecutado, el contraste entre dos diseños del generador "describe tu producto" de Mercado: el antipatrón que confía en la salida y la publica tal cual —y termina publicando un dict que ni siquiera es texto, y una respuesta corrupta con caracteres de control— contra el patrón que trata la salida como no confiable y la valida por propiedades en la frontera antes de usarla.

Conexión con el módulo. La lección 1 mostró la frontera funcionando; esta lección instala el principio que la hace necesaria —la salida es no confiable— y la técnica base para validarla: verificar propiedades, no confiar. Es el mismo giro que viste en el módulo 1 con el contrato probabilístico (no afirmas la salida exacta), llevado a la frontera de confianza: aquí no afirmas la salida, la custodias. Las lecciones 3 a 6 son casos concretos de este principio —schema, entrada, inyección, moderación— y la 7 los compone. La frontera con la guía de seguridad se mantiene: aquí validamos la salida del componente de IA, no diseñamos el modelo de amenazas del sistema.

Una analogía: el borrador del pasante brillante

Imagina que contratas a un pasante extraordinario. Escribe rapidísimo, con una prosa impecable, y la mayoría de sus borradores son excelentes. Un día le pides que redacte la respuesta a un cliente importante y, con la misma seguridad y la misma prosa impecable de siempre, te entrega un texto que menciona un número de pedido que no existe, promete un descuento que la empresa no ofrece, y en un párrafo se cuela una frase sin sentido porque se distrajo. El texto se ve perfecto. Si lo lees por encima —confiando en que "el pasante siempre escribe bien"—, lo envías y creas un problema real.

Ahora piensa cómo trabaja un jefe sensato con ese pasante. No revisa si le gusta el estilo —el estilo siempre es bueno, esa no es la cuestión—. Revisa hechos y reglas: ¿el número de pedido existe?, ¿el descuento está dentro de política?, ¿el texto está completo y tiene sentido?, ¿no promete nada prohibido? Verifica propiedades que puede comprobar objetivamente, no la impresión general. Y no lo hace porque desconfíe del talento del pasante; lo hace porque el talento no es lo mismo que la corrección, y lo que sale con el nombre de la empresa tiene que ser correcto, no solo bien escrito.

Aquí está el punto: un LLM es ese pasante brillante, y su salida es un borrador, no un hecho. La fluidez del texto —que es real, el modelo escribe muy bien— no dice nada sobre si el contenido es válido. Confiar en la salida "porque suena bien" es leer el borrador por encima y enviarlo. Validarla en la frontera es el jefe sensato revisando hechos y reglas antes de que salga. En Mercado, cada descripción que el generador produce es un borrador del pasante; la frontera que la valida es el jefe que comprueba que no haya un claim inventado, un texto corrupto, o algo que ni siquiera es una descripción, antes de publicarla.

Ejemplo trabajado: confiar vs validar la salida

Vamos a modelar los dos diseños. El ai_component es un stub determinista que simula el generador: a propósito devuelve una mezcla realista —a veces una descripción útil, a veces basura de distintos tipos: una cadena vacía, un texto con caracteres de control (corrupción), un objeto que no es texto (dict), un claim prohibido—. Un modelo probabilístico produce de todo, y el punto es ver qué hace cada diseño con esa mezcla. La función is_publishable es el guardrail: no afirma qué dice la salida, verifica que cumpla propiedades.

# Leccion 2: la salida del modelo es NO CONFIABLE hasta validarla.
# Se trata como entrada de usuario: valida en la frontera antes de usarla.
# LLM simulado por stub determinista; sin red ni APIs.
import random

_RNG = random.Random(1)


# El stub simula "describe tu producto": a veces da texto util, a veces
# basura realista (vacio, ruido de control, tipo equivocado, claim
# prohibido). Un modelo probabilistico produce de todo.
def ai_component(_attributes):
    outputs = [
        "Bicicleta de montana rodada 29, 21 velocidades, frenos de disco.",   # ok
        "",                                                                    # vacia
        "\x00\x00 respuesta corrupta \x07",                                    # ruido de control
        {"unexpected": "json"},                                                # tipo equivocado
        "El mejor producto del mundo, garantizado, cura todo.",                # claim prohibido
        "Licuadora de 600W con vaso de vidrio de 1.5 litros.",                 # ok
    ]
    return _RNG.choice(outputs)


MAX_LEN = 200
BANNED = ("cura", "mejor del mundo", "garantizado", "100%")


def is_publishable(text):
    # Contrato por PROPIEDADES sobre la salida (no igualdad exacta).
    if not isinstance(text, str):
        return (False, "no es texto")
    if text.strip() == "":
        return (False, "vacia")
    if any(ord(c) < 32 and c not in "\n\t" for c in text):
        return (False, "contiene caracteres de control")
    if len(text) > MAX_LEN:
        return (False, "excede el limite de longitud")
    low = text.lower()
    for claim in BANNED:
        if claim in low:
            return (False, f"claim prohibido '{claim}'")
    return (True, "ok")


N = 6
print("=== Antipatron: confiar en la salida y publicarla directo ===")
_RNG.seed(1)
for _ in range(N):
    out = ai_component("attrs")
    print(f"  PUBLICADO tal cual -> {out!r}")

print()
print("=== Patron: la salida es no confiable; validar en la frontera ===")
_RNG.seed(1)
published = rejected = 0
for _ in range(N):
    out = ai_component("attrs")
    ok, reason = is_publishable(out)
    if ok:
        published += 1
        print(f"  PUBLICADO   {out!r}")
    else:
        rejected += 1
        print(f"  RECHAZADO   ({reason})")

print()
print(f"Resumen: {published} publicadas, {rejected} rechazadas en la frontera.")

Qué esperar. Al correr el archivo, la salida es exactamente esta:

=== Antipatron: confiar en la salida y publicarla directo ===
  PUBLICADO tal cual -> ''
  PUBLICADO tal cual -> 'El mejor producto del mundo, garantizado, cura todo.'
  PUBLICADO tal cual -> 'Bicicleta de montana rodada 29, 21 velocidades, frenos de disco.'
  PUBLICADO tal cual -> '\x00\x00 respuesta corrupta \x07'
  PUBLICADO tal cual -> 'Bicicleta de montana rodada 29, 21 velocidades, frenos de disco.'
  PUBLICADO tal cual -> {'unexpected': 'json'}

=== Patron: la salida es no confiable; validar en la frontera ===
  RECHAZADO   (vacia)
  RECHAZADO   (claim prohibido 'cura')
  PUBLICADO   'Bicicleta de montana rodada 29, 21 velocidades, frenos de disco.'
  RECHAZADO   (contiene caracteres de control)
  PUBLICADO   'Bicicleta de montana rodada 29, 21 velocidades, frenos de disco.'
  RECHAZADO   (no es texto)

Resumen: 2 publicadas, 4 rechazadas en la frontera.

Lee las dos secciones en contraste, porque ahí está toda la lección.

En el antipatrón, el sistema confía y publica lo que sea. Mira lo que llegó a la tienda: una descripción vacía (un producto sin texto), un claim prohibido ("el mejor del mundo, garantizado, cura todo"), un texto corrupto con caracteres de control ('\x00\x00 respuesta corrupta \x07', que puede romper el render o hasta ser un vector de ataque), y —el más elocuente— un {'unexpected': 'json'}, un objeto que ni siquiera es texto. Ese último caso es el que hay que grabarse: el sistema esperaba una descripción de producto y publicó una estructura de datos. No es que el modelo "escribiera mal"; es que la salida no era del tipo que el resto del sistema asumía, y nadie lo revisó. Todo esto llegó a producción porque el diseño confió.

En el patrón, el mismo stub con la misma semilla produce exactamente lo mismo, pero ahora cada salida choca contra is_publishable antes de usarse. La vacía se rechaza por vacía; el claim se rechaza por prohibido; el texto corrupto se rechaza por caracteres de control; el dict se rechaza por "no es texto". Solo las dos salidas legítimas —las dos descripciones de la bicicleta, bien formadas y sin claims— pasan. Dos publicadas, cuatro rechazadas. El sistema nunca expuso la basura, no porque el modelo mejorara —es idéntico—, sino porque la salida se trató como no confiable y se validó en la frontera.

Fíjate en lo que hace la validación: no afirma qué dice la salida, verifica que cumpla propiedades —es texto, no vacío, sin caracteres de control, dentro de un límite, sin claims prohibidos—. Eso es exactamente lo que hace el jefe sensato con el borrador del pasante: no revisa el estilo, revisa hechos y reglas. Y por eso funciona incluso cuando el modelo produce algo que nunca anticipaste: la propiedad "debe ser texto" atrapó un dict que ningún test de contenido específico habría previsto.

Profundización: qué significa "tratar la salida como entrada de usuario"

Hay una frase que conviene interiorizar porque reordena todo el diseño: la salida del LLM se trata como entrada de usuario. Piénsalo. ¿Confías en lo que un usuario anónimo escribe en un formulario web y lo insertas directo en tu base de datos, lo ejecutas, lo muestras a otros usuarios sin sanear? No. Lo validas, lo escapas, lo acotas —décadas de seguridad web se construyeron sobre "nunca confíes en la entrada del usuario"—. La tesis de esta lección es que la salida de un LLM merece exactamente el mismo trato, y por una razón profunda: esa salida puede haber sido influida por entrada de usuario no confiable (una inyección, que veremos en la lección 5), así que efectivamente es una forma de entrada de usuario que dio una vuelta por el modelo.

Vale la pena hacer explícita la anatomía, porque es la forma que vas a aplicar a cada componente de IA:

        atributos del vendedor (entrada, no confiable)
                        │
                        ▼
              ┌───────────────────┐
              │   ai_component    │   el LLM (stub): PROPONE una salida.
              │   (el LLM)        │   La salida es una HIPOTESIS, no un hecho.
              └───────────────────┘
                        │  salida propuesta (NO CONFIABLE)
                        ▼
              ┌───────────────────┐
              │  is_publishable   │   GUARDRAIL de salida (determinista):
              │  (guardrail)      │   verifica PROPIEDADES, no confia.
              └───────────────────┘
                   │            │
                 PASA        RECHAZA
                   │            │
                   ▼            ▼
          tienda publica   (nada se publica; fallback/reintento)

Verificar propiedades, no igualdad. No puedes escribir assert output == "la descripción correcta" —el modelo da salidas distintas para el mismo input, como viste en el módulo 1—. El contrato de la salida es un conjunto de propiedades e invariantes: es del tipo esperado, no vacía, dentro de un rango, con el formato correcto, sin contenido prohibido. is_publishable es ese contrato hecho código. Y por ser determinista, es testeable con assert exacto: assert is_publishable("")[0] is False pasa siempre. La incertidumbre vive en el modelo; la frontera que la contiene es certera.

El tipo es la primera propiedad, y la más olvidada. El caso del {'unexpected': 'json'} no es exótico: cuando pides al modelo salida estructurada (JSON) y el modelo devuelve texto que no parsea, o parsea a algo que no es lo que esperabas, tienes un objeto del tipo equivocado corriendo por tu sistema. La primera línea de is_publishableif not isinstance(text, str)— atrapa toda una familia de bugs que de otro modo explotan mucho más adentro, cuando algo intenta hacer len() o .lower() sobre un dict. Validar el tipo en la frontera convierte un crash misterioso en un rechazo limpio y explicado. La lección 3 lleva esto a fondo con la validación por schema.

"Casi siempre acierta" es exactamente la trampa. El argumento más común contra validar la salida es "el modelo acierta el 95% de las veces, para qué tanto guardrail". Dale la vuelta: si el modelo acierta el 95%, falla 1 de cada 20 salidas, y a la escala de un marketplace eso son miles de descripciones malas publicadas al mes. Y el problema no es solo el volumen: es que no sabes cuáles de las 20 es la mala hasta que la validas, porque todas suenan bien. La validación no existe para el caso normal —para eso no haría falta—; existe precisamente para atrapar ese 5% que, sin frontera, llega a producción con la misma confianza que el 95% bueno.

Rechazar no es el final: es el inicio del fallback. Cuando el guardrail rechaza una salida, el sistema no se queda sin respuesta; degrada. Puede reintentar el modelo (a veces la segunda salida sí pasa), caer a una plantilla determinista, o pedir intervención humana. Esa ruta degradada es el tema del módulo 5 (resiliencia). Por ahora, lo importante es que rechazar una salida mala siempre es mejor que publicarla: el costo de un reintento o una plantilla es mínimo comparado con el de un claim ilegal en la tienda.

Errores comunes

Confiar en la salida porque el modelo "es muy bueno". Qué pasa: el equipo mide que el modelo acierta el 95% en sus pruebas y decide que validar es paranoia. En producción, el 5% que falla —a escala— es un flujo constante de salidas malas publicadas, y como todas suenan bien, nadie las detecta hasta que un cliente reclama o un regulador pregunta. Por qué pasa: se confunde "buena tasa de acierto" con "confiable". Un componente con 5% de fallo silencioso no es confiable para un camino que toca lo público. Cómo detectarlo: tu diseño usa la salida del modelo sin una validación determinista en medio, y tu justificación es la tasa de acierto. Cómo corregirlo: valida siempre, porque la validación es barata y el fallo silencioso es caro. La tasa de acierto decide cuánto reintentas o degradas, no si validas.

Validar el contenido pero no el tipo/forma. Qué pasa: el guardrail revisa claims prohibidos y longitud, pero asume que la salida es un string y llama .lower() sobre ella; el día que el modelo devuelve un objeto o None, el guardrail mismo revienta con un AttributeError. Por qué pasa: se valida "lo que puede estar mal en el texto" y se olvida que "puede que ni sea texto". Cómo detectarlo: tu validación asume el tipo de la salida sin comprobarlo en la primera línea. Cómo corregirlo: valida el tipo/forma primeroisinstance, parseo, campos requeridos— y solo después el contenido. Como en el ejemplo: if not isinstance(text, str) va antes que cualquier chequeo de claims. La lección 3 formaliza esto con schema.

Poner la validación después de usar la salida. Qué pasa: el sistema publica la descripción y luego corre un job que revisa contenido y despublica lo malo. Entre la publicación y la despublicación, la salida mala estuvo visible —quizás horas—, y el daño (un claim ilegal visto por clientes, un texto ofensivo) ya ocurrió. Por qué pasa: validar "después" se siente más simple y no bloquea el flujo. Cómo detectarlo: en tu diseño, la salida cruza a producción antes de pasar el guardrail. Cómo corregirlo: la validación va en la frontera, antes de usar la salida —el guardrail es una compuerta que la salida cruza para llegar a producción, no un auditor que la revisa una vez que ya llegó—. Rechazar antes de publicar es prevención; despublicar después es limpieza de un incidente que ya pasó.

Ejercicios

Ejercicio 1 — El orden de las propiedades. En is_publishable, el primer chequeo es isinstance(text, str) y el último es el de claims prohibidos. Explica por qué ese orden importa, y qué pasaría si el chequeo de claims (text.lower()) fuera primero y la salida del modelo fuera el dict {'unexpected': 'json'}.

Ver solución

El orden importa porque los chequeos posteriores asumen lo que los anteriores garantizaron. El chequeo de claims hace text.lower(), que solo existe en un string; asume que la salida ya se confirmó como texto. Si el chequeo de claims fuera primero y la salida fuera {'unexpected': 'json'} (un dict), la línea text.lower() lanzaría AttributeError: 'dict' object has no attribute 'lower' —el guardrail mismo reventaría en vez de rechazar limpiamente—. Poniendo isinstance(text, str) primero, el dict se rechaza con "no es texto" antes de que ningún chequeo que asuma string lo toque. La regla general: valida de lo más básico y estructural (tipo, no vacío) a lo más específico y de contenido (claims), porque cada capa depende de que la anterior se cumpla. Es la misma idea que en la lección 7 con el orden del pipeline: barato/estructural primero.

Ejercicio 2 — La tasa de acierto no decide si validas. Un compañero argumenta: "medimos que el generador acierta el 98% de las descripciones; validar el 100% de las salidas es desperdicio de cómputo". Refuta el argumento con un cálculo concreto a escala de Mercado (supón 50,000 descripciones generadas al mes) y explica qué decisión de diseño depende de la tasa de acierto.

Ver solución

Con 98% de acierto y 50,000 descripciones al mes, el 2% que falla son 1,000 descripciones malas al mes —claims falsos, textos vacíos, corrupción— que sin validación llegan a la tienda pública. Mil incidentes potenciales mensuales no es "desperdicio de cómputo"; validar una cadena de texto cuesta microsegundos, mientras que un solo claim ilegal publicado puede costar una multa o la confianza de los clientes. El cálculo deja claro que la validación no es opcional a ninguna tasa de acierto realista: incluso al 99.9%, son 50 salidas malas al mes, y no sabes cuáles hasta validarlas.

Lo que depende de la tasa de acierto es la política de fallback: si el modelo acierta el 98%, rechazar el 2% y reintentar o caer a plantilla es barato y raro; si acertara solo el 60%, rechazarías tanto que necesitarías un modelo mejor, un prompt mejor, o replantear la feature. La tasa de acierto informa cuánto degradas y si el modelo es lo bastante bueno para la feature —eso es el eval del módulo 3—, no si pones la frontera. La frontera va siempre.

Ejercicio 3 — Propiedades para el agente de soporte. El guardrail del ejemplo valida descripciones de producto. Ahora el agente de soporte propone una acción en forma de objeto: {"action": "reply", "text": "..."} o {"action": "refund", "order_id": "...", "amount": ...}. Escribe (en pseudocódigo o Python) las propiedades que un guardrail debería verificar sobre esa salida antes de usarla, y explica por qué verificar el tipo/forma es aquí aún más crítico que en las descripciones.

Ver solución

Propiedades a verificar sobre la propuesta del agente:

def is_valid_action(proposal):
    # 1) Tipo/forma: debe ser un dict con una accion conocida.
    if not isinstance(proposal, dict):
        return (False, "no es un objeto")
    action = proposal.get("action")
    if action not in ("reply", "refund"):
        return (False, "accion desconocida")
    # 2) Campos por tipo de accion.
    if action == "reply":
        text = proposal.get("text")
        if not isinstance(text, str) or text.strip() == "":
            return (False, "reply: texto vacio o no es string")
        # (ademas: moderacion, no fuga de datos internos — lecciones 5 y 6)
    if action == "refund":
        oid = proposal.get("order_id")
        amount = proposal.get("amount")
        if not isinstance(oid, str) or not isinstance(amount, (int, float)):
            return (False, "refund: campos con tipo invalido")
        if amount <= 0:
            return (False, "refund: monto no positivo")
        # (ademas: validar contra la politica de negocio — modulo 6)
    return (True, "ok")

Verificar el tipo/forma es aquí aún más crítico que en las descripciones porque esta salida no se muestra, se ejecuta: el sistema va a leer proposal["amount"] y potencialmente mover dinero. Si la salida no es un dict, o amount viene como el string "9999" en vez de número, o falta order_id, el código que ejecuta la acción se comporta de forma indefinida —o peor, hace algo con un valor basura—. Una descripción malformada ensucia la tienda; una acción malformada puede tocar dinero o estado. Por eso el tipo/forma se valida primero y sin excepción, y por eso el módulo 6 (la cáscara determinista) desarrolla la validación de acciones propuestas con todo el rigor de las reglas de negocio. Aquí queda la propiedad base: nunca ejecutes una acción cuya forma no verificaste.

Resumen y siguiente paso

En esta lección instalaste el principio raíz del módulo: la salida del LLM es no confiable hasta que la validas, y se trata como entrada de usuario, no como el retorno confiable de una función. Lo viste con el pasante brillante cuyo borrador se ve perfecto y puede estar mal, y lo mediste: el antipatrón que confía publicó a la tienda una salida vacía, un claim prohibido, un texto corrupto con caracteres de control y —el más elocuente— un dict que ni siquiera era texto; el patrón que valida por propiedades rechazó cuatro de seis y dejó pasar solo las dos legítimas, con el mismo modelo idéntico. La seguridad no vino de que el modelo mejorara; vino de tratar su salida como una hipótesis que hay que verificar. Y viste por qué "casi siempre acierta" es exactamente la trampa: el 5% que falla suena igual de bien que el 95% bueno, y no sabes cuál es hasta validarlo.

Antes de avanzar deberías poder: explicar por qué la fluidez de una salida no dice nada sobre su corrección; verificar propiedades de una salida en vez de confiar en ella; argumentar por qué el tipo/forma se valida primero; y refutar el "casi siempre acierta" con un cálculo a escala.

La lección 3 toma la propiedad más estructural —el tipo y la forma— y la formaliza: la validación por schema en la frontera. Cuando pides al modelo una salida estructurada (un JSON con title, description, category, tags), el schema es el contrato que verifica tipos, campos requeridos, rangos y un conjunto cerrado de valores. Vas a ver, ejecutado, seis salidas simuladas del generador contra ese schema: dos cumplen y pasan, y el resto se rechaza —no es JSON, categoría inválida, título vacío, demasiados tags—. La forma de convertir "no es texto" en un contrato preciso y testeable.

Recursos

  • OWASP Top 10 for LLM Applications — owasp.org/www-project-top-10-for-large-language-model-applications. El riesgo LLM05 Improper Output Handling es exactamente el tema de esta lección: tratar la salida del modelo como confiable y usarla sin validar es una vulnerabilidad catalogada. Léelo por su encuadre de la salida como superficie de riesgo. En inglés.
  • Martin Fowler y Bharani Subramaniam, "Emerging Patterns in Building GenAI Apps" — martinfowler.com/articles/gen-ai-patterns. La sección de guardrails trata la validación de salida como un patrón de primera clase alrededor del componente de IA. En inglés.
  • Anthropic, documentación de Claude — docs.anthropic.com. Las guías de salida estructurada y de uso de herramientas muestran, a nivel conceptual, cómo pedir salidas con forma verificable —el punto de partida para validarlas—, sin fijarte en una versión de modelo. En inglés.
  • Chip Huyen, AI Engineering (O'Reilly, 2024). Los capítulos sobre confiabilidad de aplicaciones con modelos de fundación tratan la salida del modelo como no confiable por defecto y la validación como parte del diseño. En inglés.