Skip to Content
ДизайнМонитор давления памяти

Монитор давления памяти

Проблема

Длительные сессии Qwen Code могут накапливать память из-за больших результатов выполнения инструментов, повторных чтений файлов, истории чата и нативных/внешних аллокаций. До этого изменения основной пакет содержал диагностику и очистку при сбросе сессии, но в нем отсутствовала реакция в рантайме на рост давления памяти во время обычного выполнения инструментов.

Главное упущение на уровне кэша связано с FileReadCache: у него уже есть ограниченный FIFO-размер, но не было механизма вытеснения по времени. Это означает, что сессия может хранить неактивные метаданные чтения файлов до тех пор, пока не будет достигнут жесткий лимит записей, даже если процесс испытывает нехватку памяти.

Цели

  • Добавить проверку давления памяти с низкими накладными расходами после выполнения инструмента.
  • Отдавать предпочтение точечной очистке перед деструктивной.
  • Учитывать лимиты памяти контейнера, если доступны файлы лимитов памяти cgroup v2 или cgroup v1.
  • Реагировать на давление на кучу V8 до наступления OOM кучи JavaScript на хостах с большим объемом памяти.
  • Сохранять изоляцию экземпляров Config для подагентов/областей видимости от очистки родительской сессии.
  • Сделать поведение настраиваемым через переменные окружения без добавления нового пользовательского интерфейса настроек.

Задачи, не входящие в скоуп (Non-Goals)

  • Не добавлять фоновый цикл опроса.
  • Не делать явный GC поведением по умолчанию; он запускается только при включении и если Node была запущена с флагом --expose-gc.
  • Не изменять семантику строгого предварительного чтения. Вытеснение из кэша может удалять старые метаданные, но не должно ослаблять проверки устаревших файлов для сохраненных записей.

Дизайн

Config.initialize() создает один MemoryPressureMonitor для каждой инициализированной Config. getMemoryPressureMonitor() повторяет существующий паттерн изоляции Object.create из getFileReadCache(): когда дочерняя конфигурация создается через делегирование прототипа, геттер лениво устанавливает собственный монитор, привязанный к этой дочерней конфигурации.

CoreToolScheduler.executeSingleToolCall() вызывает scheduleCheck() в своем блоке finally после завершения спана инструмента. scheduleCheck() объединяет множественные вызовы в одном проходе цикла событий с помощью queueMicrotask, поэтому параллельные пакеты инструментов чтения не запускают отдельную проверку памяти для каждого результата.

Монитор использует более сильный из двух сигналов давления:

  • RSS, деленный на эффективный лимит памяти процесса. Предпочитать cgroup v2 /sys/fs/cgroup/memory.max, если это конечное положительное значение; иначе использовать cgroup v1 /sys/fs/cgroup/memory/memory.limit_in_bytes, а затем os.totalmem(). Огромные сигнальные значения «unlimited» из cgroup v1 игнорируются.
  • V8 heapUsed, деленный на getHeapStatistics().heap_size_limit.

Использование обоих сигналов важно, поскольку контейнеры обычно завершаются из-за лимита RSS/cgroup, в то время как локальные машины с большим объемом памяти могут столкнуться с OOM кучи V8 задолго до того, как RSS станет большой долей общей системной памяти.

Пороговые значения по умолчанию намеренно сделаны достаточно консервативными, чтобы реагировать до того, как сработает OOM-киллер ОС или контейнера:

  • softPressureRatio = 0.50
  • hardPressureRatio = 0.65
  • criticalRatio = 0.80
  • cleanupCooldownMs = 5000
  • enableExplicitGC = false

Переопределения через переменные окружения:

  • QWEN_MEMORY_PRESSURE_SOFT
  • QWEN_MEMORY_PRESSURE_HARD
  • QWEN_MEMORY_PRESSURE_CRITICAL
  • QWEN_MEMORY_ENABLE_GC=1

Некорректные коэффициенты откатываются к значениям по умолчанию. Корректные коэффициенты должны быть упорядочены как soft < hard < critical, с нижней границей soft 0.3 и верхней границей critical 0.98. Переменные окружения для коэффициентов строго парсятся с помощью Number(), поэтому значения вроде 0.8extra отклоняются, а не принимаются частично. Некорректная конфигурация давления памяти в переменных окружения записывает видимое предупреждение в stderr и в лог отладки перед откатом к значениям по умолчанию.

Политика очистки

Уровни давления соответствуют все более агрессивной очистке:

  • soft: вытеснять устаревшие записи FileReadCache, к которым не было обращений в течение 60 минут.
  • hard: вытеснять записи кэша, к которым не было обращений в течение 30 минут.
  • critical: очищать кэш чтения файлов и опционально запускать global.gc().

Монитор намеренно не инициирует принудительное сжатие чата. Сжатие может вызывать бэкенд модели и переписывать активное состояние чата, поэтому его следует запускать только из места вызова, которое может безопасно координироваться с циклом диалога.

Очистка выполняется из планировщика по принципу fire-and-forget, но монитор защищает шаги очистки с помощью cleanupInProgress и временной метки кулдауна. Очистка при более высоком давлении может обойти кулдаун и встать в очередь после выполняющейся очистки при более низком давлении, чтобы проверка critical не потерялась, пока завершается очистка soft. После успешной очистки в setImmediate() логируется дельта RSS, но изменение RSS носит исключительно диагностический характер: V8 и libc могут удерживать освобожденные страницы, даже если JavaScript-объекты стали доступны для сборщика мусора. Последовательные сбои учитывают исключения на шагах очистки, а не неизменный RSS; счетчик сбрасывается при начале новой сессии. Если три успешные попытки очистки подряд освобождают менее 1% RSS, монитор генерирует событие memory-cleanup-ineffective как диагностический сигнал, не считая сам шаг очистки неудачным.

Покрытие тестами

Реализация покрыта следующими тестами:

  • тестами валидации пороговых значений;
  • тестами парсинга конфигурации окружения, отката, видимых предупреждений и явного GC;
  • тестами классификации давления с использованием замоканного process.memoryUsage();
  • поведением cgroup v2 memory.max и cgroup v1 memory.limit_in_bytes;
  • поведением лимита кучи V8;
  • объединением вызовов scheduleCheck();
  • интеграцией с планировщиком, вызывающим scheduleCheck() после выполнения инструмента;
  • действиями очистки soft и critical;
  • учетом сбоев очистки для шагов, выбрасывающих исключения;
  • изоляцией исключений слушателя очистки и диагностикой неэффективной очистки;
  • изоляцией монитора дочерней Config через Object.create;
  • поведением FileReadCache.evictNotAccessedSince().

Риски и компромиссы

  • RSS может оставаться неизменным после очистки, так как V8 или libc могут удерживать освобожденную память. Дельты RSS логируются, но неизменный RSS не считается сбоем очистки.
  • Вытеснение из кэша чтения файлов по времени может снизить количество попаданий по fast-path для старых файлов, но при этом сохраняются недавно активные записи, а сама очистка запускается только при нехватке памяти.
Last updated on