Streaming JSON estructurado: parse en tiempo real de tu agente
Le pides al agente que analice un cliente y devuelva un objeto JSON con diez campos: nombre, puntuación de riesgo, recomendación, resumen, próximas acciones. El agente empieza a generar. La pantalla del usuario se queda en blanco. Doce segundos después, todos los campos aparecen de golpe.
Ese "blanco de doce segundos" es un problema de arquitectura, no de velocidad de respuesta. Los tokens están llegando. El LLM está generando desde el primer momento. El problema es que tu implementación no sabe qué hacer con un JSON parcial.
Por qué el JSON estructurado rompe el streaming
Cuando un LLM genera texto libre, cada token que llega es directamente consumible: lo añades al estado, el usuario lo ve. Cuando generas JSON estructurado, el objeto no es válido hasta que se cierra la llave final. Un JSON.parse sobre {"name": "Empresa M devuelve un SyntaxError. La consecuencia natural es esperar a que llegue el objeto completo antes de renderizarlo.
El resultado: el usuario ve una pantalla vacía aunque el modelo esté activo desde el primer segundo.
Los tres antipatrones que llevan a esto:
- Esperar el stream completo antes de parsear. Sencillo, seguro, terrible para la UX.
- Parsear con
try/catchen cada chunk. Funciona para las últimas líneas, pero ignora los campos que ya llegaron hace segundos. - Usar tool calls para obtener la estructura. El agente llama a una tool con los datos como argumento — funciona, pero añade una vuelta extra al LLM y no resuelve el streaming real.
El patrón correcto: parse incremental field-by-field
La solución es un parser que entiende JSON parcial: en lugar de esperar el objeto completo, extrae campos a medida que se completan, aunque el objeto global no esté cerrado.
Usando zod-stream (parte del ecosistema de instructor-js):
import Anthropic from "@anthropic-ai/sdk";
import { OAIStream, withResponseModel } from "zod-stream";
import { z } from "zod";
const ClientAnalysisSchema = z.object({
name: z.string(),
risk_score: z.number().min(0).max(10),
recommendation: z.string(),
summary: z.string(),
action_items: z.array(z.string()),
next_contact: z.string(),
});
type ClientAnalysis = z.infer<typeof ClientAnalysisSchema>;
async function analyzeClientStreaming(
clientData: string,
onPartialUpdate: (partial: Partial<ClientAnalysis>) => void
) {
const client = new Anthropic();
const params = withResponseModel({
response_model: { schema: ClientAnalysisSchema, name: "ClientAnalysis" },
params: {
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [
{
role: "user",
content: `Analiza este cliente y devuelve el análisis estructurado: ${clientData}`,
},
],
},
mode: "TOOLS",
});
const stream = await client.chat.completions.create({
...params,
stream: true,
});
const oaiStream = OAIStream({ res: stream });
for await (const partial of oaiStream) {
// Cada partial contiene los campos que se han completado hasta ese momento
onPartialUpdate(partial as Partial<ClientAnalysis>);
}
}
Cada llamada a onPartialUpdate contiene los campos que ya han terminado de generarse. El campo name puede estar disponible a los 200ms, risk_score a los 400ms. No necesitas esperar los 12 segundos del objeto completo.
Integración con React: estado progresivo
En el componente React, el estado se actualiza con cada partial:
// components/ClientAnalysis.tsx
import { useState } from "react";
type PartialAnalysis = Partial<ClientAnalysis>;
export function ClientAnalysisCard({ clientId }: { clientId: string }) {
const [analysis, setAnalysis] = useState<PartialAnalysis>({});
const [isStreaming, setIsStreaming] = useState(false);
async function runAnalysis() {
setIsStreaming(true);
setAnalysis({});
await analyzeClientStreaming(
clientId,
(partial) => {
setAnalysis((prev) => ({ ...prev, ...partial }));
}
);
setIsStreaming(false);
}
return (
<div>
{analysis.name && <h2>{analysis.name}</h2>}
{analysis.risk_score !== undefined && (
<RiskBadge score={analysis.risk_score} />
)}
{analysis.recommendation && (
<p>{analysis.recommendation}</p>
)}
{analysis.summary ? (
<p>{analysis.summary}</p>
) : isStreaming ? (
<SkeletonLine /> // Placeholder mientras llega
) : null}
</div>
);
}
El usuario ve el nombre en 200ms, la puntuación de riesgo en 400ms, la recomendación en 800ms. La experiencia percibida es la de una respuesta rápida aunque el análisis completo tarde 12 segundos.
Implementación manual si no usas zod-stream
Si prefieres no añadir la dependencia, puedes implementar un parser de partial JSON con un buffer:
function parsePartialJSON(buffer: string): Partial<ClientAnalysis> {
// Intentar cerrar el JSON añadiendo llaves y corchetes faltantes
let attempt = buffer.trim();
if (!attempt.startsWith("{")) return {};
// Contar llaves y corchetes abiertos para cerrarlos
let braces = 0;
let brackets = 0;
let inString = false;
let escaped = false;
for (const char of attempt) {
if (escaped) { escaped = false; continue; }
if (char === "\\") { escaped = true; continue; }
if (char === '"') { inString = !inString; continue; }
if (inString) continue;
if (char === "{") braces++;
else if (char === "}") braces--;
else if (char === "[") brackets++;
else if (char === "]") brackets--;
}
// Cerrar strings, brackets y braces abiertos
if (inString) attempt += '"';
attempt += "]".repeat(Math.max(0, brackets));
attempt += "}".repeat(Math.max(0, braces));
try {
return JSON.parse(attempt);
} catch {
return {};
}
}
Qué hacer cuando el stream se corta
Los streams pueden interrumpirse: timeout de Vercel, red inestable, el usuario cierra la pestaña. Con JSON estructurado, un stream cortado significa un objeto incompleto. Dos estrategias:
Rollback atómico: si el stream se corta antes de que llegue un campo crítico, vuelves al estado anterior y muestras un error claro. Útil cuando el análisis parcial no tiene valor sin los campos esenciales.
Commit incremental: marcas qué campos llegaron como "confirmados" y cuáles como "pendientes". Si el stream se corta, tienes los campos confirmados disponibles. Útil cuando cada campo tiene valor independiente.
const confirmedFields = new Set<keyof ClientAnalysis>();
onPartialUpdate((partial) => {
Object.entries(partial).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
confirmedFields.add(key as keyof ClientAnalysis);
}
});
setAnalysis((prev) => ({ ...prev, ...partial }));
});
El salto de UX que esto representa
La diferencia entre un agente que espera y uno que actualiza en tiempo real no es cosmética. En análisis de clientes, generación de informes o cualquier flujo donde el usuario espera una respuesta estructurada, el parse incremental reduce el tiempo de primera información útil de segundos a décimas de segundo.
En DAILYMP implementamos este patrón en los agentes que construimos para clientes: la UX del agente es parte del diseño, no un detalle de implementación. Si estás construyendo un agente con outputs estructurados y quieres que la experiencia sea fluida, la base técnica está en nuestro servicio de Integración IA.
¿Quieres implementar esto en tu proyecto? Escríbeme por WhatsApp y te cuento cómo aplicarlo a tu stack.