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

# Cache de prompts

À chaque tour, la boucle renvoie toute la requête au modèle. Presque tout est identique à la fois précédente, et le fournisseur peut s'en souvenir, tant que vous ne changez pas la façon dont elle commence.

## Le problème

Un modèle ne se souvient de rien entre deux appels, alors la boucle renvoie tout à chaque tour : le system prompt, la liste des outils et tout l'historique. Dans un agent qui fait dix tours, les longues instructions du début voyagent dix fois, et se paient dix fois.

Et elles ralentissent tout : avant d'écrire un seul mot, le modèle doit relire toute la requête, depuis le premier token.

## La solution

Les fournisseurs gardent une mémoire de courte durée des requêtes qu'ils viennent de lire. Quand une nouvelle requête commence exactement comme une requête récente, ils réutilisent la partie qui correspond et ne traitent que le reste. Cette partie est facturée une fraction du prix (souvent un dixième, selon le fournisseur et le modèle), et la réponse démarre plus tôt.

La règle tient dans le mot *commence* : la correspondance se compte depuis le premier token et s'arrête au premier qui diffère. Un agent s'y prête naturellement, car chaque requête est la précédente plus quelques messages à la fin. Il suffit de ne pas la casser :

- **Quelque chose qui change, tout en haut**
   l'heure, un id de requête, le nom de l'utilisateur
   Gardez le system prompt fixe. Ce qui change va dans le dernier message, à la fin.
- **Des outils qui bougent**
   un autre ordre, un outil ajouté en cours de route
   Construisez la liste d'outils une fois, dans un ordre fixe, et envoyez la même à chaque tour.
- **Réécrire le passé**
   modifier, couper ou résumer d'anciens messages
   N'ajoutez qu'à la fin. Quand il faut réécrire, tout ce qui suit ce point est repayé.
- **Changer de modèle**
   un modèle moins cher pour un tour
   Chaque modèle a son propre cache. Un changement le fait repartir de zéro.

Certains fournisseurs mettent en cache d'eux-mêmes dès qu'une requête est assez longue : OpenAI le fait à partir de 1 024 tokens. D'autres, comme Anthropic, ne le font que là où vous le marquez. Dans tous les cas, l'usage qu'ils renvoient indique combien de tokens d'entrée viennent du cache : vous pouvez vérifier.

## Les personnages

Les mêmes personnages que d'habitude, un soir de jeux.

- **Le Simon** (la requête): Chaque manche répète toute la séquence et y ajoute quelque chose à la fin, comme chaque tour renvoie la requête.
- **Les lumières** (ses blocs): Jaunes pour le system prompt et les outils, puis une par message, aux couleurs de l'historique.
- **Astor** (la boucle): Joue toute la séquence à l'Oracle à chaque tour, puis exécute les outils comme d'habitude.
- **L'Oracle** (le modèle): Sa bulle est le cache du fournisseur : les lumières qu'il connaît déjà, à partir de la première.
- **Le tableau** (l'usage): Tokens d'entrée envoyés, lus depuis le cache et payés, ceux du cache à un dixième.
- **Le salon** (les outils): L'étagère de jeux et le téléphone : `game_shelf` et `order_pizza`.

## Le code

**Avec astorlm :** l'agent construit son system prompt une fois et le renvoie tel quel à chaque tour, avec les mêmes outils dans le même ordre, et l'historique ne grandit qu'à la fin. Votre part : garder ce qui change hors de `systemPrompt`. Chaque `turn_end` porte `cacheReadTokens`, que `OpenAIProvider` lit dans la réponse.

**À partir de zéro :** la boucle du niveau 2, avec son début fixe construit une fois hors de la boucle, et une ligne qui journalise `cached_tokens` depuis l'usage de chaque réponse.

**Avec astorlm**

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

const gameShelf = tool({
  name: 'game_shelf',
  description: 'List the board games on the living-room shelf.',
  schema: z.object({}),
  execute: async () => home.shelf(), // your code
})

const orderPizza = tool({
  name: 'order_pizza',
  description: 'Order pizza for delivery. Returns how long it will take.',
  schema: z.object({ size: z.enum(['medium', 'large']), count: z.number().int().min(1) }),
  execute: async ({ size, count }) => pizzeria.order(size, count), // 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
  }),
  // The start of every request. astorlm builds it once and resends it unchanged every turn.
  // Nothing that changes goes here: no clock, no request id, no user name.
  systemPrompt: HOUSE_RULES, // a long, fixed text: the more of it, the more the cache saves
  tools: [gameShelf, orderPizza], // same tools, same order, every turn
  maxTurns: 10,
})

// OpenAI caches long prompts on its own (from 1,024 tokens), and says how much it reused.
agent.on('event', (event) => {
  if (event.type !== 'turn_end' || !event.usage) return
  const { inputTokens, cacheReadTokens = 0 } = event.usage
  console.log(`turn ${event.turn}: ${cacheReadTokens} of ${inputTokens} input tokens from cache`)
})

// Need the time? Put it at the end, in the message, where it only changes what comes after it.
const now = new Date().toLocaleTimeString()
await agent.run(`Game night for four: see what games we have, and order pizza. (It is ${now}.)`)
```

**TypeScript**

```ts
// Prompt caching from scratch. Plain fetch, no SDK.
// There is nothing to build: the provider caches. Your job is to not break it.

// 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: 'system' | 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

const tools: Record<string, (args: Record<string, string>) => Promise<string>> = { game_shelf: gameShelf, order_pizza: orderPizza }

// 1. The fixed start, built once: same text and same tools, in the same order, every request.
const SYSTEM: Message = { role: 'system', content: HOUSE_RULES } // ✗ never `It is ${new Date()}` up here
const TOOL_SCHEMAS = Object.freeze([/* one JSON Schema per tool, always in this order */])

export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
  // 2. The history only grows at the end. Editing an old message breaks the cache from there on.
  const messages: Message[] = [SYSTEM, { role: 'user', content: prompt }]

  for (let turn = 1; turn <= maxTurns; turn++) {
    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, tools: TOOL_SCHEMAS }),
    })
    const { choices, usage } = await res.json()

    // 3. Check it's working: how much of the input the provider read from its cache.
    const cached = usage?.prompt_tokens_details?.cached_tokens ?? 0
    console.log(`turn ${turn}: ${cached} of ${usage?.prompt_tokens} input tokens from cache`)

    const [choice] = choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls' || reply.role !== 'assistant') return reply.content ?? ''

    for (const call of reply.tool_calls ?? []) {
      const output = await tools[call.function.name]!(JSON.parse(call.function.arguments || '{}'))
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}
```

**Python**

```python
# Prompt caching from scratch. Standard library only, no SDK.
# There is nothing to build: the provider caches. Your job is to not break it.
import json
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 = {"game_shelf": game_shelf, "order_pizza": order_pizza}

# 1. The fixed start, built once: same text and same tools, in the same order, every request.
SYSTEM = {"role": "system", "content": HOUSE_RULES}  # never f"It is {datetime.now()}" up here
TOOL_SCHEMAS = (...)  # one JSON Schema per tool, always in this order

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({"model": LLM["model"], "messages": messages, "tools": list(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)

def run_agent(prompt, max_turns=10):
    # 2. The history only grows at the end. Editing an old message breaks the cache from there on.
    messages = [SYSTEM, {"role": "user", "content": prompt}]

    for turn in range(1, max_turns + 1):
        data = chat(messages)

        # 3. Check it's working: how much of the input the provider read from its cache.
        usage = data.get("usage") or {}
        cached = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
        print(f"turn {turn}: {cached} of {usage.get('prompt_tokens')} input tokens from cache")

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

        for call in reply.get("tool_calls", []):
            output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"] or "{}"))
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

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

## Points de vigilance

- **Le cache ne dure pas.** Il vit quelques minutes sans usage. Un agent qui attend une personne pendant une heure, ou un heartbeat qui bat toutes les deux heures, repaie sa première requête en entier.
- **La compaction et le cache tirent dans des sens opposés.** Réduire les anciens messages, le niveau suivant, réécrit le passé : chaque token après le premier changement est payé en entier au tour suivant. Compactez rarement, et par grandes étapes.
- **Les prompts courts ne sont pas mis en cache.** Sous le minimum du fournisseur, il n'y a rien à économiser. Le cache rapporte avec de longs system prompts, beaucoup d'outils ou de longs historiques : exactement ce qu'ont les agents.
- **Mesurez-le.** Si `cacheReadTokens` reste à zéro dès le deuxième tour, quelque chose change au début. Comparez deux requêtes côte à côte et trouvez la première différence.
- **Il n'économise que sur l'entrée.** Les tokens que le modèle écrit coûtent autant. Dans les agents, l'entrée fait généralement l'essentiel de la facture : ça reste la plus grosse économie possible.

## Patterns liés

- [0 · Votre boîte à outils](https://harnesspatterns.dev/fr/patterns/your-toolkit.md)
- [2 · La boucle de l'agent](https://harnesspatterns.dev/fr/patterns/agent-loop.md)
- [9 · Le sac à dos déborde](https://harnesspatterns.dev/fr/patterns/compaction.md)
- [15 · Observabilité et évaluations](https://harnesspatterns.dev/fr/patterns/observability.md)
