La facture que personne n'avait budgétée
Votre assistant RAG fonctionne. Il récupère les bons documents, les réponses sont pertinentes, les utilisateurs sont satisfaits. Puis arrive le premier mois complet de trafic réel, et la facture de l'API est quatre fois supérieure à ce que le prototype laissait présager.
Rien n'est cassé. Vous payez simplement le tarif plein, à chaque requête, pour renvoyer le même prompt système de 40 000 tokens, les mêmes définitions d'outils et le même historique de conversation que le modèle a traités il y a trente secondes.
La mise en cache des prompts règle exactement ce problème. Ce n'est ni une réécriture, ni un nouveau SDK, ni un modèle différent — c'est un champ que vous ajoutez à un bloc de contenu. Mais il est tout aussi facile de l'ajouter d'une manière qui ne produit strictement aucun effet, et c'est pourquoi la plupart des équipes qui ont « activé le cache » paient toujours le tarif plein sans le savoir.
Ce tutoriel présente le mécanisme honnêtement : comment fonctionne la correspondance de préfixe, pourquoi un simple horodatage peut tout désactiver silencieusement, où placer réellement les points de rupture, et comment prouver les économies avec des chiffres plutôt qu'avec de l'espoir.
À la fin, vous disposerez de :
- Un client Claude en TypeScript avec une mise en cache correctement appliquée
- Une route API Next.js servant un large contexte documentaire avec cache
- Un utilitaire d'instrumentation qui rapporte les économies réelles de tokens par requête
- Une liste d'audit des invalidateurs qui cassent le cache en silence
- Une mise en cache de conversations multi-tours dont le rendement croît avec la discussion
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Les bases de TypeScript — interfaces, async/await, unions discriminées
- Une clé API Anthropic (définie comme
ANTHROPIC_API_KEY) ou un profil actif viaant auth login - Une familiarité avec l'appel d'une API de modèle de langage — ceci n'est pas un guide d'introduction aux LLM
- Optionnel : une application Next.js 15+ pour suivre la section route API
Tout ici utilise le paquet officiel @anthropic-ai/sdk. Aucun wrapper, pas de LangChain, aucune couche d'abstraction intermédiaire.
Ce que vous allez construire
Un point de terminaison de questions-réponses documentaires qui charge une fois un large corpus de référence dans le prompt système, puis répond à un nombre illimité de questions utilisateur en payant environ un dixième du prix d'entrée pour la portion partagée.
En chemin, nous construirons un petit utilitaire logCacheUsage qui affiche, requête par requête, exactement combien de tokens ont été écrits en cache, lus depuis le cache, et traités au tarif plein — parce qu'un cache que vous ne pouvez pas mesurer est un cache auquel vous ne pouvez pas vous fier.
Étape 1 : La règle unique dont tout découle
Lisez cette section deux fois. Chaque bug étrange de mise en cache y remonte.
La mise en cache des prompts est une correspondance de préfixe. Tout changement, où qu'il soit dans le préfixe, invalide tout ce qui suit.
La clé du cache est dérivée des octets exacts du prompt rendu jusqu'à chaque point de rupture. Un seul octet différent en position N invalide le cache pour tous les points de rupture situés en position N ou après.
L'API rend votre requête dans un ordre fixe :
tools → system → messages
Cet ordre est tout l'enjeu. Les outils sont rendus en premier, donc modifier une définition d'outil invalide tout. Le prompt système est rendu en deuxième, donc un horodatage interpolé dans le prompt système invalide l'intégralité de l'historique de conversation qui suit.
Cela vous donne un principe de conception unique pour le reste du tutoriel :
Le contenu stable d'abord. Le contenu volatile en dernier.
Si un fragment de votre prompt change à chaque requête, il doit se situer après votre dernier point de rupture. S'il se situe avant, rien de ce qui suit ne sera jamais mis en cache, quel que soit le nombre de marqueurs cache_control que vous disperserez.
Étape 2 : Mise en place du projet
Créez le projet et installez le SDK.
mkdir claude-caching-demo && cd claude-caching-demo
npm init -y
npm install @anthropic-ai/sdk
npm install -D typescript tsx @types/node
npx tsc --initDéfinissez vos identifiants. Ne codez jamais la clé en dur.
export ANTHROPIC_API_KEY="sk-ant-..."Créez src/client.ts :
import Anthropic from "@anthropic-ai/sdk";
// Le constructeur sans argument résout les identifiants depuis l'environnement :
// ANTHROPIC_API_KEY, puis ANTHROPIC_AUTH_TOKEN, puis un profil `ant auth login`.
export const client = new Anthropic();
export const MODEL = "claude-opus-5";Une remarque sur le choix du modèle, plus importante qu'elle n'en a l'air : le préfixe minimum pouvant être mis en cache varie selon le modèle, et il n'est pas monotone d'une génération à l'autre.
| Modèle | Préfixe minimum en cache |
|---|---|
| Claude Opus 5 | 512 tokens |
| Claude Opus 4.8, Claude Sonnet 5, Sonnet 4.6 | 1024 tokens |
| Claude Opus 4.7 | 2048 tokens |
| Claude Opus 4.6, Haiku 4.5 | 4096 tokens |
Un prompt de 3 000 tokens est mis en cache sur Claude Opus 5 et ne l'est pas, silencieusement, sur Haiku 4.5. Aucune erreur, aucun avertissement — vous obtenez simplement cache_creation_input_tokens: 0 et une facture d'apparence normale. Si vous changez de modèle et que votre taux de succès tombe à zéro, consultez ce tableau avant de déboguer quoi que ce soit d'autre.
Étape 3 : Une requête de référence sans cache
Commençons par la version naïve pour disposer d'un point de comparaison. Créez src/baseline.ts :
import { client, MODEL } from "./client";
import { readFileSync } from "fs";
// Imaginez qu'il s'agisse de votre documentation produit, de votre corpus
// juridique, d'un résumé de code — tout ce qui est volumineux et stable.
const REFERENCE_DOC = readFileSync("./data/handbook.md", "utf-8");
const SYSTEM_PROMPT = `You are a support assistant for Noqta.
Answer strictly from the reference document below.
If the answer is not in the document, say so plainly.
<reference_document>
${REFERENCE_DOC}
</reference_document>`;
async function ask(question: string) {
const response = await client.messages.create({
model: MODEL,
max_tokens: 16000,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: question }],
});
for (const block of response.content) {
if (block.type === "text") {
console.log(block.text);
}
}
console.log("--- usage ---", response.usage);
}
await ask("What is the refund window for annual plans?");
await ask("Do you support SSO on the team tier?");Exécutez :
npx tsx src/baseline.tsLes deux requêtes rapportent une valeur input_tokens élevée et une activité de cache nulle. Si le manuel fait 40 000 tokens, vous avez payé deux fois 40 000 tokens d'entrée pour répondre à deux questions d'une ligne sans rapport entre elles. Dix mille questions par mois, cela représente 400 millions de tokens d'entrée de pure répétition.
Notez le rétrécissement d'union discriminée dans cette boucle. response.content est de type ContentBlock[], et TypeScript rejettera content[0].text sans une vérification block.type === "text". Ce n'est pas spécifique au cache, mais cela fait trébucher tout le monde lors de sa première requête Claude en TypeScript.
Étape 4 : Ajouter le cache — la méthode simple
La correction correcte la plus rapide est la mise en cache automatique de niveau supérieur. Ajoutez un seul champ.
Créez src/cached-auto.ts :
import { client, MODEL } from "./client";
import { readFileSync } from "fs";
const REFERENCE_DOC = readFileSync("./data/handbook.md", "utf-8");
const SYSTEM_PROMPT = `You are a support assistant for Noqta.
Answer strictly from the reference document below.
If the answer is not in the document, say so plainly.
<reference_document>
${REFERENCE_DOC}
</reference_document>`;
async function ask(question: string) {
const response = await client.messages.create({
model: MODEL,
max_tokens: 16000,
// Place automatiquement le point de rupture sur le dernier bloc cacheable.
cache_control: { type: "ephemeral" },
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: question }],
});
console.log("--- usage ---", response.usage);
return response;
}
await ask("What is the refund window for annual plans?");
await ask("Do you support SSO on the team tier?");Exécutez et observez les deux objets usage :
--- usage --- { input_tokens: 14, cache_creation_input_tokens: 41203, cache_read_input_tokens: 0, output_tokens: 87 }
--- usage --- { input_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 41203, output_tokens: 64 }
Cette seconde ligne est tout l'objectif. La première requête a écrit 41 203 tokens en cache à environ 1,25× le tarif d'entrée normal. La seconde les a relus à environ 0,1×.
Un champ de l'objet usage mérite d'être intériorisé : input_tokens est le reliquat non mis en cache uniquement, et non la taille totale du prompt. Le total réel est :
input_tokens + cache_creation_input_tokens + cache_read_input_tokens
Si vous avez un agent qui a tourné deux heures et que input_tokens rapporte 14, ce n'est ni un bug ni un miracle — le reste venait du cache. Les tableaux de bord de coûts qui n'affichent que input_tokens sous-estimeront discrètement votre consommation réelle.
Étape 5 : Points de rupture manuels, et quand en avoir besoin
La mise en cache automatique place un point de rupture sur le dernier bloc cacheable. C'est correct pour le cas du contexte partagé ci-dessus, et incorrect pour un cas très courant : un préfixe partagé suivi d'un suffixe variable dans le même message.
Prenons un classificateur few-shot qui envoie à chaque requête le même large jeu d'exemples plus une entrée différente :
// FAUX — le point de rupture se situe après l'entrée variable,
// donc chaque requête écrit une nouvelle entrée de cache sans jamais en lire.
const response = await client.messages.create({
model: MODEL,
max_tokens: 1024,
cache_control: { type: "ephemeral" },
messages: [
{
role: "user",
content: [
{ type: "text", text: FEW_SHOT_EXAMPLES },
{ type: "text", text: `Classify this ticket: ${ticketBody}` },
],
},
],
});Chaque requête produit un bloc final différent, donc chaque requête indexe une entrée de cache différente. Vous payez la prime d'écriture de 1,25× indéfiniment sans jamais rien relire. C'est de loin la manière la plus courante de rendre le cache plus coûteux que son absence.
La correction consiste à marquer explicitement la fin de la portion partagée :
// CORRECT — point de rupture sur les exemples partagés, entrée variable après.
const response = await client.messages.create({
model: MODEL,
max_tokens: 1024,
messages: [
{
role: "user",
content: [
{
type: "text",
text: FEW_SHOT_EXAMPLES,
cache_control: { type: "ephemeral" },
},
// Aucun marqueur — ceci diffère à chaque fois et doit venir en dernier.
{ type: "text", text: `Classify this ticket: ${ticketBody}` },
],
},
],
});Les règles du placement manuel :
- Maximum 4 points de rupture par requête
- Valide sur les blocs de texte système, les définitions d'outils et les blocs de contenu de message (
text,image,tool_use,tool_result,document) - Un point de rupture sur le dernier bloc système met en cache les outils et le système ensemble, puisque les outils sont rendus en premier
Étape 6 : Construire un utilitaire d'instrumentation
Deviner le comportement du cache, c'est ainsi que des équipes se retrouvent avec une implémentation qui n'a jamais produit un seul succès. Construisez d'abord la mesure.
Créez src/cache-usage.ts :
import type Anthropic from "@anthropic-ai/sdk";
// Tarifs approximatifs de Claude Opus 5, en USD par million de tokens.
const INPUT_RATE = 5.0;
const CACHE_WRITE_MULTIPLIER = 1.25; // 2.0 pour la durée de vie d'une heure
const CACHE_READ_MULTIPLIER = 0.1;
export function logCacheUsage(label: string, usage: Anthropic.Usage) {
const fresh = usage.input_tokens;
const written = usage.cache_creation_input_tokens ?? 0;
const read = usage.cache_read_input_tokens ?? 0;
const total = fresh + written + read;
const actual =
(fresh +
written * CACHE_WRITE_MULTIPLIER +
read * CACHE_READ_MULTIPLIER) *
(INPUT_RATE / 1_000_000);
const uncached = total * (INPUT_RATE / 1_000_000);
const saved = uncached - actual;
const hitRate = total > 0 ? (read / total) * 100 : 0;
console.log(
[
`[${label}]`,
`total=${total}`,
`fresh=${fresh}`,
`written=${written}`,
`read=${read}`,
`hit=${hitRate.toFixed(1)}%`,
`cost=$${actual.toFixed(5)}`,
`saved=$${saved.toFixed(5)}`,
].join(" "),
);
return { total, fresh, written, read, hitRate, actual, saved };
}Branchez-le sur n'importe quel appel :
import { logCacheUsage } from "./cache-usage";
const response = await client.messages.create({ /* ... */ });
logCacheUsage("doc-qa", response.usage);La règle de diagnostic devient alors concrète : si read reste à 0 sur des requêtes répétées avec un préfixe censé être identique, vous avez un invalidateur silencieux. Ce n'est pas une limitation du cache — c'est un bug dans l'assemblage de votre prompt, et l'étape suivante explique comment le trouver.
Étape 7 : Auditer les invalidateurs silencieux
Voici les motifs qui désactivent discrètement la mise en cache. Recherchez chacun d'eux dans votre code d'assemblage de prompt.
| Motif | Pourquoi cela casse le cache |
|---|---|
Date.now() ou new Date() dans le prompt système | Le préfixe change à chaque requête |
crypto.randomUUID() ou un identifiant de requête placé tôt | Idem — chaque requête est unique au niveau octet |
JSON.stringify(obj) sur un objet dont l'ordre des clés est instable | La sérialisation diffère d'une exécution à l'autre |
| Interpoler un identifiant de session ou d'utilisateur dans le prompt système | Préfixe par utilisateur ; aucun partage entre utilisateurs |
Sections système conditionnelles construites avec if (flag) | Chaque combinaison de drapeaux est un préfixe distinct |
tools: buildTools(user) où le jeu varie selon l'utilisateur | Les outils sont rendus en position 0, donc plus rien n'est mis en cache |
Le coupable le plus fréquent, et de loin :
// Cette seule ligne rend toute la conversation non cacheable.
const system = `You are a helpful assistant.
Current date: ${new Date().toISOString()}
${LARGE_STABLE_INSTRUCTIONS}`;La date se trouve en tête du préfixe, donc les 30 000 tokens d'instructions stables qui la suivent sont retraités au tarif plein à chaque fois.
La correction consiste à sortir entièrement le contexte volatile du prompt système :
const system = [
{
type: "text" as const,
text: LARGE_STABLE_INSTRUCTIONS,
cache_control: { type: "ephemeral" as const },
},
];
const messages = [
...history,
{
role: "user" as const,
content: `Current date: ${new Date().toISOString()}\n\n${userQuestion}`,
},
];La même information parvient au modèle, avec le cache intact.
Sérialiser les outils de façon déterministe
Les outils sont rendus en position 0, ce qui en fait l'élément dont la stabilité a le plus d'effet :
// Tri par nom pour que l'ordre ne dépende jamais de l'itération de clés ou d'un Set.
const tools = Object.values(toolRegistry).sort((a, b) =>
a.name.localeCompare(b.name),
);N'échangez pas le jeu d'outils pour implémenter des « modes ». Si vous avez besoin d'un changement de mode, passez-le en contenu de message, ou donnez au modèle un outil qui enregistre la transition — tout sauf muter le tableau d'outils en cours de conversation.
Étape 8 : Tout n'invalide pas tout
C'est de là que vient beaucoup de prudence excessive. Modifier un paramètre de requête ne détruit pas automatiquement l'ensemble du cache. Il existe trois niveaux, et un changement n'invalide que son propre niveau et ceux en dessous.
| Changement | Cache outils préservé | Cache système préservé | Cache messages préservé |
|---|---|---|---|
| Définitions d'outils ajoutées, supprimées ou réordonnées | Non | Non | Non |
| Changement de modèle | Non | Non | Non |
| Activation de la recherche web ou des citations | Oui | Non | Non |
| Modification du contenu du prompt système | Oui | Non | Non |
Changer tool_choice, ajouter des images, basculer thinking | Oui | Oui | Non |
| Ajouter du contenu de message | Oui | Oui | Non |
L'enseignement pratique : vous pouvez modifier tool_choice à chaque requête, ou activer et désactiver le raisonnement, sans perdre le cache outils-plus-système. Seuls les changements de définition d'outils et les changements de modèle imposent une reconstruction complète.
Deux de ces lignes disposent d'une échappatoire qu'il vaut la peine de connaître :
- Modifications du prompt système. Sur Claude Opus 5, Claude Opus 4.8, Claude Fable 5 et Claude Mythos 5, vous pouvez ajouter un message
{ role: "system", content: "..." }au tableaumessagesau lieu de modifier le champsystemde niveau supérieur. Il se place après l'historique mis en cache, donc le préfixe survit. Aucun en-tête beta requis. C'est aussi la manière sûre contre l'injection de transmettre des instructions opérateur, puisqu'un texte placé dans un tour utilisateur peut être falsifié par tout ce qui écrit dans l'entrée utilisateur. À noter : ce n'est pas pris en charge sur Claude Sonnet 5 — cela renvoie une erreur 400. - Changements du jeu d'outils. À partir de Claude Opus 5, derrière l'en-tête beta
mid-conversation-tool-changes-2026-07-01, vous pouvez ajouter et retirer des outils entre les tours via des blocstool_additionettool_removalsans invalider le cache.
Le changement de modèle n'a pas d'échappatoire — les caches sont liés au modèle. Si vous voulez un modèle moins cher pour une sous-tâche, lancez un appel séparé et gardez votre boucle principale sur un seul modèle.
Étape 9 : Mettre en cache les conversations multi-tours
Dans une interface de discussion, le gain se cumule. Placez le point de rupture sur le dernier bloc de contenu du tour le plus récemment ajouté, et chaque requête réutilise l'intégralité de la conversation précédente.
Créez src/conversation.ts :
import Anthropic from "@anthropic-ai/sdk";
import { client, MODEL } from "./client";
import { logCacheUsage } from "./cache-usage";
const SYSTEM_PROMPT = "You are a concise technical assistant.";
export class CachedConversation {
private messages: Anthropic.MessageParam[] = [];
async send(userText: string): Promise<string> {
this.messages.push({
role: "user",
content: [
{
type: "text",
text: userText,
// Point de rupture sur le tour le plus récent : toute la conversation
// antérieure devient le préfixe réutilisable mis en cache.
cache_control: { type: "ephemeral" },
},
],
});
const response = await client.messages.create({
model: MODEL,
max_tokens: 16000,
system: SYSTEM_PROMPT,
messages: this.messages,
});
// Ajoutez le tableau de contenu complet, pas seulement le texte —
// omettre des blocs casse l'usage d'outils et la continuité du raisonnement.
this.messages.push({ role: "assistant", content: response.content });
logCacheUsage(`turn-${this.messages.length / 2}`, response.usage);
return response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
}
}Lancez quelques tours et observez read grimper pendant que fresh reste minuscule. Les points de rupture antérieurs restent des points de lecture valides, donc les succès s'accumulent progressivement au fil de la conversation au lieu de repartir de zéro à chaque tour.
Le piège de la fenêtre de 20 blocs
Chaque point de rupture remonte au maximum 20 blocs de contenu à la recherche d'une entrée de cache antérieure. Dans une boucle d'agent, un seul tour peut facilement ajouter plus de 20 blocs — chaque paire tool_use / tool_result compte.
Quand cela se produit, le point de rupture de la requête suivante ne voit pas le cache précédent et échoue silencieusement. Aucune erreur ; votre taux de succès s'effondre simplement sur les tours longs et riches en outils.
La solution consiste à placer un point de rupture intermédiaire environ tous les 15 blocs sur les tours longs, en gardant chaque marqueur à moins de 20 blocs du précédent bloc mis en cache. C'est exactement le type de défaillance invisible sans l'utilitaire logCacheUsage de l'étape 6.
Étape 10 : Une route API Next.js avec cache
Assemblons le tout dans un point de terminaison réel. Créez app/api/ask/route.ts :
import Anthropic from "@anthropic-ai/sdk";
import { NextResponse } from "next/server";
import { getHandbook } from "@/lib/handbook";
const client = new Anthropic();
export async function POST(request: Request) {
const { question } = await request.json();
if (typeof question !== "string" || question.trim().length === 0) {
return NextResponse.json(
{ error: "A non-empty question is required." },
{ status: 400 },
);
}
// Chargé une seule fois au niveau du module dans getHandbook — identique
// octet pour octet entre les requêtes, ce qui permet le succès du cache.
const handbook = await getHandbook();
try {
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 4096,
system: [
{
type: "text",
text: `You are a support assistant. Answer only from the handbook below.\n\n<handbook>\n${handbook}\n</handbook>`,
cache_control: { type: "ephemeral", ttl: "1h" },
},
],
messages: [{ role: "user", content: question }],
});
const answer = response.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
return NextResponse.json({
answer,
usage: {
cached: response.usage.cache_read_input_tokens ?? 0,
fresh: response.usage.input_tokens,
},
});
} catch (error) {
if (error instanceof Anthropic.RateLimitError) {
return NextResponse.json(
{ error: "Rate limited. Retry shortly." },
{ status: 429 },
);
}
if (error instanceof Anthropic.APIError) {
return NextResponse.json(
{ error: `Upstream error: ${error.message}` },
{ status: 502 },
);
}
throw error;
}
}Deux détails pèsent réellement ici.
Gestion typée des erreurs. Anthropic.RateLimitError et ses semblables sont des classes exportées dotées d'un champ status typé. Vérifiez du plus spécifique au plus général, et ne faites jamais de correspondance sur les chaînes de messages d'erreur — elles changent sans préavis.
La durée de vie d'une heure. Notez le ttl: "1h" dans cache_control. Ce choix mérite sa propre section.
Étape 11 : Choisir une durée de vie, honnêtement
L'économie est assez simple pour être raisonnée directement :
| Opération | Coût relatif au tarif d'entrée de base |
|---|---|
| Lecture depuis le cache | ~0,1× |
| Écriture, durée de vie 5 minutes | 1,25× |
| Écriture, durée de vie 1 heure | 2× |
Le seuil de rentabilité découle de ces chiffres :
- Durée de vie 5 minutes : deux requêtes suffisent. Une écriture plus une lecture donnent 1,35× contre 2× sans cache.
- Durée de vie 1 heure : il en faut au moins trois. Une prime d'écriture de 2× plus deux lectures donnent 2,2× contre 3× sans cache.
La durée d'une heure n'est donc pas strictement meilleure. Elle maintient les entrées vivantes à travers les creux de trafic, ce qui compte pour les charges irrégulières, mais le doublement du coût d'écriture signifie qu'elle exige davantage de lectures pour se rentabiliser. Pour un point de terminaison à trafic régulier où les requêtes arrivent plus souvent que toutes les cinq minutes, la durée par défaut de 5 minutes est généralement le choix le moins cher — le trafic réel maintient le cache chaud de lui-même.
Étape 12 : Deux comportements temporels qui surprennent
Les requêtes concurrentes échouent toutes
Une entrée de cache ne devient lisible qu'après le début du streaming de la première réponse. Lancez dix requêtes parallèles avec un préfixe identique et les dix paieront le tarif plein — aucune ne peut lire ce que les autres sont encore en train d'écrire.
Pour les motifs en éventail, séquencez la première :
// Envoyez une requête, attendez le premier token streamé, puis déployez le reste.
const first = client.messages.stream({ /* ...préfixe partagé... */ });
for await (const _event of first) break; // attendre l'ouverture du flux
const rest = await Promise.all(
remainingInputs.map((input) => client.messages.create({ /* ... */ })),
);Vous attendez le premier token, pas la réponse complète. Le cache est actif dès cet instant.
Le préchauffage avec max_tokens: 0
Pour supprimer la latence de démarrage à froid sur la première requête réelle, envoyez une requête à sortie nulle au démarrage. L'API exécute le préremplissage, écrit le cache et retourne immédiatement avec un contenu vide :
await client.messages.create({
model: MODEL,
max_tokens: 0,
system: [
{
type: "text",
text: SYSTEM_PROMPT,
cache_control: { type: "ephemeral" },
},
],
messages: [{ role: "user", content: "warmup" }],
});Vous êtes facturé au tarif d'écriture normal et zéro token de sortie. La réponse revient avec content: [] et stop_reason: "max_tokens".
Soyez délibéré sur le moment où cela en vaut la peine. Le préchauffage échange un coût d'écriture maintenant contre un temps réduit jusqu'au premier token plus tard. Il est rentable quand les trois conditions sont réunies : la latence de la première requête est visible par l'utilisateur, le préfixe partagé est volumineux, et il existe un moment calme avant le trafic — démarrage de l'application, lancement d'un worker, après un déploiement.
Passez-vous-en quand le trafic est continu (les vraies requêtes maintiennent le cache chaud gratuitement), quand le préfixe varie par utilisateur (rien de partagé à préchauffer), ou quand vous préchaufferiez spéculativement de nombreux préfixes distincts (chacun est une écriture que vous ne relirez peut-être jamais).
Placez le point de rupture sur le dernier bloc partagé avec la requête réelle — le prompt système ou les définitions d'outils — et non sur le message utilisateur factice ; n'utilisez pas non plus la mise en cache automatique de niveau supérieur ici, car elle indexerait le cache sur ce message jetable.
max_tokens: 0 est rejeté en combinaison avec stream: true, le raisonnement activé, output_config.format, un tool_choice forcé, ou l'API Batches.
Tester votre implémentation
La vérification est directe parce que l'API vous dit la vérité :
- Exécutez deux fois la même requête. La seconde doit afficher un
cache_read_input_tokensnon nul. Si ce n'est pas le cas, vous avez un invalidateur. - Vérifiez la somme.
input_tokens + cache_creation_input_tokens + cache_read_input_tokensdoit correspondre approximativement à la taille complète de votre prompt. Si la somme est bien inférieure à vos attentes, vous n'envoyez pas ce que vous croyez envoyer. - Comparez le prompt rendu. Quand un succès refuse d'apparaître, sérialisez le corps complet de la requête sur deux appels consécutifs et comparez les octets. L'horodatage fautif ou la clé réordonnée sautera aux yeux.
- Observez le taux de succès sur une session réelle. Une conversation qui démarre à 0% et dépasse 90% au cinquième tour se comporte correctement. Une qui oscille se heurte à la limite des 20 blocs.
Dépannage
cache_read_input_tokens est toujours à 0.
Le préfixe diffère entre les requêtes. Parcourez le tableau de l'étape 7. En pratique, c'est un horodatage, un UUID ou un identifiant par utilisateur dans le prompt système dans environ 80% des cas.
cache_creation_input_tokens est également à 0, sans erreur.
Votre préfixe est en dessous de la longueur minimale cacheable du modèle. Consultez le tableau de l'étape 2 — le minimum va de 512 tokens sur Claude Opus 5 jusqu'à 4096 sur Opus 4.6 et Haiku 4.5.
Le cache a fait augmenter ma facture. Vous écrivez sans lire. C'est l'erreur préfixe-partagé-suffixe-variable de l'étape 5 — le point de rupture est après le contenu volatile, donc chaque requête écrit une nouvelle entrée. Déplacez le marqueur à la fin de la portion partagée.
Le taux de succès s'effondre sur les longs tours d'agent. La fenêtre de recherche de 20 blocs. Ajoutez des points de rupture intermédiaires tous les 15 blocs environ.
Les succès fonctionnent en local, échouent en production. Généralement la concurrence. Des requêtes parallèles ne peuvent pas lire un cache encore en cours d'écriture. Séquencez la première requête, ou acceptez l'échec au démarrage à froid.
Les succès ont cessé après un déploiement. Vérifiez si la chaîne du modèle, le jeu d'outils ou le prompt système ont changé. Les trois invalident. Les caches sont aussi liés au modèle, donc une montée de version repart toujours à froid.
Prochaines étapes
- Ajoutez le comptage de tokens avant les requêtes.
client.messages.countTokens()fournit des comptes exacts et spécifiques au modèle. N'utilisez pastiktoken— c'est le tokeniseur d'OpenAI, il sous-estime les tokens Claude de 15 à 20% sur du texte ordinaire, et bien davantage sur du code. - Combinez avec le réglage d'
effort.output_config: { effort: "low" | "medium" | "high" | "xhigh" | "max" }contrôle la profondeur du raisonnement et la dépense totale en tokens. Le cache réduit le coût d'entrée ; l'effort réduit le coût de sortie. Ce sont deux leviers indépendants, et la plupart des équipes n'en actionnent qu'un. - Instrumentez en production. Envoyez la sortie de l'utilitaire de l'étape 6 vers votre plateforme d'observabilité. Un graphique du taux de succès du cache détecte une régression d'assemblage de prompt le jour même où elle est déployée, plutôt que sur la facture.
- Explorez les tutoriels liés sur noqta.tn : le guide Claude Agent SDK en TypeScript pour les boucles d'agents qui profitent fortement du cache, l'observabilité LLM avec Langfuse pour tracer la dépense en tokens de bout en bout, et le routage Vercel AI Gateway pour les configurations multi-fournisseurs.
Conclusion
La mise en cache des prompts fait partie des rares optimisations à la fois massives et peu coûteuses à adopter. Un point de rupture correctement placé sur un préfixe stable prend une dizaine de minutes à implémenter et réduit d'environ 90% le coût d'entrée de la portion partagée.
Le piège, c'est que « correctement placé » porte tout le poids. Le mécanisme est une correspondance de préfixe exacte à l'octet près : un seul horodatage interpolé, une sérialisation JSON non triée, ou un point de rupture placé un bloc trop tard transforme une économie de 90% en un surcoût de 25% — silencieusement, sans erreur ni avertissement.
C'est pourquoi l'instrumentation de l'étape 6 n'est pas un extra facultatif. Journalisez cache_read_input_tokens à chaque requête, alertez quand il chute, et traitez un taux de succès déclinant comme la régression qu'il est. Les équipes qui tirent une valeur réelle du cache ne sont pas celles qui l'ont activé — ce sont celles qui peuvent prouver, aujourd'hui, qu'il fonctionne encore.