Agents single-step (Anti-Pattern) + Correctifs + Code

  • Repère le piège avant qu’il arrive en prod.
  • Vois ce qui casse quand le modèle est sûr de lui.
  • Copie des defaults sûrs : permissions, budgets, idempotence.
  • Sache quand il ne faut pas d’agent.
Signaux de détection
  • Tool calls/run explosent (ou se répètent avec args hash).
  • Spend/tokens montent sans amélioration des outputs.
  • Retries passent de rares à constants (429/5xx).
Un 'agent single-step' est souvent une completion collée à des effets secondaires. Pourquoi ça casse en production, et à quoi ressemble la boucle minimale exploitable.
Sur cette page
  1. Problème (d’abord)
  2. Pourquoi ça casse en production
  3. 1) Pas de boucle de feedback = pas de recovery
  4. 2) Budgets et stop reasons arrivent trop tard
  5. 3) Tool output ignoré ou mal utilisé
  6. 4) Les writes deviennent un pile ou face
  7. Quand single-step suffit (oui, parfois)
  8. Règle de routage (celle qui te sauve)
  9. Chemin de migration (single-step → boucle)
  10. Implémentation (vrai code)
  11. Failure evidence (à quoi ça ressemble)
  12. Example failure case (composite)
  13. 🚨 Incident: Premature ticket closure
  14. Compromis
  15. Quand NE PAS l’utiliser
  16. Checklist (copier-coller)
  17. Safe default config
  18. FAQ
  19. Related pages
  20. Production takeaway
  21. What breaks without this
  22. What works with this
  23. Minimum to ship
En bref

En bref: Les “agents” single-step (un call modèle → exécuter → terminé) n’ont pas d’espace pour la validation, pas de boucle de recovery, pas de stop reasons. Si tu as des tools ou des effets secondaires (changements d'état), tu as besoin d’une boucle bornée + gouvernance.

Tu vas apprendre : Quand single-step est ok • la règle de routage minimale • une interface de boucle bornée • stop reasons • un smell test d’incident

Concrete metric

Single-step : validation nulle part • recovery dans des prompts “malins” • writes trop tôt
Runner en boucle : budgets • tool gateway • stop reasons • safe-mode
Impact : moins d’incidents + des failures debuggables au lieu de “execute & pray”


Problème (d’abord)

Quelqu’un dit : “on a construit un agent”.

Le code :

  1. appeler le modèle une fois
  2. parser un tool call
  3. exécuter
  4. retourner ce qui s’est passé
Truth

Ce n’est pas un agent. C’est un appel de fonction avec des arguments imprévisibles.

En démo, c’est rapide. En prod, ça casse parce que les systèmes sont bruyants et tu as besoin de feedback + contrôle.


Pourquoi ça casse en production

Failure analysis

1) Pas de boucle de feedback = pas de recovery

Timeouts, réponses partielles, 429, données stale, schema drift. En single-step, tu n’as nulle part où mettre la logique de recovery, donc elle finit “dans le prompt” et tu l’exécutes aveuglément.

2) Budgets et stop reasons arrivent trop tard

“Ça ne loop pas, donc pas besoin de budgets.”

Puis tu ajoutes des retries dans les tools, des retries dans le call modèle, et un deuxième tool call “au cas où”.

Truth

Tu as réinventé des boucles sans gouvernance.

3) Tool output ignoré ou mal utilisé

Un tool call, un output, et ensuite… tu le retournes. Pas de validation, pas d’invariants, pas de “on a vraiment résolu ?”.

4) Les writes deviennent un pile ou face

Single-step autorise une write immédiatement. Pas de policy “read first, write later”. Blast radius dès le début.


Quand single-step suffit (oui, parfois)

Single-step est ok si tout est vrai :

  • pas de tools (ou tools strictement read-only)
  • pas d’effets secondaires (changements d'état)
  • output utilisé comme texte, pas comme commande
  • output validable via schéma strict (ou non nécessaire)

Framework de décision : single-step est OK seulement si tout est vrai :

  • ✅ Read-only (pas d’effets secondaires)
  • ✅ Output fortement typé (ou pas de tools)
  • ✅ Failure “cheap” (faible blast radius)
  • ✅ Pas besoin de retry / boucle de recovery

Sinon, route vers un runner en boucle.


Règle de routage (celle qui te sauve)

Si la prochaine étape peut avoir des effets secondaires, single-step n’est pas autorisé.

TEXT
if action.has_side_effects:
  run_looped_runner()
else:
  run_single_step()

Chemin de migration (single-step → boucle)

Voilà ce que les équipes ship, et pourquoi ça casse :

PYTHON
# v1: single-step (fast, unsafe)
result = tool(llm_decide(task))  # damage can happen before validation

# v2: add validation (still unsafe if the tool already ran)
result = tool(llm_decide(task))
if not valid(result):
    raise RuntimeError("too late: side effect already happened")

# v3: bounded loop (safe enough to operate)
for step in range(max_steps):
    action = llm_decide(state)
    if action.kind == "tool":
        obs = tool_gateway.call(action.name, action.args)  # policy + budgets
        state = update(state, obs)
    else:
        return action.final_answer

Implémentation (vrai code)

Le pattern garde single-step dans le safe (read-only) et route le reste vers un runner borné.

PYTHON
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal


@dataclass(frozen=True)
class Budgets:
  max_steps: int = 25
  max_tool_calls: int = 12
  max_seconds: int = 60


class Stopped(RuntimeError):
  def __init__(self, stop_reason: str):
      super().__init__(stop_reason)
      self.stop_reason = stop_reason


def is_side_effecting(action: dict[str, Any]) -> bool:
  # Production: decide side-effect class in code, not by prompt vibes.
  return action.get("kind") in {"write", "payment", "email", "ticket_close"}


def run_single_step(task: str, *, llm) -> dict[str, Any]:
  """
  Safe single-step: no tools, no writes.
  This is a completion, not an agent.
  """
  text = llm.text({"task": task, "style": "direct"})  # (pseudo)
  return {"status": "ok", "stop_reason": "single_step", "answer": text}


def run_looped(task: str, *, budgets: Budgets, runner) -> dict[str, Any]:
  """
  Delegate to a bounded runner that has:
  - tool gateway
  - output validation
  - stop reasons
  """
  return runner.run(task, budgets=budgets)  # (pseudo)


def route(task: str, *, llm, budgets: Budgets, runner) -> dict[str, Any]:
  # First decision is read-only: are we about to do anything with side effects?
  action = llm.json(
      {
          "task": task,
          "rule": "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
          "examples": [{"task": "Summarize this text", "kind": "read_only"}, {"task": "Close ticket #123", "kind": "side_effects"}],
      }
  )  # (pseudo)

  if action.get("kind") == "side_effects":
      return run_looped(task, budgets=budgets, runner=runner)

  return run_single_step(task, llm=llm)
JAVASCRIPT
export class Stopped extends Error {
constructor(stopReason) {
  super(stopReason);
  this.stop_reason = stopReason;
}
}

export function runSingleStep(task, { llm }) {
// Safe single-step: no tools, no writes.
return llm.text({ task, style: "direct" }).then((text) => ({ status: "ok", stop_reason: "single_step", answer: text })); // (pseudo)
}

export function runLooped(task, { budgets, runner }) {
// Delegate to a bounded runner with tool gateway + stop reasons.
return runner.run(task, { budgets }); // (pseudo)
}

export async function route(task, { llm, budgets, runner }) {
const action = await llm.json({
  task,
  rule: "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
  examples: [
    { task: "Summarize this text", kind: "read_only" },
    { task: "Close ticket #123", kind: "side_effects" },
  ],
}); // (pseudo)

if (action.kind === "side_effects") return await runLooped(task, { budgets, runner });
return await runSingleStep(task, { llm });
}
Note

Ça n’a pas l’air “agentic”. Ça a l’air opérable. C’est le but.


Failure evidence (à quoi ça ressemble)

Les failures single-step = “une mauvaise décision avec blast radius immédiat”.

JSON
{"run_id":"run_44a1","step":0,"event":"tool_call","tool":"ticket.close","args_hash":"b5d0aa","decision":"allow"}
{"run_id":"run_44a1","step":0,"event":"tool_result","tool":"ticket.close","ok":true}
{"run_id":"run_44a1","step":0,"event":"stop","reason":"success","note":"single-step"}

Example failure case (composite)

Incident

🚨 Incident: Premature ticket closure

System: agent single-step “close resolved tickets”
Duration: moins d’1 heure
Impact: 18 tickets fermés à tort


What happened

ticket.close a été exécuté immédiatement sur un snippet. Sarcasme lu comme “resolved”.

Le pire : aucune explication. Pas d’état de boucle, pas de stop reasons utiles, pas d’endroit pour valider.


Fix

  1. router les actions avec side effects vers un runner en boucle
  2. policy de tool gateway + audit logs
  3. approbations pour ticket.close

Compromis

Trade-offs
  • Une boucle = plus de code qu’un call modèle.
  • Plus d’étapes peut ajouter de la latence (budgets).
  • Il faut de l’observabilité (de toute façon).

Quand NE PAS l’utiliser

Don’t
  • Si tu as un transform déterministe, ne l’appelle pas agent.
  • Si tu as besoin de feedback tool + recovery, single-step sera fragile.
  • Si tu ne peux pas logger traces + stop reasons, fixe l’observabilité d’abord.

Checklist (copier-coller)

Production checklist
  • [ ] si side effects → runner en boucle obligatoire
  • [ ] router les tasks side-effecting hors single-step
  • [ ] budgets (steps, tool calls, seconds)
  • [ ] tool gateway (deny by default)
  • [ ] validation avant writes
  • [ ] stop reasons retournées (et loggées)
  • [ ] approbations pour writes

Safe default config

YAML
routing:
  allow_single_step_only_when: "read_only"
budgets:
  max_steps: 25
  max_tool_calls: 12
  max_seconds: 60
tools:
  allow: ["search.read", "kb.read", "http.get"]
writes:
  require_approval: true
stop_reasons:
  return_to_user: true

FAQ

FAQ
Les agents single-step sont parfois ok ?
Oui — si pas de tools et pas de side effects. Dans ce cas, c’est une completion, pas un agent.
Une boucle, ce n’est pas plus lent ?
Parfois. En gros : single-step ≈ 1 call LLM + 1 call tool ; une boucle 3 steps ≈ 3 calls LLM + 2 calls tool. C’est souvent ~3× la latence. Les budgets bornent le worst case — et la vitesse ne sert à rien si c’est faux ou inopérable.
Gouvernance minimale pour une boucle ?
Step limits, budgets de tool calls, policy deny-by-default, stop reasons.
Où trouver un bon pattern de boucle ?
Commence avec un runner ReAct borné et un tool gateway. N’invente pas ta boucle sans budgets et traces.

Related

Production takeaway

Production takeaway

What breaks without this

  • ❌ writes avant validation
  • ❌ “recovery” dans des prompts + retries
  • ❌ pas de stop reasons explicatives

What works with this

  • ✅ side effects → runner borné
  • ✅ budgets + tool gateway = contrôle
  • ✅ failures explicables (stop reasons + traces)

Minimum to ship

  1. Règle de routage (read-only peut être single-step; side effects non)
  2. Runner borné (budgets + stop reasons)
  3. Tool gateway (deny by default)
  4. Validation layer (avant writes)

Pas sur que ce soit votre cas ?

Concevez votre agent ->
⏱️ 8 min de lectureMis à jour Mars, 2026Difficulté: ★★★
Implémenter dans OnceOnly
Safe defaults for tool permissions + write gating.
Utiliser dans OnceOnly
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
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.