Stack de production pour agents IA (le truc entre ton agent et la catastrophe)

Un agent, ce n’est pas un prompt. C’est une stack : budgets, outils, état, logs, contrôles. C’est ça qui évite les incidents.
Sur cette page
  1. Le problème
  2. Pourquoi ça arrive dans la vraie vie
  3. Ce qui casse si tu l’ignores
  4. La stack (ce qu’on run vraiment)
  5. Diagramme (où se place la control layer)
  6. Layer par layer : ce qu’on a appris à la dure
  7. Point d’entrée
  8. Orchestrateur
  9. Couche modèle
  10. Couche tools
  11. Couche state
  12. Observabilité
  13. Couche de contrôle
  14. Code : squelette d’orchestration (TypeScript)
  15. Ce qu’on mesure (parce que “ça a l’air ok” n’est pas une métrique)
  16. Taxonomie des stop reasons (pour debugger sans vibes)
  17. Réalité multi‑tenant (là où se cachent les incidents)
  18. Rate limits & circuit breakers (parce que les agents amplifient les outages)
  19. Notre rollout (parce que les agents ne méritent pas ta confiance jour 1)
  20. Où doit vivre la “memory” (indice : pas dans les prompts)
  21. Incident response (à préparer avant le premier pager)
  22. Tests & replay (parce que “ça marche avec mon prompt” n’est pas un test)
  23. Ordre de construction “boring first”
  24. Échec réel
  25. Compromis
  26. Quand NE PAS construire une stack d’agent
  27. Liens

Le problème

En dev, ton agent “marche”.

En prod :

  • il boucle sur une API flaky
  • il fait 200 tool calls parce que “juste un dernier”
  • tu ne peux pas expliquer ce qu’il s’est passé parce que le seul log, c’est le final answer

Ce n’est pas un problème de LLM. C’est un problème de stack.

Pourquoi ça arrive dans la vraie vie

Un agent, c’est surtout :

  • un planner (LLM)
  • une runtime (ton code)
  • des side effects (tools)
  • du state (memory/artifacts)
  • des contraintes (budgets/policy)
  • de l’observabilité (logs/audit)

Si tu ne construis que le planner, tu vas te faire pager.

Ce qui casse si tu l’ignores

  • Pas d’audit = pas de postmortem (ou alors “c’est le modèle”)
  • Pas de budgets = coût sans limite
  • Pas de frontière de policy = writes accidentels avec des creds prod
  • Pas de state = travail répété, tool calls en double, prompt bloat

La stack (ce qu’on run vraiment)

  1. Point d’entrée : UI/API, auth, request id
  2. Orchestrator : routing, retries, budgets, tracing
  3. Model layer : appels LLM (avec spend tracking)
  4. Tool layer : APIs, browser, DB (avec allowlists)
  5. State : memory, artifacts, caches, clés d’idempotence
  6. Observabilité : logs structurés, traces, événements d’audit
  7. Control layer : policy engine, kill switch, arrêt incident

Diagramme (où se place la control layer)

Voilà le modèle mental qu’on utilise :

Si tu mets le “control” dans un prompt, tu n’as pas de control layer. Tu as une suggestion.

Layer par layer : ce qu’on a appris à la dure

Point d’entrée

Le point d’entrée, c’est là où tu choisis le blast radius.

Bonnes valeurs par défaut :

  • authentifier avant que l’agent tourne
  • générer un request id
  • lier tenant/environnement à ce request id
  • poser un budget upfront (ne laisse pas le modèle “négocier” le budget)

Si tu laisses le modèle choisir le tenant ou l’environnement, tu écriras un jour au mauvais endroit.

Orchestrateur

C’est la runtime qui garde ton agent “honnête” :

  • step loop
  • timeouts
  • retry policy
  • tool allowlists
  • trace collection
  • stop reasons

Si tu ne construis pas ça, chaque agent devient une snowflake qui casse différemment. Les snowflakes c’est mignon… jusqu’au moment où tu dois les opérer.

Couche modèle

Ta model layer sert surtout à :

  • provider fallbacks (if you have them)
  • spend tracking
  • predictable output formats (tool actions)

Le modèle n’est pas la seule partie “unreliable”. Mais c’est la seule que tout le monde blame, parce que c’est plus simple que d’admettre que la runtime manque.

Couche tools

Les tools, c’est là où vivent les side effects. C’est là où tu imposes :

  • allowlists (what can be called)
  • permissions (what can be written)
  • idempotency keys (what can be repeated safely)
  • timeouts (what can’t hang)
  • rate limits (what can’t DDoS your dependencies)

La tool layer ne doit pas accepter “do the thing” comme input. Elle doit accepter des args structurés avec validation.

Couche state

Le state n’est pas un seul bucket.

On le découpe en :

  • scratch : notes par run, courtes (petit, structuré)
  • artifacts : outputs à garder (drafts, extraits, plans)
  • memory : ce que tu veux porter entre runs (avec prudence)
  • cache : déduper les lectures chères (URLs, lookups KB)

Si tu balances tout dans “memory”, tu as du prompt bloat et des réponses pires. Si tu ne gardes rien, tu as du travail répété et des tool calls en double.

Observabilité

Si tu ne peux pas répondre à “qu’est‑ce qu’il a fait ?”, tu ne peux pas le run en prod.

Observabilité minimale :

  • action trace (steps, tool calls, stop reason)
  • structured tool logs (args hash, duration, status)
  • spend/cost estimation
  • per-tenant usage metrics

Si tu es sérieux, ajoute du tracing (spans model, spans tools). Mais même des logs structurés “simples” battent “le modèle a dit”.

Couche de contrôle

La control layer, c’est la pièce que tu veux voir exister quand tu dors :

  • budgets (hard limits)
  • tool permissions (least privilege)
  • approvals (for writes)
  • kill switch (operator stop)
  • incident stop (circuit breakers)

Ce n’est pas du “security theater”. C’est ce qui transforme une démo LLM en système que tu peux laisser tourner.

Code : squelette d’orchestration (TypeScript)

Tu n’as pas besoin d’un framework énorme. Tu as besoin de points de contrôle explicites.

TS
type Budget = { maxSteps: number; maxSeconds: number; maxUsd: number };
type ToolName = "web.search" | "http.get" | "ticket.create";

type Policy = {
  allowTools: ToolName[];
  budget: Budget;
  requireApprovalFor: ToolName[];
};

type AuditEvent =
  | { type: "tool.call"; tool: ToolName; args: unknown; ms: number }
  | { type: "budget.stop"; reason: string }
  | { type: "kill"; reason: string };

export async function runAgent(input: string, policy: Policy) {
  const started = Date.now();
  const events: AuditEvent[] = [];

  for (let step = 0; step < policy.budget.maxSteps; step++) {
    if (Date.now() - started > policy.budget.maxSeconds * 1000) {
      events.push({ type: "budget.stop", reason: "time" });
      break;
    }
    if (await killSwitchIsOn()) {
      events.push({ type: "kill", reason: "operator" });
      break;
    }

    const action = await llmDecideNext(input); // returns {tool, args} or {finish}
    if (action.type === "finish") return { output: action.text, events };

    if (!policy.allowTools.includes(action.tool)) {
      throw new Error(`tool not allowed: ${action.tool}`);
    }
    if (policy.requireApprovalFor.includes(action.tool)) {
      await waitForHumanApproval(action); // (pseudo)
    }

    const t0 = Date.now();
    const obs = await callTool(action.tool, action.args); // must enforce timeouts + idempotency
    events.push({ type: "tool.call", tool: action.tool, args: action.args, ms: Date.now() - t0 });

    input = updateState(input, action, obs); // keep state small, structured
  }

  return { output: "stopped", events };
}

Ce qu’on mesure (parce que “ça a l’air ok” n’est pas une métrique)

Si tu veux opérer des agents en prod, mesure le stuff boring :

  • completion rate (terminé vs budget atteint ?)
  • p50/p95 runtime
  • p50/p95 tool calls per run
  • cost per run (tokens + tool credits)
  • loop rate (runs stopped by loop guard)
  • policy deny rate (how often your allowlist blocks it)

Si tu ne mesures pas les policy denies, tu vas “fixer” l’agent en élargissant les permissions au lieu de fixer la tâche.

Taxonomie des stop reasons (pour debugger sans vibes)

Si tu shippes sans stop reasons explicites, tes dashboards deviennent 100% vibes : “it didn’t work” → “it timed out” → “maybe the model was bad”.

On log un seul stop_reason par run et on le traite comme un contrat. C’est la différence entre :

  • “agent feels flaky”
  • “60% of runs stop on tool_timeout:http.get because the upstream is dying”

Stop reasons courants qu’on voit vraiment :

  • finish
  • max_steps, max_seconds, max_usd
  • policy_deny:<tool>
  • approval_timeout
  • tool_timeout:<tool>
  • tool_error_exhausted:<tool>
  • loop_detected
  • operator_kill

Exemple d’event (le genre de ligne boring qui te sauve une journée plus tard) :

JSON
{
  "request_id": "req_9f2c",
  "tenant": "acme-prod",
  "steps": 25,
  "tool_calls": 17,
  "usd_estimate": 1.03,
  "stop_reason": "max_usd"
}

Oui, tu peux faire fancy avec “partial success” et “degraded mode”. Commence avec un stop reason. Rends‑le cohérent. Ton toi de l’astreinte dira merci.

Réalité multi‑tenant (là où se cachent les incidents)

Les systèmes multi‑tenant cassent de façons très prévisibles :

  • mauvais contexte de tenant
  • caches cross‑tenant
  • credentials partagés
  • tools “globaux” qui accèdent à tout en douce

Garde‑fous :

  • le tenant id est fixé par le point d’entrée, jamais par le modèle
  • les caches sont keyés par tenant + environnement
  • les credentials sont scopés par tenant + environnement
  • les audit logs incluent toujours le tenant id

S’il en manque un, tu finiras par leak des données.

Rate limits & circuit breakers (parce que les agents amplifient les outages)

Si une dépendance est flaky, un agent est un amplificateur d’échecs : il retry, cherche des alternatives, réessaie, “vérifie”, réessaie.

C’est comme ça que tu transformes :

  • “l’API upstream renvoie 500 pendant 2 minutes” en
  • “on envoie 80k requêtes et on se fait rate‑limiter une heure”

On fait trois trucs boring :

  1. Caps de concurrence par tool (par tenant). Exemple : tool browser max 2 runs en parallèle. Plus = self‑DDoS.
  2. Rate limiting à la frontière du tool. Pas dans le modèle.
  3. Circuit breakers qui fail fast quand le taux d’erreur explose.

Pseudo‑code :

TS
const httpGet = rateLimit({ perTenantRps: 5 }, async (url: string) => {
  return fetch(url, { signal: AbortSignal.timeout(8000) });
});

const breaker = new CircuitBreaker({
  windowMs: 30_000,
  failureRate: 0.5,
  cooldownMs: 60_000,
});

const res = await breaker.exec(() => httpGet("https://api.example.com/health"));

Quand le breaker est open, on stoppe le run avec une raison claire (tool_unhealthy:http.get), et on ne prétend pas que le modèle va “reason” à travers une panne. Il ne peut pas. Il va juste cramer du budget.

Notre rollout (parce que les agents ne méritent pas ta confiance jour 1)

Shipper en prod n’est pas un interrupteur binaire.

On shippe comme ça :

  1. utilisateurs internes uniquement
  2. tools read‑only uniquement
  3. petit pourcentage canary
  4. élargir les permissions progressivement (avec approvals pour les writes)
  5. et seulement ensuite envisager du comportement “autonome”

Et oui : on garde le kill switch à portée pendant tout le rollout.

Où doit vivre la “memory” (indice : pas dans les prompts)

Si tu stockes tout dans le prompt, tu obtiens :

  • des context windows qui gonflent
  • des réponses pires (le modèle se noie dans le bruit)
  • plus de coût

On préfère :

  • un scratchpad petit et structuré par run
  • des artifacts stockés à l’extérieur (drafts, notes, citations)
  • une memory long terme optionnelle avec scoping strict + TTL

La memory est une feature produit. Traite‑la comme telle. Teste‑la. Audite‑la. Scope‑la.

Incident response (à préparer avant le premier pager)

Les agents vont échouer. La question, c’est si tu peux stopper les dégâts vite.

Avant de shipper, assure‑toi de pouvoir :

  • désactiver un tool (browser, email, payments) sans déployer de code
  • désactiver un tenant sans faire tomber tout le monde
  • retrouver un run via request id
  • rejouer un run dans un environnement sûr
  • répondre “quels tool calls ont eu lieu ?” en moins d’une minute

Si tu ne peux pas faire ça, le premier incident sera lent et douloureux.

Tests & replay (parce que “ça marche avec mon prompt” n’est pas un test)

La vérité pénible : le comportement d’un agent change quand tu changes n’importe quoi. Version du modèle. Prompt. Schéma du tool. Réponses de l’API upstream. Même les timeouts.

Donc on teste la stack, pas seulement le prompt :

  • enregistrer/rejouer les réponses de tools dans une sandbox (mêmes inputs, outputs stables)
  • exécuter une petite suite de tâches “golden” à chaque deploy
  • faire des assertions sur les traces, pas seulement sur le texte final (steps, tools, stop_reason)

Ça a capté de vraies régressions chez nous :

  • un rename du schéma du tool a fait boucler l’agent sur des erreurs de validation
  • un tweak de retries a doublé les tool calls (coût ~2× du jour au lendemain)

Si tu ne peux pas rejouer un run de façon déterministe, le debug devient de l’archéologie.

Ordre de construction “boring first”

Si tu pars de zéro, construis dans cet ordre :

  1. wrapper de tools (allowlist + timeouts + idempotency)
  2. budgets (steps/time) + stop reasons
  3. events d’audit (tool calls avec hash des args)
  4. kill switch
  5. et seulement ensuite : planning fancy, memory, routing multi‑agent

La plupart des équipes font l’inverse parce que les démos récompensent le “smart”. La prod récompense “s’arrête quand ça devient bizarre”.

Échec réel

On a déjà shippé un agent “fonctionnel” sans événements d’audit structurés. Ensuite il a fait un truc bizarre en prod.

Timeline du postmortem :

  • “il a appelé le tool un nombre indécent de fois”
  • “on pense qu’il a retry”
  • “on ne sait pas quels args il a utilisés”

Ça nous a coûté ~une demi‑journée d’ingénierie, surtout à se disputer sur ce qui s’était passé.

Fix :

  • chaque tool call émet un event structuré (tool, hash des args, durée, statut)
  • un request id traverse tout
  • le kill switch est un clic, pas un déploiement

Compromis

  • Plus d’instrumentation = plus de code.
  • Plus de policy = plus de cas “agent refused”.
  • C’est toujours moins cher que debugger à l’aveugle.

Quand NE PAS construire une stack d’agent

Si c’est un script interne one‑off qui tourne une fois par semaine, ne sur‑engineer pas. Mais si ça touche des systèmes de prod ou de l’argent réel, tu as besoin de la stack. Point.

Liens

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱️ 11 min de lectureMis à jour Mars, 2026Difficulté: ★★★
Intégré : contrôle en productionOnceOnly
Ajoutez des garde-fous aux agents tool-calling
Livrez ce pattern avec de la gouvernance :
  • Budgets (steps / plafonds de coût)
  • Permissions outils (allowlist / blocklist)
  • Kill switch & arrêt incident
  • Idempotence & déduplication
  • Audit logs & traçabilité
Mention intégrée : OnceOnly est une couche de contrôle pour des systèmes d’agents en prod.
Auteur

Cette documentation est organisée et maintenue par des ingénieurs qui déploient des agents IA en production.

Le contenu est assisté par l’IA, avec une responsabilité éditoriale humaine quant à l’exactitude, la clarté et la pertinence en production.

Les patterns et recommandations s’appuient sur des post-mortems, des modes de défaillance et des incidents opérationnels dans des systèmes déployés, notamment lors du développement et de l’exploitation d’une infrastructure de gouvernance pour les agents chez OnceOnly.