Baue deinen ersten AI Agent (sicher, mit Code)

Starte winzig: ein Tool, harte Budgets und ein Kill Switch. Der schnellste Weg zu einem Agent, der dich in Prod nicht blamiert.
Auf dieser Seite
  1. Das Problem
  2. Warum das in echten Systemen wichtig ist
  3. Das Playbook (20 Minuten, kein Heldentum)
  4. Was “ein Tool” wirklich heißt
  5. Schritt 0: wÀhle eine Aufgabe, die dich nicht ruiniert
  6. Schritt 1: Budgets (das, was aus einem Loop ein System macht)
  7. Schritt 2: logge den Action Trace
  8. Schritt 2.5: mach Tools deterministisch (sonst kannst du’s nicht debuggen)
  9. Code (minimal, aber production-tauglich)
  10. Mach’s real (ein kleines, lauffĂ€higes Skeleton)
  11. Schritt 3: eine kleine “Tool Policy” (sogar fĂŒr read-only)
  12. Tool contracts (make the model play inside the lines)
  13. Schritt 4: ein Rollout, der nicht weh tut
  14. Schritt 4.5: Cancellation (stoppe Kosten, wenn der User weg ist)
  15. Schritt 5: Erfolgsmetriken (damit du weißt, dass es besser wird)
  16. Wo du es laufen lÀsst (serverless vs worker)
  17. Hardening v2 (immer noch klein, immer noch sicher)
  18. Args‑Hashing + Dedupe
  19. Add per-tool budgets
  20. Add a kill switch
  21. Add a “stop reason”
  22. Das erste Write‑Tool (wie du’s nicht verkackst)
  23. Typische Fehler beim ersten Agent (wir haben’s auch gemacht)
  24. Echter Ausfall
  25. Wann du noch keinen Agent bauen solltest
  26. NĂ€chste Schritte

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)

  1. Nimm ein einziges read-only Tool (web.search oder kb.search).
  2. Setze Budgets: steps + time (+ $ wenn du’s kannst).
  3. Logge jeden Tool Call (Name, Args-Hash, Dauer, Status).
  4. Erkenne Loops ĂŒber wiederholte Tool-Args.
  5. 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.request Tool mit vollem Internet
  • es gibt kein run_shell Tool “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:

PYTHON
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 impl
JAVASCRIPT
export 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)

PYTHON
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"
JAVASCRIPT
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:

PYTHON
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"
JAVASCRIPT
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:

PYTHON
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}
JAVASCRIPT
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:

  1. Nur intern (du und eine Person)
  2. Read-only (keine Write‑Tools)
  3. Canary (kleiner Traffic‑Slice)
  4. 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.search max 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:

  1. make it a separate tool (don’t reuse read tool name)
  2. add idempotency keys
  3. require approval by default
  4. 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.search und endest bei http.request plus 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

Nicht sicher, ob das dein Fall ist?

Agent gestalten ->
⏱ 13 Min. Lesezeit ‱ Aktualisiert MĂ€r, 2026Schwierigkeit: ★★☆
Integriert: Production ControlOnceOnly
Guardrails fĂŒr Tool-Calling-Agents
Shippe dieses Pattern mit Governance:
  • Budgets (Steps / Spend Caps)
  • Tool-Permissions (Allowlist / Blocklist)
  • Kill switch & Incident Stop
  • Idempotenz & Dedupe
  • Audit logs & Nachvollziehbarkeit
Integrierter Hinweis: OnceOnly ist eine Control-Layer fĂŒr Production-Agent-Systeme.
Autor

Diese Dokumentation wird von Engineers kuratiert und gepflegt, die AI-Agenten in der Produktion betreiben.

Die Inhalte sind KI-gestĂŒtzt, mit menschlicher redaktioneller Verantwortung fĂŒr Genauigkeit, Klarheit und Produktionsrelevanz.

Patterns und Empfehlungen basieren auf Post-Mortems, Failure-Modes und operativen Incidents in produktiven Systemen, auch bei der Entwicklung und dem Betrieb von Governance-Infrastruktur fĂŒr Agenten bei OnceOnly.