Подробный разбор: как собирать контекст для LLM-агента
При каждом вызове модели среда выполнения агента выбирает контекст: всю беседу, отдельные сообщения, сводки, результаты инструментов и инструкции. От этого зависят доступная модели информация, стоимость вызова и сохранение прежних результатов работы.
Сравним четыре стратегии сборки контекста на небольшой задаче исправления ошибки, изучая сохранённые запуски. Эксперимент иллюстрирует компромиссы, а не устанавливает универсальный рейтинг.
Среда использует pi: хук transformContext позволяет менять представление сообщений перед вызовом модели. Одна из стратегий сохраняет недавние результаты инструментов целиком и сокращает старые.
Почему контекст — центральная задача управления для агента
Агент для программирования объединяет модель, цикл управления и среду, управляющую инструментами и контекстом. Среда выбирает доступную при каждом вызове информацию, поэтому обработка контекста может менять поведение даже при неизменной модели.
Цикл агента, сведённый к существу, таков:
- Отправить текущий контекст модели.
- Получить текст, вызовы инструментов или и то и другое.
- Выполнить вызовы, добавить результаты и повторить.
- Если вызовов инструментов нет, завершить этот цикл агента.
Вывод инструментов может занимать большую часть контекста. Размер зависит от файлов, команд и числа вызовов; тридцать ходов не означают фиксированного числа токенов. Символы и токены — разные единицы.
Поэтому сосредоточимся на сохранении и сокращении результатов инструментов, а также на сводках старой истории. Эти решения связаны с выбором сообщений и кешированием промптов.
Если просто дать истории расти бесконтрольно, мы упираемся в четыре проблемы:
- Стоимость: длинный промпт может увеличить стоимость входа; важны также скидки за кеш и выходные токены.
- Задержка: передача и обработка большого промпта может задержать ответ. Кеширование сокращает часть этой работы.
- Ёмкость: запрос должен помещаться в контекст модели с учётом места для ответа.
- Качество: в длинном контексте важные детали может быть труднее использовать. Эффект зависит от модели, задачи и расположения информации.
Стратегия сборки контекста — это не одно решение, а стопка взаимодействующих выборов:
- Что выбрасывается или переписывается?
- Когда — каждый ход или только при достижении порогов?
- Где в стеке — на слое инструментов, на слое стратегии или на обоих?
- Насколько агрессивно — в токенах, символах, сообщениях?
- Что вы резюмируете — старую историю, весь разговор, определённые типы инструментов?
- Как вы резюмируете — свободно, по структурированному шаблону, в несколько связанных раундов?
- А что с кешем — уважает ли ваше преобразование префикс или инвалидирует его каждый ход?
Мы выполнили по три запуска каждой стратегии на одной небольшой задаче. Средние и выборочные стандартные отклонения описывают эти запуски; трёх попыток недостаточно для надёжной оценки успешности или частоты редких дорогих сбоев.
| Метрика | Значение |
|---|---|
| Успешные тесты | Успешные запуски из трёх |
| Указанная стоимость | Среднее и выборочное стандартное отклонение стоимости записанных вызовов агента; без вызовов суммаризатора |
| Ходы | Записанные вызовы ассистента, не прямое измерение задержки |
| Размер промпта | Некешированные и кешированные входные токены по нормализованным полям pi |
| Доля кеша | Кешированные входные токены, делённые на все входные токены |
Рассматривайте успешность вместе со стоимостью и размером промпта. Более дешёвый запуск, не решивший задачу, не обязательно лучше.
Таксономия стратегий
Метрики установлены — теперь посмотрим на пространство решений, к которому они будут применяться. Стратегии, встречающиеся в продакшен-CLI, распадаются на несколько семейств по двум осям: где происходит сжатие и что сжимается.
Простейшая стратегия отправляет всю беседу при каждом вызове. Назовём её baseline. Неизменный префикс допускает повторное использование кеша по правилам провайдера. История всё равно приближается к пределу контекста, а качество работы с длинным контекстом может зависеть от расположения нужной информации.
Любая другая стратегия — это способ реализовать сжатие. Сжатие может происходить в трёх местах:
- На каждом ходу. Применять преобразование к списку сообщений на каждом вызове LLM, формируя промпт каждого хода по мере роста разговора.
- Только при порогах. Не трогать разговор, пока он не пересечёт некоторый предел размера, а затем однократно выполнить операцию (обычно резюмирование) над старой частью и заморозить результат на весь остаток прогона.
- На уровне инструмента. Ограничить или переписать возвращаемое значение инструмента до того, как оно попадёт в историю разговора. Живёт на слой ниже двух остальных и композируется с ними — подробно разбираем дальше.
Преобразования на каждом ходе можно сочетать с компактизацией по порогу. Детерминированное усечение не требует вызова суммаризатора, но может скрыть нужный текст. Сводка сокращает больше истории, однако стоит денег и может упустить детали. Ни один подход сам по себе не гарантирует хорошего использования кеша.
Скользящее окно сохраняет недавние сообщения и удаляет старые. Оно может терять полезную историю и снижать повторное использование префикса, но подходит некоторым ограниченным задачам с внешним состоянием. Здесь мы его не оцениваем.
Есть несколько вариантов того, какую информацию удалять или сокращать:
- Удалять старые ходы, при необходимости сохраняя исходную задачу.
- Заменять старые результаты краткими версиями или ссылками для повторного чтения.
- Ограничивать текст результата, явно отмечая усечение.
- Суммировать старую историю.
- Извлекать нужную информацию из полного журнала. При сборке запроса сохраняйте корректные связи вызовов инструментов с результатами.
Эти шаблоны не взаимоисключающие — большинство продакшен-стратегий складывают два-три вместе, например: усечь каждый результат инструмента, затем резюмировать старые ходы, затем выбросить содержимое до резюме.
Стабильность кеша
Кеширование префикса повторно использует вычисления для неизменного начала входа модели. Правила совпадения, минимальные размеры, сроки хранения и цены различаются; см. документацию OpenAI, Anthropic и Gemini. Важно совпадение сформированного контекста модели, а не обязательно байтов HTTP-запроса.
Преобразование помогает повторно использовать префикс, если уже отправленный текст не меняется. Перезапись старого сообщения нарушает совпадение с существующей записью начиная с этого места; следующий запрос может использовать новый префикс.
Простой пример стабильной для кеша стратегии — ограничение на результаты инструментов: каждый ход стратегия проходит по разговору с полным выводом инструментов и усекает каждый результат до 500 символов. Поскольку правило детерминировано, а исходный текст результата инструмента не меняется, усечённая версия побитово идентична в той же позиции на каждом последующем вызове.
Скользящее окно обычно меняет префикс беседы после постоянных инструкций. Усечение по возрасту тоже меняет префикс, когда результат впервые выходит из сохраняемой группы. Детерминированность стабилизирует сокращённый текст впоследствии, но не предотвращает потерю совпадения дальнейшего кешированного контекста в момент изменения.
Компактизация может заменить старую сводку или добавить новый блок. Замена сокращает повторное использование префикса, но уменьшает промпт; добавление сохраняет больше истории, но увеличивает её. Ключи кеша не позволяют переиспользовать изменённое содержимое: prompt_cache_key OpenAI — не аналог точки cache_control Anthropic.
Два смысла слова «кеширование»: префиксное и семантическое
Всё изложенное выше — про префиксное кеширование, механизм на стороне провайдера, который делает дешёвой повторную отправку длинного стабильного разговора. Его стоит отделять от другой вещи, которую в агентных системах тоже называют «кешированием»: от семантического кеширования, которое стоит перед моделью и пытается пропустить вызов целиком.
Эти два легко перепутать, потому что оба обещают «более дешёвые вызовы LLM», но они работают на разных слоях и имеют разные режимы отказа:
- Кеширование префикса повторно использует вычисления для совпадающего начала входа. Модель всё равно генерирует новый ответ; кеш не гарантирует одинаковые ответы.
- Семантический кеш возвращает сохранённый ответ на достаточно похожий запрос. Это позволяет избежать инференса, но приложение должно учитывать ошибочные совпадения и устаревшие ответы.
Ключ семантического кеша для агента должен учитывать состояние репозитория, инструкции и результаты инструментов. Похожей формулировки недостаточно для повторного использования ответа. Далее речь о кешировании префиксов.
Теряющее хранилище против теряющего представления
Отдельно стоит вопрос о том, где происходит сжатие, а значит — что попадает в разговор на хранение. Два варианта с одинаковым наблюдаемым эффектом для модели:
- Уровень инструментов: инструмент ограничивает вывод до добавления в беседу. Пропущенный текст отсутствует в результате, но может оставаться в файле или отдельном журнале.
- Уровень стратегии: среда сохраняет полные результаты и формирует сокращённое представление для каждого вызова модели. Так работают наши четыре стратегии.
Разница не проявляется в промпте LLM — оба дизайна порождают один и тот же текст. Она проявляется в том, что остаётся на диске:
Стратегия может восстановить текст из журнала без повторного вызова инструмента. Ограничение на уровне инструмента может потребовать нового чтения, которое вернёт уже другую версию файла. Ни один подход не гарантирует бессрочную доступность старой информации.
Это разделение отражено в том, какие хуки предоставляют фреймворки агентов. Сжатию на слое инструментов хук фреймворка не нужен — инструменты это просто функции, которые вы пишете, так что ограничение на слое инструментов — это ограничение внутри реализации инструмента. Со слоем стратегии иначе: он работает каждый ход по движущейся мишени (растущему разговору), поэтому фреймворк обязан предоставить для него точку входа.
Каждый запуск сохраняет полные результаты инструментов. Это независимые запуски агента, а не воспроизведение одной беседы: действия и траектории могут различаться.
Дизайн вывода инструментов: вторая половина картины
Всё до сих пор происходило на слое сборки контекста — transformContext работает над уже имеющимся списком сообщений. Но есть параллельное пространство решений слоем ниже: что сами инструменты решают возвращать. Инструмент, вываливающий сырой вывод, заставляет вашу стратегию делать всю работу. Инструмент, ограничивающий собственный вывод, уменьшает задачу вашей стратегии — иногда до исчезающе малой.
Здесь важны лимит вывода за вызов и постраничное чтение. Например, указанная версия read в opencode ограничивает число байтов и длину строк и позволяет читать выбранный диапазон. Агент может запросить пропущенные части отдельно.
Лимиты инструментов ограничивают отдельные результаты, а не всю беседу. Постраничное чтение — выборочное получение данных, а не сжатие; повторные чтения всё равно могут создать большую историю.
Как с контекстом обходятся реальные CLI
Прежде чем зафиксировать, какие стратегии мы будем мерить, стоит посмотреть, как эту задачу на самом деле решают продакшен-CLI. Каждый берёт свою смесь шаблонов из таксономии, иногда с оглядкой на то, что предоставляет API их целевого провайдера. Вот быстрый обзор того, что видно в исходниках.
Claude Code
Документация Claude Code описывает автоматическую компактизацию при заполнении контекста. Приведённые ниже схемы и правила для инструментов — примеры проектирования, а не проверенная реконструкция реализации.
Удобная для кеша схема помещает стабильные инструкции перед меняющейся историей. Псевдокод ниже показывает начальное сообщение до компактизации и сводку после неё. В реальном запросе Anthropic точки кеширования сообщений задаются в блоках содержимого.
// 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: [{ type: "text", text: 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: [{ type: "text", text: FROZEN_SUMMARY,
cache_control: { type: "ephemeral" } }] }, // 👈 cache breakpoint #2 (last frozen item)
// ─── tail: appended each turn since compaction ───────────
...recentKMessages,
],
});Маркеры задают вложенные префиксы, а не независимые кешируемые фрагменты. Изменение раннего блока нарушает совпадение последующего содержимого. После замены сводки нужен новый совпадающий префикс; стабильный префикс инструкций может оставаться доступным.
Политики старения по инструментам
Правила для отдельных инструментов могут сокращать старые чтения, сохраняя подтверждения записи и результаты тестов. Ниже — пример политики для наших четырёх инструментов. Отдельно её не тестируем.
| Инструмент | Правило старения |
|---|---|
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 каждого ранга остаются дословными.
Сохранённый путь позволяет перечитать файл, но не сохраняет прежнее содержимое. Результаты тестов тоже могут устареть после правок. Политика должна учитывать, какие исторические сведения нужны задаче.
Шаблон структурированного резюме
В эксперименте со структурированной компактизацией используется следующий шаблон из пяти разделов. Он просит сохранить задачу, текущее состояние, находки, следующие шаги и точные детали, необходимые для продолжения.
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.Структура задаёт конкретный формат передачи работы. Сводка всё равно может пропускать или искажать детали, поэтому полезность шаблона нужно проверять на последующих задачах.
Codex
В Codex есть отдельные настройки автоматической компактизации и сохранённого вывода инструментов. Справочник конфигурации описывает model_auto_compact_token_limit и tool_output_token_limit. Они ограничивают разные источники роста контекста.
opencode
opencode сочетает ограничение вывода инструментов с компактизацией беседы. Это разные уровни: ограничение каждого результата не отменяет управление историей.
pi-coding-agent
Эксперимент использует базовый Agent с нашими инструментами и явными стратегиями контекста. Это не следует путать с pi coding agent, который добавляет свои инструменты и управление контекстом.
Стратегии, которые мы реализуем
Сравним четыре стратегии, взяв за основу baseline, отправляющий неизменённую историю.
Для каждой стратегии проводим три независимых запуска с одинаковыми задачей, моделью и начальным промптом. Приводим средние и выборочные стандартные отклонения. Три запуска показывают поведение на этой задаче, но не дают надёжной оценки частоты ошибок или общего рейтинга.
Используем одну небольшую тестовую задачу и один набор параметров для каждой стратегии. Названия ниже обозначают реализации и их параметры.
| шаблон | стратегия | режим |
|---|---|---|
| Без преобразования (контроль) | baseline | — |
| Усечение вывода инструментов (единообразное) | truncate-500 | каждый ход |
| Усечение вывода инструментов (с учётом возраста) | age-truncate-500-keep-3 | каждый ход |
| Резюмирование старых ходов (структурированное) | compact-at-12000-structured | при порогах |
В age-truncate-500-keep-3 число 500 — длина сохраняемого начала сокращаемого текстового блока, а 3 — число полных последних результатов. Маркер усечения добавляет символы сверх лимита. compact-at-12000-structured выполняет одну структурированную компактизацию после порога 12 000 символов.
Скользящие окна, поиск, замена результатов ссылками и повторная компактизация не входят в эксперимент. Их поведение с кешем зависит от реализации и не следует из этих четырёх условий.
Таблица из четырёх стратегий ниже — тот же набор, сгруппированный по режиму, той оси, вокруг которой строится анализ стоимости в статье:
Каждый ход (преобразование применяется на каждом вызове LLM).
| стратегия | что выбрасывает | реализация |
|---|---|---|
truncate-500 | текст после 500 символов в каждом результате инструмента | map по результатам инструментов |
age-truncate-500-keep-3 | текст после 500 символов только в старых результатах инструментов | усечение с учётом позиции |
При порогах (срабатывает один раз, когда разговор пересекает предел размера, затем замораживается).
| стратегия | что заменяет | реализация |
|---|---|---|
compact-at-12000-structured | историю до фиксированной точки разделения | одна сводка по нашему шаблону из пяти разделов |
Таблица сравнивает четыре реализации и их ограничения.
| strategy | mode | what it does | implementation | cache behavior | cost and results | when to use it | scope |
|---|---|---|---|---|---|---|---|
baseline | none (reference) | Send the full message history unchanged. | messages => messages | An unchanged prefix permits reuse, subject to provider cache rules. | Depends on history length, output, caching, and the number of turns. | A reference condition while the full history fits the context and budget. | Identity transform in this experiment. |
truncate-500 | per-turn | Keep the first 500 characters of each tool-result text block, followed by a truncation marker. | Shorten each oversized text block on every call. | Each shortened block stays unchanged on later calls. The marker adds characters beyond the 500-character prefix. | Passed 0/3 trials; logged agent cost was about six times baseline on this fixture. | An example of a cap that hides relevant code when tools provide no pagination. | Custom strategy in this experiment. |
age-truncate-500-keep-3 | per-turn | Keep the three newest tool results intact; shorten older text blocks to a 500-character prefix plus a marker. | Locate the three newest tool results, then shorten older ones. | When a result first ages out, its changed text breaks the existing prefix match from that point. It stays stable afterward. | Passed 3/3 trials at $0.017 mean logged agent cost. History still grows. | A candidate to evaluate when recent full results matter; three trials do not establish a general default. | Custom strategy in this experiment. |
compact-at-12000-structured | at thresholds | At the 12,000-character threshold, summarize older history once using the five-section template. Retain the seed, frozen summary, and recent messages. | Generate one summary, save the split point, then reuse the resulting view. | Compaction changes the prefix. The frozen summary allows later requests to reuse the new prefix. | Passed 3/3 trials at $0.016 mean logged agent cost. The separate summarizer call is excluded, so all-in cost is unknown. | An example of single-shot compaction; repeated compaction and other tasks need separate evaluation. | Custom strategy in this experiment. |
Как 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 преобразует внутренний AgentMessage[] в формат интерфейса моделей pi. Адаптеры провайдеров затем формируют запросы для конкретных API.
Для этой статьи мы оставляем convertToLlm в дефолте (тождественный фильтр для стандартных ролей сообщений) и полностью сосредотачиваемся на transformContext.
В архитектуре pi стратегия сборки контекста — это просто функция:
type Strategy = (messages: AgentMessage[]) => Promise<AgentMessage[]>;Эта сигнатура — вся имеющаяся поверхность расширения. Поскольку это код (а не конфиг-блоб), стратегия может делать произвольную работу: вызывать другую LLM, чтобы резюмировать старые ходы, эмбеддить прошлые сообщения и извлекать их по схожести, читать файлы с диска, хранить состояние между ходами через замыкание. Стратегии, по которым мы прошлись выше, тянутся от трёх строк (baseline) до нескольких десятков (compact-at-N-structured), но все они разделяют эту форму — и все взаимозаменяемы передачей другой функции в тот же хук.
Чтобы стало конкретно, вот что pi делает на одном ходу — подхватывая посреди сессии, когда в истории разговора уже лежат стартовый промпт пользователя, несколько раундов ответов LLM и стопка результатов инструментов с прошлых ходов:
transformContextпроходит по всей истории разговора — включая любые нетронутые результаты инструментов по 50 КБ, лежащие там, — и порождает список сообщений для отправки. Стратегия решает, что делать с каждым куском: пропустить как есть (baseline), усечь единообразно до 500 символов (truncate-500), усечь только старые результаты (age-truncate-500-keep-3), свернуть старую историю в резюме (compact-at-12000-structured) и так далее. Pi отправляет получившийся список в LLM. LLM видит представление стратегии, а не оригинал.- LLM отвечает — текстом, намерениями вызвать инструменты или тем и другим.
- Если LLM выдала вызовы инструментов, pi выполняет каждый. Каждый инструмент возвращает свой полный вывод (например, 50 КБ содержимого файла). Pi дописывает в историю разговора и ответ LLM, и каждый результат инструмента.
- Возврат к шагу 1.
- Повторять, пока 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 (типизированное хранилище) — приведение типа в одном файле плюс строгое равенство в другом и порождают падающий тест.
Исправление преобразует ID через Number() на границе API. Нужная строка находится после 500-го символа api.ts, за пределом равномерного усечения.
Эта задача намного меньше репозиторных тестов вроде SWE-bench. Здесь её преимущество — короткие записи, которые можно изучить целиком. Мы фиксируем модель и меняем обработку контекста; более крупные задачи требуют отдельной оценки.
Тесты в 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"Тесты сериализуют запросы со строковым id и передают их в parseRequest(). JSON сохраняет строку, а не превращает числовые идентификаторы в строки. Агент получает четыре инструмента и задачу добиться прохождения тестов.
Наши четыре инструмента — простые функции, зарегистрированные через интерфейс AgentTool в pi. Чтение файлов намеренно не ограничено и не разбито на страницы, чтобы лимиты инструментов не мешали сравнению стратегий. Независимые запуски всё равно могут идти разными путями.
Ниже можно увидеть тела четырёх инструментов, урезанные до путей 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}`,
);
}График показывает по одному примеру запуска каждой стратегии. Можно выбрать размер промпта, накопленную учтённую стоимость или долю входа из кеша.
- Размер промпта: новые и кешированные входные токены по нормализованным полям pi.
- Накопленная учтённая стоимость: сумма стоимости вызовов агента. Отдельный вызов для сводки сюда не входит.
- Доля входа из кеша: кешированные входные токены, делённые на весь вход данного вызова.
Также можно пройтись по любому из четырёх прогонов ход за ходом ниже. Сначала несколько терминов.
Ход — это один вызов LLM. Цикл вокруг каждого хода:
transformContextпроходит по накопленной истории разговора агента.- LLM вызывается с результатом.
- LLM выдаёт ответ — текст и/или намерения вызвать инструменты.
- Агент раздаёт вызовы инструментов, выполняет каждый и дописывает результаты в историю.
Каждый следующий вызов модели считается новым ходом. Число сообщений зависит от числа вызовов инструментов в ответах ассистента.
Нажмите на любой Turn N в левом навигаторе, чтобы сфокусироваться на нём. Виджет показывает момент прямо перед вызовом LLM на этом ходу: сторона Before strategy — это всё накопленное вплоть до результатов инструментов хода N-1 (ответа хода N в этот момент ещё не случилось); сторона After strategy — это то, что transformContext породил из этого входа, то есть промпт, который LLM реально увидела. Вид Diff по умолчанию раскрашивает изменения: красные строки выброшены стратегией, зелёные добавлены или заменены, серые совпадают с обеих сторон. Переключитесь на Cards для структурированного вида по сообщениям. Для baseline обе стороны побитово идентичны — это контрольный случай.
Для остальных трёх стратегий дифф — это центральный вопрос статьи, сделанный буквальным.
=== 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. Даже с этой настройкой траектории различались, поэтому для каждого условия есть три запуска. Причина изменчивости в эксперименте не устанавливается.
Что мы измеряем на прогон:
pass— стал ли набор тестов зелёным в конце?turns— число ходов ассистента (то есть вызовов LLM).cost— учтённая стоимость вызовов агента без отдельного вызова для сводки.peak prompt— наибольший размер промпта (новые + кешированные токены), который агент когда-либо отправил.new input tokens— некешированные токены промпта, суммарно. Те, за которые вы платите полную цену.cached input tokens— токены, отданные из неявного префиксного кеша Gemini.
Число кешированных токенов зависит и от повторного использования префикса, и от числа вызовов. Само по себе оно не измеряет стабильность кеша стратегии.
Результаты по стратегиям
Числа ниже — средние по 3 прогонам (k=3) на стратегию; значения после ± — выборочное стандартное отклонение (так что 13±3 означает среднее в 13 ходов при σ ≈ 3 по прогонам). Колонки new и cached тоже средние на прогон — сколько типичный одиночный прогон заплатил некешированными и кешированными входными токенами.
| стратегия | n | pass | ходы | стоимость | пиковый промпт | new | cached |
|---|---|---|---|---|---|---|---|
| baseline | 3 | 3/3 | 13±3 | $0.016±0.006 | 6,705±1,830 | 27,164 | 18,224 |
| truncate-500 | 3 | 0/3 | 12±6 | $0.098±0.012 | 5,851±3,187 | 27,167 | 14,050 |
| age-truncate-500-keep-3 | 3 | 3/3 | 13±3 | $0.017±0.006 | 5,744±1,444 | 29,082 | 11,394 |
| compact-at-12000-structured | 3 | 3/3 | 12±4 | $0.016±0.007 | 5,212±1,170 | 22,834 | 14,462 |
Равномерное усечение дало 0/3 успешных запусков, а учтённая стоимость оказалась примерно в шесть раз выше baseline. Нужный код находится после 500-го символа. Маркер сообщает о пропуске текста, но инструменты не поддерживают постраничное чтение для его получения в ограниченном представлении.
Усечение по возрасту дало 3/3 успешных запусков при средней учтённой стоимости $0,017. Свежие результаты остаются полными, но старые сведения могут исчезнуть из представления. 11 тыс. кешированных токенов меньше 18 тыс. у baseline: сокращение стареющего результата меняет префикс даже при детерминированном преобразовании.
Компактизация дала 3/3 успешных запусков при средней учтённой стоимости агента $0,016. Журнал не учитывает вызов для сводки, поэтому это не полная стоимость и не доказательство окупаемости суммаризации. Суммаризатор также получает заранее сокращённую запись: до 1500 символов вывода инструмента и 200 символов аргументов вызова.
Эксперимент показывает неудачу жёсткого лимита и три успешных альтернативы на этой задаче. Он не определяет лучший вариант для программирования в целом.
Предлагаемый собственный алгоритм: усечение результатов инструментов с учётом возраста
Стратегия по возрасту сохраняет три последних результата целиком и сокращает более старые:
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);
});
};
}- Последние результаты остаются полными независимо от размера.
- Старые результаты могут потерять всё ещё нужные детали.
- Сокращение стареющего результата меняет префикс начиная с этого места.
- Преобразование не требует дополнительного вызова модели, но история продолжает расти.
Три защищённых результата — параметр этой задачи, а не общая рекомендация. Длинные задачи и большие свежие результаты требуют отдельных тестов и ограничений размера.
Чем это отличается от двухслойного подхода opencode
Лимит инструмента ограничивает то, что попадает в беседу; представление по возрасту — то, что отправляется позже. Файл может оставаться на диске в обоих случаях, но только сохранённый полный результат сохраняет точное содержимое прежнего чтения. Механизмы можно сочетать, учитывая постраничное чтение и доступ к пропускам.