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

Исправление очистки изображений при компактификации + оценка токенов

Постановка проблемы

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

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

  2. Оценка токенов в 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.ts
  • packages/core/src/services/compactionInputSlimming.test.ts

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

  • packages/core/src/config/config.ts — расширение ChatCompressionSettings
  • packages/core/src/services/chatCompressionService.ts — вызов slimming перед runSideQuery; замена счётчика символов; однократное вычисление charCounts для splitter + guard
  • packages/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

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

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