Startseite / Artikel

Strukturierte Systemprompts für Rust-KI-Agenten: Schichten, Caching, Abweichungsprüfungen

Erstelle einen schichtbasierten Systemprompt für einen Rust-Agenten: Identität, Anweisungen, Ausgaberegeln, Beispiele und frischer Kontext, zusätzlich eine Cache-Grenze sowie ein Fingerabdruck für CI.

Verlag: REACTAPP.TOP12 Min. Lesezeit2843 Wörter

Quellen: Building AI Agents in Rust — part 2

Ein erster Agent kommt in der Regel mit einem Systemprompt aus, der aus einem einzigen Zeichenstring besteht – vielleicht sechzig Wörter lang: Er soll Fragen zu einem Rust-Projekt beantworten und read_file aufrufen, wenn er den Inhalt einer Datei ansehen muss. Das funktioniert, weil es genau ein Werkzeug und einen Benutzer gibt sowie keine Regeln darüber geben, wie die Antworten aussehen sollen. Dann häufen sich jedoch die Anforderungen. Ein zweites Werkzeug bedeutet, dass das Modell lernen muss, wann es Dateien auflisten und wann sie lesen soll. Ein drittes Werkzeug erfordert zudem das Erlernen einer bestimmten Reihenfolge. Eine Ausgaberegel wie „Zitieren Sie die gelesene Datei“ benötigt einen Platz, an dem sie umgesetzt werden kann. Ein Regressionstest, der prüft, ob der Prompt bei festem Eingang weiterhin dieselbe Antwort liefert, braucht eine Möglichkeit, festzustellen, ob jemand den Prompt versehentlich geändert hat. Das Caching von Prompts sorgt dafür, dass man beachtet, welche Teile pro Turnus sich ändern und welche nicht. Ein einfacher Zeichenstring kann all das nicht angemessen handhaben.

Was folgt, ist eine strukturierte Anweisung für einen Agenten namens Eugene, der damit umgehen kann: vier Ebenen, ein vorgefertigter Aufbausatz, Kontext pro Anfrage, ein einzelnes Beispiel mit wenigen Beispielen, ein Hash zur Erkennung von Abweichungen sowie eine statische/dynamische Grenze, damit Anthropics Prompt-Cache die Persona nur einmal statt bei jeder Antwort bezahlt. Der Agent-Loop bleibt unverändert – nur die Systemnachricht ändert ihre Form.

Warum Struktur hilft

Eine Anweisung ist im Grunde Code, da sie das Verhalten steuert, und jeder Satz darin konkurriert um die Aufmerksamkeit des Modells. Ein langer, unstrukturierter Textabschnitt wird teilweise ignoriert, während eine kurze Anzahl klar gekennzeichneter Abschnitte sorgfältig gelesen wird.

Es klingt nach Aberglauben, aber es handelt sich um beobachtetes Verhalten. Claude und seine Pendants werden mit Anweisungen in vertrauten Formen trainiert: in Abschnitte unterteilte Systemanfragen, Nutzeranfragen, die eine einfache Bitte enthalten, sowie Assistentenantworten, die eine einfache Antwort liefern. Wenn man diese Formen beibehält, ist das Modell weniger unsicher darüber, was jeder Bestandteil bedeutet, es entstehen weniger Halluzinationen, die Ausgaben sind präziser und die Auswahl der Tools verbessert sich.

Die vier unten aufgeführten Schichten sind nicht die einzige gültige Struktur. Es handelt sich dabei um die kleinste Struktur, die den Veränderungen standhält, denen ein echter Agent ausgesetzt ist. Eine nahezu identische Struktur findet sich auch in den Systemanfragen, die Claude Code CLI für sich selbst erstellt – und das ist kein Zufall: Agenten, die echten Nutzern standhalten, neigen dazu, zur gleichen Struktur zu gelangen.

Die Schichten

Identität ist ein Absatz darüber, wer spricht – er bestimmt Tonfall, Fachkompetenz und Haltung. Beispiel: Eugene ist ein sorgfältiger Forschungsassistent für ein Rust-Projekt, der lieber den Quellcode liest, anstatt zu raten. Darin gehören weder Regeln noch Fakten.

Anweisungen sind Verhaltensregeln, wobei jede einzelne eine überprüfbare Aussage darstellt: Verwenden Sie list_files, um herauszufinden, was im Projekt enthalten ist, bevor Sie es lesen, oder versuchen Sie nicht erneut einen Aufruf, der einen Fehler zurückgegeben hat. Im Laufe der Zeit sammeln sie sich an. Halten Sie sie als Liste, da es einfacher ist, Punkte hinzuzufügen und zu entfernen als Absätze.

Ausgabebeschränkungen beschreiben die Form der Antwort und nicht ihren Inhalt: reine Prosa ohne Markdown, eine eckige Zitierung der gelesenen Datei, Antworten unter 200 Wörtern. Sie gelten unabhängig davon, was der Benutzer gefragt hat. Durch Trennung von diesen Beschränkungen und den Anweisungen kann man das Format ändern, ohne das Verhalten zu beeinflussen, und umgekehrt.

Beispiele zeigen die zu verwendende Persönlichkeit in Aktion. Ein Beispielsatz erledigt in einer Zeile das, wofür eine Anweisung drei Zeilen benötigen würde, da es eine gute Ausgabe inklusive Zitierstil zeigt. Beispiele sind Teil der Identität des Agenten, weshalb sie neben dieser sowie den Anweisungen im statischen Präfix stehen.

Context enthält alles, was von Ausführung zu Ausführung variiert: das Datum, die Lokalität des Benutzers, die Wurzel des Projekts sowie die derzeit angebotenen Fähigkeiten. Da sich dies ändert, kann es nicht in einem statischen String gespeichert werden. Es erhält eine eigene Schicht unterhalb der Cache-Grenze, die bei jeder Anfrage neu erstellt wird.

Ein typisierter Builder in Rust

In Rust wird die Struktur zu einem Builder, wobei jede Methode für eine Schicht steht. Er erfasst, welche Schichten statisch und welche dynamisch sind, und stellt die Grenze zwischen ihnen dar.

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

Der Compiler kann hier nicht viel überprüfen, doch die Lesbarkeit verbessert sich erheblich. Man kann die Kette durchsehen und wissen, was der Agent sehen wird. Die ersten fünf Methoden bilden das statische Präfix, während die letzte Methode das dynamische Suffix erstellt. Das renderierte Präfix sieht so aus:

## 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) ...

Der Suffix ist kürzer und vollständig dynamisch:

## Context

<env>
today: 2026-05-22
project_root: /Users/me/code/eugene
</env>

Die Überschriften tragen eine Bedeutung. „## Anweisungen“ teilt dem Modell mit, dass die darunterstehenden Punkte Regeln sind, und „## Kontext“ besagt, dass die darunterstehenden Zeilen Fakten sind. Ohne sie verschwimmen diese beiden Ebenen, und das Modell könnte Fakten als Befehle oder umgekehrt interpretieren. Mit Überschriften ist die Auswahl der Werkzeuge deutlich besser sichtbar.

Die XML-ähnlichen Tags um die Umgebungsinformationen funktionieren auf dieselbe Weise. Der <env>-Wrapper ist ein Marker, den das Modell während des Trainings bereits tausendfach gesehen hat; daher interpretiert es den Inhalt als Laufzeitzustand und nicht als Anleitung. Claude Code CLI übermittelt auf diese Weise seinen aktuellen Verzeichnispfad, den Git-Zustand, das Betriebssystem sowie die Modell-ID in seine eigene Prompt-Struktur – und es handelt sich dabei um eine Konvention, die man übernehmen sollte.

Identität verleiht eine Stimme, nicht die Wahrheit

Diese Schicht wird am häufigsten überschrieben. Anfänger füllen sie mit Behauptungen wie „ein erfahrener Rust-Entwickler mit zwanzig Jahren Erfahrung in großen Technologieunternehmen“. Dem Modell zu sagen, es sei ein Experte, macht es noch lange nicht dazu. Die Identität bestimmt den Stil: Ein sorgfältiger Forschungsassistent zögert, bevor er spricht, ein schneller, direkter Ingenieur antwortet knapp und ein geduldiger Lehrer erklärt ausführlich.

Sie legt außerdem die Einstellung gegenüber Unsicherheiten fest. Ein Satz, der dem Agenten vorschreibt, lieber Quellen zu lesen als zu raten, ist eine echte Hebelwirkung – das Modell ruft in solchen Fällen häufiger read_file auf, da dies beeinflusst, welche Antwort zum Charakter passt. Betrachten Sie die Identität als Leitfaden für das Verhalten des Agenten, nicht als Lebenslauf.

Anweisungen sind eine Liste

Jede Anweisung sollte für sich allein bestehen – entweder erfolgreich oder fehlgeschlagen. „Verwende list_files vor dem Lesen“ lässt sich überprüfen, da das Modell entweder zuerst darauf zugreift oder nicht. „Sei dem Benutzer hilfreich“ lässt sich hingegen nicht überprüfen, daher sollte es weggelassen werden.

Die Reihenfolge ist wichtig, wenn Regeln miteinander kollidieren, denn das Modell liest von oben nach unten, wobei frühere Regeln als Grundlage dienen und spätere Regeln diese präzisieren. Wenn zwei Regeln tatsächlich im Widerspruch zueinander stehen, ist die Anfrage fehlerhaft und das Modell wählt zufällig. Beheben Sie den Widerspruch in der Anfrage, nicht während der Auswertung.

Drei bis vier Regeln eignen sich für einen kleinen Agenten. Zehn sind bereits zu viel. Zwanzig sind an sich schon ein Versagen, da das Modell sie nicht alle behalten kann und einige stillschweigend weglässt. Eine lange Liste deutet in der Regel auf ein fehlendes Ausgabe- oder Kontextmerkmal hin, das mehrere Regeln überflüssig machen würde.

Ausgabebeschränkungen trennen Format von Verhalten

Nehmen Sie einen Prompt, der vorschreibt, höflich in einfachem Prosa-Stil zu antworten, die Quelle in Klammern anzugeben und sich auf 200 Wörter zu beschränken. Er vereint drei Aspekte: Höflichkeit gehört zur Identität des Anspruchs, die Zitierung sowie die Länge gehören zum Ausgabeinhalt. Wenn jemand um eine Änderung des Zitierformats bittet, sollten Sie nur eine Zeile in einer Schicht bearbeiten, ohne den Rest erneut durchzulesen.

Hier wird auch die strukturierte Ausgabe integriert. Wenn ein nachgeschaltetes System JSON verlangt, weist die Ausgabeschicht an, im Einklang mit dem bereitgestellten Schema in JSON zu antworten. Wenn ein Benutzer nach Schritten fragt, soll jede Anweisung nummeriert werden. Das Verhalten bleibt unverändert, während sich das Format anpasst.

Beispiele zeigen, sie erklären nicht

Ein Beispiel ersetzt oft mehrere Anweisungen:

.example(
    "What edition does Cargo.toml use?",
    "I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.",
)

Dieses einzige Beispiel veranschaulicht das Zitierformat, zeigt, dass eine kurze Erwähnung ausreicht, legt die erwartete Länge fest und zeigt das Verhältnis zwischen einem Toolaufruf und einer Zitation. Nichts davon steht in den Anweisungen, und es ist auch nicht notwendig.

Das Risiko besteht im Überanpassen. Wenn jedes Beispiel Cargo.toml beinhaltet, entwickelt das Modell eine ungewöhnliche Fixierung darauf. Verwenden Sie ein oder zwei Beispiele, die den tatsächlichen Bereich der Eingaben abdecken. Wenn Sie kein repräsentatives Beispiel schreiben können, ist wahrscheinlich etwas anderes in der Anfrage ungenügend spezifiziert.

Der Kontext wird bei jeder Anfrage neu erstellt

Jeder vergisst zunächst diese Ebene. Das Modell hat einen Wissensstopp und kennt weder das heutige Datum noch den Namen Ihres Projekts oder welche Dateien vorhanden sind. Es wird raten, und die Raten klingen plausibel, obwohl sie falsch sind. „Wahrscheinlich Mitte 2024“ ist kein nützliches Datum.

Die Lösung ist einfach: Geben Sie jedes Mal die relevanten Fakten mit an.

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

Zwei Fakten, etwa eine halbe Kilobyte an Tokens – und der Agent weiß nun, wann und wo er sich befindet. Die Kosten sind vernachlässigbar, und die Genauigkeit bei daten- oder pathabhängigen Fragen verbessert sich erheblich.

Die Kontextschicht eines echten Agents wächst schnell: Benutzer- und Projektnamen, aktive Fähigkeiten, die angezeigte Seite, kürzliche Fehlermeldungen sowie der neueste Commit-Hash. Nichts davon ist Meinung oder Anweisung – es handelt sich um Daten, die auf eine bestimmte Anfrage beschränkt sind und durch Code bereitgestellt werden.

Die Cache-Grenze

Das ist es, was einen Hobby-Agenten von einem Agenten unterscheidet, der kostengünstig betrieben werden kann. Anthropics API bietet Prompt-Caching: Übergeben Sie das system-Feld als Array von Textblöcken anstelle einer einzigen Zeichenkette, kennzeichnen Sie einen Block mit cache_control: {"type": "ephemeral"}, und alles, was durch diesen Block geht, wird fünf Minuten lang im Cache gespeichert. Jeder spätere Anfragen, der denselben Präfix verwendet, greift auf den Cache zu, und die in diesem Cache gespeicherten Eingabetoken werden zu etwa einem Zehntel des üblichen Preises abgerechnet.

Für einen Agenten, der einen sechsfachen Schleifenprozess mit einer Persönlichkeit aus tausend Tokenen verwendet, bedeutet das eine Reduzierung der Eingabekosten um etwa das Fünffache bei jeder Schleifendrehung nach der ersten, zusätzlich zu geringerer Latenz. Die Einsparungen nehmen zu, je stabiler der Prompt ist. Die Persönlichkeit ist fast immer stabil, während das heutige Datum es nicht ist.

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

Der Builder teilt sich natürlich auf. Identität, Anweisungen, Ausgabebeschränkungen und Beispiele sind statisch, der Kontext ist dynamisch, und jede Hälfte wird mit eigenen Überschriften eigenständig gerendert. Nur die erste Hälfte enthält den Cache-Marker, wodurch die zweite bei jedem Durchlauf neu tokenisiert wird, während die Persona im Cache bleibt.

Diese Logik erklärt, warum der Quellcode von Claude Code einen literalen SYSTEM_PROMPT_DYNAMIC_BOUNDARY-Marker enthält, an dem seine Cache-Logik trennt. Alles, was sich zwischen den Durchläufen ändert – wie MCP-Verbindungen während der Sitzung, Modell-Überwrites oder Sprachvorlieben – befindet sich unter dieser Linie. Die darüber liegende statische Persona kann bei zulässiger Konfiguration über verschiedene Benutzer hinweg im Cache gespeichert werden.

Zwei Regeln sorgen dafür, dass der Cache aktiv bleibt. Das Präfix muss bei verschiedenen Anfragen bytegenau identisch sein – daher ändert bereits ein zusätzlicher Leerzeichen, eine umgeordnete Anweisung oder ein anderes Datumsformat den Hash, und man muss erneut Kosten für die Erstellung des Caches tragen. Zudem muss das Präfix 1024 Token umfassen, bevor Anthropic es speichert. Kurze Personenprofile profitieren nicht, während lange Profile große Vorteile haben. Ist Ihr Profil kurz, fügen Sie Inhalte hinzu, die ihm einen Platz verdienen – und keine Fülltexte.

Die Antwort enthält eine Quittung:

[turn 0] in=4 cache_read=0 cache_create=1247 out=89
[turn 1] in=3 cache_read=1247 cache_create=0 out=42

Die erste Iteration erstellt den Cache, und jede nachfolgende Iteration liest daraus. Drucken Sie während der Entwicklung Nutzungstatistiken, denn der Cache bleibt unsichtbar, solange er nicht gemessen wird.

Speichern von dynamischen Abschnitten

Die Cache-Grenze speichert Token. Eine kleinere, im Prozess verarbeitete Version der Idee spart Rechenleistung. Einige dynamische Kontexte sind aufwendig zu erstellen, wie zum Beispiel das Scannen des Dateisystems, das Laden eines Speichersnappshots oder das Aufrufen eines entfernten Einstellungsstores. Dies immer wieder zu tun, verschwendet Zeit und überlädt die Protokolle.

Jede dynamische Section wird nach ihrem Namen gememorisert und nur bei expliziter Außer Kraft Setzung, wie beispielsweise bei einer neuen Benutzeranfrage, einem /clear oder einem /compact, neu berechnet. In Claude Code werden die für das Caching geeigneten Sections (systemPromptSection) von den instabilen Sections (DANGEROUS_uncachedSystemPromptSection) getrennt, wobei letztere ein reason-Argument erfordern, damit der Autor die Aufhebung des Caches begründen muss. Der auffällige Name ist berechtigt: Eine instabile Section, die bei jeder Antwort ändert sich, macht alles danach für diese Anfrage ungültig.

In einem Rust-Agenten entspricht dies einem Register von Section { name, compute_fn, cache_break }-Einträgen, die beim Erstellen der Anfrage gelöst werden, wobei jedes Ergebnis an eine Turn-ID gebunden ist. Derzeit bleibt das eugene-prompt-Kreuz einfach und berechnet jede dynamische Section bei jedem Erstellen neu. Falls dies aufwändig ist, sollte man es in einen eigenen Cache packen und den Wert mit übergeben, da die Aufgabe des Kreuzes das Layout und nicht die Memoisierung ist.

Versionierung und Regressionstests

Der schwierigste Fehler in einer Anfrage ist der unbeabsichtigte. Jemand ändert einen Satz, um ein anderes Problem zu beheben, das Verhalten ändert sich und stört einen Einsatzfall, den niemand beobachtet hat – dabei versagt kein Test, weil es keinen solchen Test gibt.

Eine Fingerabdruck-Funktion hilft dabei. Der Ersteller hashiert den gerenderten Text und stellt ihn zweimal zur Verfügung: prefix_fingerprint für den Präfix allein, der als Cache-Schlüssel dient, sowie fingerprint für die gesamte Anfrage.

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

In CI wird der Präfix-Fingerabdruck mit einer Konstante verglichen. Wenn sich diese unterscheiden, meldet CI einen Fehler – man kann die Änderung entweder akzeptieren (die Konstante aktualisieren und dem Team den Grund mitteilen) oder ablehnen (zurücksetzen). In jedem Fall ist das sichtbar. Derselbe Fingerabdruck, der Rückfälle verhindert, zeigt außerdem genau an, wann sich das Cache-Präfix geändert hat – also zu dem Zeitpunkt, an dem die nächste Anfrage das Cache verfehlt.

Ein Fingerabdruck ist notwendig, aber nicht ausreichend. Der andere Teil besteht aus Verhaltenstests: feste Eingaben, festgelegte erwartete Verhaltensweisen sowie ein Evaluator, der die Ausgabe mit den Erwartungen vergleicht. Der Fingerabdruck erfasst die 90 Prozent der trivialen Abweichungen.

Das Ergebnis in Eugene

Der Loop bleibt unverändert. Was sich unterscheidet, ist die strukturierte Anfrage sowie die Art und Weise, wie sie übertragen wird:

let prompt = system_prompt(&today, &sandbox);
let system_blocks = prompt.into_system_blocks();

let response = send(&http, &api_key, &system_blocks, &tools, &messages).await?;

Durch die Verfügbarkeit von list_files und read_file lehren die Anweisungen sowie das Beispiel den Agenten, diese Funktionen miteinander zu verknüpfen. Wenn gefragt wird, was sich im src-Verzeichnis befindet, listet der Agent die Inhalte des Verzeichnisses auf, wählt ein wahrscheinliches Datei aus, liest sie und antwortet mit einer Quellenangabe. Bei der Frage nach der Edition in Cargo.toml sagt der Agent zunächst, er werde das Manifest öffnen, und gibt anschließend die Edition (2024) zusammen mit dem Dateinamen in Klammern an. Die Platzierung der Quellenangabe obliegt der Ausgabeschicht; stattdessen zu prüfen anstatt zu raten, ist Teil der Anweisungen. Der Quellensatz hingegen wird vom Beispiel vorgegeben. Die Kosten sind nur ein Bruchteil dessen, was ohne die Cachegrenze anfallen würde.

Zusammenfassung

Ein hardcodierter Literal ist in Ordnung – bis er es plötzlich nicht mehr ist. Wenn man mehrere Tools, verschiedene Ausgabeformate sowie mehrere Ingenieure hat, die den Prompt bearbeiten, oder bestimmte Budgeterwartungen berücksichtigen muss, lohnt sich eine strukturierte Vorgehensweise. Die Schichten sind nicht willkürlich: Identität bezeichnet, wer etwas tut; Anweisungen beschreiben, was zu tun ist; die Ausgabe zeigt, wie es umgesetzt wird; und der Kontext gibt an, wann etwas stattfindet. Wenn etwas nicht in eine dieser Kategorien passt, liegen unklare Anforderungen vor – das liegt nicht an der Struktur.

Durch die Cache-Grenzen wird die Struktur zu einer Ersparnis. Der typed builder ist die von Rust kostenlos angebotene Strukturform, und der Fingerabdruck dient als kostengünstige Regressionstest-Methode. Zusammen ermöglichen sie Ihnen einen Systemprompt, den Sie an einem Freitagnachmittag ohne Bedenken bearbeiten können.

Die beiden hier genannten Tools sind weiterhin ad-hoc-Funktionen innerhalb einer match-Anweisung, ohne gemeinsame Schnittstelle, ohne Erstellung von Schemata, ohne Möglichkeit zur Wiederholung sowie ohne Option für Dritte, ein Tool bereitzustellen, das der Agent nutzen kann. Der natürliche nächste Schritt ist ein Skill-Trait – ein Registry-System, das die Dispatch-Tabelle sowie schemars bereitstellt, um aus einer Rust-Struktur ein JSON Schema abzuleiten, sodass sich der Agent durch Komposition statt durch schrittweises Hinzufügen weiterentwickeln kann.