Extracción LLM en producción: el JSON que falla sin avisar
Llevas tres semanas con el pipeline en producción. La extracción de facturas de proveedor funciona: los datos llegan a Supabase, los informes se generan solos. Esta mañana el equipo contable te manda un mensaje: los importes de 23 registros están mal. Revisas los logs. No hay ninguna excepción. Todo aparece verde.
El problema: el LLM devolvió "amount_eur": "1847.30" — un string — en lugar de "amount_eur": 1847.30. Tu código hizo JSON.parse() sin validar tipos. El dato entró en la base de datos. La suma agregada del informe salió incorrecta. Nadie lo vio hasta que alguien cuadró números a mano.
Esto no es un bug. Es una característica estadística de los LLMs.
El error que todos cometen al extraer datos con IA
Un LLM no "devuelve JSON". Genera texto que, en la mayoría de los casos, parece JSON. La diferencia importa en producción.
En local, con diez o veinte llamadas de prueba, el output es prácticamente perfecto. En producción, con miles de documentos reales — facturas mal formateadas, contratos con tipografías raras, emails con saltos de línea inesperados — el modelo empieza a cometer errores estadísticamente predecibles:
- Tipo incorrecto:
"amount": "1847.30"en lugar de"amount": 1847.30 - Campo renombrado:
"invoiceNumber"en lugar de"invoice_number"según el documento de entrada - Campo ausente: el modelo "olvidó" incluir
due_dateporque en ese documento concreto no era visible - JSON con texto extra:
"Aquí están los datos extraídos: { ... }"—JSON.parse()lanza excepción
Ninguno de estos casos lanza un error automáticamente. En los tres primeros, el dato entra en tu sistema con el valor incorrecto. En el cuarto te enteras, pero demasiado tarde.
Por qué JSON.parse() no es defensa suficiente
El patrón más común que veo en implementaciones nuevas:
// ❌ Patrón frágil — falla en producción
const response = await anthropic.messages.create({
model: "claude-sonnet-4-6",
messages: [{ role: "user", content: `Extrae los datos de esta factura en JSON:\n\n${text}` }],
max_tokens: 1024,
});
const raw = response.content[0].text;
const data = JSON.parse(raw); // Lanza si hay texto extra; no valida tipos
await db.insert(data); // Escribe datos incorrectos sin saberlo
JSON.parse() solo verifica que la sintaxis es válida. No comprueba que amount_eur sea number y no string. No detecta que invoice_number falte. No te avisa de que el LLM usó camelCase en lugar de snake_case. Tu sistema acepta el dato, lo persiste y el error se descubre tres semanas después.
Tres capas para una extracción fiable
La solución se construye en capas. Cada una atrapa un tipo distinto de error.
Capa 1 — Tool forcing: fuerza el schema desde el API
En lugar de pedir texto y parsear, defines la estructura como una herramienta y fuerzas al modelo a usarla. El API garantiza que el output es un JSON válido que sigue el schema definido — nunca texto libre.
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
const extractionTool = {
name: "extract_invoice",
description: "Extrae los datos estructurados de la factura proporcionada",
input_schema: {
type: "object" as const,
properties: {
vendor_name: { type: "string", description: "Nombre del proveedor" },
invoice_number: { type: "string", description: "Número de factura" },
amount_eur: { type: "number", description: "Importe total en euros (número, no string)" },
due_date: { type: "string", description: "Fecha de vencimiento ISO YYYY-MM-DD" },
line_items: {
type: "array",
items: {
type: "object",
properties: {
description: { type: "string" },
quantity: { type: "number" },
unit_price: { type: "number" },
},
required: ["description", "quantity", "unit_price"],
},
},
},
required: ["vendor_name", "invoice_number", "amount_eur"],
},
};
async function callExtraction(text: string) {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: [extractionTool],
tool_choice: { type: "tool", name: "extract_invoice" }, // Fuerza esta herramienta concreta
messages: [{ role: "user", content: `Extrae los datos:\n\n${text}` }],
});
const toolUse = response.content[0];
if (toolUse.type !== "tool_use") throw new Error("Respuesta inesperada del modelo");
return toolUse.input; // Objeto garantizado, nunca texto
}
El parámetro clave es tool_choice: { type: "tool", name: "extract_invoice" }. Fuerza al modelo a llamar exactamente esa herramienta — el resultado es siempre un objeto estructurado, nunca texto libre.
Capa 2 — Validación con Zod: comprueba tipos y campos obligatorios
Tool forcing garantiza que el JSON tiene la forma correcta, pero no que los tipos sean los esperados. Zod añade esa capa:
import { z } from "zod";
const InvoiceSchema = z.object({
vendor_name: z.string().min(1),
invoice_number: z.string().min(1),
amount_eur: z.number().positive(),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
line_items: z.array(z.object({
description: z.string().min(1),
quantity: z.number().positive(),
unit_price: z.number().positive(),
})).optional(),
});
type Invoice = z.infer<typeof InvoiceSchema>;
function validate(raw: unknown): Invoice {
const result = InvoiceSchema.safeParse(raw);
if (!result.success) {
throw new ValidationError(result.error.format());
}
return result.data;
}
Si amount_eur llega como "1847.30" (string), Zod lo detecta y lanza ValidationError antes de que el dato entre en la base de datos.
Capa 3 — Retry con feedback: el modelo corrige su propio error
Cuando la validación Zod falla, en lugar de propagar el error, se lo devuelves al modelo con contexto:
async function extractWithRetry(text: string, maxRetries = 2): Promise<Invoice> {
let lastError: string | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const userContent = lastError
? `Extrae los datos de la factura. Intento anterior fallido:\n${lastError}\nCorrige los problemas.\n\nFactura:\n${text}`
: `Extrae los datos de la factura:\n\n${text}`;
const raw = await callExtraction(userContent);
try {
return validate(raw);
} catch (err) {
lastError = err instanceof ValidationError
? JSON.stringify(err.issues)
: "Error de validación desconocido";
}
}
throw new Error(`Extracción fallida tras ${maxRetries + 1} intentos`);
}
En la práctica, el retry con feedback resuelve más del 95% de los fallos de validación — el modelo entiende exactamente qué campo tenía el tipo incorrecto y lo corrige.
Cuándo usar extracción estructurada vs tool calling libre
Estos dos patrones no son lo mismo, aunque usen la misma API de herramientas:
| Caso de uso | Patrón recomendado |
|---|---|
| Extraer datos de un documento (factura, contrato, email) | Tool forcing + Zod |
| Clasificar intención o categoría desde texto libre | Tool forcing con enum |
| Agente que decide llamar a APIs externas de forma autónoma | Tool calling libre + validación de parámetros |
| Respuesta conversacional o explicación | Texto libre |
| Resumen estructurado al final de un análisis | Tool forcing para el bloque final |
La diferencia clave: en tool calling clásico (como cubrimos en la validación de herramientas en producción), el modelo decide cuándo y cómo llamar a la herramienta. En extracción estructurada, tú fuerzas la herramienta desde fuera — el modelo solo tiene que rellenar los campos.
Lo que cambia cuando el pipeline es fiable
Cuando implementas estas tres capas en un agente de integración de IA, el comportamiento cambia radicalmente: los errores de tipo dejan de llegar a la base de datos, los fallos de validación se autocorrigen en el retry, y cuando algo falla de verdad (un documento completamente ilegible, por ejemplo), el sistema lanza un error claro y trazable en lugar de escribir basura silenciosa.
En proyectos de extracción de datos en producción — facturas de proveedores, contratos, emails de clientes — la combinación de tool forcing, Zod y retries con feedback reduce los errores de parsing a prácticamente cero. No porque el LLM sea más preciso, sino porque la arquitectura no permite que un output incorrecto llegue al sistema.
Si tienes un pipeline de extracción de datos con IA que falla de forma impredecible, o estás planificando construir uno, en 30 minutos te digo exactamente qué capa te falta y cómo añadirla.