Подробный разбор: как собирать контекст для LLM-агента

Откройте Claude Code, Cursor или любой другой агентный CLI для кода. Попросите исправить баг. Понаблюдайте, как он делает один вызов инструмента, потом другой, потом ещё один. Под капотом на каждом без исключения ходу LLM этот CLI принимает решение, которого вы никогда не видите: что именно отправить модели в качестве истории разговора. Весь транскрипт, только последние несколько сообщений, резюме предыдущих, исходная задача плюс последнее чтение файла — каждый вариант даёт другого агента. CLI принял решение за вас, зашитое на этапе сборки, невидимое, пока вы не прочтёте исходники.

Эта статья — экскурсия по пространству решений стратегий сборки контекста для LLM-агентов, то есть по невидимым решениям, которые обвязка принимает о содержимом промпта на каждом ходу. Мы строим таксономию выборов (где происходит сжатие, что сжимается, когда оно срабатывает), разбираем, как эту задачу на самом деле решают продакшен-CLI в исходниках, и ставим небольшой эксперимент с четырьмя показательными стратегиями на фикстуре с багом.

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

Почему контекст — центральная задача управления для агента

Любую агентную сборку для кода составляют три части: модель (LLM, генерирующая токены), агент (цикл управления, который многократно вызывает модель внутри окружения) и обвязка (софт вокруг, управляющий контекстом, инструментами, промптами, состоянием и потоком управления). У Себастьяна Рашки есть хороший разбор того, как это складывается в работающего агента для кода. Важный здесь момент: многое из того, что в сессии выглядит как «качество модели», на самом деле — качество контекста, а качество контекста — это работа обвязки.

Цикл агента, сведённый к существу, таков:

  1. Отправить историю сообщений в LLM.
  2. Получить обратно либо текстовый ответ, либо вызовы инструментов.
  3. Если вызовы инструментов: выполнить их, дописать результаты в историю, перейти к 1.
  4. Если текст: готово.

Теперь подумайте, что происходит с шагом 1 на протяжении реальной сессии. Промпт пользователя мал — максимум несколько сотен токенов. Системный промпт фиксирован и ограничен. Почти весь контекст, который модель видит на любом конкретном ходу, — это вызовы инструментов и их результаты. Чтение файла возвращает 2000 символов исходника, прогон тестов — 1500 символов вывода об ошибке, ls — 500. Агент может сделать тридцать ходов, каждый раз дописывая в историю очередную порцию. К тридцатому ходу вы отправляете ~100 000 токенов одного лишь вывода инструментов на каждый вызов, каждый раз. Ввод-вывод инструментов доминирует в размере контекста, а значит, доминирует в стоимости, задержке и в том, сколько места остаётся модели, чтобы собственно рассуждать.

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

Если просто дать истории расти бесконтрольно, мы упираемся в четыре проблемы:

  • Стоимость. Вы платите за каждый входной токен на каждом ходу. Линейная по истории стоимость на тридцатиходовом прогоне означает, что последние ходы — самые дорогие вызовы, которые вы делаете.
  • Задержка. Провайдеры стримят вывод, но не стримят ввод. Промпт на 100 тыс. токенов требует заметного времени по часам, чтобы его отправить и токенизировать, прежде чем придёт первый токен ответа.
  • Ограничения размера окна контекста. У Gemini 2.5 Flash потолок 1 млн токенов; у Claude Sonnet — 1 млн; у большинства остальных — 200 тыс. Упрётесь в потолок — и вызов упадёт целиком.
  • Деградация на длинном контексте. Даже внутри окна модели уделяют больше внимания началу и концу промпта, чем середине (Liu et al., 2023). Для агента это значит, что самые ранние ходы (цель, системный промпт) и самые свежие (последний результат инструмента) хорошо «отсматриваются», а середина — устаревшие выводы инструментов, брошенные попытки починки, недодуманные рассуждения — размывается независимо от того, сколько окна ещё осталось.

Каждый агент вынужден как-то решить, как обходиться с этим давлением. Это решение — явное или неявное — и есть его стратегия сборки контекста. Некоторые стратегии игнорируют давление полностью (отправлять всё, каждый ход) и оставляют платить пользователю. Некоторые агрессивно переписывают историю. Большинство CLI выбирают одну точку на этом спектре и выпускают её в продакшен.

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

  • Что выбрасывается или переписывается?
  • Когда — каждый ход или только при достижении порогов?
  • Где в стеке — на слое инструментов, на слое стратегии или на обоих?
  • Насколько агрессивно — в токенах, символах, сообщениях?
  • Что вы резюмируете — старую историю, весь разговор, определённые типы инструментов?
  • Как вы резюмируете — свободно, по структурированному шаблону, в несколько связанных раундов?
  • А что с кешем — уважает ли ваше преобразование префикс или инвалидирует его каждый ход?

Каждый пункт — реальный выбор, и комбинаторика усложняется очень быстро. Стратегия, идеальная для коротких сессий с одним багом, может быть катастрофической на исследовательских задачах с несколькими багами; ограничение на слое инструментов, помогающее на маленьких файлах, активно прячет баги в больших; шаблон резюме, схватывающий починку одного бага, может потерять нить, когда багов четыре.

По ходу статьи мы будем опираться на небольшой набор чисел, чтобы говорить о каждой стратегии конкретно. Это не бенчмарк-оценки — фикстура намеренно маленькая, и мы делаем всего 3 прогона (k=3) на стратегию, так что ничто здесь не является формальной оценкой. Считайте числа общим словарём: способом указать на то, как ведёт себя каждая стратегия, где проявляются её режимы отказа и куда уходят деньги, когда они уходят. Две метрики, к которым мы будем возвращаться чаще всего, — доля успехов и стоимость, а под ними несколько диагностических чисел, помогающих объяснить, почему стратегия оказывается дешёвой или дорогой.

МетрикаЧто она нам говоритКак мы её измеряем
Доля успеховБыла ли задача решена? Без успеха остальные числа не особо важны.Бинарно на прогон; 3 прогона (k=3) на стратегию.
Медианная стоимостьСколько стоит типичный прогон.Сумма стоимостей вызовов; приводится как медиана по трём прогонам.
Стоимость худшего случаяКак выглядит плохой прогон. Стоит выделять, потому что выбор по медиане может скрыть катастрофу на $4, случающуюся 1 раз из 20.Максимальная стоимость по трём прогонам.
Число ходовЗаменитель задержки — больше вызовов LLM означает больше времени по часам.Число ходов ассистента на прогон.
Размер промпта на ходДиагностика. Объясняет, почему стоимость такая, какая есть.input_tokens + cached_tokens на вызов LLM.
Доля попаданий в кешДиагностика. Маленький промпт на ход всё равно может стоить как большой, если стратегия инвалидирует кеш каждый ход.cached_tokens / total_tokens на вызов.

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

Таксономия стратегий

Метрики установлены — теперь посмотрим на пространство решений, к которому они будут применяться. Стратегии, встречающиеся в продакшен-CLI, распадаются на несколько семейств по двум осям: где происходит сжатие и что сжимается.

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

Любая другая стратегия — это способ реализовать сжатие. Сжатие может происходить в трёх местах:

  • На каждом ходу. Применять преобразование к списку сообщений на каждом вызове LLM, формируя промпт каждого хода по мере роста разговора.
  • Только при порогах. Не трогать разговор, пока он не пересечёт некоторый предел размера, а затем однократно выполнить операцию (обычно резюмирование) над старой частью и заморозить результат на весь остаток прогона.
  • На уровне инструмента. Ограничить или переписать возвращаемое значение инструмента до того, как оно попадёт в историю разговора. Живёт на слой ниже двух остальных и композируется с ними — подробно разбираем дальше.

Между двумя режимами на слое стратегии ни один не доминирует, и часто их комбинируют. Каждый ход дешевле (нет лишнего вызова LLM), стабилен для кеша с первого хода и честен — маркер, вставленный в промпт вроде …[truncated 1500 chars], сообщает модели, что чего-то не хватает, тогда как резюме может выглядеть полным, даже если оно пропустило баг. При порогах ограничивает разговор (компактизация ужимает список сообщений, так что многораундовые версии могут работать сколь угодно долго), не платит за сжатие ничего на коротких сессиях и возвращает бюджет внимания — промпт после компактизации достаточно мал, чтобы каждая позиция снова была хорошо «отсмотрена». Как правило: короткие сессии тяготеют к «каждый ход», длинные — к «при порогах», а любой продакшен-CLI, достойный релиза, в итоге комбинирует оба.

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

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

  • Выбрасывать старые ходы. Хранить только последние N сообщений. Классическое скользящее окно. Вариант: закрепить исходный промпт пользователя в голове.
  • Выбрасывать старые результаты инструментов. Хранить вызовы инструментов (сохраняя след рассуждений), но выбрасывать или усекать их выводы.
  • Усекать вывод инструментов. Хранить каждое сообщение, но ограничивать каждый результат инструмента максимальным числом символов — либо на слое стратегии (переприменяется каждый ход), либо внутри реализации инструмента (ограничивается один раз в момент выполнения и хранится уже ограниченным).
  • Резюмировать старые ходы. Как только история превысила порог, вызвать ещё одну LLM, чтобы получить компактное резюме, встающее вместо выброшенных ходов. Именно так поступают и opencode, и собственный coding-agent из pi при переполнении.
  • Извлекать по требованию. Хранить полный лог, эмбеддить каждый ход и на каждом вызове включать только top-k наиболее релевантных текущей цели ходов. Насколько нам известно, в реальном CLI этого пока никто не выпустил.

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

Стабильность кеша

Одно свойство доминирует в стоимости независимо от того, на каком слое происходит сжатие, и потому его крайне важно не испортить. Это свойство — стабильность кеша. Каждый современный API для LLM берёт разные ставки за токены, которые он видел недавно, и за токены, которые он видит впервые. Неявный префиксный кеш Gemini, блоки cache_control у Anthropic и кеш промптов OpenAI структурно работают одинаково: провайдер хеширует ведущую последовательность байтов вашего запроса, ищет совпадение среди недавних запросов и, если находит, берёт за эти токены гораздо меньшую ставку (обычно 10–25% от некешированной цены; детали различаются у провайдеров). Кешированная часть обязана быть префиксом — непрерывной идентичной последовательностью, начинающейся с байта 0. Первый же отличающийся байт инвалидирует всё, что после него.

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

Разговор агента растёт монотонно: пользователь → вызовы инструментов → результаты инструментов → ассистент → вызовы инструментов → результаты инструментов → ассистент. К 30-му ходу вы отправляете ~100 тыс. токенов в основном стабильной истории на каждый вызов, каждый раз. Если ваша стратегия держит префикс стабильным, эти 100 тыс. токенов в основном попадают в кеш, и полную ставку вы платите только за несколько сотен новых токенов в хвосте. Если ваша стратегия изменяет префикс каждый ход, те же 100 тыс. токенов все некешированы, и счёт растёт в 4–10 раз без всякой пользы для поведения.

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

Пример обратного — то, что мы называем враждебным кешу, — это скользящее окно: оно переставляет то, что попадает в каждую позицию, каждый ход, так что попадания в кеш схлопываются дальше начального промпта, и каждый токен оплачивается по некешированной ставке. Здесь нет «после этого заморожено» — есть непрерывная болтанка. Разница между стабильностью и враждебностью кешу — доминирующий фактор стоимости по всем прогонам.

Следствие: дружественных кешу форм у стратегии на самом деле всего две — только-дописывание (менять всегда только хвост — сюда попадают и baseline, и варианты с усечением, ведь как только результат инструмента усечён до N символов, эти N символов больше не меняются) или заморозить-однажды (сделать одну большую перезапись — обычно компактизацию — и больше её не трогать, так что префикс после перезаписи становится новым стабильным замороженным префиксом). Всё остальное — периодическая перекомпактизация, вытеснение по динамическому окну, привязанное к текущему ходу, переписывание резюме на месте — изменяет префикс ход за ходом и враждебно кешу по умолчанию. Продакшен-CLI действительно перекомпактизируют периодически, не платя эту цену, но только за счёт того, что оставляют прежние резюме замороженными, дописывают новые блоки резюме вместо переписывания старых и используют явные точки останова кеша (cache_control у Anthropic, prompt_cache_key у OpenAI), чтобы провайдер знал, где кончается стабильный префикс.

Два смысла слова «кеширование»: префиксное и семантическое

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

Эти два легко перепутать, потому что оба обещают «более дешёвые вызовы LLM», но они работают на разных слоях и имеют разные режимы отказа:

  • Префиксное кеширование ключуется по точному префиксу токенов — непрерывной побайтово идентичной последовательности, начинающейся с позиции 0. Попадание означает, что провайдер пропускает prefill на этих токенах и берёт 10–25% от обычной ставки; модель всё равно работает и всё равно генерирует свежее завершение. Это чисто вычислительная оптимизация, поэтому она всегда корректна — вывод идентичен варианту без кеша. Именно под это неявно оптимизирует каждая стратегия в этой статье, и живёт оно внутри провайдера инференса.
  • Семантическое кеширование ключуется по смыслу запроса — вы эмбеддите входящий промпт, делаете векторный поиск по хранилищу прошлых промптов, и если что-то набирает больше порога схожести, возвращаете сохранённый ответ, вообще не вызывая модель. Попадание экономит весь вызов, а не только prefill. Но оно приближённое: слишком мягкий порог возвращает ответ на другой вопрос, а всё, что зависит от времени или контекста, протухает. Живёт оно в вашем приложении (Redis, векторная БД, GPTCache), и порог, TTL и логика инвалидации — на вас.
Префиксное кешированиеСемантическое кеширование
СлойПровайдер инференсаВаше приложение
КлючТочный префикс токеновЭмбеддинг запроса
При попаданииБыстрее prefill, свежая генерацияСохранённый ответ, вызова модели нет
ЭкономияЧастичная — только prefillПолная — инференса нет
КорректностьВсегда точнаяПриближённая; может быть неверной или протухшей
Вы управляетеТочками останова кеша, стабильностью префиксаПорогом, вытеснением, инвалидацией, партиционированием

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

Теряющее хранилище против теряющего представления

Отдельно стоит вопрос о том, где происходит сжатие, а значит — что попадает в разговор на хранение. Два варианта с одинаковым наблюдаемым эффектом для модели:

  • На слое инструментов (теряющее хранилище). Инструмент ограничивает или переписывает возвращаемое значение до того, как результат попадёт в разговор. Сжатый текст — это то, что дописывается в историю и остаётся там навсегда. Так делает read_file в opencode — остальная часть раздела про дизайн вывода инструментов разбирает это позже. Как только инструмент вернул срез в 50 КБ, остатка файла нигде локально нет; чтобы его восстановить, агент должен вызвать инструмент снова с другим offset.
  • На слое стратегии (теряющее представление, полное хранилище). Инструмент возвращает полный вывод. Полный текст дописывается в историю. Каждый ход хук уровня хода (в pi он называется transformContext, разбираем позже, когда дойдём до нашей реализации) заново выводит сжатое представление над полной историей только для этого вызова LLM — усекая, резюмируя, заменяя заглушками, выбрасывая, что бы стратегия ни делала. Лог разговора навсегда сохраняет каждый байт; уменьшается только представление модели на данный ход. Все четыре стратегии, которые мы меряем (baseline, truncate-500, age-truncate-500-keep-3, compact-at-12000-structured), работают именно так.

Разница не проявляется в промпте LLM — оба дизайна порождают один и тот же текст. Она проявляется в том, что остаётся на диске:

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

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

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

Дизайн вывода инструментов: вторая половина картины

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

На слое инструментов важны два измерения:

  • Ограничение на вызов. Максимальный размер, который инструмент вообще когда-либо вернёт за один вызов. read_file в opencode ограничен ~50 КБ всего / 2000 символов на строку. Инструмент чтения в Claude Code ограничен 256 КБ со стороны файла и 25 тыс. токенов на отрисованном выводе. За пределами ограничения содержимое опускается из возвращаемого значения, и агент его не видит, пока не спросит снова.
  • Постраничность. Может ли агент запросить следующий срез. И opencode, и Claude Code принимают параметры offset / limit в своём инструменте чтения, так что файл на 200 КБ превращается в четыре последовательных вызова read_file вместо одного усечённого чтения. В разговоре оказываются четыре маленьких, стабильных для кеша результата вместо одного большого частично усечённого.

Взаимодействие со слоем сборки контекста прямое: если ваши инструменты ограничивают себя сами, вашей стратегии остаётся меньше работы. opencode держит весь разговор в контексте (в дефолтном пути нет ни выбрасывания сообщений, ни усечения на уровне разговора) и обходится этим, потому что каждый результат инструмента уже мал по построению. Стратегия baseline поверх инструментов opencode вела бы себя совсем иначе, чем baseline поверх инструмента, возвращающего 1 МБ сырого текста, — хотя это один и тот же baseline.

Инструмент с постраничностью в реальном смысле делает сжатие без потерь: ничего не выбрасывается навсегда, остаток файла всё ещё на диске, и агент может подтянуть его по требованию. Инструмент, который просто обрезает без постраничности, делает сжатие с потерями: всё за пределами ограничения невидимо, пока не изменится контракт инструмента.

Как с контекстом обходятся реальные CLI

Прежде чем зафиксировать, какие стратегии мы будем мерить, стоит посмотреть, как эту задачу на самом деле решают продакшен-CLI. Каждый берёт свою смесь шаблонов из таксономии, иногда с оглядкой на то, что предоставляет API их целевого провайдера. Вот быстрый обзор того, что видно в исходниках.

Claude Code

В марте 2026 года Anthropic выпустила @anthropic-ai/claude-code v2.1.88 с картой исходников на ~60 МБ, обнажившей ~512 тыс. строк TypeScript. Вскоре появилось несколько разборов (Straiker, Karan Prasad) и дословный архив промптов (Piebald-AI/claude-code-system-prompts). Это сделало Claude Code самой эмпирически обоснованной точкой отсчёта в этой статье — это единственный крупный агентный CLI с закрытым кодом, чью реализацию мы реально можем прочитать.

Стабильность кеша — одна из вещей, о которых Claude Code явно заботится больше всего: утечка показывает несколько намеренных, взаимно усиливающих ходов, чтобы префикс оставался побитово стабильным между запросами.

Самый заметный из этих ходов — разделение промпта на статическую и динамическую части. Промпт Claude Code устроен как раскладка с фиксированными позициями, где ранние блоки — системный промпт, описания инструментов, сводка по рабочему пространству, содержимое CLAUDE.md — побитово идентичны в каждом запросе сессии. Они явно помечены Anthropic-овским cache_control: { type: "ephemeral" }, чтобы сообщить API, что этот префикс кешируем. Переменные блоки идут после, в известных позициях. Схематически каждый запрос выглядит как пример ниже — переключите вкладки, чтобы увидеть, как меняется форма, когда срабатывает компактизация:

// Early in a session, before history has crossed the compaction threshold.
await client.messages.create({
  model: "claude-...",
  system: [
    // ─── STATIC: bit-identical across turns ─────────────────
    { type: "text", text: SYSTEM_PROMPT },                    // ~5KB, never changes
    { type: "text", text: TOOL_DESCRIPTIONS },                // ~8KB, never changes
    { type: "text", text: workspaceSummary },                 // computed once at session start
    { type: "text", text: CLAUDE_MD_CONTENTS,
      cache_control: { type: "ephemeral" } },                 // 👈 cache breakpoint #1
                                                              // (everything above this point is cached)
  ],
  messages: [
    // ─── frozen prefix: just the seed user message ─────
    { role: "user", content: SEED_USER_MESSAGE,
      cache_control: { type: "ephemeral" } },                 // 👈 cache breakpoint #2 (on the seed)
    // ─── tail: every turn so far, appended ─────────────
    ...allTurnsSoFar,
  ],
});
// After compaction has fired at least once: the older portion of the
// conversation has been replaced by a frozen summary, and breakpoint #2
// has shifted forward to land on it.
await client.messages.create({
  model: "claude-...",
  system: [
    // ─── STATIC: bit-identical across turns ─────────────────
    { type: "text", text: SYSTEM_PROMPT },                    // ~5KB, never changes
    { type: "text", text: TOOL_DESCRIPTIONS },                // ~8KB, never changes
    { type: "text", text: workspaceSummary },                 // computed once at session start
    { type: "text", text: CLAUDE_MD_CONTENTS,
      cache_control: { type: "ephemeral" } },                 // 👈 cache breakpoint #1
  ],
  messages: [
    // ─── frozen prefix: bit-stable from compaction onward ─────
    { role: "user", content: SEED_USER_MESSAGE },             // first user message in the run
    { role: "assistant", content: FROZEN_SUMMARY,
      cache_control: { type: "ephemeral" } },                 // 👈 cache breakpoint #2 (last frozen item)
    // ─── tail: appended each turn since compaction ───────────
    ...recentKMessages,
  ],
});

Две точки останова в фиксированных позициях. Новые токены оплачиваются только за то, что дописано в хвост. Почему две, а не одна? Потому что каждый маркер cache_control создаёт независимую запись в кеше — что позволяет разным частям промпта инвалидироваться с разной скоростью (статический блок не меняется ~никогда, секция после резюме меняется раз на компактизацию, хвост — каждый ход) и даёт запасные попадания, когда TTL одной записи истекает раньше остальных.

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

А как насчёт следующей компактизации и той, что после неё? Независимо от того, как повторяется компактизация, запрос всегда несёт те же две точки останова кешаcache_control действует в пределах запроса, так что важны только маркеры в текущем вызове. Точка останова #1 остаётся заякоренной в конце CLAUDE.md; точка останова #2 сидит на том, что является самым свежим стабильным элементом на момент запроса. Вы не накапливаете точки останова по ходу сессии — лимит Anthropic в 4 на запрос это бюджет, а не счётчик, растущий с длиной сессии.

Что может отличаться между событиями компактизации, так это раскладка замороженного содержимого за этим единственным маркером точки останова #2. Два разумных дизайна:

  • Ротация (выбор Claude Code). Слот FROZEN_SUMMARY всегда только один. На каждом следующем событии компактизации предыдущее резюме плюс новый хвост заново резюмируются в свежий единственный блок, заменяющий старый. Байты в позиции FROZEN_SUMMARY меняются, так что запись кеша для точки останова #2 приходится каждый раз перезаписывать. Одна инвалидация кеша на событие компактизации, зато промпт остаётся компактным (форма [seed + summary + recent] независимо от длины сессии).
  • Цепочка. Каждое новое резюме дописывается после предыдущих замороженных резюме; точка останова #2 сдвигается вперёд на самое новое. Префикс растёт — [seed, summary_1, summary_2, ..., recent], — но каждое предыдущее резюме остаётся побайтово стабильным, так что старые записи кеша (всё ещё в пуле провайдера от прошлых запросов) могут служить запасными попаданиями, даже если они уже не помечены в текущем запросе. Дружелюбнее к кешу, но промпт растёт линейно с числом компактизаций, так что в итоге пришлось бы компактизировать саму цепочку.

Ротационный выбор Claude Code меняет редкие инвалидации кеша на компактность промпта. Арифметика в пользу компактности, потому что компактизации редки относительно ходов — вы можете срабатывать раз в несколько десятков ходов, съесть одну инвалидацию кеша, а потом ехать на новой записи несколько тысяч кешированных токенов до следующей компактизации. Цена: каждое повторное резюмирование — операция с потерями поверх уже потерявшего резюме, так что за долгую сессию детали накопленно вымываются.

Документация Anthropic по кешированию промптов описывает всю механику. Кратко: каждая директива cache_control создаёт новую запись в кеше, заякоренную на байте 0 и заканчивающуюся в позиции маркера. Так что пример с двумя точками останова выше записывает две вложенные записи — одну, кончающуюся на границе CLAUDE.md, и одну, кончающуюся на замороженном резюме. На следующем запросе API сначала пытается попасть в самый длинный кешированный префикс и откатывается на более короткие, если длинный больше не совпадает. Это позволяет разным частям промпта инвалидироваться с разной скоростью и удерживает вас на частичных попаданиях вместо «всё или ничего».

Паттерн Anthropic с встроенными маркерами — самая аккуратная версия этого среди крупных провайдеров. У OpenAI всё чисто автоматически: API сам решает, где писать записи (TTL ~5 минут); группировать запросы можно через prompt_cache_key, но пометить позицию нельзя. Gemini предлагает и то и другое: автоматический неявный кеш плюс явный API cachedContents, где вы заранее создаёте кешированный ресурс с настраиваемым TTL и ссылаетесь на него по имени в последующих вызовах (эргономика отличается от внутризапросных маркеров Anthropic). Anthropic позволяет помечать до 4 байтовых позиций встроенно в каждом запросе, с опциональным TTL в 1 час за более высокую наценку на запись. К варианту OpenAI/Codex — автоматика плюс prompt_cache_key — мы вернёмся в следующем разделе.

Каждое преобразование, которое Claude Code делает над своим промптом, — чистая функция от чётко определённого состояния (число ходов, аргументы вызова инструмента, позиция сообщения) и никогда не зависит от переменчивых сигналов вроде хеша HEAD текущей ветки. Всё, что дрейфует ход за ходом, заставило бы префикс меняться на каждом запросе и обрушило бы кеш. Вот как 30-ходовая сессия по коду может стоить почти как baseline, несмотря на отправку ~100 тыс. токенов на вызов.

Политики старения по инструментам

Claude Code держит захардкоженный список имён инструментов — в утечке названный COMPACTABLE_TOOLS, — которые подлежат старению на каждом ходу. В нашей таксономии это стратегия уровня хода на слое стратегии, несмотря на именование «compactable» (намекающее на компактизацию при порогах — отдельный механизм, который у Claude Code тоже есть). Инструменты не из списка освобождены: их результаты хранятся дословно навсегда.

Интересный ход здесь — отойти от привычных двух крайностей в обращении с выводом инструментов: всегда дословно (то, к чему по умолчанию скатывается большинство стратегий) и всегда с ограничением или постраничностью (то, что делает read_file в opencode на слое инструментов). Старение добавляет третий вариант, комбинирующий оба: свежие результаты хранить дословно, старые состаривать. Тот же инструмент, разное обращение в зависимости от того, насколько результат протух. Это решает проблему, которую каждая крайность создаёт сама по себе: всегда дословно даёт устаревшим блобам по 50 КБ накапливаться вечно; всегда с ограничением может отрезать файл, который агент только что открыл (что и есть режим отказа truncate-500, показанный ниже).

Claude Code делает ещё шаг: не только когда состаривать, но и как. Каждый инструмент из списка получает своё правило, настроенное под то, как вывод этого инструмента сохраняет ценность со временем:

ИнструментПравило старения
read_fileКак только старше последних 3 чтений — заменить результат однострочной заглушкой с именем пути
list_filesКак только старше последних 2 листингов — усечь до 200 символов
write_fileВсегда хранить дословно
run_testsВсегда хранить дословно

Маленькое уточнение о том, что на самом деле значит «старше последних 3 чтений»: считаются вызовы того же инструмента, а не ходы. Чтобы стало конкретно, допустим, история вызовов агента такова:

turn 1:  read_file(api.ts)         ← 1st read
turn 2:  list_files(./src)
turn 3:  read_file(api.ts)         ← 2nd read
turn 4:  run_tests()
turn 5:  read_file(storage.ts)     ← 3rd read
turn 6:  read_file(api.ts)         ← 4th read
turn 7:  read_file(serializer.ts)  ← 5th read

После хода 7, если смотреть только на вызовы read_file (те, что на ходах 1, 3, 5, 6, 7), три самых свежих — это ходы 5, 6 и 7. Значит:

  • Чтения на ходах 1 и 3 → заменены заглушками.
  • Чтения на ходах 5, 6, 7 → хранятся дословно.

Если ход 8 — это run_tests() (не чтение), ничего не меняется. Как только ход 9 окажется ещё одним read_file, чтение с хода 5 состарится — оно станет 4-м по свежести чтением — и будет заменено заглушкой. Каждый инструмент ранжируется по свежести вызовов независимо, и топ-K каждого ранга остаются дословными.

Логика за каждой строкой, простыми словами:

  • Устаревший list_files — почти чистый шум: как только агент перестал исследовать каталог, старый листинг имеет околонулевую дальнейшую ценность. Поэтому его усекают жёстко и быстро.
  • Устаревший результат read_file тоньше: файл может понадобиться агенту снова. Вместо усечения текста Claude Code заменяет его заглушкой с именем пути; агент может подтянуть заново, вызвав read_file с теми же аргументами. Без потерь в том смысле, что ничего не пропало, просто отложено.
  • Устаревший write_file представляет действие, которое агент совершил, — он изменил этот файл. Забыть, что вы что-то записали, — верный рецепт переписать это иначе на следующем ходу. Хранится дословно.
  • Устаревший run_tests несёт авторитетное состояние тестового набора, с которым агент часто сверяется повторно. Хранится дословно.

Форма та же, что у нашего age-truncate-500-keep-3: стабильна для кеша, ключуется по возрасту, детерминирована. Обобщение — гранулярность по инструментам вместо одного единого правила для всех результатов. На длинных сессиях, где многие инструменты вызываются много раз, гранулярность окупается, потому что вытеснение каждого инструмента соответствует его реальной кривой удержания ценности. На наших прогонах в 10–20 ходов агент вызывает каждый инструмент лишь несколько раз, так что нюансу негде проявиться — единообразное усечение по возрасту уже забирает большую часть экономии.

Шаблон структурированного резюме

Последний дизайн Claude Code, который стоит вытащить, — сам промпт для резюмирования, то есть системный промпт, отправляемый модели, когда срабатывает компактизация. В утечке он лежит как system-prompt-context-compaction-summary.md. Наша стратегия compact-at-12000-structured переиспользует перефразированную версию ровно этого промпта — те же пять секций, те же ограничения, — так что порождаемое ею резюме имеет ту же форму, что и у Claude Code:

You produce continuation summaries for coding agents that have run out of context.

Output the summary wrapped in <summary></summary> tags, with the following five sections
as level-2 markdown headings, in order:

## Task Overview — what the user asked for, in one or two sentences.
## Current State — files created, modified, or analyzed, listed with their full paths;
                   state of the test suite; open work.
## Important Discoveries — key facts the agent learned, including approaches that did
                           NOT work and why.
## Next Steps — the immediate action the continuing agent should take.
## Context to Preserve — user preferences, promises made, constraints that must not
                         be violated.

Be specific. Cite exact filenames. No filler. No conversational framing.

Три детали этого дизайна делают его работающим:

  • Порядок секций повторяет человеческую передачу дел. Task Overview → Current State → Discoveries → Next Steps → Context to Preserve — примерно так инженер вводит в курс коллегу, принимающего задачу: в чём цель, где мы, что узнали, что дальше, что нельзя уронить. Агент после компактизации читает это точно так же.
  • «Подходы, которые НЕ сработали» — в разделе Discoveries. Без этой явной инструкции резюме склонны фокусироваться на достигнутом и тихо ронять тупики, из-за чего агент после компактизации повторяет те же провальные подходы и жжёт ходы. Одна эта фраза предотвращает конкретный режим отказа, в который иногда попадают свободные промпты для резюме.
  • «Cite exact filenames. No filler. No conversational framing.» Шаблон заставляет резюме быть действенным, а не повествовательным. Имена файлов к тому же побитово стабильны между перегенерациями, что сохраняет попадания в кеш, если многораундовая реализация позже станет инкрементально перерезюмировать.

Три строки ограничений, добавленные к обычному промпту «резюмируй транскрипт», — и получившееся резюме острее, меньше и удобнее для действий агенту после компактизации. Обобщаемый урок: сам промпт резюмирования — часть стратегии, а не деталь, которую можно оставить на усмотрение дефолтов модели.

Codex

Codex подходит к той же задаче куда проще. И Claude Code, и Codex глубоко заботятся о стабильности кеша — просто достигают её разным количеством машинерии. Claude Code делает её явным контрактом: точки останова cache_control в запросе, детерминированное вытеснение, вручную настроенная структура промпта. Codex опирается на историю в режиме «только дописывание» плюс префиксный кеш OpenAI — API сам решает, где и на сколько писать записи в кеш, а Codex просто не мешает.

В терминах нашей таксономии установившееся поведение Codex — это стратегия «ничего не делать»: отправлять разговор дословно, только дописывая, без переписывания на каждом ходу, префикс растёт монотонно. Интересные добавки включаются только как запасные механизмы. Два из них работают последовательно:

  • Компактизация при порогах — основной запасной механизм, проактивный. Она срабатывает, когда разговор пересекает порог, контролируемый Codex и выставленный ниже фактического окна контекста модели, так что срабатывает до того, как API отказал бы. Механизм: дополнительный вызов LLM резюмирует старую часть, и разговор пересобирается как [summary, recent...]. Стоит одного вызова резюмировщика, зато сохраняет связного заместителя выброшенных деталей.
  • Аварийная обрезка с начала — кнопка паники, реактивная. Она срабатывает, только если обычный запрос всё ещё возвращает ContextWindowExceeded уже после компактизации (что может случиться, если хвост после компактизации снова разросся или даже вывод самой компактизации слишком велик). Механизм: выбросить самое старое сообщение, повторить; выбросить следующее по старшинству, повторить; и так по кругу, пока запрос не влезет. Вызова LLM нет, но элементы исчезают полностью и без резюме, а каждая повторная попытка — потраченный впустую оплаченный запрос.

Основную часть любой сессии — до первой компактизации — поведение Codex на слое стратегии тождественно. Механизмы выше — то, что не даёт подходу «ничего не делать» падать на долгих сессиях, а не способ обращаться с установившейся стоимостью. (У Codex также есть ограничения на слое инструментов, разбираем отдельно ниже.)

Детали слоя стратегии для любопытных. Стабильность кеша держится не только на префиксе «только дописывание» — Codex задаёт ключ кеша на разговор (prompt_cache_key = conversation_id), чтобы ограничить кеш OpenAI рамками сессии. Компактизация реализована в отдельном модуле; её промпт резюмирования — структурированный шаблон, поданный как «сводка передачи дел для другой LLM», тот же концептуальный ход, что и у 5-секционного шаблона Claude Code выше, только менее жёстко структурированный. Оба продакшен-CLI усвоили один и тот же урок: свободного «резюмируй транскрипт» недостаточно, нужен контракт на то, что резюме обязано содержать. Хвост после компактизации ограничен 20 тыс. токенов. Путь аварийной обрезки несёт явный комментарий с обоснованием: «to preserve cache (prefix-based) and keep recent messages intact.»

Детали слоя инструментов. Два механизма Codex на слое инструментов работают во время выполнения, так что в разговоре хранится только уже усечённая версия — тот же архитектурный слот, что и ограничение в 50 КБ у read_file в opencode, вариант сжатия с теряющим хранилищем, разобранный в разделе Дизайн вывода инструментов выше. Во-первых, у инструмента shell есть жёсткое ограничение вывода в 1 МиБ; сверх него модели приходится самой изворачиваться с постраничностью через sed -n '...p'. Во-вторых, записанный вывод shell проходит через TruncationPolicy с усечением середины — усечение середины сохраняет дословно первые N байт и последние M байт, заменяя срединный отрезок маркером ...[truncated K bytes]..., в расчёте на то, что для вывода shell эхо команды в голове и код выхода в хвосте — это те байты, что несут сигнал.

opencode

opencode держит весь разговор в контексте без усечения на уровне разговора. Сжатие происходит на двух слоях:

  • Слой инструментов. Инструменты ограничивают себя во время выполнения: read_file ограничен ~50 КБ / 2000 символов на строку, совпадения grep выдаются постранично и так далее. В разговоре накапливается много маленьких результатов, а не несколько огромных.
  • Слой стратегии. Когда разговор пересекает порог, opencode запускает многораундовое резюмирование (похожее по форме на Claude Code и Codex), чтобы компактизировать старую историю.

Разделение: ограниченные инструменты снимают основное давление по размеру, компактизация подбирает остаток. Ставка opencode в том, что аккуратный дизайн каждого инструмента позволяет слою стратегии оставаться лёгким.

pi-coding-agent

Голый Agent (тот, что использует наш эксперимент) не поставляется с дефолтом — именно это сделало его удобным для чистого сравнения стратегий. Пакет более высокого уровня pi-coding-agent, построенный поверх Agent, поставляется с многораундовым резюмированием при переполнении, по форме похожим на opencode и Codex. Инструменты определяются на уровне агента; встроенный read_file собственного ограничения не накладывает.

Стратегии, которые мы реализуем

Теперь, увидев, что реально делают продакшен-CLI, мы можем выбрать небольшой набор показательных стратегий для измерения. Каждая стратегия в этой статье сравнивается с baseline — тождественной стратегией, где ничего не выбрасывается и не переписывается, а префикс растёт монотонно от хода к ходу, так что попадания в кеш находятся на теоретическом максимуме.

Каждая стратегия работает на одной и той же нагрузке — та же фикстура (описана ниже), та же модель, те же промпты. Для каждой стратегии мы запускаем агента 3 раза (k=3), потому что даже при temperature=0 траектории Gemini Flash расходятся от прогона к прогону, так что мы собираем 3 точки на стратегию, чтобы посчитать медиану/разброс, а не ставить на один прогон. Мы измеряем долю успехов, суммарную стоимость, ходы, пиковый размер промпта и то, сколько входных токенов было оплачено по кешированной и по некешированной ставке.

Мы подробно пройдём одну фикстуру — задачу починки бага в одном файле, которую будем звать основной фикстурой, — и приведём для неё результаты, транскрипты и подиходовые диффы по каждой стратегии. Мы выбрали по одному каноническому экземпляру на шаблон, вместо того чтобы гонять переборы параметров; цель — научить формам, а не отбенчмаркать каждую настройку. Каждая строка ниже соответствует шаблону из продакшен-CLI выше:

шаблонстратегиярежим
Без преобразования (контроль)baseline
Усечение вывода инструментов (единообразное)truncate-500каждый ход
Усечение вывода инструментов (с учётом возраста)age-truncate-500-keep-3каждый ход
Резюмирование старых ходов (структурированное)compact-at-12000-structuredпри порогах

Замечание о наименовании. Имя каждой стратегии следует форме <семейство>-<параметр>[-<модификатор>]. Так что age-truncate-500-keep-3 читается как: семейство = age-truncate (усечение с учётом возраста), 500 = ограничение в символах для старых результатов, keep-3 = 3 самых свежих результата инструментов проходят дословно. Аналогично compact-at-12000-structured — это: семейство = compact, at-12000 = срабатывает, когда разговор пересекает 12 000 символов, structured = использует шаблон структурированного резюме (в отличие от свободного варианта). У каждого числового токена смысл задан той ручкой семейства, которую он параметризует.

Оставшиеся шаблоны из таксономии — выбрасывание старых ходов (sliding-N), извлечение по требованию, замена старых чтений заглушками (форма COMPACTABLE_TOOLS у Claude Code), свободные резюме — намеренно вне рамок. Sliding-N и извлечение — потому что враждебны кешу по построению (префикс меняется каждый ход). Заглушки вместо старых чтений и свободная компактизация — потому что это вариации шаблонов, которые мы и так меряем (заглушки вместо старых чтений — это поинструментальная форма age-truncate; свободная компактизация — та же форма, что и структурированная, с другим промптом резюмировщика). И продакшен-грейд многораундовая компактизация — потому что это отдельная инженерная задача (какой порог, что хранить дословно, связывать ли резюме в цепочку), заслуживающая своей статьи.

Таблица из четырёх стратегий ниже — тот же набор, сгруппированный по режиму, той оси, вокруг которой строится анализ стоимости в статье:

Каждый ход (преобразование применяется на каждом вызове LLM).

стратегиячто выбрасываетреализация
truncate-500текст после 500 символов в каждом результате инструментаmap по результатам инструментов
age-truncate-500-keep-3текст после 500 символов только в старых результатах инструментовусечение с учётом позиции

При порогах (срабатывает один раз, когда разговор пересекает предел размера, затем замораживается).

стратегиячто выбрасываетреализация
compact-at-12000-structuredвсе ходы до фиксированной точки разреза, заменённые LLM-резюме по 5-секционному шаблону Claude Codeодин вызов LLM, замороженное резюме

Подробности по каждой стратегии — что она делает, реализация в одну строку, поведение кеша, форма стоимости, когда её применять и кто выпускает её в продакшене — см. в справочной таблице ниже. Нажмите на иконку, чтобы развернуть на весь экран для чтения бок о бок.

Strategy referenceAll strategies side-by-side. Click the icon to expand.
strategymodewhat it doesimplementationcache behaviorcost shapewhen to use itwho ships it
baselinenone (reference)transformContext returns the message list unchanged. The model sees the entire conversation on every call.messages => messages. Three characters of code.Maximally cache-friendly. Prompt prefix grows monotonically — every byte from position 0 is identical across turns.Linear in conversation length. Per-turn cost grows with each appended message; the last few turns of a 30-turn run are the most expensive of the run.Short sessions (under ~20 turns) where the conversation fits in budget and window. Also: any session where the cost of losing information would exceed paying for the full prompt.Every CLI ships baseline implicitly when no strategy is configured. Default in pi (when transformContext is omitted), opencode, and Claude Code before compaction fires.
truncate-500per-turnKeep every message, but cap each tool result's text at 500 characters with a `…[truncated K chars]` marker. Uniform cap, applied to every tool result regardless of age.Walk the message list; for each toolResult whose text exceeds 500 chars, replace the tail with the marker.Cache-stable. The cap is deterministic — once truncated to 500 chars, it stays exactly 500 chars on every subsequent turn.Smaller prompts than baseline, cache preserved. Cheap in the median when it works. Failure mode: if a bug lives past character 500 of a file the agent reads, the agent never sees it. Fails 0/3 on the main fixture and 0/3 on the multi-file fixture.Almost never at this aggressive a cap — it actively hides bugs. Demonstrates that the *uniform* truncation pattern is dangerous without an age qualifier.Variants of this pattern with larger caps (opencode caps tool output at ~2,000 chars at the tool layer); 500 is the failure-mode example we added.
age-truncate-500-keep-3per-turnSame 500-char cap as truncate-500, but applied only to older tool results. The 3 most recent tool results are kept verbatim regardless of length.Index tool results oldest → newest. Truncate text past 500 chars on all but the last 3. Recent results pass through unchanged.Cache-stable. The decision depends only on a tool result's position in the message list, which is monotonic — once a result becomes 'old enough', it stays truncated.Caps cumulative growth without hiding the file the agent is currently looking at. Ties baseline on cost, passes 3/3. On the multi-file fixture: 50% cheaper than baseline at the same pass rate; on the four-bug fixture: the only non-baseline strategy that passes 3/3.The article's default recommendation for short coding-agent sessions (10–50 turns). Avoids truncate-500's 'hide the bug' failure while still tightening the middle.Not directly. A generalization of Claude Code's per-tool eviction policy. We propose it explicitly because it's the lightest strategy satisfying cache stability, working-set preservation, and bounded growth.
compact-at-12000-structuredat thresholdsTrack conversation size. When it crosses 12,000 chars, fire one extra LLM call to summarize the older portion using Claude Code's 5-section template (Task Overview / Current State / Important Discoveries / Next Steps / Context to Preserve), wrapped in <summary> tags. Replace history with [first user message, frozen summary, recent messages]. Fires once per run; summary is frozen forever.Count chars across messages. If past threshold, slice off older portion, call summarizer model with the structured prompt, save the result. From that turn on, return trimmed-and-summarized list every time.Cache-stable after first compaction (summary is bit-stable once generated). Before compaction, identity. The single transition is the only place the prefix changes shape.Extra LLM call costs something. On short sessions: ties baseline ($0.016 on the main fixture) because the post-compaction prompt is small enough that the savings absorb the summarization cost. The structured template is shorter and more actionable than freeform alternatives.Sessions that reliably cross the threshold. The CC template is a strict improvement over freeform compaction — same infrastructure, more focused prompt.Paraphrased from system-prompt-context-compaction-summary.md in the leaked Claude Code source. opencode and pi-coding-agent both run compaction at thresholds (production versions are multi-round; ours is single-shot to isolate first-fire behavior).

Как pi их подключает

Стратегии выше не зависят от фреймворка агентов — они описывают, что делать со списком сообщений. Чтобы реально запустить их в нашем эксперименте, нужно место, куда их подключить. Мы используем pi, потому что, в отличие от большинства агентных CLI, он предоставляет сборку контекста как полноправную точку расширения — функцию, которую вы пишете, — что делает стратегии тривиально взаимозаменяемыми для сравнения.

Pi устроен так, что на каждой итерации агентного цикла — прямо перед отправкой истории сообщений в LLM, после того как последние результаты инструментов уже дописаны в эту историю, — агент вызывает два переопределяемых пользователем хука между «текущим транскриптом» и «тем, что LLM реально видит»:

new Agent({
  initialState: { systemPrompt, model, tools, thinkingLevel: "off" },
  // Structural layer: prune, summarize, or inject messages.
  transformContext: async (messages) => { /* ...your logic... */ },
  // Mapping layer: filter or translate custom message types.
  convertToLlm: (messages) => messages.filter(/* ... */),
});

transformContext — это хук, решающий, какой разговор должен быть у агента. Он принимает весь разговор как AgentMessage[]так в pi называется унифицированный тип сообщения, покрывающий пользовательские, ассистентские сообщения и результаты инструментов, — и возвращает (возможно, изменённый) AgentMessage[]. Тот же тип на входе, тот же на выходе. Именно здесь живёт каждая стратегия из таксономии выше: ограничить каждый результат инструмента N символами (truncate-500), ограничить только старые (age-truncate-500-keep-3), резюмировать при пороге (compact-at-12000-structured) и так далее.

convertToLlm — это хук, упаковывающий этот разговор для отправки по проводу. Он работает над выходом transformContext и переводит внутренний AgentMessage[] в специфичный для провайдера Message[], который реально уходит в Anthropic, Gemini или OpenAI: отфильтровывает пользовательские типы сообщений, которых провайдер не понимает, чинит блоки содержимого для моделей без поддержки вложений и так далее.

Для этой статьи мы оставляем convertToLlm в дефолте (тождественный фильтр для стандартных ролей сообщений) и полностью сосредотачиваемся на transformContext. В архитектуре pi стратегия сборки контекста — это просто функция:

type Strategy = (messages: AgentMessage[]) => Promise<AgentMessage[]>;

Эта сигнатура — вся имеющаяся поверхность расширения. Поскольку это код (а не конфиг-блоб), стратегия может делать произвольную работу: вызывать другую LLM, чтобы резюмировать старые ходы, эмбеддить прошлые сообщения и извлекать их по схожести, читать файлы с диска, хранить состояние между ходами через замыкание. Стратегии, по которым мы прошлись выше, тянутся от трёх строк (baseline) до нескольких десятков (compact-at-N-structured), но все они разделяют эту форму — и все взаимозаменяемы передачей другой функции в тот же хук.

Чтобы стало конкретно, вот что pi делает на одном ходу — подхватывая посреди сессии, когда в истории разговора уже лежат стартовый промпт пользователя, несколько раундов ответов LLM и стопка результатов инструментов с прошлых ходов:

  1. transformContext проходит по всей истории разговора — включая любые нетронутые результаты инструментов по 50 КБ, лежащие там, — и порождает список сообщений для отправки. Стратегия решает, что делать с каждым куском: пропустить как есть (baseline), усечь единообразно до 500 символов (truncate-500), усечь только старые результаты (age-truncate-500-keep-3), свернуть старую историю в резюме (compact-at-12000-structured) и так далее. Pi отправляет получившийся список в LLM. LLM видит представление стратегии, а не оригинал.
  2. LLM отвечает — текстом, намерениями вызвать инструменты или тем и другим.
  3. Если LLM выдала вызовы инструментов, pi выполняет каждый. Каждый инструмент возвращает свой полный вывод (например, 50 КБ содержимого файла). Pi дописывает в историю разговора и ответ LLM, и каждый результат инструмента.
  4. Возврат к шагу 1.
  5. Повторять, пока LLM не выдаст ответ без вызовов инструментов — это сигнал агенту остановиться.

Принципиально: исходные полные выводы инструментов никогда не покидают лог разговора на стороне pi. Они невидимы для LLM (потому что стратегия резюмирует или усекает их из промпта), но восстановимы — поменяйте стратегию посреди прогона или переиграйте лог позже, и полный текст вернётся. Это свойство «хранилища без потерь» из предыдущего раздела, сделанное конкретным.

Одна деталь, которую стоит проговорить явно: в pi у голого класса Agent нет стратегии по умолчанию. Если вы создаёте new Agent({...}), не передав transformContext, вы получаете тождественное поведение baseline — весь разговор отправляется каждый ход. Пакет более высокого уровня pi-coding-agent, построенный поверх Agent, дефолт всё же имеет (многораундовое резюмирование при переполнении, по форме похожее на opencode). Мы используем голый Agent для этих экспериментов, чтобы каждая стратегия в сравнении была написана нами явно и ничего встроенного не приходилось контролировать.

Постановка эксперимента

Фикстура, на которой мы будем тестировать разные стратегии, — слой сервиса и хранилища веб-приложения TODO. Работу делают два класса: TaskStore держит список задач в памяти, а Api — тонкий слой диспетчеризации, принимающий объекты запросов и маршрутизирующий их в хранилище:

export type ApiRequest =
  | { action: "add"; payload: { title: string } }
  | { action: "complete"; payload: { id: unknown } }
  | { action: "get"; payload: { id: unknown } }
  | { action: "list" };

export class Api {
  constructor(private store: TaskStore) {}

  handle(request: ApiRequest): ApiResponse {
    switch (request.action) {
      case "complete": {
        // 👇 The bug. `payload.id` is typed `unknown` and arrives as a string
        //    when the request comes from JSON. The cast silences TypeScript
        //    but does no runtime coercion — so `markComplete("1")` reaches
        //    `t.id === id` where t.id is a number, and the lookup misses.
        const found = this.store.markComplete(request.payload.id as number);
        return { ok: true, data: { completed: found } };
      }
      // …other cases…
    }
  }
}
export class TaskStore {
  private tasks: Task[] = [];

  markComplete(id: number): boolean {
    // 👇 Strict equality. If `id` arrives as a string ("1"), this returns
    //    undefined even when a Task with id 1 exists. Combined with the
    //    missing coercion in api.ts, this is what breaks the test.
    const task = this.tasks.find((t) => t.id === id);
    if (!task) return false;
    task.completed = true;
    return true;
  }
  // …add, get, list, clear…
}

Баг охватывает src/api.ts (диспетчер) и src/storage.ts (типизированное хранилище) — приведение типа в одном файле плюс строгое равенство в другом и порождают падающий тест.

Починка — один вызов Number() на границе API. Каким бы поверхностным баг ни был, он сидит после 500-го символа api.ts, и именно поэтому truncate-500 катастрофически провалится ниже: агент попросту не доходит до строки, которую нужно чинить.

Наша фикстура крайне проста по сравнению с серьёзными открытыми оценками, по которым отчитывается каждая лаборатория моделей: SWE-bench (и её варианты Verified / Live) для починок багов на полном репозитории, τ-bench для корректности использования инструментов, TerminalBench для задач в шелле, BigCodeBench для реалистичного кода с библиотеками, полиглот-бенчмарк Aider для многоязычного редактирования, — но она лучше подходит нашей цели. Все эти бенчмарки держат обвязку фиксированной и варьируют модель, порождая одно число на модель: полезно для ранжирования, непрозрачно насчёт причин. Эта статья делает обратное: модель фиксирована (Gemini 2.5 Flash), стратегия обвязки варьируется, а фикстура намеренно достаточно мала, чтобы каждый транскрипт можно было прочитать от начала до конца. Как только вы видите эти механизмы в 13-ходовом транскрипте, вы можете рассуждать о том, что они сделают в 200-ходовом.

Тесты в test/tasklist.test.ts прогоняют всю поверхность сквозь: добавить задачи, вывести список, отметить выполненной через JSON-запрос, получить по id. 3 файла исходников, 1 падающий тест, починка в одном файле.

// the failing test, abridged
import { test } from "node:test";
import assert from "node:assert/strict";
import { Api, parseRequest } from "../src/api.ts";
import { TaskStore } from "../src/storage.ts";

test("complete via API with a JSON payload marks the task completed", () => {
  const store = new TaskStore();
  const api = new Api(store);
  api.handle({ action: "add", payload: { title: "buy milk" } });

  // Clients serialize ids as strings (JSON over HTTP, URL path params, etc.).
  const raw = JSON.stringify({ action: "complete", payload: { id: "1" } });
  const request = parseRequest(raw);
  const res = api.handle(request);

  assert.equal(res.ok, true);
  if (!res.ok) return;
  assert.deepEqual(res.data, { completed: true });
});

// …also: "add creates a task with an id", "list returns all tasks"

Набор тестов гоняет API через JSON-цикл — JSON.stringify({...}), затем parseRequest(), — вместо того чтобы вызывать handle() с типизированным объектом напрямую. Именно этот цикл делает баг достижимым: это единственный способ, при котором payload.id приходит во время выполнения строкой. «Пользователь» библиотеки — это набор тестов; агента забрасывают внутрь, дают четыре инструмента (read_file, write_file, list_files, run_tests) и просят сделать набор зелёным.

Четыре инструмента, используемые в обвязке, мы реализовали сами как тонкие обёртки. Каждый — это несколько строк на Node fs плюс JSON-схема, зарегистрированная через интерфейс AgentTool в pi. Они намеренно без ограничений: read_file возвращает содержимое файла целиком без ограничения на вызов, без постраничности, без лимита на строку; write_file — простая перезапись; list_files возвращает полный листинг каталога. Если бы read_file ограничивал вывод на слое инструментов (как это делает opencode), truncate-500 и age-truncate-500-keep-3 вели бы себя неразличимо на маленьких файлах — ограничение инструмента делало бы работу, которую должна делать стратегия. Минимальность инструментов заставляет всякую наблюдаемую разницу в эксперименте происходить исключительно от стратегии сборки контекста.

Ниже можно увидеть тела четырёх инструментов, урезанные до путей execute (схемы, метки и разрешение рабочего каталога опущены для ясности):

// no per-call cap, no pagination, no per-line limit
async (_id, args) => {
  const abs = resolveInWorkdir(workdir, args.path);
  const contents = fs.readFileSync(abs, "utf8");
  return textResult(contents);
}
// plain overwrite — no diff, no validation, no edit-tolerance policy
async (_id, args) => {
  const abs = resolveInWorkdir(workdir, args.path);
  fs.mkdirSync(path.dirname(abs), { recursive: true });
  fs.writeFileSync(abs, args.content, "utf8");
  return textResult(`wrote ${args.content.length} bytes to ${args.path}`);
}
// recursive walk; returns every file path joined by newlines
async (_id, args) => {
  const rel = args.path ?? ".";
  const abs = resolveInWorkdir(workdir, rel);
  const entries: string[] = [];
  const walk = (dir: string) => {
    for (const name of fs.readdirSync(dir)) {
      const full = path.join(dir, name);
      if (fs.statSync(full).isDirectory()) {
        if (name === "node_modules" || name === ".git") continue;
        walk(full);
      } else {
        entries.push(path.relative(workdir.root, full));
      }
    }
  };
  walk(abs);
  entries.sort();
  return textResult(entries.join("\n") || "(empty)");
}
// shells out to `node --test`; returns full stdout + stderr + exit code
async () => {
  const testFiles = fs.readdirSync(path.join(workdir.root, "test"))
    .filter((n) => n.endsWith(".test.ts"))
    .map((n) => path.join("test", n));
  const result = spawnSync(
    "node",
    ["--experimental-strip-types", "--test", ...testFiles],
    { cwd: workdir.root, encoding: "utf8", timeout: 30_000 },
  );
  return textResult(
    `exit_code: ${result.status ?? -1}\n` +
    `--- stdout ---\n${result.stdout}\n` +
    `--- stderr ---\n${result.stderr}`,
  );
}

График ниже рисует четыре стратегии ход за ходом — по одному представителю на каждую форму, которую мы хотим показать: baseline (контроль — без преобразования), age-truncate-500-keep-3 (усечение с учётом возраста на каждом ходу), compact-at-12000-structured (компактизация при порогах с шаблоном Claude Code) и truncate-500 (катастрофический режим отказа — единообразное усечение, достаточно агрессивное, чтобы прятать баги). Переключайтесь между тремя метриками:

  • размер промпта — всего входных токенов, отправленных на этом ходу, считая и новые, и кешированные (input_tokens + cached_tokens из отчёта об использовании от провайдера). Именно это определяет, сколько работы LLM придётся прочитать.
  • накопленная стоимость — текущая сумма в долларах всех подиходовых счетов (промпт + кеш + вывод Gemini Flash) включительно до этого хода.
  • доля попаданий в кеш — для этого хода cached_tokens / (cached_tokens + input_tokens). 1.0 означает, что каждый входной токен пришёл из префиксного кеша; 0.0 — что не кешировано ничего и вы заплатили полную ставку за весь промпт. Оба числа мы берём прямо из подетального отчёта об использовании от провайдера и считаем долю на каждый ход.
metric:bug-01 · four strategies side-by-side

Также можно пройтись по любому из четырёх прогонов ход за ходом ниже. Сначала несколько терминов.

Ход — это один вызов LLM. Цикл вокруг каждого хода:

  1. transformContext проходит по накопленной истории разговора агента.
  2. LLM вызывается с результатом.
  3. LLM выдаёт ответ — текст и/или намерения вызвать инструменты.
  4. Агент раздаёт вызовы инструментов, выполняет каждый и дописывает результаты в историю.

На этом ход заканчивается. Следующий ход — следующий вызов LLM. К ходу N разговор вырастает примерно до 1 + 2(N-1) сообщений: стартовый промпт плюс чередующаяся пара «ответ LLM / результат инструмента» на каждый предыдущий ход.

Нажмите на любой Turn N в левом навигаторе, чтобы сфокусироваться на нём. Виджет показывает момент прямо перед вызовом LLM на этом ходу: сторона Before strategy — это всё накопленное вплоть до результатов инструментов хода N-1 (ответа хода N в этот момент ещё не случилось); сторона After strategy — это то, что transformContext породил из этого входа, то есть промпт, который LLM реально увидела. Вид Diff по умолчанию раскрашивает изменения: красные строки выброшены стратегией, зелёные добавлены или заменены, серые совпадают с обеих сторон. Переключитесь на Cards для структурированного вида по сообщениям. Для baseline обе стороны побитово идентичны — это контрольный случай. Для остальных трёх стратегий дифф — это центральный вопрос статьи, сделанный буквальным.

baseline · ✗ fail · $0.0614 · 27 LLM calls
Turns
Diff: before → after — red = removed by strategy, green = added/replaced
No changes — the strategy returned the conversation unchanged for this turn.
Before strategy
After strategy
=== USER ===
A test in test/tasklist.test.ts is failing. Find the bug in the source code under src/, fix it, and make the whole test suite pass.
=== USER ===
A test in test/tasklist.test.ts is failing. Find the bug in the source code under src/, fix it, and make the whole test suite pass.

Для каждого прогона мы копируем фикстуру в изолированный временный каталог, даём агенту четыре инструмента и позволяем работать, пока он не остановится. verify() запускает node --test ещё раз и фиксирует, зелёный ли набор тестов.

Модель: Gemini 2.5 Flash с temperature = 0 (пробрасывается через хук onPayload в pi, поскольку Agent не выставляет температуру напрямую). Даже при нуле Flash на практике не полностью детерминирована — пакетный инференс и шум плавающей точки заставляют траекторию агента расходиться между прогонами, поэтому мы делаем k=3 на ячейку, а не k=1.

Что мы измеряем на прогон:

  • pass — стал ли набор тестов зелёным в конце?
  • turns — число ходов ассистента (то есть вызовов LLM).
  • cost — доллары, просуммированные по каждому вызову в прогоне (промпт + вывод + кеш Gemini Flash).
  • peak prompt — наибольший размер промпта (новые + кешированные токены), который агент когда-либо отправил.
  • new input tokens — некешированные токены промпта, суммарно. Те, за которые вы платите полную цену.
  • cached input tokens — токены, отданные из неявного префиксного кеша Gemini.

Колонка cached — сигнал о границе кеша: высокое значение означает, что стратегия сохраняет префикс между ходами; низкое — что инвалидирует его.

Результаты по стратегиям

Числа ниже — средние по 3 прогонам (k=3) на стратегию; значения после ± — выборочное стандартное отклонение (так что 13±3 означает среднее в 13 ходов при σ ≈ 3 по прогонам). Колонки new и cached тоже средние на прогон — сколько типичный одиночный прогон заплатил некешированными и кешированными входными токенами.

стратегияnpassходыстоимостьпиковый промптnewcached
baseline33/313±3$0.016±0.0066,705±1,83027,16418,224
truncate-50030/312±6$0.098±0.0125,851±3,18727,16714,050
age-truncate-500-keep-333/313±3$0.017±0.0065,744±1,44429,08211,394
compact-at-12000-structured33/312±4$0.016±0.0075,212±1,17022,83414,462

Бросаются в глаза три вещи.

truncate-500 проваливается катастрофически — и дорого. 0/3 успехов, примерно за 6× стоимости baseline. Баг в api.ts сидит после 500-го символа файла, который читает агент, так что ограничение результатов инструментов в 500 символов буквально отрезает багованный код. Агент читает файл, видит выглядящий полным блок импортов и определения типов, доверяет им, не находит баг, пробует случайные правки и жжёт деньги. Слишком агрессивное усечение не просто теряет данные — оно обманчиво теряет данные, потому что у агента нет способа узнать, что он не видит нужный кусок. Это самый острый антипаттерн статьи.

age-truncate-500-keep-3 — лучший баланс. 3/3 успехов, средняя стоимость $0.017 (по сути вровень с baseline), попадания в кеш сохранены (11 тыс. кешированных токенов). Идея стратегии — хранить последние K результатов инструментов дословно, старые усекать — избегает режима отказа «спрятать баг» (агент всегда видит свежие чтения целиком), при этом всё равно поджимая середину разговора. К тому же она детерминирована и привязана к позициям, так что проходит правило стабильности кеша. Если вам нужен дефолт, это он.

Компактизация тоже работает. compact-at-12000-structured даёт 3/3 при $0.016 — по сути вровень с baseline, несмотря на лишний вызов LLM, нужный для резюме, потому что промпт после компактизации достаточно мал, чтобы экономия поглотила стоимость резюмирования. Порог важен: сработать слишком рано (до того, как диагноз бага устоялся в разговоре) — и резюме зафиксирует исследование, но не решение; сработать слишком поздно — и большая часть счёта уже оплачена. 12 000 символов попадают в удачную точку для этой фикстуры; продакшен-CLI используют многораундовую компактизацию с адаптивными порогами, чтобы покрыть общий случай.

Более широкая картина по четырём: три стратегии из четырёх дают 3/3 по сути за одну и ту же цену. Это само по себе находка: на задаче починки бага в 10–20 ходов, если ваша стратегия стабильна для кеша и не уничтожает информацию, которую агент активно использует, можно выбрать более-менее что угодно. Стоимость и надёжность резко расходятся только тогда, когда стратегия нарушает один из этих двух принципов — что и делает truncate-500 (она уничтожает рабочий набор), а остальные нет.

Предлагаемый собственный алгоритм: усечение результатов инструментов с учётом возраста

Большинство CLI и эталонных реализаций трактуют управление контекстом либо как «выбрасывать старое», либо как «резюмировать старое». У обоих есть проблемы, которые мы видели в данных. Выбрасывание старого (скользящее окно) убивает кеш. Резюмирование старого (компактизация) стоит лишнего вызова LLM и чувствительно к качеству резюме — если резюме пропустит диагноз бага, агент потеряет нить.

Есть третий вариант, который, судя по нашим данным, используется недостаточно: усекать хвост старых результатов инструментов, свежие хранить дословно, больше ничего не менять. Конкретно:

export function makeAgeAwareTruncate({ keepRecent, maxChars }) {
  return async function(messages: AgentMessage[]) {
    const resultIndices = messages
      .map((m, i) => (m.role === "toolResult" ? i : -1))
      .filter((i) => i !== -1);
    const keepFromIndex =
      resultIndices.length > keepRecent
        ? resultIndices[resultIndices.length - keepRecent]
        : -1;

    return messages.map((msg, i) => {
      if (msg.role !== "toolResult") return msg;
      if (i >= keepFromIndex) return msg;  // recent — leave verbatim
      return truncateTextBlocks(msg, maxChars);
    });
  };
}

Почему это хорошо работает:

  • Соблюдает правило границы кеша. Решение об усечении — чистая функция от позиции сообщения и длины текста: детерминированная, стабильная между ходами. Результат инструмента на позиции 5, однажды усечённый, остаётся усечённым одинаково на каждом последующем вызове. Префиксный кеш попадает так же, как у baseline.
  • Никогда не прячет баг. Последние K результатов инструментов — те, от которых зависит текущее рассуждение агента, — не трогаются вообще. Если агент только что прочитал файл, он видит файл целиком.
  • Это дёшево. Чистая работа в процессе; лишнего вызова LLM нет, в отличие от компактизации.
  • Это скучно. ~15 строк кода, один ясный инвариант. Не нужно упражняться в настройке порога (маленькое K вроде 3 работает на разных задачах). Нет подзадачи «а что должно попасть в резюме?».

Данные на основной фикстуре подтверждают заявку: 3/3 успехов, $0.017 — по сути вровень с baseline, несмотря на применение ограничения на каждом ходу. Ограничение не вредит агенту, потому что хвост — свежие чтения и вывод тестов, о которых агент активно рассуждает, — остаётся нетронутым. Интуиция в том, что ограничение ограничивает, насколько может вырасти промпт любого конкретного хода, тогда как свежий рабочий набор сохраняется дословно, так что ничего из нужного агенту прямо сейчас не прячется.

Она не превзойдёт компактизацию на действительно длинных разговорах — за некоторым горизонтом даже усечённые старые результаты инструментов вытесняют полезный контекст, — но для диапазона в 10–50 ходов, покрывающего большинство однозадачных сессий по коду, это лучший найденный нами дефолт.

Чем это отличается от двухслойного подхода opencode

opencode берётся за ту же задачу с противоположной стороны: он ограничивает вывод инструментов на слое инструментов (read_file возвращает максимум ~50 КБ / 2000 символов на строку, grep выдаёт постранично) и оставляет долгие сессии компактизации. Усечение результатов инструментов с учётом возраста живёт вместо этого на слое стратегии. Из этого выбора вытекают два существенных отличия:

  • Модель хранения. opencode делает теряющее хранилище — как только инструмент вернул ограниченный результат, остатка файла нигде локально нет; агенту приходится снова вызывать read_file с offset/limit, чтобы получить больше. age-truncate делает теряющее представление при хранилище без потерь — лог разговора навсегда хранит полный вывод каждого инструмента, а стратегия заново выводит сжатое представление на каждом ходу. Поменяйте стратегию посреди прогона — и полные байты вернутся без повторных вызовов инструментов. Проще экспериментировать; больше на диске.
  • Свежий рабочий набор. Ограничение opencode на слое инструментов единообразно: файл, который вы только что открыли, тоже ограничен 50 КБ. Если баг живёт за границей ограничения, агенту приходится запрашивать следующий срез — ровно тот режим отказа, который truncate-500 продемонстрировал в миниатюре на нашей фикстуре (там ограничение единообразно в 500 символов). opencode смягчает это сильно бо́льшим ограничением и явной постраничностью. age-truncate переворачивает дисциплину: K самых свежих результатов проходят дословно независимо от размера, и ограничиваются только старые. Так что чтение на 100 КБ, которое вы только что сделали, видно полностью; такое же чтение на 100 КБ десять ходов назад усечено до 500 символов.

Эти два подхода дополняют друг друга, а не соперничают. Ограничения opencode на слое инструментов сдерживают любой единичный огромный вывал (лог-файл на 1 МБ, стек-трейс от разбежавшегося теста); age-truncate-500-keep-3 не даёт количеству сохранённых дословно результатов расти неограниченно от хода к ходу. Продакшен-стек делал бы и то и другое: ограничивал вывод каждого инструмента на слое инструментов (чтобы единичные вывалы оставались разумными), затем гонял усечение с учётом возраста на слое стратегии (чтобы рабочий набор не накапливался), а компактизацию приберёг бы для настоящего случая долгой сессии.

Причина, по которой age-truncate работает в нашем эксперименте без ограничений на слое инструментов, — в том, что мы намеренно убрали эти ограничения, чтобы изолировать эффект стратегии. В продакшене вам нужны оба.

Есть отдельная линия работ, о которой стоит знать: обученные компакторы. Каждая стратегия компактизации в этой статье использует резюмировщика на промпте — ту же модель, просто попрошенную написать резюме. Обученный компактор заменяет промпт маленькой моделью, дообученной специально под достижение целевого коэффициента сжатия с сохранением качества на конечной задаче. Cmprsr (Zakazov et al., 2026) обучает Qwen3-4B через SFT + GRPO ровно для этого, включая «вопрос-агностичный» режим, чей вывод переиспользуем между уточняющими запросами, то есть стабилен для кеша в том смысле, который волновал §2. Продуктовая версия (compresr.ai) поставляет это как SDK-вызов compress(), который можно вставить в хук transformContext. Это естественно ложится на Pro-режим, вскрытый нашим переключением: когда дыры бьют по бюджету мышления агента и выигрывает связное сжатое представление, компактор, обученный на цели сжатия, а не уговорённый на неё, — следующее, к чему стоит потянуться.