Problème (pourquoi tu lis ça)
En dev, ton agent “marche”.
En prod, une fois sur 200 runs :
- un client dit “il a envoyé le mauvais truc”
- le coût explose pendant 15 minutes
- il boucle sur une API flaky jusqu’au timeout
Et toi, tu n’as… presque rien :
- une “réponse finale”
- deux logs perdus
- parfois une erreur tool sans contexte
Résultat : le pire debugging possible, au pif avec une CB branchée.
Cette page, c’est du logging qui rend les incidents ennuyeux (le rêve).
Pourquoi ça casse en prod
Les agents cassent comme des systèmes distribués parce que c’en sont :
- le modèle est un planner peu fiable
- les tools sont des side effects (HTTP/DB/tickets/email)
- retries + timeouts créent des comportements émergents
Si tu ne logges pas la boucle, tu ne peux pas répondre à :
- quels tool calls, dans quel ordre ?
- quels args (ou au moins quel args_hash) ?
- qu’est-ce que le tool a renvoyé (ou qu’est-ce qu’on a redigé) ?
- pourquoi le run s’est arrêté (
stop_reason) ? - quelle requête / quel user ?
Sans stop_reason, tu n’observes rien. Tu collectionnes des impressions.
Diagramme : pipeline d’événements minimal
Code réel : instrumenter le tool gateway (Python + JS)
Commence par la frontière. C’est là que l’argent (et les dégâts) se passent.
On log :
run_id,trace_id,tool_nameargs_hash(pas les args bruts par défaut)- latence + statut
error_class(normalisée)
Et on force le passage par un gateway pour éviter le “oops j’ai oublié de logger”.
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
def stable_hash(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@dataclass(frozen=True)
class RunCtx:
run_id: str
trace_id: str
user_id: Optional[str] = None
request_id: Optional[str] = None
class Logger:
def event(self, name: str, fields: Dict[str, Any]) -> None: ...
class ToolGateway:
def __init__(self, *, impls: dict[str, Callable[..., Any]], logger: Logger):
self.impls = impls
self.logger = logger
def call(self, ctx: RunCtx, name: str, args: Dict[str, Any]) -> Any:
fn = self.impls.get(name)
if not fn:
self.logger.event("tool_call", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"args_hash": stable_hash(args),
"ok": False,
"error_class": "unknown_tool",
})
raise RuntimeError(f"unknown tool: {name}")
t0 = time.time()
self.logger.event("tool_call", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"args_hash": stable_hash(args),
})
try:
out = fn(**args)
self.logger.event("tool_result", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"latency_ms": int((time.time() - t0) * 1000),
"ok": True,
})
return out
except TimeoutError:
self.logger.event("tool_result", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"latency_ms": int((time.time() - t0) * 1000),
"ok": False,
"error_class": "timeout",
})
raise
except Exception as e:
self.logger.event("tool_result", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"latency_ms": int((time.time() - t0) * 1000),
"ok": False,
"error_class": type(e).__name__,
})
raiseimport crypto from "node:crypto";
export function stableHash(obj) {
const raw = JSON.stringify(obj);
return crypto.createHash("sha256").update(raw).digest("hex");
}
export class ToolGateway {
constructor({ impls = {}, logger }) {
this.impls = impls;
this.logger = logger;
}
call(ctx, name, args) {
const fn = this.impls[name];
const argsHash = stableHash(args);
if (!fn) {
this.logger.event("tool_call", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
args_hash: argsHash,
ok: false,
error_class: "unknown_tool",
});
throw new Error("unknown tool: " + name);
}
const t0 = Date.now();
this.logger.event("tool_call", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
args_hash: argsHash,
});
try {
const out = fn(args);
this.logger.event("tool_result", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
latency_ms: Date.now() - t0,
ok: true,
});
return out;
} catch (e) {
this.logger.event("tool_result", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
latency_ms: Date.now() - t0,
ok: false,
error_class: e?.name || "Error",
});
throw e;
}
}À combiner avec :
- budgets (
/fr/governance/budget-controls) - dedupe pour éviter le spam (
/fr/failures/tool-spam) - unit tests qui verrouillent les stop reasons (
/fr/testing-evaluation/unit-testing-agents)
Panne réelle (avec chiffres)
On a livré un agent de recherche “read-only” qui faisait http.get.
Un jour, un partenaire a commencé à renvoyer des 200 avec un payload d’erreur (oui). Notre wrapper tool a traité “200 == ok” et on loggait uniquement “success”.
Impact :
- ~18% des runs ont produit des résumés faux mais confiants pendant ~2 heures
- ~30 tickets
- astreinte : ~4 heures pour prouver que ce n’était pas juste “le modèle qui hallucine”
Fix :
- log d’une
error_classnormalisée + échecs de validation args_hash+ latence pour repérer les hot spots- alerte : validation_fail_rate > 2% pendant 5 minutes
Tu n’as pas besoin de logs parfaits. Tu as besoin de logs qui répondent “qu’est-ce qui s’est passé ?” en <10 minutes.
Compromis
- Logger les args bruts aide… et leak de la PII. Par défaut :
args_hash. - Stocker les résultats complets rend le debug simple et la conformité pénible. Sampling + redaction.
- Trop de logs peut devenir une panne. Commence par ce que tu alertes.
Quand NE PAS faire ça
- Si l’agent tourne seulement en local/trusté, tu peux être plus light (un temps).
- Si tu changes la boucle tous les jours : garde les logs légers mais cohérents (IDs + stop reasons).
- Ne construis pas un système de tracing maison si tu ne peux pas l’opérer.
Checklist copy-paste
- [ ]
run_id,trace_id,request_id,user_idsur chaque event - [ ]
tool_call+tool_result(name, args_hash, latency, ok, error_class) - [ ]
stop_reason+ budgets en fin de run - [ ] policy de redaction (PII/secrets) + hashes par défaut
- [ ] alertes : tool calls/run, timeouts, validation fails
- [ ] une query “incident” par failure fréquente (dashboard / saved search)
Config par défaut (YAML)
logging:
ids:
run_id: required
trace_id: required
request_id: required
tool_calls:
enabled: true
store_args: false
store_args_hash: true
store_results: "sampled"
result_sample_rate: 0.01
pii:
redact_fields: ["email", "phone", "token", "authorization", "cookie"]
stop_reasons:
enabled: true
alerts:
tool_calls_per_run_p95: { warn: 10, critical: 20 }
timeout_rate: { warn: 0.02, critical: 0.05 }
validation_fail_rate: { warn: 0.02, critical: 0.05 }
Implémenter dans OnceOnly (optionnel)
# onceonly-python: governed audit logs + metrics
import os
from onceonly import OnceOnly
client = OnceOnly(api_key=os.environ["ONCEONLY_API_KEY"])
agent_id = "support-bot"
# Pull last 50 actions (includes args_hash + decisions)
for e in client.gov.agent_logs(agent_id, limit=50):
print(e.ts, e.tool, e.decision, e.args_hash, e.spend_usd, e.reason)
# Rollups for dashboards/alerts
m = client.gov.agent_metrics(agent_id, period="day")
print("spend_usd=", m.total_spend_usd, "blocked=", m.blocked_actions)
FAQ (3–5)
Utilisé par les patterns
Pannes associées
Gouvernance requise
Q: Je dois logger les args bruts des tools ?
A: Par défaut non. Log args_hash + champs safe. Active les args bruts seulement pendant un incident, puis coupe.
Q: Le champ le plus utile ?
A: Un run_id/trace_id stable sur chaque event.
Q: Comment détecter une boucle vite ?
A: Alerte sur tool_calls/run + répétitions (tool, args_hash). Va lire /failures/tool-spam.
Q: Il faut du distributed tracing ?
A: Si tes tools touchent d’autres services, oui. Trace IDs + spans tool, puis on reparle.
Pages liées (3–6 liens)
- Fondations: Appels d’outils d’un agent IA (avec code) · Un agent prêt pour la prod (guardrails + code)
- Pannes: Tool spam loops (failure mode + fixes + code) · Budget explosion (quand un agent brûle de l’argent) + fixes + code
- Gouvernance: Budget Controls pour agents IA (steps, temps, $) + Code · Kill switch (design + policy + code)
- Tests: Unit tests pour agents IA (déterministes, cheap, vraiment utiles)
- Stack prod: Stack de production pour agents IA (le truc entre ton agent et la catastrophe)