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

Otwórz Claude Code, Cursor albo dowolne inne agentowe CLI do kodu. Poproś o naprawienie błędu. Popatrz, jak wykonuje jedno wywołanie narzędzia, potem kolejne, potem jeszcze jedno. Pod maską, w każdej pojedynczej turze LLM, to CLI podejmuje decyzję, której nigdy nie widzisz: co dokładnie wysłać modelowi jako historię rozmowy. Cały transkrypt, tylko kilka ostatnich wiadomości, streszczenie wcześniejszych, oryginalne zadanie plus ostatni odczyt pliku — każdy wariant daje innego agenta. CLI podjęło tę decyzję za Ciebie, wpieczoną na etapie kompilacji, niewidoczną, dopóki nie przeczytasz źródeł.

Ten artykuł to wycieczka po przestrzeni projektowej strategii składania kontekstu dla agentów LLM — po niewidocznych decyzjach, które obudowa podejmuje w sprawie zawartości promptu w każdej turze. Budujemy taksonomię wyborów (gdzie zachodzi kompresja, co jest kompresowane, kiedy się odpala), przeglądamy, jak ten problem faktycznie rozwiązują produkcyjne CLI w źródłach, i przeprowadzamy mały eksperyment z czterema reprezentatywnymi strategiami na fixture’ze z błędem.

Pojazdem dla eksperymentu jest pi — framework agentowy, który wystawia składanie kontekstu jako funkcję, którą sam piszesz, przez co podmiana strategii jest trywialna. Artykuł kończy się małą propozycją algorytmiczną — świadomym wieku obcinaniem wyników narzędzi — najtańszą jednolinijkową strategią, jaką znaleźliśmy, która trzyma koszt w ryzach, nie tracąc tego, o czym agent aktualnie rozumuje.

Dlaczego kontekst jest centralnym problemem sterowania dla agenta

Na każdą agentową konfigurację do kodu składają się trzy elementy: model (LLM generujący tokeny), agent (pętla sterowania, która wielokrotnie wywołuje model wewnątrz środowiska) i obudowa (oprogramowanie wokół, zarządzające kontekstem, narzędziami, promptami, stanem i przepływem sterowania). Sebastian Raschka ma dobre omówienie tego, jak to składa się w działającego agenta do kodu. Kluczowe tutaj: wiele z tego, co w sesji wygląda jak „jakość modelu”, to w rzeczywistości jakość kontekstu — a jakość kontekstu to zadanie obudowy.

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

  1. Wyślij historię wiadomości do LLM.
  2. Odbierz albo odpowiedź tekstową, albo wywołania narzędzi.
  3. Jeśli wywołania narzędzi: uruchom je, dopisz wyniki do historii, wróć do 1.
  4. Jeśli tekst: gotowe.

Teraz pomyśl, co dzieje się z krokiem 1 na przestrzeni prawdziwej sesji. Prompt użytkownika jest mały — najwyżej kilkaset tokenów. Prompt systemowy jest stały i ograniczony. Prawie cały kontekst, który model widzi w danej turze, to wywołania narzędzi i ich wyniki. Odczyt pliku zwraca 2000 znaków źródła, uruchomienie testów 1500 znaków wyjścia z błędem, ls — 500. Agent może wykonać trzydzieści tur, za każdym razem dopisując do historii kolejną porcję.

Do tury trzydziestej wysyłasz ~100 000 tokenów samego wyjścia narzędzi na wywołanie, przy każdym wywołaniu. Wejście-wyjście narzędzi dominuje w rozmiarze kontekstu — a więc dominuje w koszcie, opóźnieniu i w tym, ile miejsca zostaje modelowi na faktyczne rozumowanie.

Właśnie dlatego większość strategii składania kontekstu to w gruncie rzeczy strategie obchodzenia się z wyjściem narzędzi: kiedy trzymać wyniki dosłownie, kiedy je obcinać, kiedy zastępować zaślepkami, kiedy zwijać partię starszych w streszczenie. Kilka strategii działa na granulacji całych wiadomości (okna przesuwne, kompaktowanie całej historii), ale gros przestrzeni projektowej — i niemal cały sygnał różnicy kosztowej w tym artykule — bierze się z tego, jak agresywnie każda strategia traktuje megabajty wyjścia narzędzi, które agent gromadzi w ciągu sesji.

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

  • Koszt. Płacisz za każdy token wejściowy, w każdej turze. Koszt liniowy względem historii przy trzydziestu turach oznacza, że ostatnie tury to najdroższe wywołania, jakie robisz.
  • Opóźnienie. Dostawcy strumieniują wyjście, ale nie strumieniują wejścia. Prompt o wielkości 100 tys. tokenów potrzebuje zauważalnego czasu zegarowego na przesłanie i tokenizację, zanim przyjdzie pierwszy token odpowiedzi.
  • Ograniczenia rozmiaru okna kontekstu. Gemini 2.5 Flash kończy się na 1 mln tokenów; Claude Sonnet na 1 mln; większość pozostałych na 200 tys. Uderz w sufit, a wywołanie po prostu padnie.
  • Degradacja przy długim kontekście. Nawet wewnątrz okna modele bardziej uważają na początek i koniec promptu niż na środek (Liu i in., 2023). Dla agenta znaczy to, że najwcześniejsze tury (cel, prompt systemowy) i najświeższe (ostatni wynik narzędzia) są dobrze obsłużone uwagą, a środek — przeterminowane wyjścia narzędzi, porzucone próby naprawy, niedokończone rozumowanie — rozmywa się niezależnie od tego, ile okna jeszcze zostało.

Każdy agent musi jakoś rozstrzygnąć, co zrobić z tymi naciskami. Ten wybór — jawny albo niejawny — jest jego strategią składania kontekstu. Niektóre strategie ignorują naciski całkowicie (wysyłaj wszystko, w każdej turze) i zostawiają płacenie użytkownikowi. Niektóre agresywnie przepisują historię. Większość CLI wybiera jeden punkt na tym spektrum i go wypuszcza.

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?

Każdy punkt to realny wybór, a kombinatoryka komplikuje się bardzo szybko. Strategia idealna dla krótkich sesji z jednym błędem może być katastrofalna przy eksploracyjnych zadaniach z wieloma błędami; limit na warstwie narzędzi, który pomaga przy małych plikach, aktywnie ukrywa błędy w większych; szablon streszczenia, który chwyta naprawę jednego błędu, może zgubić wątek, gdy błędów są cztery.

W całym artykule będziemy opierać się na małym zestawie liczb, żeby mówić o każdej strategii konkretnie. To nie są wyniki benchmarku — fixture jest celowo mały i robimy tylko 3 próby (k=3) na strategię, więc nic tutaj nie jest formalną ewaluacją. Traktuj te liczby jak wspólny słownik: sposób pokazania, jak zachowuje się każda strategia, gdzie ujawniają się jej tryby awarii i dokąd idą pieniądze, kiedy już idą. Dwie metryki, do których będziemy wracać najczęściej, to wskaźnik zaliczeń i koszt, a pod nimi kilka liczb diagnostycznych, które pomagają wyjaśnić, dlaczego dana strategia wychodzi tania albo droga.

MetrykaCo nam mówiJak ją mierzymy
Wskaźnik zaliczeńCzy zadanie zostało wykonane? Bez sukcesu reszta liczb nie ma większego znaczenia.Binarnie na przebieg; 3 próby (k=3) na strategię.
Mediana kosztuIle kosztuje typowy przebieg.Suma kosztów wywołań; podana jako mediana z trzech prób.
Koszt najgorszego przypadkuJak wygląda zły przebieg. Warto go wyróżnić, bo wybór po medianie może ukryć katastrofę za 4 dolary zdarzającą się raz na 20 przebiegów.Maksymalny koszt z trzech prób.
Liczba turZamiennik opóźnienia — więcej wywołań LLM to więcej czasu zegarowego.Liczba tur asystenta na przebieg.
Rozmiar promptu na turęDiagnostyka. Wyjaśnia, dlaczego koszt jest taki, jaki jest.input_tokens + cached_tokens na wywołanie LLM.
Współczynnik trafień w cacheDiagnostyka. Mały prompt w turze i tak może kosztować jak duży, jeśli strategia unieważnia cache w każdej turze.cached_tokens / total_tokens na wywołanie.

Kiedy będziesz czytać tabele dalej, naturalna kolejność patrzenia jest taka: najpierw wskaźnik zaliczeń (strategia, która nie naprawia błędu niezawodnie, w zasadzie nie startuje), potem koszt najgorszego przypadku (mówi o trybie awarii, który mediana uśrednia i chowa), a potem diagnostyka, jeśli chcesz zrozumieć dlaczego.

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 to nie robić nic — wysyłać całą rozmowę dosłownie przy każdym wywołaniu LLM. Nazywamy ją baseline i jest to oczywisty punkt wyjścia: zerowy koszt implementacji, doskonała stabilność cache’a, a na krótkich sesjach jest konkurencyjna wobec wszystkiego, co bardziej wymyślne. Dopóki nie uderzysz w okno kontekstu modelu, w swój budżet albo dopóki degradacja przy długim kontekście nie zacznie kosztować Cię trafności w rzeczach, które się liczą, baseline jest w porządku.

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.

Między dwoma trybami na warstwie strategii żaden nie dominuje i często się je łączy. W każdej turze jest tańsze (bez dodatkowego wywołania LLM), stabilne dla cache’a od pierwszej tury i uczciwe — znacznik wstawiony w prompt w rodzaju …[truncated 1500 chars] mówi modelowi, że czegoś brakuje, podczas gdy streszczenie może wyglądać na kompletne, nawet jeśli pomija błąd. Przy progach ogranicza rozmowę (kompaktowanie kurczy listę wiadomości, więc wersje wielorundowe mogą działać dowolnie długo), nie płaci nic za kompresję na krótkich sesjach i odzyskuje budżet uwagi — prompt po kompaktowaniu jest na tyle mały, że każda pozycja znów jest dobrze obsłużona uwagą. Z grubsza: krótkie sesje sprzyjają trybowi „w każdej turze”, długie trybowi „przy progach”, a każde produkcyjne CLI warte wydania kończy łącząc oba.

Jest jedna strategia, która nie wymaga kompresji — okno przesuwne — trzymająca tylko ostatnie K wiadomości i wyrzucająca resztę. Jest jednak wroga cache’owi z konstrukcji, a wyrzucone wiadomości znikają na zawsze — co czyni ją najbardziej agresywnie stratną strategią w zestawieniu. Przy zadaniach z wieloma błędami jest katastrofalna: wcześniejsze naprawy wyjeżdżają poza okno, agent napotyka je jako nieznany kod, cofa je i wpada w pętlę. Dlatego nie włączamy okna przesuwnego do eksperymentów — wspominamy o nim niżej tam, gdzie ilustruje wrogość wobec cache’a jako pojęcie, ale żadne porównanie istotne produkcyjnie nie wyszłoby z takim rozwiązaniem.

Skoro więc każda strategia musi kompresować, kolejne pytanie brzmi: co kompresować. Pięć częstych wzorców:

  • Wyrzucanie starych tur. Trzymaj tylko ostatnie N wiadomości. Klasyczne okno przesuwne. Wariant: przypnij oryginalny prompt użytkownika na początku.
  • Wyrzucanie starych wyników narzędzi. Zachowaj wywołania narzędzi (zachowując ślad rozumowania), ale wyrzuć albo obetnij ich wyjścia.
  • Obcinanie wyjścia narzędzi. Zachowaj każdą wiadomość, ale ogranicz każdy wynik narzędzia do maksymalnej liczby znaków — albo na warstwie strategii (stosowane ponownie w każdej turze), albo wewnątrz implementacji narzędzia (ograniczone raz, w momencie wykonania, i zapisane już ograniczone).
  • Streszczanie starych tur. Gdy historia przekroczy próg, wywołaj kolejny raz LLM, żeby wyprodukować zwięzłe streszczenie, które stanie w miejsce wyrzuconych tur. Tak robią i opencode, i własny coding-agent w pi przy przepełnieniu.
  • Pobieranie na żądanie. Trzymaj pełny log, osadź (embedding) każdą turę i przy każdym wywołaniu dołączaj tylko top-k tur najbardziej związanych z bieżącym celem. O ile nam wiadomo, nikt jeszcze nie wypuścił tego w prawdziwym CLI.

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

Jedna właściwość dominuje w koszcie niezależnie od tego, na której warstwie zachodzi kompresja, i dlatego niezwykle ważne jest, żeby jej nie zepsuć. Tą właściwością jest stabilność cache’a. Każde współczesne API LLM nalicza inne stawki za tokeny, które widziało niedawno, niż za tokeny widziane po raz pierwszy. Niejawny cache prefiksów Gemini, bloki cache_control Anthropic i cache promptów OpenAI działają strukturalnie tak samo: dostawca haszuje wiodącą sekwencję bajtów Twojego żądania, szuka dopasowania wśród niedawnych żądań i jeśli je znajdzie, nalicza za te tokeny znacznie niższą stawkę (zwykle 10–25% ceny niecache’owanej; szczegóły różnią się między dostawcami). Część cache’owana musi być prefiksem — ciągłą, identyczną sekwencją zaczynającą się od bajtu 0. Pierwszy różniący się bajt unieważnia wszystko, co po nim.

Strategia jest stabilna dla cache’a, jeśli modyfikuje prefiks co najwyżej ograniczoną liczbę razy w znanych miejscach. Konkretnie: strategia może przekształcić daną pozycję raz — na przykład obciąć wynik narzędzia, który właśnie wypadł z okna ostatnich K, albo zastąpić starszą historię zamrożonym streszczeniem przy kompaktowaniu — ale po przekształceniu bajty na tej pozycji już nigdy się nie zmieniają. Każde przekształcenie kosztuje jeden zapis do cache’a na tej pozycji; każda tura po nim ląduje na trafieniu w cache. Najsurowsza wersja to baseline, gdzie każda wiadomość pozostaje bit w bit identyczna od chwili dopisania i w ogóle nigdy się nie przekształca. Strategia nie musi być aż tak sztywna, żeby być przyjazna cache’owi: dopóki przekształcenia dzieją się w znanych punktach i potem pozostają zamrożone, cache amortyzuje się tanio.

Rozmowa agenta rośnie monotonicznie: użytkownik → wywołania narzędzi → wyniki narzędzi → asystent → wywołania narzędzi → wyniki narzędzi → asystent. Do tury 30 wysyłasz ~100 tys. tokenów w większości stabilnej historii na wywołanie, przy każdym wywołaniu. Jeśli Twoja strategia trzyma prefiks stabilnym, te 100 tys. tokenów to w większości trafienia w cache i pełną stawkę płacisz tylko za kilkaset nowych tokenów na końcu. Jeśli Twoja strategia modyfikuje prefiks w każdej turze, te same 100 tys. tokenów jest niecache’owane, a rachunek rośnie 4–10× bez żadnej korzyści behawioralnej.

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.

Przykładem przeciwieństwa — tego, co nazywamy wrogim cache’owi — jest okno przesuwne: przestawia to, co ląduje na każdej pozycji, w każdej turze, więc trafienia w cache załamują się za promptem początkowym i każdy token jest rozliczany po stawce niecache’owanej. Nie ma tu „po tym zamrożone” — jest ciągła szarpanina. Różnica między stabilnością a wrogością wobec cache’a to dominujący czynnik kosztowy we wszystkich przebiegach.

Wniosek jest taki, że przyjazne cache’owi kształty strategii są tak naprawdę tylko dwa: tylko-dopisywanie (modyfikuj wyłącznie ogon — pasują tu i baseline, i warianty z obcinaniem, bo gdy wynik narzędzia zostanie obcięty do N znaków, te N znaków już się nie zmienia) albo zamroź-raz (zrób jedno duże przepisanie — zwykle kompaktowanie — i nigdy go nie ruszaj, tak że prefiks po przepisaniu staje się nowym stabilnym zamrożonym prefiksem). Wszystko inne — okresowe rekompaktowanie, eksmisja z dynamicznego okna zależna od bieżącej tury, przepisywanie streszczenia w miejscu — modyfikuje prefiks tura po turze i jest domyślnie wrogie cache’owi. Produkcyjne CLI owszem rekompaktują okresowo, nie płacąc tej ceny, ale tylko dzięki temu, że zostawiają wcześniejsze streszczenia zamrożone, dopisują nowe bloki streszczeń zamiast przepisywać stare i używają jawnych punktów podziału cache’a (cache_control u Anthropic, prompt_cache_key u OpenAI), żeby dostawca wiedział, gdzie kończy się stabilny prefiks.

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’owanie prefiksów kluczuje po dokładnym prefiksie tokenów — ciągłej, identycznej bajt po bajcie sekwencji zaczynającej się od pozycji 0. Trafienie znaczy, że dostawca pomija prefill na tych tokenach i nalicza 10–25% normalnej stawki; model i tak działa i i tak generuje świeże uzupełnienie. To czysto obliczeniowa optymalizacja, więc jest zawsze poprawna — wyjście jest identyczne jak bez cache’a. To jest to, pod co niejawnie optymalizuje każda strategia w tym artykule, i mieszka to wewnątrz dostawcy inferencji.
  • Cache’owanie semantyczne kluczuje po znaczeniu żądania — osadzasz przychodzący prompt, robisz wyszukiwanie wektorowe po składzie wcześniejszych promptów i jeśli coś przekroczy próg podobieństwa, zwracasz zapisaną odpowiedź, w ogóle nie wywołując modelu. Trafienie oszczędza całe wywołanie, nie tylko prefill. Ale jest przybliżone: luźny próg zwraca odpowiedź na inne pytanie, a wszystko zależne od czasu albo kontekstu się starzeje. Mieszka w Twojej aplikacji (Redis, baza wektorowa, GPTCache), a próg, TTL i logika unieważniania są na Twojej głowie.
Cache’owanie prefiksówCache’owanie semantyczne
WarstwaDostawca inferencjiTwoja aplikacja
KluczDokładny prefiks tokenówOsadzenie żądania
Przy trafieniuSzybszy prefill, świeża generacjaZapisana odpowiedź, brak wywołania modelu
OszczędnośćCzęściowa — tylko prefillCałkowita — brak inferencji
PoprawnośćZawsze dokładnaPrzybliżona; może być błędna lub nieaktualna
Ty zarządzaszPunktami podziału cache’a, stabilnością prefiksuProgiem, eksmisją, unieważnianiem, partycjonowaniem

Dla agenta do kodu cache’owanie semantyczne to głównie nie to narzędzie: tury rzadko są semantycznie wymienne (każda jest uwarunkowana dokładnym bieżącym stanem repozytorium), a fałszywe trafienie oznacza działanie na nieaktualnej odpowiedzi o kodzie, który od tego czasu się zmienił. Zarabia na siebie w węższych zastosowaniach — asystentach typu FAQ, endpointach klasyfikacji, wszędzie tam, gdzie ta sama garstka pytań powtarza się u wielu użytkowników, a poprawna odpowiedź nie zależy od zmiennego stanu. Oba komponują się czysto, gdy oba mają zastosowanie: cache semantyczny z przodu, żeby skrócić powtarzające się pytania, cache’owanie prefiksów pod spodem dla wszystkiego, co jednak dociera do modelu. Reszta tego artykułu jest o tym drugim — o utrzymaniu stabilnego prefiksu, żeby wywołania, które faktycznie robisz, pozostały tanie.

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:

  • Na warstwie narzędzi (stratne przechowywanie). Narzędzie ogranicza albo przepisuje swoją wartość zwracaną, zanim wynik trafi do rozmowy. Skompresowany tekst jest tym, co dopisuje się do historii i zostaje tam na zawsze. Tak robi read_file w opencode — reszta sekcji o projektowaniu wyjścia narzędzi omawia to dalej. Gdy narzędzie zwróci wycinek 50 KB, reszty pliku nie ma nigdzie lokalnie; żeby ją odzyskać, agent musi wywołać narzędzie ponownie z innym offset.
  • Na warstwie strategii (stratny widok, bezstratne przechowywanie). Narzędzie zwraca pełne wyjście. Pełny tekst jest dopisywany do historii. W każdej turze hook poziomu tury (w pi nazywa się transformContext, omawiany dalej, gdy dojdziemy do naszej implementacji) wyprowadza na nowo skompresowany widok na pełną historię, wyłącznie na potrzeby tego wywołania LLM — obcinając, streszczając, zaślepiając, wyrzucając, cokolwiek robi strategia. Log rozmowy zachowuje każdy bajt na zawsze; redukowany jest tylko widok modelu w danej turze. Wszystkie cztery strategie, które mierzymy (baseline, truncate-500, age-truncate-500-keep-3, compact-at-12000-structured), działają w ten sposób.

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

  • Odzyskiwalność. Kompresja na warstwie strategii jest odwracalna: podmień strategię w trakcie przebiegu (albo odtwórz log później z inną strategią) i pełny tekst wraca. Kompresja na warstwie narzędzi jest nieodwracalna bez kolejnego wywołania narzędzia.
  • Komponowalność. Warstwa strategii pozwala eksperymentować z różnymi widokami na ten sam bazowy log. Warstwa narzędzi zamraża dane na stałe w ich pierwszej postaci.
  • Koszt czasu działania. Warstwa strategii wykonuje pracę kompresji w każdej turze (tanio, ale niezerowo). Warstwa narzędzi robi to raz.

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.

Nasz eksperyment używa czystej kompresji na warstwie strategii właśnie po to, żeby zachować pełną rozmowę — każdy przebieg zapisuje niezmienione wyjście narzędzi (przez logger, który dodaliśmy na strumieniu zdarzeń pi — pi trzyma rozmowę w pamięci, ale sam niczego nie utrwala), a warianty strategii są czystymi odtworzeniami na tych samych danych.

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

Wszystko dotąd działo się na warstwie składania kontekstutransformContext 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.

Na warstwie narzędzi liczą się dwa wymiary:

  • Limit na wywołanie. Maksymalny rozmiar, jaki narzędzie kiedykolwiek zwróci w jednym wywołaniu. read_file w opencode ogranicza do ~50 KB łącznie / 2000 znaków na linię. Narzędzie odczytu w Claude Code ogranicza do 256 KB po stronie pliku i 25 tys. tokenów na wyrenderowanym wyjściu. Poza limitem treść jest pomijana w wartości zwracanej i agent jej nie widzi, dopóki nie zapyta ponownie.
  • Stronicowanie. Czy agent może poprosić o kolejny wycinek. I opencode, i Claude Code przyjmują parametry offset / limit w swoim narzędziu odczytu, więc plik 200 KB staje się czterema kolejnymi wywołaniami read_file zamiast jednym obciętym odczytem. W rozmowie lądują cztery małe, stabilne dla cache’a wyniki zamiast jednego dużego, częściowo obciętego.

Interakcja z warstwą składania kontekstu jest bezpośrednia: jeśli Twoje narzędzia same się ograniczają, Twoja strategia ma mniej pracy. opencode trzyma całą rozmowę w kontekście (w domyślnej ścieżce nie ma ani wyrzucania wiadomości, ani obcinania na poziomie rozmowy) i uchodzi mu to na sucho, bo każdy wynik narzędzia jest już mały z konstrukcji. Strategia baseline na narzędziach opencode zachowywałaby się zupełnie inaczej niż baseline na narzędziu zwracającym 1 MB surowego tekstu — mimo że to ten sam baseline.

Narzędzie ze stronicowaniem w realnym sensie wykonuje kompresję bezstratną: nic nie jest trwale wyrzucane, reszta pliku wciąż jest na dysku, a agent może ją pobrać na żądanie. Narzędzie, które tylko obcina bez stronicowania, wykonuje kompresję stratną: wszystko za limitem jest niewidoczne, dopóki nie zmieni się kontrakt narzędzia.

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

W marcu 2026 Anthropic wypuściło @anthropic-ai/claude-code v2.1.88 z mapą źródeł o wielkości ~60 MB, odsłaniającą ~512 tys. linii TypeScriptu. Wkrótce potem pojawiło się kilka analiz (Straiker, Karan Prasad) i dosłowne archiwum promptów (Piebald-AI/claude-code-system-prompts). To uczyniło Claude Code zdecydowanie najlepiej empirycznie ugruntowanym punktem odniesienia w tym artykule — to jedyne duże agentowe CLI o zamkniętym kodzie, którego implementację możemy faktycznie przeczytać.

Stabilność cache’a to jedna z rzeczy, na których Claude Code wyraźnie zależy najbardziej — wyciek pokazuje wiele celowych, wzajemnie wzmacniających się posunięć, żeby prefiks pozostawał bit w bit stabilny między żądaniami.

Najbardziej widocznym z nich jest podział promptu na część statyczną i dynamiczną. Prompt Claude Code jest zbudowany jako układ o stałych pozycjach, w którym wczesne bloki — prompt systemowy, opisy narzędzi, podsumowanie przestrzeni roboczej, treść CLAUDE.md — są bit w bit identyczne w każdym żądaniu w sesji. Są jawnie oznaczone cache_control: { type: "ephemeral" } Anthropic, żeby powiedzieć API, że ten prefiks jest cache’owalny. Bloki zmienne idą po nich, na znanych pozycjach. Schematycznie każde żądanie wygląda jak przykład poniżej — przełącz zakładki, żeby zobaczyć, jak zmienia się kształt, gdy odpala kompaktowanie:

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

Dwa punkty podziału, na stałych pozycjach. Nowe tokeny są naliczane tylko za to, co dopisano na końcu. Dlaczego dwa, a nie jeden? Bo każdy znacznik cache_control tworzy niezależny wpis w cache’u — pozwalając różnym częściom promptu unieważniać się w różnym tempie (blok statyczny zmienia się ~nigdy, sekcja po streszczeniu raz na kompaktowanie, ogon w każdej turze) i dając zapasowe trafienia, gdy TTL jednego wpisu wygaśnie przed pozostałymi.

Przejście kształtu między dwiema zakładkami to jedyne miejsce, w którym prefiks do punktu podziału #2 zmienia się w trakcie sesji. Gdy odpala kompaktowanie, dzieją się naraz dwie rzeczy: starsza część ogona zapada się w nowy blok FROZEN_SUMMARY, a punkt podziału #2 przesuwa się do przodu z „na wiadomości początkowej” na „na zamrożonym streszczeniu”. To pojedyncze przejście kosztuje jedno unieważnienie cache’a — API musi zapisać nowy wpis na nowej granicy — ale każda tura po nim ląduje na nowym, dłuższym cache’owanym prefiksie.

A co z następnym kompaktowaniem i tym po nim? Niezależnie od tego, jak kompaktowanie się powtarza, żądanie zawsze niesie te same dwa punkty podziału cache’acache_control działa w obrębie żądania, więc liczą się tylko znaczniki w bieżącym wywołaniu. Punkt podziału #1 pozostaje zakotwiczony na końcu CLAUDE.md; punkt podziału #2 siedzi na tym, co w chwili żądania jest najświeższym stabilnym elementem. Nie dokładasz punktów podziału wraz z biegiem sesji — limit Anthropic wynoszący 4 na żądanie to budżet, a nie licznik rosnący z długością sesji.

To, co może się różnić między zdarzeniami kompaktowania, to układ zamrożonej treści za tym jednym znacznikiem punktu podziału #2. Dwa rozsądne projekty:

  • Rotacyjny (wybór Claude Code). Jest zawsze tylko jeden slot FROZEN_SUMMARY. Przy każdym kolejnym zdarzeniu kompaktowania poprzednie streszczenie plus nowy ogon są streszczane na nowo w świeży pojedynczy blok, zastępujący stary. Bajty na pozycji FROZEN_SUMMARY się zmieniają — więc wpis cache’a punktu podziału #2 trzeba za każdym razem przepisać. Jedno unieważnienie cache’a na zdarzenie kompaktowania, ale prompt pozostaje zwarty (kształt [seed + summary + recent], niezależnie od długości sesji).
  • Łańcuchowy. Każde nowe streszczenie dopisuje się po poprzednich zamrożonych streszczeniach; punkt podziału #2 przesuwa się do przodu, na najnowsze. Prefiks rośnie — [seed, summary_1, summary_2, ..., recent] — ale każde poprzednie streszczenie pozostaje stabilne bajtowo, więc starsze wpisy cache’a (wciąż w puli dostawcy z wcześniejszych żądań) mogą służyć jako zapasowe trafienia, mimo że nie są już oznaczone w bieżącym żądaniu. Bardziej przyjazny cache’owi, ale prompt rośnie liniowo z liczbą kompaktowań, więc w końcu trzeba by kompaktować sam łańcuch.

Rotacyjny wybór Claude Code wymienia sporadyczne unieważnienia cache’a na zwartość promptu. Rachunek sprzyja zwartości, bo kompaktowania są rzadkie względem tur — możesz odpalać jedno co kilkadziesiąt tur, przełknąć jedno unieważnienie cache’a, a potem jechać na nowym wpisie przez kilka tysięcy cache’owanych tokenów aż do następnego kompaktowania. Koszt: każde ponowne streszczanie to operacja stratna na już stratnym streszczeniu, więc przez długą sesję szczegóły kumulatywnie się wypłukują.

Dokumentacja cache’owania promptów Anthropic opisuje pełną mechanikę. W skrócie: każda dyrektywa cache_control tworzy nowy wpis w cache’u, zakotwiczony na bajcie 0 i kończący się na pozycji znacznika. Więc przykład z dwoma punktami podziału powyżej zapisuje dwa zagnieżdżone wpisy — jeden kończący się na granicy CLAUDE.md, jeden na zamrożonym streszczeniu. Przy następnym żądaniu API najpierw próbuje trafić w najdłuższy cache’owany prefiks, a wraca do krótszych, jeśli dłuższy już nie pasuje. Pozwala to różnym częściom promptu unieważniać się w różnym tempie i utrzymuje Cię na trafieniach częściowych zamiast „wszystko albo nic”.

Wzorzec Anthropic ze znacznikami w treści żądania to najczystsza wersja tego pomysłu wśród dużych dostawców. OpenAI działa czysto automatycznie — API samo decyduje, gdzie zapisywać wpisy (TTL ~5 minut); możesz grupować żądania przez prompt_cache_key, ale nie możesz oznaczyć pozycji. Gemini oferuje oba: automatyczny cache niejawny plus jawne API cachedContents, gdzie z góry tworzysz cache’owany zasób z konfigurowalnym TTL i odwołujesz się do niego po nazwie w kolejnych wywołaniach (inna ergonomia niż znaczniki Anthropic w żądaniu). Anthropic pozwala oznaczyć w żądaniu do 4 pozycji bajtowych w treści, z opcjonalnym godzinnym TTL za wyższą dopłatę przy zapisie. Do wariantu OpenAI/Codex — automat plus prompt_cache_key — wracamy w następnej sekcji.

Każda transformacja, którą Claude Code wykonuje na swoim prompcie, jest czystą funkcją dobrze zdefiniowanego stanu (liczba tur, argumenty wywołania narzędzia, pozycja wiadomości) i nigdy nie zależy od ulotnych sygnałów w rodzaju hasza HEAD bieżącej gałęzi. Cokolwiek dryfującego z tury na turę zmuszałoby prefiks do zmiany przy każdym żądaniu i załamałoby cache. Tak właśnie 30-turowa sesja z kodem może kosztować prawie tyle co baseline mimo wysyłania ~100 tys. tokenów na wywołanie.

Polityki starzenia per narzędzie

Claude Code trzyma zaszytą na sztywno listę nazw narzędzi — w wycieku nazwaną COMPACTABLE_TOOLS — które podlegają starzeniu w każdej turze. W naszej taksonomii jest to strategia poziomu tury na warstwie strategii, mimo nazewnictwa „compactable” (sugerującego kompaktowanie przy progach — osobny mechanizm, który Claude Code też ma). Narzędzia spoza listy są zwolnione: ich wyniki są trzymane dosłownie na zawsze.

Ciekawym posunięciem jest tu odejście od zwykłych dwóch skrajności w obchodzeniu się z wyjściem narzędzi: zawsze dosłownie (do czego domyślnie sprowadza się większość strategii) i zawsze ograniczone albo stronicowane (co robi read_file w opencode na warstwie narzędzi). Starzenie dodaje trzecią opcję, która łączy oba: świeże wyniki trzymaj dosłownie, starsze postarzaj. To samo narzędzie, inne traktowanie w zależności od tego, jak nieaktualny jest wynik. Rozwiązuje to problem, który każda skrajność tworzy sama z siebie: zawsze dosłownie pozwala przeterminowanym blobom po 50 KB kumulować się w nieskończoność; zawsze ograniczone może odciąć plik, który agent właśnie otworzył (co jest dokładnie trybem awarii truncate-500 pokazanym niżej).

Claude Code idzie o krok dalej: nie tylko kiedy postarzać, ale i jak. Każde narzędzie z listy dostaje inną regułę, dostrojoną do tego, jak wyjście tego narzędzia zachowuje wartość w czasie:

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.

Uzasadnienie każdego wiersza, po ludzku:

  • Przeterminowany list_files to niemal czysty szum — gdy agent skończył eksplorować katalog, stary listing ma niemal zerową dalszą wartość. Więc obcina się go ostro i szybko.
  • Przeterminowany wynik read_file jest subtelniejszy: agent może jeszcze chcieć ten plik. Zamiast obcinać jego tekst, Claude Code zastępuje go zaślepką z nazwą ścieżki; agent może pobrać go ponownie, wywołując read_file z tymi samymi argumentami. Bezstratne w tym sensie, że nic nie przepadło, tylko zostało odroczone.
  • Przeterminowany write_file reprezentuje działanie, które agent wykonał — zmodyfikował ten plik. Zapomnienie, że coś się zapisało, to przepis na przepisanie tego inaczej w następnej turze. Trzymany dosłownie.
  • Przeterminowany run_tests niesie autorytatywny stan zestawu testów, z którym agent często ponownie się zestawia. Trzymany dosłownie.

Kształt jest ten sam co w naszym age-truncate-500-keep-3 — stabilny dla cache’a, kluczowany wiekiem, deterministyczny. Uogólnieniem jest granulacja per narzędzie zamiast jednej jednolitej reguły dla wszystkich wyników narzędzi. Na długich sesjach, gdzie wiele narzędzi wywoływanych jest wiele razy, granulacja się opłaca, bo eksmisja każdego narzędzia odpowiada jego rzeczywistej krzywej utrzymywania wartości. Na naszych przebiegach 10–20 tur agent wywołuje każde narzędzie tylko kilka razy, więc niuans nie ma gdzie się ujawnić — jednolite obcinanie po wieku i tak zgarnia większość oszczędności.

Szablon strukturalnego streszczenia

Ostatni projekt Claude Code wart wyciągnięcia to sam prompt streszczania — prompt systemowy wysyłany do modelu, gdy odpala kompaktowanie. Wyciek zawiera go jako system-prompt-context-compaction-summary.md. Nasza strategia compact-at-12000-structured używa sparafrazowanej wersji dokładnie tego promptu — te same pięć sekcji, te same ograniczenia — więc produkowane przez nią streszczenie ma ten sam kształt co u Claude Code:

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

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

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

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

Trzy szczegóły tego projektu sprawiają, że działa:

  • Kolejność sekcji odwzorowuje ludzkie przekazanie zadania. Task Overview → Current State → Discoveries → Next Steps → Context to Preserve to mniej więcej to, jak inżynier wprowadza kolegę przejmującego zadanie: jaki jest cel, gdzie jesteśmy, czego się nauczyliśmy, co dalej, czego nie wolno upuścić. Agent po kompaktowaniu czyta to tak samo.
  • „Podejścia, które NIE zadziałały” są w Discoveries. Bez tej jawnej instrukcji streszczenia zwykle skupiają się na osiągnięciach i po cichu gubią ślepe zaułki — przez co agent po kompaktowaniu ponawia te same nieudane podejścia i przepala tury. To jedno zdanie zapobiega konkretnemu trybowi awarii, w który czasem wpadają swobodne prompty streszczające.
  • „Cite exact filenames. No filler. No conversational framing.” Szablon wymusza, żeby streszczenie było wykonawcze, a nie narracyjne. Nazwy plików są też stabilne bit w bit między regeneracjami, co zachowuje trafienia w cache, jeśli implementacja wielorundowa będzie później streszczać przyrostowo.

Trzy linijki ograniczeń dołożone do zwykłego promptu „streść transkrypt” — i wynikowe streszczenie jest ostrzejsze, mniejsze i łatwiejsze do działania dla agenta po kompaktowaniu. Uogólniająca lekcja: sam prompt streszczania jest częścią strategii — nie szczegółem, który można zostawić domyślnym ustawieniom modelu.

Codex

Codex podchodzi do tego samego problemu znacznie prościej. Zarówno Claude Code, jak i Codex głęboko dbają o stabilność cache’a — po prostu osiągają ją inną ilością maszynerii. Claude Code czyni z niej jawny kontrakt: punkty podziału cache_control w żądaniu, deterministyczna eksmisja, ręcznie dostrojona struktura promptu. Codex opiera się na historii w trybie tylko-dopisywania plus cache prefiksów OpenAI — API samo decyduje, gdzie i na jak długo zapisywać wpisy, a Codex po prostu mu nie przeszkadza.

W terminach naszej taksonomii zachowanie ustalone Codeksa to strategia „nic nie rób” — wysyłaj rozmowę dosłownie, tylko dopisując, bez przepisywania w każdej turze, prefiks rośnie monotonicznie. Ciekawe dodatki włączają się tylko jako awaryjne. Dwa z nich działają szeregowo:

  • Kompaktowanie przy progach to podstawowy mechanizm awaryjny — proaktywny. Odpala, gdy rozmowa przekroczy próg kontrolowany przez Codeksa i ustawiony poniżej faktycznego okna kontekstu modelu, więc wyzwala się, zanim API by odrzuciło. Mechanizm: dodatkowe wywołanie LLM streszcza starszą część, a rozmowa jest przebudowywana jako [summary, recent...]. Kosztuje jedno wywołanie streszczające, ale zachowuje spójnego zastępcę wyrzuconych szczegółów.
  • Awaryjne przycinanie od początku to przycisk paniki — reaktywny. Odpala tylko wtedy, gdy normalne żądanie mimo wszystko zwraca ContextWindowExceeded już po kompaktowaniu (co może się zdarzyć, jeśli ogon po kompaktowaniu odrósł albo jeśli nawet wyjście samego kompaktowania jest za duże). Mechanizm: wyrzuć najstarszą wiadomość, ponów; wyrzuć kolejną najstarszą, ponów; w pętli, aż żądanie się zmieści. Bez wywołania LLM, ale elementy znikają całkowicie i bez streszczenia, a każda ponowna próba to zmarnowane, opłacone żądanie.

Przez większość dowolnej sesji — zanim odpali pierwsze kompaktowanie — zachowanie Codeksa na warstwie strategii jest tożsamościowe. Powyższe mechanizmy awaryjne to to, co powstrzymuje podejście „nic nie rób” przed wywróceniem się w długich sesjach, a nie sposób radzenia sobie z kosztem w stanie ustalonym. (Codex ma też limity na warstwie narzędzi, omawiane osobno niżej.)

Szczegóły warstwy strategii dla ciekawych. Stabilność cache’a opiera się na czymś więcej niż prefiks w trybie tylko-dopisywania — Codex ustawia klucz cache’a na rozmowę (prompt_cache_key = conversation_id), żeby zawęzić cache OpenAI do sesji. Kompaktowanie jest zaimplementowane we własnym module; jego prompt streszczający to strukturalny szablon — ujęty jako „streszczenie przekazania dla innego LLM”, to samo konceptualne posunięcie co 5-sekcyjny szablon Claude Code powyżej, tylko mniej sztywno ustrukturyzowany. Oba produkcyjne CLI wyciągnęły tę samą lekcję: swobodny prompt „streść transkrypt” nie wystarczy; chcesz kontraktu na to, co streszczenie musi zawierać. Ogon po kompaktowaniu jest ograniczony do 20 tys. tokenów. Ścieżka awaryjnego przycinania niesie jawny komentarz z uzasadnieniem: „to preserve cache (prefix-based) and keep recent messages intact.”

Szczegóły warstwy narzędzi. Dwa mechanizmy Codeksa na warstwie narzędzi działają w czasie wykonania, więc rozmowa przechowuje zawsze tylko wersję już obciętą — ten sam slot architektoniczny co limit 50 KB w read_file opencode’a, stratno-przechowująca odmiana kompresji omówiona wcześniej w sekcji Projektowanie wyjścia narzędzi. Po pierwsze, narzędzie shell ma twardy limit wyjścia 1 MiB; powyżej niego model musi sam kombinować ze stronicowaniem przez sed -n '...p'. Po drugie, zapisane wyjście shella przechodzi przez TruncationPolicy z obcinaniem środka — obcinanie środka zachowuje dosłownie pierwsze N bajtów i ostatnie M bajtów, a środkowy odcinek zastępuje znacznikiem ...[truncated K bytes]..., w zakładzie, że dla wyjścia shella echo polecenia na początku i status wyjścia na końcu to bajty niosące sygnał.

opencode

opencode trzyma całą rozmowę w kontekście bez obcinania na poziomie rozmowy. Kompresja zachodzi na dwóch warstwach:

  • Warstwa narzędzi. Narzędzia same się ograniczają w czasie wykonania: read_file ogranicza do ~50 KB / 2000 znaków na linię, dopasowania grep są stronicowane i tak dalej. W rozmowie gromadzi się wiele małych wyników zamiast kilku ogromnych.
  • Warstwa strategii. Gdy rozmowa przekroczy próg, opencode uruchamia wielorundowe streszczanie (podobne w kształcie do Claude Code i Codeksa), żeby skompaktować starszą historię.

Podział: ograniczone narzędzia zdejmują większość nacisku na rozmiar, kompaktowanie zbiera resztę. Zakład opencode’a polega na tym, że staranny projekt każdego narzędzia pozwala warstwie strategii pozostać lekką.

pi-coding-agent

Goły Agent (ten, którego używa nasz eksperyment) nie ma żadnego domyślnego ustawienia — właśnie to uczyniło go wygodnym do czystego porównania strategii. Wyższopoziomowy pakiet pi-coding-agent zbudowany na Agent dostarcza wielorundowe streszczanie przy przepełnieniu, kształtem podobne do opencode’a i Codeksa. Narzędzia definiuje się per agent; dołączony read_file nie narzuca własnego limitu.

Strategie, które implementujemy

Skoro zobaczyliśmy już, co faktycznie robią produkcyjne CLI, możemy wybrać mały zestaw reprezentatywnych strategii do zmierzenia. Każda strategia w tym artykule jest porównywana z baseline — strategią tożsamościową, w której nic nie jest wyrzucane ani przepisywane, a prefiks rośnie monotonicznie z turami, więc trafienia w cache są na teoretycznym maksimum.

Każda strategia działa na tym samym obciążeniu — ten sam fixture (opisany niżej), ten sam model, te same prompty. Dla każdej strategii uruchamiamy agenta 3 razy (k=3) — bo nawet przy temperature=0 trajektorie Gemini Flash rozjeżdżają się między przebiegami, więc zbieramy 3 punkty danych na strategię, żeby policzyć medianę/rozrzut, zamiast stawiać na jedną próbę. Mierzymy wskaźnik zaliczeń, całkowity koszt, tury, szczytowy rozmiar promptu i to, ile tokenów wejściowych zostało naliczonych po stawce cache’owanej, a ile po niecache’owanej.

Przejdziemy szczegółowo przez jeden fixture — zadanie naprawy błędu w jednym pliku, które nazwiemy głównym fixture’em — i podamy dla niego wyniki, transkrypty i różnice per tura dla każdej strategii. Wybraliśmy po jednej kanonicznej instancji na wzorzec, zamiast przeczesywać parametry; celem jest nauczyć kształtów, a nie zbenchmarkować każde ustawienie. Każdy wiersz poniżej odpowiada wzorcowi z produkcyjnych CLI powyżej:

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

Uwaga o nazewnictwie. Nazwa każdej strategii ma kształt <rodzina>-<parametr>[-<modyfikator>]. Więc age-truncate-500-keep-3 czyta się jako: rodzina = age-truncate (obcinanie świadome wieku), 500 = limit znaków dla starszych wyników, keep-3 = 3 najświeższe wyniki narzędzi przechodzą dosłownie. Podobnie compact-at-12000-structured to: rodzina = compact, at-12000 = odpala, gdy rozmowa przekroczy 12 000 znaków, structured = używa strukturalnego szablonu streszczenia (w odróżnieniu od wariantu swobodnego). Każdy token liczbowy ma znaczenie nadane przez pokrętło rodziny, które parametryzuje.

Pozostałe wzorce z taksonomii — wyrzucanie starych tur (sliding-N), pobieranie na żądanie, zastępowanie starych odczytów zaślepkami (kształt COMPACTABLE_TOOLS z Claude Code), swobodne streszczenia — są celowo poza zakresem. Sliding-N i pobieranie, bo są wrogie cache’owi z konstrukcji (prefiks zmienia się w każdej turze). Zaślepianie starych odczytów i swobodne kompaktowanie, bo to warianty wzorców, które i tak mierzymy (zaślepianie starych odczytów to forma age-truncate per narzędzie; swobodne kompaktowanie to ten sam kształt co strukturalne, z innym promptem streszczającym). No i produkcyjnej klasy wielorundowe kompaktowanie, bo to osobny problem inżynierski (który próg, co trzymać dosłownie, czy łańcuchować streszczenia) zasługujący na własny artykuł.

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 wyrzucaimplementacja
compact-at-12000-structuredwszystkie tury sprzed stałego punktu podziału, zastąpione streszczeniem LLM według 5-sekcyjnego szablonu Claude Codejedno wywołanie LLM, zamrożone streszczenie

Szczegóły każdej strategii — co robi, implementacja w jednej linii, zachowanie cache’a, kształt kosztu, kiedy jej używać i kto wypuszcza ją produkcyjnie — znajdziesz w tabeli referencyjnej poniżej. Kliknij ikonę, żeby rozwinąć na pełny ekran do czytania obok siebie.

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

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 to hook pakujący tę rozmowę na drut. Działa na wyjściu transformContext i tłumaczy wewnętrzną tablicę AgentMessage[] na specyficzną dla dostawcy Message[], która faktycznie leci do Anthropic, Gemini albo OpenAI — filtrując własne typy wiadomości, których dostawca nie rozumie, naprawiając bloki treści dla modeli bez obsługi załączników i tak dalej.

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ć.

Co kluczowe, oryginalne pełne wyjścia narzędzi nigdy nie opuszczają logu rozmowy po stronie pi. Są niewidzialne dla LLM (bo strategia streszcza je albo obcina z promptu), ale są odzyskiwalne — podmień strategię w trakcie przebiegu albo odtwórz log później, a pełny tekst wraca. To ta właściwość „bezstratnego przechowywania” z poprzedniej sekcji, uczyniona konkretną.

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 to jedno wywołanie Number() na granicy API. Choć błąd jest płytki, siedzi za 500. znakiem api.ts — i właśnie dlatego truncate-500 polegnie niżej katastrofalnie: agent nigdy nie dociera do linii, którą trzeba naprawić.

Nasz fixture jest skrajnie prosty w porównaniu z poważnymi otwartymi ewaluacjami, przeciw którym raportuje każde laboratorium modeli — SWE-bench (i jego warianty Verified / Live) dla napraw błędów w pełnych repozytoriach, τ-bench dla poprawności użycia narzędzi, TerminalBench dla zadań w powłoce, BigCodeBench dla realistycznego kodu korzystającego z bibliotek, poliglotyczny benchmark Aidera dla edycji wielojęzycznej — ale lepiej służy naszemu celowi. Wszystkie te benchmarki trzymają obudowę stałą i zmieniają model, produkując jedną liczbę na model: przydatne do rankingu, nieprzejrzyste co do przyczyn. Ten artykuł robi odwrotnie: model stały (Gemini 2.5 Flash), strategia obudowy zmienna, na fixture’ze celowo na tyle małym, że można przeczytać każdy transkrypt od początku do końca. Gdy raz zobaczysz te mechanizmy w 13-turowym transkrypcie, możesz rozumować o tym, co zrobiłyby w 200-turowym.

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"

Zestaw testów prowadzi API przez pełną rundę JSON — JSON.stringify({...}), a potem parseRequest() — zamiast wołać handle() bezpośrednio z typowanym obiektem. To właśnie ta runda czyni błąd osiągalnym: to jedyny sposób, w jaki payload.id dociera w czasie wykonania jako łańcuch znaków. „Użytkownikiem” biblioteki jest ten zestaw testów; agent zostaje wrzucony do środka, dostaje cztery narzędzia (read_file, write_file, list_files, run_tests) i prośbę, żeby doprowadzić zestaw do zieleni.

Cztery narzędzia użyte w obudowie zaimplementowaliśmy sami jako cienkie nakładki. Każde to kilka linijek node’owego fs plus schemat JSON zarejestrowany w interfejsie AgentTool pi. Są celowo bez limitów: read_file zwraca całą zawartość pliku bez limitu na wywołanie, bez stronicowania, bez limitu na linię; write_file to zwykłe nadpisanie; list_files zwraca pełny listing katalogu. Gdyby read_file ograniczał wyjście na warstwie narzędzi (tak jak robi to opencode), truncate-500 i age-truncate-500-keep-3 zachowywałyby się nierozróżnialnie na małych plikach — limit narzędzia wykonywałby pracę, którą ma wykonywać strategia. Utrzymanie narzędzi minimalnymi wymusza, żeby każda obserwowalna różnica w eksperymencie brała się wyłącznie ze strategii składania kontekstu.

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 poniżej rysuje cztery strategie tura po turze — po jednym przedstawicielu na kształt, którego chcemy nauczyć: baseline (kontrola — bez transformacji), age-truncate-500-keep-3 (obcinanie świadome wieku w każdej turze), compact-at-12000-structured (kompaktowanie przy progach z szablonem Claude Code) i truncate-500 (katastrofalny tryb awarii — jednolite obcinanie na tyle agresywne, żeby ukrywać błędy). Przełączaj się między trzema metrykami:

  • rozmiar promptu — łączne tokeny wejściowe wysłane w tej turze, licząc zarówno nowe, jak i cache’owane (input_tokens + cached_tokens z raportu zużycia dostawcy). To określa, ile pracy LLM musi przeczytać.
  • koszt skumulowany — bieżąca suma, w USD, każdego rachunku per tura (prompt + cache + wyjście Gemini Flash) do tej tury włącznie.
  • współczynnik trafień w cache — dla tej tury cached_tokens / (cached_tokens + input_tokens). 1,0 znaczy, że każdy token wejściowy przyszedł z cache’a prefiksów; 0,0 znaczy, że nic nie było cache’owane i zapłaciłeś pełną stawkę za cały prompt. Obie liczby bierzemy wprost z rozbicia zużycia per wywołanie od dostawcy i liczymy współczynnik dla każdej tury.
metric:bug-01 · four strategies side-by-side

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.

Na tym tura się kończy. Następna tura to następne wywołanie LLM. Do tury N rozmowa urosła mniej więcej do 1 + 2(N-1) wiadomości — prompt początkowy plus naprzemienna para odpowiedź-LLM/wynik-narzędzia na każdą wcześniejszą turę.

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 (wstrzyknięta przez hook onPayload pi, bo Agent nie wystawia temperatury bezpośrednio). Nawet przy zerze Flash w praktyce nie jest w pełni deterministyczny — wsadowa inferencja i szum zmiennoprzecinkowy powodują rozjazd trajektorii agenta między przebiegami, dlatego robimy k=3 na komórkę zamiast k=1.

Co mierzymy na przebieg:

  • pass — czy zestaw testów zzieleniał na końcu?
  • turns — liczba tur asystenta (czyli wywołań LLM).
  • cost — dolary, zsumowane po każdym wywołaniu w przebiegu (prompt + wyjście + cache Gemini Flash).
  • 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.

Kolumna cached to sygnał granicy cache’a: wysoka wartość znaczy, że strategia zachowuje prefiks między turami; niska — że go unieważnia.

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

Rzucają się w oczy trzy rzeczy.

truncate-500 zawodzi katastrofalnie — i drogo. 0/3 zaliczeń, przy mniej więcej 6× koszcie baseline. Błąd w api.ts siedzi za 500. znakiem pliku, który agent czyta, więc limit 500 znaków na wyniki narzędzi dosłownie odcina wadliwy kod. Agent czyta plik, widzi wyglądający kompletnie blok importów i definicje typów, ufa im, nie znajduje błędu, próbuje losowych edycji i przepala pieniądze. Zbyt agresywne obcinanie nie jest tylko stratne — jest stratne w sposób mylący, bo agent nie ma jak się dowiedzieć, że brakuje mu istotnego fragmentu. To najostrzejszy antywzorzec w artykule.

age-truncate-500-keep-3 to najlepsza równowaga. 3/3 zaliczeń, średni koszt $0.017 (praktycznie remis z baseline), trafienia w cache zachowane (11 tys. cache’owanych tokenów). Pomysł strategii — trzymaj ostatnie K wyników narzędzi dosłownie, starsze obcinaj — omija tryb awarii „ukryj błąd” (agent zawsze widzi świeże odczyty w całości), a mimo to dociska środek rozmowy. Jest też deterministyczna i przypięta do pozycji, więc przechodzi regułę stabilności cache’a. Jeśli potrzebujesz domyślnego rozwiązania, to jest ten kształt.

Kompaktowanie też działa. compact-at-12000-structured zalicza 3/3 przy $0.016 — praktycznie remis z baseline mimo dodatkowego wywołania LLM potrzebnego do wyprodukowania streszczenia, bo prompt po kompaktowaniu jest na tyle mały, że oszczędności wchłaniają koszt streszczania. Próg ma znaczenie: odpal za wcześnie (zanim diagnoza błędu ustali się w rozmowie), a streszczenie uchwyci eksplorację, ale nie rozwiązanie; odpal za późno, a większość rachunku już zapłacona. 12 000 znaków trafia w słodki punkt dla tego fixture’a; produkcyjne CLI używają wielorundowego kompaktowania z adaptacyjnymi progami, żeby obsłużyć przypadek ogólny.

Szerszy obraz z tych czterech: trzy z czterech strategii zaliczają 3/3 przy zasadniczo tym samym koszcie. To samo w sobie jest ustaleniem: przy zadaniu naprawy błędu na 10–20 tur, jeśli Twoja strategia jest stabilna dla cache’a i nie niszczy informacji, z której agent aktywnie korzysta, możesz wybrać mniej więcej cokolwiek. Koszt i niezawodność rozjeżdżają się ostro tylko wtedy, gdy strategia łamie jedną z tych dwóch zasad — co robi truncate-500 (niszczy zbiór roboczy), a reszta nie.

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

Większość CLI i implementacji referencyjnych traktuje zarządzanie kontekstem jako „wyrzucaj stare rzeczy” albo „streszczaj stare rzeczy”. Oba mają problemy, które widzieliśmy w danych. Wyrzucanie starych rzeczy (okno przesuwne) zabija cache. Streszczanie starych rzeczy (kompaktowanie) kosztuje dodatkowe wywołanie LLM i jest wrażliwe na jakość streszczenia — jeśli streszczenie pominie diagnozę błędu, agent gubi wątek.

Jest trzecia opcja, która według naszych danych jest niedoceniana: obcinaj ogon starych wyników narzędzi, świeże trzymaj dosłownie, nic więcej nie zmieniaj. Konkretnie:

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);
    });
  };
}

Dlaczego to dobrze działa:

  • Szanuje regułę granicy cache’a. Decyzja o obcięciu jest czystą funkcją pozycji wiadomości i długości tekstu — deterministyczna, stabilna między turami. Wynik narzędzia na pozycji 5, raz obcięty, pozostaje obcięty identycznie przy każdym kolejnym wywołaniu. Cache prefiksów trafia tak jak przy baseline.
  • Nigdy nie ukrywa błędu. Ostatnie K wyników narzędzi — te, od których zależy bieżące rozumowanie agenta — nie są w ogóle ruszane. Jeśli agent właśnie przeczytał plik, widzi cały plik.
  • Jest tanie. Czysta praca w procesie; żadnego dodatkowego wywołania LLM, w odróżnieniu od kompaktowania.
  • Jest nudne. ~15 linii kodu, jeden jasny niezmiennik. Nie wymaga ćwiczeń ze strojenia progu (małe K w rodzaju 3 sprawdza się w różnych zadaniach). Nie ma podproblemu „co powinno trafić do streszczenia?”.

Dane z głównego fixture’a wspierają tę tezę: 3/3 zaliczeń, $0.017 — praktycznie remis z baseline mimo stosowania limitu w każdej turze. Limit nie szkodzi agentowi, bo ogon — świeże odczyty i wyjście testów, o których agent aktywnie rozumuje — pozostaje nietknięty. Intuicja jest taka, że limit ogranicza to, jak bardzo może urosnąć prompt danej tury, podczas gdy świeży zbiór roboczy jest zachowany dosłownie, więc nic z tego, czego agent aktualnie potrzebuje, nie zostaje ukryte.

Nie zdominuje kompaktowania przy naprawdę długich rozmowach — za pewnym horyzontem nawet obcięte stare wyniki narzędzi wypierają użyteczny kontekst — ale dla zakresu 10–50 tur, który pokrywa większość jednozadaniowych sesji z kodem, jest to najlepsze domyślne rozwiązanie, jakie znaleźliśmy.

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

opencode bierze się za ten sam problem z przeciwnej strony: ogranicza wyjście narzędzi na warstwie narzędzi (read_file zwraca najwyżej ~50 KB / 2000 znaków na linię, grep stronicuje) i zostawia długie sesje kompaktowaniu. Obcinanie wyników narzędzi świadome wieku żyje zamiast tego na warstwie strategii. Z tego wyboru wynikają dwie zasadnicze różnice:

  • Model przechowywania. opencode robi stratne przechowywanie — gdy narzędzie zwróci swój ograniczony wynik, reszty pliku nie ma nigdzie lokalnie; agent musi ponownie wywołać read_file z offset/limit, żeby dostać więcej. age-truncate robi stratny widok przy bezstratnym przechowywaniu — log rozmowy trzyma pełne wyjście każdego narzędzia na zawsze, a strategia wyprowadza skompresowany widok na nowo w każdej turze. Podmień strategię w trakcie przebiegu, a pełne bajty wracają bez ponownego wołania narzędzi. Łatwiej eksperymentować; więcej miejsca na dysku.
  • Świeży zbiór roboczy. Limit opencode’a na warstwie narzędzi jest jednolity: plik, który właśnie otworzyłeś, też jest ograniczony do 50 KB. Jeśli błąd mieszka za limitem, agent musi poprosić o kolejny wycinek — dokładnie ten tryb awarii, który truncate-500 pokazał w miniaturze na naszym fixture’ze (tam limit jest jednolity, 500 znaków). opencode łagodzi to znacznie większym limitem i jawnym stronicowaniem. age-truncate odwraca dyscyplinę: K najświeższych wyników przechodzi dosłownie niezależnie od rozmiaru, a ograniczane są tylko starsze wyniki. Więc odczyt 100 KB, który właśnie zrobiłeś, jest w pełni widoczny; ten sam odczyt 100 KB sprzed 10 tur został obcięty do 500 znaków.

Te dwa podejścia są komplementarne, nie konkurencyjne. Limity opencode’a na warstwie narzędzi ograniczają każdy pojedynczy ogromny zrzut (plik logu 1 MB, ślad stosu z rozbieganego testu); age-truncate-500-keep-3 zapobiega nieograniczonemu rośnięciu liczby wyników zachowanych dosłownie w kolejnych turach. Produkcyjny stos robiłby oba: ograniczał wyjście każdego narzędzia na warstwie narzędzi (żeby pojedyncze zrzuty pozostały rozsądne), potem uruchamiał obcinanie świadome wieku na warstwie strategii (żeby zbiór roboczy się nie kumulował), a kompaktowanie rezerwował na prawdziwy przypadek długiej sesji.

Powód, dla którego age-truncate działa w naszym eksperymencie bez limitów na warstwie narzędzi, jest taki, że celowo usunęliśmy te limity, żeby wyizolować efekt strategii. Produkcyjnie chciałbyś obu.

Jest osobna linia prac warta poznania: uczone kompaktory. Każda strategia kompaktowania w tym artykule używa streszczacza sterowanego promptem — tego samego modelu, tylko poproszonego o napisanie streszczenia. Uczony kompaktor zastępuje prompt małym modelem dostrojonym specjalnie pod trafienie w zadany współczynnik kompresji przy zachowaniu skuteczności na zadaniu docelowym. Cmprsr (Zakazov i in., 2026) trenuje Qwen3-4B przez SFT + GRPO dokładnie w tym celu, w tym tryb „niezależny od pytania”, którego wyjście da się ponownie użyć przy pytaniach uzupełniających — czyli stabilny dla cache’a w sensie, na którym zależało §2. Wersja sproduktyzowana (compresr.ai) dostarcza to jako wywołanie SDK compress(), które możesz wpiąć w hook transformContext. Naturalnie pasuje to do reżimu po stronie Pro, który ujawniło nasze przełączenie: gdy dziury karzą budżet myślenia agenta i wygrywa spójny skompresowany widok, kompaktor wytrenowany pod cel kompresji, a nie namówiony do niej, jest kolejną rzeczą, po którą warto sięgnąć.