Сжатие контекста и кэширование

Hermes Agent использует двухуровневую систему сжатия и промпт-кэширование Anthropic, чтобы эффективно управлять окном контекста в длинных диалогах.

Исходные файлы: agent/context_engine.py (ABC), agent/context_compressor.py (движок по умолчанию), agent/prompt_caching.py, gateway/run_turn.py (гигиена сессий), agent/compression_facade.py (ищите _compress_context)

Кэш окна контекста Bedrock

Разрешение контекста Bedrock в agent/model_metadata.py идёт в таком порядке приоритета:

  • Явные переопределения главнее всего. Заданная в конфигурации длина контекста важнее кэша, проб и статической таблицы.
  • Лимиты, подтверждённые провайдером, сохраняются. Успешная проба или лимит, узнанный из ошибки провайдера, остаются авторитетными, даже если они ниже статической таблицы. Компрессор после перезапуска использует то же значение.
  • Устаревшие записи перепроверяются. У старых скалярных записей нет происхождения: это могут быть и результаты проб, и фолбэки. По размеру этого не определить.
  • Неудачные пробы используют текущую таблицу, не сохраняя её. У неудач есть пятиминутная пауза в памяти, привязанная к домашнему каталогу Hermes, эндпоинту, модели и региону. По её истечении или после явной инвалидации кэша можно пробовать снова.

Кэш по-прежнему лежит в context_length_cache.yaml внутри активного домашнего каталога Hermes. context_lengths хранит скалярные значения для старых читателей. Дополнительная карта bedrock_confirmed_v1 привязывает каждый подтверждённый ключ к его точному значению в той же атомарной записи. Обычные записи снимают происхождение этого ключа. Старые писатели могут выбросить дополнительную карту — тогда после повторного обновления запись перепроверяется. После отката версии кэш остаётся читаемым, но возвращаются правила разрешения старого рантайма.

Статический фолбэк для xai.grok-4.6 (включая профили инференса global. и us.) — 500 000 токенов, согласно карточке модели AWS. Это значение специфично для Bedrock и не совпадает с окном прямого API xAI. Существующие правила сжатия действуют как прежде: без резерва под вывод или явного лимита токенов нижняя граница порога в 75% для малых окон даёт при таком окне триггер в 375 000 токенов.

Подключаемый движок контекста

Управление контекстом построено на ABC ContextEngine (agent/context_engine.py). Встроенный ContextCompressor — реализация по умолчанию, но плагины могут заменить её альтернативными движками (например, Lossless Context Management).

context:
  engine: "compressor"    # по умолчанию — встроенная суммаризация с потерями
  engine: "lcm"           # пример — плагин с lossless-контекстом

Движок отвечает за:

  • Решение о том, когда запускать сжатие (should_compress())
  • Выполнение сжатия (compress())
  • Опциональное предоставление инструментов, которые может вызывать агент (например, lcm_grep)
  • Учёт расхода токенов по ответам API

Выбор движка задаётся через конфиг — параметр context.engine в config.yaml. Порядок разрешения:

  1. Проверка каталога plugins/context_engine/<name>/
  2. Проверка общей системы плагинов (register_context_engine())
  3. Возврат к встроенному ContextCompressor

Движки-плагины никогда не активируются автоматически — пользователь должен явно указать в context.engine имя плагина. Значение по умолчанию "compressor" всегда использует встроенный движок.

Настроить можно через hermes plugins → Provider Plugins → Context Engine, либо прямым редактированием config.yaml.

О том, как создать плагин движка контекста, см. Плагины движка контекста.

Двухуровневая система сжатия

В Hermes есть два независимых друг от друга слоя сжатия:

                     ┌──────────────────────────┐
  Входящее сообщение │   Gateway Session Hygiene │  Срабатывает при 85% контекста
  ─────────────────► │   (до агента, грубая оценка) │  Страховка для больших сессий
                     └─────────────┬────────────┘
                                   │
                                   ▼
                     ┌──────────────────────────┐
                     │   Agent ContextCompressor │  Срабатывает при 50% контекста (по умолчанию)
                     │   (внутри цикла, реальные токены) │  Обычное управление контекстом
                     └──────────────────────────┘

1. Gateway Session Hygiene (порог 85%)

Находится в gateway/run_turn.py (ищите Session hygiene). Это страховочный механизм, который срабатывает до того, как агент начнёт обрабатывать сообщение. Он предотвращает сбои API, когда сессии разрастаются между репликами сильнее, чем ожидалось (например, за ночь накопилась переписка в Telegram/Discord).

  • Порог: фиксированные 85% от длины контекста модели
  • Источник данных о токенах: сначала реальные токены, о которых сообщил API на последнем ходе, затем якорь использования, сохранённый в строке сессии (настоящий счёт плюс дельта того, что дописали после; переживает перезапуски шлюза), и только потом грубая оценка по числу символов (estimate_messages_tokens_rough)
  • Срабатывает: только когда len(history) >= 4 и сжатие включено
  • Назначение: отловить сессии, которые «проскочили» мимо собственного компрессора агента

Порог гигиены шлюза намеренно выше, чем у компрессора агента. Установка его на 50% (как у агента) приводила к преждевременному сжатию на каждом ходе в длинных сессиях шлюза.

2. Agent ContextCompressor (порог 50%, настраивается)

Находится в agent/context_compressor.py. Это основная система сжатия, работающая внутри цикла инструментов агента с доступом к точному числу токенов, о котором сообщает API.

Учёт токенов: якоря провайдера и явные эвристические подстраховки

Каждый гейт сжатия — предпроверка в начале хода, простой, давление перед обращением к API, состояние после инструмента — сначала спрашивает якорь использования (agent/usage_anchor.py): последние счётчики токенов промпта и ответа от провайдера плюс грубая оценка ТОЛЬКО тех сообщений, что дописали после этого ответа. Оценённый транскрипт якорь узнаёт по отпечатку содержимого, поэтому переживает то, что шлюз перечитывает историю из базы каждый ход, и хранится в строке сессии — так свежий процесс (--resume, десктопный serve на один ход) его восстановит, пока постоянный транскрипт ещё совпадает. Сжатие, сброс сессии и нативное сжатие codex его очищают.

У встроенного движка на гейтах порога в начале хода и перед обращением к API без якоря (первый запрос, перемотка или повторная отправка правки) грубая оценка всего контекста выше порога ждёт один запрос — ради свидетельств от провайдера (should_defer_preflight_to_real_usage). Это касается и оценок, дотягивающих до всего окна контекста: величина оценки ещё не доказывает, что запрос провалится. После смены модели прежние данные об использовании сбрасываются, и первый запрос тоже отдают на суд новому провайдеру: по-настоящему раздутый запрос может стоить одного отказа, прежде чем сработает реактивное восстановление.

Ожидание — не отключение. Стоит ответу прийти без данных об использовании, как прежняя эвристическая подстраховка снова доступна; реальное использование, уже перешедшее порог, и подтверждённое провайдером переполнение сжатие по-прежнему разрешают. Защёлка после сжатия ждёт один ответ и расходуется, даже если тот придёт без данных об использовании. Восстановление всё так же ограничено бюджетом попыток сжатия и защитой от отсутствия прогресса, а не бесконечным циклом повторных отправок.

Это не политика «только точные счётчики» и не закрытие буквального «никогда не оценивать» из #104462. Прежними остаются:

  • Якорь включает токены промпта и ответа от провайдера плюс грубую дельту дописанных сообщений (первый дописанный ответ ассистента уже учтён в данных о завершении). Так что крупный новый результат инструмента всё ещё способен перейти порог по оценённой дельте. Сверка отпечатка на границе не отпечатывает ни весь префикс, ни модель, ни инструменты, ни системный промпт.
  • Сжатие в простое, которое включают отдельно, живёт по своему нижнему порогу и своей паузе и может действовать по давлению без якоря; ожидание одного запроса с гейтом порога оно не делит.
  • Гигиена шлюза перед запуском агента сохраняет и подстраховку по грубой истории, и предохранительный клапан по жёсткому числу сообщений. Форма gateway в стенде повторов перезагружает словари транскрипта, но эту отдельную политику гигиены не прогоняет.
  • Подстраховка после инструмента без данных об использовании, микросжатие, расчёт размеров сводки и хвоста, обрезка и проверки прогресса при переполнении по-прежнему опираются на локальные оценки. Нативное сжатие сохраняет своё владение под конкретного провайдера и свою защёлку контрольной точки.

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

evals/token_accounting/replay_gates.py покрывает раздувание в пределах окна и за его пределами, контроль реального превышения порога, якоря при перезагрузке и восстановлении, а также восстановление после переполнения и ответов без данных об использовании на локальном HTTP — с настоящим сжатием, но фиксированным локальным текстом сводки. Это скриптованные проверки хода управления, а не свидетельство от токенизатора вендора или из счёта.

Непрозрачные блобы провайдера (encrypted_content у элементов рассуждений и сжатия Codex) вносят 0 в любую локальную оценку; оценить их может только реальное использование.

Картинки считаются по цене за штуку, выученной из данных провайдера (agent/image_token_cost.py), а не по формуле вендора: если в дельте с прошлого якоря появилось N картинок, остаток между реальными prompt_tokens и проекцией по одному тексту и есть N × цена провайдера. Значение хранится по ключу model@host в ~/.hermes/cache/image_token_costs.json и привязывается на ход, поэтому оценщик триггера, обход бюджета хвоста и гигиена шлюза пользуются одной и той же цифрой. До первого хода со зрением действует плоское значение 1500.

Пауза после сбоя и переполнение, подтверждённое провайдером

Сорванная или зависшая попытка суммаризации взводит для сессии паузу после сбоя — нарастающую лесенку 60 с → 300 с → 900 с, никогда не короче compression.context_timeout_seconds, которая хранится в state.db. Пока пауза взведена, обычное сжатие по порогу откладывается, чтобы сломанный бэкенд суммаризации не срабатывал вхолостую каждый ход. Таймауты и зависания эскалируют по одному счётчику; сводка, закончившаяся с finish_reason=length (упёрлась в лимит вывода, транскрипт сохранён), эскалирует по собственному счётчику по тем же ступеням 60 с → 300 с → 900 с, поэтому более поздний ход — например, завершение асинхронного делегирования, пришедшее после истечения паузы, — не может каждые 30 секунд заново отправлять тот же упирающийся в лимит запрос (#69637). Сбои декодирования JSON, закрытого потока и пустого содержимого остаются на плоской паузе в 30 с. Реальную попытку всё же запускают три пути:

  • Ручная команда /compress (force=True) — снимает паузу и пробует снова.
  • Повтор по fallback_chain в том же ходу после зависшего основного маршрута — собственная пауза за зависание у отменённого основного маршрута не должна его подавлять (bypass_cooldown). Если вызов сводки по этому закреплённому маршруту сорвётся, compress() всё равно фиксирует детерминированную резервную сводку (по умолчанию abort_on_summary_failure: false); в логе тогда будет «committed a deterministic fallback summary», а не «recovered».
  • Повторное зависание → детерминированный фолбэк. Первое зависание сохраняет транскрипт, взводит паузу и даёт LLM-маршруту повторить попытку, когда та истечёт. Если маршрут зависает снова, пока на лесенке ещё висит сбой класса «зависание» (_consecutive_timeout_failures >= 1), лесенка повторов завершается детерминированной ступенью: воркер перезапускается с пропуском LLM для сводки (закрепление DETERMINISTIC_SUMMARY_ROUTE) и фиксирует статическую резервную сводку через обычный конвейер lease/fence/watermark — та же деградация, что и при сорвавшемся вызове сводки, — вместо того чтобы «продолжать без сжатия» и каждый ход заново входить в тот же молчаливый поток (#112420). abort_on_summary_failure: true по-прежнему прерывает попытку (ничего не отбрасывается). Зафиксированное сжатие заново привязывает компрессор и сбрасывает счётчик лесенки, так что каждый цикл сжатия даёт LLM-маршруту одно зависание до эскалации; сохранённая строка паузы всё так же задаёт темп попыток между ходами и перезапусками.
  • Провайдер сводки перегружен → прерывание, транскрипт сохранён. Если вызов сводки падает с ошибкой перегрузки провайдера (overloaded, at capacity, HTTP 529), а однократный повтор на основной модели тоже не удался, compress() прерывается и оставляет транскрипт как есть, не фиксируя детерминированную резервную сводку; предупреждение называет перегрузку (failure_class=summary_overload_failure), а /compress повторит попытку, когда мощности вернутся. Сбои авторизации/квоты, сети и пустого содержимого и раньше прерывались так же. Устойчивая перегрузка эскалирует (#123167): после 3 подряд прерываний из-за перегрузки в одной сессии перегрузка перестаёт считаться окончательной, и compress() фиксирует детерминированную резервную сводку (failure_class=summary_overload_degraded) — ограниченная потеря в середине окна вместо того, чтобы дать транскрипту дорасти до compression_exhausted и автосброса шлюза, который выбрасывает сессию целиком. Успешная сводка сбрасывает этот бюджет; abort_on_summary_failure: true по-прежнему жёстко прерывает каждую попытку.
  • Переполнение, подтверждённое провайдером — когда провайдер сам отверг запрос с ошибкой длины контекста, восстановительный проход игнорирует паузу ради одной ограниченной попытки (max_compression_attempts), не снимая её. Отложить сжатие здесь означало бы заклинить сессию: каждый ход отскакивал бы от провайдера, а очередной сбой удлинял бы лесенку (#100661). Если и эта попытка сорвётся, пауза записывается обычным порядком.

Телеметрия попыток

Каждый вызов compress_context, который вернул управление, пишет одну INFO-строку без содержимого: context compression attempt telemetry: {...} (логгер agent.conversation_compression). Текст сообщений, текст сводки, тема фокуса и тексты ошибок в неё никогда не попадают.

  • trigger_source называет причину запуска попытки: manual, idle, turn_start_threshold, engine_preflight, pre_api, post_tool, gateway_hygiene или overflow. Автоматический вызов без метки даёт auto; отказ до начала любой попытки (пул занят) даёт unknown.
  • route — hermes для локального компрессора и codex_app_server, когда сжатием владеет тред Codex.
  • commit_status — committed, aborted, failed, skipped или blocked. blocked означает, что попытку отклонил автоматический предохранитель; его назвает failure_class (blocked:cooldown, blocked:structural_backoff, blocked:ineffective). Под lease сессии session_ownership_lost (skipped) означает, что другой путь уже ротировал сессию, а session_ownership_unreadable / cooldown_state_unreadable (aborted) — что это состояние не удалось прочитать. empty_transcript (aborted) означает, что движок не вернул сообщений, поэтому ничего не зафиксировано.
  • attempt_id называет эту попытку, а session_id — сессию, в которой она началась, даже если потом попытка переходит на ротированную дочернюю сессию или сворачивается после того, как началась более новая попытка (запасной вариант при зависании). Попытка, которая останавливается до запуска компрессора или откатывает своё состояние, пишет method: none и не указывает счётчиков токенов, а числа более ранней попытки не подставляет никогда.
  • method говорит, как получена сводка: llm_summary, aux_fallback_main (модель сводки отказала, и сводку написала основная модель), deterministic_fallback (статическая сводка из якорей; items_dropped считает замещённые сообщения), provider (Codex) или none.
  • messages_before / messages_after и tokens_before / tokens_after / tokens_reclaimed описывают транскрипт, пересёкший границу фиксации, включая сохранённые хвостовые строки /compress here N и якоря границ. Отклонённый или откатанный кандидат в транскрипт не попал, поэтому в его записи нет messages_after, tokens_after, tokens_reclaimed и items_dropped. token_count_method: estimate_rough помечает числа как грубые оценки только по сообщениям (без системного промпта и схем инструментов), так что они сравнимы между собой. tool_results_pruned и reasoning_items_pruned считают детерминированные проходы, которые выполнялись до сводки и после неё.

Опциональная общая метрика hermes.compression.count хранит только грубые триггеры (auto, manual, overflow) и считает лишь попытки, которые запускал локальный компрессор; заблокированные попытки и попытки через Codex видны только в строке лога. Когда рантайм NeMo Relay запущен, та же запись, а также проходы микроуплотнения и зафиксированные проактивные обрезки выдаются ещё и как метка hermes.compaction (см. Общие метрики NeMo Relay).

Конфигурация

Все настройки сжатия читаются из config.yaml, из блока compression:

compression:
  enabled: true              # Включить/выключить сжатие (по умолчанию: true)
  threshold: 0.50            # Доля окна контекста (по умолчанию: 0.50 = 50%)
  # model_thresholds:        # Переопределения порога для отдельных моделей
  #   "glm-5.2": 0.40        # (совпадение по подстроке, побеждает самый длинный
  #   "claude-sonnet": 0.35  # ключ). См. «Переопределения порога для отдельных моделей» ниже.
  target_ratio: 0.20         # Какая часть порога сохраняется как «хвост» (по умолчанию: 0.20)
  tail_mode: lean            # Политика хранения хвоста: lean | legacy (по умолчанию: lean)
  protect_last_n: 20         # Минимальное число защищённых сообщений в хвосте (по умолчанию: 20)
  min_tail_user_messages: 1  # Реальные сообщения пользователя, гарантированные в хвосте (по умолчанию: 1)
  codex_gpt55_autoraise: true  # gpt-5.5 через Codex OAuth: поднять порог до 85% (по умолчанию: true)
  codex_gpt55_autoraise_notice: true  # Показать одноразовое уведомление об автоподъёме (по умолчанию: true)
  codex_app_server_auto: native  # native|hermes|off для сжатия треда через Codex app-server
  codex_responses_native: false  # Серверное сжатие по желанию: gpt-5.6 в OpenAI/Codex; Astra в Codex OAuth
  codex_responses_compact_threshold: null  # Порог серверного сжатия; используется только при codex_responses_native: true
  in_place: true             # Сжимать в том же session id, без ротации (по умолчанию: true)

# Модель/провайдер для суммаризации настраиваются в auxiliary:
auxiliary:
  compression:
    model: null              # Переопределить модель для сводок (по умолчанию: автоопределение)
    provider: auto           # Провайдер: "auto", "openrouter", "nous", "main" и т. д.
    base_url: null           # Кастомный OpenAI-совместимый endpoint

Параметры подробно

ПараметрПо умолчаниюДиапазонОписание
threshold0.500.0-1.0Сжатие запускается, когда число токенов промпта ≥ threshold × context_length (для окон меньше 512K — не ниже 0.75)
threshold_tokensnullint или nullНеобязательный абсолютный потолок триггера: если задан, сжатие срабатывает по меньшему из порога по доле и этого числа. null = только по доле
model_thresholds{}mapПереопределения threshold для отдельных моделей. Ключи сопоставляются с именем модели по подстроке (побеждает самый длинный ключ); ключи вида "<provider>:<substring>" действуют только у этого провайдера. Порог-пол для маленьких окон контекста применяется поверх (см. ниже)
target_ratio0.200.10-0.80Определяет бюджет токенов для защиты хвоста: threshold_tokens × target_ratio (только для режима legacy — у lean свой собственный клэмп)
tail_modeleanlean, legacyПолитика хранения хвоста. legacy сохраняет дословный хвост размером target_ratio (100K+ токенов на моделях с большим окном). lean сохраняет клэмпнутый хвост размером 2.5% × окно контекста (пол 10K, потолок 25K), а непрерывность вместо этого несёт сводка: подробный журнал сессии с сохранением идентификаторов (его строит тот же самый единственный запрос на сводку — lean-сжатие делает ровно один вспомогательный вызов LLM за попытку), механически извлечённый индекс якорей (номера PR, SHA, пути, тексты ошибок — по регулярным выражениям, без перефразирования), каждое реальное сообщение пользователя дословно (бюджет «сначала новые») и указатель восстановления session_search, чтобы агент мог заново получить доступ к чему угодно, что ушло в сводку. Слишком объёмные области сэмплируются равномерно во вход суммаризатора (с явными маркерами пропуска), а не порождают дополнительные вызовы. Результат на реальных сессиях в 500K токенов: удерживается ~49K против ~162K, с более высокой полнотой восстановления при использовании вместе с механизмом восстановления (см. evals/compaction/results/). Старые результаты инструментов внутри lean-хвоста понижаются до однострочных заглушек с указателем восстановления
protect_last_n20≥1Минимальное число последних сообщений, которые сохраняются всегда
min_tail_user_messages1≥1Минимальное число РЕАЛЬНЫХ (содержательных) сообщений пользователя, гарантированно сохраняющихся в несжатом хвосте. 1 — это существующий единственный якорь на последнем сообщении пользователя (поведение по умолчанию, ничего не меняющее). Поднимите, например, до 3, чтобы сохранять дословно последние 3 реальных реплики пользователя, даже когда объёмные результаты инструментов заполняют весь токен-бюджет хвоста. Пустые эхо от платформы, передачи после сжатия и синтетические строки продолжения в счёт N не идут. Гарантия важнее токен-бюджета хвоста — хвост может превысить бюджет, если якорь отодвигает границу назад
protect_first_n3(жёстко задано)Системный промпт + первый обмен репликами сохраняются всегда
idle_compact_after_seconds0≥0 секундОпционально: сжимать заранее, когда сессия возобновляется после простоя в это число секунд (0 = отключено). Пропускается, если контекст ≤ threshold × target_ratio; учитывает защиту от «дребезга», кулдаун и блокировки
codex_gpt55_autoraisetrueboolПоднимает порог до 85% для gpt-5.4/5.5/5.6 и gpt-6 Astra на маршруте ChatGPT Codex OAuth (см. ниже). Установите false, чтобы использовать глобальный threshold
codex_gpt55_autoraise_noticetrueboolПоказывать одноразовое уведомление об автоподъёме для Codex gpt-5.5. Установите false, чтобы сохранить автоподъём до 85%, но убрать баннер
codex_app_server_autonativenative, hermes, offРежим сжатия треда для сессий Codex app-server (см. ниже)
codex_responses_nativefalseboolВключает серверное сжатие OpenAI в Responses API. Действует для моделей семейства gpt-5.6 на прямом API OpenAI или подписке ChatGPT Codex, а также для gpt-6-astra (включая его псевдоним -900k в выборе моделей) на официальном Codex OAuth (см. ниже)
codex_responses_compact_thresholdnullnull или положительное целоеТриггер серверного сжатия, читается только при codex_responses_native: true — на момент срабатывания локального сжатия он не влияет никогда; локальный триггер — это threshold (доля) с потолком threshold_tokens. null выводит нативный порог из разрешённого локального триггера с запасом в 8192 токена. Положительное целое значение остаётся абсолютным и ограничивается сверху только при необходимости. Некорректные значения запускают автоматическое поведение. Если рабочего локального триггера нет, автоматический режим откатывается к 200000
in_placetrueboolСжимать в том же session id вместо ротации на новый (см. ниже)

In-place-сжатие (стабильный session id)

При compression.in_place: true (значение по умолчанию) сжатие переписывает живой список сообщений в том же самом session id: системный промпт перестраивается, суммаризированная середина подставляется на место, а ходы до сжатия мягко архивируются под тем же id (active=0, compacted=1 в хранилище сессий) — их по-прежнему можно найти через session_search и восстановить, они никогда не удаляются. Нет цепочки parent_session_id и нет перенумерования name #N — один разговор хранит один устойчивый id всю свою жизнь. Это устранило целый класс багов, связанных с ротацией сессий (потеря состояния /goal, осиротевшие сессии, разрывы поиска на границах сессий).

Потребители событий ориентируются на режим, а не на сравнение session id:

  • Событие session:compress несёт in_place: true/false и old_session_id (пустая строка в in-place-режиме, поскольку старого id нет).
  • Шлюз перебазирует обработку транскрипта по флагу агента _last_compaction_in_place, не зависящему от ротации, а не по разнице id.

Установите in_place: false, чтобы вернуться к старому пути с ротацией, где каждое сжатие фиксирует новый session id, связанный с предыдущим через parent_session_id.

Посильность для вспомогательной модели и сохранение хвоста

Меньшая вспомогательная модель сжатия способна опустить живой триггер сжатия, не меняя выбранную политику хвоста. В режиме lean бюджет отбора по-прежнему считается от окна контекста основной модели: 2,5%, зажатые в 10–25 тысяч токенов, и никогда не больше 20% этого окна (одна только нижняя граница в 10K — это 61% локального окна в 16K, так что без потолка «защищённый» хвост маленькой модели занимал весь запрос, и сжатию нечего было освобождать). Например, у основной модели на 1M и вспомогательной на 512K бюджет отбора остаётся 25K, даже когда посильность опускает триггер с 850K до 512K. Явно заданный режим legacy, наоборот, пересчитывает threshold_tokens × target_ratio (102 400 токенов при 512K × 0,20). Это бюджеты на отбор хвоста, а не жёсткие пределы всего сжатого контекста: защищённые сообщения, выравнивание по границе, сводки и якоря могут добавить токенов.

Опущенный триггер — это устойчивый потолок компрессора, поэтому поправки окна для той же модели (лимит, сообщённый провайдером, выросшее локальное окно) его сохраняют. Когда меняется основной рантайм — /model, включение резервного провайдера или возврат к основному, — вспомогательная модель сразу пробуется заново: триггер снова ограничивается до первого сжатия на новом окне или возвращается к собственному значению основной модели, если вспомогательная теперь помещается.

Переопределения порога для отдельных моделей

compression.model_thresholds позволяет запускать сжатие в разных точках в зависимости от активной модели — полезно, когда вы переключаетесь между моделями с сильно разными окнами контекста (например, модель с окном 1M может сжиматься позже, а модель с окном 128K должна сжиматься раньше):

compression:
  threshold: 0.50
  model_thresholds:
    "glm-5.2": 0.40
    "glm-5.2-1M": 0.25
    "claude-sonnet": 0.35
    "openai-codex:astra": 0.85   # только на маршруте Codex OAuth (потолок 272K)

Правила разрешения:

  • Ключи сопоставляются с именем модели по подстроке; побеждает самый длинный совпавший ключ (glm-5.2-1M побеждает glm-5.2 для модели glm-5.2-1M).
  • Ключи можно привязать к провайдеру в форме "<provider>:<substring>" (например, "openai-codex:astra": 0.85). Такой ключ совпадает, только когда провайдер сессии — именно этот маршрут, поэтому тот же слаг, который где-то ещё (OpenRouter, Nous, прямой OpenAI) отдаётся с другим окном, сохраняет глобальный threshold. Ранжирование смотрит только на подстроку модели, так что "astra-900k" по-прежнему побеждает "openai-codex:astra" для варианта 900K из выбора моделей; ключ с привязкой побеждает голый ключ с той же подстрокой.
  • Если ни один ключ не совпал (или карта пуста), применяется глобальный threshold.
  • Переопределение пересчитывается при каждом переключении /model; переключение на модель без совпадающего ключа возвращает к глобальному threshold.
  • Порог-пол для маленьких окон контекста по-прежнему применяется поверх переопределений (только в сторону повышения): у моделей с окном контекста меньше 512K порог не может быть ниже 0.75, так что переопределение ниже этого порога-пола поднимается до 0.75, а переопределение выше него (например, 0.80) остаётся в силе.

Плагины движков контекста могут переиспользовать ту же логику разрешения через from agent.context_compressor import resolve_model_threshold; движки, которые переопределяют update_model(), реализуют собственную политику сжатия и могут игнорировать эту карту.

Автоподъём порога для Codex gpt-5.x / Astra

Бэкенд ChatGPT Codex OAuth жёстко ограничивает окно контекста gpt-5.4/5.5/5.6 и gpt-6 Astra значением 272K (тот же самый слаг модели даёт 1.05M на прямом API OpenAI и в OpenRouter, и 400K на GitHub Copilot). При стандартном пороге в 50% сжатие срабатывало бы уже на ~136K — это половина окна, которое модель фактически способна использовать. Когда активный маршрут — Codex OAuth (provider: openai-codex), а модель принадлежит одному из этих семейств (под Astra подходит любой слаг со словом astra; варианты -900k из отдельного переключателя исключены — они и так открывают более широкое окно), Hermes поднимает порог до 85% (~231K) и показывает уведомление с командой для отключения этого поведения. Уведомление показывается один раз на профиль — маркер в $HERMES_HOME (.codex_gpt55_autoraise_notice) фиксирует, что оно уже сработало, поэтому повторные инициализации агента/сессии (например, при каждом входящем сообщении шлюза) не выводят его снова; если поднятый порог позже изменится, уведомление появится ещё раз. Затрагивается только этот конкретный маршрут; те же модели у любого другого провайдера сохраняют ваш глобальный threshold. Чтобы вернуться к глобальному значению:

hermes config set compression.codex_gpt55_autoraise false

Чтобы сохранить автоподъём до 85%, но скрыть только одноразовое уведомление:

hermes config set compression.codex_gpt55_autoraise_notice false

Варианты -900k с большим контекстом в выборе моделей Codex (подключаются явно)

Бэкенд ChatGPT Codex заявляет окно в 272K для семейств gpt-5.4, gpt-5.6 (Sol/Terra/Luna) и GPT-6 (Sol/Terra/Luna), но на деле принимает ~911K входных токенов для аккаунтов с подпиской ChatGPT (проверено вживую в августе 2026). Для базовых слагов Hermes оставляет по умолчанию заявленные 272K: окно побольше означает больше токенов на запрос и куда более быстрый расход лимита подписки, поэтому большое окно подключается строго вручную.

Чтобы задействовать большое окно, выберите в /model явный вариант с суффиксом -900k (например, gpt-6.1-sol-900k, gpt-6-sol-900k, gpt-6-luna-900k, gpt-5.6-sol-900k, gpt-5.6-terra-900k, gpt-5.6-luna-900k, gpt-5.4-900k). Это псевдонимы на стороне Hermes: суффикс отрезается перед тем, как идентификатор модели уйдёт на бэкенд, а в учёте цен и расхода такие варианты считаются базовой моделью. У слагов, которые действительно ограничены 272K (gpt-5.5, gpt-5.4-mini), варианта -900k нет. Если аутентифицированный каталог Codex публикует для базового слага max_context_window меньше 900K (например, 872K), вариант -900k разрешается в этот живой максимум; 900K остаётся офлайн-фолбэком, а опубликованный максимум выше 900K его не поднимает.

Пороги сжатия следуют за окном: базовые слаги (272K) получают автоподъём до 85%, описанный выше, а варианты -900k сохраняют ваш глобальный compression.threshold (по умолчанию 50%, ~450K) — автоподъём нужен, чтобы не растрачивать впустую маленькое окно, а окну на 900K это ни к чему.

Сжатие треда Codex app-server

Сессии Codex app-server (api_mode: codex_app_server — среда выполнения codex CLI/агента) отличаются от всех остальных маршрутов: контекст треда принадлежит агенту codex, поэтому вспомогательный суммаризатор Hermes не может его уменьшить — переписывание локального зеркала транскрипта оставляет реальный тред неограниченно расти вплоть до жёсткого сброса контекста. Для этой среды выполнения сжатие идёт через собственный механизм app-server:

  • Ручное сжатие (/compress) просит app-server сжать тред (thread/compact/start) и дожидается завершения хода сжатия.
  • Автоматическое сжатие управляется параметром compression.codex_app_server_auto: значение по умолчанию native позволяет app-server самому решать, когда сжимать, а Hermes фиксирует итоговые события сжатия (счётчики компрессии, события сессии). Установите hermes, чтобы порог сжатия Hermes инициировал сжатие app-server, либо off, чтобы полностью отключить автоматическое сжатие по инициативе Hermes (сам codex по-прежнему может сжимать нативно).

Локальный транскрипт Hermes в этой среде выполнения никогда не переписывается — state.db фиксирует границу сжатия, а видимый транскрипт остаётся нетронутым. Все остальные маршруты (включая чат-сессии Codex OAuth) продолжают использовать компрессор сводок Hermes.

Нативное сжатие Responses (gpt-5.6 и Astra на поддерживаемых маршрутах)

Responses API от OpenAI поддерживает серверное сжатие: когда запрос содержит context_management: [{type: "compaction", compact_threshold: N}], а отрендеренный ввод превышает N токенов, сервер вытесняет старый контекст в непрозрачный зашифрованный элемент вывода compaction. Hermes перехватывает этот элемент в существующий replay-sidecar сообщения ассистента и отправляет его обратно на последующих ходах, замещая собой вытесненную историю — долгосрочная память без клиентского прохода суммаризации, совместимая с ZDR (store: false, без previous_response_id).

Включается через compression.codex_responses_native: true. Условие срабатывания намеренно узкое и перепроверяется на каждом запросе:

  • Модели: семейство gpt-5.6, а также gpt-6-astra (и его псевдоним -900k в выборе моделей) на официальном OAuth подписки Codex. Astra на прямом API, другие варианты Astra и другие модели GPT-6 исключены. gpt-5.1/5.2 при наличии поля возвращают HTTP 500 или зависают на стриме (структурированного отказа, на который можно было бы понизиться, нет; проверено вживую в августе 2026).
  • Маршруты: только api.openai.com (API-ключ OpenAI) или бэкенд ChatGPT Codex (OAuth-подписка Codex). xAI, GitHub/Copilot, OpenRouter, релеи и локальные серверы это поле никогда не видят.

Для Astra нужны и разрешённый провайдер openai-codex, и официальный HTTPS-эндпоинт chatgpt.com/backend-api/codex. Доверенное переопределение прокси сжатие Astra не включает. Используется существующий автоматический путь context_management, история configuration_update не добавляется.

Всё остальное в сжатии не меняется: локальный компрессор остаётся вооружён как запасной владелец процесса (нативный порог ограничен примерно на 8K токенов ниже локального триггера, чтобы сервер сжимал первым), а структурированный отказ провайдера от этого поля отключает нативное сжатие для сессии и повторяет запрос уже без него. Переключение сессии на неподходящую модель или маршрут просто прекращает отправку поля — захваченные чекпоинты выбрасываются из replay существующей защитой от смешения между эмитентами при смене endpoint.

compression.codex_responses_compact_threshold учитывается только пока действует codex_responses_native: true; при выключенном нативном сжатии (по умолчанию) он игнорируется, и локальное сжатие срабатывает только по threshold / threshold_tokens. По умолчанию codex_responses_compact_threshold: null выводит нативный порог из разрешённого локального триггера. Например, при локальном триггере в 765 000 будет выбрано значение 756 808. Задайте положительное целое число, чтобы зафиксировать абсолютный порог — скажем, 200 000. Некорректные значения запускают автоматическое поведение. Если рабочего локального триггера нет, автоматический режим использует 200 000. Минимум на стороне провайдера — 1024 токена, так что необычно маленький локальный триггер на этом пороге или ниже не может гарантировать строгий приоритет нативного сжатия.

Вычисленные значения (для модели с контекстом 200K при настройках по умолчанию)

context_length       = 200,000
threshold_tokens     = 200,000 × 0.50 = 100,000
tail_token_budget    = 100,000 × 0.20 = 20,000
max_summary_tokens   = min(200,000 × 0.05, 12,000) = 10,000
заметка
Порог рассчитывается от окна контекста ОСНОВНОЙ модели

threshold_tokens равен threshold × context_length (с последующим потолком compression.threshold_tokens, если он задан), где context_length — окно контекста именно основной модели агента, а не вспомогательной модели/модели для сводок. На модели с окном 262 144 токена при значении по умолчанию 0.50 порог составит 262 144 × 0.50 = 131 072. То, что это число близко к привычным «128K контекста», — просто совпадение процентного расчёта, а не признак того, что триггером служит окно вспомогательной модели. Окно контекста вспомогательной модели — отдельный вопрос: см. предупреждение «Длина контекста модели для сводки» ниже о том, как это влияет на возможность построить сводку, а не на момент срабатывания сжатия.

Алгоритм сжатия

Метод ContextCompressor.compress() состоит из 4 фаз:

Фаза 1: Очистка старых результатов инструментов (дёшево, без обращения к LLM)

Старые результаты инструментов (>200 символов) за пределами защищённого хвоста заменяются на:

[Old tool output cleared to save context space]

Это дешёвый предварительный проход, который экономит заметное число токенов на объёмных результатов инструментов (содержимое файлов, вывод терминала, результаты поиска).

Раунд инструментов, на который модель ещё не ответила (сжатие сработало сразу после его выполнения, с сообщениями /steer, доставленными после него, или без них), сохраняет в хвосте текстовые результаты дословно, а изображения в результатах — нетронутыми, так что модель может воспользоваться выводом, который запросила. Раунд, который один превышает 20% входного бюджета (окно контекста минус резерв под вывод), может быть сведён в сводку, а его более старые изображения снимаются, чтобы сжатие всё же смогло освободить место.

Фаза 2: Определение границ

┌─────────────────────────────────────────────────────────────┐
│  Список сообщений                                            │
│                                                             │
│  [0..2]  ← protect_first_n (системный промпт + первый обмен)  │
│  [3..N]  ← средние ходы → СУММАРИЗИРУЮТСЯ                     │
│  [N..end] ← хвост (по бюджету токенов ИЛИ protect_last_n)     │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Защита хвоста устроена на основе бюджета токенов: идёт обход от конца назад, накапливая токены, пока бюджет не исчерпан. Бюджет — и мягкий потолок в 1,5×, на который его могут превысить целые строки, — на любой модели ограничен 20% окна контекста, так что protect_last_n служит минимумом только до небольшой нижней границы по числу (8 строк) и никогда не навязывает хвост, при котором сжимать уже нечего; превысить потолок могут только обязательные якоря последнего сообщения пользователя / ассистента и атомарные группы инструментов.

Границы выравниваются так, чтобы не разрывать пары tool_call/tool_result. Метод _align_boundary_backward() проходит назад через последовательные результаты инструментов, пока не найдёт родительское сообщение ассистента, сохраняя группу целостной.

Фаза 3: Построение структурированной сводки

внимание
Длина контекста модели для сводки

Модель для сводки должна иметь окно контекста не меньше, чем у основной модели агента. Весь средний раздел отправляется модели для сводки одним вызовом call_llm(task="compression"). Если контекст модели для сводки меньше, API возвращает ошибку превышения длины контекста — _generate_summary() перехватывает её, логирует предупреждение и возвращает None. После этого компрессор отбрасывает средние ходы без сводки, незаметно теряя контекст разговора. Это самая распространённая причина деградации качества сжатия.

Средние ходы суммаризируются с помощью вспомогательной LLM по структурированному шаблону:

## Goal
[Чего добивается пользователь]

## Constraints & Preferences
[Предпочтения пользователя, стиль кода, ограничения, важные решения]

## Progress
### Done
[Выполненная работа — конкретные пути к файлам, запущенные команды, результаты]
### In Progress
[Работа, ведущаяся в данный момент]
### Blocked
[Любые блокеры или возникшие проблемы]

## Key Decisions
[Важные технические решения и их обоснование]

## Relevant Files
[Файлы, которые читались, изменялись или создавались — с кратким пояснением по каждому]

## Next Steps
[Что нужно сделать дальше]

## Critical Context
[Конкретные значения, сообщения об ошибках, детали конфигурации]

Бюджет сводки масштабируется в зависимости от объёма сжимаемого содержимого:

  • Формула: content_tokens × 0.20 (константа _SUMMARY_RATIO)
  • Минимум: 2000 токенов
  • Максимум: min(context_length × 0.05, 12 000) токенов

Фаза 4: Сборка сжатого списка сообщений

Сжатый список сообщений состоит из:

  1. Головных сообщений (с примечанием, добавленным в системный промпт при первом сжатии)
  2. Сообщения со сводкой (роль подбирается так, чтобы избежать двух подряд сообщений одной роли)
  3. Хвостовых сообщений (без изменений)

Осиротевшие пары tool_call/tool_result очищает _sanitize_tool_pairs():

  • Результаты инструментов, ссылающиеся на удалённые вызовы, — удаляются
  • Для вызовов инструментов, чьи результаты были удалены, — подставляется заглушка результата

Итеративное повторное сжатие

При последующих сжатиях предыдущая сводка передаётся в LLM с инструкцией обновить её, а не строить заново с нуля. Это сохраняет информацию через несколько циклов сжатия — пункты переходят из «In Progress» в «Done», добавляется новый прогресс, устаревшая информация удаляется.

Поле _previous_summary экземпляра компрессора хранит текст последней сводки именно для этой цели. Туда же попадает и детерминированная резервная сводка: именно она теперь служит передаточной записью в транскрипте.

Пример «до и после»

До сжатия (45 сообщений, ~95K токенов)

[0] system:    "You are a helpful assistant..." (системный промпт)
[1] user:      "Help me set up a FastAPI project"
[2] assistant: <tool_call> terminal: mkdir project </tool_call>
[3] tool:      "directory created"
[4] assistant: <tool_call> write_file: main.py </tool_call>
[5] tool:      "file written (2.3KB)"
    ... ещё 30 ходов правки файлов, тестирования, отладки ...
[38] assistant: <tool_call> terminal: pytest </tool_call>
[39] tool:      "8 passed, 2 failed\n..."  (вывод 5KB)
[40] user:      "Fix the failing tests"
[41] assistant: <tool_call> read_file: tests/test_api.py </tool_call>
[42] tool:      "import pytest\n..."  (3KB)
[43] assistant: "I see the issue with the test fixtures..."
[44] user:      "Great, also add error handling"

После сжатия (25 сообщений, ~45K токенов)

[0] system:    "You are a helpful assistant...
               [Note: Some earlier conversation turns have been compacted...]"
[1] user:      "Help me set up a FastAPI project"
[2] assistant: "[CONTEXT COMPACTION] Earlier turns were compacted...

               ## Goal
               Set up a FastAPI project with tests and error handling

               ## Progress
               ### Done
               - Created project structure: main.py, tests/, requirements.txt
               - Implemented 5 API endpoints in main.py
               - Wrote 10 test cases in tests/test_api.py
               - 8/10 tests passing

               ### In Progress
               - Fixing 2 failing tests (test_create_user, test_delete_user)

               ## Relevant Files
               - main.py — FastAPI app with 5 endpoints
               - tests/test_api.py — 10 test cases
               - requirements.txt — fastapi, pytest, httpx

               ## Next Steps
               - Fix failing test fixtures
               - Add error handling"
[3] user:      "Fix the failing tests"
[4] assistant: <tool_call> read_file: tests/test_api.py </tool_call>
[5] tool:      "import pytest\n..."
[6] assistant: "I see the issue with the test fixtures..."
[7] user:      "Great, also add error handling"

Промпт-кэширование (Anthropic)

Источник: agent/prompt_caching.py

Снижает стоимость входных токенов примерно на 75% в многоходовых диалогах за счёт кэширования префикса разговора. Использует точки останова cache_control от Anthropic.

Стратегия: system_and_3

Anthropic допускает максимум 4 точки останова cache_control на запрос. Hermes использует стратегию «system_and_3»:

Точка останова 1: Системный промпт           (стабильна на всех ходах)
Точка останова 2: 3-е с конца сообщение не от системы  ─┐
Точка останова 3: 2-е с конца сообщение не от системы   ├─ Скользящее окно
Точка останова 4: Последнее сообщение не от системы     ─┘

Как это работает

apply_anthropic_cache_control() делает глубокую копию сообщений и вставляет маркеры cache_control:

# Формат маркера кэша
marker = {"type": "ephemeral"}
# Или для TTL в 1 час:
marker = {"type": "ephemeral", "ttl": "1h"}

Маркер вставляется по-разному в зависимости от типа содержимого:

Тип содержимогоКуда вставляется маркер
Строковое содержимоеПреобразуется в [{"type": "text", "text": ..., "cache_control": ...}]
Содержимое-списокДобавляется в словарь последнего элемента
None/пустоДобавляется как msg["cache_control"]
Сообщения инструментовДобавляется как msg["cache_control"] (только для нативного Anthropic)

Паттерны проектирования с учётом кэша

  1. Стабильный системный промпт: системный промпт — это точка останова 1, кэшируется на всех ходах. Избегайте изменения его в середине разговора (сжатие добавляет примечание только при первом сжатии).

  2. Важен порядок сообщений: попадания в кэш требуют совпадения префикса. Добавление или удаление сообщений в середине аннулирует кэш для всего, что идёт после.

  3. Взаимодействие сжатия с кэшем: после сжатия кэш аннулируется для сжатой области, но кэш системного промпта сохраняется. Скользящее окно из 3 сообщений восстанавливает кэширование за 1–2 хода.

  4. Выбор TTL: по умолчанию 5m (5 минут). Используйте 1h для долгоживущих сессий, где пользователь делает паузы между ходами.

  5. Идентичность модели — часть ключа кэша: кэши на стороне провайдера привязаны к модели (и аккаунту/API-ключу), обслуживающим запрос. Любая смена модели в середине разговора — явное переключение /model, фолбэк основной модели или ротация пула учётных данных на другой аккаунт — означает, что следующий запрос получит ноль попаданий в кэш и заново прочитает весь разговор по недисконтированной цене за входные токены. Это неотъемлемое свойство того, как работают кэши на стороне провайдера, и Hermes не может это обойти; пользовательская документация по /model, резервным провайдерам и пулам учётных данных не зря содержит предупреждения о стоимости. Не добавляйте функции, которые незаметно подменяют модель или учётные данные в середине сессии.

Включение промпт-кэширования

Промпт-кэширование включается автоматически, когда:

  • Модель — это модель Anthropic Claude (определяется по имени модели)
  • Провайдер поддерживает cache_control (нативный API Anthropic или OpenRouter)
# config.yaml — TTL настраивается: "5m", "1h" или "auto"
prompt_caching:
  cache_ttl: "5m"

"auto" разрешается один раз за сессию в agent/agent_init.py::_init_prompt_cache_config через agent/prompt_caching.py::auto_cache_ttl_for_source: 1h для источников в человеческом темпе, 5m для MACHINE_PACED_SOURCES (subagent, cron, oneshot, webhook, kanban, api, tool, batch). Вспомогательные вызовы и заглушки (configured_cache_ttl()) считают auto равным 5m.

CLI показывает статус кэширования при запуске:

💾 Prompt caching: ENABLED (Claude via OpenRouter, 5m TTL)

Предупреждения о давлении на контекст

Промежуточные предупреждения о давлении на контекст были убраны (см. блок бюджета итераций в agent/turn_iteration_prep.py, где указано: «No intermediate pressure warnings — they caused models to ‘give up’ prematurely on complex tasks» — «Никаких промежуточных предупреждений о давлении — они заставляли модели преждевременно “сдаваться” на сложных задачах»). Сжатие срабатывает, когда число токенов промпта достигает настроенного compression.threshold (по умолчанию 50%), без каких-либо предварительных шагов-предупреждений; гигиена сессии шлюза срабатывает как вторичная страховка при 85% окна контекста модели.

перевод и редактура:

обновлено

⌕ ESC