Leçon 5 — Contexte et reprise

Le budget de contexte

7 min de lecture

Le budget de contexte

Sur le run réel de la démo, le dernier tour envoie 5 643 tokens au modèle. La session entière en facture pourtant 60 661 en entrée : chaque tour renvoie tout l'historique. Doublez la longueur de la tâche et vous multipliez le coût par environ quatre, sans compter qu'au-delà d'un certain volume la qualité baisse avant même la limite de la fenêtre : le modèle retrouve moins bien une information noyée au milieu de longues sorties d'outils. Le harness doit donc décider ce que le modèle garde sous les yeux.

Le mécanisme

Trois leviers, du moins coûteux au plus délicat.

  1. Troncature des sorties d'outils. Une sortie de plus de TOOL_OUTPUT_MAX_CHARS (4 000) garde ses 40 % de début et ses 60 % de fin, avec un marqueur « [… N caractères omis …] ». La fin pèse plus parce que pytest, un compilateur ou un linter y écrivent le verdict.
  2. Plafond de session. SESSION_TOKEN_CAP (400 000) arrête la session : c'est le garde-fou de coût, pas un outil de qualité.
  3. Compaction. Quand le dernier prompt dépasse COMPACT_RATIO × CONTEXT_BUDGET (0,7 × 24 000), les anciens tours sont remplacés par un résumé produit par le modèle. On garde intacts la tâche et les KEEP_RECENT_TURNS derniers tours.
flowchart LR
  subgraph "Avant (19 messages)"
    T1["tâche"] --> A1["tours 1 à 6 : lectures, tests"] --> R1["tours 7 à 9 : éditions"]
  end
  subgraph "Après (7 messages)"
    T2["tâche + résumé des 12 premiers messages"] --> R2["tours 7 à 9 intacts"]
  end
  A1 -. "résumé (1 200 → 435 tokens)" .-> T2

La coupe a une contrainte dure : un function_call et sa function_response ne doivent jamais être séparés, sinon l'API rejette l'historique. On coupe donc toujours juste avant un message du modèle.

La mise en œuvre

files/harness/context.py
def compact(history, keep_recent_turns, summarize):
    starts = [i for i, c in enumerate(history) if i > 0 and c.role == "model"]
    if len(starts) <= keep_recent_turns:
        return history, 0
    split = starts[-keep_recent_turns]          # juste avant un message du modèle
    old = history[1:split]
    summary = summarize(render(old))            # transcription texte, sorties limitées à 600 caractères
    head = types.Content(role="user", parts=[types.Part(text=(
        f"{task_text}\n\n[Résumé des {len(old)} premiers messages, compactés par le harness. "
        f"Les messages qui suivent sont conservés tels quels et priment sur ce résumé.]\n{summary}"))])
    return [head, *history[split:]], len(old)

Le prompt de résumé impose des rubriques (objectif, fichiers lus, modifications faites, derniers tests, hypothèses écartées, reste à faire) : un résumé libre perd précisément les détails dont l'agent a besoin, comme les chemins de fichiers.

Résultat obtenu

Run réel du 2026-10-15 avec CONTEXT_BUDGET=5000 et KEEP_RECENT_TURNS=3, pour forcer la compaction sur une tâche courte :

tour  prompt  sortie  outil
   8    3476     526  edit_file
   9    4108     174  edit_file      4108 > 0,7 × 5000 : compaction de 12 messages
  10    2479      33  run_command    (pytest -v)
  11    2884      50  run_command    (git diff, refusé)
  12    2969      84  run_command    (pytest -q : 6 passed)
  13    3094     299  —              compte rendu final
statut terminé · 13 tours · 34 418 tokens en entrée · 2 086 en sortie · 0,0336 $

Le résumé produit par le modèle (extrait) :

**Fichiers lus et contenu utile :**
- `facturation/remises.py` : `taux_remise()` utilise une comparaison stricte `quantite > seuil` au lieu de `>=`.
- `facturation/montants.py` : `arrondir()` utilise `quantize(CENTIME)` sans spécifier le mode d'arrondi commercial.
**Modifications déjà faites :** Aucune.
**Reste à faire :** 1. Corriger `facturation/remises.py` (`quantite >= seuil`). 2. Corriger `facturation/montants.py` …

Ce résumé contient un piège réel. « Modifications déjà faites : aucune » est exact pour les 12 messages résumés, mais faux pour la session : les deux éditions sont dans les tours conservés. Ici l'agent a lu les tours récents et n'a rien refait. Sur une tâche plus longue, il aurait pu réappliquer une édition, ou se croire en retard. La version publiée du harness ajoute donc la phrase « les messages qui suivent […] priment sur ce résumé » en tête du résumé. Ce run a été fait avant ce correctif.

Ce qui casse en production

  • Résumé périmé ou contradictoire. Le cas ci-dessus. Parade : dire au modèle ce que couvre le résumé, et faire tenir l'état à jour par le harness plutôt que par le résumé (liste des fichiers modifiés tirée des points de reprise, dernier verdict de test).
  • Détail perdu à la compaction. Après la compaction, l'agent cite la règle métier en paraphrasant (« dès 50 unités ») au lieu du texte exact du README. Sans gravité ici ; sur un message d'erreur ou un identifiant, c'est une nouvelle lecture de fichier, donc des tokens. Parade : garder les valeurs exactes dans des notes structurées, comme le recommande Anthropic dans son article sur l'ingénierie de contexte.
  • Troncature du mauvais côté. Une troncature qui garde seulement le début coupe le résumé de pytest ; l'agent relance les tests en boucle pour « voir le résultat ». Parade : garder la fin, ou extraire la ligne de verdict.
  • Cache de préfixe invalidé. Le cache implicite de Gemini facture moins cher un préfixe d'historique déjà vu. La compaction réécrit le début de l'historique : le tour suivant repart sans cache. Compactez rarement et franchement plutôt que souvent et un peu.

La règle de décision

Tronquez toujours, dès le premier jour : c'est gratuit. Compactez seulement quand vos tâches dépassent régulièrement la moitié du budget fixé, et commencez haut (70 % d'un budget large) : chaque compaction coûte un appel et un risque de perte. Pour les tâches très longues, préférez découper en sous-tâches avec un contexte neuf chacune. À surveiller : prompt du dernier tour, nombre de compactions par session, et relectures du même fichier après compaction.

Pratique

Votre agent relance pytest quatre fois de suite après chaque modification. Le journal montre que la sortie de pytest fait 18 000 caractères et que TOOL_OUTPUT_MAX_CHARS vaut 4 000. Quelle est la cause la plus probable ?

Après une compaction, l'agent réapplique une édition déjà faite et edit_file échoue avec « old introuvable ». Quelle correction est la plus robuste ?

Exercice. Ajoutez au message de tête après compaction une section « État tenu par le harness » : liste des fichiers modifiés depuis le début de la session et dernière ligne de verdict pytest.

Voir la solution

Le harness a déjà les deux informations. Les fichiers modifiés : git diff --name-only <premier point de reprise> via Checkpoints, ou la liste des path des edit_file réussis tenue dans RunReport. Le verdict : à chaque run_command, extraire la dernière ligne qui correspond à r"\d+ (passed|failed)" et la stocker dans report.last_verdict.

state = (f"\n\n[État tenu par le harness]\nFichiers modifiés : {', '.join(sorted(report.edited)) or 'aucun'}"
         f"\nDernier verdict des tests : {report.last_verdict or 'aucun test lancé'}")

Passez ce texte à compact() (nouveau paramètre state: str = "") et ajoutez-le après le résumé. Test hors ligne : scénario avec deux éditions puis plusieurs recherches, budget minuscule ; vérifiez que le premier message vu par le modèle après compaction contient les deux chemins.

  • Je sais expliquer pourquoi le coût d'entrée croît plus vite que le nombre de tours
  • Je tronque les sorties d'outils en gardant la fin
  • Je compacte sans séparer un appel d'outil de sa réponse
  • Je sais repérer un résumé qui contredit les tours conservés
Suivant