En los artículos anteriores construimos un asistente de tienda con Symfony AI: primero con una sola tool, después con historial y varias herramientas, luego con streaming SSE y, por último, con una migración a la v0.12 y manejo granular de excepciones.
Desde entonces el componente ha seguido evolucionando rápido. Entre la v0.12 y la v0.14.1 cambiaron piezas importantes: el valor que devuelve call(), la forma en que se ejecutan las tools y las excepciones relacionadas con los límites de los proveedores. En este artículo documento esa migración, el código que tuvimos que tocar y lo que validamos en producción.
1. Qué cambió entre v0.12 y v0.14.1
A. El objeto Execution (v0.13)
Hasta ahora, $agent->call(...) devolvía un ResultInterface. A partir de la v0.13 devuelve un Symfony\AI\Agent\Execution\Execution, que es a la vez un ResultInterface y un iterable perezoso (\IteratorAggregate). Con él llegan tres métodos que nos interesan:
asTextStream(): un generador tipado que emite solo losTextDelta. Se acabó elinstanceof TextDeltadentro de cada bucle de streaming.cancel(): cancela la llamada y cierra la conexión HTTP subyacente.isStreamed(): indica si la ejecución se configuró con streaming.
B. Adiós a AgentProcessor (v0.13)
En el primer artículo mencionamos el AgentProcessor como la pieza que ejecutaba las tools. Desde la v0.13 ya no existe: la clase Agent orquesta el ciclo de tool calls por sí misma, apoyándose en implementaciones de ToolExecutorInterface. Por defecto se usa SequentialToolExecutor.
C. Ejecución concurrente con Fibers (v0.14)
Cuando el modelo pide varias herramientas a la vez, ahora podemos ejecutarlas concurrentemente con FiberToolExecutor. Se activa en la configuración:
# config/packages/ai.yaml
ai:
agent:
default:
platform: 'ai.platform.generic.groq'
model: 'openai/gpt-oss-120b'
tools:
execution_strategy: fiber # o sequential (por defecto)
services:
- { service: 'App\AI\Tool\ProductSearchTool' }
- { service: 'App\AI\Tool\ProductPriceTool' }
- { service: 'App\AI\Tool\ProductCategoryTool' }
En nuestra tienda la ganancia será visible cuando el usuario pida algo como «compárame el precio de estos dos portátiles», porque el modelo puede pedir dos consultas de precio en la misma vuelta.
D. Excepciones de resiliencia (v0.14)
Se amplió la jerarquía de Symfony\AI\Platform\Exception con dos excepciones que resuelven casos que antes caían en el catch (\Throwable) genérico:
RateLimitExceededException: se lanza ante respuestas HTTP 429 de proveedores como Groq u OpenAI. IncluyegetRetryAfter(), con los segundos sugeridos de espera.MaxOutputTokensException: se lanza cuando el modelo corta la respuesta por superar el límite de tokens de salida.
2. Resolución de versiones en Composer
El monorepo de Symfony AI no versiona todos sus componentes al mismo ritmo. En nuestro caso, ai-bundle, ai-agent y ai-platform están en v0.14.1, mientras que los bridges de OpenAI y OpenRouter siguen en v0.14.0. Para no pelear con eso, usamos el prefijo de compatibilidad menor:
"require": {
"symfony/ai-agent": "^0.14",
"symfony/ai-bundle": "^0.14",
"symfony/ai-open-ai-platform": "^0.14",
"symfony/ai-open-router-platform": "^0.14"
}
Recuerda que el componente sigue siendo experimental. Antes de actualizar, conviene revisar las notas de cada release.
3. Código migrado
3.1. El servicio: ejecución lazy y asTextStream()
El cambio más visible está en ProductChatService. Ya no recorremos el stream buscando TextDelta, sino que usamos el generador tipado:
// 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);
// Clonamos el MessageBag: el agente lo muta in-place durante las tool calls
$execution = $this->defaultAgent->call(clone $messages, ['stream' => true]);
$fullAnswer = '';
foreach ($execution->asTextStream() as $delta) {
$content = $delta->getText();
if ($content !== '') {
$fullAnswer .= $content;
$onChunk($content);
}
}
if (trim($fullAnswer) === '') {
$fullAnswer = 'Lo siento, hubo un problema procesando tu solicitud.';
$onChunk($fullAnswer);
}
$history[] = ['role' => 'assistant', 'content' => $fullAnswer];
$session->set('chat_history', array_slice($history, -10));
}
El código es más corto y se lee mejor, pero la lógica de fondo no cambia: el historial sigue siendo una ventana deslizante de 10 mensajes en sesión y solo guardamos el texto final del asistente.
3.2. El controlador: resiliencia ante Rate Limits
Con RateLimitExceededException podemos decirle al usuario cuánto tiene que esperar, en vez de mostrarle un mensaje genérico:
// src/Controller/ProductChatController.php (fragmento)
} catch (RateLimitExceededException $e) {
$retry = $e->getRetryAfter();
$msg = $retry
? sprintf('Límite de peticiones alcanzado. Por favor, reintenta en %d segundos.', $retry)
: 'Límite de peticiones alcanzado temporalmente. Espera unos instantes.';
error_log('AI RATE LIMIT: ' . $e->getMessage());
echo 'data: ' . json_encode(['error' => $msg]) . "\n\n";
ob_flush();
flush();
} catch (MaxOutputTokensException $e) {
error_log('AI MAX TOKENS: ' . $e->getMessage());
echo 'data: ' . json_encode([
'error' => 'La respuesta del modelo excedió el límite de salida y se cortó.'
]) . "\n\n";
ob_flush();
flush();
}
Estos bloques van dentro del mismo try del StreamedResponse que vimos en la tercera parte, junto a los catch de ExceedContextSizeException, ModelNotFoundException y ServerException. El orden importa: las excepciones específicas deben ir antes del \Throwable final.
4. Validación
Probamos la migración de extremo a extremo y comprobamos:
- Consulta de categorías con
ProductCategoryTool, con streaming en tiempo real. - Búsqueda y paginación con
ProductSearchTool. - Consulta de precios unitarios con
ProductPriceTool. - Manejo de rate limit con
RateLimitExceededException, mostrando el tiempo de espera. - Redirección educada ante preguntas fuera de contexto.
5. Detalles que nos costaron tiempo
- Migra la API, no solo la versión. Cambiar de
instanceof TextDeltaaasTextStream()parece cosmético, pero elimina una clase de errores silenciosos: si el generador cambia de forma, el código antiguo deja de emitir texto sin lanzar ningún error. - Un 429 no es un error de código. Tratarlo como un fallo genérico frustra al usuario. Con
getRetryAfter()la interfaz puede decirle cuándo volver a intentarlo. - Clona el
MessageBag, siempre. Desde la v0.10 el agente muta el bag durante las tool calls. Sin el clone, el historial que guardamos en sesión puede contener mensajes internos del ciclo de herramientas. - Activa Fibers solo si tus tools son independientes. La ejecución 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ón correcta.
Que aprendimos con la actualización
La migración de v0.12 a v0.14.1 nos deja un código más limpio: el streaming se lee con un solo foreach, las tools se orquestan dentro del propio agente y los límites de los proveedores se tratan como lo que son, errores esperables con una solución clara.
En el próximo artículo retomamos la serie con Structured Output con DTOs tipados y el Profiler de Symfony AI, y exploraremos estrategias de failover entre proveedores.
Sigamos codificando.
Tema Relacionado: IA Aplicada