{"id":253,"date":"2026-04-19T21:15:40","date_gmt":"2026-04-20T01:15:40","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=253"},"modified":"2026-04-19T21:21:55","modified_gmt":"2026-04-20T01:21:55","slug":"symfony-ai-practica-iii-streaming-con-sse-gestion-de-tools-anidadas","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/04\/symfony-ai-practica-iii-streaming-con-sse-gestion-de-tools-anidadas\/","title":{"rendered":"Symfony AI en la pr\u00e1ctica (III): Streaming real con SSE y gesti\u00f3n de Tools anidadas"},"content":{"rendered":"\n<p>Nada mata m\u00e1s la experiencia de usuario en una aplicaci\u00f3n de IA que tener que esperar.<\/p>\n\n\n\n<p>Env\u00edas tu pregunta, la pantalla se queda en blanco y pasan varios segundos eternos hasta que, de golpe, aparece la respuesta completa. Es frustrante.<\/p>\n\n\n\n<p>En el <a href=\"https:\/\/juredev.com\/blog\/2026\/04\/symfony-ai-practica-chat-con-historial-tools-y-vanilla-js\/\">art\u00edculo anterior<\/a> construimos un asistente conversacional que manten\u00eda el historial y pod\u00eda usar herramientas. Pero el usuario segu\u00eda teniendo que esperar a que el modelo generara <strong>toda<\/strong> la respuesta (incluyendo la ejecuci\u00f3n de tools) antes de ver una sola palabra en pantalla.<\/p>\n\n\n\n<p>En esta tercera parte vamos a solucionar eso de ra\u00edz: implementaremos <strong>streaming real en tiempo real<\/strong> usando <strong>Server-Sent Events (SSE)<\/strong>.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p><strong>Nota importante<\/strong>: Symfony AI est\u00e1 todav\u00eda en fase de preview (v0.7.0). La API, los nombres de clases y el comportamiento pueden cambiar bastante antes de la versi\u00f3n estable. El c\u00f3digo que ver\u00e1s aqu\u00ed refleja el estado actual del componente y lo ir\u00e9 actualizando seg\u00fan evolucione. Siempre consulta la documentaci\u00f3n oficial antes de usarlo en producci\u00f3n.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">1. El Backend: StreamedResponse y SSE<\/h2>\n\n\n\n<p>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 \u00e9l a medida que el modelo genera texto.<\/p>\n\n\n\n<p>Symfony nos facilita esto con la clase <code>StreamedResponse<\/code>. En lugar de devolver un <code>Response<\/code> normal, le pasamos una funci\u00f3n que se encarga de escribir directamente en el buffer de salida mientras el modelo trabaja.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">El Controlador del Chat<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/Controller\/ProductChatController.php\n\npublic function chat(Request $request, ProductChatService $chatService): Response\n{\n    $body = json_decode($request->getContent(), true);\n    $question = trim(strip_tags($body&#91;'question'] ?? ''));\n\n    $response = new StreamedResponse(function () use ($chatService, $question) {\n        try {\n            $chatService->streamAsk($question, function (string $token) {\n                \/\/ Enviamos cada trozo en formato SSE\n                echo \"data: \" . json_encode(&#91;'text' => $token], JSON_THROW_ON_ERROR) . \"\\n\\n\";\n                ob_flush();\n                flush();\n            });\n\n            \/\/ Indicamos al cliente que el stream ha terminado\n            echo \"data: &#91;DONE]\\n\\n\";\n            ob_flush();\n            flush();\n        } catch (\\Throwable $e) {\n            echo \"data: \" . json_encode(&#91;'error' => 'Error: ' . $e->getMessage()]) . \"\\n\\n\";\n            ob_flush();\n            flush();\n        }\n    });\n\n    $response->headers->set('Content-Type', 'text\/event-stream');\n    $response->headers->set('Cache-Control', 'no-cache');\n    $response->headers->set('X-Accel-Buffering', 'no'); \/\/ Muy importante\n    $response->headers->set('Connection', 'keep-alive');\n\n    return $response;\n}<\/code><\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p><strong>\u00bfPor qu\u00e9<\/strong> <code>X-Accel-Buffering: no<\/code><strong>?<\/strong><br>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\u00edan en grupos en lugar de uno a uno, rompiendo la sensaci\u00f3n de tiempo real. Esta cabecera le dice a Nginx que desactive ese buffering para esta respuesta concreta.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">2. El Servicio: Consumiendo el Stream de Symfony AI<\/h2>\n\n\n\n<p>El n\u00facleo de la soluci\u00f3n est\u00e1 en <code>ProductChatService<\/code>. Aqu\u00ed invocamos al agente con la opci\u00f3n <code>stream => true<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">El reto de las Tools anidadas<\/h3>\n\n\n\n<p>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 \u201ccinta\u201d que recoge el resultado de la herramienta y la fusiona con la original.<\/p>\n\n\n\n<p>Desde nuestro c\u00f3digo, todo este proceso es invisible. El framework se encarga de la recursividad por nosotros.<\/p>\n\n\n\n<p>T\u00e9cnicamente, <code>$result->getContent()<\/code> devuelve un generador que, mediante <code>yield from<\/code>, encadena todos los flujos (inicial, de la herramienta y de la respuesta final) en una sola secuencia lineal de objetos <code>Delta<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/AI\/ProductChatService.php\n\npublic function streamAsk(string $userQuestion, \\Closure $onChunk): void\n{\n    $session = $this->requestStack->getSession();\n    $history = $session->get('chat_history', &#91;]);\n    $history&#91;] = &#91;'role' => 'user', 'content' => $userQuestion];\n\n    $messages = $this->buildMessageBag($history);\n    $result = $this->defaultAgent->call($messages, &#91;'stream' => true]);\n    $stream = $result->getContent();\n    $fullAnswer = '';\n\n    foreach ($stream as $delta) {\n        if ($delta instanceof \\Symfony\\AI\\Platform\\Result\\Stream\\Delta\\TextDelta) {\n            $content = $delta->getText();\n            if ($content !== '') { \/\/ Importante: !== '' en lugar de empty()\n                $fullAnswer .= $content;\n                $onChunk($content);\n            }\n        }\n    }\n\n    \/\/ Fallback por si el modelo no gener\u00f3 texto (ej. error silencioso tras una Tool)\n    if (trim($fullAnswer) === '') {\n        $fullAnswer = 'Lo siento, hubo un problema procesando tu solicitud.';\n        $onChunk($fullAnswer);\n    }\n\n    $history&#91;] = &#91;'role' => 'assistant', 'content' => $fullAnswer];\n    \/\/ Guardamos solo los \u00faltimos 10 mensajes\n    $session->set('chat_history', array_slice($history, -10));\n}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">3. El Frontend: ReadableStream y UX Profesional<\/h2>\n\n\n\n<p>En el lado del cliente ya no podemos usar un <code>fetch<\/code> tradicional que espera a que termine toda la respuesta. Ahora leemos el cuerpo de la respuesta en tiempo real usando <code>body.getReader()<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfPor qu\u00e9 no usar <code>EventSource<\/code>?<\/h3>\n\n\n\n<p>La API nativa <code>EventSource<\/code> est\u00e1 pensada para SSE, pero solo acepta peticiones <strong>GET<\/strong>. Como necesitamos enviar la pregunta por <strong>POST<\/strong> (y posiblemente el historial), implementamos nuestro propio parser SSE con <code>fetch<\/code>. La complejidad extra es m\u00ednima y el control que obtenemos vale la pena.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Procesando el flujo en JavaScript Vanilla<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ templates\/chat\/demo.html.twig\n\nasync function sendMessage() {\n    const text = inputPrompt.value.trim();\n    if (!text) return;\n\n    \/\/ ... l\u00f3gica de UI (spinner, deshabilitar input, etc.)\n\n    const response = await fetch('\/api\/chat', { ... });\n\n    \/\/ Limpiamos el indicador de carga en cuanto llega el primer chunk\n    aiBubble.innerHTML = '';\n\n    const reader = response.body.getReader();\n    const decoder = new TextDecoder('utf-8');\n    let buffer = '';\n\n    while (true) {\n        const { done, value } = await reader.read();\n        if (done) break;\n\n        buffer += decoder.decode(value, { stream: true });\n        let boundary = buffer.indexOf('\\n\\n');\n\n        while (boundary !== -1) {\n            const chunk = buffer.slice(0, boundary);\n            buffer = buffer.slice(boundary + 2);\n\n            if (chunk.startsWith('data: ')) {\n                const dataStr = chunk.slice(6);\n                if (dataStr === '&#91;DONE]') break;\n\n                try {\n                    const data = JSON.parse(dataStr);\n                    if (data.text) {\n                        aiBubble.innerHTML += escapeHTML(data.text).replace(\/\\n\/g, '&lt;br>');\n                        chatContainer.scrollTop = chatContainer.scrollHeight;\n                    }\n                } catch (e) {}\n            }\n            boundary = buffer.indexOf('\\n\\n');\n        }\n    }\n}<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Indicador de carga (Typing Indicator)<\/h3>\n\n\n\n<p>Para que todo se sienta fluido, mostramos un spinner animado justo despu\u00e9s de enviar el mensaje. Este desaparece autom\u00e1ticamente en cuanto llega el primer token real:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.typing-indicator span {\n    height: 8px;\n    width: 8px;\n    background: #555;\n    display: inline-block;\n    border-radius: 50%;\n    animation: bounce 1.3s infinite;\n}\n\n@keyframes bounce {\n    0%, 60%, 100% { transform: translateY(0); }\n    30%           { transform: translateY(-4px); }\n}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">4. Elige bien el modelo: el tama\u00f1o no lo es todo<\/h2>\n\n\n\n<p>Durante el desarrollo probamos varios modelos disponibles en Groq para encontrar el mejor equilibrio entre velocidad, coste y capacidad real de usar herramientas.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Comparativa real<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Modelo<\/th><th>Comportamiento con Tools<\/th><th>Resultado<\/th><\/tr><tr><td><strong>Qwen 2.5 (32B)<\/strong><\/td><td>Excelente. Respeta paginaci\u00f3n, contexto y es muy preciso<\/td><td><strong>GANADOR<\/strong><\/td><\/tr><tr><td><strong>Llama 3.3 (70B)<\/strong><\/td><td>Inconsistente. Alucina formatos y entra en bucles<\/td><td>Descartado<\/td><\/tr><tr><td><strong>GPT-OSS (20B)<\/strong><\/td><td>Aceptable, pero menos preciso con paginaci\u00f3n<\/td><td>Alternativa<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">\u00bfPor qu\u00e9 Qwen 2.5 (32B) fue el ganador?<\/h3>\n\n\n\n<p>Aunque Llama 3.3 es bastante m\u00e1s grande (70B), result\u00f3 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\u00f3digo interno al usuario (un error grave en producci\u00f3n).<\/p>\n\n\n\n<p>Qwen 2.5 (32B), en cambio, se lleva muy bien con la API de herramientas. Fue el \u00fanico que gestion\u00f3 correctamente la paginaci\u00f3n (mostrar solo 3 productos y ofrecer ver m\u00e1s en el siguiente turno) sin perder el hilo de la conversaci\u00f3n.<\/p>\n\n\n\n<p>Adem\u00e1s, tiene una capacidad de <strong>autocorrecci\u00f3n proactiva<\/strong> muy interesante: si detectaba que solo hab\u00eda mirado la primera p\u00e1gina de resultados, invocaba autom\u00e1ticamente 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\u00f3 en la mejor opci\u00f3n.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p><strong>Conclusi\u00f3n pr\u00e1ctica<\/strong>: En los agentes de IA, lo importante no es cu\u00e1ntos par\u00e1metros tenga el modelo, sino lo bien que est\u00e9 optimizado para Tool Use. Esa es la diferencia entre un chat que parece inteligente y uno que realmente funciona como una aplicaci\u00f3n s\u00f3lida.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">5. Lecciones aprendidas<\/h2>\n\n\n\n<p>Estos son los \u00abpuntos\u00bb que nos hicieron perder tiempo y que espero que te ahorren a ti:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>El buffer de Nginx<\/strong>: La cabecera <code>X-Accel-Buffering: no<\/code> no es opcional. Sin ella el streaming funciona perfecto en local, pero falla silenciosamente en producci\u00f3n.<\/li>\n\n\n\n<li><strong>El peligro de <\/strong><code>empty()<\/code><strong> en PHP<\/strong>: <code>empty(\"0\")<\/code> devuelve <code>true<\/code>. Si tu IA responde con n\u00fameros (como \u201c0 productos encontrados\u201d), esa respuesta puede desaparecer. Usa siempre comparaciones estrictas (<code>!== ''<\/code>).<\/li>\n\n\n\n<li><strong>La recursividad del stream es transparente<\/strong>: Symfony AI maneja internamente el ciclo herramienta \u2192 respuesta. No necesitas bucles manuales; el foreach sobre getContent() ya te da la secuencia final limpia.<\/li>\n\n\n\n<li><strong>Limpia el historial<\/strong>: Aseg\u00farate de guardar solo el texto final del asistente. Evita persistir llamadas a herramientas o fragmentos intermedios que el modelo pueda haber filtrado.<\/li>\n<\/ol>\n\n\n\n<p>Si quieres ver c\u00f3mo queda todo integrado en un proyecto real, te recomiendo clonar el repositorio completo: <a href=\"https:\/\/github.com\/jure-ve\/symfony-ai-product-agent\">https:\/\/github.com\/jure-ve\/symfony-ai-product-agent<\/a><\/p>\n\n\n\n<p>All\u00ed encontrar\u00e1s la implementaci\u00f3n completa del controlador, el servicio, el frontend y la configuraci\u00f3n del agente.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">\u00bfTerminamos aqu\u00ed?<\/h2>\n\n\n\n<p>Casi. Implementar streaming real con Symfony AI resulta m\u00e1s limpio de lo que parece una vez que entiendes c\u00f3mo el framework gestiona las tools anidadas. El servicio queda muy elegante: solo un <code>foreach<\/code> sobre los tokens.<\/p>\n\n\n\n<p>Pero la historia no acaba aqu\u00ed.<\/p>\n\n\n\n<p>La lecci\u00f3n m\u00e1s importante de toda esta serie no ha sido t\u00e9cnica, sino de criterio: <strong>elegir el modelo correcto es tan importante como escribir buen c\u00f3digo<\/strong>. Un modelo mal alineado con Tool Use puede destruir una arquitectura impecable. Y eso es algo que ning\u00fan tutorial te cuenta hasta que lo sufres en tus propias pruebas.<\/p>\n\n\n\n<p>\u00bfEst\u00e1s probando Symfony AI en alg\u00fan proyecto? \u00bfQu\u00e9 modelos te han funcionado mejor (o peor) con tools? \u00bfTienes alguna duda sobre la implementaci\u00f3n?<\/p>\n\n\n\n<p>Cu\u00e9ntame en los comentarios. Me interesa mucho conocer qu\u00e9 combinaciones est\u00e1n usando otros desarrolladores, porque las mejores preguntas suelen convertirse en el pr\u00f3ximo art\u00edculo de la serie.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p>Este art\u00edculo forma parte de la serie <strong>Symfony AI en la pr\u00e1ctica<\/strong>. Si llegaste directamente aqu\u00ed, te recomiendo empezar por el <a href=\"https:\/\/juredev.com\/blog\/2026\/04\/symfony-ai-agentes-tools-php\/\">primer art\u00edculo<\/a>. Todo el c\u00f3digo de ejemplo de esta serie est\u00e1 en el repositorio <a href=\"https:\/\/github.com\/jure-ve\/symfony-ai-product-agent\">symfony-ai-product-agent<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Nada mata m\u00e1s la experiencia de usuario en una aplicaci\u00f3n de IA que tener que esperar. Env\u00edas 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\u00edculo anterior construimos un asistente conversacional que manten\u00eda el historial y pod\u00eda usar [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[134,138,15],"class_list":["post-253","post","type-post","status-publish","format-standard","hentry","category-desarrollo","tag-ia","tag-llm","tag-php"],"_links":{"self":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/253","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/comments?post=253"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/253\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=253"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=253"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=253"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}