Módulo 7: Seguridad y confiabilidad del agente

6. Alucinación y verificación de la salida

Descripción

Al terminar esta lección vas a poder distinguir los tres tipos de alucinación que se dan en un agente con tools —que no son el mismo problema ni se defienden igual—, vas a saber estructurar la salida del agente de modo que el hecho viaje separado de la redacción, y vas a tener montado el patrón que verifica cada dato factual contra lo que la tool devolvió de verdad, con la comparación exacta hecha en un nodo determinista y no en el criterio de otro modelo.

Esto importa porque cambia el enemigo. Las lecciones 2 a 5 se ocuparon de alguien que intenta que tu agente haga algo indebido. Aquí no hay nadie: el filtro está puesto, el contenido está aislado, los permisos están recortados, la aprobación funciona — y el agente igual le dice a la clienta que su pedido llega el jueves 12, un dato que lookup_order nunca devolvió. Para esa clienta, la diferencia entre un ataque y un descuido del modelo es exactamente cero: le prometiste algo falso, y el jueves 12 va a estar esperando. Un agente que inventa datos no es inseguro en el sentido de la lección 2; es poco confiable, y eso lo saca de producción con la misma velocidad.

Conexión con el módulo: las lecciones 4 y 5 pusieron límites a lo que el agente puede hacer. Esta pone límites a lo que el agente puede decir, que es la superficie que ninguna credencial de solo lectura cubre. Reutiliza dos piezas que ya tienes: la salida estructurada del Módulo 2, lección 6, que hasta ahora usabas para que la respuesta fuera fácil de procesar y hoy se vuelve el mecanismo que hace verificable un dato; y el nodo Guardrails de la lección 2, ahora aplicado a la salida en vez de a la entrada. La lección 7 cierra el módulo con la capacidad de reconstruir qué pasó cuando cualquiera de estas defensas no alcanzó.

El vendedor que cree recordar

Piensa en alguien que lleva años atendiendo el mostrador de una tienda. Conoce el catálogo, conoce a los clientes, y responde rápido — que es su virtud. Llega un cliente y pregunta cuánto cuesta un modelo que se acaba de agregar. Esta persona no lo tiene memorizado, pero conoce la línea, sabe cuánto valen los modelos parecidos, y responde con total naturalidad: "ese está en $890".

No mintió. No está siendo negligente en el sentido que solemos darle a la palabra. Su cerebro hizo lo que hace siempre: completar el patrón. Los modelos de esa línea valen entre $850 y $920, así que $890 es una respuesta perfectamente razonable. El problema es que el precio real es $1,150, y el cliente se acaba de ir con un número en la cabeza que la tienda va a tener que honrar o desmentir, y ninguna de las dos cosas es buena.

Fíjate en algo importante: esa persona respondió con la misma seguridad que cuando sabe el dato. No dijo "creo que", no dudó. La confianza con la que se dice algo no tiene relación con si es cierto. Esa desconexión es exactamente lo que hace peligrosa la alucinación de un modelo.

Una alucinación es una salida plausible que no está respaldada por los datos. No es un error aleatorio ni un fallo del sistema: es el mecanismo normal del modelo —completar lo más probable dado el contexto— aplicado a un hueco donde no había información. Y por eso las alucinaciones nunca son absurdas. Son siempre creíbles, con el formato correcto, con el tono correcto. Un modelo no inventa un número de referencia como XZ%%2; inventa TT-2026-4482, que se ve exactamente como los tuyos.

En un agente con tools, esto toma tres formas distintas, y conviene separarlas porque se defienden de manera diferente:

Tipo 1 — El dato inventado en la respuesta. La tool devolvió información parcial y el agente completó el resto al redactar. Es el caso más común y el del ejemplo de arriba: lookup_order devolvió status: in_transit sin fecha, y el agente escribió "llega el jueves". Se defiende verificando la respuesta contra la salida de la tool, que es el grueso de esta lección.

Tipo 2 — El parámetro inventado en una llamada. El agente llama a una tool con un valor que nadie proporcionó: un orderId que el cliente nunca dijo, un amount que no aparece en ningún lado. Ya viste el caso en el Módulo 4, lección 5, con $fromAI("refundAmount") sin descripción. Se defiende con el contrato de la tool, con parámetros fijos (lección 4) y, para lo sensible, con la aprobación humana (lección 5), donde el motivo inventado salta a la vista.

Tipo 3 — La afirmación sin ninguna tool detrás. El agente responde una pregunta que requería consultar un sistema sin consultar nada, porque el modelo "sabe" cómo suelen ser esas cosas. Es el más traicionero, porque la traza no muestra ninguna llamada equivocada — no muestra ninguna llamada. Se detecta comparando qué afirma la respuesta contra qué tools se llamaron en ese turno.

Los tres tienen un origen común, que vale la pena nombrar porque orienta la defensa: el modelo prefiere responder antes que admitir que no sabe. No porque le "dé vergüenza", sino porque en el material con el que fue entrenado una respuesta completa es abrumadoramente más frecuente que un "no tengo ese dato". Pedirle en el prompt que diga "no sé" ayuda —y hay que hacerlo—, pero estás pidiéndole que vaya contra su tendencia estadística, y eso funciona la mayoría de las veces, no todas. Otra vez la distinción del módulo: capa, no garantía.

Ejemplo trabajado

El caso concreto, con la traza completa, para ver dónde se cuela el dato falso.

# Sistema: order_specialist con lookup_order recortada
#          (la versión endurecida de la lección 4)

Mensaje de la clienta:

Hola, mi pedido 4521, ¿cuándo llega?

Traza de la ejecución:

1. order_specialist → llama lookup_order { orderId: "4521" }

2. lookup_order → devuelve:
   {
     "order_id": "4521",
     "customer_id": "CUS-8842",
     "status": "in_transit",
     "created_at": "2026-07-14",
     "shipped_at": "2026-07-19",
     "carrier_tracking_code": "TT-99381042"
   }

3. order_specialist → responde:
   "¡Hola! Tu pedido 4521 salió el 19 de julio y está en camino.
    Según el tiempo estimado de la transportadora debería llegarte
    el jueves 23. Puedes seguirlo con el código TT-99381042."

Lee la respuesta del paso 3 con cuidado y separa lo que es verdad de lo que no:

Afirmación¿De dónde salió?
"pedido 4521"order_id de la tool ✓
"salió el 19 de julio"shipped_at de la tool ✓
"está en camino"status: in_transit de la tool ✓
"el código TT-99381042"carrier_tracking_code de la tool ✓
"debería llegarte el jueves 23"De ningún lado

Cuatro de cinco afirmaciones son correctas y verificables. La quinta es inventada, y es —por supuesto— la única que a la clienta le importa. Es la respuesta a su pregunta.

Y mira lo bien construida que está la mentira. "El jueves 23" es coherente: el pedido salió el 19, cuatro días de tránsito es plausible, y el 23 de julio de 2026 efectivamente cae jueves. El modelo no tiró un número al azar; razonó sobre lo que sabe de envíos en general y produjo la estimación más razonable. Si tú, como persona, tuvieras que adivinar, dirías algo parecido.

El problema no es que la estimación sea mala. El problema es que la clienta no la va a leer como una estimación. Va a leer "el jueves 23" como un compromiso de TuTienda, y el viernes 24 va a escribir enojada. Y si además order_specialist tiene conectada create_ticket, va a haber un ticket documentando que la tienda prometió una fecha que nadie prometió.

Ahora, la pregunta que ordena la defensa: ¿cómo detecta un workflow que esa frase estaba de más? No hay ningún error. La ejecución está en verde, la tool respondió bien, el agente usó los datos correctamente en cuatro de cinco casos. La única forma de detectarlo es comparar, campo por campo, lo que la respuesta afirma contra lo que la tool devolvió. Y para poder comparar campo por campo, primero hay que tener campos — que es exactamente el problema de un párrafo de texto libre.

Separar el hecho de la redacción

Aquí está el cambio de diseño que hace posible todo lo demás, y es más simple de lo que parece: la respuesta del agente deja de ser un párrafo y pasa a ser un objeto con dos zonas. Una zona de hechos, con un campo por cada dato factual, y una zona de redacción, con el texto que va al cliente.

Piénsalo como una factura. Una factura tiene los importes en su renglón, cada uno en su casilla, y aparte tiene un campo de observaciones donde se puede escribir en prosa. Nadie audita una factura leyendo las observaciones: se auditan las casillas. Si los importes solo existieran dentro de un párrafo —"le cobramos aproximadamente mil doscientos por los tres artículos"— no habría nada que cuadrar.

Con la salida del agente pasa igual. Un párrafo no se puede verificar; un campo sí.

# Nodo: Structured Output Parser conectado a order_specialist
#
# JSON Schema:
{
  "type": "object",
  "properties": {
    "order_id":       { "type": "string" },
    "status":         { "type": "string",
                        "enum": ["pending", "in_transit",
                                 "delivered", "canceled"] },
    "shipped_at":     { "type": ["string", "null"] },
    "eta_date":       { "type": ["string", "null"],
                        "description": "Delivery date ONLY if the
                          lookup_order tool returned one. If the tool
                          did not return a delivery date, this MUST
                          be null. Never estimate it." },
    "tracking_code":  { "type": ["string", "null"] },
    "message_to_customer": { "type": "string",
                        "description": "The reply text. It must not
                          state any fact that is not present in the
                          fields above." },
    "facts_source":   { "type": "array", "items": { "type": "string" },
                        "description": "Names of the tools whose
                          output supports the facts above." }
  },
  "required": ["order_id", "status", "message_to_customer",
               "facts_source"]
}

Tres decisiones de este esquema merecen explicación, porque son las que hacen que funcione:

eta_date admite null explícitamente. Si el campo fuera obligatorio y de tipo string, estarías obligando al modelo a inventar una fecha — el esquema no le deja otra salida. Un campo que puede ser nulo le da al modelo una forma correcta de decir "no tengo ese dato", que es justamente lo que necesitas que pueda hacer.

status es un enum. No es texto libre. El modelo no puede escribir "probablemente entregado" ni "casi en destino"; tiene cuatro valores posibles y ninguno más. Cada vez que puedas cerrar un vocabulario, ciérralo: es la forma más barata de eliminar una clase entera de invenciones.

facts_source obliga a declarar el origen. Es un campo que muchas veces se omite y que hace un trabajo doble. Sirve para verificar el tipo 3 —si la respuesta afirma cosas y facts_source viene vacío, el agente respondió sin consultar nada— y, además, el solo hecho de tener que declarar la fuente cambia el comportamiento del modelo: al escribir la respuesta ya sabe que va a tener que decir de dónde sacó cada cosa.

Y ahora la verificación. Esto no lo hace otro modelo. Es tentador conectar un segundo agente que "revise si la respuesta es correcta", y es un error de diseño: estarías verificando una salida probabilística con otra salida probabilística, y cuando las dos se equivoquen a la vez —que pasa, porque suelen equivocarse en las mismas zonas grises— no tienes nada. La comparación es aritmética, y va en un nodo Code.

# Nodo: Code — Name: validate_agent_output
# (después del agente, antes de responder al canal)

// Comparamos, campo por campo, lo que el agente AFIRMA contra lo
// que la tool DEVOLVIÓ. No hay criterio ni interpretación aquí:
// o los valores coinciden exactamente, o no coinciden.

const claimed = $input.first().json;                 // salida del agente
const actual  = $('lookup_order').first().json;      // salida real de la tool

const violations = [];

// 1. El identificador tiene que ser el mismo. Si el agente habla
//    de otro pedido, todo lo demás carece de sentido.
if (String(claimed.order_id) !== String(actual.order_id)) {
  violations.push(
    `order_id no coincide: agente="${claimed.order_id}" ` +
    `tool="${actual.order_id}"`
  );
}

// 2. El estado tiene que ser literalmente el que devolvió la tool.
if (claimed.status !== actual.status) {
  violations.push(
    `status no coincide: agente="${claimed.status}" ` +
    `tool="${actual.status}"`
  );
}

// 3. La fecha de envío: si el agente afirma una, tiene que ser la
//    misma. Si la tool no devolvió ninguna, el agente no puede
//    tener una.
if (claimed.shipped_at && claimed.shipped_at !== actual.shipped_at) {
  violations.push(`shipped_at inventado o alterado: "${claimed.shipped_at}"`);
}

// 4. LA REGLA DEL EJEMPLO. La tool de TuTienda no devuelve fecha
//    estimada de entrega — ese dato no existe en nuestra vista.
//    Por lo tanto, cualquier valor no nulo aquí es una invención.
if (claimed.eta_date !== null && claimed.eta_date !== undefined) {
  violations.push(
    `eta_date inventado: el agente afirma "${claimed.eta_date}" ` +
    `y lookup_order no devuelve fecha de entrega`
  );
}

// 5. El código de seguimiento: exacto o nada. Un código con un
//    dígito cambiado es peor que ninguno, porque el cliente lo
//    va a pegar en la web de la transportadora.
if (claimed.tracking_code &&
    claimed.tracking_code !== actual.carrier_tracking_code) {
  violations.push(`tracking_code no coincide: "${claimed.tracking_code}"`);
}

// 6. Tipo 3: afirmar hechos sin haber consultado nada.
if (!Array.isArray(claimed.facts_source) ||
    claimed.facts_source.length === 0) {
  violations.push("el agente afirma hechos sin declarar ninguna fuente");
}

// 7. Una comprobación de texto, deliberadamente simple: que el
//    mensaje al cliente no contenga fechas que no estén validadas.
//    No intenta entender el texto — busca un patrón de fecha y
//    verifica que corresponda a un campo que sí verificamos.
const datePattern = /\b(\d{1,2}) de (enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre)\b/gi;
const datesInText = claimed.message_to_customer.match(datePattern) || [];
if (datesInText.length > 0 && !claimed.shipped_at) {
  violations.push(
    `el mensaje menciona fechas (${datesInText.join(", ")}) ` +
    `sin ningún campo de fecha validado`
  );
}

return [{
  json: {
    ...claimed,
    validation_passed: violations.length === 0,
    violations,
  }
}];

Y la rama que decide qué hacer con el resultado:

# Nodo: IF — Name: output_is_valid
#   Condition: {{ $json.validation_passed }} is true
#
#   [true]  ─► responder al cliente con message_to_customer
#
#   [false] ─► Switch por gravedad:
#                 · reintento con feedback   (1 vez)
#                 · respuesta degradada segura
#                 · escalar a una persona
#              y SIEMPRE: Google Sheets → hallucination_log

Qué esperar. Corre el ejemplo del pedido 4521 contra este validador. El agente produce:

{
  "order_id": "4521",
  "status": "in_transit",
  "shipped_at": "2026-07-19",
  "eta_date": "2026-07-23",
  "tracking_code": "TT-99381042",
  "message_to_customer": "¡Hola! Tu pedido 4521 salió el 19 de julio...",
  "facts_source": ["lookup_order"]
}

La comprobación 4 salta: eta_date viene con "2026-07-23" y lookup_order no devuelve ese campo. validation_passed queda en false, con la violación registrada. La respuesta no llega a la clienta. Y fíjate que la detección no dependió de que ningún modelo se diera cuenta de nada: dependió de que un if comparara un valor contra null.

Qué hacer cuando la validación falla

Tres caminos, y elegir mal aquí arruina la capa.

Reintento con feedback — una sola vez. Se vuelve a llamar al agente, agregando al contexto qué falló específicamente:

# Fragmento que se agrega al input del reintento
Tu respuesta anterior fue rechazada por la validación:
- eta_date inventado: afirmaste "2026-07-23" y lookup_order no
  devuelve fecha estimada de entrega.

Vuelve a responder. Si no tienes un dato, el campo va en null y
el mensaje al cliente no debe mencionarlo. Es correcto y esperado
decirle al cliente que no tenemos ese dato.

Qué esperar. En la mayoría de los casos el reintento produce una respuesta limpia: eta_date: null y un mensaje del tipo "Tu pedido 4521 salió el 19 de julio y está en camino. No tengo una fecha exacta de entrega, pero puedes seguirlo en tiempo real con el código TT-99381042." Esa respuesta es peor comercialmente y mejor en todo lo demás — y, sobre todo, es cierta.

El límite de un reintento es importante: si la segunda respuesta también falla, no insistas. Reintentar en bucle cuesta llamadas al modelo, agrega latencia que el cliente está esperando en el chat, y si el problema es sistemático —tu esquema pide algo que la tool no puede dar— reintentar no lo va a arreglar nunca.

Respuesta degradada segura. Se descarta el texto del agente y se arma la respuesta con una plantilla, usando solo los campos que sí se validaron:

# Nodo: Set — Name: safe_degraded_response
#
# message =
#   "Tu pedido {{ $('lookup_order').item.json.order_id }} está en
#    estado: {{ $('lookup_order').item.json.status }}.
#    {{ $('lookup_order').item.json.carrier_tracking_code
#       ? 'Código de seguimiento: ' +
#         $('lookup_order').item.json.carrier_tracking_code + '.'
#       : '' }}
#    Si necesitas más detalle, con gusto te comunico con una
#    persona del equipo."

Es fría, es plana, y no puede estar equivocada — porque no la escribió el modelo, la armó una plantilla con datos de la tool. Para acciones de bajo riesgo, es una salida perfectamente aceptable.

Escalar. Para casos donde ni el reintento ni la plantilla sirven: se registra, se le dice al cliente que una persona va a retomar el caso, y se notifica al equipo. Es lo correcto cuando el campo que falló es de los que cuestan dinero — un monto, una condición de garantía, un plazo legal.

Un criterio simple para elegir: reintento si la violación parece un desliz de redacción; degradada si el dato faltante no es crítico para el cliente; escalar si el campo que falló es de los que, mal, generan un reclamo con costo.

Segunda capa: Guardrails sobre la salida

La validación por campos cubre los datos que puedes comparar. Queda una franja que no: cosas que el agente no debe decir, independientemente de si son ciertas.

Para eso vuelve el nodo Guardrails de la lección 2, ahora aplicado al texto final antes de que salga hacia el canal.

# Nodo: Guardrails — Name: output_guardrail
#
# Operation:     Check Text for Violations
# Text To Check: {{ $json.message_to_customer }}
#
# Guardrails:
#
#   PII
#     Entities: CREDIT_CARD, EMAIL_ADDRESS, PHONE_NUMBER
#     # Evita que un dato de otra persona, arrastrado desde el
#     # resultado de una tool, salga en la respuesta.
#
#   Keywords
#     Keywords: reembolso aprobado, garantía de por vida,
#       le devolvemos el dinero, sin costo alguno, 100% garantizado,
#       descuento especial
#     # Compromisos que ningún agente de TuTienda puede hacer por
#     # escrito. Determinista y barato: no necesita modelo.
#
#   Secret Keys
#     # Por si una clave se coló en el resultado de un API y el
#     # agente la repitió.
#
# Rama Pass -> enviar la respuesta al canal
# Rama Fail -> respuesta degradada + notificar al equipo

Este filtro es distinto en naturaleza al de la entrada, y conviene tenerlo claro: en la entrada buscabas ataques; aquí buscas compromisos y fugas. Un agente perfectamente honesto, sin ningún injection de por medio, puede escribir "te devolvemos el dinero sin costo alguno" simplemente porque suena a buena atención al cliente. Keywords corta eso sin llamar a ningún modelo.

Y sobre el orden: la validación por campos va primero, porque es la que puede disparar un reintento con feedback útil. El guardrail de salida va al final, sobre el texto que ya pasó la validación, como última puerta antes del canal.

Lo que la validación no puede verificar

Tres límites honestos, para que no vendas esta capa por más de lo que da.

No puede verificar lo que no tiene con qué comparar. Toda la lección se apoya en que exista una salida de tool que sea la verdad. Si el cliente pregunta "¿este suéter es abrigado?" y el agente responde "sí, es ideal para invierno", no hay ningún campo contra el cual comparar eso. Es una afirmación cualitativa. Para esa franja, la única defensa es de diseño: si el agente no debe opinar sobre propiedades de producto, se lo dices en el System Message y aceptas que es una capa débil; o le das una tool que traiga la descripción oficial del catálogo y validas que la respuesta no salga de ahí.

No puede verificar el matiz de la redacción. El validador confirma que status es in_transit. No confirma que el mensaje diga "está en camino" en vez de "ya casi llega", que es el mismo dato con una promesa implícita distinta. Los patrones de texto del ejemplo —la comprobación 7— arañan esto, pero no lo resuelven; entender matices en texto libre requiere un modelo, y volvemos al problema de verificar probabilidad con probabilidad. Lo que sí puedes hacer, y es efectivo, es cerrar vocabularios: si message_to_customer se arma a partir de una plantilla con huecos en vez de redactarse libre, el matiz deja de estar en manos del modelo.

No cubre la IA documental. Cuando la fuente de verdad no es una fila de base de datos sino un documento —un PDF de políticas, un manual—, verificar que la respuesta esté respaldada por el texto recuperado es un problema distinto, con su propio conjunto de técnicas (citas obligatorias, verificación contra el fragmento recuperado, medición de fidelidad). Eso pertenece a la guía de IA documental del ecosistema, no a esta. Aquí la fuente de verdad es siempre estructurada: un JSON que devolvió una tool.

Y una observación que resume el espíritu de la lección: la mejor defensa contra la alucinación no es detectarla, es no dejar que el modelo sea el que escribe el dato. Cada vez que un número, una fecha o un estado sale de una plantilla alimentada por la tool en vez de salir de la redacción del agente, eliminaste la posibilidad de que ese dato sea falso. El modelo sigue siendo excelente para el tono, la empatía y la estructura de la respuesta. Los datos, cuando puedas, ponlos tú.

Errores comunes

Verificar la salida del agente con otro agente (conceptual). Qué pasa: alguien conecta un segundo AI Agent cuyo system prompt dice "revisa si esta respuesta está respaldada por estos datos y responde válida o inválida". Funciona bien en las pruebas y falla en producción, porque los dos modelos comparten los mismos puntos ciegos: donde el primero completó un dato plausible, el segundo lo encuentra plausible también. Por qué pasa: es la solución que primero se le ocurre a cualquiera, se arma en cinco minutos, y en los casos obvios acierta — lo que da confianza. Cómo detectarlo: pásale al verificador una respuesta con una fecha inventada pero coherente, no una absurda; si la aprueba, tienes dos modelos de acuerdo en una mentira. Cómo corregirlo: la comparación de campos va en un nodo Code o en nodos IF, donde "2026-07-23" !== null no admite interpretación; reserva el modelo para lo que solo un modelo puede hacer, y mantén los hechos en terreno determinista.

Hacer obligatorios en el esquema campos que la tool no siempre devuelve (práctico). Qué pasa: alguien pone eta_date como required y de tipo string, y descubre que el agente siempre manda una fecha, incluso cuando la tool no devolvió ninguna. Se concluye que el modelo alucina mucho, cuando en realidad el esquema no le dejó ninguna otra opción: un campo obligatorio de tipo string no puede quedar vacío. Por qué pasa: marcar todo como obligatorio se siente como rigor, y el efecto secundario —forzar la invención— no es evidente hasta que se mira el esquema con esa pregunta en la cabeza. Cómo detectarlo: por cada campo required de tu esquema, pregúntate si existe algún caso real en el que la tool no tenga ese dato; si existe, el campo no puede ser obligatorio. Cómo corregirlo: todo campo cuyo dato pueda faltar se declara como ["string", "null"], con una description que diga explícitamente cuándo debe ir en null y que prohíba estimarlo.

Pedir "no inventes datos" en el System Message y dar el tema por resuelto (conceptual). Qué pasa: se agrega al prompt "responde únicamente con información obtenida de las tools, nunca inventes datos", se prueban diez casos, ninguno alucina, y no se monta ninguna validación. Semanas después aparece un cliente enojado por una fecha que nadie prometió. Por qué pasa: esa instrucción funciona de verdad y baja mucho la frecuencia, y una mejora que se nota en las pruebas se siente como una solución. Cómo detectarlo: prueba veinte casos donde la tool devuelva información parcial —sin fecha, sin monto, con un campo nulo— que son los que provocan la invención, en vez de casos con datos completos donde el modelo no necesita completar nada. Cómo corregirlo: la instrucción se queda, porque ayuda; pero la salida estructurada con campos nulos permitidos y la comparación determinista es lo que convierte "casi nunca alucina" en "si alucina, no llega al cliente".

Ejercicios

Ejercicio 1 — Diseña el esquema. billing_specialist responde consultas sobre cargos con lookup_charge, que devuelve charge_id, customer_id, order_id, amount, currency, charged_at y status. Diseña el JSON Schema de su salida estructurada separando hechos de redacción, y marca qué campos pueden ser nulos y por qué.

Ver solución
{
  "type": "object",
  "properties": {
    "charge_id":   { "type": "string" },
    "amount":      { "type": ["number", "null"],
                     "description": "The charge amount exactly as
                       returned by lookup_charge. Never rounded,
                       never estimated. Null if the tool returned
                       no matching charge." },
    "currency":    { "type": ["string", "null"], "enum": ["COP", "USD", null] },
    "charged_at":  { "type": ["string", "null"] },
    "status":      { "type": "string",
                     "enum": ["settled", "pending", "disputed",
                              "refunded", "not_found"] },
    "related_order_id": { "type": ["string", "null"],
                     "description": "Null if the charge is not
                       linked to any order. Do NOT guess an order
                       from the conversation." },
    "dispute_id":  { "type": ["string", "null"],
                     "description": "Only if open_dispute was called
                       in this turn and returned an id. Never invent
                       a reference number." },
    "message_to_customer": { "type": "string" },
    "facts_source": { "type": "array", "items": { "type": "string" } }
  },
  "required": ["charge_id", "status", "message_to_customer",
               "facts_source"]
}

Qué puede ser nulo y por qué:

  • amount, currency, charged_at — nulos cuando la consulta no encontró el cargo. Si fueran obligatorios, el agente tendría que inventar un monto para un cargo que no existe, que es el peor caso posible.
  • related_order_id — hay cargos que no corresponden a un pedido (un ajuste, una suscripción). Obligarlo llevaría al agente a asociar el cargo con cualquier pedido que aparezca en la conversación.
  • dispute_id — solo existe si se abrió una disputa en ese turno. Es el campo más propenso a la invención, porque los números de referencia tienen un formato muy predecible y el modelo los completa sin esfuerzo. La description lo prohíbe de forma explícita.

Y una decisión de diseño: status incluye "not_found" en el enum. Sin ese valor, el agente que no encuentra el cargo tiene que elegir entre cuatro estados que no aplican. Darle un valor correcto para el caso "no hay dato" es la misma idea que permitir null, aplicada a un vocabulario cerrado.

Por qué funciona: cada campo que puede faltar en el mundo real tiene una forma legítima de faltar en el esquema. Un esquema que no la tiene es un esquema que fabrica alucinaciones.

Ejercicio 2 — Escribe el validador. Para el esquema del ejercicio 1, escribe el nodo Code que compara la salida del agente contra la de lookup_charge. Incluye al menos una comprobación que detecte una alucinación de tipo 3.

Ver solución
// Nodo: Code — Name: validate_billing_output

const claimed = $input.first().json;
const actual  = $('lookup_charge').first().json;
const violations = [];

// El caso "no se encontró el cargo" se valida distinto: si la tool
// no devolvió nada, TODOS los campos de hecho deben venir nulos.
const toolFoundCharge = actual && actual.charge_id;

if (!toolFoundCharge) {
  if (claimed.status !== "not_found") {
    violations.push(
      `la tool no encontró el cargo pero el agente reporta ` +
      `status="${claimed.status}"`
    );
  }
  for (const field of ["amount", "currency", "charged_at",
                       "related_order_id"]) {
    if (claimed[field] !== null && claimed[field] !== undefined) {
      violations.push(
        `${field} tiene valor "${claimed[field]}" para un cargo ` +
        `que no existe`
      );
    }
  }
} else {
  // Comparación exacta campo por campo.
  if (String(claimed.charge_id) !== String(actual.charge_id)) {
    violations.push(`charge_id no coincide`);
  }
  // El monto se compara como número, no como texto: "1200" y
  // "1200.00" son el mismo dinero, "1200" y "1250" no.
  if (claimed.amount !== null &&
      Number(claimed.amount) !== Number(actual.amount)) {
    violations.push(
      `amount no coincide: agente=${claimed.amount} ` +
      `tool=${actual.amount}`
    );
  }
  if (claimed.currency !== null && claimed.currency !== actual.currency) {
    violations.push(`currency no coincide`);
  }
  if (claimed.charged_at !== null &&
      claimed.charged_at !== actual.charged_at) {
    violations.push(`charged_at no coincide`);
  }
  if (claimed.status !== actual.status) {
    violations.push(
      `status no coincide: agente="${claimed.status}" ` +
      `tool="${actual.status}"`
    );
  }
  if (claimed.related_order_id !== null &&
      claimed.related_order_id !== actual.order_id) {
    violations.push(`related_order_id no corresponde a este cargo`);
  }
}

// TIPO 3 — afirmar hechos sin haber llamado a ninguna tool.
const sources = Array.isArray(claimed.facts_source)
  ? claimed.facts_source : [];
if (!sources.includes("lookup_charge") &&
    (claimed.amount !== null || claimed.charged_at !== null)) {
  violations.push(
    "el agente reporta datos del cargo sin declarar lookup_charge " +
    "como fuente"
  );
}

// TIPO 3 sobre referencias: un dispute_id solo puede existir si
// open_dispute corrió en este turno.
if (claimed.dispute_id && !sources.includes("open_dispute")) {
  violations.push(
    `dispute_id "${claimed.dispute_id}" inventado: open_dispute ` +
    `no se llamó en este turno`
  );
}

return [{
  json: { ...claimed,
          validation_passed: violations.length === 0,
          violations }
}];

La comprobación del dispute_id es la que más vale. Un número de referencia inventado es la alucinación con peor relación costo-beneficio de todas: al cliente le llega un identificador con el formato correcto, se queda tranquilo, y descubre que no existe cuando vuelve a preguntar por él — que suele ser días después, cuando el enojo ya se acumuló.

Por qué funciona: el validador trata el caso "no hay dato" como un escenario de primera clase, con sus propias reglas, en vez de como una excepción. La mayoría de las alucinaciones ocurren precisamente ahí, en el hueco, y un validador que solo compara valores cuando existen no cubre el hueco.

Ejercicio 3 — Cierra la puerta antes de que se abra. El validador del ejemplo trabajado detecta la fecha inventada después de que el agente la escribió. Propón un rediseño donde esa alucinación específica sea imposible desde el principio, y di qué pierdes con tu propuesta.

Ver solución

La respuesta al cliente deja de redactarla el modelo en lo que respecta a los datos, y se arma con una plantilla alimentada por la tool:

# El agente ya no produce message_to_customer.
# Produce solo la clasificación y el tono:
#   { order_id, status, customer_sentiment, needs_escalation }
#
# Nodo: Switch por status
#   └─► Set — Name: compose_response
#
#   status = in_transit:
#     "Tu pedido {{ $('lookup_order').item.json.order_id }} salió
#      el {{ $('lookup_order').item.json.shipped_at }} y está en
#      camino. Puedes seguirlo con el código
#      {{ $('lookup_order').item.json.carrier_tracking_code }}.
#      No manejamos fecha exacta de entrega, pero el seguimiento
#      se actualiza cada día."
#
#   status = delivered:
#     "Tu pedido {{ ... }} figura como entregado el {{ ... }}.
#      Si no lo recibiste, escríbenos y lo revisamos."
#
#   status = pending:
#     "Tu pedido {{ ... }} está confirmado y aún no sale de
#      nuestro centro de distribución. Te avisamos apenas salga."

La fecha inventada se vuelve imposible, no improbable. No hay ningún punto del flujo donde un modelo escriba una fecha: las tres fechas que aparecen en las plantillas vienen de campos de la tool, y donde la tool no tiene dato, la plantilla dice explícitamente que no lo hay.

Lo que pierdes:

  • Naturalidad. Cuatro clientes con el mismo estado reciben exactamente el mismo texto. Se nota, y en un canal como WhatsApp se nota más.
  • Capacidad de atender lo inesperado. Si alguien pregunta por su pedido y de paso menciona que el empaque anterior venía roto, la plantilla no lo ve. Necesitas que el agente siga estando ahí para el resto de la conversación; la plantilla solo cubre el tramo factual.
  • Trabajo de mantenimiento. Cada estado nuevo es una plantilla nueva. Con cuatro estados es cómodo; con veinte, deja de serlo.

Cuándo vale la pena igual: para las respuestas de alto volumen y alto riesgo. "¿Dónde está mi pedido?" es probablemente el 40% del tráfico de TuTienda y es donde una fecha inventada genera reclamos. Vale la pena que ese camino sea de plantilla y que el modelo se ocupe del 60% restante, donde el riesgo por respuesta es menor.

Por qué funciona: es el principio del final de la lección llevado al extremo — la mejor forma de que un dato no sea falso es que el modelo no lo escriba. Y el ejercicio muestra que ese principio tiene un costo real, así que se aplica donde el riesgo lo justifica, no en todo el sistema.

Resumen y siguiente paso

Una alucinación es una salida plausible sin respaldo en los datos, y en un agente con tools toma tres formas: el dato inventado al redactar, el parámetro inventado al llamar una tool, y la afirmación hecha sin consultar nada. La defensa central es de diseño: separar el hecho de la redacción con una salida estructurada donde cada dato factual tiene su campo, donde los campos que pueden faltar admiten null explícitamente —porque un campo obligatorio fabrica invenciones— y donde el agente declara sus fuentes. Sobre esa estructura, la comparación es determinista: un nodo Code que confronta campo por campo lo que el agente afirma contra lo que la tool devolvió, nunca otro modelo. Cuando falla: reintento con feedback una sola vez, respuesta degradada armada por plantilla, o escalar. Y como última puerta, el nodo Guardrails sobre el texto de salida, buscando ya no ataques sino compromisos y fugas.

Antes de avanzar a la lección 7 deberías poder: distinguir los tres tipos de alucinación y decir con qué se defiende cada uno; escribir un JSON Schema que permita al modelo decir "no tengo ese dato" sin romper el esquema; explicar por qué la verificación no puede hacerla otro agente; y señalar, en tu propio sistema, cuál es la respuesta de mayor volumen que convendría armar con plantilla en vez de redactar.

Con esto tienes las cinco capas del módulo montadas. Y falta la pregunta que las atraviesa a todas: cuando algo se cuela igual —y algo se va a colar—, ¿cómo te enteras y cómo reconstruyes qué pasó? Un reembolso indebido aparece en el estado de cuenta tres días después. Un cliente reclama por algo que el agente le dijo la semana pasada. Un ticket quedó clasificado mal y nadie sabe por qué. La lección 7 es la capacidad forense: leer la traza de un agente para saber qué entró a su contexto, qué decidió y con qué parámetros — e instrumentar una bitácora propia, porque las ejecuciones de n8n no viven para siempre.

Recursos

  • Structured Output Parser — n8n Docs — el sub-nodo con el que defines el JSON Schema de la salida del agente, base de todo el patrón de esta lección.
  • Guardrails node — n8n Docs — los guardrails PII, Keywords y Secret Keys aplicados al texto de salida, con la operación Check Text for Violations.
  • Code node — n8n Docs — referencia del nodo donde vive la comparación determinista, incluidas las formas de acceder a la salida de otro nodo con $('Nombre del nodo').
  • Tools Agent — n8n Docs — cómo se conecta un output parser al agente y qué implica para el formato de su respuesta.
  • Reducing hallucinations — Anthropic Docs — técnicas de prompt para bajar la frecuencia de invención, útiles como capa complementaria a la validación determinista.