Inteligencia Artificial

Prompts en APIs: Forzando JSON Estricto

Echo Code

Domina la ingeniería de prompts en Node/Express. Técnicas avanzadas para forzar JSON estricto en LLMs y validación robusta de esquemas con Zod.

Gato descansando sobre algo que desconozco, pero parece tener que ver con electricidad.

La Aleatoridad de la IA#

Integrar Modelos de Lenguaje Grande (LLMs) en aplicaciones empresariales requiere un cambio de paradigma. Cuando construimos APIs tradicionales en Node.js y Express, damos por sentado el determinismo: si consultamos una base de datos relacional o una API de terceros bien documentada, sabemos exactamente la estructura de los datos que recibiremos. Sin embargo, al interactuar con modelos generativos como GPT-4, Claude 3 o Gemini, introducimos un motor estocástico en el corazón de nuestro backend.

El frontend moderno, ya sea en React, Vue o Angular, no está diseñado para lidiar con la ambigüedad. Espera contratos de datos estrictos, tipos bien definidos y respuestas predecibles. Si tu LLM decide que hoy es un buen día para responder con un “¡Claro! Aquí tienes tu respuesta:” seguido de un bloque de Markdown, tu frontend lanzará un error de parseo catastrófico y la experiencia del usuario se degradará instantáneamente.

En este artículo, como Ingenieros de Software, vamos a diseccionar arquitecturas robustas para resolver la “alucinación estructural”. Exploraremos cómo utilizar ingeniería de prompts avanzada a nivel de sistema, modos JSON nativos, sanitización de expresiones regulares y, finalmente, un pipeline de validación y auto-corrección (“Self-Healing”) utilizando Zod en Node.js.

El Problema: La Naturaleza Conversacional de los LLMs#

Los LLMs están entrenados primariamente para interactuar con humanos mediante chat. Su instinto natural es ser serviciales y conversacionales. Esta característica, excelente para un chatbot, es tóxica para una API REST.

Los problemas más comunes al intentar extraer JSON de un LLM incluyen:

  1. Preamble y Postamble: Texto conversacional antes o después del JSON (ej. “Aquí está el resultado: \n {...} \n ¡Espero que sirva!”).
  2. Envolturas de Markdown: El JSON envuelto en triple backtick (json ... ).
  3. JSON Inválido: Comas al final de listas o diccionarios (Trailing commas), comillas simples en lugar de dobles, o caracteres de control no escapados en medio de cadenas de texto.
  4. Alucinación de Esquema: El modelo devuelve un JSON válido, pero altera los nombres de las claves (ej. devuelve user_age en lugar de age) o cambia tipos de datos (devuelve "25" en lugar del entero 25).

Para solucionar esto, no basta con pedirle al modelo “devuelve JSON”. Necesitamos una estrategia de defensa en profundidad.

Nivel 1: Ingeniería de Prompts Estructural y Few-Shot#

El primer paso es construir un System Prompt inquebrantable. El prompt del sistema dicta el comportamiento fundamental del modelo. Aquí es donde establecemos el contrato de la API.

Reglas de Hierro en el Prompt#

Un buen prompt para forzar JSON debe contener directivas explícitas, negativas y restrictivas. Debemos ser autoritarios.

const systemPrompt = `
Eres un procesador de datos backend altamente eficiente.
Tu ÚNICO propósito es recibir información no estructurada y devolver un objeto JSON estricto.
REGLAS ESTRICTAS:
1. NO saludes, NO te despidas, NO incluyas texto conversacional.
2. NO envuelvas la respuesta en bloques de código Markdown (sin \`\`\`json).
3. Devuelve EXCLUSIVAMENTE un objeto JSON válido que pueda ser procesado directamente por la función JSON.parse() de JavaScript.
4. Asegúrate de escapar correctamente las comillas dobles internas y no uses comillas simples para las claves.
`;

Few-Shot Prompting para Inyección de Esquema#

Las directivas ayudan, pero los ejemplos son absolutos. El Few-Shot Prompting implica pasar ejemplos de entrada y la salida JSON exacta esperada. Esto condiciona la red neuronal a replicar el patrón exacto.

Sin embargo, en lugar de hardcodear la estructura JSON en el prompt como un simple string, debemos aprovechar herramientas modernas. Si usamos TypeScript, ya tenemos nuestros esquemas definidos.

Nivel 2: Sincronización de Esquemas con Zod y JSON Schema#

Mantener un prompt de texto sincronizado con una interfaz TypeScript es una receta para el desastre. Si cambias el nombre de una propiedad en tu código pero olvidas actualizar el string del prompt, la API se romperá silenciosamente en tiempo de ejecución.

La solución es utilizar Zod como fuente única de verdad para la validación, y generar el esquema que verá el LLM dinámicamente.

Primero, instalamos las dependencias necesarias:

Terminal window
npm install zod zod-to-json-schema

Definimos nuestro esquema de salida esperado:

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
// Definimos la fuente única de la verdad
export const UserExtractionSchema = z.object({
fullName: z.string().describe("El nombre completo extraído del texto"),
age: z.number().int().positive().nullable().describe("La edad extraída, o null si no se menciona"),
skills: z.array(z.string()).min(1).describe("Lista de habilidades técnicas en minúsculas"),
sentiment: z.enum(["positive", "negative", "neutral"]).describe("Análisis de sentimiento del perfil del candidato")
});
export type UserExtraction = z.infer<typeof UserExtractionSchema>;
// Generamos el JSON Schema para inyectarlo en el LLM
const jsonSchema = zodToJsonSchema(UserExtractionSchema, "UserExtraction");
export const generateInstructionPrompt = () => `
Debes adherirte ESTRICTAMENTE al siguiente JSON Schema para tu respuesta:
${JSON.stringify(jsonSchema, null, 2)}
Devuelve solo el JSON que cumpla con este esquema, sin texto adicional.
`;

Al utilizar zod-to-json-schema, aprovechamos la propiedad .describe() de Zod para pasar metadatos semánticos al LLM. El modelo ahora no solo sabe que skills es un array de strings, sino que sabe exactamente qué debe contener ese array gracias a la descripción.

Nivel 3: Consumo de la API del LLM y Sanitización#

Incluso con los mejores prompts y JSON Mode habilitado en la API del proveedor (como response_format: { type: "json_object" } en OpenAI), los LLMs pueden fallar de formas impredecibles. A veces un modelo menor escupirá Markdown por pura inercia de su entrenamiento.

Necesitamos una capa de middleware de sanitización antes de siquiera intentar JSON.parse.

/**
* Elimina bloques de markdown, texto previo y posterior de una respuesta de LLM
* intentando encontrar el primer '{' o '[' y el último '}' o ']'.
*/
function sanitizeJSONString(rawResponse: string): string {
let cleanString = rawResponse.trim();
// Eliminar bloques de código markdown si el modelo los incluyó a pesar de las instrucciones
if (cleanString.startsWith('```')) {
const lines = cleanString.split('\n');
if (lines[0].includes('json') || lines[0].includes('javascript')) {
lines.shift(); // Remover la primera línea (```json)
} else {
lines.shift(); // Remover la primera línea (```)
}
if (lines[lines.length - 1].startsWith('```')) {
lines.pop(); // Remover la última línea (```)
}
cleanString = lines.join('\n');
}
// Fallback heurístico: extraer desde el primer { o [ hasta el último } o ]
const firstBrace = cleanString.indexOf('{');
const firstBracket = cleanString.indexOf('[');
const lastBrace = cleanString.lastIndexOf('}');
const lastBracket = cleanString.lastIndexOf(']');
const isObject = firstBrace !== -1 && lastBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket);
const isArray = firstBracket !== -1 && lastBracket !== -1 && (firstBrace === -1 || firstBracket < firstBrace);
if (isObject) {
return cleanString.substring(firstBrace, lastBrace + 1);
} else if (isArray) {
return cleanString.substring(firstBracket, lastBracket + 1);
}
// Si no podemos determinar los límites, devolvemos el string limpio con la esperanza
// de que JSON.parse logre lidiar con él, o falle limpiamente.
return cleanString;
}

Este sanitizador es crucial en producción. Maneja el 99% de las “alucinaciones de formato” donde el LLM envuelve el resultado correctamente estructurado dentro de ruido innecesario.

Nivel 4: El Pipeline de “Self-Healing” (Auto-Corrección)#

Aquí es donde separamos una aplicación amateur de una arquitectura de grado empresarial. ¿Qué ocurre si el JSON está perfectamente formateado, pero no cumple con el esquema de Zod? (Por ejemplo, el modelo olvidó la clave sentiment obligatoria, o devolvió un string en lugar de un array).

Si simplemente devolvemos un HTTP 500 al frontend, la experiencia del usuario se destruye. En lugar de eso, podemos implementar un mecanismo de reintento iterativo, pasando los errores de Zod de vuelta al LLM para que se corrija a sí mismo. A esto se le llama “Self-Healing”.

Implementaremos esta lógica en un servicio de Node.js agnóstico del framework, que luego conectaremos a Express.

import { z } from 'zod';
// llmClient es una abstracción hipotética de tu proveedor (OpenAI, Anthropic, etc.)
import { llmClient } from './services/llmClient';
export async function generateValidatedJSON<T extends z.ZodTypeAny>(
userPrompt: string,
schema: T,
schemaName: string,
maxRetries: number = 3
): Promise<z.infer<T>> {
const systemPrompt = `
Eres un sistema que extrae información.
${generateInstructionPrompt(schema, schemaName)}
`;
let currentAttempt = 1;
// Mantenemos un historial para que el LLM tenga contexto de sus errores
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
];
while (currentAttempt <= maxRetries) {
try {
console.log(`Intento ${currentAttempt} de ${maxRetries}...`);
const rawOutput = await llmClient.chatCompletion(messages);
const sanitizedOutput = sanitizeJSONString(rawOutput);
const parsedObject = JSON.parse(sanitizedOutput);
// Zod lanza un error si la validación falla
const validatedData = schema.parse(parsedObject);
return validatedData; // Si llegamos aquí, el JSON es perfecto y tipeado.
} catch (error) {
if (error instanceof z.ZodError) {
console.warn(`Error de validación Zod en el intento ${currentAttempt}`);
// Transformamos los errores de Zod en instrucciones claras para el LLM
const errorDetails = error.errors.map(e =>
`- Ruta '${e.path.join('.')}': ${e.message}`
).join('\n');
const correctionPrompt = `
Tu respuesta anterior no cumplió con el JSON Schema requerido.
Falló con los siguientes errores de validación:
${errorDetails}
Por favor, revisa tu respuesta anterior, corrige EXCLUSIVAMENTE estos errores y devuelve el JSON completo corregido. No incluyas explicaciones.
`;
// Agregamos el error al historial de mensajes para el próximo intento
messages.push({ role: 'assistant', content: "Generé un JSON inválido." });
messages.push({ role: 'user', content: correctionPrompt });
} else if (error instanceof SyntaxError) {
console.warn(`Error de parseo JSON puro en el intento ${currentAttempt}`);
messages.push({ role: 'assistant', content: "Generé una sintaxis que no es JSON." });
messages.push({
role: 'user',
content: "Tu respuesta no es un JSON válido (SyntaxError). Elimina comillas rebeldes, comas finales y asegúrate de que esté bien formado. Solo JSON."
});
} else {
// Errores de red o de API de terceros. No vale la pena reintentar el prompt.
throw error;
}
currentAttempt++;
}
}
throw new Error(`El modelo no pudo generar un JSON válido tras ${maxRetries} intentos.`);
}

Esta función es el núcleo del sistema. Si el LLM se equivoca, no colapsa; es reprendido. El error semántico detallado de Zod le dice exactamente qué propiedad arreglar. Los modelos como GPT-4o o Claude 3.5 Sonnet son extremadamente adeptos a autocorregirse si se les señala su error exacto.

Nivel 5: Integración Segura en un Controlador de Express#

Finalmente, debemos exponer esto a nuestro frontend utilizando un controlador de Express estándar. Es imperativo envolver el proceso en un bloque try/catch y configurar timeouts razonables, ya que las llamadas recurrentes al LLM pueden sumar latencia.

import { Request, Response } from 'express';
import { UserExtractionSchema, generateValidatedJSON } from '../services/aiService';
export const extractProfileController = async (req: Request, res: Response) => {
try {
const { rawText } = req.body;
if (!rawText || typeof rawText !== 'string') {
return res.status(400).json({
success: false,
error: "Se requiere un campo 'rawText' en el body"
});
}
// Ejecutamos nuestro pipeline con Self-Healing
const extractedData = await generateValidatedJSON(
`Analiza el siguiente texto y extrae la información del candidato:\n\n${rawText}`,
UserExtractionSchema,
"UserExtraction",
3 // Permitimos hasta 3 intentos
);
// Gracias a TypeScript y Zod, 'extractedData' tiene el tipo UserExtraction exacto
// No hay "any" ni aserciones de tipo peligrosas
return res.status(200).json({
success: true,
data: extractedData
});
} catch (error: any) {
console.error("Fallo definitivo en la extracción de IA:", error.message);
// Devolvemos un error 422 (Unprocessable Entity) si la IA falló los 3 intentos
return res.status(422).json({
success: false,
error: "No se pudo extraer información estructurada válida del texto proporcionado.",
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
};

Consideraciones Finales sobre Rendimiento y Latencia#

El patrón de “Self-Healing” es sumamente potente, pero tiene un coste: la latencia. Si tu esquema es muy complejo y el modelo falla dos veces antes de acertar a la tercera, una petición que tomaría 2 segundos podría tardar 6 o 7 segundos.

Para mitigar esto en entornos de producción con alta concurrencia:

  1. Elige el modelo adecuado: Para extracciones simples, modelos rápidos (como Gemini 1.5 Flash o GPT-4o-mini) suelen ser suficientes y cometen menos errores de formato que los modelos locales de parámetros bajos, manteniendo la latencia al mínimo.
  2. Timeouts en Express: Asegúrate de que el middleware de timeout de tu servidor (o tu Ingress/Load Balancer) no corte la conexión prematuramente. Un ciclo de 3 reintentos requiere configurar el timeout general de la ruta a unos 15-20 segundos.
  3. Monitorización: Registra cuántas veces se acciona el bloque catch del reintento en tus logs o herramientas como Datadog. Si notas que una ruta específica requiere reintentos el 40% del tiempo, el problema no es el LLM, es tu esquema de Zod (quizás es ambiguo) o tu prompt (quizás le falta un few-shot example).

La ingeniería de prompts en el backend ya no es simplemente escribir instrucciones ingeniosas. Se ha convertido en un ejercicio riguroso de diseño de sistemas, donde la tolerancia a fallos, la tipificación estricta de esquemas y los bucles de retroalimentación en tiempo de ejecución son requisitos innegociables para construir APIs que no se desmoronen en producción. Al combinar el análisis robusto de Node, el tipado de TypeScript y la validación en tiempo de ejecución de Zod, podemos finalmente domar el caos probabilístico de los LLMs.