Ustrukturyzowane instrukcje systemowe dla agentów AI w języku Rust: warstwy, cacheowanie, sprawdzanie odchyleń.
Stwórz warstwową instrukcję systemową dla agenta w języku Rust: tożsamość, instrukcje, zasady wyjścia, przykłady oraz aktualny kontekst, plus granice cache’u i odcisk palca do wykorzystania w CI.
Pierwszy agent zazwyczaj radzi sobie z prostem poleceniem składającym się z jednej ciągłej wartości, mogącej mieć około sześćdziesięciu słów: odpowiada na pytania dotyczące projektu w Rust i wywołuje funkcję read_file, gdy potrzeba zobaczyć zawartość pliku. To działa, ponieważ istnieje dokładnie jeden narzędzie, jeden użytkownik i nie ma żadnych zasad dotyczących tego, jak powinny wyglądać odpowiedzi. Potem sytuacja się komplikuje – dodanie drugiego narzędzia oznacza, że model musi nauczyć się, kiedy wyświetlać listę plików, a kiedy je czytać. Trzecie narzędzie wymaga ustalenia określonej kolejności działań. Zasada wyjściowa typu „uwzględnij plik, który przeczytałeś” potrzebuje konkretnego miejsca w strukturze polecenia. Test regresji sprawdzający, czy polecenie nadal daje tę samą odpowiedź przy stałym wejściu, wymaga sposobu na wykrycie przypadkowej zmiany polecenia. Cacheowanie poleceń sprawia, że trzeba zwracać uwagę na to, które części zmieniają się przy każdej turze, a które nie. Zwykła wartość literalna nie radzi sobie z tym wszystkim w sposób uporządkowany.
Poniżej znajduje się ustrukturyzowany prompt dla agenta o nazwie Eugene, który potrafi sobie z tym poradzić: cztery warstwy, narzędzie do tworzenia tekstów, kontekst na każde żądanie, jeden przykład typu few-shot, hash służący do wykrywania odchyleń oraz granica statyczna/dynamiczna, dzięki czemu bufor promptów Anthropic płaci za tę postać tylko raz, a nie przy każdej odpowiedzi. Pętla agenta pozostaje bez zmian – zmienia się jedynie forma wiadomości systemowej.
Dlaczego struktura pomaga
Prompt to kod, ponieważ kieruje zachowaniem, a każde zdanie w nim zawarte konkuruje o uwagę modelu. Długi, nieuporządkowany blok jest częściowo ignorowany, natomiast krótki zestaw oznaczonych sekcji jest czytany uważnie.
Brzmi to jak zabobon, ale jest to zaobserwowane zachowanie. Claude i jego odpowiedniki są szkolone na instrukcjach w znajomych formatach: sekcjonowanych komendach systemowych, zapytaniach użytkownika składających się z prostych żądań oraz odpowiedziach asystenta zawierających proste informacje. Gdy zachowujemy te formy, model mniej się waha co do znaczenia poszczególnych elementów, co zmniejsza ilość halucynacji, poprawia jakość wyników i ułatwia wybór narzędzi.
Cztery warstwy opisane poniżej nie są jedyną możliwą strukturą. Są to najprostsze rozwiązanie, które przetrwa zmiany, przez które przechodzi rzeczywisty agent. Niemal identyczna struktura występuje w komendach systemowych, które Claude Code CLI tworzy dla siebie, i to nie jest przypadek – agenci, którzy radzą sobie z rzeczywistymi użytkownikami, mają tendencję do przyjmowania tej samej struktury.
Warstwy
Tożsamość to akapit opisujący, kto mówi, co określa ton, specjalistyczną wiedzę oraz stanowisko. Przykład: Eugene to staranny asystent badawczy w projekcie Rust, który woli przeczytać kod źródłowy niż zgadywać. Nie powinny się w nim znajdować żadne zasady ani fakty.
Instrukcje to zasady zachowania, z których każda stanowi pojedyncze stwierdzenie podlegające weryfikacji: użyj list_files, aby dowiedzieć się, co znajduje się w projekcie przed jego przeczytaniem, lub nie próbuj ponownie wykonać operacji, która zwróciła błąd. Z czasem gromadzą się one w miarę rozwoju agenta. Trzymaj je w formie listy, ponieważ punkty są łatwiejsze do dodawania i usuwania niż akapity.
Ograniczenia dotyczące wyniku opisują formę odpowiedzi, a nie jej treść: zwykła proza bez formatowania Markdown, cytat w nawiasie odnoszący się do przeczytanej pliku, odpowiedzi poniżej 200 słów. Obowiązują one niezależnie od tego, o co zapytał użytkownik. Oddzielenie ich od instrukcji umożliwia zmianę formatu bez wpływu na zachowanie, i odwrotnie.
Przykłady pokazują, jak funkcjonuje ta postać. Przykład typu „kilka prób” realizuje w jednej linii to, co instrukcja wymaga w trzech liniach, ponieważ pokazuje dobry wynik wraz ze stylem cytatu. Przykłady stanowią część tożsamości agenta, dlatego znajdują się w statycznym prefiksie obok informacji o tożsamości i instrukcji.
Kontekst przechowuje wszystko, co zmienia się przy każdym uruchomieniu: datę, ustawienia lokalne użytkownika, korzeń projektu oraz dostępne umiejętności w danym momencie. Ponieważ się zmienia, nie może znajdować się w statycznej ciągu znaków. Znajduje się on w osobnym warstwie poniżej granicy cache’u i jest odbudowywany przy każdej prośbie.
Budowniczy typowany w Rust
W języku Rust ta struktura staje się budowniczym, w którym każda metoda reprezentuje jedną warstwę. Odnotowuje on, które warstwy są statyczne, a które dynamiczne, oraz renderuje granicę pomiędzy nimi.
let prompt = SystemPromptBuilder::new()
.identity("You are Eugene, a careful research assistant who answers \
questions about a Rust project. You prefer reading the source \
over guessing.")
.instruction("Use `list_files` to discover what is in the project before reading.")
.instruction("Use `read_file` to inspect a specific file. Do not call it on \
paths you have not seen listed.")
.instruction("If a tool returns an error, do not retry the same call.")
.output_constraints("Answer in plain prose. Cite the file you read in parentheses, \
for example: (src/main.rs).")
.example("What edition does Cargo.toml use?",
"I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.")
.context(format!("<env>\ntoday: {today}\nproject_root: {sandbox}\n</env>"))
.build();
Kompilator nie może tu wiele sprawdzić, ale czytelność znacznie się poprawia. Można przejrzeć cały łańcuch i wiedzieć, co zobaczy agent. Pierwsze pięć metod tworzy statyczny prefiks, a ostatnia buduje dynamiczny sufiks. Renderowany prefiks wygląda tak:
## Identity
You are Eugene, a careful research assistant ...
## Instructions
- Use `list_files` to discover what is in the project before reading.
- Use `read_file` to inspect a specific file. Do not call it on paths ...
- If a tool returns an error, do not retry the same call.
## Output
Answer in plain prose. Cite the file you read in parentheses ...
## Examples
Example 1:
User: What edition does Cargo.toml use?
Assistant: I'll check Cargo.toml directly. (Cargo.toml) ...
Sufiks jest krótszy i w pełni dynamiczny:
## Context
<env>
today: 2026-05-22
project_root: /Users/me/code/eugene
</env>
Tytuły mają znaczenie. „## Instrukcje” mówią modelowi, że punkty poniżej to zasady, a „## Kontekst” wskazuje, że linie poniżej to fakty. Bez nich te dwa poziomy się zlewają, a model może traktować fakty jako polecenia lub odwrotnie. Dzięki tytułom wybór narzędzi jest znacznie lepszy.
Tagi w stylu XML otaczające informacje o środowisku działają w ten sam sposób. Zawijka <env> jest znacznikiem, z którym model spotkał się tysiące razy podczas szkolenia, więc interpretuje to, co znajduje się w środku, jako stan w czasie wykonywania, a nie jako wskazówki. Claude Code CLI przekazuje swój aktualny katalog, stan git, system operacyjny oraz identyfikator modelu do własnego promptu właśnie w ten sposób, i to konwencja wartą przejęcia.
Tożsamość daje głos, nie prawdę
To właśnie warstwę tę najczęściej modyfikują ludzie. Początkujący wypełniają ją twierdzeniami, na przykład że są ekspertami od Rusta z dwudziestoletnim doświadczeniem w dużych firmach technologicznych. Powiedzenie modelowi, że jest ekspertem, nie czyni go nim faktycznie. Tożsamość wpływa na sposób mówienia: ostrożny asystent badawczy waha się przed odpowiedzią, szybki i bezpośredni inżynier odpowiada zwięźle, a cierpliwy nauczyciel wyjaśnia.
Określa ona również postawę wobec niepewności. Zdanie nakazujące agencji preferować czytanie źródła zamiast zgadywania jest istotnym czynnikiem wpływającym na zachowanie modelu, który rzeczywiście częściej wywołuje funkcję read_file, gdy takie zdanie jest obecne, ponieważ zmienia to to, jaka odpowiedź będzie bardziej zgodna z charakterem. Traktuj tożsamość jako kierunek działania aktora, a nie jak CV.
Instrukcje to lista
Każda instrukcja powinna być sprawdzalna jako taka sama – może zostać przyjęta lub odrzucona. Instrukcja „Użyj list_files przed odczytaniem” jest sprawdzalna, ponieważ model albo ją najpierw wywołał, albo nie. Instrukcja „Bądź pomocny dla użytkownika” nie może być sprawdzalna, więc należy ją usunąć.
Porządek ma znaczenie, gdy reguły się kolidują, ponieważ model czyta od góry do dołu – wcześniejsze reguły stanowią podstawę, a późniejsze je uzupełniają. Jeśli dwie reguły rzeczywiście się sprzeciwiają, prompt jest błędny i model wybierze losowo. Należy naprawić konflikt w promptzie, a nie w momencie przetwarzania.
Trzy lub cztery reguły nadają się dla małego agenta. Dziesięć to już za dużo. Dwadzieścia stanowi sam w sobie problem, ponieważ model nie jest w stanie je wszystkie zapamiętać i po cichu pomija niektóre. Długa lista zazwyczaj oznacza brak ograniczeń dotyczących wyniku lub faktów kontekstowych, które sprawiłyby, że kilka reguł stałoby się niepotrzebnych.
Ograniczenia wyniku oddzielają format od zachowania
Weźmy prompt, który nakazuje odpowiadać uprzejmie w prostym prozie, podawać odniesienie do pliku w nawiasach i zachować długość poniżej 200 słów. Łączy on trzy aspekty: uprzejmość stanowi element tożsamości, natomiast odniesienia i długość odpowiadają treści wyjściowej. Gdy ktoś prosi o zmianę formatu odniesień, należy edytować tylko jedną linię w odpowiedniej warstwie, bez ponownego czytania całej reszty.
Tutaj również wpisuje się zorganizowana forma wyjścia. Jeśli system poniżej wymaga formatu JSON, warstwa wyjściowa nakazuje odpowiedź w tym formacie zgodnie z dostarczonym schematem. Gdy użytkownik prosi o kroki, warstwa ta nakazuje ich numerowanie. Zachowanie systemu pozostaje niezmienne, podczas gdy format się dostosowuje.
Przykłady pokazują, nie wyjaśniają
Jeden przykład często zastępuje kilka instrukcji:
.example(
"What edition does Cargo.toml use?",
"I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.",
)
To pojedyncze przykład pokazuje format cytatu, wskazuje, że krótka informacja o źródle jest wystarczająca, określa oczekiwany rozmiar tekstu oraz ilustruje związek pomiędzy wywołaniem narzędzia a cytatem. Nic z tego nie występuje w instrukcjach, ani też nie jest konieczne.
Ryzykiem jest nadprzystosowanie modelu. Jeśli każdy przykład dotyczy pliku Cargo.toml, model zaczyna być nadmiernie skupiony na nim. Należy użyć jednego lub dwóch przykładów obejmujących rzeczywisty zakres danych wejściowych. Jeśli nie można stworzyć reprezentatywnego przykładu, prawdopodobnie coś innego w instrukcjach jest niewystarczająco określone.
Kontekst jest budowany na nowo przy każdej prośbie
Na początku wszyscy zapominają o tym aspekcie. Model ma określony termin ostatniej aktualizacji wiedzy i nie zna dzisiejszej daty, nazwy projektu ani listy istniejących plików. Będzie zgadywał, a jego przypuszczenia brzmią wiarygodnie, mimo że są błędne. „Prawdopodobnie połowa 2024 roku” to nieprzydatna data.
Rozwiązanie jest proste: należy za każdym razem przekazywać niezbędne fakty.
let today = OffsetDateTime::now_utc()
.date()
.format(&Iso8601::DATE)
.unwrap_or_else(|_| "unknown".into());
let sandbox = sandbox_root()?.display().to_string();
let prompt = system_prompt(&today, &sandbox);
Dwa fakty, około pół kilobajta tokenów – dzięki temu agent wie już, kiedy i gdzie się znajduje. Koszt jest znikomy, a dokładność w pytaniach zależnych od daty lub ścieżki znacznie się poprawia.
Szczegóły dotyczące rzeczywistego agenta szybko się gromadzą: nazwy użytkowników i projektów, aktywne umiejętności, strona, którą jest przeglądana, ostatnie komunikaty o błędach, najnowszy hash commitu. Nic z tego nie stanowi opinii ani instrukcji – to dane przypisane do jednej prośby i dostarczane przez kod.
Granica pamięci cache
To właśnie odróżnia agenta hobby od takiego, którego obsługa jest dostępna cenowo. API Anthropic oferuje funkcję cacheowania promptów: należy przekazać pole system jako tablicę bloków tekstowych zamiast pojedynczej ciągu znaków, oznaczyć blok tagiem cache_control: {"type": "ephemeral"}, a wszystko, co znajduje się w tym bloku, jest cacheowane na pięć minut. Każda późniejsza prośba z identycznym prefiksem trafia do cache’u, a koszt tych zasobów z cache’u wynosi około jednej dziesiątej standardowej ceny.
Dla agenta działającego w cyklu sześciu kroków z osobowością składającą się z tysiąca tokenów oznacza to około pięciokrotny spadek kosztów wejścia po pierwszym kroku, a także niższą opóźnioność. Oszczędności rosną wraz z tym, jak stabilny jest prompt – osobowość niemal zawsze jest stabilna, natomiast data dzisiejsza nie.
let blocks = prompt.into_system_blocks();
// blocks[0] = { type: "text", text: <static prefix>, cache_control: {type: "ephemeral"} }
// blocks[1] = { type: "text", text: <dynamic suffix> } // no cache_control
Budowniczy dzieli się naturalnie. Tożsamość, instrukcje, ograniczenia wyników oraz przykłady są statyczne, kontekst natomiast dynamiczny – każda połowa renderowana jest oddzielnie z własnymi nagłówkami. Tylko pierwsza zawiera znacznik cache, więc druga jest ponownie tokenizowana przy każdej rundzie, podczas gdy persona pozostaje w pamięci cache.
Dzięki tej logice arytmetycznej kod źródłowy Claude Code zawiera dosłowny znacznik SYSTEM_PROMPT_DYNAMIC_BOUNDARY, na którym opiera się jego logika cache. Wszystko, co zmienia się pomiędzy rundami – takie jak połączenia MCP w trakcie sesji, modyfikacje modelu czy preferencje językowe – znajduje się poniżej tej linii. Statyczna persona powyżej może być przechowywana w cache dla różnych użytkowników, o ile to możliwe w ramach procesu budowania.
Dwie zasady zapewniają utrzymanie cache w aktywnym stanie. Przedrostek musi być identyczny pod względem bajtów pomiędzy różnymi żądaniami, więc dodatkowy przestrzeń, zmieniona kolejność instrukcji lub inny format daty powodują zmianę hasza, co zmusza do ponownego utworzenia cache. Ponadto przedrostek musi zawierać 1024 tokeny, zanim Anthropic go zapisze w cache. Krótkie profile nie odnoszą żadnych korzyści, natomiast długie – wiele. Jeśli twój profil jest krótki, dodaj treść, która uzasadnia jego obecność, a nie tylko materiał przeznaczony do wypełnienia przestrzeni.
Odpowiedź zawiera potwierdzenie:
[turn 0] in=4 cache_read=0 cache_create=1247 out=89
[turn 1] in=3 cache_read=1247 cache_create=0 out=42
Krok zero tworzy pamięć cache, a każdy kolejny krok z niej czyta dane. Podczas rozwoju wyświetlaj statystyki użycia, ponieważ pamięć cache pozostaje niewidoczna dopóki nie zostanie zmierzona.
Memoizacja dynamicznych sekcji
Granice pamięci cache przechowują tokeny. Mniejsza wersja rozwiązania działająca wewnątrz procesu oszczędza zasoby obliczeniowe. Niektóre dynamiczne konteksty wymagają dużo zasobów do utworzenia, np. skanowanie systemu plików, ładowanie zrzutu pamięci lub wywoływanie zdalnego magazynu ustawień. Powtarzanie tych działań przy każdym kroku marnuje czas i utrudnia analizę śladów.
Memoizuj każdą dynamiczną sekcję pod jej nazwą i przeliczaj ją ponownie tylko w przypadku wyraźnej anulacji, takiej jak nowy żądanie użytkownika, pole /clear lub /compact. Kod źródłowy Claude Code oddziela sekcje, które mogą być zapisywane w pamięci cache (systemPromptSection), od tych niestabilnych (DANGEROUS_uncachedSystemPromptSection); te drugie wymagają argumentu reason, dzięki czemu autor musi uzasadnić usunięcie danych z cache. Tak długa nazwa jest uzasadniona – jedna niestabilna sekcja, która zmienia się przy każdej turze, unieważnia wszystko po niej dla danego żądania.
W agencie Rust odpowiednikiem jest rejestr wpisów Section { name, compute_fn, cache_break } rozwiązanych podczas tworzenia promptu, przy czym każdy wynik jest powiązany z identyfikatorem tury. Na razie biblioteka eugene-prompt pozostaje prostsza i przelicza każdą dynamiczną sekcję przy każdym budowaniu. Jeśli coś takiego jest kosztowne, umieść to w własnej pamięci podręcznej i przekaż wartość, ponieważ zadaniem biblioteki jest układ, a nie memorowanie.
Wersjonowanie i sprawdzanie regresji
Najtrudniejszym błędem w promptach jest ten niezamierzony. Ktoś edytuje zdanie, aby naprawić inne problemy, zachowanie się zmienia i psuje sposób użycia, którego nikt nie obserwował, a żaden test się nie zawodzi, ponieważ żaden test nie istnieje.
Pomaga odcisk palca. Narzędzie do budowania promptów hashuje wyświetlony tekst i udostępnia go dwa razy: prefix_fingerprint dla samego prefiksu, który służy jako klucz pamięci podręcznej, oraz fingerprint dla całego promptu.
const EXPECTED_PROMPT_FINGERPRINT: u64 = 0; // set after first successful run
if EXPECTED_PROMPT_FINGERPRINT != 0
&& prompt.fingerprint() != EXPECTED_PROMPT_FINGERPRINT
{
eprintln!(
"warning: system prompt fingerprint drifted (was {EXPECTED_PROMPT_FINGERPRINT}, \
is {}). Update the constant if the change was intentional.",
prompt.fingerprint()
);
}
W CI porównuje się odcisk palca prefiksu z wartością stałą. Gdy występują różnice, CI wyświetla komunikat ostrzegawczy – wtedy albo akceptuje zmianę (aktualizuje wartość stałą i wyjaśnia zespółowi powód), albo ją odrzuca (wraca do poprzedniej wersji). W obu przypadkach różnice są widoczne. Ten sam odcisk palca, który chroni przed regresją, pokazuje również dokładnie, kiedy zmienił się prefiks cache – wtedy następna prośba nie może skorzystać z pamięci cache.
Odcisk palca jest konieczny, ale niewystarczający. Drugą połowę stanowi testowanie behawioralne: ustalone dane wejściowe, określone zachowania oczekiwane oraz narzędzie porównujące wynik z oczekiwaniami. Odcisk palca wykrywa 90% najprostszych przypadków odchyleń.
Wynik w Eugene
Pętla pozostaje nienaruszona. Różnica polega na strukturyzowanym zapytaniu oraz sposobie jego wysyłania:
let prompt = system_prompt(&today, &sandbox);
let system_blocks = prompt.into_system_blocks();
let response = send(&http, &api_key, &system_blocks, &tools, &messages).await?;
Dzięki dostępności funkcji list_files i read_file instrukcje oraz przykład uczą agenta, jak je łączyć. Gdy zostanie zapytany o zawartość folderu src, wylicza pliki w tym katalogu, wybiera jeden z nich, odczytuje go i odpowiada z odniesieniem. Gdy zostanie zapytany o wersję określoną w pliku Cargo.toml, agent najpierw mówi, że otworzy ten plik, a następnie podaje wersję (2024) w nawiasach razem z nazwą pliku. Umieszczenie odniesienia jest zadaniem warstwy wyjściowej, sprawdzanie zamiast domyślania się jest zgodne z instrukcjami, a styl odniesienia pochodzi z przykładu. Koszt jest znikomy w porównaniu z tym, co byłoby bez ograniczeń związanych z pamięcią cache.
Wnioсы
Literal hardkodowany jest w porządku, dopóki taki pozostaje. Gdy masz kilka narzędzi, kilka formatów wyjściowych, kilku inżynierów edytujących instrukcje lub jakiekolwiek oczekiwania budżetowe, struktura okazuje się przydatna. Warstwy nie są arbitralne: tożsamość to „kto”, instrukcje to „co”, wynik to „jak”, a kontekst to „teraz”. Jeśli coś nie pasuje do jednej z tych kategorii, wymagania agenta są niejasne, a winą nie jest struktura.
Granica pamięci cache przekształca strukturę w oszczędności. Budownik typowany to forma, którą Rust oferuje za darmo, a „odcisk palca” to tani test regresji. Razem dają one prompt systemowy, który możesz edytować w piątkowe popołudnie bez lęku.
Dwa narzędzia tutaj są nadal funkcjami ad-hoc w instrukcji match, bez wspólnej interfejsu, bez generowania schematów, bez możliwości ponawiania prób oraz bez sposobu na dostarczenie przez strony trzecie narzędzia, które agent mógłby wykorzystać. Naturalnym następnym krokiem jest cecha Skill – rejestry, który zarządza tabelą dyspozycji oraz schemarami służącymi do wyprowadzania JSON Schema z struktury Rust, dzięki czemu agent rozwija się poprzez kompozycję, a nie kumulację.