Bildlastige Agenten-Züge im Arbeitsspeicher halten
Ein Agenten-Zug in der Produktion ist bei 952 MB aus dem Arbeitsspeicher gelaufen, während er sechs Katalogscans nach Produkten zuschnitt und seine Ausschnitte ansah. Es gab nicht ein Leck, sondern drei. Jeder Modellaufruf sendete die Bilder des Zugs in voller Größe erneut. Die Bibliothek, über die wir das Modell ansprechen, hielt jede Anfrage bis zum Ende des Zugs am Leben. Und das Tracing des Agenten-Frameworks behielt die Eingaben jedes Aufrufs, Bilder eingeschlossen, für den ganzen Zug. Wir haben alle drei behoben: Bilder werden vor dem Senden verkleinert, und die Bilder eines Aufrufs werden freigegeben, sobald der Aufruf fertig ist.
So sieht der Agent Bilder
Auf Mythex arbeitet der Agent in der Cloud-Sandbox deines Projekts. Er kann Bilder ansehen – einen Screenshot, den du anhängst, einen Entwurf, eine Datei in deinem Projekt oder ein Bild, das er selbst gerade erzeugt hat –, indem er sie zusammen mit der Unterhaltung an das Modell schickt.
Ein Zug ist alles, was der Agent als Antwort auf eine Nachricht von dir tut. Innerhalb eines Zugs ruft der Agent das Modell viele Male auf: Er denkt nach, führt ein Tool aus, sieht sich das Ergebnis an, denkt weiter. Jeder dieser Aufrufe sendet die bisherige Unterhaltung, und darin sind die Bilder des Zugs enthalten, als Text kodiert (Base64, was sie etwa ein Drittel größer macht).
Der Zug, der abstürzte, tat etwas Vernünftiges. Er nahm sechs Katalogscans mit je etwa 2,4 MB, schnitt einzelne Produkte heraus und las seine Ausschnitte immer wieder, um sie zu prüfen. Das waren viele Modellaufrufe, und jeder trug mehrere große Bilder mit sich.
Ursache 1: Jeder Aufruf sendete Bilder in voller Größe erneut
Jeder Modellaufruf baute seinen Anfragekörper mit den Bildern des Zugs in voller Größe neu auf, rund 20 MB Base64 pro Aufruf. Der Körper wird als ein einziger String im Speicher des Agenten gebaut, sodass jeder Aufruf weitere etwa 20 MB belegte.
Die volle Größe brachte dabei nichts. Das Modell skaliert Bilder selbst auf etwa 1.300 Pixel an der langen Seite herunter. Die zusätzlichen Pixel kosteten nur Speicher und Upload-Zeit.
Die Lösung: einmal verkleinern, klein senden
- Bilder über 400 KB werden vor dem Senden in ein JPEG umgewandelt, das an der langen Seite höchstens 1.568 Pixel misst. Kleinere Bilder gehen unverändert.
- Die Umwandlung erfolgt mit ImageMagick, das in jeder Mythex-Sandbox vorhanden ist, und läuft einmal pro Datei. Ein Zug, der dieselben Scans bei jedem Aufruf zeigt, verwendet die verkleinerte Kopie wieder. Der Cache ist über Pfad, Größe und Änderungszeitpunkt der Datei geschlüsselt, sodass eine geänderte Datei neu umgewandelt wird. Er hat außerdem eine eigene Größenbegrenzung, damit er nicht selbst den Speicher füllt.
- Das Budget für Bilder in einem einzelnen Modellaufruf sinkt von 16 MB auf 6 MB.
Dieselben sechs Scans kosten jetzt 4,4 MB pro Aufruf statt 19,7 MB.
Ursache 2: Der Modellclient gab eine Anfrage nie frei
Die Größe jeder Anfrage war nur ein Teil des Problems. Der Speicherbedarf wuchs auch von Aufruf zu Aufruf. Auf unserer Staging-Umgebung hielt ein gleichartiger Zug 369 MB lebendigen Speicher, selbst nach einer vollständigen Garbage Collection, also dem Punkt, an dem alles Unerreichbare freigegeben ist. Die Heap-Analyse führte das gesamte Wachstum auf den Teil der Modellclient-Bibliothek zurück, der die Anfragekörper kodiert.
Die Ursache war ein Abbruch-Listener. Wenn du auf Stopp drückst, löst das Abort-Signal des Zugs aus, und alle zuhörenden Anfragen werden abgebrochen. Wir haben dieses zugweite Signal an jede Anfrage übergeben. Die Client-Bibliothek fügt dem übergebenen Signal einen „abort“-Listener hinzu und entfernt ihn nie wieder.
Dadurch blieb jede Anfrage eines Zugs über das Zug-Signal erreichbar, solange der Zug lief: der Listener, der den Controller hielt, der die Anfrage hielt, die den gesamten Anfragekörper mit seinen Bildern hielt.
Die Lösung: ein Signal pro Anfrage
Jede Anfrage bekommt jetzt ihr eigenes Signal. Es wird nur während der Laufzeit dieser Anfrage mit dem Zug-Signal verknüpft – bei einer gestreamten Antwort bis zum Lesen des letzten Chunks – und danach sofort wieder getrennt. Stopp erreicht weiterhin die gerade laufende Anfrage, aber das Zug-Signal hält immer nur einen Listener gleichzeitig.
Die Tests decken alle drei Teile ab:
- Nach 12 Aufrufen sind keine Listener mehr am Zug-Signal übrig. Vor der Änderung waren es 12.
- Bei 15 Anfragen mit je 8 MB bleibt der Speicher konstant. Vorher wuchs er um 128 MB.
- Stopp bricht eine Anfrage mitten im Stream weiterhin ab.
Ursache 3: Das Tracing behielt die Eingaben jedes Aufrufs
Eine Heap-Momentaufnahme zeigte einen dritten Halter: etwa 6 MB pro Modellaufruf. Der stammte aus dem Event-Streaming des Agenten-Frameworks. Es erfasst jeden Modellaufruf eines Zugs als Kind des Zugs, samt Eingaben, bis der Zug endet. Jedes Bild jedes Aufrufs blieb deshalb im Speicher.
Die Lösung: Bilder nach der Rückkehr des Aufrufs leeren
Bilder hängen nur an den Nachrichten, die für einen bestimmten Modellaufruf gebaut werden. Sie werden nie im gespeicherten Zustand der Unterhaltung abgelegt. Sobald ein Aufruf zurückkehrt, können die Bilddaten in diesen Nachrichten also sicher geleert werden. Das Tracing behält einen Textbericht des Aufrufs, die Pixel werden entfernt.
Wichtig war dabei: Die Bilder müssen direkt an Ort und Stelle geleert werden. Der Tracer kann dieselben Bilddaten über mehr als eine Referenz an einer Nachricht erreichen. Würde man eine neue, leere Liste einsetzen, bliebe das Original mit allen Pixeln erreichbar. Der gespeicherte Unterhaltungszustand wird nie verändert.
Ein Test führt den Graphen des Agenten unter demselben Event-Streaming aus, mit einem 4-MB-Bild bei jedem Aufruf. Ohne die Änderung wuchs der Speicher über 16 Aufrufe um 77 MB. Mit der Änderung bleibt er konstant.
Was wir daraus gelernt haben
- Speicherprobleme können schichtweise auftreten. Drei getrennte Ursachen hielten Bilder zu lange fest, und alle drei mussten weg, bevor der Zug im Speicherrahmen blieb.
- Alles, was du einer Bibliothek übergibst, kann behalten werden. Ein Signal und ein Trace überlebten die Aufrufe, denen sie übergeben wurden. Wenn der Speicher pro Aufruf wächst, schau nach, welche langlebigen Objekte der Aufruf berührt.
- Sende nicht, was die andere Seite ohnehin wegwirft. Pixel, die das Modell ohnehin verwirft, kosteten uns bei jedem Aufruf Speicher.
- Teste den Speicher direkt. Jede Korrektur kam mit einem Test, der das Wachstum des Heaps oder die Anzahl der Listener misst, sodass sich eine Regression als Zahl zeigt und nicht als Absturz.
Dies ist innerhalb kurzer Zeit die zweite Speichergeschichte rund um die Ausgabe des Agenten. Die erste war ein Fortschrittslabel, das 41.000 Mal erneut gesendet wurde. Wenn du wissen willst, was der Agent ist und wie er arbeitet, lies unseren Guide zu KI-Agenten und die Chat-Dokumentation.