Як зібрати контекст для LLM-агента: докладний розбір
Під час кожного виклику моделі програма агента кодування вибирає, який контекст надсилати: повну розмову, вибрані повідомлення, підсумки, результати інструментів та інструкції. Ці вибори впливають на те, на що може діяти модель, скільки коштує дзвінок і чи залишається доступною попередня робота.
Ми порівнюємо чотири стратегії збирання контексту на невеликій системі виправлення помилок, використовуючи збережені запуску, щоб перевірити їх поведінку. Експеримент ілюструє компроміси, а не встановлення загального рейтингу.
У джгуті використовується pi, чий гачок transformContext дозволяє змінювати вигляд повідомлення перед кожним викликом моделі. Одна стратегія зберігає останні результати інструменту недоторканими та скорочує старі.
Чому контекст є центральною проблемою керування агента
Агент кодування поєднує в собі модель, цикл керування та джгут, який керує інструментами та контекстом. Канал визначає, яка інформація доступна для кожного виклику, тому обробка контексту може змінити поведінку, навіть якщо модель залишається фіксованою.
Контур агента, розібраний до основних, це:
- Надішліть поточний контекст до моделі.
- Отримувати текстові повідомлення, виклики інструментів або те й інше.
- Виконайте виклики інструментів і додайте їх результати, а потім повторіть.
- Якщо відповідь не містить викликів інструментів, завершіть цей цикл агента.
Результат інструменту може стати значною частиною контексту сеансу кодування. Його розмір залежить від файлів, команд і кількості викликів; тридцять ходів не означають фіксованої кількості жетонів. Символи та жетони також є різними одиницями.
Тому ми зосереджуємося на обробці результатів інструментів: зберігаємо їх, скорочуємо або узагальнюємо старішу історію. Ці параметри взаємодіють із вибором повідомлень і кешуванням підказок.
Якщо ми просто залишимо історію без контролю, ми зіткнемося з чотирма проблемами:
- Вартість: довші підказки можуть збільшити вартість введення; знижки на кеш-пам’ять і використання виводу також мають значення.
- Затримка: передача та обробка великого запиту може затримати відповідь. Кешоване попереднє заповнення може зменшити частину цієї роботи.
- Ємність: запит має відповідати обмеженню контексту вибраної моделі, включаючи простір для виведення.
- Якість: релевантні деталі може стати складніше використовувати в довгому контексті. Ефект залежить від моделі, завдання та розміщення інформації.
Стратегія збирання контексту — це не окреме рішення — це набір взаємодіючих варіантів:
- Що викидається або переписується?
- Коли — на кожному кроці чи тільки на порогах?
- Де в стеку — рівень інструментів, рівень стратегії чи обидва?
- Наскільки агресивно — у токенах, символах, повідомленнях?
- Що ви підсумовуєте — давнішу історію, цілу розмову, певні типи інструментів?
- Як ви підсумовуєте — довільна форма, структурований шаблон, багатораундовий ланцюжок?
- А як щодо кешу — ваша трансформація поважає префікс чи робить його недійсним щоразу?
Ми провели по три випробування для кожної стратегії на одному невеликому приладі. Повідомлені середні значення та вибіркові стандартні відхилення описують ці цикли; три випробування не можуть встановити надійний рівень успіху або частоту рідкісних дорогих невдач.
| Метрика | Тлумачення |
|---|---|
| Тест проходить | Успішні пробіги з трьох |
| Повідомлена вартість | Середнє та вибіркове стандартне відхилення зареєстрованих витрат на виклик агента; виклики підсумовувача виключаються |
| Повороти | Зареєстровані виклики помічника, а не пряме вимірювання затримки |
| Підкажіть розмір | Некешовані та кешовані маркери введення, використовуючи нормалізовані поля використання pi |
| Коефіцієнт попадання в кеш | Кешовані введення, поділені на загальні введення |
Ознайомтеся з успіхом виконання завдання разом із вартістю та оперативним розміром. Дешевша пробіжка, яка не виконує завдання, не обов’язково є корисним покращенням.
Таксономія стратегій
Отримавши метрики, давайте розглянемо простір дизайну, до якого вони будуть застосовані. Стратегії, які з’являються у робочих CLI, поділяються на декілька груп за двома осями: де відбувається стиснення та що стискається.
Найпростіша стратегія надсилає повну розмову під час кожного виклику. Ми називаємо це baseline. Незмінний префікс дозволяє повторно використовувати кеш відповідно до правил постачальника. Історія все ще зростає до обмеження контексту, і продуктивність довгоконтекстного режиму може залежати від того, де з’являється відповідна інформація.
Будь-яка інша стратегія є способом впровадження стиснення. Стиснення може відбуватися в трьох місцях:
- За хід. Застосовуйте трансформацію до списку повідомлень під час кожного виклику LLM, формуючи підказку кожного ходу в міру зростання розмови.
- Лише на порогових значеннях. Залиште розмову в спокої, доки вона не перетне певний ліміт розміру, а потім запустіть одноразову операцію (зазвичай підсумовування) на старішій частині та заморозьте результат на решту циклу.
- На рівні інструменту. Обмежте або перепишіть значення, що повертається інструментом, перш ніж він увійде в історію розмов. Живе на один шар нижче двох інших і створює з ними — ми детально розкриваємо це далі.
Покрокові перетворення та ущільнення за допомогою порогу можна комбінувати. Детермінізоване скорочення дозволяє уникнути виклику підсумовувача, але може приховати необхідний текст. Резюмування може значно скоротити історію, але збільшує вартість і може пропустити деталі. Жоден підхід не гарантує автоматичного повторного використання кешу.
У розсувному вікні зберігаються останні повідомлення та видаляються старіші. Він може видалити корисну історію та зменшити повторне використання префіксів, але може підійти для обмежених завдань із збереженим зовнішнім станом. Ми не оцінюємо це тут.
Є кілька варіантів щодо того, що видалити або конденсувати:
- Відкидайте старі ходи, за бажанням зберігаючи початкове завдання.
- Замініть старі результати інструментів коротшими результатами або відновленими посиланнями.
- Закрити текст результату інструмента, зберігаючи маркер усічення.
- Узагальніть старішу історію.
- За потреби отримайте відповідну інформацію з повного журналу. Під час побудови запиту зберігайте дійсні зв’язки інструмент-виклик/результат.
Ці шаблони не є взаємовиключними — більшість виробничих стратегій складаються з двох або трьох, напр. скоротити кожен результат інструменту, потім узагальнити старіші повороти, а потім видалити попередній підсумковий вміст.
Стабільність кешу
Кешування префіксів повторно використовує обчислення для незмінного початку вхідних даних моделі. Правила відповідності, мінімальні розміри, утримання та виставлення рахунків залежать від постачальника. Див. документацію OpenAI, Anthropic і Gemini. Релевантним збігом є відтворений контекст моделі, не обов’язково необроблені байти запиту HTTP.
Перетворення підтримує повторне використання префікса, коли вже виданий вміст залишається незмінним. Переписування старішого повідомлення порушує відповідність із цього моменту для наявного запису кешу; пізніший запит може повторно використовувати новостворений префікс.
Простий кеш-стабільний приклад — це обмеження результатів інструменту: кожен хід стратегії запускає бесіду з повним виведенням виклику інструменту та скорочує кожен результат інструмента до 500 символів. Оскільки правило є детермінованим і текст результату основного інструменту не змінюється, усічена версія є бітово-ідентичною в тій самій позиції під час кожного наступного виклику.
Розсувне вікно зазвичай змінює префікс розмови після фіксованих інструкцій. Усічення з урахуванням віку також змінює префікс щоразу, коли результат спочатку старіє. Детермінізм робить скорочений текст стабільним згодом, але не заважає цьому переходу зробити недійсним пізніше кешований вміст.
Ущільнення може замінити старе резюме або додати інший блок підсумку. Його заміна втрачає частину повторного використання префікса, але зберігає розмір підказки; додавання зберігає більше історії, але дозволяє їй рости. Ключі кешу не роблять змінений вміст повторно використаним: prompt_cache_key OpenAI не є точкою зупину cache_control у стилі Anthropic.
Два значення “кешування”: префікс проти семантики
Усе вище стосується кешування префіксів — механізму на стороні постачальника, який робить повторне надсилання довгої стабільної розмови дешевим. Варто відокремити це від іншої речі, яку також називають «кешуванням» в агентських системах: семантичного кешування, яке знаходиться перед моделлю і намагається повністю пропустити виклик.
Обидва легко об’єднати, оскільки обидва обіцяють «дешевші дзвінки LLM», але вони працюють на різних рівнях і мають різні режими відмови:
- Кешування префіксів повторно використовує обчислення для відповідності префіксів введення. Модель все ще викликає свіжий відгук; кешування не обіцяє однакових завершень.
- Семантичне кешування повертає збережену відповідь на достатньо схожий запит. Це може уникнути висновків, але програма повинна обробляти неправильні збіги та застарілі відповіді.
Ключ семантичного кешу для агента кодування повинен враховувати стан сховища, інструкції та результати інструменту. Одного подібного формулювання недостатньо для безпечного повторного використання старої відповіді. Решта цієї статті стосується кешування префіксів.
Зберігання з втратою даних проти перегляду з втратою даних
Окремо стоїть питання про те, де відбувається стиснення — і, отже, що зберігається в розмові. Два варіанти, однаковий спостережуваний вплив на модель:
- Рівень інструментів: інструмент обмежує вихід до того, як увійде в розмову. Пропущений текст відсутній у цьому результаті, але все ще може існувати у файлі або окремому вихідному журналі.
- Стратегічний рівень: джгут зберігає повні результати та створює скорочене подання для кожного виклику моделі. Ось як працюють наші чотири стратегії.
Різниця не відображається в підказці LLM — обидва дизайни створюють однаковий текст. Це відображається в тому, що залишається на диску:
Стратегія може відновити текст, збережений у журналі, без повторного запуску інструменту. Для обмеження рівня інструментів може знадобитися ще одне читання, і це читання може повернути новішу версію файлу. Жоден підхід не гарантує, що стара інформація залишається доступною протягом невизначеного часу.
Цей розкол відображається в тому, як фреймворки агентів виявляють хуки. Стиснення рівня інструментів не потребує перехоплення фреймворку — інструменти — це лише функції, які ви пишете, тому обмеження на рівні інструментів означає встановлення обмежень у реалізації інструменту. Рівень стратегії відрізняється: він працює щоразу проти рухомої цілі (зростаюча розмова), тому фреймворк має надати для нього точку входу.
Кожне випробування реєструє повний вихід інструменту. Випробування — це незалежні дії агента, а не повторення однієї фіксованої розмови: їхні дії та траєкторії можуть відрізнятися.
Дизайн виведення інструменту: інша половина зображення
Досі все працювало на рівні збірки контексту — transformContext працює зі списком повідомлень, який уже є в руках. Але є паралельний простір дизайну на один рівень нижче: те, що самі інструменти вирішують повернути. Інструмент, який скидає вихідні дані, дозволяє вашій стратегії виконувати всю роботу. Інструмент, який обмежує свій власний результат, зменшує завдання вашої стратегії — іноді навіть зовсім.
Тут важливі дві функції інструменту: обмеження виводу на виклик і розбивка на сторінки. Наприклад, закріплена реалізація читання у відкритому коді обмежує вихідні байти та довжину рядка та підтримує читання вибраного діапазону. Пагінація дозволяє агенту запитувати пропущені частини.
Обмеження інструментів пов’язують окремі результати, а не загальну розмову. Пагінація — це вибіркове пошук, а не стиснення, і повторне читання може накопичувати велику історію.
Як справжні CLI обробляють контекст
Перш ніж ми визначимо, які стратегії ми будемо вимірювати, варто побачити, як робочі CLI насправді вирішують проблему. Кожен вибирає певне поєднання шаблонів із таксономії, іноді враховуючи те, що відкриває API цільового постачальника. Ось короткий огляд видимого джерела.
Клод Код
Документація Клода Коду описує автоматичне стиснення, коли контекст заповнюється. Наведені нижче макети та політики для кожного інструменту є ілюстративними проектами, а не перевіреною реконструкцією внутрішньої реалізації.
Зручний для кешу макет розміщує стабільні інструкції перед зміною історії. Наступний псевдокод показує вихідне повідомлення перед ущільненням і підсумок після. У справжньому запиті 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), 3 останніми є ходи 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 надає окремі елементи керування для автоматичного ущільнення та виведення збережених інструментів. Його посилання на конфігурацію документи model_auto_compact_token_limit і tool_output_token_limit. Вони стосуються різних джерел зростання контексту.
відкритий код
відкритий код поєднує обмежений вихід інструмента з ущільненням діалогу. Вони працюють на різних рівнях; обмеження кожного результату не позбавляє необхідності керувати історією.
pi-coding-agent
У нашому експерименті використовується чистий Agent із спеціальними інструментами та явними контекстними стратегіями. Це не слід плутати з високорівневим агентом кодування pi, який додає власні інструменти та керування контекстом.
Стратегії, які ми реалізуємо
Ми порівнюємо чотири стратегії з 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 символів у кожному результаті інструменту | карта результатів інструменту |
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. |
Як пі підключає їх
Наведені вище стратегії не залежать від фреймворку агента — вони описують що робити зі списком повідомлень. Щоб фактично запустити їх у нашому експерименті, нам потрібне місце, де їх можна підключити. Ми використовуємо пі, тому що, на відміну від більшості агентських 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[] — ім’я пі для уніфікованого типу повідомлення, яке охоплює повідомлення користувачів, помічників і інструментів — і повертає (можливо, модифікований) AgentMessage[]. Той самий тип вхідний, той самий тип вихідний. Тут живе кожна стратегія в наведеній вище таксономії: обмеження кожного результату інструменту до N символів (truncate-500), обмеження лише старіших (age-truncate-500-keep-3), підсумовування за пороговим значенням (compact-at-12000-structured) і так далі.
convertToLlm перетворює внутрішній AgentMessage[] у формат повідомлення, який використовується інтерфейсом моделі pi. Після цього адаптери постачальника виконують перетворення запиту для постачальника.
У цій статті ми залишаємо 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, постачається за замовчуванням (багатораундове підсумовування при переповненні, форма подібна до відкритого коду). Ми використовуємо чистий 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, за межею рівномірного скорочення.
Цей інструмент набагато менший, ніж оцінювання на рівні сховища, наприклад SWE-bench. Його перевага тут полягає в тому, що кожна стенограма є досить короткою, щоб перевірити її. Ми тримаємо модель фіксованою та змінюємо обробку контексту; більші завдання потребуватимуть окремої оцінки.
Тести в test/tasklist.test.ts виконують повну поверхню від кінця до кінця: додають завдання, перераховують їх, позначають як завершені через запит у кодуванні JSON, вибірку за ідентифікатором. 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 зберігає цей рядок; він не перетворює числові ідентифікатори на рядки. Агент отримує чотири інструменти та завдання зробити тести успішними.
Наші чотири користувальницькі інструменти — це тонкі обгортки, зареєстровані в інтерфейсі pi AgentTool. Зчитування файлів навмисно розблоковано та не має розбивки на сторінки, тому обмеження рівня інструментів не заважають порівнянню стратегій. Незалежні пробіжки все ще можуть слідувати різними траєкторіями.
Нижче ви можете побачити чотири корпуси інструментів, розділені на шляхи 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 видає відповідь — текст і/або наміри виклику інструментів.
- Агент надсилає виклики інструментів, запускає кожен інструмент і додає результати до історії.
Кожен наступний виклик моделі зараховується як черговий хід. Кількість повідомлень залежить від того, скільки викликів інструментів містить кожна відповідь помічника.
Натисніть будь-який Поворот N у лівому навігаторі, щоб зосередитися на ньому. Віджет показує момент безпосередньо перед викликом LLM цього ходу: сторона Перед стратегією — це все, накопичене за результатами інструментів ходу N-1 (відповіді ходу N на даний момент ще не відбулося); сторона Після стратегії — це те, що transformContext створив на основі цього введення — підказка, яку фактично бачив LLM. Перегляд за замовчуванням Відмінність фарбує те, що змінилося: червоні лінії було відкинуто стратегією, зелені лінії додано або замінено, сірі лінії вирівнюються по обидва боки. Перейдіть на Картки для структурованого перегляду повідомлень. Для 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, встановленим через гачок pi onPayload. Ми спостерігали різні траєкторії навіть за цього налаштування, тому кожна умова має три випробування. Експеримент не визначає джерело цієї варіації.
Що ми вимірюємо за прогін:
pass— набір тестів став зеленим у кінці?turns— кількість ходів асистента (тобто викликів LLM).cost— зареєстрована вартість виклику агента, за винятком окремого виклику підсумовувача.peak prompt— найбільший розмір запиту (нові + кешовані токени), який будь-коли надсилав агент.new input tokens— некешовані токени запитів, сумовані. Ті, за які ви платите повну ціну.cached input tokens— токени, що подаються з неявного кешу префіксів Gemini.
Кількість кешованих маркерів відображає як повторне використання префікса, так і кількість викликів, здійснених під час виконання. Вони не є окремим показником стабільності кешу стратегії.
Результати за стратегією
Числа нижче є середніми для 3 випробувань (k=3) на стратегію; ± значення є стандартним відхиленням вибірки (таким чином, 13±3 означає середнє значення 13 обертів із σ ≈ 3 по серіях). Стовпці new і cached також означають запуск — те, що типова одноразова пробна версія оплачується некешованими чи кешованими вхідними токенами.
| стратегія | n | пропуск | повороти | вартість | пікова підказка | новий | кешований |
|---|---|---|---|---|---|---|---|
| базова лінія | 3 | 3/3 | 13±3 | $0.016±0.006 | 6,705±1,830 | 27,164 | 18,224 |
| обрізати-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 |
| компактний-на-12000-структурований | 3 | 3/3 | 12±4 | $0.016±0.007 | 5,212±1,170 | 22,834 | 14,462 |
Уніфіковане скорочення пройшло 0/3 випробувань із зареєстрованими витратами, які приблизно в шість разів перевищують базовий рівень. Відповідний код лежить за межами символу 500. Маркер усічення показує, що тексту бракує, але ці інструменти не пропонують читання з розбивкою на сторінки, щоб відновити його в обмеженому поданні.
Укорочення з урахуванням віку пройшло 3/3 випробувань із середньою зареєстрованою вартістю 0,017 доларів США. Свіжі результати залишаються завершеними, але старіша інформація може зникнути з поля зору. Його 11K кешованих токенів менше, ніж базові 18K; старіння результату змінює префікс, навіть якщо перетворення є детермінованим.
Ущільнення пройшло 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);
});
};
}- Останні результати залишаються повними, незалежно від розміру.
- Старіші результати можуть втратити деталі, які все ще актуальні.
- Старіння результату змінює префікс із цього моменту.
- Трансформація не потребує додаткового виклику моделі, але історія все одно зростає.
Вибір із трьох захищених результатів є параметром цього приладу, а не загальною рекомендацією. Довші завдання та великі свіжі результати потребують окремих тестів і контролю розміру.
Чим це відрізняється від дворівневого підходу відкритого коду
Кришка інструменту обмежує те, що входить у розмову; перегляд на основі віку обмежує те, що надсилається пізніше. Файл може залишатися на диску в будь-якому випадку, тоді як лише збережений повний результат зберігає точне історичне зчитування. Ці механізми можна комбінувати, але розбиття на сторінки та доступ до пропущеного вмісту мають значення.