Сжатие контекста

Алгоритм многоуровневого сжатия контекста (compaction) для борьбы с превышением лимита контекста LLM. Включает эскалацию: от мягкого сжатия до агрессивного с обрезкой tool-результатов и жёсткой остановки.

При очень большом контексте обычное сжатие (50/50 split) может не уменьшить контекст ниже лимита. Без эскалации агент попадает в бесконечный цикл: context length error → compact → всё ещё превышено → context length error → compact → ... Многоуровневое сжатие решает эту проблему прогрессивным увеличением доли суммаризируемых сообщений и обрезкой тяжёлых tool-результатов.

  • Автоматическое: при ошибке context length exceeded от LLM (tryAutoCompact в agent/stream.go). Ошибка определяется функцией isContextLengthError по ключевым словам: context + length/max/maximum, а также truncated JSON (unexpected end of json input).
  • Ручное: команда /compact в TUI (builtin/compact.goapp_commands.go). Всегда уровень 0, tokenBudget=0 (сжимать независимо от текущего размера).
Уровень Trigger Split ratio TruncateResults Retain
0 1-я попытка 50% нет ~половина ветки
1 2-я попытка 75% нет ~четверть
2 3-я попытка 90% да ~10% + обрезанные tool results
3 4-я попытка 95% да последние 1-2 сообщения
стоп 5-я попытка (превышен лимит) жёсткий стоп, ошибка
  • maxCompactionAttempts = 4 — максимум попыток в одном turn'е (уровни 0..3)
  • MaxEscalationLevel = 3 — максимальный уровень эскалации
  • contextBudgetMargin = 8000 — резерв токенов для system prompt + tools + ответ
  • truncateHeadLines = 20, truncateTailLines = 20 — строки head/tail при обрезке
 1func computeSplitPoint(branchLen, level int) int {
 2    var ratio float64
 3    switch {
 4    case level >= 3: ratio = 0.95
 5    case level >= 2: ratio = 0.90
 6    case level >= 1: ratio = 0.75
 7    default:         ratio = 0.50
 8    }
 9    return max(int(float64(branchLen) * ratio), 1)
10}

После расчёта split point корректируется через findSafeSplitPoint — ищется ближайшая безопасная граница, не разрывающая tool-call/result пару.

  • Авто-сжатие: getContextBudget() запрашивает provider.ContextWindow(ctx) и вычитает contextBudgetMargin (8000 токенов). Если провайдер не поддерживает ContextWindow или вызов неудачен — budget=0 (означает "всегда сжимать").
  • Ручное /compact: budget=0 (всегда сжимать).
  • CompactDetailed: при tokenBudget <= 0 пропускает проверку "превышен ли budget" и переходит сразу к сжатию.

При уровне >= 2 в compaction entry устанавливается флаг TruncateResults = true. BuildSessionContext проверяет этот флаг у последней компакции и, если он установлен, обрезает ToolResultMessage в retained-части:

  • Сохраняются первые 20 и последние 20 строк текста
  • Середина заменяется маркером: [... N lines truncated ...]
  • Короткие результаты (< 40 строк) не обрезаются
  • Обрезка недеструктивна — оригинал остаётся в s.data и БД; truncation применяется только на копии сообщения, отправляемой LLM

Это даёт радикальное уменьшение: read 2000-строчного файла → 40 строк (50x сжатие).

CompactResult.Effective() проверяет, уменьшило ли сжатие токены минимум на 10%:

1func (r CompactResult) Effective() bool {
2    return float64(r.TokensBefore-r.TokensAfter)/float64(r.TokensBefore) >= 0.10
3}

В tryAutoCompact логируется предупреждение, если сжатие неэффективно. Неэффективное сжатие → следующая попытка с более высоким уровнем эскалации.

flowchart TD
    Start[Stream → context length error] --> Check{isContextLengthError?}
    Check -- нет --> Stop1[Обычная обработка ошибки]
    Check -- да --> Inc[compactionAttempts++]
    Inc --> Limit{attempts > 4?}
    Limit -- да --> HardStop[❌ Жёсткий стоп: msg.compaction.exhausted]
    Limit -- нет --> Level[level = attempts - 1, max 3]
    Level --> Budget[getContextBudget: ContextWindow - 8000]
    Budget --> Compact[CompactDetailed budget, level]
    Compact --> CheckEntry{EntryID пуст?}
    CheckEntry -- да --> Stop2[Нечего сжимать]
    CheckEntry -- нет --> CheckEff{Effective?}
    CheckEff -- нет --> LogWarn[Лог: неэффективно, эскалируем]
    CheckEff -- да --> OK
    LogWarn --> OK[continue → следующий Stream]
    OK --> NextStream[Stream → снова error?]
    NextStream -- да --> Inc
    NextStream -- нет --> Done[Turn продолжается]

compactionAttempts хранится в loopState, который создаётся заново в каждом Run(). Таким образом, каждый turn (ход) начинает с нулевого счётчика. Внутри одного turn'а счётчик не сбрасывается — эскалация накапливается.

1type CompactionEntry struct {
2    SessionEntryBase
3    Summary          string         // LLM-сгенерированный summary
4    FirstKeptEntryID string         // ID первого retained сообщения
5    TokensBefore     int            // Токены до сжатия
6    TokensAfter      int            // Токены после сжатия
7    Details          map[string]any // previousSummary и др.
8    TruncateResults  bool           // обрезать tool results в retained части
9}
1type loopState struct {
2    accumulatedContent  []model.Content
3    totalUsage          model.Usage
4    lastContentHash     string
5    repeatCount         int
6    recoveryAttempts    int
7    compactionAttempts  int  // <-- счётчик эскалации
8}

buildContextEntriesLocked строит контекст так:

  1. Находит последнюю compaction entry в ветке
  2. Включает её первой (как CompactionSummaryMessage)
  3. Добавляет все сообщения от FirstKeptEntryID до конца ветки
  4. Старые compaction entries исключаются (их данные остаются в БД)

При TruncateResults=true BuildSessionContext дополнительно обрезает ToolResultMessage перед добавлением в результат.

Файл Назначение
internal/session/compaction.go Compact, CompactDetailed, computeSplitPoint, generateSummary, findPreviousSummary
internal/session/truncation.go maybeTruncateMessage, truncateToolResultText
internal/session/context.go BuildSessionContext (применяет truncation)
internal/session/safesplit.go findSafeSplitPoint — безопасная граница split
internal/session/fallback.go buildFallbackSummary — детерминированный fallback
internal/session/tokens.go CountTokens, countTokensRealLocked
internal/agent/stream.go tryAutoCompact, getContextBudget, loopState
internal/agent/loop.go Главный цикл — вызывает tryAutoCompact при ошибках
internal/agent/detect.go isContextLengthError
internal/model/session.go CompactionEntry (структура с TruncateResults)
internal/prompts/compact.gotmpl Промпт для LLM-суммаризации
internal/session/compaction_test.go Тесты базового сжатия
internal/session/escalation_test.go Тесты эскалации и truncation
  1. Контекст 100k токенов, лимит модели 80k → LLM возвращает context length error
  2. tryAutoCompact вызывается с budget = 80k - 8k = 72k, level = 0
  3. Ветка делится 50/50, старая половина суммаризируется LLM
  4. После сжатия: ~50k токенов (summary + retained) — ниже лимита
  5. Stream продолжается успешно
  1. Контекст 200k токенов, лимит 128k → context length error
  2. Уровень 0 (50/50): после сжатия ~100k — всё ещё > 120k (budget)
  3. Stream → снова context length error
  4. Уровень 1 (75/25): после сжатия ~50k summary + 25k retained = ~75k — ниже лимита
  5. Stream продолжается
  1. Контекст 500k токенов, лимит 128k
  2. Уровень 0 (50/50): ~250k — превышено
  3. Уровень 1 (75/25): ~125k — чуть превышено
  4. Уровень 2 (90/10 + truncate): ~50k summary + 10k (truncated) = ~60k — ниже лимита
  5. Stream продолжается

Если все 4 попытки не помогли: 6. Попытка 5: compactionAttempts > maxCompactionAttempts → жёсткий стоп 7. Публикуется ErrorEvent с msg.compaction.exhausted 8. Turn завершается с StopReasonError

  1. Пользователь выполняет /compact
  2. Compact(ctx, provider, modelID, 0, 0) — budget=0, level=0
  3. Сжатие 50/50, без truncation
  4. Результат отображается в TUI

Если LLM недоступен (ошибка или пустой ответ), buildFallbackSummary генерирует детерминированный summary без LLM: извлекает последние user-asks, tool actions, file paths. Это гарантирует, что сжатие всегда возможно, даже если LLM провайдер недоступен.