cd ../back to blog
$Guide//June 4, 2026//8 min read

Eine KI-Agent-Schleife bauen: Tool-Nutzung mit vier Leitplanken

Baue eine lauffähige KI-Agent-Schleife auf dem OpenAI-kompatiblen Gateway von Brievio: hartes Iterationslimit, validierter Tool-Dispatch und ein Budget aus ehrlichen Token-Zahlen.

Ein Agent ist das, was du bekommst, wenn du Tool-Nutzung in eine Schleife packst. Ein Tool-Call beantwortet eine Frage; ein Agent ruft ein Tool auf, liest das Ergebnis, entscheidet über den nächsten Schritt und macht weiter, bis die Aufgabe wirklich erledigt ist — suchen, dann den Top-Treffer lesen, dann einen Preis nachschlagen, dann die Antwort schreiben. Der Mechanismus ist simpel, und es sind jedes Mal dieselben vier Takte: das Modell fordert ein Tool an, du führst es aus, du speist das Ergebnis zurück, du rufst das Modell erneut auf. Der schwierige Teil ist nicht die Schleife. Es sind die Leitplanken, die sie davon abhalten, ewig zu kreisen oder dir still und leise 40 $ auf einem einzigen Lauf zu berechnen.

Dieser Beitrag baut eine echte, lauffähige Agent-Schleife gegen https://api.brievio.com/v1 mit dem OpenAI-Python-SDK und umgibt sie dann mit den vier Kontrollen, die sie produktionsreif machen: einem harten Iterationslimit, einem validierten Tool-Dispatch, einem Kostenbudget pro Lauf, das aus ehrlichen Token-Zahlen gelesen wird, und sinnvollem Umgang mit den Fällen, in denen das Modell sich danebenbenimmt. Jedes Snippet läuft so, wie es ist; tausche claude-sonnet-4-6 gegen gemini-2.5-pro aus, und derselbe Code steuert ein anderes Modell.

Die Schleife, und warum sie eine Obergrenze braucht

Hier ist die ganze Maschine. Es ist die Tool-Nutzungs-Schleife, die du bereits kennst, mit einer Ergänzung, die alles verändert: for step in range(MAX_ITERS) statt while True.

agent_loop.py
# Die Agent-Schleife mit hartem Iterationslimit. Modell -> tool_calls -> ausführen ->
# Ergebnisse zurückspeisen -> wiederholen, bis das Modell in Prosa antwortet oder wir
# die Obergrenze erreichen. Das Limit ist der Unterschied zwischen "Agent" und "Rechnung außer Kontrolle".
from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

MAX_ITERS = 8   # die meisten Aufgaben sind in 2-4 Runden fertig; 8 ist großzügiger Spielraum.

def run_agent(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [
        {"role": "system", "content": "You are a helpful research agent. "
         "Use the tools when you need live data. Answer directly when you "
         "already know enough."},
        {"role": "user", "content": question},
    ]

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = resp.choices[0].message

        # Kein Tool angefordert -> das ist die finale Antwort. Fertig.
        if not msg.tool_calls:
            return msg.content

        # Den Assistant-Turn GENAU so anhängen, wie er zurückkam — er trägt die
        # tool_call-ids, auf die sich die nächsten Nachrichten beziehen müssen.
        messages.append(msg)

        # Jeden angeforderten Call ausführen und pro id eine Tool-Nachricht anhängen.
        for call in msg.tool_calls:
            result = dispatch(call)              # siehe nächstes Snippet
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,          # MUSS zur id des Calls passen
                "content": json.dumps(result),
            })
        # Schleife: das Modell sieht jetzt den Tool-Output und macht weiter.

    # Limit erreicht, ohne aufzulösen. Laut scheitern — nicht still ewig weiterlaufen.
    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

Dieses begrenzte for ist die mit Abstand wichtigste Zeile in einem Agenten. Ein fähiges Modell auf einer gut abgegrenzten Aufgabe ist in zwei bis vier Runden fertig. Aber Modelle verheddern sich: sie rufen dieselbe Suche zweimal auf, jagen einer Sackgasse hinterher oder — der Klassiker unter den Fehlern — rufen ein Tool auf, bekommen ein Ergebnis, das ihnen nicht gefällt, und rufen es mit nahezu identischen Argumenten erneut auf, immer weiter. Ein while True macht daraus eine unbegrenzte Rechnung und einen hängenden Request. Das Limit verwandelt „kreist ewig" in „scheitert nach 8 Versuchen mit einem klaren Fehler" — und das ist etwas, das du abfangen, protokollieren und davon wiederherstellen kannst. Wähle die Zahl passend zu deiner Aufgabe: ein einmaliges Nachschlagen braucht 2, ein mehrstufiger Recherche-Agent vielleicht 10. Setze sie bewusst; lass sie nicht unbegrenzt.

Beachte die andere Leitplanke, die offen sichtbar lauert: behandle den Fall, dass das Modell kein Tool aufruft. Wenn msg.tool_calls leer ist, hat das Modell entschieden, dass es genug zum Antworten hat — das ist dein Ausstieg, kein Fehler. Eine Schleife, die annimmt, dass jeder Turn einen Tool-Call erzeugt, stürzt entweder ab oder endet nie. Verzweige bei jeder Iteration über beide Ausgänge.

Tool-Dispatch: das Modell schlägt vor, dein Code entscheidet

Das Modell fasst deine Systeme niemals an. Es gibt einen Funktionsnamen und einen JSON-String mit Argumenten aus und stoppt; dein Code entscheidet, ob er dem nachkommt. Diese Grenze ist die gesamte Sicherheitsgeschichte eines Agenten, deshalb lebt die Validierung in der Dispatch-Funktion — nicht als nette Beigabe, sondern weil jedes Argument nicht vertrauenswürdiger Modell-Output ist, genau wie ein Formularfeld, das ein Fremder ausgefüllt hat.

dispatch.py
# Tool-Dispatch mit Validierung. Das Modell SCHLÄGT einen Call VOR; dein Code
# ENTSCHEIDET darüber. Jedes Argument ist nicht vertrauenswürdiger Modell-Output — parse es,
# prüfe, dass der Name einer ist, den du registriert hast, und validiere die Typen vor dem Ausführen.
def get_weather(city: str, unit: str = "celsius") -> dict:
    if not isinstance(city, str) or not city.strip():
        raise ValueError("city must be a non-empty string")
    if unit not in ("celsius", "fahrenheit"):
        raise ValueError(f"unsupported unit: {unit!r}")
    return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}

# Whitelist: ein Modell kann nur aufrufen, was du explizit registriert hast.
TOOL_IMPLS = {"get_weather": get_weather}

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string",
                         "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
        },
    },
}]

def dispatch(call) -> dict:
    name = call.function.name
    fn = TOOL_IMPLS.get(name)
    if fn is None:
        # Das Modell hat ein Tool halluziniert. Die Schleife nicht abstürzen lassen — den
        # Fehler als Tool-Ergebnis zurückgeben, damit das Modell sich selbst korrigieren kann.
        return {"error": f"unknown tool: {name}"}

    try:
        args = json.loads(call.function.arguments)   # immer ein JSON-STRING
    except json.JSONDecodeError:
        return {"error": "arguments were not valid JSON"}

    try:
        return fn(**args)
    except (TypeError, ValueError) as e:
        # Schlechte Args (falscher Typ, fehlendes Feld, außerhalb des Bereichs). Die
        # Meldung zurückspeisen; das Modell wiederholt meist mit einem korrigierten Call.
        return {"error": str(e)}

Drei Fehlermodi werden hier behandelt, und alle speisen den Fehler an das Modell zurück, statt die Schleife abstürzen zu lassen. Ein halluzinierter Tool-Name, den das Modell erfunden, du aber nie registriert hast — die Whitelist fängt ihn ab. Fehlerhaftes JSON im Argument-String — selten bei einem echten Spitzenmodell, aber du parst trotzdem defensiv. Und schlechte Argumentwerte — falscher Typ, fehlendes Pflichtfeld, ein enum, das sich das Modell ausgedacht hat. In jedem Fall ist die Rückgabe von {"error": "..."} als Tool-Ergebnis besser als ein Raise, denn das Modell liest diese Meldung im nächsten Turn und korrigiert seinen eigenen Call meist selbst. Ein Agent, der sich von seinen eigenen Fehlern erholen kann, ist weit robuster als einer, der beim ersten schlechten Argument stirbt.

Halte die Whitelist eng. TOOL_IMPLS.get(name) bedeutet, dass ein Modell — echt oder nicht — immer nur Funktionen aufrufen kann, die du explizit registriert hast. Dieses eine Dict ist dein Schadensradius. Wenn ein Tool Daten löscht, eine Karte belastet oder eine E-Mail versendet, sichere es hinter einer expliziten Bestätigung ab, statt die Schleife es autonom auslösen zu lassen.

Der Budget-Wächter: Schleifen senden einen wachsenden Kontext erneut

Das Iterationslimit begrenzt, wie oft du das Modell aufrufst. Es begrenzt nicht, wie viel jeder Call kostet — und in einer Schleife steigen die Kosten mit jeder Runde. Der Grund ist struktureller Natur: jeder Turn sendet die gesamte bisherige Konversation erneut, plus jedes daran angehängte Tool-Ergebnis. Turn eins sind vielleicht 800 Input-Tokens; Turn sechs, nachdem sich fünf Tool-Outputs angehäuft haben, können 6.000 sein. Acht günstige Runden summieren sich still und leise zu einem nicht günstigen Lauf. Die Lösung ist eine zweite, unabhängige Obergrenze auf den Verbrauch, berechnet aus den echten Token-Zahlen, die jeder Call zurückgibt:

budget_guard.py
# Ein Kosten-/Token-Budget-Wächter pro Lauf. Jeder Schleifendurchlauf sendet einen WACHSENDEN
# Kontext erneut (Historie + Tool-Outputs), also steigen die Kosten mit jeder Runde. Lies das
# ehrliche usage-Objekt nach jedem Call, bepreise es und stoppe, wenn der Lauf
# sein Budget übersteigt — unabhängig vom Iterationslimit.
from decimal import Decimal

# Veröffentlichte Brievio-Tarife, USD pro 1M Tokens (~15% unter offizieller Liste).
RATES = {
    "claude-sonnet-4-6": {"in": Decimal("2.55"), "out": Decimal("12.75")},
    "claude-haiku-4-5":  {"in": Decimal("0.85"), "out": Decimal("4.25")},
}

def call_cost(model: str, usage) -> Decimal:
    r = RATES[model]
    m = Decimal("1000000")
    return usage.prompt_tokens * r["in"] / m + usage.completion_tokens * r["out"] / m

RUN_BUDGET = Decimal("0.10")   # 10 Cent pro Agent-Lauf, harte Obergrenze.

def run_agent_budgeted(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [{"role": "user", "content": question}]
    spent = Decimal("0")

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model, messages=messages, tools=TOOLS, tool_choice="auto",
        )
        spent += call_cost(model, resp.usage)   # ECHTE Tokens zählen, jede Runde
        if spent > RUN_BUDGET:
            raise RuntimeError(f"run exceeded ${RUN_BUDGET} (spent ${spent:.4f})")

        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content

        messages.append(msg)
        for call in msg.tool_calls:
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(dispatch(call))})

    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

Entscheidend ist, dass resp.usage auf Brievio die ehrlichen Input- und Output-Token-Zahlen trägt, die das echte Modell tatsächlich verarbeitet hat — die laufende Summe ist also echtes Geld, keine Schätzung. usage nach jedem Turn zu lesen und bei RUN_BUDGET zu stoppen bedeutet, dass ein verwirrter Agent, der sonst acht teure Runden durchbrennen würde, in dem Moment gekappt wird, in dem er einen Zehntel-Dollar überschreitet — egal, wie viele Iterationen das gebraucht hat. Zwei Obergrenzen, zwei verschiedene Fehlermodi abgedeckt: das Iterationslimit stoppt unendliche Schleifen, das Budget stoppt teure. Du willst beides, denn eine Schleife kann kurz und teuer oder lang und günstig sein, und keine schützt dich allein vor der anderen.

Wissenswert für die Rechnung: fehlgeschlagene 4xx/5xx -Calls werden auf Brievio nicht berechnet, ein Retry gegen ein wackeliges Tool oder ein vorübergehender Upstream-Fehler leert also nicht das Lauf-Budget — du zählst Kosten nur für Calls, die tatsächlich ein Ergebnis zurückgegeben haben. Das hält die Verbrauchskurve an der geleisteten Arbeit ausgerichtet, nicht an abgefangenen Fehlern. Das vollständige Muster zur Begrenzung des Verbrauchs pro Call und pro Nutzer steht im Leitfaden zum Deckeln von API-Ausgaben.

Die Token-Rechnung niedrig halten, während die Schleife wächst

Kosten zu begrenzen ist das eine; sie zu senken ist das andere. Weil jeder Turn ein wachsendes Präfix erneut sendet, wird derselbe Kontext immer und immer wieder bezahlt — und genau dafür ist Prompt-Caching gemacht. Markiere die statischen Teile des Requests (den System-Prompt, die Tool-Definitionen) als cachebar, und ab dem zweiten Turn zahlst du auf alles, was sich nicht geändert hat, nur einen Bruchteil des Input-Tarifs. In einer Schleife, die denselben mehrtausend Token großen Tool-Katalog und System-Prompt in jeder einzelnen Runde erneut sendet, ist das der mit Abstand größte Hebel auf der Rechnung.

Ein paar praktische Gewohnheiten helfen ebenfalls. Halte System-Prompt und Tool-Definitionen über den Lauf hinweg stabil — ein mitten in der Schleife hinzugefügtes Tool oder ein Zeitstempel im System-Prompt invalidiert den Cache und verdoppelt still und leise deine Input-Kosten. Und wenn ein Tool eine Datenflut zurückgeben kann (eine ganze Webseite, eine Abfrage mit tausend Zeilen), fasse das Ergebnis zusammen oder kürze es, bevor du es an messages anhängst; das Modell braucht selten alles davon, und jedes Byte, das du anhängst, wird in jedem folgenden Turn erneut gesendet. Die Agent-Schleife ist besonders empfindlich gegenüber aufgeblähtem Kontext, gerade weil der Kontext N-mal gesendet wird, nicht nur einmal.

Konversationsgedächtnis: was du zwischen Läufen mitnimmst

Alles oben ist Gedächtnis innerhalb eines einzelnen Laufs — die messages-Liste ist das Arbeitsgedächtnis des Agenten, und das Anhängen daran ist, wie sich das Modell merkt, was es bereits nachgeschlagen hat. Für einen mehrteiligen Agenten, der über mehrere Requests hinweg mit einem Nutzer spricht, trägst du diese Liste vorwärts: persistiere messages pro Session (Redis, eine Datenbankspalte, wo auch immer), lade sie beim nächsten Request neu und hänge den neuen User-Turn an. Die Schleife ist identisch; nur der Startzustand ändert sich.

Was du im Griff behalten musst, ist das ungebremste Wachstum. Eine langlebige Session sammelt Historie an, bis sie bei jedem Call teuer wird und irgendwann das Kontextfenster sprengt. Zwei gängige Strategien: halte ein gleitendes Fenster der letzten N Turns und wirf die ältesten weg, oder fasse die ältere Historie regelmäßig zu einer kompakten Notiz zusammen und ersetze die rohen Turns dadurch. Beide tauschen etwas Detailtreue gegen eine begrenzte, vorhersehbare Kontextgröße. Welche du auch wählst, der Budget-Wächter pro Lauf von oben gilt weiterhin — er ist das Sicherheitsnetz, das eine Session abfängt, die größer wurde, als du geplant hattest.

Ein Schlüssel für einen Agenten, der eskaliert

Eine nützliche Eigenschaft, wenn du das hinter Brievio baust: ein Agent kann mitten in der Aufgabe das Modell wechseln, ohne sonst etwas zu ändern. Lass die günstigen Runden auf einem kleineren Modell laufen und eskaliere nur dann auf ein Spitzenmodell, wenn die Aufgabe schwer ist — leite einfachen Tool-Dispatch durch Haiku 4.5 mit $0,85 in / $4,25 out, falle für die reasoning-lastige Endantwort auf Sonnet oder eine andere Familie zurück. Weil ein Schlüssel jedes Modell hinter einer einzigen base_url abdeckt, ist diese Eskalation eine einzeilige Änderung am model-String innerhalb der Schleife — kein zweites SDK, kein zweites Auth-Schema, keine zweite Abrechnungsbeziehung. Der vollständige Request/Response-Vertrag, einschließlich der Tool-Felder, steht in den Chat-Completions-Docs, und die aktuelle Modell-Liste mit den exakten ids ist auf der Modelle-Seite.

Es zählt natürlich nur, wenn das Modell am anderen Ende echt ist: eine Agent-Schleife verzeiht keinen heruntergestuften Ersatz, denn ein Modell, das Tool-Argumente verpfuscht oder ein Tool ignoriert, verbrennt Iterationen und Verbrauch, während es seinen eigenen Fehlern hinterherjagt. Brievio liefert die echten First-Party-Modelle aus, ehrt natives Tool-Calling und meldet ehrliche Token-Zahlen — und genau das lässt sowohl die Schleife als auch die Budget-Rechnung tatsächlich funktionieren.

Das Fazit: vier Leitplanken, dann ausliefern

Die Schleife selbst ist ein Dutzend Zeilen. Was sie produktionsreif macht, ist die Grenze um sie herum:

  • Begrenze die Iterationen. Ein begrenztes for statt while True. Scheitere laut an der Obergrenze, statt ewig zu kreisen.
  • Behandle den Fall ohne Tool. Leeres tool_calls ist der Ausstieg, kein Fehler. Verzweige bei jedem Turn darüber.
  • Validiere jeden Dispatch. Tool-Namen per Whitelist, Argumente parsen, Typen prüfen — und Fehler an das Modell zurückspeisen, statt abzustürzen.
  • Budgetiere den Lauf. Lies usage bei jedem Turn, bepreise es gegen veröffentlichte Tarife, stoppe bei einer harten Verbrauchsobergrenze. Behalte den wachsenden Kontext im Blick und cache das statische Präfix, um die erneut gesendeten Kosten niedrig zu halten.

Mach diese vier richtig, und du hast einen Agenten, der echte mehrstufige Arbeit leistet, sich von seinen eigenen Fehlern erholt und einen Worst-Case-Kostenrahmen hat, den du gewählt und nicht auf einer Rechnung entdeckt hast. Starte beim Tool-Nutzungs-Leitfaden, wenn du zuerst die Mechanik des Einzel-Calls brauchst, und umgib ihn dann mit der Schleife und den vier Leitplanken von oben.