Problème (ce qui casse en premier)
En dev, ton agent “marche”.
Puis tu changes un truc boring :
- une clé de schéma de tool
- des defaults de retry/backoff
- une condition d’arrêt
- la version du modèle
Et d’un coup la prod ressemble à :
- 3× plus de tool calls
- 2× plus de coût du jour au lendemain
- des runs qui ne font jamais
finishet finissent en “stop: budget”
Si tu ne peux pas reproduire un run de façon déterministe, tu n’as pas un bug — tu fais de l’archéologie.
Pourquoi ça casse en prod
Un agent casse différemment du code normal, parce qu’il est piloté par :
- un planner probabiliste (le modèle)
- une boucle runtime (ton orchestration)
- des side effects (tools)
- de l’instabilité externe (429/5xx, réponses partielles, timeouts)
Beaucoup d’équipes “testent” le prompt. Pas suffisant. Il faut tester le contrat de la boucle :
- input → actions → tool calls → trace → stop_reason
Si stop reasons et traces ne sont pas stables, rien ne l’est.
Diagramme : à quoi ressemble un agent testable en unit
Code réel : une boucle testable (Python + JS)
Le hack est boring : injection de dépendances. La boucle accepte deux trucs qu’elle ne contrôle pas :
llm.next_action(...)tools.call(...)
Le reste doit être déterministe et asserté.
from dataclasses import dataclass
from typing import Any, Dict, List, Protocol
@dataclass(frozen=True)
class Budget:
max_steps: int = 10
max_tool_calls: int = 10
class LLM(Protocol):
def next_action(self, state: Dict[str, Any]) -> Dict[str, Any]: ...
class Tools(Protocol):
def call(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]: ...
def run_agent(task: str, *, llm: LLM, tools: Tools, budget: Budget) -> Dict[str, Any]:
trace: List[Dict[str, Any]] = []
tool_calls = 0
state: Dict[str, Any] = {"task": task, "notes": []}
for step in range(budget.max_steps):
action = llm.next_action(state)
trace.append({"step": step, "action": action})
if action.get("type") == "finish":
return {"output": action.get("answer", ""), "trace": trace, "stop_reason": "finish"}
if action.get("type") != "tool":
return {"output": "", "trace": trace, "stop_reason": "invalid_action"}
tool_calls += 1
if tool_calls > budget.max_tool_calls:
return {"output": "", "trace": trace, "stop_reason": "max_tool_calls"}
obs = tools.call(action["tool"], action.get("args", {}))
trace.append({"step": step, "observation": obs, "tool": action["tool"]})
state["notes"].append(obs)
return {"output": "", "trace": trace, "stop_reason": "max_steps"}
# --- unit test (pytest style) ---
class FakeLLM:
def __init__(self):
self.n = 0
def next_action(self, state):
self.n += 1
if self.n == 1:
return {"type": "tool", "tool": "http.get", "args": {"url": "https://example.com"}}
return {"type": "finish", "answer": "ok"}
class FakeTools:
def __init__(self):
self.calls = []
def call(self, name, args):
self.calls.append((name, args))
return {"ok": True, "status": 200, "body": "hello"}
def test_unit_loop_contract():
out = run_agent(
"fetch once and finish",
llm=FakeLLM(),
tools=FakeTools(),
budget=Budget(max_steps=5, max_tool_calls=3),
)
assert out["stop_reason"] == "finish"
assert len(out["trace"]) >= 2export function runAgent(task, { llm, tools, budget }) {
const trace = [];
let toolCalls = 0;
const state = { task, notes: [] };
for (let step = 0; step < budget.maxSteps; step++) {
const action = llm.nextAction(state);
trace.push({ step, action });
if (action?.type === "finish") {
return { output: action.answer ?? "", trace, stop_reason: "finish" };
}
if (action?.type !== "tool") {
return { output: "", trace, stop_reason: "invalid_action" };
}
toolCalls += 1;
if (toolCalls > budget.maxToolCalls) {
return { output: "", trace, stop_reason: "max_tool_calls" };
}
const obs = tools.call(action.tool, action.args || {});
trace.push({ step, tool: action.tool, observation: obs });
state.notes.push(obs);
}
return { output: "", trace, stop_reason: "max_steps" };
}
// --- unit test (jest style) ---
test("unit loop contract", () => {
const llm = {
n: 0,
nextAction() {
this.n += 1;
if (this.n === 1) return { type: "tool", tool: "http.get", args: { url: "https://example.com" } };
return { type: "finish", answer: "ok" };
},
};
const tools = { calls: [], call(name, args) { this.calls.push([name, args]); return { ok: true, status: 200 }; } };
const out = runAgent("fetch once and finish", {
llm,
tools,
budget: { maxSteps: 5, maxToolCalls: 3 },
});
expect(out.stop_reason).toBe("finish");
expect(tools.calls.length).toBe(1);
});Incident réel (celui qui fait mal)
On a déjà changé un default de retry dans un wrapper de tool partagé. Rien n’a “crash”.
Mais sur une route chargée :
- tool calls/run : 8 → 16
- impact coût : +~$900/jour (tokens + crédits tools)
- astreinte : ~3 heures à prouver que ce n’était pas “le modèle bizarre”
Le fix n’était pas un meilleur prompt. Le fix, c’était un unit test qui vérifie :
- un bound sur le nombre de tool calls/run
- pas de drift de la taxonomie des stop reasons
- pas de double retry (agent retry + tool retry = storm)
Compromis
- Les unit tests ne prouvent pas que le modèle est “smart”. Ils prouvent que ta boucle est safe.
- Les stubs déterministes cachent la flakiness des tools (c’est pour ça qu’on fait du replay).
- Tu vas écrire plus de code boring. Tu vas aussi pager moins.
Quand NE PAS faire ça
Ne “unit test” pas la qualité d’un prompt comme si c’était une fonction déterministe. Si le but est style/ton : sampling + evals.
Unit‑teste :
- budgets
- allowlists de tools
- stop reasons
- validation du schéma d’action
- comportement d’idempotence
Checklist (copier-coller)
- [ ] Injecter
llmettoolsvia interfaces (pas de globals). - [ ] Assert sur
stop_reasondans chaque test. - [ ] Assert sur les tool calls : count + ordre + hash des args (pas les args bruts si sensibles).
- [ ] Tester les “bad paths” : action invalide, tool error, arrêt budget.
- [ ] Un golden test par incident prod (oui).
Config safe par défaut (YAML)
agent_tests:
budgets:
max_steps: 25
max_tool_calls: 12
invariants:
stop_reason_required: true
action_schema_strict: true
tool_allowlist_required: true
golden_tasks:
- id: "fetch_once"
task: "Fetch https://example.com and summarize in 3 bullets."
expect_stop_reason: "finish"
max_tool_calls: 2
replay:
enabled: true
mode: "record_then_replay"
store: ".agent-replays/"
FAQ (3–5)
Utilisé par les patterns
Pannes associées
Gouvernance requise
Q: Ce n’est pas juste du mocking du modèle ?
A: Si — volontairement. Les unit tests servent à tester le contrat de ta boucle : budgets, gateway tools, stop reasons, forme de la trace.
Q: Sur quoi faut-il faire des asserts ?
A: Stop reason, nombre d’appels tools, décisions d’allowlist, shape de la trace. Pas le texte exact de sortie.
Q: Comment tester la flakiness des tools ?
A: Record/replay de fixtures (ou tests d’intégration en sandbox). Les unit tests doivent rester déterministes.
Q: J’ai des unit tests : je dois quand même faire des evals ?
A: Oui. Les unit tests évitent des incidents. Les evals détectent la dérive de qualité. Deux classes de failures différentes.
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
- Stack prod: Stack de production pour agents IA (le truc entre ton agent et la catastrophe)