Il crash per esaurimento di memoria nascosto in un'etichetta di avanzamento
Una sera di fine settembre, l'agente IA nello spazio di lavoro di un utente è andato in crash con un errore di esaurimento della memoria a metà di un turno. I sospetti più ovvi, troppi sotto-agenti in esecuzione contemporaneamente oppure un contesto cresciuto troppo, erano entrambi sbagliati. La causa reale era un'etichetta di stato: mentre il modello trasmetteva un lungo comando shell a pochi caratteri alla volta, ri-inviavamo l'intero comando fino a quel momento come etichetta di avanzamento a ogni pezzo. Un comando da 124 KB è diventato circa 41.000 eventi e circa 690 MB di stringhe in coda.
Come l'agente trasmette il suo lavoro
Quando costruisci su Mythex, l'agente gira nella sandbox cloud privata del tuo progetto. Mentre il modello scrive, il suo output viene trasmesso al tuo browser: il testo appare parola per parola, e quando l'agente esegue uno strumento, per esempio un comando shell che scrive un file, vedi un'etichetta che si aggiorna in tempo reale, così sai cosa sta facendo.
Il modello trasmette in piccoli pezzi. Con il modello che usavamo, quei pezzi erano spesso lunghi appena tre caratteri.
Le prime ipotesi erano sbagliate
Il crash sembrava un problema di scala, quindi abbiamo cominciato da lì.
- Sotto-agenti in parallelo? Il turno ne aveva usati solo due, ciascuno con circa 1,5 MB di stato. Non abbastanza.
- Tracce conservate? Il nostro sistema di tracciamento teneva un albero delle esecuzioni in memoria, ma non era neanche lontanamente delle dimensioni dell'heap al momento del crash.
Entrambe erano storie plausibili, ed entrambe costavano poco a crederci. Nessuna riproduceva il crash. La lezione che continuiamo a reimparare: un'ipotesi non è una causa finché non riproduce il guasto.
Riprodurlo davvero
Abbiamo costruito una sandbox usa e getta con gli stessi limiti di memoria della produzione e un modello finto che trasmetteva un file grande a pezzi realistici da tre caratteri. Abbiamo anche rallentato il lettore dall'altra parte del socket, come una rete vera.
Ha funzionato. L'heap è cresciuto e il processo è morto con lo stesso errore.
Il meccanismo era semplice una volta che potevamo osservarlo. Mentre il modello scriveva un comando lungo, in questo caso un heredoc che scriveva un file da 124 KB, il nostro codice di streaming aggiornava l'etichetta dello strumento a ogni pezzo. L'etichetta non era i tre nuovi caratteri; era l'intero comando fino a quel punto. Così gli eventi crescevano: 3 caratteri, poi 6, poi 9, … fino a 124 KB, circa 41.000 volte. Il socket non riusciva a inviarli alla stessa velocità con cui venivano prodotti, quindi si accumulavano nel suo buffer di scrittura come stringhe, circa 690 MB.
La soluzione
- Limita l'etichetta. Un'etichetta di stato deve solo dire cosa sta succedendo. Ora è limitata a 200 caratteri, e riduciamo la frequenza con cui ri-analizziamo il comando parziale.
- Elimina la concatenazione quadratica. Unire i pezzi trasmessi con concatenazioni ripetute di stringhe è O(n²) per output grandi. Siamo passati a un approccio a blocchi che aggiunge in tempo lineare.
Poi abbiamo cercato qualsiasi altra cosa che potesse crescere senza limite con un flusso di output enorme, e l'abbiamo limitata:
- L'output dei comandi in tempo reale mostrato in chat viene raggruppato e limitato agli ultimi 8.000 caratteri.
- Le singole righe di evento sono limitate a 4 MB.
- Il buffer di replay che un browser usa per recuperare dopo una riconnessione è limitato a 8 MB per progetto.
- I vecchi checkpoint vengono potati man mano che il turno procede, mantenendo lo stato più recente e i punti di ancoraggio di cui hanno bisogno i turni precedenti.
Testare le inondazioni in modo onesto
Due cose hanno reso il test più difficile di quanto dovesse essere:
- I modelli veri non collaborano. Chiedi a un modello di stampare un file enorme e di solito si rifiuta, riassume o lo scrive in modo più sensato. Per testare le inondazioni serve un modello scriptato che faccia esattamente ciò che gli dici, eseguito attraverso il vero codice dell'agente.
- Gli script devono comportarsi come i modelli. Il nostro primo modello scriptato riusava lo stesso ID di risposta a ogni risposta, quindi il framework dell'agente considerava ogni risposta una sostituzione della precedente e chiudeva i turni in anticipo. Sembrava un bug di aggiornamento dell'interfaccia. Non lo era: era l'harness di test che ci mentiva. Abbiamo anche dovuto far rispettare nello script il vero limite di contesto del modello, altrimenti i numeri di memoria non erano realistici.
Cosa ci siamo portati a casa
- Le interfacce in streaming moltiplicano i piccoli errori. Tutto ciò che invii «per pezzo» viene inviato decine di migliaia di volte. Invia i delta, oppure limita il carico.
- Limita ogni buffer che attraversa un confine lento. Socket, log di replay e checkpoint hanno tutti bisogno di un tetto.
- Riproduci prima di correggere. Le nostre prime due spiegazioni suonavano giuste ed erano sbagliate.
Se di recente hai visto apparire nella chat di Mythex un file molto lungo senza che l'agente si bloccasse, è in parte per questo.