Das Problem
Dein erster Agent wird versuchen, alles auf einmal zu machen.
Er wird im Web herumklicken, in Datenbanken schreiben, âConfigs optimierenâ und sich generell benehmen wie ein Praktikant mit Root-Rechten.
Fang nicht so an.
Starte mit einem Tool, read-only, plus Budgets und Logs.
Warum das in echten Systemen wichtig ist
Beim ersten Release mit Tool Calling wirst du entdecken:
- fehlende Timeouts
- fehlende Idempotenz
- Logs, die âda sindâ, aber dir nichts erklĂ€ren
Besser du findest das mit einem Tool als mit zwölf.
Das Playbook (20 Minuten, kein Heldentum)
- Nimm ein einziges read-only Tool (
web.searchoderkb.search). - Setze Budgets: steps + time (+ $ wenn duâs kannst).
- Logge jeden Tool Call (Name, Args-Hash, Dauer, Status).
- Erkenne Loops ĂŒber wiederholte Tool-Args.
- FĂŒge noch keine âsendâ oder âwriteâ Tools hinzu.
Was âein Toolâ wirklich heiĂt
âEin Toolâ ist nicht âeine Tool-Funktion plus geheimer Escape Hatchâ.
Es heiĂt:
- du kannst auf die Allowlist zeigen und sie an einer Hand abzÀhlen
- es gibt kein generisches
http.requestTool mit vollem Internet - es gibt kein
run_shellTool âzum Debuggenâ
Wenn dein erster Agent beliebige URLs abrufen kann, hast du die sichere Phase schon ĂŒbersprungen.
Schritt 0: wÀhle eine Aufgabe, die dich nicht ruiniert
Gute erste Aufgaben:
- âfinde 5 relevante Docs in unserer KBâ
- âfasse aktuelle Incidents aus dem internen Postmortem-Ordner zusammenâ
- âentwirf eine Antwort mit bekannten Templatesâ
Schlechte erste Aufgaben:
- âbrowse das Web bis du dir sicher bistâ
- âfix die Prod-Configâ
- âfĂŒhr Commands auf Servern ausâ
Dein erster Agent sollte langweilig und billig sein.
Schritt 1: Budgets (das, was aus einem Loop ein System macht)
Budgets sind kein ânice to haveâ. Sie sind der Unterschied zwischen:
- einem Request, der stoppt
- einem Request, der weiter bezahlt
Wir setzen mindestens:
- max steps
- max seconds
Wenn du $-Kosten schĂ€tzen kannst, fĂŒge es frĂŒh hinzu. Es zahlt sich aus.
Schritt 2: logge den Action Trace
Du brauchst am ersten Tag kein perfektes Tracing-System. Du brauchst genug, um zu beantworten:
- welche Tools hat er aufgerufen?
- mit welchen Argumenten?
- wie lange hat jeder Call gedauert?
- warum hat er gestoppt?
Wenn du das nicht beantworten kannst, kannst du es nicht shippen.
Schritt 2.5: mach Tools deterministisch (sonst kannst duâs nicht debuggen)
Dein Agent ist ein Loop. Loops sind schon schwer genug. Jetzt nimm Tools, die jedes Mal andere Ergebnisse liefern (Timeouts, flaky APIs, sich Ă€ndernde Seiten). GlĂŒckwunsch: du hast ein nichtâdeterministisches System gebaut und wirst es um 03:00 debuggen.
Der Trick, den wir frĂŒh nutzen: record/replay. FĂŒr einen Run zeichnest du auf:
- Tool-Name
- Args-Hash
- Response (oder Error)
Dann kannst du dieselbe âWeltâ abspielen und testen:
- Prompt-Ănderungen
- Model-Ănderungen
- Anpassungen am Loop Guard
Minimaler Konzept-Code:
class TapeTools(SafeTools):
def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
super().__init__(allow=allow, impl=impl)
self.tape = tape if tape is not None else []
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
try:
out = super().call(name, args=args, request_id=request_id)
self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
return out
except Exception as e:
self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
raise
def replay(tape: list[dict]):
# Replace real tools with deterministic playback.
i = 0
def impl(name: str, **args):
nonlocal i
item = tape[i]
i += 1
assert item["tool"] == name
if not item["ok"]:
raise RuntimeError(item["err"])
return item["out"]
return implexport class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
super({ allow, impl });
this.tape = tape || [];
}
async call(name, { args, requestId }) {
try {
const out = await super.call(name, { args, requestId });
this.tape.push({ tool: name, args, ok: true, out });
return out;
} catch (e) {
this.tape.push({
tool: name,
args,
ok: false,
err: String(e && e.message ? e.message : e),
});
throw e;
}
}
}
export function replay(tape) {
let i = 0;
return async function call(name, args) {
const item = tape[i];
i += 1;
if (!item || item.tool !== name) throw new Error("tape mismatch");
if (!item.ok) throw new Error(item.err);
return item.out;
};
}Ist das glamourös? Nein. LĂ€sst es dich eine âgolden suiteâ aus 20 Tasks vor dem Release laufen lassen? Ja. So erfĂ€hrst du von Regressionen, bevor echte Kunden sie finden.
Code (minimal, aber production-tauglich)
from dataclasses import dataclass
import time
from typing import Any
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
class Tools:
def __init__(self):
self.allow = {"web.search"}
def call(self, name: str, *, args: dict[str, Any]) -> Any:
if name not in self.allow:
raise RuntimeError(f"tool not allowed: {name}")
return tool_impl(name, args=args) # (pseudo)
def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
started = time.time()
last = None
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return "stopped: time budget exceeded"
action = llm_pick_action(question) # (pseudo): either {"tool":..., "args":...} or {"finish":...}
if action.get("finish"):
return action["finish"]
key = f"{action['tool']}:{action['args']}"
if key == last:
return "stopped: loop guard (same call twice)"
last = key
obs = tools.call(action["tool"], args=action["args"])
question = update_state(question, action, obs) # (pseudo)
return "stopped: step budget exceeded"export class Tools {
constructor() {
this.allow = new Set(["web.search"]);
}
async call(name, { args }) {
if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
return toolImpl(name, { args }); // (pseudo)
}
}
export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) {
return "stopped: time budget exceeded";
}
const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
if (action.finish) return action.finish;
const key = String(action.tool) + ":" + JSON.stringify(action.args);
if (key === last) return "stopped: loop guard (same call twice)";
last = key;
const obs = await tools.call(action.tool, { args: action.args });
question = updateState(question, action, obs); // (pseudo)
}
return "stopped: step budget exceeded";
}Machâs real (ein kleines, lauffĂ€higes Skeleton)
Das Minimalbeispiel oben ist absichtlich kurz. Hier ist ein etwas vollstĂ€ndigeres âSingleâFileâ-Skeleton, das du tatsĂ€chlich hinter eine API hĂ€ngen könntest:
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
def args_hash(args: dict[str, Any]) -> str:
raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:12]
class LoopGuard(RuntimeError):
pass
class Monitor:
def __init__(self, *, max_repeat: int = 2):
self.max_repeat = max_repeat
self.counts: dict[str, int] = {}
def mark(self, tool: str, args: dict[str, Any]) -> None:
key = f"{tool}:{args_hash(args)}"
self.counts[key] = self.counts.get(key, 0) + 1
if self.counts[key] >= self.max_repeat:
raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")
class SafeTools:
def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
self.allow = allow
self.impl = impl
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
if name not in self.allow:
raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
started = time.time()
try:
return self.impl[name](**args)
finally:
ms = int((time.time() - started) * 1000)
print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")
def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
request_id = uuid.uuid4().hex
started = time.time()
monitor = Monitor(max_repeat=2)
state = {"question": question, "notes": []}
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return f"[{request_id}] stopped: time budget"
action = llm_pick_action(state) # (pseudo)
if action.get("finish"):
return action["finish"]
tool = action["tool"]
args = action["args"]
monitor.mark(tool, args)
obs = tools.call(tool, args=args, request_id=request_id)
state = update_state(state, action, obs) # (pseudo)
return f"[{request_id}] stopped: step budget"import crypto from "node:crypto";
function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}
export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}
export class LoopGuard extends Error {}
export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
this.maxRepeat = maxRepeat;
this.counts = new Map();
}
mark(tool, args) {
const key = tool + ":" + argsHash(args);
const next = (this.counts.get(key) || 0) + 1;
this.counts.set(key, next);
if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}
export class SafeTools {
constructor({ allow, impl }) {
this.allow = allow;
this.impl = impl;
}
async call(name, { args, requestId }) {
if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
const started = Date.now();
try {
return await this.impl[name](args);
} finally {
const ms = Date.now() - started;
console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
}
}
}
export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";
const action = await llmPickAction(state); // (pseudo)
if (action.finish) return action.finish;
const tool = action.tool;
const args = action.args;
monitor.mark(tool, args);
const obs = await tools.call(tool, { args, requestId });
state = updateState(state, action, obs); // (pseudo)
}
return "[" + requestId + "] stopped: step budget";
}Das ist nicht âdas perfekte Frameworkâ. Es ist ein kleiner, begrenzter Loop mit:
- allowlisted tools
- budgets
- loop guard
- basic logs
Das reicht, um zu lernen, ohne Production anzuzĂŒnden.
Schritt 3: eine kleine âTool Policyâ (sogar fĂŒr read-only)
Selbst read-only Tools sollten explizit allowlisted sein. Das zwingt dich, die OberflÀche sichtbar zu machen.
Du kannst die Spielzeug-Tools-Klasse oben zu etwas ausbauen, das du betreiben kannst:
- add args hashing
- add timing
- add error classification (retryable vs fatal)
Ăberâengineere es nicht. Mach es einfach debuggable.
Tool contracts (make the model play inside the lines)
The model will happily invent args:
- wrong field names
- huge strings
- weird nested objects
Wenn dein ToolâWrapper âany dictâ akzeptiert, wird dein Runtime zur ValidationâLayer. HeiĂt: du debugst ValidationâErrors in Production.
Starte simpel:
- define an args shape per tool
- validate types and bounds
- reject unknown fields
Konzeptbeispiel:
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("q", "")).strip()
if not q or len(q) > 200:
raise ValueError("invalid q")
k = int(args.get("k", 5))
if k < 1 or k > 8:
raise ValueError("invalid k")
return {"q": q, "k": k}export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");
const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");
return { q, k };
}Das sieht dumm aus. Es verhindert aber, dass dein Agent web.search mit einem 10.000âZeichen PromptâDump aufruft.
Und es gibt dir einen sauberen Error, den du als fatal behandeln kannst (nicht ewig retryen).
Schritt 4: ein Rollout, der nicht weh tut
So shippen wir die erste Version:
- Nur intern (du und eine Person)
- Read-only (keine WriteâTools)
- Canary (kleiner TrafficâSlice)
- Kill switch (OperatorâStop)
Wenn du Canary ĂŒberspringst, looped es das erste Mal bei deinem wichtigsten Kunden.
Schritt 4.5: Cancellation (stoppe Kosten, wenn der User weg ist)
Wenn du eine UI hast, brechen Leute Requests ab. Wenn du Cancellation nicht durchreichst, lÀuft der Agent trotzdem weiter. Er ruft weiter Tools auf. Er verbrennt weiter Tokens. Er erzeugt Output, den niemand liest.
Mach Cancellation zu einem StopâReason erster Klasse:
- Client disconnect â aktuellen Run abbrechen
- Abort muss in ModelâCalls und ToolâCalls propagieren
- logge
stop_reason = client_cancel
Selbst eine grobe Implementierung ist besser als âweiter zahlen bis das Budget stirbtâ.
Schritt 5: Erfolgsmetriken (damit du weiĂt, dass es besser wird)
WĂ€hle 3 Zahlen und tracke sie:
- Completion Rate (hat es beendet?)
- durchschnittliche Kosten pro Run ($ oder Tokens/ToolâCredits)
- p95 Runtime
Wenn Completion hoch ist, aber die Kosten explodieren, sind deine Budgets falsch. Wenn die Kosten niedrig sind, aber Completion mies ist, ist dein ToolâContract wahrscheinlich schlecht.
Wo du es laufen lÀsst (serverless vs worker)
Dein erster Agent lÀuft wahrscheinlich als API Route. Das ist ok⊠bis es das nicht mehr ist.
Zwei typische Fallen:
- cold starts: lange Cold Starts + ein Loop = schlechter UX
- time limits: serverless Plattformen haben Execution Limits; dein â90âSekundenâBudgetâ passt evtl. nicht
Wir splitten es oft so:
- Request kommt rein (schnell)
- AgentâRun lĂ€uft im Worker (mit Budgets)
- UI pollt / streamt Progress
Wenn das nach âzu viel Architekturâ klingt, halte es simpel: setze Budgets so niedrig, dass deine Plattform den Run wirklich stoppen kann. Der schnellste Weg zu 500ern ist ein langer Loop im Request Handler ohne Guardrails.
Hardening v2 (immer noch klein, immer noch sicher)
Wenn die âein Toolâ-Version stabil ist, fĂŒgen wir als NĂ€chstes hinzu:
ArgsâHashing + Dedupe
Wenn der Agent dasselbe Tool mit denselben Args wiederholt aufruft: stop. Das fÀngt die einfachsten Infinite Loops ab.
Add per-tool budgets
Globale Budgets stoppen den Run. PerâToolâBudgets verhindern, dass eine flaky Dependency den ganzen Run frisst.
Example:
web.searchmax 3 calls
Add a kill switch
Auch fĂŒr einen winzigen Agent. Wenn es in Production lĂ€uft, willst du es ohne Deploy stoppen können.
Add a âstop reasonâ
Mach den StopâReason in Logs und UI sichtbar:
- time budget
- step budget
- loop detected
- tool denied
Das verhindert, dass User Refresh hÀmmern.
Das erste WriteâTool (wie duâs nicht verkackst)
Wenn du endlich ein WriteâTool hinzufĂŒgst:
- make it a separate tool (donât reuse read tool name)
- add idempotency keys
- require approval by default
- log an audit event for the intended write
Wenn du Idempotenz ĂŒberspringst, shipst du Duplicate Writes. Das ist nicht âvielleichtâ. Das ist âwannâ.
Typische Fehler beim ersten Agent (wir habenâs auch gemacht)
- âNur noch ein Toolâ-Creep. Du startest mit
web.searchund endest beihttp.requestplus AdminâToken. - Retries ohne Limits. Du âbehandelst Errorsâ und baust dir aus Versehen einen Infinite Loop.
- Kein StopâReason in der UI. User refreshen, weil sie nicht wissen was passiert ist, und du zahlst doppelt.
- Nur die finale Antwort loggen. Dann kannst du nicht erklĂ€ren, warum es das Tool 17âmal aufgerufen hat.
Wenn du diese vier vermeidest, bist du schon weiter als die meisten âAgent Demosâ. Und: lass es gegen ein FakeâTool laufen, das einmal failt. Wenn der Loop das ohne RetryâSpam ĂŒberlebt: ok. Wennâs schmilzt: fix es jetzt. Ernsthaft, jetzt.
Echter Ausfall
Wir haben gesehen, wie ein âFirst Agentâ mit 0 Budgets live ging. Er bekam einen weird Prompt, fing an zu browsen und blieb hĂ€ngen.
Ergebnis:
- User wartete ~2 Minuten
- Agent verbrannte Geld
- alle gaben dem Modell die Schuld
Es war nicht das Modell. Es waren fehlende Budgets.
Wann du noch keinen Agent bauen solltest
- Wenn ein Script reicht, schreib das Script.
- Wenn ein Workflow reicht, bau den Workflow.
- Wenn du keine ToolâPermissions + Audit Logs liefern kannst, shippe kein Tool Calling.
NĂ€chste Schritte
- Start: Was ist ein AI Agent?
- Pattern: Research Agent
- Failure Mode: Infinite Loop