Сжатие контекста
Алгоритм многоуровневого сжатия контекста (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.go→app_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 при обрезке
Расчёт split point
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 пару.
Token budget
- Авто-сжатие:
getContextBudget()запрашиваетprovider.ContextWindow(ctx)и вычитаетcontextBudgetMargin(8000 токенов). Если провайдер не поддерживаетContextWindowили вызов неудачен — budget=0 (означает "всегда сжимать"). - Ручное /compact: budget=0 (всегда сжимать).
- CompactDetailed: при
tokenBudget <= 0пропускает проверку "превышен ли budget" и переходит сразу к сжатию.
Обрезка tool-результатов (TruncateResults)
При уровне >= 2 в compaction entry устанавливается флаг TruncateResults = true.
BuildSessionContext проверяет этот флаг у последней компакции и, если он установлен,
обрезает ToolResultMessage в retained-части:
- Сохраняются первые 20 и последние 20 строк текста
- Середина заменяется маркером:
[... N lines truncated ...] - Короткие результаты (< 40 строк) не обрезаются
- Обрезка недеструктивна — оригинал остаётся в
s.dataи БД; truncation применяется только на копии сообщения, отправляемой LLM
Это даёт радикальное уменьшение: read 2000-строчного файла → 40 строк (50x сжатие).
Анти-thrashing: проверка эффективности
CompactResult.Effective() проверяет, уменьшило ли сжатие токены минимум на 10%:
1func (r CompactResult) Effective() bool {
2 return float64(r.TokensBefore-r.TokensAfter)/float64(r.TokensBefore) >= 0.10
3}
В tryAutoCompact логируется предупреждение, если сжатие неэффективно.
Неэффективное сжатие → следующая попытка с более высоким уровнем эскалации.
Жизненный цикл авто-сжатия в одном turn'е
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'а
счётчик не сбрасывается — эскалация накапливается.
Структуры данных
CompactionEntry
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}
loopState (agent)
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 строит контекст так:
- Находит последнюю compaction entry в ветке
- Включает её первой (как
CompactionSummaryMessage) - Добавляет все сообщения от
FirstKeptEntryIDдо конца ветки - Старые 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 |
Сценарии
Обычное сжатие (уровень 0)
- Контекст 100k токенов, лимит модели 80k → LLM возвращает context length error
tryAutoCompactвызывается с budget = 80k - 8k = 72k, level = 0- Ветка делится 50/50, старая половина суммаризируется LLM
- После сжатия: ~50k токенов (summary + retained) — ниже лимита
- Stream продолжается успешно
Эскалация (уровни 0→3)
- Контекст 200k токенов, лимит 128k → context length error
- Уровень 0 (50/50): после сжатия ~100k — всё ещё > 120k (budget)
- Stream → снова context length error
- Уровень 1 (75/25): после сжатия ~50k summary + 25k retained = ~75k — ниже лимита
- Stream продолжается
Жёсткий стоп
- Контекст 500k токенов, лимит 128k
- Уровень 0 (50/50): ~250k — превышено
- Уровень 1 (75/25): ~125k — чуть превышено
- Уровень 2 (90/10 + truncate): ~50k summary + 10k (truncated) = ~60k — ниже лимита
- Stream продолжается
Если все 4 попытки не помогли:
6. Попытка 5: compactionAttempts > maxCompactionAttempts → жёсткий стоп
7. Публикуется ErrorEvent с msg.compaction.exhausted
8. Turn завершается с StopReasonError
Ручное /compact
- Пользователь выполняет
/compact Compact(ctx, provider, modelID, 0, 0)— budget=0, level=0- Сжатие 50/50, без truncation
- Результат отображается в TUI
Fallback-суммаризация
Если LLM недоступен (ошибка или пустой ответ), buildFallbackSummary генерирует
детерминированный summary без LLM: извлекает последние user-asks, tool actions,
file paths. Это гарантирует, что сжатие всегда возможно, даже если LLM провайдер недоступен.