Accueil / Articles

Prompts de système structurés pour les agents IA Rust : couches, mise en cache, vérifications de dérive.

Créer un prompt de système en couches pour un agent Rust : identité, instructions, règles de sortie, exemples et contexte actualisé, ainsi qu’une limite de cache et une empreinte digitale pour les tests d’intégration.

Éditeur : REACTAPP.TOP12 min de lecture2843 mots

Sources: Building AI Agents in Rust — part 2

Un premier agent se contente généralement d’un prompt qui n’est qu’une chaîne de caractères, peut-être soixante mots : répondre aux questions sur un projet Rust et appeler read_file lorsqu’il faut consulter le contenu d’un fichier. Cela fonctionne parce qu’il n’y a qu’un seul outil, un seul utilisateur et aucune règle concernant l’apparence des réponses. Puis les choses s’accumulent : un deuxième outil oblige le modèle à apprendre quand lister les fichiers et quand les lire, un troisième exige qu’on lui enseigne une séquence d’actions. Une règle de sortie comme « citer le fichier que vous avez lu » nécessite un cadre d’application. Un test de régression vérifiant si le prompt donne toujours la même réponse pour une entrée fixe doit permettre de détecter si quelqu’un a modifié le prompt par accident. Le cache des prompts oblige à tenir compte des parties qui changent à chaque tour et de celles qui ne le font pas. Une simple chaîne de caractères ne gère pas tout cela correctement.

Ce qui suit est une instruction structurée destinée à un agent nommé Eugene qui sait s’en occuper : quatre niveaux, un outil de construction par type, un contexte par requête, un seul exemple de type few-shot, un hash permettant d’identifier les écarts, ainsi qu’une frontière statique/dynamique afin que le cache d’instructions d’Anthropic ne doive générer la personnalité qu’une seule fois plutôt que à chaque tour. La boucle d’agent reste identique ; seule la message système change de forme.

Pourquoi une structure est utile

Une instruction est un code, car elle guide le comportement, et chaque phrase qu’elle contient lutte pour attirer l’attention du modèle. Un bloc long et non différencié est partiellement ignoré, tandis qu’un ensemble court de sections étiquetées est lu avec attention.

Cela peut sembler superstitieux, mais il s’agit d’un comportement observé. Claude et ses homologues sont entraînés avec des instructions sous des formes familières : des prompts système structurés en sections, des messages de l’utilisateur contenant une demande simple, et des réponses de l’assistant qui répondent directement à cette demande. En reproduisant ces formes, le modèle est moins incertain quant au sens de chaque élément, ce qui réduit les hallucinations, améliore la précision des réponses et favorise un meilleur choix d’outils.

Ces quatre couches ne constituent pas la seule structure possible. Ce sont celles les plus simples qui survivent aux modifications subies par un agent réel. Une anatomie presque identique apparaît dans les prompts système que Claude Code CLI crée pour lui-même, et ce n’est pas un hasard : les agents capables de fonctionner avec des utilisateurs réels ont tendance à adopter la même structure.

Les couches

Identité est un paragraphe indiquant qui parle, ce qui définit le ton, l’expertise et la position. Exemple : Eugene est un assistant de recherche méticuleux travaillant sur un projet Rust qui préfère lire le code source plutôt que de deviner. Aucune règle ni aucun fait ne doit y figurer.

Instructions sont des règles de comportement, chacune étant une affirmation testable : utilisez list_files pour savoir ce qu’il y a dans le projet avant de lire, ou ne réessayez pas une requête qui a retourné une erreur. Elles s’accumulent au fur et à mesure que l’agent évolue. Conservez-les sous forme de liste, car les points sont plus faciles à ajouter ou à supprimer que des paragraphes.

Contraintes de sortie décrivent la forme de la réponse et non son contenu : prose simple sans formatage Markdown, une citation entre parenthèses du fichier consulté, des réponses de moins de 200 mots. Elles s’appliquent quel que soit la question posée par l’utilisateur. Les séparer des instructions vous permet de changer le format sans affecter le fonctionnement, et inversement.

Exemples montrent comment le personnage agit. Un exemple à quelques exemples suffit pour accomplir en une ligne ce pour quoi trois instructions sont nécessaires, car il présente une bonne sortie, y compris le style de citation. Les exemples font partie de l’identité de l’agent, c’est pourquoi ils se trouvent dans le préfixe statique à côté de l’identité et des instructions.

Context contient tout ce qui varie à chaque exécution : la date, le paysage linguistique de l’utilisateur, la racine du projet, les compétences proposées en ce moment. Comme il change, il ne peut pas être stocké dans une chaîne de caractères statique. Il dispose de sa propre couche en dehors des limites du cache, qui est reconstruite à chaque requête.

Un constructeur typé en Rust

Au sein de Rust, cette structure devient un constructeur où chaque méthode représente une couche. Il identifie quelles couches sont statiques et lesquelles sont dynamiques, puis affiche la frontière entre elles.

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

Le compilateur ne peut pas vérifier grand-chose ici, mais la lisibilité s’en trouve considérablement améliorée. Vous pouvez parcourir la chaîne pour savoir ce que l’agent verra. Les cinq premières méthodes forment le préfixe statique et la dernière crée le suffixe dynamique. Le préfixe généré ressemble à ceci :

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

Le suffixe est plus court et entièrement dynamique :

## Context

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

Les en-têtes ont une signification. "## Instructions" indique au modèle que les points suivants sont des règles, tandis que "## Contexte" signale que les lignes suivantes sont des faits. Sans eux, ces deux niveaux se mélangent, et le modèle peut interpréter les faits comme des commandes ou l’inverse. Avec des en-têtes, le choix des outils devient nettement plus clair.

Les balises de style XML entourant les informations sur l’environnement fonctionnent de la même manière. L’encadrement <env> est un marqueur que le modèle a rencontré des milliers de fois pendant son entraînement ; il interprète donc ce qui se trouve à l’intérieur comme un état en temps réel et non comme des directives. Claude Code CLI transmet ainsi son répertoire actuel, l’état de git, le système d’exploitation et l’ID du modèle dans sa propre prompt, et c’est une convention qui vaut la peine d’être adoptée.

L’identité donne une voix, pas la vérité

C’est cette couche que les gens modifient le plus souvent. Les débutants la remplissent de prétentions, comme être un ingénieur Rust expert ayant travaillé pendant vingt ans dans de grandes entreprises technologiques. Dire à un modèle qu’il est expert ne fait pas de lui un expert pour autant. L’identité influence le ton : un assistant de recherche prudent hésite avant de parler, un ingénieur rapide et direct répond de manière concise, tandis qu’un enseignant patient explique patiemment.

Elle définit également l’attitude face à l’incertitude. Une instruction demandant à l’agent de privilégier la lecture des sources plutôt que de deviner constitue un véritable levier ; le modèle appelle effectivement read_file plus souvent lorsque cette phrase est présente, car cela influence le type de réponse qui semble correspondre au personnage. Considérez l’identité comme une orientation pour un interprète, et non comme un CV.

Les instructions forment une liste

Chaque instruction doit réussir ou échouer indépendamment. L’instruction « Utilisez list_files avant de lire » peut être vérifiée, car le modèle l’a soit appelée en premier, soit non. L’instruction « Soyez utile à l’utilisateur » ne peut pas être vérifiée, il faut donc l’omettre.

L’ordre est important lorsque les règles entrent en conflit, car le modèle lit de haut en bas, les règles précédentes servant de référence et les suivantes de précision. Si deux règles entrent réellement en contradiction, la demande est incorrecte et le modèle choisira au hasard. Corrigez le conflit dans la demande, et non au moment de l’inférence.

Trois ou quatre règles conviennent à un petit agent. Dix, c’est déjà trop. Vingt, c’est une échec en soi, car le modèle ne peut pas en tenir compte toutes et en omettra discrètement certaines. Une longue liste indique généralement l’absence d’une contrainte de sortie ou d’un fait contextuel qui rendrait plusieurs règles inutiles.

Les contraintes de sortie séparent le format du comportement

Prenez un prompt qui demande de répondre poliment en prose simple, de citer le fichier entre parenthèses et de rester sous 200 mots. Il combine trois exigences : la politesse fait partie de l’identité du message, tandis que la citation et la longueur relèvent de la forme de la réponse. Lorsqu’on demande de changer le format de citation, il suffit d’éditer une seule ligne sans avoir à relire tout le reste.

C’est également ici que s’applique la sortie structurée. Si un système ultérieur exige du JSON, la couche de sortie indique de répondre en JSON conforme au schéma fourni. Si un utilisateur demande des étapes, cette même couche indique de numéroter chacune d’elles. Le comportement reste inchangé, tandis que le format s’adapte.

Les exemples montrent, ils n’expliquent pas

Un seul exemple peut souvent remplacer plusieurs instructions :

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

Cet exemple unique illustre le format de citation, montre qu’une brève mention suffit, définit la longueur attendue et illustre le lien entre une invocation d’outil et une citation. Rien de tout cela n’apparaît dans les instructions, et rien n’en a besoin.

Le risque est le surajustement. Si tous les exemples concernent Cargo.toml, le modèle devient étrangement focalisé sur ce fichier. Utilisez un ou deux exemples couvrant toute la gamme réelle des entrées. Si vous ne pouvez pas en écrire un représentatif, quelque chose d’autre dans la demande est probablement insuffisamment spécifié.

Le contexte est reconstruit à chaque demande

Tout le monde oublie ce aspect au début. Le modèle a une date de fin de connaissance et ne connaît ni la date d’aujourd’hui, ni le nom de votre projet, ni quels fichiers existent. Il va deviner, et ces suppositions semblent plausibles tout en étant fausses. « Probablement au milieu de 2024 » n’est pas une date utile.

La solution est simple : transmettez les faits à chaque fois.

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

Deux faits : environ un demi-kilooctet de tokens, et l’agent sait désormais quand et où il se trouve. Le coût est négligeable et la précision pour les questions sensibles à la date ou au chemin s’améliore considérablement.

La couche de contexte d’un véritable agent se développe rapidement : noms d’utilisateurs et de projets, compétences actives, page consultée, messages d’erreur récents, hash du dernier commit. Rien de tout cela n’est une opinion ou une instruction ; il s’agit de données liées à une seule requête et fournies par du code.

Les limites du cache

C’est ce qui distingue un agent de loisir d’un agent dont le fonctionnement est abordable. L’API d’Anthropic propose un mécanisme de mise en cache des prompts : transmettez le champ system sous forme d’un tableau de blocs de texte plutôt que d’une seule chaîne, marquez un bloc avec cache_control: {"type": "ephemeral"}, et tout ce qui se trouve dans ce bloc sera mis en cache pendant cinq minutes. Toute demande ultérieure utilisant le même préfixe accèdera à la cache, et les tokens d’entrée stockés coûteront environ un dixième du prix habituel.

Pour un agent fonctionnant en boucle de six tours avec une personnalité représentée par mille tokens, cela représente une réduction d’environ cinq fois des coûts d’entrée à partir du deuxième tour, en plus d’une latence moindre. Les économies augmentent en fonction du degré de stabilité du prompt. La personnalité est presque toujours stable, tandis que la date d’aujourd’hui ne l’est pas.

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

Le constructeur se divise naturellement. L’identité, les instructions, les contraintes de sortie et les exemples sont statiques, le contexte est dynamique, et chaque moitié s’affiche séparément avec ses propres en-têtes. Seule la première contient le marqueur de cache, de sorte que la seconde est re-tokenisée à chaque tour tandis que le personnage reste en cache.

C’est cette logique mathématique qui explique pourquoi le code source de Claude Code comporte un marqueur littéral SYSTEM_PROMPT_DYNAMIC_BOUNDARY sur lequel repose sa logique de cache. Tout ce qui change entre les tours, comme les connexions MCP au cours de la session, les modifications du modèle ou les préférences linguistiques, se trouve en dessous de cette ligne. Le personnage statique situé au-dessus peut être mis en cache pour plusieurs utilisateurs, dès lors que la configuration le permet.

Deux règles permettent de maintenir le cache actif. Le préfixe doit être identique en termes de bytes entre les différentes requêtes ; ainsi, un espace supplémentaire, une instruction réordonnée ou un format de date différent modifient le hash, ce qui oblige à recréer le cache. De plus, le préfixe doit contenir 1024 tokens avant qu’Anthropic ne le mette en cache. Les personas courtes n’y gagnent rien, tandis que celles de grande taille en tirent beaucoup d’avantages. Si la vôtre est courte, ajoutez du contenu qui justifie sa place et non des éléments superflus.

La réponse inclut un reçu :

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

La première itération crée le cache, et chaque itération suivante y lit des données. Affichez les statistiques d’utilisation pendant le développement, car le cache reste invisible tant qu’il n’a pas été mesuré.

Mémorisation des sections dynamiques

Les limites du cache permettent de conserver des tokens. Une version plus petite du contenu, générée en mémoire, permet d’économiser des ressources de calcul. Certaines opérations contextuelles dynamiques sont coûteuses à exécuter, comme la consultation du système de fichiers, le chargement d’un instantané mémoire ou l’appel à un stockage de paramètres distant. Répéter ces opérations à chaque itération gaspille du temps et encombre les traces d’exécution.

Mémorisez chaque section dynamique par son nom et ne la recalculez qu’en cas de invalidation explicite, comme une nouvelle demande d’utilisateur, une requête /clear ou /compact. Le code source de Claude Code sépare les sections pouvant être mises en cache (systemPromptSection) de celles qui sont volatiles (DANGEROUS_uncachedSystemPromptSection), et ces dernières exigent un argument reason afin que l’auteur doive justifier la rupture du cache. Ce nom évocateur est justifié : une seule section volatile qui change à chaque tour invalide tout ce qui suit pour cette demande.

Dans un agent Rust, l’équivalent est un registre des entrées Section { name, compute_fn, cache_break } résolues lors de la construction du prompt, chaque résultat étant associé à un identifiant de tour. Pour l’instant, le crate eugene-prompt reste plus simple et recalcule chaque section dynamique à chaque construction. Si l’un de ces calculs est coûteux, enveloppez-le dans votre propre cache et transmettez la valeur correspondante, car la fonction principale du crate est le formatage et non la mémorisation.

Gestion des versions et vérifications de régression

Le bug de prompt le plus difficile à détecter est celui qui survient de manière inattendue. Quelqu’un modifie une phrase pour résoudre un autre problème, le comportement change et endommage un cas d’usage que personne n’observait, sans pour autant provoquer l’échec d’un test, car aucun test n’existe.

Une empreinte digitale peut aider. Le générateur hachage le texte affiché et le expose deux fois : prefix_fingerprint pour le préfixe uniquement, qui sert de clé au cache, et fingerprint pour l’ensemble du prompt.

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

Dans CI, on compare l’empreinte du préfixe à une constante. Lorsqu’elles diffèrent, CI émet un avertissement ; il vous faut alors soit accepter le changement (mettre à jour la constante et expliquer pourquoi à l’équipe), soit le rejeter (rétablir la version précédente). Dans tous les cas, cela reste visible. La même empreinte qui permet de détecter les régressions indique également exactement quand le préfixe du cache a changé, c’est-à-dire au moment où votre prochaine requête ne trouve pas de données dans le cache.

Une empreinte est nécessaire, mais insuffisante. L’autre élément essentiel est le test comportemental : des entrées fixes, des comportements attendus fixes, ainsi qu’un outil d’évaluation qui compare les résultats obtenus aux attentes. L’empreinte permet de détecter 90 % des écarts mineurs.

Résultat dans Eugene

La boucle reste inchangée. Ce qui diffère, c’est la requête structurée et la manière dont elle est transmise :

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

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

Avec les fonctions list_files et read_file disponibles, les instructions ainsi que l’exemple apprennent à l’agent à les combiner. Lorsqu’on lui demande ce qu’il y a dans le dossier src, il liste ce dernier, choisit un fichier probable, le lit et répond en citant une source. Interrogé au sujet de l’édition indiquée dans Cargo.toml, l’agent déclare d’abord qu’il va ouvrir le manifeste, puis indique l’édition (2024) accompagnée du nom du fichier entre parenthèses. La disposition de la citation relève de la couche de sortie, tandis que le fait de vérifier plutôt que de deviner relève des instructions ; enfin, le style de citation est déterminé par l’exemple. Le coût est inférieur à ce qu’il serait sans la limite imposée par le cache.

Points clés

Un texte littéral codé en dur est acceptable tant que tout se passe bien. Lorsqu’on dispose de plusieurs outils, de plusieurs formats de sortie, de plusieurs ingénieurs qui modifient les instructions ou que des attentes budgétaires existent, une structure bien organisée s’avère très utile. Ces différentes couches ne sont pas arbitraires : l’identité correspond à « qui », les instructions à « quoi », la sortie à « comment », et le contexte au « maintenant ». Si quelque chose ne correspond pas à l’une d’elles, c’est que les exigences de l’agent sont floues, et ce n’est pas la faute de la structure.

Les limites du cache transforment cette structure en un avantage économique. Le constructeur de texte est la solution offerte gratuitement par Rust, tandis que l’empreinte digitale permet des tests de régression peu coûteux. Ensemble, ils vous fournissent un prompt système que vous pouvez modifier un vendredi après-midi sans crainte.

Ces deux outils restent des fonctions ad hoc au sein d’une instruction match, sans interface commune, sans génération de schéma, sans possibilité de tentative répétée, et sans moyen pour un tiers de fournir un outil que l’agent puisse utiliser. La prochaine étape logique est l’introduction d’un trait Skill, un registre qui gère le tableau de routage ainsi que des schemars permettant d’obtenir un JSON Schema à partir d’une structure Rust, afin que l’agent puisse s’enrichir par composition plutôt que par accumulation.