Todos los artículos

Ingeniería de Software • Laravel 12

Chatbot con Laravel 12: State Machine, IA y Serverless

Análisis técnico de los desafíos reales al construir un motor de chatbot con Laravel 12, BotMan, máquinas de estado, IA (Gemini/Groq) y arquitectura serverless en AWS Lambda.

La mayoría de los tutoriales de chatbots muestran un bot que responde un mensaje y listo. La realidad de producción es muy diferente: conversaciones que deben persistir entre múltiples mensajes, flujos condicionales que dependen del estado del usuario, integraciones con APIs externas que fallan, y todo esto ejecutándose en un entorno serverless donde cada instancia Lambda es efímera.

En este artículo describo los desafíos técnicos que resolvimos al construir laravel-v12-api-converse-admin: una API REST en Laravel 12 para gestionar conversaciones de WhatsApp y Telegram con chatbots inteligentes, desplegada en AWS Lambda.

¿Por qué modelar un chatbot como una máquina de estados?

El problema central de un chatbot es que HTTP es stateless pero una conversación no lo es. El usuario envía un mensaje, el sistema responde, y luego el usuario envía otro mensaje que solo tiene sentido dentro del contexto de lo que se habló antes.

Decidimos modelar cada flujo de conversación como una Máquina de Estado Finito (FSM). Cada conversación tiene un estado actual, y cada mensaje entrante dispara una transición a un nuevo estado o permanece en el mismo si el input es inválido.

StateDefinition — definir un flujo en ~10 líneas:

// Estado simple: pregunta y valida
'ask_name' => StateDefinition::make()
    ->ask('¿Cómo prefieres que te llame?')
    ->validateMinLength(2, 'Ingresa al menos 2 caracteres.')
    ->saveAs('custom_name')
    ->then('register'),

// Estado dinámico: accede a datos previos
'start' => function (array $data) {
    $name = $data['platform_name'] ?? '';
    return StateDefinition::make()
        ->say("Hola, {$name}!")
        ->askWithButtons('¿Continuamos con ese nombre?', [
            'yes' => 'Sí, ese es mi nombre',
            'no'  => 'Prefiero usar otro',
        ])
        ->validateIn(['yes', 'no'])
        ->saveAs('name_confirmed')
        ->when('yes', 'register')
        ->when('no', 'ask_name');
},

// Estado de proceso: lógica de negocio, sin input
'register' => StateDefinition::make()
    ->process(function ($_, array $data) {
        $identity = Identities::create(['display' => $data['custom_name']]);
        return ['data' => ['identity_id' => $identity->id], 'next' => 'welcome'];
    }),

El builder pattern de StateDefinition hace que el código del flujo sea declarativo y fácil de razonar. Los estados pueden ser estáticos o dinámicos (closures que reciben $data). Los métodos saveAs, then, when y process controlan el almacenamiento de respuestas y las transiciones entre estados.

Tipos de input soportados

  • Texto libre
  • Botones de respuesta rápida
  • Listas interactivas
  • Ubicación (coordenadas GPS)
  • Imágenes y documentos
  • Palabras clave de cancelación

Comportamientos del motor

  • Retry automático (max 3 intentos)
  • Cancelación por keyword en cualquier estado
  • Timeout configurable por estado
  • Conversation chaining entre flujos
  • Persistencia en Redis via BotMan
  • Serialización personalizada de closures

¿Por qué falla la serialización de conversaciones en Redis?

Usamos BotMan como framework base para el manejo de conversaciones. BotMan persiste el estado de cada conversación en Redis para poder reanudarla cuando llega el siguiente mensaje del usuario.

El problema: PHP no puede serializar Closures. Y nuestro StateDefinition usa closures para las callbacks de validación y transformación. Cuando BotMan intentaba serializar la conversación al guardarla en Redis, obteníamos un TypeError silencioso que corrompía el estado.

  • El problema concreto — Sin solución personalizada, el flujo era: usuario envía mensaje → BotMan intenta serializar la conversación con sus Closures → serialize() lanza excepción → conversación se pierde → usuario recibe un error genérico o la conversación reinicia desde cero.

La solución fue implementar __serialize() y __unserialize() personalizados en StateMachineConversation. En lugar de serializar los closures, persistimos solo el estado mínimo necesario: el nombre del estado actual, los datos del usuario y el número de reintentos. Al deserializar, reconstruimos el objeto StateDefinition completo en runtime llamando al método que define los flujos.

StateMachineConversation — serialización selectiva:

public function __serialize(): array
{
    return [
        'currentState'  => $this->currentState,
        'retryCount'    => $this->retryCount,
        'conversationId'=> $this->conversationId,
        // Los StateDefinitions NO se serializan
        // Se reconstruyen en __unserialize()
    ];
}

public function __unserialize(array $data): void
{
    $this->currentState   = $data['currentState'];
    $this->retryCount     = $data['retryCount'];
    $this->conversationId = $data['conversationId'];
    // Reconstruir definiciones desde el método del flujo
    $this->states = $this->defineStates();
}

¿Hace falta un driver propio para WhatsApp Cloud API?

BotMan tiene drivers para múltiples plataformas, pero el driver oficial de WhatsApp no soporta los tipos de mensajes interactivos de WhatsApp Cloud API (botones, listas, templates). Tampoco implementa la verificación HMAC de webhooks requerida por Meta.

Construimos CustomWhatsappDriver (600+ líneas) que cubre todo el contrato con la Cloud API de Meta:

  • Verificación HMAC SHA-256 sobre cada webhook entrante de Meta. Rechaza payloads no firmados antes de procesarlos.
  • Mensajes Interactivos Botones, listas, templates con variables y mensajes de media (imágenes, documentos).
  • Challenge-Response Handshake de verificación de webhook con Meta. Respuesta correcta en el primer intento.

Un detalle que requirió trabajo especial fue el challenge-response handshake de Meta. Cuando registras un webhook, Meta hace un GET con un token de verificación y espera que tu servidor lo devuelva en un formato específico. Si el endpoint no responde correctamente en los primeros segundos, Meta rechaza el webhook definitivamente.

Implementamos también el evento OutgoingMessageSent que se dispara cada vez que el bot envía un mensaje, permitiendo al resto del sistema (logging, analytics, billing) reaccionar sin acoplar esa lógica al driver.

¿Cómo detectar la intención del usuario sin depender de un solo proveedor de IA?

No todos los mensajes que recibe el bot son respuestas a una pregunta dentro de un flujo. Los usuarios escriben cosas como "cancelar", "ayuda", "quiero hablar con un agente", y el sistema necesita detectar estas intenciones antes de procesarlos como input del estado actual.

Usamos LLMs para clasificar intenciones, con configuración deliberadamente conservadora:

IntentDetectionService — configuración para respuestas deterministas:

// Temperatura baja: queremos clasificación, no creatividad
'temperature' => 0.2,

// Output mínimo: un intent es máximo 2-3 palabras
'maxTokens' => 64,

// Modelo: gemini-2.5-flash-lite (rápido y económico para clasificación)
'model' => 'gemini-2.5-flash-lite',

// El prompt está en un archivo Markdown externo
// → facilita versionar y probar variaciones del prompt sin tocar el código
'promptFile' => resource_path('prompts/intent-detection.md'),

El problema de depender de una sola API externa es la disponibilidad. Gemini puede estar caído, puede tener rate limits, o simplemente latencia alta. Implementamos dos capas de resiliencia:

  • FallbackAIClient — Si Gemini falla (timeout, error 5xx, rate limit), automáticamente reintenta con Groq. El cliente de fallback es transparente: el IntentDetectionService no sabe qué modelo usó realmente.
  • Circuit Breaker (Ganesha) — Usamos ackintosh/ganesha con el patrón bulkhead. Si Gemini acumula suficientes fallas, el circuit breaker se abre y las solicitudes van directo a Groq sin intentar Gemini, evitando latencia acumulada.

¿Cómo avisar a los demás servicios cuando el usuario completa un flujo?

El chatbot no vive aislado. Cuando un usuario completa un flujo de onboarding, otros servicios necesitan saberlo: el sistema de CRM debe crear el perfil, el servicio de notificaciones debe enviar una confirmación, el servicio de analytics debe registrar la conversión.

El acoplamiento directo entre servicios es frágil: si el CRM está caído, ¿el onboarding falla? ¿Se reintenta? ¿Cuántas veces? En su lugar, publicamos eventos de negocio en AWS EventBridge y cada servicio se suscribe a lo que le interesa.

  • Pipeline de mensajes

La Lambda también actúa como consumidor de EventBridge. Cuando otro servicio publica un evento (por ejemplo, que se creó una cuenta espejo para el usuario), la Lambda lo recibe y puede continuar o iniciar una conversación con el usuario en WhatsApp, todo sin que el usuario haya enviado un mensaje.

¿Qué hace el bot cuando el usuario abandona la conversación a medias?

Los usuarios abandonan conversaciones a medias. Si alguien deja una conversación en el paso 3 de un onboarding y vuelve 30 minutos después, ¿qué hace el bot?

Implementamos ConversationTimeoutJob: un job que se encola con un delay configurable por estado. Si el usuario no responde antes de que expire, el job se ejecuta y puede cancelar la conversación, enviar un recordatorio, o hacer una transición de estado automática.

El desafío técnico aquí es que BotMan fue diseñado para responder a mensajes entrantes. Para reanudar una conversación sin que el usuario haya enviado nada, necesitamos simular el contexto del driver. Desarrollamos TimeoutBridge: una clase que construye un objeto BotMan con un driver sintético que apunta al usuario correcto, permitiendo al FSM ejecutarse como si hubiera recibido un mensaje real.

  • Race condition a evitar — Si el usuario responde justo cuando el job de timeout se está ejecutando, podemos tener dos transiciones de estado simultáneas. El job verifica el estado esperado antes de ejecutar: si el estado cambió desde que se encoló, el job cancela su ejecución silenciosamente.

¿Cómo se rastrea una conversación que se rompió en producción?

En un sistema event-driven con múltiples plataformas, saber qué pasó cuando algo falla es tan importante como que las cosas funcionen. Implementamos trazabilidad end-to-end desde el webhook hasta el evento de negocio.

  • Canal de errores (Slack) — Excepciones no manejadas, fallos de integración con APIs externas y errores de serialización.
  • Canal de negocio (Slack) — Eventos de alto valor: onboarding completado, cuenta creada, alertas de ubicación activadas.
  • Eventos de usuario (Slack) — Inicio de conversaciones, cambios de estado del FSM y cancelaciones.
  • CloudWatch (producción) — Logs estructurados de Lambda. Correlación de request IDs entre el webhook y los eventos de negocio.

Todos los mensajes se persisten en la tabla Messages con dirección (incoming/outgoing), permitiendo reconstruir cualquier conversación completa. Los webhooks crudos se guardan en WebhookLogs antes de procesarse, lo que permite depurar problemas con el payload original de Meta o Telegram.

¿Se puede correr Laravel en AWS Lambda para un chatbot?

Desplegamos con Bref, que permite ejecutar PHP en AWS Lambda. No hay servidores que mantener, el escalado es automático, y solo se paga por ejecución.

Los desafíos específicos de serverless con Laravel:

Cold Starts de Lambda

PHP con Bref tiene cold starts de ~200-400ms, aceptable para webhooks. Usamos Provisioned Concurrency para los endpoints críticos de webhooks de WhatsApp, donde Meta espera respuesta en menos de 5 segundos.

Estado Compartido entre Instancias

Lambda es efímero: cada instancia no tiene estado local. Todo el estado de conversaciones vive en Redis Cloud. Múltiples instancias Lambda pueden manejar mensajes del mismo usuario sin conflicto porque Redis es el único source of truth.

Colas Asíncronas

Los jobs de Laravel (incluyendo ConversationTimeoutJob) se procesan via AWS SQS. Un proceso worker separado consume las colas sin bloquear el manejo de webhooks, y SQS garantiza entrega at-least-once con reintentos automáticos.

Base de Datos Serverless

Usamos NeonDB (PostgreSQL serverless) como base de datos de producción. Se escala automáticamente y tiene connection pooling nativo, lo que evita el problema clásico de "too many connections" que ocurre cuando muchas instancias Lambda abren conexiones directas a PostgreSQL.

  • Stack de producción

¿Qué aprendimos construyendo el motor?

  • Diseña para la asincronía desde el día 1 — En un sistema de chatbot, ningún mensaje se procesa en tiempo real desde la perspectiva del usuario. Asumimos latencia, reintentos y fallas desde el diseño inicial. Los sistemas que asumen happy path primero tienen deuda técnica imposible de pagar después.
  • Los patrones de resiliencia no son opcionales — Circuit breakers, retries con backoff, fallbacks y timeouts no son features adicionales: son parte del diseño de cualquier integración con APIs externas. Gemini, Meta y Telegram fallan. No con frecuencia, pero cuando fallan en producción importa.
  • Separa el core de los adaptadores — El FSM de StateMachineConversation no sabe si está hablando con WhatsApp o Telegram. Esa separación nos permitió agregar Telegram después sin tocar la lógica de negocio, y nos prepara para agregar futuras plataformas.
  • Observabilidad como feature de primera clase — La trazabilidad end-to-end (webhook → estado → evento de negocio) no fue un afterthought. Sin ella, depurar por qué una conversación se rompió en producción es prácticamente imposible en un sistema event-driven.

¿Qué queda por resolver?

El sistema funciona en producción, pero hay componentes de optimización en diseño cuya implementación está pendiente. Documentamos estos retos para no perder el razonamiento detrás de las decisiones tomadas.

  • Cache semántico de intents (L1 → L2 → LLM) — El pipeline llama al LLM en cada mensaje para detectar intent y extraer parámetros. El diseño plantea un sistema en capas: L1 con Redis para exact match por hash xxh3 (O(1), sin cómputo), y L2 con pgvector para búsqueda por similitud semántica. Solo ante un doble miss se llega al LLM. Las tablas ai_intent_cache y el pipeline de capas están diseñadas pero pendientes de implementar.
  • Resolución de entidades con cache por identity — Cuando el LLM extrae "FORD CAJA" de un mensaje, actualmente siempre consulta la API externa. El reto es cachear estas resoluciones por identity_id + entity_type + raw_name via ai_entity_resolutions, con ILIKE matching sobre ai_entity_catalog. El scope por identity es intencionado: cada cliente tiene su propio vocabulario de entidades y un cache global contaminaría datos entre clientes.
  • Sincronización periódica del catálogo de entidades — El catálogo local de entidades (unidades, conductores, rutas desde mirror accounts) necesita mantenerse fresco. El diseño plantea sync on-demand ante un cache miss, y sync periódico via job programado (~cada 6 horas). Al re-sincronizar, las resoluciones de esa identity deben invalidarse porque los nombres canónicos pueden haber cambiado en el sistema externo.
  • Evaluación del Laravel AI SDK — Laravel 12 incluye un SDK oficial de AI con soporte nativo para embeddings (Gemini, OpenAI, Cohere, etc.) y vector queries en Eloquent via pgvector. Esto cubriría la búsqueda L2 del ai_intent_cache sin SQL raw. La decisión pendiente es si migrar también los clientes custom (GeminiAIClient / GroqAIClient / FallbackAIClient) al SDK, o adoptarlo solo para embeddings y vector queries mientras se mantiene el pipeline de cache como código propio.
  • Desarrollado con asistencia de IA — Gran parte de este proyecto fue desarrollado con la asistencia de Claude Code como herramienta principal. También experimentamos con Gemini CLI, Qwen y Kimi AI en distintas etapas del desarrollo. Fue un ejercicio real de cómo los agentes de IA pueden acelerar la construcción de sistemas complejos sin reemplazar el criterio de ingeniería detrás de cada decisión.

Caso de Uso: Cuentas Espejo GPS

La mejor forma de entender el rol de API Converse es ver un producto real que la usa. Cuentas Espejo GPS es una PWA que permite a empresas de transporte compartir el seguimiento de su flota desde WhatsApp o Telegram —sin revelar credenciales ni cambiar de proveedor GPS. En este sistema, API Converse administra la conversación con el operador; la lógica de negocio (crear la cuenta espejo, validar el vehículo, emitir el token) vive en una API separada: API Mirror Accounts.

El flujo completo ocurre dentro del chat: el dueño de transporte indica el vehículo, configura el tiempo de acceso, y en segundos recibe un enlace temporal con branding propio que puede mandar a su cliente. La cuenta espejo expira automáticamente. Sin apps, sin paneles de administración, sin fricción.

Cuentas Espejo GPS — canal conversacional en API Converse, lógica de negocio en API Mirror Accounts. Compatible con Atlantida, Geotrucks y Zeekgps, con branding personalizado, acceso temporal de expiración automática y alertas periódicas de ubicación al destinatario.

Leer el caso de uso completo →

Preguntas Frecuentes

¿Por qué una máquina de estados y no dejar que el LLM lleve la conversación?

Porque HTTP es stateless y una conversación no lo es: el mensaje que llega solo tiene sentido dentro de lo que se habló antes. La FSM guarda ese contexto de forma explícita y auditable —qué estado, qué datos, cuántos reintentos—, mientras que el LLM se usa para una tarea acotada: clasificar la intención, con temperatura 0.2 y 64 tokens de salida. Un flujo de negocio que cobra, factura o comparte ubicación necesita ser determinista; la creatividad del modelo ahí es un riesgo, no una ventaja.

¿Qué pasa si el proveedor de IA se cae?

La detección de intención sigue funcionando. FallbackAIClient reintenta automáticamente con Groq cuando Gemini responde con timeout, error 5xx o rate limit, y el servicio que lo llama no sabe qué modelo acabó respondiendo. Encima hay un circuit breaker (ackintosh/ganesha): si Gemini acumula suficientes fallas, el circuito se abre y las solicitudes van directo a Groq sin pagar la latencia del intento fallido.

¿El mismo flujo sirve para WhatsApp y para Telegram?

Sí. El FSM no sabe con qué plataforma está hablando: eso vive en los drivers. Por eso Telegram se agregó después sin tocar la lógica de negocio. Para WhatsApp construimos un driver propio de más de 600 líneas, porque el oficial de BotMan no cubre los mensajes interactivos de la Cloud API —botones, listas, plantillas— ni la verificación HMAC SHA-256 de los webhooks que exige Meta.

¿Aguanta un entorno serverless los webhooks de WhatsApp?

Sí, con dos cuidados. PHP sobre Bref tiene cold starts de 200 a 400 ms y Meta espera respuesta en menos de 5 segundos, así que los endpoints críticos de webhook van con Provisioned Concurrency. Y como cada instancia Lambda es efímera, todo el estado de la conversación vive en Redis: varias instancias pueden atender mensajes del mismo usuario sin pisarse porque Redis es la única fuente de verdad.

¿Construyendo algo similar?

En Mango Binario diseñamos e implementamos sistemas de chatbot para empresas que necesitan automatizar conversaciones a escala.

Si estás evaluando arquitecturas para tu proyecto o buscas un equipo con experiencia en integraciones de WhatsApp, IA aplicada y arquitecturas serverless, conversemos.

¿Te sirvió lo que leíste?

Si tu negocio tiene un reto parecido, cuéntanoslo. La primera llamada es para entenderlo, no para venderte nada.