Нехватка памяти, спрятанная в подписи прогресса
Однажды вечером в конце сентября AI-агент в рабочем пространстве одного пользователя упал с ошибкой нехватки памяти посреди хода. Очевидные подозреваемые — слишком много субагентов, работающих одновременно, или слишком выросший контекст — оба оказались неверными. Настоящей причиной была строка статуса: пока модель передавала большую shell-команду по нескольку символов, мы при каждом фрагменте заново отправляли всю команду целиком как подпись прогресса. Одна команда размером 124 КБ превратилась примерно в 41 000 событий и около 690 МБ строк в очереди.
Как агент передаёт свою работу
Когда вы создаёте что-то в Mythex, агент работает внутри приватной облачной песочницы вашего проекта. Пока модель пишет, её вывод передаётся в ваш браузер: текст появляется слово за словом, а когда агент запускает инструмент — например, shell-команду, которая записывает файл, — вы видите подпись к нему, которая обновляется на лету, и понимаете, что он делает.
Модель передаёт данные маленькими кусками. С моделью, которую мы использовали, эти куски часто были всего три символа длиной.
Первые гипотезы оказались неверными
Падение выглядело как проблема масштабирования, поэтому мы начали с неё.
- Параллельные субагенты? В ходе использовалось только два, и каждый занимал около 1,5 МБ состояния. Недостаточно.
- Удерживаемые трассировки? Наша трассировка держала дерево запусков в памяти, но оно было далеко не такого размера, как куча в момент падения.
Оба объяснения были правдоподобны и одинаково легко принимались на веру. Ни одно не воспроизводило падение. Урок, который мы то и дело заново усваиваем: гипотеза — это ещё не причина, пока она не воспроизводит сбой.
Воспроизведение по-настоящему
Мы собрали одноразовую песочницу с теми же лимитами памяти, что в продакшене, и поддельную модель, которая передавала большой файл реалистичными кусками по три символа. Мы также сделали читателя на другом конце сокета медленным, как настоящая сеть.
Это сработало. Куча росла, и процесс упал с той же ошибкой.
Механизм стал простым, как только мы смогли за ним наблюдать. Пока модель писала большую команду — в данном случае heredoc, который записывал файл на 124 КБ, — наш код потоковой передачи обновлял подпись инструмента при каждом фрагменте. Подпись содержала не новые три символа, а всю команду целиком на данный момент. Поэтому события росли: 3 символа, потом 6, потом 9… до 124 КБ, примерно 41 000 раз. Сокет не успевал отправлять их так быстро, как они создавались, и они копились в его буфере записи в виде строк — около 690 МБ.
Исправление
- Ограничить подпись. Строка статуса должна лишь сообщать, что происходит. Теперь она ограничена 200 символами, а частоту повторного сканирования частичной команды мы ограничиваем.
- Убрать квадратичную конкатенацию. Склеивание переданных кусков повторной конкатенацией строк для больших выводов имеет сложность O(n²). Мы перешли на подход с чанками, который добавляет данные за линейное время.
После этого мы поискали другие места, которые могли бесконечно расти под потоком вывода, и ограничили их:
- Вывод команд в реальном времени, показываемый в чате, пакетируется и хранится только последние 8 000 символов.
- Отдельные строки событий ограничены 4 МБ.
- Буфер воспроизведения, который браузер использует, чтобы догнать события после переподключения, ограничен 8 МБ на проект.
- Старые чекпоинты обрезаются по ходу хода, сохраняя самое свежее состояние и опорные точки, которые нужны более ранним ходам.
Честное тестирование потоков
Две вещи сделали тестирование сложнее, чем должно было быть:
- Настоящие модели не сотрудничают. Попросите модель вывести огромный файл, и она обычно откажется, сократит или запишет его разумнее. Чтобы тестировать потоки, нужна скриптованная модель, которая делает ровно то, что вы задали, и работает через настоящий код агента.
- Скрипты должны вести себя как модели. Наша первая скриптованная модель использовала один и тот же ID ответа при каждом ответе, поэтому фреймворк агента воспринимал каждый ответ как замену предыдущего и завершал ходы раньше времени. Это выглядело как баг обновления интерфейса. Это был не он: тестовый стенд нас обманывал. Нам также пришлось задать в скрипте реальный лимит контекста модели, иначе цифры по памяти были нереалистичными.
Что мы из этого вынесли
- Потоковые интерфейсы умножают мелкие ошибки. Всё, что отправляется «на каждый кусок», уходит десятки тысяч раз. Отправляйте дельты или ограничивайте объём данных.
- Ограничивайте каждый буфер, который пересекает медленную границу. Сокеты, журналы воспроизведения и чекпоинты — всем нужен потолок.
- Воспроизводите, прежде чем чинить. Наши первые два объяснения звучали убедительно и были неверны.
Если в последнее время в чате Mythex вы видели, как появляется очень длинный файл, а агент при этом не зависает, — отчасти поэтому.