{"id":359,"date":"2026-10-10T09:57:15","date_gmt":"2026-10-10T13:57:15","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=359"},"modified":"2026-10-10T09:57:17","modified_gmt":"2026-10-10T13:57:17","slug":"symfony-ai-migracion-v0-14-1-ejecucion-lazy-y-resiliencia-rate-limits","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/10\/symfony-ai-migracion-v0-14-1-ejecucion-lazy-y-resiliencia-rate-limits\/","title":{"rendered":"Symfony AI en la pr\u00e1ctica: Migraci\u00f3n a v0.14.1, ejecuci\u00f3n lazy y resiliencia ante Rate Limits"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">En los art\u00edculos anteriores construimos un asistente de tienda con Symfony AI: primero con una sola tool, despu\u00e9s con historial y varias herramientas, luego con streaming SSE y, por \u00faltimo, con una migraci\u00f3n a la v0.12 y manejo granular de excepciones.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Desde entonces el componente ha seguido evolucionando r\u00e1pido. Entre la v0.12 y la v0.14.1 cambiaron piezas importantes: el valor que devuelve <code>call()<\/code>, la forma en que se ejecutan las tools y las excepciones relacionadas con los l\u00edmites de los proveedores. En este art\u00edculo documento esa migraci\u00f3n, el c\u00f3digo que tuvimos que tocar y lo que validamos en producci\u00f3n.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Qu\u00e9 cambi\u00f3 entre v0.12 y v0.14.1<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">A. El objeto <code>Execution<\/code> (v0.13)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hasta ahora, <code>$agent->call(...)<\/code> devolv\u00eda un <code>ResultInterface<\/code>. A partir de la v0.13 devuelve un <code>Symfony\\AI\\Agent\\Execution\\Execution<\/code>, que es a la vez un <code>ResultInterface<\/code> y un iterable perezoso (<code>\\IteratorAggregate<\/code>). Con \u00e9l llegan tres m\u00e9todos que nos interesan:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>asTextStream()<\/code>: un generador tipado que emite solo los <code>TextDelta<\/code>. Se acab\u00f3 el <code>instanceof TextDelta<\/code> dentro de cada bucle de streaming.<\/li>\n\n\n\n<li><code>cancel()<\/code>: cancela la llamada y cierra la conexi\u00f3n HTTP subyacente.<\/li>\n\n\n\n<li><code>isStreamed()<\/code>: indica si la ejecuci\u00f3n se configur\u00f3 con streaming.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">B. Adi\u00f3s a <code>AgentProcessor<\/code> (v0.13)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">En el primer art\u00edculo mencionamos el <code>AgentProcessor<\/code> como la pieza que ejecutaba las tools. Desde la v0.13 ya no existe: la clase <code>Agent<\/code> orquesta el ciclo de tool calls por s\u00ed misma, apoy\u00e1ndose en implementaciones de <code>ToolExecutorInterface<\/code>. Por defecto se usa <code>SequentialToolExecutor<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">C. Ejecuci\u00f3n concurrente con Fibers (v0.14)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Cuando el modelo pide varias herramientas a la vez, ahora podemos ejecutarlas concurrentemente con <code>FiberToolExecutor<\/code>. Se activa en la configuraci\u00f3n:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># config\/packages\/ai.yaml\nai:\n    agent:\n        default:\n            platform: 'ai.platform.generic.groq'\n            model: 'openai\/gpt-oss-120b'\n            tools:\n                execution_strategy: fiber # o sequential (por defecto)\n                services:\n                    - { service: 'App\\AI\\Tool\\ProductSearchTool' }\n                    - { service: 'App\\AI\\Tool\\ProductPriceTool' }\n                    - { service: 'App\\AI\\Tool\\ProductCategoryTool' }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">En nuestra tienda la ganancia ser\u00e1 visible cuando el usuario pida algo como \u00abcomp\u00e1rame el precio de estos dos port\u00e1tiles\u00bb, porque el modelo puede pedir dos consultas de precio en la misma vuelta.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">D. Excepciones de resiliencia (v0.14)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Se ampli\u00f3 la jerarqu\u00eda de <code>Symfony\\AI\\Platform\\Exception<\/code> con dos excepciones que resuelven casos que antes ca\u00edan en el <code>catch (\\Throwable)<\/code> gen\u00e9rico:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>RateLimitExceededException<\/code>: se lanza ante respuestas HTTP 429 de proveedores como Groq u OpenAI. Incluye <code>getRetryAfter()<\/code>, con los segundos sugeridos de espera.<\/li>\n\n\n\n<li><code>MaxOutputTokensException<\/code>: se lanza cuando el modelo corta la respuesta por superar el l\u00edmite de tokens de salida.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">2. Resoluci\u00f3n de versiones en Composer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El monorepo de Symfony AI no versiona todos sus componentes al mismo ritmo. En nuestro caso, <code>ai-bundle<\/code>, <code>ai-agent<\/code> y <code>ai-platform<\/code> est\u00e1n en <code>v0.14.1<\/code>, mientras que los bridges de OpenAI y OpenRouter siguen en <code>v0.14.0<\/code>. Para no pelear con eso, usamos el prefijo de compatibilidad menor:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"require\": {\n    \"symfony\/ai-agent\": \"^0.14\",\n    \"symfony\/ai-bundle\": \"^0.14\",\n    \"symfony\/ai-open-ai-platform\": \"^0.14\",\n    \"symfony\/ai-open-router-platform\": \"^0.14\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Recuerda que el componente sigue siendo experimental. Antes de actualizar, conviene revisar las notas de cada release.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. C\u00f3digo migrado<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">3.1. El servicio: ejecuci\u00f3n lazy y <code>asTextStream()<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">El cambio m\u00e1s visible est\u00e1 en <code>ProductChatService<\/code>. Ya no recorremos el stream buscando <code>TextDelta<\/code>, sino que usamos el generador tipado:<\/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\n    \/\/ Clonamos el MessageBag: el agente lo muta in-place durante las tool calls\n    $execution = $this->defaultAgent->call(clone $messages, &#91;'stream' => true]);\n    $fullAnswer = '';\n\n    foreach ($execution->asTextStream() as $delta) {\n        $content = $delta->getText();\n\n        if ($content !== '') {\n            $fullAnswer .= $content;\n            $onChunk($content);\n        }\n    }\n\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    $session->set('chat_history', array_slice($history, -10));\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">El c\u00f3digo es m\u00e1s corto y se lee mejor, pero la l\u00f3gica de fondo no cambia: el historial sigue siendo una ventana deslizante de 10 mensajes en sesi\u00f3n y solo guardamos el texto final del asistente.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3.2. El controlador: resiliencia ante Rate Limits<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Con <code>RateLimitExceededException<\/code> podemos decirle al usuario cu\u00e1nto tiene que esperar, en vez de mostrarle un mensaje gen\u00e9rico:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/Controller\/ProductChatController.php (fragmento)\n\n} catch (RateLimitExceededException $e) {\n    $retry = $e->getRetryAfter();\n\n    $msg = $retry\n        ? sprintf('L\u00edmite de peticiones alcanzado. Por favor, reintenta en %d segundos.', $retry)\n        : 'L\u00edmite de peticiones alcanzado temporalmente. Espera unos instantes.';\n\n    error_log('AI RATE LIMIT: ' . $e->getMessage());\n    echo 'data: ' . json_encode(&#91;'error' => $msg]) . \"\\n\\n\";\n    ob_flush();\n    flush();\n} catch (MaxOutputTokensException $e) {\n    error_log('AI MAX TOKENS: ' . $e->getMessage());\n    echo 'data: ' . json_encode(&#91;\n        'error' => 'La respuesta del modelo excedi\u00f3 el l\u00edmite de salida y se cort\u00f3.'\n    ]) . \"\\n\\n\";\n    ob_flush();\n    flush();\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Estos bloques van dentro del mismo try del <code>StreamedResponse<\/code> que vimos en la tercera parte, junto a los catch de <code>ExceedContextSizeException<\/code>, <code>ModelNotFoundException<\/code> y <code>ServerException<\/code>. El orden importa: las excepciones espec\u00edficas deben ir antes del <code>\\Throwable<\/code> final.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Validaci\u00f3n<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Probamos la migraci\u00f3n de extremo a extremo y comprobamos:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Consulta de categor\u00edas con <code>ProductCategoryTool<\/code>, con streaming en tiempo real.<\/li>\n\n\n\n<li>B\u00fasqueda y paginaci\u00f3n con <code>ProductSearchTool<\/code>.<\/li>\n\n\n\n<li>Consulta de precios unitarios con <code>ProductPriceTool<\/code>.<\/li>\n\n\n\n<li>Manejo de rate limit con <code>RateLimitExceededException<\/code>, mostrando el tiempo de espera.<\/li>\n\n\n\n<li>Redirecci\u00f3n educada ante preguntas fuera de contexto.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">5. Detalles que nos costaron tiempo<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Migra la API, no solo la versi\u00f3n<\/strong>. Cambiar de <code>instanceof TextDelta<\/code> a <code>asTextStream()<\/code> parece cosm\u00e9tico, pero elimina una clase de errores silenciosos: si el generador cambia de forma, el c\u00f3digo antiguo deja de emitir texto sin lanzar ning\u00fan error.<\/li>\n\n\n\n<li><strong>Un 429 no es un error de c\u00f3digo<\/strong>. Tratarlo como un fallo gen\u00e9rico frustra al usuario. Con <code>getRetryAfter()<\/code> la interfaz puede decirle cu\u00e1ndo volver a intentarlo.<\/li>\n\n\n\n<li><strong>Clona el <code>MessageBag<\/code>, siempre<\/strong>. Desde la v0.10 el agente muta el bag durante las tool calls. Sin el clone, el historial que guardamos en sesi\u00f3n puede contener mensajes internos del ciclo de herramientas.<\/li>\n\n\n\n<li><strong>Activa Fibers solo si tus tools son independientes<\/strong>. La ejecuci\u00f3n concurrente ayuda cuando las consultas no dependen unas de otras. Si una tool necesita el resultado de otra, el ejecutor secuencial sigue siendo la opci\u00f3n correcta.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Que aprendimos con la actualizaci\u00f3n<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">La migraci\u00f3n de v0.12 a v0.14.1 nos deja un c\u00f3digo m\u00e1s limpio: el streaming se lee con un solo foreach, las tools se orquestan dentro del propio agente y los l\u00edmites de los proveedores se tratan como lo que son, errores esperables con una soluci\u00f3n clara.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En el pr\u00f3ximo art\u00edculo retomamos la serie con <strong>Structured Output con DTOs tipados<\/strong> y el <strong>Profiler de Symfony AI<\/strong>, y exploraremos estrategias de failover entre proveedores.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sigamos codificando.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>En los art\u00edculos anteriores construimos un asistente de tienda con Symfony AI: primero con una sola tool, despu\u00e9s con historial y varias herramientas, luego con streaming SSE y, por \u00faltimo, con una migraci\u00f3n a la v0.12 y manejo granular de excepciones. Desde entonces el componente ha seguido evolucionando r\u00e1pido. Entre la v0.12 y la v0.14.1 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_piko_noindex":"","footnotes":""},"categories":[7],"tags":[134,138,15],"class_list":["post-359","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\/359","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=359"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/359\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=359"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=359"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=359"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}