Symfony AI en la práctica: Migración a v0.12, resiliencia con excepciones granulares y selección de modelos

Cuando trabajamos con herramientas en fase experimental, como Symfony AI, la velocidad de evolución es tanto una ventaja como un desafío. Entre la versión v0.9.0 y la v0.12.0, el componente no solo refinó sus firmas de API y su arquitectura interna, sino que introdujo herramientas esenciales para construir agentes verdaderamente resilientes y preparados para los imprevistos del mundo real.

En los artículos anteriores construimos un asistente de tienda con Vanilla JS y streaming SSE con Tools anidadas. Sin embargo, cuando un proveedor externo sufre una sobrecarga, un modelo es retirado de un catálogo o la conversación excede la ventana de contexto, un bloque catch (\Throwable $e) genérico no es suficiente para ofrecer una buena experiencia de usuario.

En este artículo, antes de continuar con la Serie II, abordamos:

  1. La auditoría de cambios y actualización segura a Symfony AI v0.12.0.
  2. Protección contra mutaciones in-place en la gestión del MessageBag.
  3. Manejo granular de excepciones (ExceedContextSizeException, ModelNotFoundException, ServerException) para recuperación automática.
  4. La importancia del modelo en la obediencia de reglas y paginación, y cómo evitar inflar el system prompt.

Recordatorio: Symfony AI se encuentra en constante evolución (actualmente en v0.12.0). Aunque los contratos principales ya están maduros, actualizar periódicamente con un enfoque por fases permite incorporar mejoras de robustez sin comprometer el código existente.

Arquitectura resultante

Antes de entrar en detalle, este es el estado final de la arquitectura tras la migración:

flowchart TD
    A["Usuario / Frontend JS"] -->|POST /api/chat SSE| B[ProductChatController]
    
    subgraph Controller [Controlador y Resiliencia]
        direction TB
        B -->|Captura| E1[ExceedContextSizeException]
        B -->|Captura| E2[ModelNotFoundException]
        B -->|Captura| E3[ServerException]
    end

    B --> C[ProductChatService]
    C -->|clona MessageBag| D["AgentInterface (v0.12.0)"]
    
    D <-->|chat/completions| G["Groq API (GPT OSS 120B)"]
    D -->|Tool calls| Tools
    
    subgraph Tools ["Herramientas #[AsTool]"]
        direction TB
        T1[ProductSearchTool]
        T2[ProductPriceTool]
        T3[ProductCategoryTool]
    end
    
    Tools --> R[ProductRepository]
    D -->|TextDelta SSE| B
    B --> A
  

1. Qué cambió entre Symfony AI v0.9 y v0.12

Al tratarse de saltos entre versiones menores de un componente experimental, es vital revisar los cambios arquitectónicos en el ciclo de vida del agente. Estos tres cambios son los que más probablemente van a romper tu código existente:

A. Mutación in-place del MessageBag (desde v0.10)

En versiones tempranas, los procesadores internos del agente clonaban el saco de mensajes. A partir de v0.10, el agente muta el MessageBag in-place para inyectar llamadas a herramientas (ToolCallMessage) y respuestas intermedias. Si nuestro servicio reutiliza la instancia o la inspecciona posteriormente, debemos ser conscientes de este comportamiento.

B. Límite automático de iteraciones de herramientas (maxToolCalls: 50)

Antes de v0.10, si un modelo entraba en un bucle alucinatorio invocando herramientas sin fin, el proceso se ejecutaba indefinidamente hasta agotar el tiempo de PHP. Ahora viene acotado por defecto a 50 iteraciones, lanzando una excepción si se sobrepasa.

C. Flexibilidad en la firma de AgentInterface::call() (v0.12)

La firma se amplió para aceptar string|MessageBag|UserMessage, permitiendo invocar agentes simples directamente con texto ($agent->call('Hola')), manteniendo total compatibilidad cuando enviamos un MessageBag estructurado con historial.

2. Protegiendo el servicio conversacional (ProductChatService)

En nuestro servicio, construimos el historial a partir de la sesión de Symfony y lo convertimos en un MessageBag.

Para evitar que la ejecución interna del agente altere el objeto de mensajes que acabamos de estructurar, aplicamos una práctica defensiva explícita: clonar el MessageBag al invocar call(). Sin este clone, el agente mutaría la instancia original in-place, lo que puede corromper el historial que acabas de construir o provocar que mensajes internos del ciclo de tool calls aparezcan mezclados con los mensajes del usuario en iteraciones posteriores.

// 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);

    // Invocamos el stream protegiendo el MessageBag original contra mutaciones
    $result = $this->defaultAgent->call(clone $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 !== '') {
                $fullAnswer .= $content;
                $onChunk($content);
            }
        }
    }

    // Guardas de seguridad e inserción en historial...
    $history[] = ['role' => 'assistant', 'content' => $fullAnswer];
    
    // Mantenemos solo los últimos 10 mensajes (sliding window)
    if (count($history) > 10) {
        $history = array_slice($history, -10);
    }
    
    $session->set('chat_history', $history);
}

3. Resiliencia en el Controlador con Excepciones Granulares

Uno de los mayores avances en Symfony AI v0.10+ es la jerarquía tipada de excepciones en el namespace Symfony\AI\Platform\Exception.

En lugar de capturar un error ciego, ahora podemos reaccionar según el tipo de fallo:

// src/Controller/ProductChatController.php

namespace App\Controller;

use App\AI\ProductChatService;
use Symfony\AI\Platform\Exception\ExceedContextSizeException;
use Symfony\AI\Platform\Exception\ModelNotFoundException;
use Symfony\AI\Platform\Exception\ServerException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

class ProductChatController extends AbstractController
{
    #[Route('/api/chat', methods: ['POST'])]
    public function chat(Request $request, ProductChatService $chatService): Response
    {
        $body = json_decode($request->getContent(), true);
        $question = trim(strip_tags($body['question'] ?? ''));

        if (empty($question)) {
            return $this->json(['error' => 'Question is required'], 400);
        }

        $response = new StreamedResponse(function () use ($chatService, $question, $request) {
            try {
                $chatService->streamAsk($question, function (string $token) {
                    echo "data: " . json_encode(['text' => $token], JSON_THROW_ON_ERROR) . "\n\n";
                    ob_flush();
                    flush();
                });
                echo "data: [DONE]\n\n";
                ob_flush();
                flush();
            } catch (ExceedContextSizeException $e) {
                // 1. Contexto saturado: purgamos la sesión para desbloquear al usuario
                error_log("AI CONTEXT ERROR: " . $e->getMessage());
                $request->getSession()->remove('chat_history');
                echo "data: " . json_encode([
                    'error' => 'El historial es muy largo. Se ha reiniciado la conversación, por favor intenta de nuevo.'
                ]) . "\n\n";
                ob_flush();
                flush();
            } catch (ModelNotFoundException $e) {
                // 2. Modelo no disponible o retirado del catálogo
                error_log("AI MODEL ERROR: " . $e->getMessage());
                echo "data: " . json_encode([
                    'error' => 'El modelo de IA configurado no está disponible en este momento.'
                ]) . "\n\n";
                ob_flush();
                flush();
            } catch (ServerException $e) {
                // 3. Error 5xx transitorio del proveedor (Groq, OpenAI, etc.)
                error_log("AI SERVER ERROR: " . $e->getMessage());
                echo "data: " . json_encode([
                    'error' => 'El servicio de IA está temporalmente saturado. Intenta de nuevo en unos segundos.'
                ]) . "\n\n";
                ob_flush();
                flush();
            } catch (\Throwable $e) {
                // 4. Última línea de defensa
                error_log("AI ERROR: " . $e->getMessage() . "\n" . $e->getTraceAsString());
                $errorMsg = "Error inesperado: " . $e->getMessage();
                echo "data: " . json_encode(['error' => $errorMsg]) . "\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');
        $response->headers->set('Connection', 'keep-alive');

        return $response;
    }
}

¿Qué ganamos con esto?

  • Auto-recuperación de sesión: Si un usuario envía textos masivos y desborda los tokens permitidos (ExceedContextSizeException), la sesión se limpia automáticamente en el backend sin dejar al usuario en un bucle de error irrecuperable.
  • Claridad diagnóstica: Distinguimos entre un fallo transitorio de infraestructura (503/504 vía ServerException) y un problema de configuración o la no disponibilidad del modelo (ModelNotFoundException).

4. Lecciones en Producción: Tamaño de Modelo vs. Prompt Engineering

Durante las pruebas de migración nos enfrentamos a un dilema habitual: ¿por qué un modelo a veces omite categorías, ignora el límite de paginación o responde preguntas fuera de tema?

El experimento con modelos compactos

Al probar con modelos más reducidos (como modelos de ~27B parámetros), observamos dos conductas frecuentes:

  1. Volcado sin paginar: Al preguntar por «todos los productos», en lugar de llamar primero a categories y esperar instrucciones, ejecutaba búsquedas y volcaba todos los resultados saltándose la instrucción de 3 elementos por página.
  2. 2. Deriva temática (Off-topic): Respondía a preguntas de cultura general o geografía en lugar de concentrarse en la tienda.

La regla de Stay on Topic

Para corregir la deriva temática, reforzamos las directrices del System Prompt:

Message::forSystem(
    "You are a helpful online store assistant. Reply ONLY in Spanish.\n\n" .
    "AVAILABLE TOOLS:\n" .
    "- 'categories': list available types.\n" .
    "- 'search': lists products by category. Pass exact slug (telefono, portatil, auriculares, reloj). Page defaults to '1' ('2' for more).\n" .
    "- 'price': gets the price of an SKU.\n\n" .
    "RULES:\n" .
    "1. Map user synonyms to exact slugs (e.g., 'laptops'->'portatil', 'relojes'->'reloj' or 'celulares'->'telefono').\n" .
    "2. NO HALLUCINATION: DO NOT invoke unlisted tools (like order/checkout). Never invent products or SKUs.\n" .
    "3. NO SALES: You cannot process orders or take payments; decline gracefully.\n" .
    "4. STAY ON TOPIC: Only answer questions about the store, its products and categories. For unrelated questions, politely redirect the user to the store's offerings."

El equilibrio: no inflar el prompt innecesariamente

Es tentador agregar decenas de reglas al prompt para tapar cada fallo de un modelo pequeño. Sin embargo, en arquitecturas con herramientas, la capacidad de seguimiento instruccional (instruction-following) del modelo es determinante.

l configurar un modelo con mayor capacidad de razonamiento en Groq, como GPT OSS 120B (openai/gpt-oss-120b), el agente ejecutó el flujo con precisión milimétrica:

# config/packages/ai.yaml
ai:
    platform:
        generic:
            groq:
                base_url: 'https://api.groq.com/openai'
                api_key: '%env(GROQ_API_KEY)%'
                model_catalog: 'ai.platform.model_catalog.generic.fallback'
                supports_completions: true
                supports_embeddings: true
                completions_path: '/v1/chat/completions'
                embeddings_path: '/v1/embeddings'
    agent:
        default:
            platform: 'ai.platform.generic.groq'
            model: 'openai/gpt-oss-120b'
            tools:
                services:
                    - { service: 'App\AI\Tool\ProductSearchTool' }
                    - { service: 'App\AI\Tool\ProductPriceTool' }
                    - { service: 'App\AI\Tool\ProductCategoryTool' }

Resultado obtenido:

  • OK – Pregunta general –> Consulta categories y presenta las 4 categorías disponibles.
  • OK – Petición de lista –> Muestra exactamente 3 productos (Página 1) y ofrece la Página 2.
  • OK – Petición «Página 2» –> Recupera los elementos restantes mediante paginación real.
  • OK – Deducción contextual –> Identifica el producto más barato cruzando el catálogo.
  • OK – Rechazo elegante –> Declina pedidos de venta y preguntas fuera de tema.

¿Que beneficios nos trajo la actualización?

La migración a Symfony AI v0.12.0 demuestra que el framework está madurando con paso firme:

  • Las excepciones tipadas permiten pasar de un manejo de errores tosco a una experiencia de usuario que se autorecupera ante situaciones como desbordamiento de contexto o caídas del proveedor.
  • El desacoplamiento de herramientas mediante #[AsTool] sigue siendo el pilar más sólido y elegante: las herramientas no requirieron ni una sola línea de modificación en toda la migración.
  • Elegir el modelo adecuado para Tool Calling ahorra complejidad en el prompt y garantiza que las reglas de negocio (paginación, idioma, límites) se cumplan de manera determinista.

En la siguiente serie exploraremos características avanzadas: Structured Output con DTOs tipados, depuración profunda con el Profiler de Symfony AI y estrategias de Failover entre proveedores.

Sigamos codificando.

Tema Relacionado:

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.