← volver al blog
Publicado en Blog 02-08-2026

Mensajes en ráfaga y acciones duplicadas en agentes conversacionales

Un usuario escribió dos mensajes seguidos y mi agente agendó los mismos eventos dos veces. Debounce adaptativo, idempotencia semántica y typing indicators.

Agentes IAAgentInTheLoopIdempotenciaDebounceChatbotsLangGraphTelegramWhatsApp

Un usuario le escribió esto a mi agente por Telegram, en dos mensajes seguidos:

[Usuario]  Confirmo las horas de psicología
[Usuario]  Agéndame las horas en mi calendario

El agente respondió dos veces. Y en ambas confirmó haber creado los mismos dos eventos de Google Calendar, con la misma fecha, la misma hora y la misma invitada.

Resultado: 4 eventos en el calendario en vez de 2.

Este post es la historia de ese bug, por qué los sospechosos habituales eran inocentes, cómo resuelven el problema plataformas como LangGraph, y las tres capas que terminé implementando en AgentInTheLoop.

Lo que NO era la causa

Antes de diseñar la solución descarté a los dos sospechosos de siempre:

  • Reintento del webhook. Telegram reenvía el update si tu servidor tarda en responder 200. Pero ya existía deduplicación por message_id: cada mensaje entrante se procesa una sola vez.
  • Ejecución concurrente. Dos turnos corriendo a la vez sobre la misma conversación podrían pisarse. Pero ya existía un lock por (canal, contacto): los turnos son estrictamente secuenciales.

Ambos mecanismos funcionaban perfecto. La duplicación no era técnica.

Era semántica.

La causa raíz: tres factores encadenados

1. Dos turnos legítimos. Los mensajes llegaron separados por ~12 segundos. Para el sistema son dos turnos independientes, y cada uno es válido por separado.

2. El LLM obedece literalmente. El primer mensaje (“confirmo las horas”) ya contenía intención suficiente para agendar. El segundo (“agéndame las horas”) es una orden explícita. Un modelo bien alineado hace lo que se le pide: si le ordenas agendar, agenda, aunque acabe de hacerlo. El historial de conversación no es un mecanismo de idempotencia — es solo contexto, y el modelo lo pondera, no lo obedece.

3. Las herramientas no tienen idempotencia. La tool de crear eventos crea un evento cada vez que se la llama. Sin clave de idempotencia, sin deduplicación, sin verificación previa.

Y hay un cuarto factor, social, que es igual de importante: el usuario escribió el segundo mensaje porque el bot llevaba 12 segundos en silencio. Sin señal de actividad, el humano asume que no le llegó y repite. Es exactamente el mismo patrón que el doble clic en un botón “Pagar” sin spinner.

Cómo lo resuelven otros

LangGraph: “double texting”

LangGraph es la única plataforma de agentes que trata esto como una primitiva de primer nivel. Documenta cuatro estrategias:

EstrategiaQué haceCuándo sirve
rejectRechaza el nuevo mensaje si hay un run activoAgentes de una sola acción; mala UX en chat
enqueueEncola el mensaje; se procesa al terminar el run actualChat con efectos secundarios reales
interruptCorta el run actual y conserva el estado parcialAgentes de solo lectura
rollbackCorta el run actual y descarta su estadoSolo si nada externo se ha tocado

La lección clave: interrupt y rollback no sirven cuando ya se disparó un efecto externo. Puedes descartar el estado del grafo, pero no puedes des-crear un evento de calendario ni des-enviar un correo. Para agentes con acciones reales, enqueue es la única semántica correcta.

Debounce: el patrón dominante en producción

Prácticamente todas las plataformas de bots serias implementan lo mismo: una ventana de silencio tras la cual los mensajes acumulados se fusionan en uno solo antes de invocar al modelo. OpenClaw lo expone como configuración (debounceMs), n8n tiene un nodo comunitario creado precisamente porque el patrón se repetía en todos los flujos de WhatsApp, y BuilderBot lo documenta como caso de uso de primera clase.

Los valores típicos van de 1 a 5 segundos. Nadie recomienda más: pasados ~5 segundos el bot se percibe lento, que es precisamente lo que provoca el re-envío.

Claves de idempotencia: el patrón de Stripe

En pagos esto está resuelto hace una década: el cliente manda un Idempotency-Key y el servidor garantiza que la misma clave nunca ejecuta dos veces la operación.

La diferencia con pagos: el agente no tiene una clave que le den; tiene que derivarla del contenido de la acción. Y ahí está el matiz difícil, que veremos abajo.

Typing indicators

Telegram, WhatsApp, Messenger e Instagram exponen un indicador de “escribiendo…” y los bots buenos lo usan. Ataca la causa social del problema: el usuario que ve actividad no repite el mensaje.

Las dos decisiones difíciles

Ventana fija vs. adaptativa

El problema de una ventana fija de debounce: todos los usuarios pagan la latencia del peor caso. Y el bot no puede ver el “escribiendo…” del usuario — Telegram y WhatsApp no se lo exponen a los bots. Hay que inferirlo.

La señal disponible es el propio texto: un mensaje que termina en ? está completo; uno que termina en “y” claramente no lo está.

Señal en el textoVentanaRazón
Termina en ? ! .1,2 sPregunta u orden cerrada — casi nunca hay continuación
Saludo solo (“hola”, “una consulta”)4,0 sCasi siempre viene la consulta detrás
Termina en coma, ... o conjunción (“y”, “con”, “para”)4,0 sFrase colgando
Cualquier otro caso2,5 sNeutral

Resultado: quien escribe una pregunta completa pierde ~1 segundo. Solo quien escribe entrecortado espera 4, y ese es justamente el que iba a mandar otro mensaje.

Por qué un hash de argumentos NO habría arreglado mi bug

Este es el detalle que decide si la solución funciona.

En el incidente, las dos llamadas a la herramienta no eran idénticas: la segunda incluía además el parámetro end_datetime. Un hash de todos los argumentos habría dado dos huellas distintas y habría dejado pasar el duplicado.

La huella tiene que ser semántica: por tipo de acción, con los campos que definen identidad de negocio.

AcciónHuella
Crear evento de calendariotítulo + inicio
Enviar correodestinatario + asunto + hash del cuerpo
Genérica (fallback)hash de los argumentos ordenados

Nótese el matiz en correo: se incluye el cuerpo a propósito, porque reenviar el mismo asunto con contenido corregido es una acción legítima distinta.

Y la ventana de idempotencia debe ser mucho mayor que la de debounce — usé 10 minutos — porque el caso real que atrapa es el usuario insistiendo 15 segundos o 2 minutos después. Pero debe ser finita: agendar la misma reunión recurrente mañana es legítimo.

La arquitectura: tres capas que se refuerzan

Diagrama de las tres capas contra acciones duplicadas: debounce adaptativo antes del LLM, idempotencia semántica en las herramientas y typing indicator en paralelo

Ninguna capa por sí sola resuelve el problema:

  1. Debounce adaptativo + merge (antes de invocar al LLM) — cubre la ráfaga rápida: el usuario que parte su idea en 3 mensajes. Fusionar todo en un solo prompt es más barato, más coherente y elimina la duplicación en el caso más común.
  2. Idempotencia semántica en las herramientas (ventana de 10 min) — cubre lo que el debounce no puede: el segundo mensaje que llega 12 segundos después. Este era mi bug.
  3. Typing indicator (en paralelo al turno) — reduce la frecuencia con que las otras dos capas tienen que actuar.

Algunas reglas que salieron de la implementación:

  • La idempotencia va en la capa de herramientas, no en el prompt. Pedirle al modelo “no repitas acciones” es una sugerencia probabilística. La garantía tiene que estar en el código que ejecuta la acción — otra capa más del harness.
  • Jamás dedupliques lecturas. Listar eventos debe poder repetirse sin límite; solo se protegen verbos con efecto (CREATE, SEND, DELETE…).
  • Marca la acción como hecha solo tras éxito confirmado. Si marcas antes y la llamada falla, el reintento legítimo queda bloqueado.
  • Cuando bloquees un duplicado, díselo al modelo, no al usuario. Devuelve un resultado exitoso con una nota (“esto ya se hizo hace 3 minutos, informa al usuario”) para que el agente responda con naturalidad en vez de mostrar un error.
  • Los comandos de control saltan el buffer. /reset o /stop deben sentirse instantáneos.
  • Audita los duplicados atrapados. Si el guard nunca se dispara, sobra; si se dispara mucho, hay un problema de prompt aguas arriba. Sin telemetría no lo sabes.

El resultado en AgentInTheLoop

Las tres capas están en producción: buffer de ráfagas por (canal, contacto) con ventana adaptativa configurable por canal, huella semántica para calendario y correo con fallback por hash para el resto, typing indicator con detección automática de qué transporte lo soporta, y cada duplicado atrapado queda auditado como “Duplicado evitado” en el panel de administración.

La suite de tests incluye una prueba que reproduce el bug original: la segunda llamada añade end_datetime y aun así queda atrapada. Ese test es la diferencia entre creer que está arreglado y saberlo.

Resumen

Un agente conversacional con acciones reales no puede confiar en el LLM para no repetirse. Necesita tres cosas: agrupar los mensajes que llegan juntos antes de razonar (debounce adaptativo, 1–4 s), una clave de idempotencia semántica en cada herramienta con efectos (ventana ~10 min), y feedback visual inmediato para que el usuario no sienta la necesidad de repetir. Y nunca debe cancelar un turno que ya tocó el mundo exterior: hay que encolar, no interrumpir.

¿Les ha pasado algo similar con agentes en producción? ¿Cómo lo resolvieron?


Referencias: LangGraph — Double texting · OpenClaw — debounceMs · n8n — message-debounce · Idempotent agent operations · Telegram sendChatAction · WhatsApp typing indicators

#AIAgents #LLM #Chatbots