Как уложить ходы агента с большим количеством изображений в память
В продакшене ход агента закончился нехваткой памяти на отметке 952 МБ, когда он вырезал товары из шести сканов каталога и рассматривал свои вырезки. Утечка была не одна — их было три. Каждый вызов модели заново отправлял изображения хода в полном размере; библиотека, которой мы вызываем модель, держала каждый запрос живым до конца хода; а трассировка фреймворка агента сохраняла входные данные каждого вызова, включая изображения, на весь ход. Мы исправили все три: изображения уменьшаются перед отправкой, а изображения каждого вызова освобождаются сразу после его завершения.
Как агент видит изображения
Когда вы создаёте что-то на Mythex, агент работает внутри облачной песочницы вашего проекта. Он может смотреть на изображения — прикреплённый вами скриншот, макет, файл в проекте или картинку, которую он только что создал сам, — отправляя их модели вместе с беседой.
Ход — это всё, что агент делает в ответ на одно ваше сообщение. Внутри хода агент много раз обращается к модели: думает, запускает инструмент, смотрит на результат, снова думает. Каждый такой вызов отправляет беседу до этого момента, включая изображения хода, закодированные в текст (base64, что делает их примерно на треть больше).
Ход, который упал, делал разумную вещь. Он взял шесть сканов каталога примерно по 2,4 МБ каждый, вырезал из них отдельные товары и снова и снова читал свои вырезки, чтобы проверить их. Это множество вызовов модели, каждый из которых нёс несколько больших изображений.
Причина 1: каждый вызов заново отправлял изображения в полном размере
Каждый вызов модели заново собирал тело запроса с изображениями хода в полном размере — около 20 МБ base64 на вызов. Тело строится как одна строка в памяти агента, поэтому каждый вызов выделял ещё около 20 МБ.
Отправка в полном размере ничего не давала. Модель сама уменьшает изображения примерно до 1 300 пикселей по длинной стороне, так что лишние пиксели тратили только память и время загрузки.
Исправление: уменьшить один раз, отправлять маленькое
- Изображения больше 400 КБ теперь конвертируются в JPEG размером не больше 1 568 пикселей по длинной стороне перед отправкой. Меньшие изображения уходят как есть.
- Конвертация использует ImageMagick, который входит в каждую песочницу Mythex, и выполняется один раз на файл. Ход, который показывает одни и те же сканы в каждом вызове, использует уже уменьшенную копию. Кэш привязан к пути файла, размеру и времени изменения, поэтому изменённый файл конвертируется заново, и у кэша есть собственный лимит размера, чтобы он сам не заполнил память.
- Бюджет изображений в одном вызове модели снижен с 16 МБ до 6 МБ.
Те же шесть сканов теперь стоят 4,4 МБ на вызов вместо 19,7 МБ.
Причина 2: клиент модели никогда не отпускал запрос
Дело было не только в размере каждого запроса. Память также росла от вызова к вызову. На нашем стейджинге ход такого же типа удерживал 369 МБ живой памяти даже после полной сборки мусора — точки, где всё недоступное освобождено. Выборка кучи показала, что весь рост приходился на часть библиотеки клиента модели, которая кодирует тела запросов.
Причиной был слушатель прерывания. Когда вы нажимаете «Стоп», срабатывает сигнал прерывания хода, и все запросы, которые его слушают, отменяются. Мы передавали этот сигнал, общий для всего хода, каждому запросу. Клиентская библиотека добавляет слушатель «abort» к любому переданному ей сигналу и никогда его не убирает.
Поэтому каждый запрос хода оставался достижимым из сигнала хода на всё время его работы: слушатель держал контроллер, контроллер держал запрос, а запрос держал всё тело запроса с изображениями.
Исправление: один сигнал на запрос
Теперь у каждого запроса свой сигнал. Он связывается с сигналом хода только на время работы этого запроса — для потокового ответа до чтения последнего фрагмента — и сразу после этого отвязывается. «Стоп» по-прежнему доходит до любого запроса в полёте, но сигнал хода в каждый момент держит только один слушатель.
Тесты покрывают все три части:
- После 12 вызовов на сигнале хода не остаётся слушателей. До исправления их было 12.
- На 15 запросах по 8 МБ каждый память остаётся ровной. До исправления она росла на 128 МБ.
- «Стоп» по-прежнему прерывает запрос на середине потока.
Причина 3: трассировка сохраняла входные данные каждого вызова
Снимок кучи показал третьего держателя: около 6 МБ на каждый вызов модели. Это было из потоковой передачи событий фреймворка агента. Она записывает каждый вызов модели в ходе как дочерний элемент хода, включая входные данные, до окончания хода. Каждое изображение, прикреплённое к каждому вызову, оставалось в памяти вместе с ним.
Исправление: очистить изображения сразу после возврата вызова
Изображения прикрепляются только к сообщениям, собранным для конкретного вызова модели. В сохранённое состояние беседы они никогда не попадают. Поэтому, когда вызов возвращает результат, можно безопасно очистить данные изображений в этих сообщениях. Трассировка хранит текстовую запись вызова, а пиксели уходят.
Важна одна деталь: изображения нужно очищать на месте. Трассировщик может добраться до тех же данных изображения по нескольким ссылкам в сообщении, поэтому подмена на новый пустой список оставила бы оригинал, вместе с пикселями, всё ещё доступным. Сохранённое состояние беседы никогда не изменяется.
Тест запускает граф агента под той же потоковой передачей событий с изображением в 4 МБ на каждом вызове. Без исправления память росла на 77 МБ за 16 вызовов. С исправлением она остаётся ровной.
Что мы из этого вынесли
- Проблемы с памятью могут идти слоями. Три отдельные причины держали изображения слишком долго, и все три пришлось устранить, прежде чем ход уложился в память.
- Всё, что вы передаёте библиотеке, может сохраняться. И сигнал, и трассировка переживали вызовы, для которых были созданы. Если память растёт на каждый вызов, смотрите, какие долгоживущие объекты трогает вызов.
- Не отправляйте то, что другая сторона всё равно выбрасывает. Пиксели, которые модель всё равно отбросила бы, стоили нам памяти при каждом вызове.
- Тестируйте память напрямую. У каждого исправления есть тест, который измеряет рост кучи или количество слушателей, поэтому регрессия видна как число, а не как падение.
Это вторая история о памяти у агента за короткое время; первой была подпись прогресса, которую переотправляли 41 000 раз. Если хотите узнать больше о том, что такое агент и как он работает, см. наше руководство по ИИ-агентам и документацию по чату.