Symfony AI en la práctica (III): Streaming real con SSE y gestión de Tools anidadas

Nada mata más la experiencia de usuario en una aplicación de IA que tener que esperar.

Envías tu pregunta, la pantalla se queda en blanco y pasan varios segundos eternos hasta que, de golpe, aparece la respuesta completa. Es frustrante.

En el artículo anterior construimos un asistente conversacional que mantenía el historial y podía usar herramientas. Pero el usuario seguía teniendo que esperar a que el modelo generara toda la respuesta (incluyendo la ejecución de tools) antes de ver una sola palabra en pantalla.

En esta tercera parte vamos a solucionar eso de raíz: implementaremos streaming real en tiempo real usando Server-Sent Events (SSE).

Nota importante: Symfony AI está todavía en fase de preview (v0.7.0). La API, los nombres de clases y el comportamiento pueden cambiar bastante antes de la versión estable. El código que verás aquí refleja el estado actual del componente y lo iré actualizando según evolucione. Siempre consulta la documentación oficial antes de usarlo en producción.

1. El Backend: StreamedResponse y SSE

La idea es bastante sencilla: en vez de calcular toda la respuesta y enviarla de una vez, abrimos un canal HTTP que se mantiene vivo y vamos escribiendo en él a medida que el modelo genera texto.

Symfony nos facilita esto con la clase StreamedResponse. En lugar de devolver un Response normal, le pasamos una función que se encarga de escribir directamente en el buffer de salida mientras el modelo trabaja.

El Controlador del Chat

// src/Controller/ProductChatController.php

public function chat(Request $request, ProductChatService $chatService): Response
{
    $body = json_decode($request->getContent(), true);
    $question = trim(strip_tags($body['question'] ?? ''));

    $response = new StreamedResponse(function () use ($chatService, $question) {
        try {
            $chatService->streamAsk($question, function (string $token) {
                // Enviamos cada trozo en formato SSE
                echo "data: " . json_encode(['text' => $token], JSON_THROW_ON_ERROR) . "\n\n";
                ob_flush();
                flush();
            });

            // Indicamos al cliente que el stream ha terminado
            echo "data: [DONE]\n\n";
            ob_flush();
            flush();
        } catch (\Throwable $e) {
            echo "data: " . json_encode(['error' => 'Error: ' . $e->getMessage()]) . "\n\n";
            ob_flush();
            flush();
        }
    });

    $response->headers->set('Content-Type', 'text/event-stream');
    $response->headers->set('Cache-Control', 'no-cache');
    $response->headers->set('X-Accel-Buffering', 'no'); // Muy importante
    $response->headers->set('Connection', 'keep-alive');

    return $response;
}

¿Por qué X-Accel-Buffering: no?
Nginx, por defecto, acumula los chunks de respuesta en su propio buffer para optimizar el ancho de banda. En un streaming esto es contraproducente: los tokens llegarían en grupos en lugar de uno a uno, rompiendo la sensación de tiempo real. Esta cabecera le dice a Nginx que desactive ese buffering para esta respuesta concreta.

2. El Servicio: Consumiendo el Stream de Symfony AI

El núcleo de la solución está en ProductChatService. Aquí invocamos al agente con la opción stream => true.

El reto de las Tools anidadas

Piensa en el stream como una cinta transportadora de tokens. Cuando el modelo decide que necesita ejecutar una herramienta (por ejemplo, buscar productos), la cinta no se detiene de golpe. Symfony AI crea internamente una nueva “cinta” que recoge el resultado de la herramienta y la fusiona con la original.

Desde nuestro código, todo este proceso es invisible. El framework se encarga de la recursividad por nosotros.

Técnicamente, $result->getContent() devuelve un generador que, mediante yield from, encadena todos los flujos (inicial, de la herramienta y de la respuesta final) en una sola secuencia lineal de objetos Delta.

// src/AI/ProductChatService.php

public function streamAsk(string $userQuestion, \Closure $onChunk): void
{
    $session = $this->requestStack->getSession();
    $history = $session->get('chat_history', []);
    $history[] = ['role' => 'user', 'content' => $userQuestion];

    $messages = $this->buildMessageBag($history);
    $result = $this->defaultAgent->call($messages, ['stream' => true]);
    $stream = $result->getContent();
    $fullAnswer = '';

    foreach ($stream as $delta) {
        if ($delta instanceof \Symfony\AI\Platform\Result\Stream\Delta\TextDelta) {
            $content = $delta->getText();
            if ($content !== '') { // Importante: !== '' en lugar de empty()
                $fullAnswer .= $content;
                $onChunk($content);
            }
        }
    }

    // Fallback por si el modelo no generó texto (ej. error silencioso tras una Tool)
    if (trim($fullAnswer) === '') {
        $fullAnswer = 'Lo siento, hubo un problema procesando tu solicitud.';
        $onChunk($fullAnswer);
    }

    $history[] = ['role' => 'assistant', 'content' => $fullAnswer];
    // Guardamos solo los últimos 10 mensajes
    $session->set('chat_history', array_slice($history, -10));
}

3. El Frontend: ReadableStream y UX Profesional

En el lado del cliente ya no podemos usar un fetch tradicional que espera a que termine toda la respuesta. Ahora leemos el cuerpo de la respuesta en tiempo real usando body.getReader().

¿Por qué no usar EventSource?

La API nativa EventSource está pensada para SSE, pero solo acepta peticiones GET. Como necesitamos enviar la pregunta por POST (y posiblemente el historial), implementamos nuestro propio parser SSE con fetch. La complejidad extra es mínima y el control que obtenemos vale la pena.

Procesando el flujo en JavaScript Vanilla

// templates/chat/demo.html.twig

async function sendMessage() {
    const text = inputPrompt.value.trim();
    if (!text) return;

    // ... lógica de UI (spinner, deshabilitar input, etc.)

    const response = await fetch('/api/chat', { ... });

    // Limpiamos el indicador de carga en cuanto llega el primer chunk
    aiBubble.innerHTML = '';

    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        let boundary = buffer.indexOf('\n\n');

        while (boundary !== -1) {
            const chunk = buffer.slice(0, boundary);
            buffer = buffer.slice(boundary + 2);

            if (chunk.startsWith('data: ')) {
                const dataStr = chunk.slice(6);
                if (dataStr === '[DONE]') break;

                try {
                    const data = JSON.parse(dataStr);
                    if (data.text) {
                        aiBubble.innerHTML += escapeHTML(data.text).replace(/\n/g, '<br>');
                        chatContainer.scrollTop = chatContainer.scrollHeight;
                    }
                } catch (e) {}
            }
            boundary = buffer.indexOf('\n\n');
        }
    }
}

Indicador de carga (Typing Indicator)

Para que todo se sienta fluido, mostramos un spinner animado justo después de enviar el mensaje. Este desaparece automáticamente en cuanto llega el primer token real:

.typing-indicator span {
    height: 8px;
    width: 8px;
    background: #555;
    display: inline-block;
    border-radius: 50%;
    animation: bounce 1.3s infinite;
}

@keyframes bounce {
    0%, 60%, 100% { transform: translateY(0); }
    30%           { transform: translateY(-4px); }
}

4. Elige bien el modelo: el tamaño no lo es todo

Durante el desarrollo probamos varios modelos disponibles en Groq para encontrar el mejor equilibrio entre velocidad, coste y capacidad real de usar herramientas.

Comparativa real

ModeloComportamiento con ToolsResultado
Qwen 2.5 (32B)Excelente. Respeta paginación, contexto y es muy precisoGANADOR
Llama 3.3 (70B)Inconsistente. Alucina formatos y entra en buclesDescartado
GPT-OSS (20B)Aceptable, pero menos preciso con paginaciónAlternativa

¿Por qué Qwen 2.5 (32B) fue el ganador?

Aunque Llama 3.3 es bastante más grande (70B), resultó mucho menos disciplinado con el sistema de tools de Symfony AI. En varias pruebas intentaba escribir las llamadas a funciones directamente en el texto en lugar de usar el canal correcto, lo que provocaba fugas de código interno al usuario (un error grave en producción).

Qwen 2.5 (32B), en cambio, se lleva muy bien con la API de herramientas. Fue el único que gestionó correctamente la paginación (mostrar solo 3 productos y ofrecer ver más en el siguiente turno) sin perder el hilo de la conversación.

Además, tiene una capacidad de autocorrección proactiva muy interesante: si detectaba que solo había mirado la primera página de resultados, invocaba automáticamente la herramienta para consultar las siguientes sin que el usuario se lo pidiera. Ese nivel de razonamiento, junto con un uso muy natural del Markdown, lo convirtió en la mejor opción.

Conclusión práctica: En los agentes de IA, lo importante no es cuántos parámetros tenga el modelo, sino lo bien que esté optimizado para Tool Use. Esa es la diferencia entre un chat que parece inteligente y uno que realmente funciona como una aplicación sólida.

5. Lecciones aprendidas

Estos son los «puntos» que nos hicieron perder tiempo y que espero que te ahorren a ti:

  1. El buffer de Nginx: La cabecera X-Accel-Buffering: no no es opcional. Sin ella el streaming funciona perfecto en local, pero falla silenciosamente en producción.
  2. El peligro de empty() en PHP: empty("0") devuelve true. Si tu IA responde con números (como “0 productos encontrados”), esa respuesta puede desaparecer. Usa siempre comparaciones estrictas (!== '').
  3. La recursividad del stream es transparente: Symfony AI maneja internamente el ciclo herramienta → respuesta. No necesitas bucles manuales; el foreach sobre getContent() ya te da la secuencia final limpia.
  4. Limpia el historial: Asegúrate de guardar solo el texto final del asistente. Evita persistir llamadas a herramientas o fragmentos intermedios que el modelo pueda haber filtrado.

Si quieres ver cómo queda todo integrado en un proyecto real, te recomiendo clonar el repositorio completo: https://github.com/jure-ve/symfony-ai-product-agent

Allí encontrarás la implementación completa del controlador, el servicio, el frontend y la configuración del agente.

¿Terminamos aquí?

Casi. Implementar streaming real con Symfony AI resulta más limpio de lo que parece una vez que entiendes cómo el framework gestiona las tools anidadas. El servicio queda muy elegante: solo un foreach sobre los tokens.

Pero la historia no acaba aquí.

La lección más importante de toda esta serie no ha sido técnica, sino de criterio: elegir el modelo correcto es tan importante como escribir buen código. Un modelo mal alineado con Tool Use puede destruir una arquitectura impecable. Y eso es algo que ningún tutorial te cuenta hasta que lo sufres en tus propias pruebas.

¿Estás probando Symfony AI en algún proyecto? ¿Qué modelos te han funcionado mejor (o peor) con tools? ¿Tienes alguna duda sobre la implementación?

Cuéntame en los comentarios. Me interesa mucho conocer qué combinaciones están usando otros desarrolladores, porque las mejores preguntas suelen convertirse en el próximo artículo de la serie.


Este artículo forma parte de la serie Symfony AI en la práctica. Si llegaste directamente aquí, te recomiendo empezar por el primer artículo. Todo el código de ejemplo de esta serie está en el repositorio symfony-ai-product-agent.


Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.