El fallo de memoria oculto en una etiqueta de progreso
Una noche de finales de septiembre, el agente de IA de un espacio de trabajo de un usuario falló con un error de falta de memoria en mitad de un turno. Los sospechosos obvios (demasiados subagentes ejecutándose a la vez, o un contexto que había crecido demasiado) estaban equivocados. La causa real era una etiqueta de estado: mientras el modelo emitía en streaming un comando de shell grande de unos pocos caracteres cada vez, reenviábamos todo el comando hasta ese momento como etiqueta de progreso en cada fragmento. Un comando de 124 KB se convirtió en unos 41.000 eventos y unos 690 MB de cadenas en cola.
Cómo transmite su trabajo el agente
Cuando construyes en Mythex, el agente se ejecuta dentro del sandbox privado en la nube de tu proyecto. Mientras el modelo escribe, su salida se transmite a tu navegador: el texto aparece palabra a palabra, y cuando ejecuta una herramienta (por ejemplo, un comando de shell que escribe un archivo) ves una etiqueta que se actualiza en directo, para saber qué está haciendo.
El modelo transmite en trozos pequeños. Con el modelo que usábamos, esos trozos solían tener solo tres caracteres.
Las primeras hipótesis estaban equivocadas
El fallo parecía un problema de escalado, así que empezamos por ahí.
- ¿Subagentes en paralelo? El turno solo había usado dos, cada uno con unos 1,5 MB de estado. No era suficiente.
- ¿Trazas retenidas? Nuestro sistema de trazas guardaba un árbol de ejecución en memoria, pero no se acercaba al tamaño del montón en el momento del fallo.
Ambas explicaciones eran plausibles y ambas eran fáciles de creer. Ninguna reproducía el fallo. La lección que seguimos reaprendiendo: una hipótesis no es una causa hasta que reproduce el fallo.
Reproducirlo de verdad
Creamos un sandbox desechable con los mismos límites de memoria que producción y un modelo falso que emitía un archivo grande en trozos realistas de tres caracteres. También hicimos lento al lector del otro extremo del socket, como una red real.
Eso lo consiguió. El montón creció y el proceso murió con el mismo error.
El mecanismo era simple una vez que pudimos observarlo. Mientras el modelo escribía un comando grande (en este caso un heredoc que escribía un archivo de 124 KB), nuestro código de streaming actualizaba la etiqueta de la herramienta en cada trozo. La etiqueta no era el trozo nuevo de tres caracteres, sino el comando completo hasta ese momento. Así que los eventos crecían: 3 caracteres, luego 6, luego 9, … hasta 124 KB, unas 41.000 veces. El socket no podía enviarlos tan rápido como se producían, así que se acumulaban en su búfer de escritura como cadenas, unos 690 MB.
La solución
- Limitar la etiqueta. Una etiqueta de estado solo necesita decir qué está pasando. Ahora se limita a 200 caracteres, y limitamos con qué frecuencia se vuelve a analizar el comando parcial.
- Eliminar la concatenación cuadrática. Unir trozos transmitidos con concatenación repetida de cadenas es O(n²) para salidas grandes. Pasamos a un enfoque por fragmentos que añade en tiempo lineal.
Después buscamos cualquier otra cosa que pudiera crecer sin límite con una avalancha de salida, y la acotamos:
- La salida de comandos en vivo que se muestra en el chat se agrupa y se conservan los últimos 8.000 caracteres.
- Las líneas de evento individuales están limitadas a 4 MB.
- El búfer de reproducción que usa un navegador para ponerse al día tras reconectar está limitado a 8 MB por proyecto.
- Los puntos de control antiguos se podan a medida que avanza un turno, conservando el estado más reciente y los anclajes que necesitan los turnos anteriores.
Probar avalanchas con honestidad
Dos cosas hicieron esto más difícil de probar de lo que debería haber sido:
- Los modelos reales no colaboran. Si pides a un modelo que imprima un archivo gigantesco, normalmente se niega, lo resume o lo escribe de forma más sensata. Para probar avalanchas necesitas un modelo programado que haga exactamente lo que le indicas, ejecutándose a través del código real del agente.
- Los scripts tienen que comportarse como modelos. Nuestro primer modelo programado reutilizaba el mismo ID de respuesta en cada respuesta, así que el framework del agente trataba cada respuesta como un reemplazo de la anterior y terminaba los turnos antes de tiempo. Parecía un fallo de refresco de la interfaz. No lo era: era el arnés de pruebas mintiéndonos. También tuvimos que aplicar en el script el límite real de contexto del modelo, o las cifras de memoria no eran realistas.
Lo que aprendimos
- Las interfaces de streaming multiplican los errores pequeños. Cualquier cosa que envíes «por fragmento» se envía decenas de miles de veces. Envía deltas, o limita la carga.
- Acota cada búfer que cruza una frontera lenta. Sockets, registros de reproducción y puntos de control necesitan un techo.
- Reproduce antes de corregir. Nuestras dos primeras explicaciones sonaban bien y estaban equivocadas.
Si has visto aparecer hace poco un archivo muy largo en el chat de Mythex sin que el agente se detuviera, esta es una de las razones.