> Niveau 7 de Agent Harness Patterns, un parcours de patterns sur le fonctionnement des agents d'IA. Version web : https://harnesspatterns.dev/fr/patterns/compaction · Tous les patterns (en anglais) : https://harnesspatterns.dev/llms.txt

# Le sac à dos déborde

Chaque tour renvoie tout l'historique au modèle. Faites durer un chat assez longtemps, et il ne tient plus. La compaction réduit les parties les plus anciennes avant que cela n'arrive.

## Le problème

Un modèle ne peut lire qu'une certaine quantité à la fois. Cette limite, c'est sa **fenêtre de contexte**, comptée en tokens (des morceaux de mots, d'environ quatre caractères chacun) : 8 000 pour beaucoup de petits modèles locaux, quelques centaines de milliers pour les gros modèles hébergés. Tout ce qu'il y a dans une requête doit y tenir : le system prompt, chaque message jusqu'ici, chaque résultat d'outil, et de la place pour la réponse.

Le modèle ne retient rien d'un appel à l'autre, donc la boucle envoie tout l'historique à chaque tour. Un chat d'assistance qui consulte une commande et cherche dans un catalogue accumule des milliers de tokens de résultats que le modèle a déjà utilisés. Chaque tour est plus lent et coûte plus cher que le précédent. Et un jour, la requête ne tient plus.

C'est Gulp, le débordement de contexte. Le fournisseur rejette alors la requête avec une erreur ou, pire, certains serveurs coupent discrètement la partie la plus ancienne pour qu'elle tienne. Or c'est dans la partie la plus ancienne que le client a dit de quelle commande il parlait.

## La solution

Avant chaque appel au modèle, la boucle vérifie la taille de la requête. Au-delà d'une ligne, placée sous la vraie limite pour que la réponse ait encore de la place, elle réduit l'historique. Il y a trois façons courantes de le faire :

- **Tronquer les vieux résultats d'outils**
   Remplacer un gros résultat que le modèle a déjà utilisé par une note d'une ligne et un court aperçu.
   Presque gratuit, et les résultats d'outils sont en général la partie la plus lourde de l'historique. Si le modèle a de nouveau besoin des détails, il rappelle l'outil.
- **Supprimer les vieux échanges**
   Retirer les demandes les plus anciennes avec tout ce qui les a suivies, jusqu'à la demande suivante.
   Gratuit aussi, mais le modèle oublie complètement cette partie de la conversation. Gardez la toute première demande, qui dit souvent de quoi parle tout le chat.
- **Résumer avec le modèle**
   Envoyer une fois la vieille partie au modèle, et mettre son résumé à la place de ces messages.
   Cela garde le sens, mais coûte un appel de plus, et un résumé peut laisser de côté, sans prévenir, le seul chiffre qui comptait.

Quelle que soit celle que vous choisissez, les mêmes règles s'appliquent : commencez par les messages les plus anciens, ne touchez pas aux dernières demandes, et arrêtez-vous dès que ça tient. astorlm fait les deux premières, dans cet ordre. Il tronque d'abord les vieux résultats d'outils, et ne supprime des messages que si cela n'a pas suffi.

## Les personnages

Les mêmes personnages que d'habitude, cette fois dans un puits de blocs qui tombent.

- **Le puits** (fenêtre de contexte): Tout ce qu'une requête peut transporter. Si la pile atteint le haut, la requête ne tient pas.
- **Les blocs** (messages): Un par message, à la taille de ses tokens. Le sol, c'est le system prompt : il part avec chaque requête et n'est jamais compacté.
- **La ligne rouge** (seuil): 80 % de la fenêtre. Au-delà, la boucle compacte avant d'appeler le modèle.
- **Le marteau** (le compacteur): Il réduit le plus ancien résultat d'outil à une note d'une ligne, et tout ce qui est au-dessus se tasse.
- **KEEP** (keepRecentTurns): Les deux dernières demandes et tout ce qui les suit. Le marteau n'y touche jamais.
- **SENT** (la facture): Les tokens envoyés jusqu'ici, sur tous les appels. Regardez de combien elle grimpe par tour avant et après le marteau.

Dans le panneau EventBus, la ligne `compact` signale l'optimiseur en action. astorlm n'émet pas d'événement pour cela ; il écrit « Context optimized » dans votre logger. Remarquez où elle tombe : après l'entrée de la demande dans l'historique, avant `turn_start`.

## Le code

**Avec astorlm :** La compaction est activée par défaut, dimensionnée d'après le fournisseur. Passez `contextOptimizer` pour fixer la vraie fenêtre, la ligne et le nombre de demandes récentes à garder.

**À partir de zéro :** La boucle du niveau 2, avec un historique qui survit d'une demande à l'autre et un appel à `compact()` avant chaque appel au modèle. Le niveau 1 tronque les vieux résultats d'outils, le niveau 2 supprime de vieux échanges entiers.

**Avec astorlm**

```ts
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const getOrder = tool({
  name: 'get_order',
  description: 'Everything about one order: items, shipping, invoice.',
  schema: z.object({ order: z.number().int() }),
  execute: async ({ order }) => loadOrder(order), // your code
})

const searchParts = tool({
  name: 'search_parts',
  description: 'Search the parts catalog, with stock and price for each match.',
  schema: z.object({ query: z.string() }),
  execute: async ({ query }) => searchCatalog(query), // your code
})

const createReturn = tool({
  name: 'create_return',
  description: 'Open a return for an order and ship a replacement part.',
  schema: z.object({ order: z.number().int(), part: z.string() }),
  execute: async ({ order, part }) => openReturn(order, part), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  tools: [getOrder, searchParts, createReturn],
  maxTurns: 10,
  // Compaction is on by default, sized from the provider. OpenAIProvider assumes a
  // 128,000-token window, so on a small local model, say how big it really is.
  contextOptimizer: {
    maxTokens: 8000,
    compressThreshold: 0.8, // compact once the request passes 80% of the window
    keepRecentTurns: 2, // never touch the last two requests, or anything after them
  },
  // There's no event for compaction: astorlm logs "Context optimized…" when it happens.
  logger: console,
})

// One agent, one history: every run() adds to it, and the optimizer checks it before each model call.
await agent.run('Hi! My order #4471 came with a bent front wheel. Can you help?')
await agent.run('Is that same wheel in stock?')
const last = await agent.run('Great. Open a return for my order and ship me the new wheel.')
console.log(last.content)
```

**TypeScript**

```ts
// Compaction, from scratch. Plain fetch, no SDK.

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
  baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
  model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
  apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}

type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = { get_order: getOrder, search_parts: searchParts, create_return: createReturn }
const toolSchemas = [/* one JSON Schema per tool */]

const SYSTEM = 'You are the support assistant of a bike shop.'
const WINDOW = { maxTokens: 8000, threshold: 0.8, keepRecentTurns: 2 }

// A rough count, about 4 characters per token. Good enough to decide when to compact.
function estimateTokens(messages: Message[]): number {
  const chars = messages.reduce((sum, m) => sum + JSON.stringify(m).length, SYSTEM.length)
  return Math.ceil(chars / 4)
}

// Where the protected part starts: the Nth user request from the end.
// With fewer requests than that, everything is recent and nothing can go.
function keepFrom(messages: Message[], keep: number): number {
  let seen = 0
  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i]!.role === 'user' && ++seen === keep) return i
  }
  return 0
}

export function compact(messages: Message[]): Message[] {
  const limit = WINDOW.maxTokens * WINDOW.threshold
  if (estimateTokens(messages) <= limit) return messages
  const out = structuredClone(messages)

  // Level 1: shrink old tool results to a one-line note, oldest first. Stop as soon as it fits.
  for (let i = 0; i < keepFrom(out, WINDOW.keepRecentTurns); i++) {
    const m = out[i]!
    if (m.role !== 'tool' || m.content.startsWith('[Truncated')) continue
    m.content = `[Truncated to save context: ${m.content.length} chars. Preview: ${m.content.slice(0, 150)}…]`
    if (estimateTokens(out) <= limit) return out
  }

  // Level 2: drop the oldest exchanges whole, from one request up to the next.
  // Keep the very first request, and never cut between a tool call and its result.
  while (estimateTokens(out) > limit) {
    const next = out.findIndex((m, i) => i > 1 && m.role === 'user')
    if (next === -1 || next > keepFrom(out, WINDOW.keepRecentTurns)) break
    out.splice(1, next - 1)
  }
  return out
}

// The history lives across requests: that's what fills up.
const messages: Message[] = []

export async function ask(prompt: string, maxTurns = 10): Promise<string> {
  messages.push({ role: 'user', content: prompt })

  for (let turn = 1; turn <= maxTurns; turn++) {
    // Before every model call: does it still fit? The compacted history replaces the old one.
    messages.splice(0, messages.length, ...compact(messages))

    const res = await fetch(`${LLM.baseURL}/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
      body: JSON.stringify({ model: LLM.model, messages: [{ role: 'system', content: SYSTEM }, ...messages], tools: toolSchemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''

    for (const call of reply.tool_calls ?? []) {
      const run = tools[call.function.name]
      let output = `Unknown tool: ${call.function.name}`
      try {
        if (run) output = await run(JSON.parse(call.function.arguments))
      } catch (err) {
        output = `Error: ${err instanceof Error ? err.message : err}`
      }
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// The chat from the animation: three requests, one growing history.
await ask('Hi! My order #4471 came with a bent front wheel. Can you help?')
await ask('Is that same wheel in stock?')
console.log(await ask('Great. Open a return for my order and ship me the new wheel.'))
```

**Python**

```python
# Compaction, from scratch. Standard library only, no SDK.
import copy
import json
import math
import urllib.request

# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
    "base_url": "http://localhost:11434/v1",  # e.g. Ollama's default address
    "model": "your-model",  # e.g. "llama3.1", "gpt-4o-mini"
    "api_key": "YOUR_API_KEY",  # local servers usually ignore it
}

TOOLS = {"get_order": get_order, "search_parts": search_parts, "create_return": create_return}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool

SYSTEM = "You are the support assistant of a bike shop."
WINDOW = {"max_tokens": 8000, "threshold": 0.8, "keep_recent_turns": 2}

def estimate_tokens(messages):
    """A rough count, about 4 characters per token. Good enough to decide when to compact."""
    chars = len(SYSTEM) + sum(len(json.dumps(m)) for m in messages)
    return math.ceil(chars / 4)

def keep_from(messages, keep):
    """Where the protected part starts: the Nth user request from the end (0 if there are fewer)."""
    seen = 0
    for i in range(len(messages) - 1, -1, -1):
        if messages[i]["role"] == "user":
            seen += 1
            if seen == keep:
                return i
    return 0

def compact(messages):
    limit = WINDOW["max_tokens"] * WINDOW["threshold"]
    if estimate_tokens(messages) <= limit:
        return messages
    out = copy.deepcopy(messages)

    # Level 1: shrink old tool results to a one-line note, oldest first. Stop as soon as it fits.
    for i in range(keep_from(out, WINDOW["keep_recent_turns"])):
        m = out[i]
        if m["role"] != "tool" or m["content"].startswith("[Truncated"):
            continue
        m["content"] = f"[Truncated to save context: {len(m['content'])} chars. Preview: {m['content'][:150]}...]"
        if estimate_tokens(out) <= limit:
            return out

    # Level 2: drop the oldest exchanges whole, from one request up to the next.
    # Keep the very first request, and never cut between a tool call and its result.
    while estimate_tokens(out) > limit:
        following = [i for i, m in enumerate(out) if i > 1 and m["role"] == "user"]
        if not following or following[0] > keep_from(out, WINDOW["keep_recent_turns"]):
            break
        del out[1 : following[0]]
    return out

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({
            "model": LLM["model"],
            "messages": [{"role": "system", "content": SYSTEM}, *messages],
            "tools": TOOL_SCHEMAS,
        }).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)["choices"][0]

# The history lives across requests: that's what fills up.
messages = []

def ask(prompt, max_turns=10):
    messages.append({"role": "user", "content": prompt})

    for turn in range(1, max_turns + 1):
        # Before every model call: does it still fit? The compacted history replaces the old one.
        messages[:] = compact(messages)

        choice = chat(messages)
        reply = choice["message"]
        messages.append(reply)
        if choice["finish_reason"] != "tool_calls":
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            run = TOOLS.get(name)
            try:
                output = run(**json.loads(call["function"]["arguments"])) if run else f"Unknown tool: {name}"
            except Exception as err:
                output = f"Error: {err}"
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

    raise RuntimeError(f"No answer after {max_turns} turns")

# The chat from the animation: three requests, one growing history.
ask("Hi! My order #4471 came with a bent front wheel. Can you help?")
ask("Is that same wheel in stock?")
print(ask("Great. Open a return for my order and ship me the new wheel."))
```

## Points de vigilance

- **Indiquez la vraie fenêtre.** L'OpenAIProvider d'astorlm suppose 128 000 tokens. Avec un modèle local de 8 000 tokens, l'optimiseur attendrait une ligne que le modèle n'atteint jamais.
- **Ne séparez jamais un appel d'outil de son résultat.** Un résultat d'outil dont l'appel a été supprimé fait rejeter toute la requête par la plupart des API. Supprimez des échanges entiers, d'une demande à la suivante.
- **Tronquer n'est sûr que si l'outil peut être relancé.** Si un résultat ne peut pas être obtenu deux fois, comme un reçu de paiement, gardez la partie importante dans la réponse, ou enregistrez-la hors de l'historique.
- **Dites au résumeur ce qui doit survivre.** Numéros de commande, références de pièces, décisions. Un résumé qui se lit bien peut quand même perdre le seul fait dont le tour suivant a besoin.
- **La compaction ne compte que ce que vous envoyez.** Estimer quatre caractères par token suffit pour décider quand agir. Laissez assez de place sous la ligne pour la réponse.

## Patterns liés

- [2 · La boucle de l'agent](https://harnesspatterns.dev/fr/patterns/agent-loop.md)
- [6 · Les hooks](https://harnesspatterns.dev/fr/patterns/hooks.md)
- [8 · Des skills à la demande](https://harnesspatterns.dev/fr/patterns/skills.md)
- [9 · La mémoire](https://harnesspatterns.dev/fr/patterns/memory.md)
- [15 · Les sous-agents](https://harnesspatterns.dev/fr/patterns/subagents.md)
