Dogłębna analiza: jak składać kontekst dla agenta LLM

Przy każdym wywołaniu modelu środowisko wykonawcze agenta wybiera kontekst: pełną rozmowę, wybrane wiadomości, podsumowania, wyniki narzędzi i instrukcje. Ten wybór wpływa na dostępne informacje, koszt wywołania i zachowanie wcześniejszych ustaleń.

Porównamy cztery strategie składania kontekstu na małym zadaniu naprawy błędu, analizując zapisane przebiegi. Eksperyment pokazuje kompromisy, a nie ustala uniwersalnego rankingu.

Środowisko używa pi. Hook transformContext pozwala zmieniać zestaw wiadomości przed każdym wywołaniem modelu. Jedna ze strategii zachowuje ostatnie wyniki narzędzi w całości, a starsze skraca.

Dlaczego kontekst jest centralnym problemem sterowania dla agenta

Agent programistyczny łączy model, pętlę sterowania i środowisko zarządzające narzędziami oraz kontekstem. Środowisko wybiera informacje dostępne przy każdym wywołaniu, więc zmiana obsługi kontekstu może zmienić zachowanie bez zmiany modelu.

Pętla agenta, sprowadzona do sedna, wygląda tak:

  1. Wyślij bieżący kontekst do modelu.
  2. Odbierz tekst, wywołania narzędzi lub oba rodzaje treści.
  3. Wykonaj wywołania, dodaj wyniki i powtórz.
  4. Jeśli odpowiedź nie zawiera wywołań narzędzi, zakończ tę pętlę agenta.

Wyniki narzędzi mogą stanowić dużą część kontekstu sesji. Rozmiar zależy od plików, poleceń i liczby wywołań; trzydzieści tur nie oznacza ustalonej liczby tokenów. Znaki i tokeny to też różne jednostki.

Skupimy się więc na obsłudze wyników narzędzi: zachowywaniu, skracaniu i podsumowywaniu starszej historii. Te decyzje łączą się z wyborem wiadomości i cache’owaniem promptów.

Jeśli po prostu pozwolimy historii rosnąć bez kontroli, wpadamy na cztery problemy:

  • Koszt: dłuższy prompt może zwiększać koszt wejścia; liczą się też rabaty za cache i tokeny wyjściowe.
  • Opóźnienie: przesłanie i przetworzenie dużego promptu może opóźnić odpowiedź. Cache może ograniczyć część tej pracy.
  • Pojemność: żądanie musi mieścić się w limicie kontekstu modelu, z miejscem na odpowiedź.
  • Jakość: w długim kontekście trudniej wykorzystać istotne szczegóły. Efekt zależy od modelu, zadania i położenia informacji.

Strategia składania kontekstu to nie pojedyncza decyzja — to stos oddziałujących na siebie wyborów:

  • Co jest wyrzucane albo przepisywane?
  • Kiedy — w każdej turze, czy tylko przy progach?
  • Gdzie w stosie — na warstwie narzędzi, na warstwie strategii, czy na obu?
  • Jak agresywnie — w tokenach, znakach, wiadomościach?
  • Co streszczasz — starszą historię, całą rozmowę, określone typy narzędzi?
  • Jak streszczasz — swobodnie, według strukturalnego szablonu, w kilku powiązanych rundach?
  • A co z cache’em — czy Twoja transformacja szanuje prefiks, czy unieważnia go w każdej turze?

Uruchomiliśmy po trzy próby każdej strategii na jednym małym zadaniu. Średnie i odchylenia standardowe próby opisują te przebiegi; trzy próby nie pozwalają wiarygodnie oszacować skuteczności ani częstości rzadkich, kosztownych niepowodzeń.

MetrykaZnaczenie
Zaliczone testyUdane przebiegi z trzech prób
Raportowany kosztŚrednia i odchylenie standardowe kosztu zarejestrowanych wywołań agenta; bez wywołań podsumowujących
TuryZarejestrowane wywołania asystenta, nie bezpośredni pomiar opóźnienia
Rozmiar promptuSuma tokenów wejściowych z cache i bez cache, według znormalizowanych pól pi
Udział cacheTokeny wejściowe z cache podzielone przez wszystkie tokeny wejściowe

Skuteczność oceniaj razem z kosztem i rozmiarem promptu. Tańszy przebieg, który nie rozwiązuje zadania, nie musi być poprawą.

Taksonomia strategii

Skoro metryki są ustalone, spójrzmy na przestrzeń projektową, do której zostaną zastosowane. Strategie pojawiające się w produkcyjnych CLI dzielą się na kilka rodzin wzdłuż dwóch osi: gdzie zachodzi kompresja i co jest kompresowane.

Najprostsza strategia wysyła całą rozmowę przy każdym wywołaniu. Nazywamy ją baseline. Niezmieniony prefiks umożliwia użycie cache zgodnie z zasadami dostawcy. Historia nadal rośnie w stronę limitu kontekstu, a jakość pracy z długim kontekstem może zależeć od położenia istotnych informacji.

Każda inna strategia to sposób zrealizowania kompresji. Kompresja może zachodzić w trzech miejscach:

  • W każdej turze. Zastosuj transformację do listy wiadomości przy każdym wywołaniu LLM, kształtując prompt każdej tury w miarę rozrostu rozmowy.
  • Tylko przy progach. Zostaw rozmowę w spokoju, dopóki nie przekroczy jakiegoś limitu rozmiaru, a potem odpal jednorazową operację (zwykle streszczanie) na starszej części i zamroź wynik na resztę przebiegu.
  • Na poziomie narzędzia. Ogranicz albo przepisz wartość zwracaną przez narzędzie, zanim w ogóle trafi do historii rozmowy. Żyje warstwę niżej niż dwie pozostałe i komponuje się z nimi — omawiamy to szczegółowo dalej.

Przekształcenia przy każdej turze można łączyć z kompakcją uruchamianą po przekroczeniu progu. Deterministyczne skracanie nie wymaga podsumowania przez LLM, ale może ukryć potrzebny tekst. Podsumowanie skraca więcej historii, lecz kosztuje i może pominąć szczegóły. Żadne podejście samo nie gwarantuje dobrego wykorzystania cache.

Okno przesuwne zachowuje ostatnie wiadomości, usuwając starsze. Może zgubić przydatną historię i ograniczyć cache, ale bywa odpowiednie dla ograniczonych zadań ze stanem przechowywanym osobno. Tutaj go nie oceniamy.

Można wybierać, jakie informacje usuwać lub skracać:

  • Usuwaj starsze tury, ewentualnie zachowując pierwotne zadanie.
  • Zastępuj starsze wyniki narzędzi skrótami lub odwołaniami umożliwiającymi odczyt.
  • Ograniczaj tekst wyników, oznaczając skrócenie.
  • Podsumowuj starszą historię.
  • Pobieraj potrzebne informacje z pełnego dziennika. Budując żądanie, zachowuj poprawne powiązania wywołań narzędzi z wynikami.

Te wzorce nie wykluczają się nawzajem — większość produkcyjnych strategii łączy dwa albo trzy, np.: obetnij każdy wynik narzędzia, potem streść starsze tury, potem wyrzuć treść sprzed streszczenia.

Stabilność cache’a

Cache prefiksu pozwala ponownie wykorzystać obliczenia dla niezmienionego początku wejścia modelu. Zasady dopasowania, minimalne rozmiary, czas przechowywania i ceny zależą od dostawcy; opisują je dokumentacje OpenAI, Anthropic i Gemini. Dopasowywany jest kontekst modelu, niekoniecznie surowe bajty żądania HTTP.

Przekształcenie sprzyja ponownemu użyciu prefiksu, gdy wysłana już treść pozostaje niezmieniona. Przepisanie starszej wiadomości przerywa dopasowanie od tego miejsca dla istniejącego wpisu cache; kolejne żądanie może wykorzystać nowy prefiks.

Prostym przykładem strategii stabilnej dla cache’a jest limit na wyniki narzędzi: w każdej turze strategia przechodzi po rozmowie z pełnym wyjściem narzędzi i obcina każdy wynik do 500 znaków. Ponieważ reguła jest deterministyczna, a bazowy tekst wyniku narzędzia się nie zmienia, wersja obcięta jest bit w bit identyczna na tej samej pozycji przy każdym kolejnym wywołaniu.

Okno przesuwne zwykle zmienia początek rozmowy po stałych instrukcjach. Skracanie według wieku także zmienia prefiks, gdy wynik po raz pierwszy wypada z chronionej grupy. Determinizm stabilizuje późniejszy skrót, ale nie zapobiega utracie dopasowania dalszej części cache podczas tej zmiany.

Kompakcja może zastąpić stare podsumowanie albo dopisać nowe. Zastąpienie ogranicza cache, ale zmniejsza prompt; dopisywanie zachowuje więcej historii, lecz pozwala jej rosnąć. Klucze cache nie pozwalają ponownie użyć zmienionej treści: prompt_cache_key w OpenAI nie jest punktem cache_control w stylu Anthropic.

Dwa znaczenia „cache’owania”: prefiksowe kontra semantyczne

Wszystko powyżej dotyczy cache’owania prefiksów — mechanizmu po stronie dostawcy, który sprawia, że ponowne wysyłanie długiej, stabilnej rozmowy jest tanie. Warto oddzielić to od innej rzeczy, którą w systemach agentowych też nazywa się „cache’owaniem”: od cache’owania semantycznego, które siedzi przed modelem i próbuje pominąć wywołanie w całości.

Łatwo je pomylić, bo oba obiecują „tańsze wywołania LLM”, ale działają na różnych warstwach i mają różne tryby awarii:

  • Cache prefiksu wykorzystuje wcześniejsze obliczenia dla zgodnego początku wejścia. Model nadal generuje nową odpowiedź; cache nie gwarantuje identycznych odpowiedzi.
  • Cache semantyczny zwraca zapisaną odpowiedź na dostatecznie podobne zapytanie. Pozwala uniknąć inferencji, ale aplikacja musi obsługiwać błędne dopasowania i nieaktualne odpowiedzi.

Klucz cache semantycznego dla agenta programistycznego musiałby uwzględniać stan repozytorium, instrukcje i wyniki narzędzi. Podobne słowa nie wystarczają do ponownego użycia odpowiedzi. Dalej zajmujemy się cache prefiksu.

Stratne przechowywanie kontra stratny widok

Osobno stoi pytanie, gdzie zachodzi kompresja — a więc co zostaje zapisane w rozmowie. Dwie opcje o tym samym obserwowalnym efekcie dla modelu:

  • Warstwa narzędzi: narzędzie ogranicza wynik przed dodaniem go do rozmowy. Pominięty tekst nie trafia do tego wyniku, ale może nadal istnieć w pliku lub osobnym logu.
  • Warstwa strategii: środowisko zapisuje pełne wyniki i tworzy skrócony widok dla każdego wywołania modelu. Tak działają nasze cztery strategie.

Różnica nie ujawnia się w prompcie LLM — oba projekty produkują ten sam tekst. Ujawnia się w tym, co zostaje na dysku:

Strategia może przywrócić tekst zachowany w logu bez ponownego wywołania narzędzia. Limit w narzędziu może wymagać kolejnego odczytu, który może już zwrócić nowszą wersję pliku. Żadne z tych podejść nie gwarantuje bezterminowego dostępu do dawnych informacji.

Ten podział odbija się w tym, jakie hooki wystawiają frameworki agentowe. Kompresja na warstwie narzędzi nie potrzebuje hooka frameworka — narzędzia to po prostu funkcje, które piszesz, więc ograniczanie na warstwie narzędzi oznacza wstawienie limitu do implementacji narzędzia. Warstwa strategii jest inna: działa w każdej turze na ruchomym celu (rosnącej rozmowie), więc framework musi wystawić dla niej punkt wejścia.

Każda próba zapisuje pełne wyniki narzędzi. Próby to niezależne uruchomienia agenta, a nie odtworzenia jednej rozmowy: działania i przebieg mogą się różnić.

Projektowanie wyjścia narzędzi: druga połowa obrazu

Wszystko dotąd działo się na warstwie składania kontekstu — transformContext działa na liście wiadomości, którą już się ma. Ale jest równoległa przestrzeń projektowa warstwę niżej: co same narzędzia decydują się zwrócić. Narzędzie zrzucające surowe wyjście każe Twojej strategii wykonać całą robotę. Narzędzie ograniczające własne wyjście zmniejsza zadanie Twojej strategii — czasem do znikomości.

Istotne są dwie cechy narzędzia: limit wyniku pojedynczego wywołania i stronicowanie. Na przykład wskazana wersja narzędzia read w opencode ogranicza liczbę bajtów i długość wierszy oraz pozwala czytać wybrany zakres. Stronicowanie umożliwia agentowi pobranie pominiętych fragmentów.

Limity narzędzi ograniczają pojedyncze wyniki, a nie całą rozmowę. Stronicowanie jest wybiórczym pobieraniem danych, a nie kompresją; kolejne odczyty nadal mogą tworzyć długą historię.

Jak z kontekstem radzą sobie prawdziwe CLI

Zanim ustalimy, które strategie będziemy mierzyć, warto zobaczyć, jak ten problem faktycznie rozwiązują produkcyjne CLI. Każde wybiera własną mieszankę wzorców z taksonomii, czasem pod wpływem tego, co udostępnia API ich docelowego dostawcy. Oto szybki przegląd tego, co widać w źródłach.

Claude Code

Dokumentacja Claude Code opisuje automatyczną kompakcję po zapełnieniu kontekstu. Poniższe układy i reguły dla narzędzi są przykładami projektowymi, a nie zweryfikowaną rekonstrukcją implementacji.

Układ sprzyjający cache umieszcza stałe instrukcje przed zmieniającą się historią. Poniższy pseudokod pokazuje wiadomość początkową przed kompakcją i podsumowanie po niej. W rzeczywistym żądaniu Anthropic znaczniki cache w wiadomościach należą do bloków treści.

// 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,
  ],
});

Znaczniki wskazują zagnieżdżone prefiksy, a nie niezależne fragmenty cache. Zmiana wcześniejszego bloku unieważnia dopasowanie dalszej treści. Zastąpienie podsumowania wymaga więc nowego dopasowania; stały prefiks instrukcji może nadal nadawać się do użycia.

Polityki starzenia per narzędzie

Reguły zależne od narzędzia mogą skracać stare odczyty, zachowując potwierdzenia zapisów i wyniki testów. Poniższa tabela przedstawia przykładową politykę dla naszych czterech narzędzi. Nie mierzymy jej osobno.

NarzędzieReguła starzenia
read_fileGdy starsze niż ostatnie 3 odczyty, zastąp wynik jednolinijkową zaślepką z nazwą ścieżki
list_filesGdy starsze niż ostatnie 2 listingi, obetnij do 200 znaków
write_fileZawsze trzymaj dosłownie
run_testsZawsze trzymaj dosłownie

Małe wyjaśnienie, co naprawdę znaczy „starsze niż ostatnie 3 odczyty”: liczy się wywołania tego samego narzędzia, nie tury. Żeby to skonkretyzować, przyjmijmy, że dotychczasowa historia wywołań agenta wygląda tak:

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

Po turze 7, patrząc tylko na wywołania read_file (te z tur 1, 3, 5, 6, 7), trzy najświeższe to tury 5, 6 i 7. Czyli:

  • Odczyty z tur 1 i 3 → zaślepione.
  • Odczyty z tur 5, 6, 7 → trzymane dosłownie.

Jeśli tura 8 to run_tests() (nie odczyt), nic się nie zmienia. W chwili, gdy tura 9 okaże się kolejnym read_file, odczyt z tury 5 się postarza — staje się 4. najświeższym odczytem — i zostaje zaślepiony. Każde narzędzie jest rankingowane po świeżości wywołań niezależnie, a top-K każdego rankingu zostaje dosłowne.

Zachowana ścieżka pozwala ponownie odczytać plik, ale nie zachowuje jego dawnej treści. Wyniki testów też mogą stracić aktualność po zmianach. Polityka musi uwzględniać, jakich dawnych informacji wymaga zadanie.

Szablon strukturalnego streszczenia

W eksperymencie z kompakcją używamy poniższego szablonu z pięcioma sekcjami. Ma zachować zadanie, aktualny stan, odkrycia, kolejne kroki i dokładne szczegóły potrzebne do kontynuacji.

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.

Ta struktura nadaje podsumowaniu konkretny format przekazania pracy. Nadal może ono pomijać lub zniekształcać szczegóły, dlatego szablon trzeba oceniać na dalszych zadaniach.

Codex

Codex udostępnia osobne ustawienia automatycznej kompakcji i zapisywanego wyniku narzędzi. Dokumentacja konfiguracji opisuje model_auto_compact_token_limit i tool_output_token_limit. Dotyczą one różnych źródeł wzrostu kontekstu.

opencode

opencode łączy ograniczanie wyników narzędzi z kompakcją rozmowy. Są to różne warstwy; limit pojedynczego wyniku nie usuwa potrzeby zarządzania historią.

pi-coding-agent

Eksperyment używa samego Agent, własnych narzędzi i jawnych strategii kontekstu. To nie to samo co pi coding agent, który dodaje własne narzędzia i zarządzanie kontekstem.

Strategie, które implementujemy

Porównujemy cztery strategie, przyjmując za punkt odniesienia baseline, który wysyła niezmienioną historię.

Każda strategia ma trzy niezależne próby z tym samym zadaniem, modelem i promptem początkowym. Podajemy średnie i odchylenia standardowe próby. Trzy próby pokazują zachowanie w tym zadaniu, ale nie pozwalają ustalić wiarygodnych częstości błędów ani ogólnego rankingu.

Używamy jednego małego zadania testowego i jednego zestawu parametrów dla każdej strategii. Poniższe nazwy określają implementację i jej parametry.

wzorzecstrategiatryb
Brak transformacji (kontrola)baseline—
Obcinanie wyjścia narzędzi (jednolite)truncate-500w każdej turze
Obcinanie wyjścia narzędzi (świadome wieku)age-truncate-500-keep-3w każdej turze
Streszczanie starszych tur (strukturalne)compact-at-12000-structuredprzy progach

W age-truncate-500-keep-3 liczba 500 oznacza zachowaną długość początku skracanego bloku tekstu, a 3 liczbę ostatnich pełnych wyników. Znacznik skrócenia dodaje kolejne znaki. compact-at-12000-structured wykonuje jedną kompakcję po przekroczeniu 12 000 znaków.

Okna przesuwne, wyszukiwanie, zastępowanie wyników odnośnikami i wielokrotna kompakcja są poza zakresem eksperymentu. Ich wpływ na cache zależy od implementacji i nie wynika z tych czterech wariantów.

Tabela czterech strategii poniżej to ten sam zestaw, pogrupowany po trybie — osi, wokół której obraca się analiza kosztów w artykule:

W każdej turze (transformacja stosowana przy każdym wywołaniu LLM).

strategiaco wyrzucaimplementacja
truncate-500tekst powyżej 500 znaków w każdym wyniku narzędziamap po wynikach narzędzi
age-truncate-500-keep-3tekst powyżej 500 znaków tylko w starszych wynikach narzędziobcinanie świadome pozycji

Przy progach (odpala raz, gdy rozmowa przekroczy limit rozmiaru, potem zamraża).

strategiaco zastępujeimplementacja
compact-at-12000-structuredhistorię przed ustalonym punktem podziałujedno podsumowanie według szablonu z pięcioma sekcjami

Tabela zbiera cztery implementacje i ich ograniczenia.

Strategy referenceAll strategies side-by-side. Click the icon to expand.
strategymodewhat it doesimplementationcache behaviorcost and resultswhen to use itscope
baselinenone (reference)Send the full message history unchanged.messages => messagesAn 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-500per-turnKeep 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-3per-turnKeep 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-structuredat thresholdsAt 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.

Jak pi je podpina

Powyższe strategie są niezależne od frameworka agentowego — opisują, co zrobić z listą wiadomości. Żeby faktycznie uruchomić je w naszym eksperymencie, potrzebujemy miejsca do ich podpięcia. Używamy pi, bo w odróżnieniu od większości agentowych CLI wystawia składanie kontekstu jako pełnoprawny punkt rozszerzeń — funkcję, którą piszesz — co czyni strategie trywialnie wymiennymi na potrzeby porównania.

Pi jest zbudowany tak, że w każdej iteracji pętli agentowej — tuż przed wysłaniem historii wiadomości do LLM, po dopisaniu do tej historii najnowszych wyników narzędzi — agent wywołuje dwa nadpisywalne przez użytkownika hooki pomiędzy „bieżącym transkryptem” a „tym, co LLM faktycznie widzi”:

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 to hook decydujący, jaką rozmowę powinien mieć agent. Przyjmuje całą rozmowę jako AgentMessage[] — tak pi nazywa ujednolicony typ wiadomości obejmujący wiadomości użytkownika, asystenta i wyniki narzędzi — i zwraca (być może zmodyfikowaną) tablicę AgentMessage[]. Ten sam typ na wejściu, ten sam na wyjściu. To tutaj żyje każda strategia z powyższej taksonomii: ogranicz każdy wynik narzędzia do N znaków (truncate-500), ogranicz tylko starsze (age-truncate-500-keep-3), streść przy progu (compact-at-12000-structured) i tak dalej.

convertToLlm przekształca wewnętrzne AgentMessage[] do formatu interfejsu modeli pi. Dopiero adaptery dostawców tworzą żądania właściwe dla poszczególnych API.

Na potrzeby tego artykułu zostawiamy convertToLlm w domyślnej postaci (filtr tożsamościowy dla standardowych ról wiadomości) i skupiamy się całkowicie na transformContext. W architekturze pi strategia składania kontekstu to po prostu funkcja:

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

Ta sygnatura to cała dostępna powierzchnia rozszerzeń. Ponieważ to kod (a nie blob konfiguracyjny), strategia może wykonać dowolną pracę: wywołać inny LLM, żeby streścić stare tury, osadzić wcześniejsze wiadomości i pobierać je po podobieństwie, czytać pliki z dysku, utrzymywać stan między turami przez domknięcie. Strategie, przez które przeszliśmy powyżej, rozciągają się od trzech linijek (baseline) do kilkudziesięciu (compact-at-N-structured), ale wszystkie mają ten sam kształt — i wszystkie są wymienne przez przekazanie innej funkcji do tego samego hooka.

Żeby to skonkretyzować, oto co pi robi w pojedynczej turze — podłapując w połowie sesji, gdy historia rozmowy zawiera już prompt początkowy użytkownika, kilka rund odpowiedzi LLM i stos wyników narzędzi z wcześniejszych tur:

  1. transformContext przechodzi po całej historii rozmowy — w tym po nietkniętych wynikach narzędzi po 50 KB, które tam siedzą — i produkuje listę wiadomości do wysłania. Strategia decyduje, co zrobić z każdym kawałkiem: przepuścić (baseline), obciąć jednolicie do 500 znaków (truncate-500), obciąć tylko starsze wyniki (age-truncate-500-keep-3), zwinąć starszą historię w streszczenie (compact-at-12000-structured) i tak dalej. Pi wysyła powstałą listę do LLM. LLM widzi widok strategii, a nie oryginał.
  2. LLM odpowiada — tekstem, intencjami wywołania narzędzi albo jednym i drugim.
  3. Jeśli LLM wystawił wywołania narzędzi, pi wykonuje każde z nich. Każde narzędzie zwraca pełne wyjście (np. 50 KB treści pliku). Pi dopisuje do historii rozmowy zarówno odpowiedź LLM, jak i każdy wynik narzędzia.
  4. Wróć do kroku 1.
  5. Powtarzaj, aż LLM wyda odpowiedź bez wywołań narzędzi — to sygnał dla agenta, żeby się zatrzymać.

W tym eksperymencie logger zachowuje pełne wyniki, a strategie skracają tylko widok modelu. Odtworzenie starego wyniku z logu nie wymaga ponownego wywołania narzędzia.

Jeden szczegół wart wyraźnego powiedzenia: w pi goła klasa Agent nie ma domyślnej strategii. Jeśli utworzysz new Agent({...}) bez podania transformContext, dostajesz tożsamościowe zachowanie baseline — cała rozmowa jest wysyłana w każdej turze. Wyższopoziomowy pakiet pi-coding-agent zbudowany na Agent ma jednak domyślną strategię (wielorundowe streszczanie przy przepełnieniu, kształtem podobne do opencode’a). Do tych eksperymentów używamy gołego Agent, żeby każda strategia w porównaniu była czymś, co napisaliśmy jawnie, bez niczego wbudowanego do kontrolowania.

Konfiguracja eksperymentu

Fixture, na którym będziemy testować różne strategie, to warstwa serwisu i składowania webowej aplikacji TODO. Robotę robią dwie klasy: TaskStore trzyma listę zadań w pamięci, a Api to cienka warstwa dyspozytorska, która przyjmuje obiekty żądań i kieruje je do składu:

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…
}

Błąd rozciąga się na src/api.ts (dyspozytor) i src/storage.ts (typowany skład) — rzutowanie w jednym pliku plus ścisła równość w drugim to właśnie to, co produkuje niezaliczony test.

Naprawa konwertuje identyfikator przez Number() na granicy API. Zmieniana linia znajduje się za 500. znakiem api.ts, poza jednolitym limitem.

To zadanie jest znacznie mniejsze niż testy całych repozytoriów, takie jak SWE-bench. Zaletą jest krótki zapis przebiegu, który można przejrzeć w całości. Używamy jednego modelu i zmieniamy obsługę kontekstu; większe zadania wymagają osobnej oceny.

Testy w test/tasklist.test.ts przechodzą całą powierzchnię od końca do końca: dodaj zadania, wypisz je, oznacz jako ukończone przez żądanie zakodowane w JSON, pobierz po id. 3 pliki źródłowe, 1 niezaliczony test, naprawa w jednym pliku.

// 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"

Testy serializują żądania z id będącym tekstem i przekazują je do parseRequest(). JSON zachowuje tekst; nie zamienia liczbowych identyfikatorów na ciągi znaków. Agent dostaje cztery narzędzia i zadanie doprowadzenia testów do poprawnego wyniku.

Nasze cztery narzędzia to proste funkcje zarejestrowane przez interfejs AgentTool w pi. Odczyty plików celowo nie mają limitów ani stronicowania, aby nie zaciemniać porównania strategii. Niezależne uruchomienia nadal mogą przebiegać różnie.

Poniżej widać ciała czterech narzędzi, obdarte do ścieżek execute (schematy, etykiety i rozstrzyganie katalogu roboczego pominięte dla przejrzystości):

// 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}`,
  );
}

Wykres pokazuje po jednym przykładowym przebiegu każdej strategii. Można wybrać rozmiar promptu, skumulowany zarejestrowany koszt lub odsetek wejścia odczytanego z cache.

  • Rozmiar promptu: nowe tokeny wejściowe plus tokeny z cache, według ujednoliconych pól zużycia pi.
  • Skumulowany zarejestrowany koszt: suma kosztów wywołań agenta. Osobne wywołanie modelu podsumowującego nie jest tu uwzględnione.
  • Odsetek wejścia z cache: tokeny wejściowe z cache podzielone przez wszystkie tokeny wejściowe tego wywołania.
metric:bug-01 · costs exclude summarization

Możesz też przejść przez którykolwiek z czterech przebiegów tura po turze poniżej. Najpierw kilka pojęć.

Tura to jedno wywołanie LLM. Cykl wokół każdej tury:

  1. transformContext przechodzi po zgromadzonej historii rozmowy agenta.
  2. LLM zostaje wywołany z wynikiem.
  3. LLM wydaje odpowiedź — tekst i/lub intencje wywołania narzędzi.
  4. Agent rozsyła wywołania narzędzi, uruchamia każde i dopisuje wyniki do historii.

Każde kolejne wywołanie modelu liczymy jako turę. Liczba wiadomości zależy od liczby wywołań narzędzi w poszczególnych odpowiedziach asystenta.

Kliknij dowolne Turn N w nawigatorze po lewej, żeby się na niej skupić. Widget pokazuje moment tuż przed wywołaniem LLM w tej turze: strona Before strategy to wszystko zgromadzone przez wyniki narzędzi tury N-1 (odpowiedź tury N jeszcze się w tym momencie nie wydarzyła); strona After strategy to to, co transformContext wyprodukował z tego wejścia — prompt, który LLM faktycznie zobaczył. Domyślny widok Diff koloruje zmiany: czerwone linie zostały wyrzucone przez strategię, zielone dodane albo zastąpione, szare zgadzają się po obu stronach. Przełącz na Cards, żeby zobaczyć uporządkowany widok per wiadomość. Dla baseline obie strony są bit w bit identyczne — to przypadek kontrolny. Dla pozostałych trzech strategii różnica to centralne pytanie artykułu wzięte dosłownie.

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.

Dla każdego przebiegu kopiujemy fixture do izolowanego katalogu roboczego, dajemy agentowi cztery narzędzia i pozwalamy mu pracować, aż się zatrzyma. verify() uruchamia node --test jeszcze raz i zapisuje, czy zestaw testów jest zielony.

Model: Gemini 2.5 Flash z temperature = 0 ustawioną przez hook onPayload w pi. Nawet przy tym ustawieniu obserwowaliśmy różne przebiegi, więc każdy wariant ma trzy próby. Eksperyment nie ustala źródła tej zmienności.

Co mierzymy na przebieg:

  • pass — czy zestaw testów zzieleniał na końcu?
  • turns — liczba tur asystenta (czyli wywołań LLM).
  • cost — zarejestrowany koszt wywołań agenta, bez osobnego wywołania podsumowującego.
  • peak prompt — największy rozmiar promptu (nowe + cache’owane tokeny), jaki agent kiedykolwiek wysłał.
  • new input tokens — niecache’owane tokeny promptu, zsumowane. Te, za które płacisz pełną cenę.
  • cached input tokens — tokeny podane z niejawnego cache’a prefiksów Gemini.

Liczba tokenów z cache zależy zarówno od ponownego użycia prefiksu, jak i liczby wywołań w przebiegu. Sama nie mierzy stabilności cache danej strategii.

Wyniki per strategia

Liczby poniżej to średnie z 3 prób (k=3) na strategię; wartości po ± to odchylenie standardowe próby (więc 13±3 znaczy średnią 13 tur przy σ ≈ 3 między przebiegami). Kolumny new i cached to również średnie na przebieg — ile typowa pojedyncza próba zapłaciła w niecache’owanych kontra cache’owanych tokenach wejściowych.

strategianpassturykosztszczytowy promptnewcached
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

Jednolite skracanie nie zaliczyło żadnej z trzech prób, a zarejestrowany koszt był około sześć razy większy niż dla baseline. Istotny kod znajduje się za znakiem 500. Znacznik informuje o pominięciu tekstu, ale narzędzia nie oferują odczytu stronicowanego pozwalającego odzyskać go w ograniczonym widoku.

Skracanie według wieku zaliczyło 3/3 próby przy średnim zarejestrowanym koszcie 0,017 USD. Nowe wyniki pozostają pełne, lecz starsze informacje mogą znikać z widoku. 11 tys. tokenów z cache to mniej niż 18 tys. w baseline; zmiana wyniku po przekroczeniu wieku zmienia prefiks mimo deterministyczności operacji.

Kompakcja zaliczyła 3/3 próby, ze średnim zarejestrowanym kosztem agenta 0,016 USD. Logger pomija zużycie wywołania podsumowującego, więc nie jest to koszt całkowity ani dowód, że oszczędności pokryły podsumowanie. Model podsumowujący otrzymuje też już skrócony zapis: do 1500 znaków wyniku narzędzia i 200 znaków argumentów wywołania.

Eksperyment pokazuje niepowodzenie agresywnego limitu i trzy skuteczne warianty na tym zadaniu. Nie ustala, który jest najlepszy dla zadań programistycznych ogółem.

Propozycja własnego algorytmu: obcinanie wyników narzędzi świadome wieku

Strategia oparta na wieku zachowuje ostatnie trzy wyniki w całości i skraca starsze:

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);
    });
  };
}
  • Ostatnie wyniki pozostają pełne bez względu na rozmiar.
  • Starsze wyniki mogą stracić nadal potrzebne szczegóły.
  • Skrócenie starzejącego się wyniku zmienia prefiks od tego miejsca.
  • Przekształcenie nie wymaga kolejnego wywołania modelu, ale historia nadal rośnie.

Trzy chronione wyniki to parametr tego eksperymentu, a nie ogólne zalecenie. Dłuższe zadania i duże nowe wyniki wymagają osobnych testów oraz kontroli rozmiaru.

Czym różni się to od dwuwarstwowego podejścia opencode’a

Limit narzędzia ogranicza to, co trafia do rozmowy; widok oparty na wieku ogranicza to, co wysyłamy później. W obu przypadkach plik może nadal istnieć na dysku, lecz tylko zapisany pełny wynik zachowuje dokładny dawny odczyt. Mechanizmy można łączyć, ale znaczenie mają stronicowanie i dostęp do pominiętej treści.