Skip to Content
ДизайнCompaction Image StrippingУдаление изображений при сжатии + исправление оценки токенов

Удаление изображений при сжатии + исправление оценки токенов

Описание проблемы

Когда ChatCompressionService срабатывает (автоматически или вручную), он отправляет historyToCompress в модель для создания сводки без изменений. Две связанные проблемы снижают качество, точность и увеличивают стоимость:

  1. Данные встроенных изображений/документов попадают в промпт для сводки. MCP-инструменты, которые подгружают вложения (скриншоты, макеты дизайна, PDF), размещают части inlineData непосредственно в разговоре. Конвейер сжатия не удаляет их, поэтому модель для сводки получает необработанный base64, который она обычно не может интерпретировать, а нагрузка side-запроса бессмысленно раздувается.

  2. Оценка токенов в findCompressSplitPoint неверна для бинарных частей. Алгоритм разделения использует JSON.stringify(content).length для распределения символов по истории. Одно изображение размером 1 МБ в base64 (~1,4 млн символов) заставляет одну запись выглядеть как ~350 000 токенов, затмевая реальный текст и смещая точку разделения в неверное место. Реальная стоимость токенов для изображения Qwen-VL составляет не более нескольких тысяч токенов. Оценщик должен рассматривать бинарные части как малую константу.

claude-code решает (1) с помощью stripImagesFromMessages. В qwen-code нет ни этого удаления, ни соответствующего исправления подсчёта символов.

Это изменение добавляет и то, и другое, ограниченное только входными данными для side-запроса при сжатии. Текущая история разговора, персистентность (chats/<sessionId>.jsonl) и промпт, отправляемый основной модели при следующем обороте, не затрагиваются. Упрощение применяется только к нагрузке side-запроса, формируемой внутри chatCompressionService.

Вне рамок (отложено или отклонено)

  • Вынесение большого объёма вставленного текста в кэш paste. В более раннем черновике этого дизайна предлагалось хешировать слишком большой текст в ~/.qwen/paste-cache/<sha>.txt и заменять его на плейсхолдер. Мы отклонили это после изучения релизов claude-code с марта по май 2026 года: направление upstream — оставлять пользовательский ввод видимым модели и амортизировать затраты через кэширование промптов (настройка TTL на 1 час, уменьшение размера изображений), а не выносить его наружу. Помещение дословного пользовательского ввода за плейсхолдер-хеш рискует «дрейфом намерения», когда сжатие уже удалило исходный текст. Если мы вернёмся к этому позже, правильным шаблоном будет read_paste(hash) как полноценный инструмент, к которому модель может обратиться, а не неявная замена.

Текущее состояние vs цель

Аспектqwen-code сегодняclaude-code (справочно)Цель после этого изменения
Изображение/документ в промпте для сжатияОтправляются без измененийstripImagesFromMessages заменяет на [image] / [document]Отправляются как плейсхолдер [image: mime] / [document: mime]
Оценка токенов для бинарных частейJSON.stringify().length (сильное отклонение)Фиксированный бюджетНастраиваемая константа (по умолчанию 1 600 токенов / ~6 400 символов)
Очистка изображений при микрокомпактированииНе затрагивается (очищаются только результаты текстовых инструментов в простое)Временной MC очищает всёМикрокомпактирование также очищает устаревшие встроенные изображения вместе с результатами инструментов

Предлагаемые изменения

Уровень 1: Упрощение входных данных для сжатия (services/compactionInputSlimming.ts)

Новый чистый модуль, который принимает Content[] и возвращает упрощённый Content[]. Одно преобразование: удаление встроенных медиа. Обход всех Part. Если часть содержит inlineData или fileData, она заменяется текстовой частью вида [image: image/png] (или [document: application/pdf]).

qwen-code присоединяет медиа, возвращённые инструментом, к functionResponse.parts (расширение по сравнению со стандартной схемой FunctionResponse из @google/genai; см. coreToolScheduler.createFunctionResponsePart). Упрощатель рекурсивно обходит этот вложенный массив, так что изображение в base64, возвращённое read_file или любым MCP-инструментом, создающим вложения, также заменяется.

Преобразование возвращает новый массив Content[]; исходный никогда не изменяется. Если преобразование не приводит к изменениям, возвращается ссылка на исходный массив (идентичное равенство). Оркестратор вызывает slimCompactionInput последним шагом перед runSideQuery в chatCompressionService.ts.

Уровень 2: Исправление оценки токенов (chatCompressionService.ts)

findCompressSplitPoint сейчас использует JSON.stringify(content).length для распределения количества символов. Заменить этот вызов на вспомогательный метод estimateContentChars, который:

  • Для text частей: text.length
  • Для частей inlineData / fileData: imageTokenEstimate * 4 (по умолчанию 1 600 × 4 = 6 400 символов).
  • Для частей functionCall / functionResponse: JSON.stringify(part).length (поведение не изменяется).

Это та же константа, которую использует модуль упрощения, поэтому бюджет, который видит алгоритм разделения, соответствует тому, что фактически потребляет упрощённый промпт ниже по конвейеру. Чтобы избежать двойного обхода, compress() предварительно вычисляет charCounts один раз и передаёт их в findCompressSplitPoint (новый необязательный 4-й аргумент); этот же массив повторно используется для проверки MIN_COMPRESSION_FRACTION.

Уровень 3: Очистка изображений при микрокомпактировании (microcompaction/microcompact.ts)

collectCompactablePartRefs теперь возвращает три группы:

  • tool — части functionResponse от компактируемых встроенных инструментов. Очищаются как единое целое: вывод ответа заменяется на сторожевой маркер, functionResponse.parts удаляются вместе с ним.
  • media — части верхнего уровня inlineData / fileData в сообщениях пользовательской роли (например, изображения, вставленные через @reference). Заменяются на [Old inline media cleared: <mime>].
  • nested-media — части functionResponse от некомпактируемых инструментов (например, MCP-инструменты для скриншотов, чьи имена не входят в COMPACTABLE_TOOLS), которые содержат изображения / документы в расширенном поле functionResponse.parts. Удаляются только вложенные медиа; текстовый вывод инструмента сохраняется.

У каждого типа свой бюджет keepRecent. Установка toolResultsNumToKeep: 1 оставляет по одному самому свежему из каждой категории (1 инструмент + 1 медиа + 1 вложенное медиа), а не одну запись в сумме по всему объединённому списку.

Значения mimeType, полученные от MCP-серверов инструментов, пропускаются через sanitizeMimeForPlaceholder перед встраиванием в любую строку плейсхолдера. Упрощатель и микрокомпактирование используют этот вспомогательный метод совместно.

Уровень 4: Конфигурация (config/config.ts)

Одно новое поле в настройках chatCompression:

{ "chatCompression": { "contextPercentageThreshold": 0.7, "imageTokenEstimate": 1600 } }

Плюс переопределение через переменную окружения для эксплуатации/отладки: QWEN_IMAGE_TOKEN_ESTIMATE.

Ключевые проектные решения

Решение 1: imageTokenEstimate = 1600. Семейство Qwen-VL ограничено до 1 280 визуальных токенов на изображение без vl_high_resolution_images; с этим флагом — до 16 384. 1 600 — консервативная середина с небольшим завышением — завышение приводит к более раннему сжатию (безопасно), занижение — к позднему сжатию (небезопасно). Для не-VL моделей (Qwen3-Coder, используемый по умолчанию в qwen-code) эта константа важна только для корректности оценки токенов, поскольку изображения всё равно не попадают в модель.

Решение 2: Упрощать копию, а не живую историю. slimCompactionInput возвращает новый массив; история чата, хранящаяся в GeminiChat, не изменяется. Локальная персистентность (.chats/<sessionId>.jsonl) сохраняет полный разговор таким, каким его видел пользователь, так что --resume работает без потерь.

Решение 3: Микрокомпактирование обрабатывает изображения единообразно с устаревшими результатами инструментов. Временной триггер простоя уже очищает устаревший вывод инструментов; расширение его на встроенные изображения делает политику согласованной и повторно использует существующее окно keepRecent.

Решение 4: Без хранилища paste / без вынесения текста. См. раздел «Вне рамок». Консенсус upstream (claude-code март → май 2026) состоит в том, чтобы оставлять дословный пользовательский ввод видимым и амортизировать через кэширование промптов, а не выносить его наружу.

Затрагиваемые файлы

Новые файлы

  • packages/core/src/services/compactionInputSlimming.ts
  • packages/core/src/services/compactionInputSlimming.test.ts

Изменённые файлы

  • packages/core/src/config/config.ts — расширение ChatCompressionSettings
  • packages/core/src/services/chatCompressionService.ts — вызов упрощения перед runSideQuery; замена вспомогательного метода подсчёта символов; предварительное вычисление charCounts один раз для разделителя + проверки
  • packages/core/src/services/chatCompressionService.test.ts — добавление интеграционного теста, проверяющего, что base64 никогда не достигает модели для сводки
  • packages/core/src/services/microcompaction/microcompact.ts — расширение сбора на встроенные изображения
  • packages/core/src/services/microcompaction/microcompact.test.ts — тестирование очистки изображений

Границы рамок

В рамках

  • Удаление встроенных медиа из входных данных для сжатия
  • Исправление оценки символов в findCompressSplitPoint
  • Очистка частей изображений при микрокомпактировании по триггеру простоя
  • Одна настройка + переопределение через переменную окружения

Отложено

  • Вынесение большого объёма вставленного текста (см. раздел «Вне рамок» выше)
  • Инструмент для восстановления (read_paste(hash) и т.п.)
  • Дедупликация на уровне персистентности
  • Разбор вставленного текста в /context
  • События телеметрии для статистики упрощения

Открытые вопросы

  1. Должен ли текст плейсхолдера включать хеш для возможного будущего восстановления? Сегодня мы выводим просто [image: image/png]. Если/когда появится инструмент типа read_paste, может потребоваться идентификатор. Пока плейсхолдер носит информационный характер; оригинальное изображение всё ещё существует в живой истории и персистентности.
  2. Корректно ли imageTokenEstimate = 1600 для не-Qwen-VL моделей, обслуживаемых через прокси Anthropic / OpenAI? Вероятно, незначительное занижение для Claude (где изображения могут весить до ~5 000 токенов), но это безвредно: константа влияет только на эвристику точки разделения, никогда — на фактический промпт, который видит пользовательская модель.
  3. Проверка MIN_COMPRESSION_FRACTION вычисляется на количестве символов до упрощения. Срез, насыщенный изображениями, может пройти порог в 5% (потому что изображения считаются как ~6 400 символов каждое в оценщике) и затем сжаться до плейсхолдеров [image: …] после упрощения. Модель для сводки тогда получает почти никакого текстового контекста. Это намеренное поведение на данный момент: задача сводки — зафиксировать «пользователь поделился изображением X», даже если большая часть среза была визуальной, а назначение проверки — «достаточно ли содержимого, чтобы стоило суммировать» — изображения разумно выполняют. Если качество ухудшится, мы сможем вернуться к этому, либо повторно проверив после упрощения, либо сместив проверку на долю imagesStripped.
Last updated on