← Все статьи

Почему Agent! сжимает контекст на половине окна и три бага, которые урезали порог до 16K

Как механизм сжатия контекста в Agent! решает, когда пора резюмировать длинную задачу, и какие исправления от 28 сентября вышли для резервных моделей, Ollama и окна Codex в 272K.

У длинных задач агента есть «физическая» проблема. Каждый вызов инструмента добавляет свой вывод в стенограмму: чтение файла, лог сборки, дифф. Рано или поздно стенограмма перестаёт помещаться в контекстное окно модели, и провайдер отклоняет запрос. Агент, работающий всю ночь, должен сжимать контекст. Он уменьшает диалог, сохраняя всё важное.

Сжатие реализовано в Agent/AgentViewModel/Messages/Compression.swift. 28 сентября за один вечер оно получило три исправления, и все они касались одного и того же симптома: задачи сжимались на 16K токенов на моделях с гораздо более крупными окнами. Ниже — как устроена система и что пошло не так.

Когда сжимать: половина окна, но с ограничением

Триггером служит структура CompactionState. Её ядро — одна-единственная функция:

static func threshold(for contextWindow: Int, maxTokens: Int = 0) -> Int {
    let byFraction = Int(Double(min(contextWindow, compactionWindowCap)) * compactionFraction)
    let reservedOutput = maxTokens > 0 ? min(maxTokens, contextWindow / 2) : 8_192
    return max(2_000, min(byFraction, contextWindow - reservedOutput))
}

При compactionFraction = 0.5 и compactionWindowCap = 256_000 это означает следующее:

  • Сжимать на 50% окна. Это процент, а не фиксированное число токенов. Вторая половина — бюджет на вывод, поэтому вход и выход всегда помещаются вместе.
  • Никогда не выше 128K. Окна, заявленные больше 256K (Claude 1M, MiniMax 1M, Gemini и Grok 2M), сжимаются на 128K. Окно в 200K сжимается на 100K.
  • Всегда оставлять место для ответа. Порог не может наступать так поздно, что зарезервированный вывод уже не помещается.
  • Никогда не ниже 2K — это нижняя граница для крошечных локальных моделей.

Зачем ограничивать окно в 2M значением 128K? Комментарий в коде говорит прямо. На огромных заявленных окнах процент без ограничения откладывает сжатие настолько, что любое расхождение на стороне провайдера превращается в жёсткое переполнение контекста вместо сжатия. Например, роутер может отдавать окно короче, чем сообщает, или системный промпт может быть подсчитан неверно. Раннее сжатие стоит одного резюме. Слишком позднее — стоит всей задачи.

Измерение: сначала доверяй провайдеру, потом оценивай

Чтобы сравнить с порогом, нужно количество токенов. Угадывать по символам — неточно, поэтому measuredTokens предпочитает истину: значение input_tokens, которое провайдер сообщил для последнего запроса. Это число учитывает системный промпт и схемы инструментов, которых локальная оценка не видит. Оцениваются только сообщения, добавленные после этого отчёта.

Если отчёта нет, используется классическая оценка «символы ÷ 4», увеличенная на 25%, потому что плотный код ближе к 3.3 символа на токен. Прежде чем тратиться на сжатие, основанное только на оценке, цикл перепроверяет результат с помощью счётчика на устройстве.

Как происходит сжатие: уровни

Когда порог срабатывает, tieredCompact проходит по нарастающим шагам:

  1. Уровень 0: структурированное резюме полной стенограммы, которое делает активная модель. Оно выполняется первым, чтобы модель-резюматор ещё видела вывод инструментов, который она резюмирует.
  2. Микросжатие: старые результаты инструментов превращаются в короткие восстанавливаемые заглушки. Инструмент restore_tool_result может вернуть любой из них.
  3. Удаление изображений: скриншоты огромны и плохо поддаются резюмированию.
  4. Уровень 1: резюмирование с помощью Apple Intelligence — быстро и прямо на устройстве.
  5. Уровень 2: агрессивная обрезка — сообщения из середины сворачиваются в резюме.

Один комментарий точно передаёт философию: структурное сжатие «это механизм безопасности, а не функция». Оно выполняется, даже если вы отключили Token Compression. Этот переключатель учитывает только уровень Apple Intelligence.

После сжатия модель получает обратно то, чего ей не хватало бы больше всего. Сюда входят незакрытые критерии цели, активный чек-лист плана и текущее содержимое до пяти файлов, которые она редактировала в ходе задачи (всего около 10K токенов). Кэш дедупликации чтений тоже сбрасывается, так что файл снова можно перечитать.

Наконец, есть автоматический предохранитель. После трёх сжатий подряд, которые не смогли уменьшить стенограмму, цикл прекращает попытки. Но сдаётся он не навсегда: как только стенограмма вырастет ещё на 25% относительно последней неудачной попытки, он пробует снова.

Баги: три дороги к 16K

Каждое исправление от 28 сентября приводило к одному и тому же числу. Резервное окно в 32K даёт min(16K, 32K − 8K) = 16K. Для модели с окном 200K+ сжатие на 16K означает почти непрерывное резюмирование и забывание файлов, которые модель только что прочитала.

1. Резервная модель брала чужое окно

Agent! поддерживает цепочку резервных провайдеров. Если провайдер возвращает 429, не отвечает по тайм-ауту или пропадает из сети, задача продолжается на следующем настроенном провайдере. Вкладки также могут переопределять модель. Но contextWindow(for:) искала окно для глобально выбранной модели провайдера, а не для той, что реально работала. Для резервной модели без записи она откатывалась к статическому размеру 32K.

Исправление добавляет модель в поиск:

func contextWindow(for provider: APIProvider, model: String? = nil) -> Int

Теперь каждое место вызова передаёт используемую модель: основной цикл, задачи вкладок, резервный путь и субагенты.

2. Ollama забывала то, что узнала

Локальные серверы сообщают реальное окно для каждой модели асинхронно: Ollama — через /api/show, LM Studio — через /api/v0/models, а vLLM — через /v1/models. Именно поэтому цикл вызывает refreshThreshold на каждой итерации после первой. Данные, пришедшие уже после старта задачи, всё равно вступают в силу.

Однако в Ollama полученные окна не сохранялись, поэтому сжатие продолжало по умолчанию срабатывать на 16K. Исправление сохраняет полученные контекстные окна и запрашивает их при старте задачи, если окно неизвестно. Оно вышло вместе с новым набором тестов OllamaContextWindowTests.swift.

3. Щедрый бюджет на вывод съел бюджет на ввод

Этот баг коварен. Codex сообщает окно в 272K для своей модели GPT-6 Astra. Пользователь устанавливает Max Output Tokens равным 256K. Старый код резервировал весь бюджет на вывод:

272K − 256K = 16K   →   threshold = min(128K, 16K) = 16K

Новый код резервирует под вывод не более половины окна:

let reservedOutput = maxTokens > 0 ? min(maxTokens, contextWindow / 2) : 8_192

Тот же случай теперь даёт min(128K, 272K − 136K) = 128K. Процентное ограничение по-прежнему использует ограниченное окно, но проверка того, помещается ли вывод, использует настоящее окно. Иначе бюджет вывода Claude по умолчанию в 500K на окне в 1M сделал бы window − maxTokens отрицательным и опустил бы порог до минимума в 2K.

Почему это важно для вас

Если вы запускаете длинные задачи — особенно ночные сессии программирования — на резервных провайдерах или локальных моделях, теперь они должны сохранять гораздо больше рабочего контекста. Это значит меньше циклов «мне нужно перечитать этот файл», меньше потерянных деталей и меньше токенов, потраченных на резюмирование. Эти исправления вышли 28 сентября вместе с версией 1.1.77 (сборка 277), релиз-кандидатом 6, наряду с кросс-провайдерным распознаванием ошибок переполнения контекста и max_tokens.

Урок для разработчиков агентов универсален: определяйте объём памяти по модели, которая реально работает, доверяйте подсчёту токенов провайдера больше, чем собственным оценкам, и ограничивайте каждый бюджет так, чтобы ни одна настройка не могла обделить остальные.