Kurzfazit: Single-Step-„Agents“ (ein Model-Call → ausführen → fertig) haben keinen Platz für Validation, keinen Recovery-Loop und keine stop reasons. Wenn du Tools oder Seiteneffekte (Zustandsänderungen) hast, brauchst du einen bounded Loop + Governance.
Du lernst: Wann Single-Step wirklich okay ist • die minimale Routing-Regel • ein bounded Loop Interface • stop reasons • ein Incident-Smell-Test
Single-Step: Validation hat keinen Platz • Recovery landet in „cleveren Prompts“ • Writes passieren zu früh
Looped runner: Budgets • Tool Gateway • stop reasons • safe-mode
Impact: weniger Incidents + debuggable Failures statt „execute & pray“
Problem (zuerst)
Jemand sagt: „wir haben einen Agenten gebaut“.
Der Code ist:
- Modell einmal aufrufen
- Tool Call parsen
- Ausführen
- Zurückgeben, was passiert ist
Das ist kein Agent. Das ist ein Function Call mit unvorhersehbaren Argumenten.
In einer Demo wirkt es schnell. In Production bricht es, weil reale Systeme noisy sind und du Feedback + Control brauchst.
Warum das in Production bricht
1) Kein Feedback Loop = kein Recovery
Timeouts, Partial Responses, 429s, stale data, Schema Drift. Single-Step hat keinen Ort für Recovery-Logik, also landen „Recovery“-Ideen im Prompt und werden blind ausgeführt.
2) Budgets und stop reasons kommen zu spät dazu
Teams sagen: „es kann nicht loopen, also brauchen wir keine Budgets.“
Dann kommen Retries in Tools, Retries im Model-Call und ein zweiter Tool Call „just in case“.
Du hast Loops ohne Governance neu erfunden.
3) Tool-Output wird ignoriert oder falsch benutzt
Ein Tool Call, ein Output, und dann… einfach zurückgeben. Ohne Validation, ohne Invariants, ohne „haben wir das Problem gelöst?“
4) Writes werden zum Coin Flip
Single-Step erlaubt Writes sofort. Es gibt keine „read first, write later“-Policy. Der Blast Radius kommt früh.
Wann Single-Step reicht (ja, manchmal)
Single-Step ist okay, wenn alles davon stimmt:
- keine Tools (oder Tools sind strikt read-only)
- keine Seiteneffekte (Zustandsänderungen)
- Output wird als Text genutzt, nicht als Command
- du kannst Output mit einer strict schema validieren (oder brauchst es nicht)
Decision-Framework: Single-Step ist nur okay, wenn alles true ist:
- ✅ Read-only (keine Seiteneffekte)
- ✅ strongly typed output (oder keine Tools)
- ✅ Failure ist billig (kleiner Blast Radius)
- ✅ kein Retry/Recovery-Loop nötig
Wenn eins davon falsch ist: route zu einem looped runner.
Hard routing rule (die dich rettet)
Wenn der nächste Schritt Seiteneffekte verursachen kann, ist Single-Step nicht erlaubt.
if action.has_side_effects:
run_looped_runner()
else:
run_single_step()
Migrationspfad (Single-Step → Loop)
So shippen Teams das oft — und darum bricht es:
# 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
Implementierung (echter Code)
Das Pattern hält Single-Step dort, wo es hingehört (safe, read-only), und routet alles andere in einen bounded Loop runner.
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)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 });
}Das sieht nicht „agentic“ aus. Es sieht operierbar aus. Genau das ist der Punkt.
Failure evidence (wie es aussieht, wenn es bricht)
Single-Step failt als „eine schlechte Entscheidung mit sofortigem Blast Radius“.
Ein Trace, der den Incident in 5 Zeilen erklärt:
{"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: Premature ticket closure
System: Single-Step „close resolved tickets“ Agent
Duration: unter 1 Stunde
Impact: 18 Tickets fälschlich geschlossen
What happened
Der Agent hat ticket.close sofort auf Basis eines Snippets ausgeführt. Sarcasm wurde als „resolved“ gelesen.
Der Worst Part: niemand konnte erklären warum. Kein Loop State, keine stop reasons, keine Gelegenheit zu validieren.
Fix
- side-effecting Actions zu einem looped runner routen
- Tool Gateway Policy + Audit Logs
- Approvals für
ticket.close
Abwägungen
- Ein Loop ist mehr Code als ein Model-Call.
- Mehr Steps können mehr Latenz bedeuten (Budgets helfen).
- Du brauchst Observability (aber du brauchtest sie sowieso).
Wann NICHT nutzen
- Wenn du wirklich nur einen deterministischen Transform hast, nenn es keinen Agenten.
- Wenn du Tool-Feedback und Recovery brauchst, ist Single-Step fragil.
- Wenn du keine Traces und stop reasons loggen kannst, fix Observability zuerst.
Checklist (Copy-Paste)
- [ ] Wenn du Seiteneffekte hast, brauchst du einen looped runner
- [ ] side-effecting Tasks weg von Single-Step routen
- [ ] Budgets hinzufügen (steps, tool calls, seconds)
- [ ] Tool Gateway nutzen (deny by default Allowlist)
- [ ] Tool-Outputs validieren, bevor du handelst
- [ ] stop reasons zurückgeben (und loggen)
- [ ] Approvals für Writes
Safe default config
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
Related pages
Production takeaway
What breaks without this
- ❌ Writes passieren vor Validation
- ❌ „Recovery“ lebt in Prompts und Tool-Retries
- ❌ keine stop reasons, die Verhalten erklären
What works with this
- ✅ Side effects routen zu einem bounded runner
- ✅ Budgets + Tool Gateway halten Runs kontrollierbar
- ✅ Failures sind erklärbar (stop reasons + traces)
Minimum to ship
- Routing-Regel (read-only kann single-step sein; side effects nicht)
- Bounded runner (budgets + stop reasons)
- Tool Gateway (deny by default)
- Validation layer (vor Writes)