Tool Calling: Aufrufe, Ergebnisse, Fehler
Track KI · M1 Baustein 04 · Teil 2 von 2 · ca. 50 Min.
Worum es geht
Tool Calling (auch function calling) ist dieselbe Mechanik wie bei Structured Outputs, nur in die andere Richtung: Das Modell sagt “ruf bitte dieses Werkzeug mit diesen Argumenten auf”, dein Code führt es aus und gibt das Ergebnis zurück. Das Modell entscheidet selbst, ob es ein Tool braucht, und die Argumente, die es schickt, sind Eingabe von außen, genau wie in Teil 1.
Was du aus Teil 1 brauchst: Ein Fake-Modell, das feste Antworten abspielt und sich jede Anfrage merkt, und die Pydantic-Validierung an der Grenze. Beides steht unten kurz wieder bereit, den Hintergrund findest du in Teil 1: Structured Outputs.
Auch hier läuft alles im Browser mit einem Fake-Modell, das Tool-Wünsche als dicts liefert.
Von JS/TS her gedacht
| Idee | TypeScript | Python |
|---|---|---|
| Tool beschreiben | Objekt mit name, description, input_schema |
dasselbe dict |
| Tool-Aufruf lesen | block.input (Antwort-Objekt des SDK) |
block.input im SDK. Hier im Browser: dict block["input"] |
| Fehler im Tool | try / catch, Fehlertext zurückgeben |
try / except Exception, Fehlertext zurückgeben |
Ein Unterschied zum echten SDK: Dort sind die Blöcke einer Antwort Objekte (b.type, b.input, b.id, wie in der Quelle). Unser Fake-Modell liefert dicts (b["type"]), damit man sie einfach hinschreiben kann. Die Felder heißen gleich.
Konzept
Schritt 0: Das Handwerkszeug aus Teil 1
Das Fake-Modell aus Teil 1 in einer Zelle (es spielt Antworten der Reihe nach ab und merkt sich die Anfragen), dazu ein kleiner Helfer, der einen tool_use-Block baut. Der Block ist so aufgebaut wie im echten SDK:
Schritt 5: Tool Calling, ohne Zwang
Beim Tool Calling (Baustein 04) lässt du tool_choice weg. Das Modell entscheidet selbst, ob es ein Tool braucht. Dein Code führt den Aufruf aus und schickt das Ergebnis in einer zweiten Anfrage zurück, die in der Quelle so aussieht (gekürzt):
tool_use = next(b for b in response.content if b.type == "tool_use")
ergebnis = hole_wechselkurs(tool_use.input["waehrung"]) # dein eigener Code
folge = client.messages.create(model=..., max_tokens=512, tools=tools,
messages=[
{"role": "user", "content": "50 TRY in Euro?"},
{"role": "assistant", "content": response.content}, # die Antwort mit dem Tool-Wunsch
{"role": "user", "content": [{"type": "tool_result",
"tool_use_id": tool_use.id, "content": str(ergebnis)}]},
])Merke drei Regeln daraus: Die erste Antwort des Modells wandert unverändert als assistant-Nachricht zurück. Das Ergebnis geht als user-Nachricht mit einem Block vom Typ tool_result. Und tool_use_id muss zur id des Wunsches passen, sonst weiß das Modell nicht, wozu das Ergebnis gehört. content ist Text (str(ergebnis)).
Dasselbe mit einem anderen Werkzeug und dem Fake-Modell durchgespielt. Beachte, dass das Modell hier erst einen Tool-Wunsch und danach den Antworttext liefert:
Mehrere Wünsche in einer Antwort. Ein Modell kann in einer einzigen Antwort mehrere tool_use-Blöcke liefern (zum Beispiel zwei Währungen). Dann gehören alle Ergebnisse als tool_result-Blöcke in eine user-Nachricht, jeweils mit der passenden id. Das prüft Übung 2.
Mehrere Runden. Nach deinem tool_result kann das Modell erneut ein Tool wünschen (zum Beispiel erst den Kurs holen, dann die Gebühr nachschlagen). Dann wiederholt sich der Ablauf: Wunsch ausführen, Antwort und Ergebnis an den Verlauf hängen, nochmal fragen. Der Verlauf wächst dabei um zwei Nachrichten pro Runde. Die Schleife endet, sobald eine Antwort keinen tool_use-Block mehr enthält. Wie bei jedem Retry gilt eine Obergrenze für die Zahl der Modellaufrufe, sonst zahlst du für ein Modell, das sich im Kreis dreht. Das baust du in Übung 2.
Die Beschreibung ist Teil des Prompts (Stolperfalle der Quelle). Das Modell entscheidet nur anhand von name und description, ob und wie es das Tool aufruft. Vergleiche "description": "Status holen" mit dem Text oben: Der zweite sagt, was das Tool liefert und in welchem Format die Eingabe ist. Formuliere eine Beschreibung wie einen Prompt, nicht wie einen internen Funktionsnamen.
Schritt 6: Wenn das Tool-Argument oder das Tool selbst scheitert
Was passiert, wenn das Modell sendungsnr="XX9" schickt und dein paket_status mit einem KeyError abbricht? Wirfst du die Exception nach oben, bricht der ganze Ablauf ab und der Nutzer sieht einen Fehler. Besser: Du fängst den Fehler und gibst ihn als Ergebnis zurück. Das Modell liest den Text und kann es mit besseren Argumenten nochmal versuchen oder dem Nutzer erklären, was fehlt. In der API markierst du das mit dem Feld is_error im tool_result (steht nicht in der Quelle, bitte gegen die aktuelle API-Doku prüfen).
Zwei weitere Fehlerquellen kommen dazu, und beide musst du abfangen: ein Tool-Name, den es nicht gibt (Modelle erfinden manchmal einen), und Argumente, die nicht zum Schema passen (falscher Typ, fehlendes Feld). Letzteres prüfst du mit demselben Pydantic-Modell wie bei Structured Outputs, nur jetzt für die Tool-Eingabe. Das setzt du in Übung 3 um.
Falle
- Vage Tool-Beschreibung. Falsche oder ausbleibende Aufrufe sind oft ein Prompt-Problem, kein Modell-Problem.
- Falsche Verdrahtung der Runde. Falsche
tool_use_id, die Antwort mit dem Wunsch fehlt in der zweiten Anfrage,contentist eine Zahl statt Text, oder nur der erste von mehreren Wünschen wird beantwortet. - Tool-Fehler als Exception nach oben. Der Ablauf bricht ab, obwohl das Modell sich hätte korrigieren können. Aber: Gib dem Modell nur Fehlertexte, die es sehen darf. Ein Stacktrace mit Pfaden, Schlüsseln oder Datenbank-Details gehört nicht in den Kontext.
- Tool-Argumente sind Eingabe von außen. Wie ein Modell durch Text in einem Dokument umgelenkt werden kann (Prompt Injection, Thema in M3), entscheidet nicht dein Prompt allein: Ein Tool, das Dateien löscht, braucht eigene Prüfungen, egal was das Modell schickt.
Übungen
Übung 1: Welche Beschreibung hilft? (leicht, ca. 4 Min.)
Ein Assistent für Lagerauskünfte hat das Tool lager_abfrage mit der Beschreibung “Daten holen” und einem Parameter q (Text). Bei Fragen wie “Ist Artikel 1001 im Lager Nord vorrätig?” ruft das Modell oft kein Tool auf, und wenn doch, schickt es einen ganzen Satz in q statt der Artikelnummer. Was ist die beste erste Maßnahme?
- a) Auf ein größeres Modell wechseln, weil kleine Modelle Tools meist schlecht erkennen und falsch aufrufen.
- b)
tool_choiceauf dieses Tool festlegen, damit es bei jeder einzelnen Nachricht des Nutzers aufgerufen wird. - c) Im System-Prompt “Benutze immer die Tools” ergänzen und am Tool selbst nichts verändern oder ergänzen.
- d) Name, Beschreibung und Parameter genauer fassen: was das Tool liefert, wofür, welches Format
qhat.
Trage den Buchstaben als String ein.
Woran entscheidet das Modell, ob und wie es ein Tool aufruft, und welche Option ändert genau das?
antwort = "d"
antwortÜbung 2: Tool-Schleife mit mehreren Runden (mittel, ca. 20 Min.)
Das ist Baustein 04 der Quelle, mit fester Testtabelle statt echter API und mit mehreren Runden: Schreibe antworte(modell, frage, werkzeuge, max_aufrufe=3), die ganze Tool-Schleife.
- Beginne mit
messages = [{"role": "user", "content": frage}]und frage das Modell. Die Antwort ist eine Liste von Blöcken ({"type": "text", ...}oder{"type": "tool_use", "id": ..., "name": ..., "input": {...}}). - Enthält die Antwort keinen
tool_use-Block, ist die Schleife zu Ende: gib den Text zurück (text_von(antwort), siehe Setup). - Sonst: Führe jeden
tool_use-Block aus (werkzeuge[name](**input),werkzeugeist ein dict Name zu Funktion) und baue pro Block einentool_result-Block mit der passendentool_use_idundcontentals Text. - Hänge die Antwort unverändert als
assistant-Nachricht an den Verlauf, dazu alle Ergebnisse zusammen in eineruser-Nachricht, und frage das Modell erneut. Weiter bei Schritt 2. - Das Modell wird höchstens
max_aufrufeMal gefragt. Verlangt auch die letzte erlaubte Antwort noch ein Werkzeug, wirfZuVieleRunden(steht im Setup).
Das Setup hat eine Testtabelle mit erfundenen Kursen und Gebühren (keine echten Werte).
Eine Schleife, die so lange läuft, bis keine Antwort mehr einen Wunsch enthält, aber nie öfter als erlaubt. Was muss bei jedem Durchlauf in den Verlauf, damit das Modell weiß, worauf sich ein Ergebnis bezieht, und wie viele Wünsche kann eine Antwort enthalten?
def antworte(modell, frage, werkzeuge, max_aufrufe=3):
messages = [{"role": "user", "content": frage}]
for _ in range(max_aufrufe):
antwort = modell(messages)
aufrufe = [b for b in antwort if b["type"] == "tool_use"]
if not aufrufe:
return text_von(antwort)
ergebnisse = [
{"type": "tool_result", "tool_use_id": b["id"],
"content": str(werkzeuge[b["name"]](**b["input"]))}
for b in aufrufe
]
messages.append({"role": "assistant", "content": antwort})
messages.append({"role": "user", "content": ergebnisse})
raise ZuVieleRunden(f"nach {max_aufrufe} Modellaufrufen noch kein Endergebnis")
antworteÜbung 3: Fehler in Tool-Argumenten abfangen (mittel, ca. 15 Min.)
Jetzt wird der Wunsch des Modells nicht mehr blind ausgeführt. Schreibe tool_result_fuer(block, werkzeuge), die zu einem tool_use-Block genau einen tool_result-Block liefert und nie eine Exception wirft. Das dict werkzeuge bildet den Namen auf ein Paar (funktion, EingabeModell) ab, das Pydantic-Modell beschreibt die erlaubten Argumente.
- Unbekannter Tool-Name: Fehler-Ergebnis, der Text nennt den Namen.
- Argumente passen nicht zum Modell (fehlendes Feld, falscher Typ, auch ein
input, das gar kein dict ist): Fehler-Ergebnis, der Text nennt die betroffenen Felder (kurzfehler(e)im Setup hilft). - Das Werkzeug selbst wirft eine Exception: Fehler-Ergebnis mit Typ und Meldung der Exception.
- Sonst: normales Ergebnis,
contentals Text.
Ein Fehler-Ergebnis hat "is_error": True zusätzlich zu type, tool_use_id und content (das Feld ist nicht in der Quelle, bitte gegen die API-Doku prüfen). Jedes Ergebnis trägt die id des Blocks.
Es gibt drei Stellen, an denen etwas schiefgehen kann: Nachschlagen des Tools, Prüfen der Argumente, Ausführen. Welche Exception gehört zu welcher Stelle, und was geschieht mit einem input, das kein dict ist, wenn du es mit ** entpackst?
def tool_result_fuer(block, werkzeuge):
def fehler(text):
return {"type": "tool_result", "tool_use_id": block["id"], "content": text, "is_error": True}
if block["name"] not in werkzeuge:
return fehler(f"Unbekanntes Tool '{block['name']}'. Verfügbar: {', '.join(werkzeuge)}")
funktion, Eingabe = werkzeuge[block["name"]]
try:
eingabe = Eingabe.model_validate(block["input"])
except ValidationError as e:
return fehler(f"Ungültige Argumente: {kurzfehler(e)}")
try:
ergebnis = funktion(**eingabe.model_dump())
except Exception as e:
return fehler(f"{type(e).__name__}: {e}")
return {"type": "tool_result", "tool_use_id": block["id"], "content": str(ergebnis)}
tool_result_fuerMerksatz
Ein Tool-Wunsch wird ausgeführt und mit der passenden tool_use_id als tool_result zurückgegeben, und ein Tool-Fehler geht als Ergebnis zurück statt den Ablauf abzubrechen: so kann das Modell sich korrigieren.
Prüfstein
Dein Assistent mit drei Tools ruft bei jeder zweiten Frage ein falsches Tool auf oder gar keines. Nenne drei Stellen, an denen du in welcher Reihenfolge suchst, und was du jeweils sehen willst (denke an description, Argumente und Verlauf der Nachrichten).
Zurück zu Teil 1: Structured Outputs. Dieser Teil schließt Baustein 04 ab, der Abschluss-Check von M1 folgt in Lektion 07.
Quelle: quellen/kursbuch-lerninhalte.md, Modul M1, Baustein “04 Tool Calling” (Stolperfalle, Übung, der Zwei-Schritte-Umlauf mit tool_use und tool_result). Über die Quelle hinaus (allgemeines Fachwissen, bitte gegen die aktuelle API-Doku prüfen): das Feld is_error im tool_result, mehrere tool_use-Blöcke in einer Antwort, mehrere Runden mit Obergrenze, die Einordnung von Fehlertexten und Prompt Injection. Das Fake-Modell und alle Beispielantworten sind von Hand geschrieben, die Wechselkurse und Gebühren in der Testtabelle sind erfunden.