Módulo 4: Guardrails y la frontera de confianza
Validación por schema en la frontera
Descripción
En la lección 2 aprendiste que la salida del LLM es no confiable y que la primera propiedad a verificar es el tipo/forma —viste un {'unexpected': 'json'} que ni siquiera era texto llegar a la tienda—. Esta lección toma esa idea y la convierte en una herramienta precisa: el schema. Cuando pides al modelo una salida estructurada —no un párrafo libre, sino un objeto con campos: title, description, category, tags—, el schema es el contrato que define exactamente qué forma debe tener esa salida: qué campos son requeridos, de qué tipo es cada uno, en qué rango, y qué valores están permitidos. Y el guardrail de schema es una compuerta determinista: una salida que no cumple el schema se rechaza en la frontera, punto. No se intenta arreglar, no se publica "a ver si funciona"; se rechaza.
Esto es más poderoso de lo que parece. Validar texto libre es difícil —¿cómo verificas que un párrafo es "una buena descripción"?—. Validar una salida estructurada contra un schema es mecánico y certero: o category está en el conjunto de categorías válidas, o no lo está. Al pedir al modelo que produzca estructura y validarla contra un schema, conviertes un problema borroso ("¿es buena esta salida?") en uno nítido y testeable ("¿cumple estos campos, tipos y rangos?"). Vas a ver, ejecutado, seis salidas simuladas del generador "describe tu producto" pasando por un validador de schema: dos cumplen y cruzan la frontera, cuatro se rechazan, cada una por una violación distinta.
Conexión con el módulo. La lección 2 dio el principio (la salida es no confiable) y la técnica base (verificar propiedades). Esta lección formaliza la propiedad más estructural —tipo y forma— como un schema verificable, la forma canónica de validar salida estructurada en la frontera. Es una de las tres compuertas que viste en la batería de la lección 1 (schema). Las lecciones 4 a 6 añaden las otras validaciones (entrada, inyección, moderación) y la 7 las compone. La frontera con AI Engineering se mantiene: aquí validamos la salida estructurada como propiedad arquitectónica; cómo logras que el modelo produzca JSON confiable (tool use, structured outputs, el prompt) es AI Engineering.
Una analogía: el formulario de aduana
Cuando entras a un país, llenas un formulario de aduana. No es una hoja en blanco donde escribes lo que se te ocurra; es un formulario con campos definidos: nombre (texto), fecha de nacimiento (fecha con formato), motivo del viaje (una lista cerrada: turismo / negocios / tránsito), número de maletas (un entero). Y en el mostrador, el oficial no evalúa si tu prosa es bonita; verifica que cada campo esté lleno, con el tipo correcto, dentro de lo permitido. Si escribiste "azul" donde va la fecha de nacimiento, el formulario se rechaza. Si pusiste como motivo "contrabando", que no está en la lista de opciones válidas, se rechaza. Si dejaste el nombre vacío, se rechaza. El oficial no interpreta ni adivina: aplica el schema del formulario.
Fíjate en dos virtudes de ese diseño. Primero, la validación es objetiva y rápida: no hay juicio, hay reglas. "¿La fecha tiene formato de fecha? ¿El motivo está en la lista?" son preguntas con respuesta binaria. Segundo, el formulario fuerza estructura en el origen: al pedirte campos en vez de un texto libre, el país hace que sea fácil verificar lo que declaras. Imagina lo difícil que sería si cada viajero entregara un ensayo describiendo su viaje y el oficial tuviera que leerlo y decidir. El formulario convierte un problema de interpretación en uno de verificación.
Aquí está el punto: el schema es el formulario de aduana de la salida del LLM. En vez de dejar que el modelo entregue un párrafo libre que después es difícil de validar, le pides una salida estructurada —campos definidos— y en la frontera aplicas el schema como el oficial aplica las reglas del formulario: ¿están todos los campos?, ¿con el tipo correcto?, ¿dentro de lo permitido? Lo que no cumple, se rechaza sin interpretación. En Mercado, la descripción del producto no es un ensayo libre que alguien tiene que leer y aprobar; es un objeto con title, description, category y tags, y el guardrail de schema es el oficial de aduana que verifica que cada campo cumpla antes de dejarlo entrar a la tienda.
Ejemplo trabajado: el validador de schema rechaza lo que no cumple
Vamos a definir el schema del generador y validar contra él. El schema dice: title es un string de 1 a 60 caracteres; description, un string de 1 a 200; category, uno de un conjunto cerrado de cuatro; tags, una lista de hasta 5 strings (opcional). Le pasamos seis salidas simuladas del modelo —algunas bien formadas, otras con una violación cada una— y vemos qué hace la frontera. El LLM está simulado: trabajamos con salidas ya producidas, porque el foco es el validador.
# Leccion 3: validacion por SCHEMA en la frontera. La salida estructurada
# del generador se valida contra un schema; lo que no cumple se RECHAZA.
# LLM simulado por stub determinista; sin red ni APIs.
import json
# El generador "describe tu producto" debe devolver un JSON con esta forma:
# title: str, 1..60 chars
# description: str, 1..200 chars
# category: str, en SCHEMA_CATEGORIES
# tags: list[str], 0..5 elementos (opcional; por defecto [])
SCHEMA_CATEGORIES = {"electronics", "home", "sports", "toys"}
def validate_product(raw):
# Devuelve (ok, errors). Determinista: mismo input, mismo veredicto.
# 1) Debe ser JSON bien formado.
try:
obj = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return (False, ["no es JSON valido"])
if not isinstance(obj, dict):
return (False, ["el top-level no es un objeto"])
errors = []
# 2) Campos requeridos y tipos/rangos.
title = obj.get("title")
if not isinstance(title, str) or not (1 <= len(title) <= 60):
errors.append("title: str de 1..60 chars")
desc = obj.get("description")
if not isinstance(desc, str) or not (1 <= len(desc) <= 200):
errors.append("description: str de 1..200 chars")
cat = obj.get("category")
if cat not in SCHEMA_CATEGORIES:
errors.append("category: fuera del conjunto permitido")
tags = obj.get("tags", [])
if (not isinstance(tags, list) or len(tags) > 5
or not all(isinstance(t, str) for t in tags)):
errors.append("tags: list[str] de 0..5 elementos")
return (len(errors) == 0, errors)
# Salidas SIMULADAS del modelo (algunas bien formadas, otras no).
CANDIDATES = [
('{"title": "Auriculares BT", "description": "Cancelacion de ruido, 30h.", '
'"category": "electronics", "tags": ["audio", "wireless"]}'), # ok
('{"title": "Bici 29", "description": "21 velocidades", '
'"category": "vehiculos", "tags": []}'), # category invalida
('{"title": "", "description": "Sin titulo", '
'"category": "home", "tags": []}'), # title vacio
('No puedo generar eso, pero aqui va un texto libre.'), # no es JSON
('{"title": "Set de pesas", "description": "Ajustables 2-20kg", '
'"category": "sports", "tags": ["a","b","c","d","e","f"]}'), # >5 tags
('{"title": "Rompecabezas", "description": "1000 piezas", '
'"category": "toys"}'), # ok (tags por defecto)
]
print(f"{'#':<3}{'veredicto':<11}detalle")
print("-" * 64)
ok_count = 0
for i, raw in enumerate(CANDIDATES, start=1):
ok, errors = validate_product(raw)
if ok:
ok_count += 1
print(f"{i:<3}{'ACEPTA':<11}pasa el schema")
else:
print(f"{i:<3}{'RECHAZA':<11}{'; '.join(errors)}")
print("-" * 64)
print(f"{ok_count}/{len(CANDIDATES)} salidas cumplen el schema; "
f"el resto se rechaza en la frontera.")
Qué esperar. Al correr el archivo, la salida es exactamente esta:
# veredicto detalle
----------------------------------------------------------------
1 ACEPTA pasa el schema
2 RECHAZA category: fuera del conjunto permitido
3 RECHAZA title: str de 1..60 chars
4 RECHAZA no es JSON valido
5 RECHAZA tags: list[str] de 0..5 elementos
6 ACEPTA pasa el schema
----------------------------------------------------------------
2/6 salidas cumplen el schema; el resto se rechaza en la frontera.
Lee la tabla caso por caso, porque cada rechazo enseña un tipo de violación distinto.
El caso 1 cumple: JSON válido, title de 14 caracteres, description corta, category = "electronics" (en el conjunto), tags con dos strings. Cruza la frontera. El caso 6 también, y es interesante por qué: no trae tags, pero el schema los hace opcionales con obj.get("tags", []) —lista vacía por defecto—, así que la ausencia de un campo opcional no es una violación. Dos aceptadas.
Los cuatro rechazos, cada uno por una razón distinta:
- Caso 2 —
categoryfuera del conjunto. El modelo puso "vehiculos", que no es una de las cuatro categorías válidas. Este es el rechazo más típico y más útil: un conjunto cerrado de valores ({"electronics", "home", "sports", "toys"}) es la forma más fuerte de validación de contenido, porque no admite creatividad. El modelo puede inventar una categoría plausible; el schema solo acepta las que existen. - Caso 3 —
titlevacío. JSON válido, perotitlees"", que viola el rango 1..60. Una descripción con título vacío rompería la tienda; el schema la para. - Caso 4 — no es JSON. El modelo devolvió texto libre ("No puedo generar eso..."). El
json.loadsfalla y el validador rechaza de inmediato, sin siquiera mirar campos. Este caso es el más común en la práctica: le pides estructura al modelo y a veces te da prosa. - Caso 5 — demasiados
tags. JSON válido, campos correctos, pero seis tags donde el máximo es cinco. Un rango sobre el tamaño de una colección, no solo sobre un valor escalar.
Fíjate en la implicación: el schema convierte cuatro problemas muy distintos —prosa en vez de JSON, un valor inventado, un campo vacío, una colección demasiado grande— en un solo veredicto binario y determinista: cumple o no cumple. No hubo interpretación, no hubo juicio, no hubo "quizás". Y como es determinista, la parte crítica de la frontera es testeable con assert exacto: assert validate_product('{"title":"","description":"x","category":"home"}')[0] is False pasa siempre. La incertidumbre del modelo queda afuera; el schema que la contiene es certeza pura.
Profundización: por qué pedir estructura y validarla es la jugada fuerte
Vale la pena entender por qué la validación por schema es tan efectiva, y dónde están sus límites.
Pedir estructura desplaza el problema de "interpretar" a "verificar". Podrías dejar que el modelo genere un párrafo libre y luego intentar validarlo —extraer el título con una regex, adivinar la categoría con otro modelo—. Es frágil y borroso. En cambio, si le pides al modelo un JSON con campos y validas ese JSON contra un schema, la validación se vuelve mecánica: comprobar tipos y rangos es trivial y certero. La estructura que pides en el origen es lo que hace posible la verificación en la frontera. Por eso, cuando la salida de un LLM va a ser consumida por código (no leída por un humano), casi siempre conviene pedirla estructurada.
El conjunto cerrado es tu amigo más fuerte. De todas las validaciones, la más poderosa es el conjunto cerrado de valores (category in {...}). Un rango numérico admite muchos valores; un conjunto cerrado admite exactamente los que enumeraste. Cuando un campo puede ser solo una de N opciones conocidas —categoría, estado, tipo de acción, prioridad—, exprésalo como un conjunto cerrado y el modelo pierde toda capacidad de inventar. El caso 2 lo muestra: "vehiculos" suena a categoría perfectamente razonable, y por eso mismo un chequeo laxo la dejaría pasar; el conjunto cerrado no.
Distingue "requerido" de "opcional con valor por defecto". El caso 6 pasa sin tags porque el schema los trata como opcionales (obj.get("tags", [])). Diseñar el schema es decidir, campo por campo, qué es obligatorio y qué tiene un valor por defecto seguro. Un title ausente es una violación (no hay default seguro para un título); unos tags ausentes no lo son (la lista vacía es un default seguro). Esa distinción evita rechazar salidas perfectamente usables por un campo opcional que faltó, y evita aceptar salidas rotas por un campo requerido que faltó.
El schema valida forma, no verdad. Aquí está el límite honesto, y hay que decirlo claro. El schema garantiza que la salida tiene la forma correcta: category es una de las válidas, title tiene largo razonable. No garantiza que el contenido sea verdadero ni apropiado. Una salida puede cumplir el schema perfectamente y aun así tener un claim falso en description ("cura el insomnio", que es un string de 1..200 caracteres, válido para el schema) o una categoría que técnicamente es válida pero incorrecta para el producto. Por eso el schema es una compuerta, no la única: la moderación (lección 6) revisa el contenido, y el schema revisa la forma. Se necesitan las dos. El caso 1 pasó el schema, pero si su description dijera "el mejor del mundo, garantizado", la moderación aún tendría que atraparlo. El schema es necesario y no suficiente.
Rechazar y reintentar es la respuesta natural al fallo de schema. Cuando el modelo devuelve algo que no parsea o que le falta un campo, muchas veces un reintento —a veces con un mensaje que le recuerda el formato— produce una salida válida. Esa lógica de reintento/degradación es del módulo 5. Aquí lo importante es que un fallo de schema es un rechazo limpio y accionable: sabes exactamente qué campo falló, así que puedes reintentar con precisión o caer a un default. Un fallo de schema nunca debe convertirse en "publiquémoslo a ver".
Errores comunes
Parsear la salida sin validar contra un schema. Qué pasa: el equipo hace data = json.loads(output) y usa data["price"] directamente, asumiendo que el modelo siempre incluye ese campo con el tipo correcto. El día que el modelo omite price, o lo pone como el string "caro", el código revienta con KeyError o hace algo absurdo con un valor basura. Por qué pasa: se confunde "parsea como JSON" con "cumple el contrato". Que un texto sea JSON válido no dice nada sobre si tiene los campos, tipos y rangos que tu sistema espera. Cómo detectarlo: accedes a campos de la salida del modelo sin haber verificado antes que existen y son del tipo correcto. Cómo corregirlo: valida contra un schema explícito antes de acceder a cualquier campo —tipos, requeridos, rangos, conjuntos cerrados—. json.loads es solo el primer paso; el schema es el contrato.
Confundir "pasa el schema" con "es correcto". Qué pasa: el equipo confía en que si la salida cumple el schema, es buena, y quita otras validaciones. Se publica una descripción que cumple el schema al pie de la letra y contiene un claim ilegal en el campo description. Por qué pasa: se sobreestima lo que el schema garantiza —forma, no verdad—. Cómo detectarlo: tu única validación de salida es el schema, y no hay moderación de contenido después. Cómo corregirlo: entiende que el schema es necesario pero no suficiente. Valida la forma con el schema y el contenido con la moderación (lección 6). Son compuertas distintas para riesgos distintos; el stack de la lección 7 las combina.
Un schema demasiado laxo que no restringe nada. Qué pasa: el schema dice category: str (cualquier string) en vez de category in {conjunto cerrado}, y description: str sin límite de longitud. El validador "pasa" salidas con categorías inventadas y descripciones de 5000 caracteres, porque técnicamente son strings. Por qué pasa: se define el schema por el tipo mínimo (es un string) sin capturar las restricciones reales (es una de estas categorías, de este largo). Cómo detectarlo: tu schema acepta salidas que sabes que están mal. Cómo corregirlo: aprieta el schema hasta que exprese las restricciones reales del dominio —conjuntos cerrados para valores enumerables, rangos para longitudes y números—. Un schema que solo comprueba tipos deja pasar casi toda la basura que importa; el valor está en los rangos y los conjuntos cerrados.
Ejercicios
Ejercicio 1 — Diseña el schema del agente de soporte. El agente de soporte propone una acción como {"action": ..., "order_id": ..., "amount": ..., "reason": ...}. Define el schema: qué campos son requeridos, sus tipos, y qué campos deberían ser un conjunto cerrado. Explica por qué expresar action como conjunto cerrado es la validación más importante de todo el schema.
Ver solución
Un schema razonable:
action— requerido, string, conjunto cerrado:{"reply", "refund", "escalate"}. Ninguna otra acción es válida.order_id— requerido siaction == "refund"; string con un formato esperado (p.ej. empieza con letra-guion). Opcional parareply.amount— requerido siaction == "refund"; número (int/float) mayor que 0. No aplica parareply.reason— opcional; string de largo acotado (para logging/auditoría).
Expresar action como conjunto cerrado es la validación más importante porque action decide qué hace el sistema con la propuesta. Si action pudiera ser cualquier string, el modelo podría proponer "delete_account", "grant_admin", o cualquier verbo que alucine, y el sistema tendría que decidir qué hacer con una acción desconocida —terreno peligroso—. Al restringir action a un conjunto cerrado de tres verbos que el sistema sabe manejar de forma segura, cualquier acción fuera de ese conjunto se rechaza en la frontera, antes de que llegue a la lógica que ejecuta. El conjunto cerrado de action es la diferencia entre "el modelo solo puede pedir cosas que sabemos manejar" y "el modelo puede pedir cualquier cosa". La validación del monto protege el dinero; la validación de la acción protege el conjunto de operaciones posibles, que es aún más fundamental.
Ejercicio 2 — Forma correcta, contenido malo. Escribe una salida del generador que pase el validate_product del ejemplo (cumple schema) pero que no debería publicarse. Explica qué compuerta —distinta del schema— haría falta para atraparla, y por qué esto demuestra que el schema es necesario pero no suficiente.
Ver solución
Una salida que pasa el schema pero no debería publicarse:
{"title": "Cura milagrosa", "description": "Este parche cura la diabetes y es el mejor del mundo, garantizado 100%.", "category": "home", "tags": ["salud"]}
Pasa el schema perfectamente: title es un string de 15 caracteres (dentro de 1..60), description es un string de largo válido (dentro de 1..200), category es "home" (en el conjunto cerrado), tags es una lista de un string. El validador de schema lo acepta. Y sin embargo es exactamente lo que no queremos publicar: un claim médico falso e ilegal.
La compuerta que haría falta es la moderación de contenido (lección 6): una regla que detecte claims prohibidos ("cura", "el mejor del mundo", "garantizado") en los campos de texto. El schema valida la forma (¿es un string del largo correcto?); la moderación valida el contenido (¿dice algo prohibido?). Este caso demuestra que el schema es necesario pero no suficiente: garantiza que la salida tiene la estructura correcta para ser consumida por el sistema, pero no garantiza que su contenido sea apropiado. Por eso el stack de la lección 7 encadena schema y moderación —cada uno atrapa una clase de problema que el otro deja pasar—.
Ejercicio 3 — El campo opcional peligroso. Un compañero propone agregar al schema del generador un campo discount_percent (número, 0..100) y hacerlo opcional con default 0. Otro compañero advierte que un default de descuento es peligroso. Explica el riesgo, y decide si discount_percent debería ser opcional-con-default, requerido, o simplemente no salir del modelo en absoluto.
Ver solución
El riesgo es que un descuento es algo que toca el precio, y por tanto el dinero. Si discount_percent es opcional con default 0, el default es "seguro" en el sentido de que 0 no cambia el precio. Pero el problema más profundo es otro: ¿por qué el modelo propondría un descuento en primer lugar? Un descuento es una decisión de negocio (margen, promoción, política), no algo que un generador de descripciones deba inventar. Aunque el schema valide que el número está en 0..100, un descuento bien formado pero inventado —digamos 30%— pasaría el schema y aplicaría una rebaja que nadie autorizó.
La decisión correcta: discount_percent no debería salir del modelo en absoluto. Es un caso donde la mejor validación es no darle al modelo la posibilidad de proponer el campo. El generador describe el producto; el precio y los descuentos los fija la lógica de negocio determinista, fuera del alcance del LLM. Si por alguna razón el modelo sí debe sugerir un descuento (p.ej. como recomendación para que un humano lo apruebe), entonces no va como un campo que se aplica, sino como una propuesta que la cáscara determinista valida contra la política de precios y que un humano aprueba (módulo 6). La regla general: cuando dudes si un campo que toca dinero/estado debe salir del modelo, la respuesta por defecto es que no salga —el schema valida forma, pero la forma correcta de un campo peligroso sigue siendo peligrosa si el modelo no debería estar decidiéndolo—.
Resumen y siguiente paso
En esta lección formalizaste la propiedad más estructural de la salida en una herramienta precisa: la validación por schema en la frontera. El schema es el formulario de aduana de la salida del LLM —campos definidos, tipos, rangos, conjuntos cerrados— y el guardrail de schema es el oficial que verifica cada campo sin interpretación: cumple o se rechaza. Lo mediste: seis salidas del generador contra un schema, dos aceptadas y cuatro rechazadas, cada una por una violación distinta —prosa en vez de JSON, categoría inventada, título vacío, demasiados tags—. Viste que pedir estructura desplaza el problema de interpretar a verificar, que el conjunto cerrado es la validación más fuerte, y —el límite honesto— que el schema valida forma, no verdad: una salida puede cumplir el schema y aun así tener un claim falso, por lo que hace falta la moderación como compuerta separada.
Antes de avanzar deberías poder: definir un schema con tipos, rangos y conjuntos cerrados para una salida estructurada; explicar por qué pedir estructura hace verificable la salida; distinguir un campo requerido de uno opcional-con-default seguro; y argumentar por qué el schema es necesario pero no suficiente.
Hasta aquí custodiamos la salida. La lección 4 gira hacia el otro borde de la frontera: los guardrails de entrada —validar lo que ENTRA al modelo—. Vas a ver, ejecutado, un guardrail que protege el costo (tope de tamaño), quita PII (redacción de email y tarjeta) y rechaza entrada vacía, aplicado a mensajes del cliente para el agente de soporte. Y vas a ver su límite dicho con honestidad: filtrar la entrada baja costo y quita datos sensibles, pero no garantiza contra prompt injection —esa frontera, la más delicada del módulo, es la lección 5—.
Recursos
- Martin Fowler y Bharani Subramaniam, "Emerging Patterns in Building GenAI Apps" — martinfowler.com/articles/gen-ai-patterns. El patrón de structured output y su validación es central en el artículo; pedir estructura y validarla contra un schema es exactamente la jugada de esta lección. En inglés.
- Anthropic, documentación de Claude, salida estructurada y uso de herramientas — docs.anthropic.com. Muestra, a nivel conceptual, cómo pedir al modelo salidas con forma definida (JSON, argumentos de herramienta) que luego validas contra tu schema, sin fijarte en una versión de modelo. En inglés.
- OWASP Top 10 for LLM Applications — owasp.org/www-project-top-10-for-large-language-model-applications. LLM05 Improper Output Handling cubre la validación de salida estructurada como control de seguridad; el schema es la implementación concreta de ese control. En inglés.
- Chip Huyen, AI Engineering (O'Reilly, 2024). Los capítulos sobre salida estructurada y confiabilidad tratan la validación de la forma de la salida como parte del diseño de una aplicación con modelos de fundación. En inglés.