Action is proposed as structured data (tool + args).
Le problème (côté prod)
Les agents sont des loops. Les loops veulent continuer.
Sans step cap, “fini” veut dire : l’agent abandonne par hasard. En prod, il n’abandonne pas.
Pourquoi ça casse en prod
1) “It’ll stop when it’s done” n’est pas un plan
En prod tu as :
- tools flaky
- rate limits
- pannes partielles
- tâches ambiguës
Ambigu + tools + pas de cap = un log avec 700 calls.
2) Sans stop reason, c’est invisible
Si tu vois seulement “timeout”, tu ne sais pas que les steps ont explosé. Les stop reasons aident à debugger.
3) Enforce dans la run loop
Pas dans l’UI. Pas “la plupart du temps”. Dans la loop, à chaque step.
Exemple d’implémentation (code réel)
Un step guard minimal :
from dataclasses import dataclass
@dataclass(frozen=True)
class StepPolicy:
max_steps: int = 25
class StepExceeded(RuntimeError):
def __init__(self, stop_reason: str):
super().__init__(stop_reason)
self.stop_reason = stop_reason
def run(task: str, *, policy: StepPolicy) -> dict:
steps = 0
try:
while True:
steps += 1
if steps > policy.max_steps:
raise StepExceeded("max_steps")
action = llm_decide(task) # (pseudo)
if action.kind != "tool":
return {"status": "ok", "answer": action.final_answer, "steps": steps}
obs = call_tool(action.name, action.args) # (pseudo)
task = update(task, action, obs) # (pseudo)
except StepExceeded as e:
return {"status": "stopped", "stop_reason": e.stop_reason, "steps": steps}export class StepExceeded extends Error {
constructor(stopReason) {
super(stopReason);
this.stopReason = stopReason;
}
}
export function run(task, { maxSteps = 25 } = {}) {
let steps = 0;
try {
while (true) {
steps += 1;
if (steps > maxSteps) throw new StepExceeded("max_steps");
const action = llmDecide(task); // (pseudo)
if (action.kind !== "tool") return { status: "ok", answer: action.finalAnswer, steps };
const obs = callTool(action.name, action.args); // (pseudo)
task = update(task, action, obs); // (pseudo)
}
} catch (e) {
if (e instanceof StepExceeded) return { status: "stopped", stopReason: e.stopReason, steps };
throw e;
}
}Incident réel (avec chiffres)
Un agent devait “juste” collecter une liste de résultats.
Il n’avait :
- ni step cap
- ni loop detection
- et un tool retournait des résultats légèrement différents
Résultat :
- ~700 tool calls en un run
- ~18 minutes de runtime
- pas un gros coût (chance), mais rate limits + queue delay, oui
Fix :
- step cap + stop reason
- loop detection (args hash)
- dedupe/caching sur les mêmes queries
Compromis
- Les step limits stoppent parfois tôt. Mieux que non borné.
- Si tu mets trop bas, tu vas avoir plus de “stopped” → il faut une UX.
- Step limits sans time/cost budgets, c’est incomplet (mais déjà utile).
Quand NE PAS l’utiliser
- Honnêtement : utilise toujours un cap. Si tu ne veux pas de max_steps, il te faut d’autres budgets durs.
Checklist (copier-coller)
- [ ]
max_stepspar run - [ ] stop reason
max_stepsloggé + surfacé - [ ] step count dans les traces
- [ ] loop detection / no-progress stop
- [ ] combiné avec time + cost budgets
Config par défaut sûre (JSON/YAML)
step_limits:
max_steps: 25
stop_reasons:
surface_to_user: true
log: true
FAQ (3–5)
Utilisé par les patterns
Pannes associées
Gouvernance requise
Q: Je mets quoi comme max_steps ?
A: Commence à 25. Mesure les stops. Si ça stoppe souvent, c’est un problème de scope/outils/prompt, pas juste le chiffre.
Q: Max_steps suffit ?
A: Non. Ajoute aussi max_seconds, max_tool_calls, souvent max_usd.
Q: Quel stop reason name ?
A: Court et machine-friendly : max_steps. Tu vas vouloir des alertes et dashboards.
Pages liées (3–6 liens)
- Foundations: The ReAct loop explained · Planning vs reactive agents
- Failure: Infinite loop failure · Tool spam loops
- Governance: Budget controls · Kill switch design
- Production stack: Production agent stack