Исправление очистки изображений при компактификации + оценка токенов
Постановка проблемы
Когда ChatCompressionService срабатывает (автоматически или вручную), он отправляет
historyToCompress суммарной модели дословно. Две связанные проблемы
ухудшают качество, точность и стоимость:
-
Внедрённые байты изображений/документов просачиваются в промпт свёртки. Инструменты MCP, которые возвращают вложения (скриншоты, макеты дизайна, PDF), помещают части
inlineDataнепосредственно в диалог. Конвейер сжатия не удаляет их, поэтому суммарная модель получает сырой base64, который она обычно не может интерпретировать, а полезная нагрузка бокового запроса неоправданно раздувается. -
Оценка токенов в
findCompressSplitPointневерна для двоичных частей. Алгоритм точки разделения используетJSON.stringify(content).lengthдля распределения символов по истории. Одно изображение размером 1 МБ в base64 (~1,4 млн символов) делает одну запись похожей на ~350 тысяч токенов, что затмевает фактический текст и смещает точку разреза в неверное место. Реальная стоимость токенов для изображения Qwen-VL составляет не более нескольких тысяч токенов. Оценщик должен обрабатывать двоичные части как небольшую константу.
claude-code решает (1) с помощью stripImagesFromMessages. qwen-code не
имеет ни этой функции очистки, ни соответствующего исправления подсчёта
символов.
Это изменение добавляет и то, и другое, ограничиваясь только входными
данными бокового запроса компактификации. Живая история диалога,
постоянное хранилище (chats/<sessionId>.jsonl) и промпт, отправляемый
основной модели на следующем ходу, остаются нетронутыми. Упрощение
применяется только к полезной нагрузке бокового запроса, создаваемой
внутри chatCompressionService.
Вне области действия (отложено или отклонено)
- Вынос больших вставок в кеш вставок. В более раннем черновике
этого дизайна предлагалось хешировать текст большого размера в
~/.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: очистка изображений в microcompact (microcompaction/microcompact.ts)
collectCompactablePartRefs теперь возвращает три группы:
tool— частиfunctionResponseот уплотняемых встроенных инструментов. Очищаются как единое целое: вывод ответа заменяется на sentinel,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 перед встраиванием в любую строку-заполнитель.
Функции slimmer и microcompact используют этот вспомогательный метод совместно.
Уровень 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: Microcompact обрабатывает изображения единообразно со старыми результатами инструментов. Временной триггер простоя уже очищает устаревший вывод инструментов; расширение его на встроенные изображения сохраняет политику согласованной и повторно использует существующее окно keepRecent.
Решение 4: Нет хранилища вставок / нет вынесения текста. См. раздел «За рамками». Консенсус сообщества (claude-code 2026-03 → 2026-05) – сохранять дословный пользовательский ввод видимым и амортизировать через кэширование промптов, а не выносить его.
Затронутые файлы
Новые файлы
packages/core/src/services/compactionInputSlimming.tspackages/core/src/services/compactionInputSlimming.test.ts
Изменённые файлы
packages/core/src/config/config.ts— расширениеChatCompressionSettingspackages/core/src/services/chatCompressionService.ts— вызов slimming передrunSideQuery; замена счётчика символов; однократное вычисление charCounts для splitter + guardpackages/core/src/services/chatCompressionService.test.ts— добавление теста интеграции, проверяющего, что base64 никогда не достигает модели суммаризацииpackages/core/src/services/microcompaction/microcompact.ts— расширение сбора до встроенных изображенийpackages/core/src/services/microcompaction/microcompact.test.ts— тест очистки изображений
Границы области
Входит в область
- Удаление встроенного медиа из входных данных сжатия
- Исправление оценки символов в
findCompressSplitPoint - Очистка частей изображений в microcompact по триггеру простоя
- Одна настройка + переопределение через переменную окружения
Отложено
- Вынесение больших вставок (см. «За рамками» выше)
- Инструмент для восстановления (
read_paste(hash)и т.д.) - Дедупликация на уровне сохранения
- Разбивка вставок в
/context - События телеметрии для статистики slimming
Открытые вопросы
- Должен ли текст-заполнитель включать хеш для возможного восстановления
в будущем? Сейчас мы выводим просто
[image: image/png]. Если/когда появится инструмент типаread_paste, может потребоваться идентификатор. Пока заполнитель носит информационный характер; оригинальное изображение всё ещё существует в живой истории и в сохранённых данных. imageTokenEstimate = 1600корректен для не-Qwen-VL моделей, обслуживаемых через прокси Anthropic / OpenAI? Вероятно, небольшая недооценка для Claude (где изображения могут быть до ~5K токенов), но это безвредно: константа влияет только на эвристику точки разделения, но никогда на фактический промпт, который видит модель пользователя.- Порог
MIN_COMPRESSION_FRACTIONвычисляется на основе количества символов до slimming. Срез с большим количеством изображений может пройти порог в 5% (потому что изображения считаются как ~6 400 символов каждое в оценщике), а затем сжаться до заполнителей вида[image: …]после slimming. Модель суммаризации получает тогда почти никакого текстового контекста. Это намеренно на данный момент: задача суммаризации – записать «пользователь поделился изображением X», даже когда большая часть среза была визуальной, а цель порога – «достаточно ли содержимого, чтобы стоило суммаризировать» – изображения вполне её удовлетворяют. Если качество ухудшится, мы сможем пересмотреть, либо повторно проверяя после slimming, либо смещая порог в зависимости от долиimagesStripped.