La primera vez que metí un LLM en producto, el modelo hacía su trabajo bien y yo hacía el mío mal: le pedía JSON en el prompt, rezaba, y luego escribía regex para rescatar el objeto de entre disculpas y bloques de markdown. Funcionaba el 90% de las veces, que en producción es otra forma de decir que fallaba. La solución no fue un prompt más listo, sino tratar la salida del modelo como una interfaz tipada: structured outputs con function calling y validación con Zod. Este artículo es cómo hice ese cambio y qué gané a cambio.
El problema real no es el modelo, es el borde
Un LLM dentro de un producto no vive solo: su salida alimenta una función, una consulta o una llamada a otra API. Ese punto de contacto entre lenguaje natural y código tipado es el borde frágil. Si el modelo devuelve texto libre, el borde se llena de parseo defensivo: buscar el primer {, contar llaves, quitar los ```json, capturar el JSON.parse que revienta. Cada una de esas líneas es deuda que se rompe en cuanto el modelo decide ser conversacional.
Durante un tiempo intenté resolverlo con prompt engineering. "Responde solo con JSON válido, sin explicaciones." Ayuda, pero no garantiza nada: el prompt es una súplica, no un contrato. La lección que me llevó tiempo aceptar es que la fiabilidad no se pide, se impone en la capa de la API.
Function calling como contrato, no como truco
Los modelos modernos aceptan un esquema de herramienta o de respuesta y se comprometen a devolver algo que encaja en ese esquema. Eso cambia el juego: en vez de describir el formato en prosa, lo declaro como estructura y el modelo rellena los huecos. Dejo de parsear intenciones y empiezo a recibir datos.
Lo modelo así: defino la forma que necesito, la paso como esquema de respuesta, y el resto de mi código asume que recibirá exactamente eso. El prompt vuelve a hablar de la tarea —qué extraer, cómo priorizar— y no de comas y comillas.
import { z } from 'zod';
const CandidateExtraction = z.object({
seniority: z.enum(['junior', 'mid', 'senior', 'lead']),
primary_stack: z.array(z.string()).max(8),
years_experience: z.number().int().min(0).max(50),
location: z.string(),
open_to_remote: z.boolean(),
});
type CandidateExtraction = z.infer<typeof CandidateExtraction>;
Ese esquema es la fuente de verdad. De él sale el tipo de TypeScript, de él sale el esquema JSON que le paso al modelo, y contra él valido la respuesta. Una sola definición, tres usos.
Zod: la red que atrapa lo que el modelo no garantiza
Aquí viene el matiz que mucha gente se salta: que la API acepte un esquema no significa que el resultado sea correcto en el sentido que a mí me importa. El modelo puede devolver un JSON estructuralmente válido y semánticamente absurdo: un years_experience de 200, un stack con quince entradas duplicadas, un enum que no existía. La forma es correcta; el contenido, no.
Por eso valido siempre en el borde con Zod, aunque use structured outputs. No es cinturón y tirantes por paranoia: es que la garantía de la API y la garantía de mi dominio son cosas distintas. Zod me deja expresar la segunda —rangos, longitudes máximas, enums cerrados— y convierte una respuesta dudosa en un error que puedo manejar antes de que contamine la base de datos.
const raw = await callModelWithSchema(prompt, CandidateExtraction);
const parsed = CandidateExtraction.safeParse(raw);
if (!parsed.success) {
// No propago basura: reintento con el error como contexto,
// o caigo a un flujo manual. Nunca escribo sin validar.
return handleInvalid(parsed.error);
}
await saveCandidate(parsed.data); // parsed.data ya es del tipo correcto
💡 Un LLM en producto no es una fuente de verdad, es una fuente de propuestas. La validación es lo que convierte una propuesta en un dato en el que confías.
Reintentos con el error como contexto
Cuando la validación falla, el instinto es reintentar con el mismo prompt. Malgastas tokens repitiendo el mismo error. Lo que funciona de verdad es reinyectar el fallo: le devuelvo al modelo qué esperaba y qué me dio, y le pido corregir. En la práctica, el 90% de los fallos de validación se resuelven en un solo reintento informado, porque casi siempre son deslices tontos —un campo que faltaba, un enum mal escrito— que el modelo corrige de inmediato cuando le señalas el punto exacto.
Le pongo un tope de reintentos, claro. Si tras dos intentos sigue sin encajar, el problema no es el modelo teniendo un mal día: es que la tarea está mal planteada o el esquema pide algo que el input no contiene. Ahí el fallo ruidoso es un regalo, porque me obliga a arreglar la causa en vez de esconderla.
El trade-off que acepté
Structured outputs no es gratis. Encorsetar al modelo en un esquema reduce su flexibilidad: para tareas realmente abiertas, donde no sé de antemano la forma de la respuesta, el esquema estorba más de lo que ayuda. Y añadir Zod más los reintentos es código y latencia que antes no tenía.
Elegí structured outputs igualmente porque en producto casi nunca quiero creatividad en el borde: quiero determinismo. Prefiero pagar en rigidez y en unas líneas de validación a cambio de que el resto de mi sistema pueda tratar la salida del LLM como trataría cualquier otra API tipada. Ese es el objetivo real: que el modelo deje de ser una excepción especial que todos rodean con cuidado y pase a ser un componente más, con su contrato y su manejo de errores, como cualquier otra pieza en la que ya confío.