Construis ton premier agent IA (safe, avec code)

Commence minuscule : un tool, des budgets durs, et un kill switch. Le chemin le plus rapide vers un agent qui ne te mettra pas la honte en prod.
Sur cette page
  1. Le problĂšme
  2. Pourquoi ça compte dans la vraie vie
  3. Le playbook (20 minutes, sans héroïsme)
  4. Ce que “un seul tool” veut vraiment dire
  5. Étape 0 : choisis une tñche qui ne va pas te ruiner
  6. Étape 1 : budgets (ce qui transforme une boucle en systùme)
  7. Étape 2 : logge la trace d’actions
  8. Étape 2.5 : rends les tools dĂ©terministes (sinon tu ne peux pas dĂ©bug)
  9. Code (minimal, mais “prod‑shaped”)
  10. Rends ça réel (un petit skeleton exécutable)
  11. Étape 3 : une petite “tool policy” (mĂȘme en read-only)
  12. Tool contracts (make the model play inside the lines)
  13. Étape 4 : un rollout qui ne fait pas mal
  14. Étape 4.5 : annulation (arrĂȘte de payer quand l’utilisateur part)
  15. Étape 5 : mĂ©triques de succĂšs (pour savoir si ça s’amĂ©liore)
  16. OĂč l’exĂ©cuter (serverless vs workers)
  17. Hardening v2 (toujours petit, toujours safe)
  18. Hash des args + dédup
  19. Add per-tool budgets
  20. Add a kill switch
  21. Add a “stop reason”
  22. Le premier write tool (comment ne pas te planter)
  23. Erreurs classiques du “premier agent” (on les a faites)
  24. Échec rĂ©el
  25. Quand NE PAS construire un agent (pour l’instant)
  26. Ensuite

Le problĂšme

Ton premier agent va essayer de tout faire.

Il va naviguer sur le web, Ă©crire dans des bases, “optimiser” des configs
 bref, se comporter comme un stagiaire avec un accĂšs root.

Ne commence pas comme ça.

Commence avec un seul tool, read-only, avec des budgets et des logs.

Pourquoi ça compte dans la vraie vie

La premiÚre fois que tu shippes du tool calling, tu vas découvrir :

  • des timeouts manquants
  • une idempotence inexistante
  • des logs “prĂ©sents”, mais inutiles

Autant le dĂ©couvrir avec un tool plutĂŽt qu’avec douze.

Le playbook (20 minutes, sans héroïsme)

  1. Choisis un seul tool read-only (web.search ou kb.search).
  2. Ajoute des budgets : steps + time (+ $ si tu peux).
  3. Logge chaque tool call (nom, hash des args, durée, statut).
  4. Détecte les boucles via les args identiques.
  5. N’ajoute pas encore de tools “send” ou “write”.

Ce que “un seul tool” veut vraiment dire

“Un seul tool”, ce n’est pas “un tool + une porte dĂ©robĂ©e”.

Ça veut dire :

  • tu peux pointer l’allowlist et la compter sur une main
  • pas de tool gĂ©nĂ©rique http.request avec internet complet
  • pas de run_shell “pour dĂ©bug”

Si ton premier agent peut fetch n’importe quelle URL, tu as dĂ©jĂ  sautĂ© la phase “safe”.

Étape 0 : choisis une tñche qui ne va pas te ruiner

Bonnes premiĂšres tĂąches :

  • “trouve 5 docs pertinents dans notre KB”
  • “rĂ©sume les incidents rĂ©cents depuis le dossier de postmortems”
  • “rĂ©dige une rĂ©ponse en utilisant des templates connus”

Mauvaises premiĂšres tĂąches :

  • “browse le web jusqu’à ĂȘtre confiant”
  • “corrige la config prod”
  • “exĂ©cute des commandes sur des serveurs”

Ton premier agent doit ĂȘtre boring et pas cher.

Étape 1 : budgets (ce qui transforme une boucle en systùme)

Les budgets ne sont pas un “nice to have”. C’est la diffĂ©rence entre :

  • une requĂȘte qui s’arrĂȘte
  • une requĂȘte qui continue de te faire payer

On met au minimum :

  • max steps
  • max seconds

Si tu peux estimer le coĂ»t en $, ajoute-le tĂŽt. Ça se rentabilise.

Étape 2 : logge la trace d’actions

Tu n’as pas besoin d’un systĂšme de tracing parfait le premier jour. Tu as besoin d’assez pour rĂ©pondre :

  • quels tools a-t-il appelĂ©s ?
  • avec quels arguments ?
  • combien de temps chaque call a pris ?
  • pourquoi il s’est arrĂȘtĂ© ?

Si tu ne peux pas répondre à ça, tu ne peux pas le shipper.

Étape 2.5 : rends les tools dĂ©terministes (sinon tu ne peux pas dĂ©bug)

Un agent, c’est une boucle. Les boucles sont dĂ©jĂ  assez pĂ©nibles. Ajoute des tools non dĂ©terministes (timeouts, APIs flaky, pages qui changent) et tu as gagnĂ©. Bravo : tu as construit un systĂšme non dĂ©terministe et tu vas le dĂ©bugger Ă  03:00.

Le truc qu’on utilise tît : record/replay. Pour un run, enregistre :

  • le nom du tool
  • le hash des args
  • la rĂ©ponse (ou l’erreur)

Ensuite tu peux rejouer le mĂȘme “monde” pour tester :

  • changements de prompt
  • changements de modĂšle
  • rĂ©glages du loop guard

Code conceptuel minimal :

PYTHON
class TapeTools(SafeTools):
  def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
      super().__init__(allow=allow, impl=impl)
      self.tape = tape if tape is not None else []

  def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
      try:
          out = super().call(name, args=args, request_id=request_id)
          self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
          return out
      except Exception as e:
          self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
          raise


def replay(tape: list[dict]):
  # Replace real tools with deterministic playback.
  i = 0
  def impl(name: str, **args):
      nonlocal i
      item = tape[i]
      i += 1
      assert item["tool"] == name
      if not item["ok"]:
          raise RuntimeError(item["err"])
      return item["out"]
  return impl
JAVASCRIPT
export class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
  super({ allow, impl });
  this.tape = tape || [];
}

async call(name, { args, requestId }) {
  try {
    const out = await super.call(name, { args, requestId });
    this.tape.push({ tool: name, args, ok: true, out });
    return out;
  } catch (e) {
    this.tape.push({
      tool: name,
      args,
      ok: false,
      err: String(e && e.message ? e.message : e),
    });
    throw e;
  }
}
}

export function replay(tape) {
let i = 0;
return async function call(name, args) {
  const item = tape[i];
  i += 1;
  if (!item || item.tool !== name) throw new Error("tape mismatch");
  if (!item.ok) throw new Error(item.err);
  return item.out;
};
}

C’est glamour ? Non. Est-ce que ça te permet de lancer une “golden suite” de 20 tĂąches avant de shipper ? Oui. C’est comme ça que tu Ă©vites d’apprendre les rĂ©gressions via de vrais clients.

Code (minimal, mais “prod‑shaped”)

PYTHON
from dataclasses import dataclass
import time
from typing import Any


@dataclass(frozen=True)
class Budget:
  max_steps: int = 12
  max_seconds: int = 20


class Tools:
  def __init__(self):
      self.allow = {"web.search"}

  def call(self, name: str, *, args: dict[str, Any]) -> Any:
      if name not in self.allow:
          raise RuntimeError(f"tool not allowed: {name}")
      return tool_impl(name, args=args)  # (pseudo)


def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
  started = time.time()
  last = None

  for step in range(budget.max_steps):
      if time.time() - started > budget.max_seconds:
          return "stopped: time budget exceeded"

      action = llm_pick_action(question)  # (pseudo): either {"tool":..., "args":...} or {"finish":...}

      if action.get("finish"):
          return action["finish"]

      key = f"{action['tool']}:{action['args']}"
      if key == last:
          return "stopped: loop guard (same call twice)"
      last = key

      obs = tools.call(action["tool"], args=action["args"])
      question = update_state(question, action, obs)  # (pseudo)

  return "stopped: step budget exceeded"
JAVASCRIPT
export class Tools {
constructor() {
  this.allow = new Set(["web.search"]);
}

async call(name, { args }) {
  if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
  return toolImpl(name, { args }); // (pseudo)
}
}

export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;

for (let step = 0; step < budget.max_steps; step++) {
  if ((Date.now() - started) / 1000 > budget.max_seconds) {
    return "stopped: time budget exceeded";
  }

  const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
  if (action.finish) return action.finish;

  const key = String(action.tool) + ":" + JSON.stringify(action.args);
  if (key === last) return "stopped: loop guard (same call twice)";
  last = key;

  const obs = await tools.call(action.tool, { args: action.args });
  question = updateState(question, action, obs); // (pseudo)
}

return "stopped: step budget exceeded";
}

Rends ça réel (un petit skeleton exécutable)

L’exemple minimal au-dessus est volontairement court. Voici un skeleton “single file” un peu plus complet, qui ressemble à quelque chose que tu peux vraiment mettre derriùre une API :

PYTHON
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable


@dataclass(frozen=True)
class Budget:
  max_steps: int = 12
  max_seconds: int = 20


def args_hash(args: dict[str, Any]) -> str:
  raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
  return hashlib.sha256(raw).hexdigest()[:12]


class LoopGuard(RuntimeError):
  pass


class Monitor:
  def __init__(self, *, max_repeat: int = 2):
      self.max_repeat = max_repeat
      self.counts: dict[str, int] = {}

  def mark(self, tool: str, args: dict[str, Any]) -> None:
      key = f"{tool}:{args_hash(args)}"
      self.counts[key] = self.counts.get(key, 0) + 1
      if self.counts[key] >= self.max_repeat:
          raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")


class SafeTools:
  def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
      self.allow = allow
      self.impl = impl

  def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
      if name not in self.allow:
          raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
      started = time.time()
      try:
          return self.impl[name](**args)
      finally:
          ms = int((time.time() - started) * 1000)
          print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")


def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
  request_id = uuid.uuid4().hex
  started = time.time()
  monitor = Monitor(max_repeat=2)
  state = {"question": question, "notes": []}

  for step in range(budget.max_steps):
      if time.time() - started > budget.max_seconds:
          return f"[{request_id}] stopped: time budget"

      action = llm_pick_action(state)  # (pseudo)
      if action.get("finish"):
          return action["finish"]

      tool = action["tool"]
      args = action["args"]
      monitor.mark(tool, args)

      obs = tools.call(tool, args=args, request_id=request_id)
      state = update_state(state, action, obs)  # (pseudo)

  return f"[{request_id}] stopped: step budget"
JAVASCRIPT
import crypto from "node:crypto";

function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}

export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}

export class LoopGuard extends Error {}

export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
  this.maxRepeat = maxRepeat;
  this.counts = new Map();
}

mark(tool, args) {
  const key = tool + ":" + argsHash(args);
  const next = (this.counts.get(key) || 0) + 1;
  this.counts.set(key, next);
  if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}

export class SafeTools {
constructor({ allow, impl }) {
  this.allow = allow;
  this.impl = impl;
}

async call(name, { args, requestId }) {
  if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
  const started = Date.now();
  try {
    return await this.impl[name](args);
  } finally {
    const ms = Date.now() - started;
    console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
  }
}
}

export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };

for (let step = 0; step < budget.max_steps; step++) {
  if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";

  const action = await llmPickAction(state); // (pseudo)
  if (action.finish) return action.finish;

  const tool = action.tool;
  const args = action.args;
  monitor.mark(tool, args);

  const obs = await tools.call(tool, { args, requestId });
  state = updateState(state, action, obs); // (pseudo)
}

return "[" + requestId + "] stopped: step budget";
}

Ce n’est pas “le framework parfait”. C’est une petite boucle bornĂ©e avec :

  • allowlisted tools
  • budgets
  • loop guard
  • basic logs

Ça suffit pour apprendre sans cramer la prod.

Étape 3 : une petite “tool policy” (mĂȘme en read-only)

MĂȘme des tools read-only doivent ĂȘtre allowlistĂ©s explicitement. Ça te force Ă  rendre la surface d’attaque visible.

Tu peux faire Ă©voluer la classe jouet Tools vers quelque chose d’opĂ©rable :

  • add args hashing
  • add timing
  • add error classification (retryable vs fatal)

Ne sur-engineer pas. Rends juste ça débuggable.

Tool contracts (make the model play inside the lines)

The model will happily invent args:

  • wrong field names
  • huge strings
  • weird nested objects

Si ton wrapper accepte “any dict”, ta runtime devient la couche de validation. Ça veut dire : tu vas dĂ©bugger des erreurs de validation en prod.

Commence simple :

  • define an args shape per tool
  • validate types and bounds
  • reject unknown fields

Exemple conceptuel :

PYTHON
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
  q = str(args.get("q", "")).strip()
  if not q or len(q) > 200:
      raise ValueError("invalid q")

  k = int(args.get("k", 5))
  if k < 1 or k > 8:
      raise ValueError("invalid k")

  return {"q": q, "k": k}
JAVASCRIPT
export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");

const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");

return { q, k };
}

Ça a l’air bĂȘte. C’est aussi ce qui empĂȘche ton agent d’appeler web.search avec un prompt dump de 10 000 caractĂšres. Et ça te donne une erreur propre que tu peux traiter comme fatale (pas “on retry Ă  l’infini”).

Étape 4 : un rollout qui ne fait pas mal

VoilĂ  comment on shippe la premiĂšre version :

  1. Interne uniquement (toi + un collĂšgue)
  2. Read-only (pas de write tools)
  3. Canary (petit slice de trafic)
  4. Kill switch (arrĂȘt opĂ©rateur)

Si tu sautes le canary, la premiĂšre boucle sera chez ton client le plus important.

Étape 4.5 : annulation (arrĂȘte de payer quand l’utilisateur part)

Si tu as une UI, des utilisateurs vont abandonner des requĂȘtes. Si tu ne propages pas l’annulation, l’agent continue quand mĂȘme. Il continue d’appeler des tools. Il continue de brĂ»ler des tokens. Il continue de gĂ©nĂ©rer un output que personne ne lit.

Fais de l’annulation un stop reason de premiùre classe :

  • client disconnect → abort du run
  • l’abort doit se propager aux appels modĂšle et aux tool calls
  • logge stop_reason = client_cancel

MĂȘme une implĂ©mentation grossiĂšre vaut mieux que “on dĂ©pense jusqu’à tuer le budget”.

Étape 5 : mĂ©triques de succĂšs (pour savoir si ça s’amĂ©liore)

Choisis 3 chiffres et suis-les :

  • taux de complĂ©tion (est-ce que ça finit ?)
  • coĂ»t moyen par run ($ ou tokens / crĂ©dits outils)
  • p95 runtime

Si le taux de complétion est haut mais que le coût explose, tes budgets sont mal faits. Si le coût est bas mais que le taux de complétion est catastrophique, ton contrat de tool est probablement mauvais.

OĂč l’exĂ©cuter (serverless vs workers)

Ton premier agent va probablement tourner en API route. C’est ok
 jusqu’à ce que ça ne le soit plus.

Deux piĂšges classiques :

  • cold starts : cold start long + boucle = mauvais UX
  • time limits : les plateformes serverless limitent l’exĂ©cution ; ton “budget 90 secondes” peut ne pas rentrer

Souvent on sépare :

  • la requĂȘte arrive (rapide)
  • le run agent tourne dans un worker (budgĂ©tĂ©)
  • la UI poll / stream le progrĂšs

Si ça sonne comme “trop d’archi”, garde simple : mets des budgets assez bas pour que ta plateforme puisse rĂ©ellement arrĂȘter le run. La maniĂšre la plus rapide d’avoir des 500, c’est une longue boucle dans un request handler sans garde-fous.

Hardening v2 (toujours petit, toujours safe)

Une fois la version “un tool” stable, voilà ce qu’on ajoute ensuite :

Hash des args + dédup

Si l’agent appelle le mĂȘme tool avec les mĂȘmes args en boucle, stoppe-le. Ça attrape les boucles infinies les plus bĂȘtes.

Add per-tool budgets

Les budgets globaux arrĂȘtent le run. Les budgets par tool empĂȘchent une dĂ©pendance flaky de manger tout le run.

Example:

  • web.search max 3 calls

Add a kill switch

MĂȘme pour un petit agent. S’il tourne en prod, tu veux pouvoir le stopper sans dĂ©ployer.

Add a “stop reason”

Rends le stop reason visible dans les logs et la UI :

  • time budget
  • step budget
  • loop detected
  • tool denied

Ça Ă©vite que les utilisateurs martĂšlent refresh.

Le premier write tool (comment ne pas te planter)

Quand tu ajoutes enfin un write tool :

  1. make it a separate tool (don’t reuse read tool name)
  2. add idempotency keys
  3. require approval by default
  4. log an audit event for the intended write

Si tu sautes l’idempotence, tu vas shipper des Ă©critures dupliquĂ©es. Ce n’est pas “peut‑ĂȘtre”. C’est “quand”.

Erreurs classiques du “premier agent” (on les a faites)

  • Glissement “juste un tool de plus”. Tu commences avec web.search et tu finis avec http.request + un token admin.
  • Retries sans limites. Tu “gĂšres les erreurs” et tu construis une boucle infinie.
  • Pas de stop reason dans la UI. Les gens refresh parce qu’ils ne savent pas ce qui s’est passĂ©, et tu payes deux fois.
  • Logger seulement la rĂ©ponse finale. Ensuite tu ne peux pas expliquer pourquoi le tool a Ă©tĂ© appelĂ© 17 fois.

Si tu Ă©vites ces quatre-lĂ , tu es dĂ©jĂ  devant la plupart des “agent demos”. Et : teste-le avec un faux tool qui Ă©choue une fois. Si la boucle survit sans spammer les retries, c’est bon. Si ça s’écroule, fixe maintenant. Vraiment, maintenant.

Échec rĂ©el

On a vu un “premier agent” partir en prod avec 0 budgets. Il a reçu un prompt bizarre, a commencĂ© Ă  brows­er, puis s’est coincĂ©.

Résultat :

  • l’utilisateur a attendu ~2 minutes
  • l’agent a brĂ»lĂ© de l’argent
  • tout le monde a blĂąmĂ© le modĂšle

Ce n’était pas le modĂšle. C’étaient les budgets absents.

Quand NE PAS construire un agent (pour l’instant)

  • Si un script suffit, Ă©cris le script.
  • Si un workflow suffit, construis le workflow.
  • Si tu ne peux pas mettre des tool permissions + des audit logs, ne shippe pas du tool calling.

Ensuite

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱ 14 min de lecture ‱ Mis Ă  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.