Zum Inhalt
wannacode

Tool Use: das Modell fragt, das Programm handelt

Kurz gesagt: Tool Use lässt das Modell das Programm bitten, eine Funktion auszuführen, etwa den Status einer Bestellung nachzuschlagen, und aus dem Ergebnis statt aus dem Gedächtnis zu antworten.

Problem

Das Modell kennt deine Bestellungen nicht

Der Kunde fragt: „Wann kommt Bestellung 1042 an?“ Der Assistent fragt das Modell, ohne ihm die Daten zu geben:

client = FakeClient()
question = "Wann kommt Bestellung 1042 an?"
messages = [{"role": "user", "content": [{"type": "text", "text": question}]}]
reply = client.create(SYSTEM, messages, [])
print(f"Modell: {text_of(reply)}")
order = ORDERS["1042"]
print(f"Shop: {order['status']}, Lieferung {order['eta']}")
Ausgabe
Modell: Bestellung 1042 kommt bis Freitag an.
Shop: versandt, Lieferung 3. November

Das Modell sagt „bis Freitag“, und der Shop sagt 3. November. Es hat deine Datenbank nie gesehen. Es hat nur den Text, den du ihm geschickt hast, und es erfindet etwas Plausibles.

In der Lektion über den Prompt sind die Bestelldaten in die Vorlage gewandert. Das funktioniert, wenn du im Voraus weißt, welche Daten gebraucht werden. Aber der Kunde kann nach einer anderen Bestellung fragen, nach einer Rückgabe oder nach zwei Dingen auf einmal. Du kannst nicht den ganzen Shop in den Prompt packen: Der Kontext ist begrenzt, und jedes Wort kostet Geld.

Die Idee

Das Modell fragt, das Programm handelt

Du gibst dem Modell eine Liste von Tools. Jedes hat einen Namen, eine Beschreibung und ein Schema für seine Eingabe: dieselbe Art Schema wie in der Lektion über strukturierte Ausgabe. Jetzt kann das Modell auf zwei Arten antworten:

  • Mit Text. stop_reason ist end_turn, und das Gespräch ist zu Ende.
  • Mit einer Anfrage. stop_reason ist tool_use, und die Antwort hat einen tool_use-Block: eine id, den Namen des Tools und eine Eingabe, zum Beispiel {"order_id": "1042"}.

Bei einer Anfrage macht das Programm Folgendes:

  1. Es hängt die Antwort des Modells an den Verlauf an, mit der Rolle assistant.
  2. Es führt das Tool gegen seine eigenen Daten aus.
  3. Es gibt das Ergebnis als neue user-Nachricht mit einem tool_result-Block zurück. Darin ist tool_use_id dieselbe id, und bei einem Fehler ist is_error wahr.
  4. Es fragt das Modell noch einmal, mit dem ganzen Verlauf.

Das ist die Tool-Schleife (Tool Use). Das Modell führt nichts aus und sieht die Datenbank nicht. Es sagt nur, was es braucht, und das Programm entscheidet, ob und wie es das tut. Darum lesen die Tools des Assistenten nur: den Status einer Bestellung, die Frist für eine Rückgabe.

Die Eingabe des Tools kommt vom Modell, also von außen. Das Programm prüft sie so, wie es ein Formular prüft.

Sieh es dir an

Das Tool und die Blöcke

Das Tool, das das Modell verlangen kann. Die Beschreibung ist für das Modell: Daran entscheidet es, wann es das Tool aufruft.

TOOLS = [
{
"name": "get_order_status",
"description": "Status und Lieferdatum einer Bestellung anhand ihrer Nummer.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"],
},
},
]
Die Blöcke sind Dictionaries mit den Feldern der API: type nennt die Art, und tool_use hat id, name und input. In Python und JavaScript hat das Tool oben genau die Form, die die API annimmt.

Das Programm führt aus, was verlangt wurde. Zuerst prüft es, ob es so ein Tool gibt und ob die Eingabe die nötigen Felder hat, dann schlägt es die Bestellung nach:

def run_tool(use):
tool = next((t for t in TOOLS if t["name"] == use["name"]), None)
if tool is None:
return error(use, f"kein Tool {use['name']}")
for field in tool["input_schema"]["required"]:
if field not in use["input"]:
return error(use, f"fehlt {field}")
order_id = use["input"]["order_id"]
order = ORDERS.get(order_id)
if order is None:
return error(use, f"keine Bestellung {order_id}")
text = f"{order_id} — {order['status']}, Lieferung {order['eta']}"
return {"type": "tool_result", "tool_use_id": use["id"], "content": text}
def error(use, text):
return {"type": "tool_result", "tool_use_id": use["id"],
"content": text, "is_error": True}

Jeder Fehler ist ein Ergebnis mit is_error, keine Exception. Das Modell muss verstehen, was passiert ist, und entscheiden, was es dem Kunden sagt.

Sieh es dir an

Die Schleife

def answer(client, question):
print(f"Frage: {question}")
messages = [{"role": "user",
"content": [{"type": "text", "text": question}]}]
for call in range(1, MAX_CALLS + 1):
reply = client.create(SYSTEM, messages, TOOLS)
print(f" Aufruf {call} (~{reply['input_tokens']} Tokens):",
reply["stop_reason"])
if reply["stop_reason"] != "tool_use":
print(f" Antwort: {text_of(reply)}")
return
messages.append({"role": "assistant",
"content": reply["content"]})
results = []
for block in reply["content"]:
if block["type"] == "tool_use":
result = run_tool(block)
shown = ("✗ " if result.get("is_error") else "")
shown += result["content"]
print(f" {block['id']} {describe(block)} → {shown}")
results.append(result)
messages.append({"role": "user", "content": results})
print(f" Stopp nach {MAX_CALLS} Aufrufen → zu einem Menschen")
Schritt für Schritt, in Worten
  1. Die Frage ist die erste Nachricht. Sie geht zusammen mit der Liste der Tools an das Modell (~34 Tokens).
  2. Das Modell kennt den Status nicht. Es antwortet mit stop_reason tool_use: „Führe get_order_status mit order_id 1042 aus“.
  3. Das Programm behält die Antwort des Modells im Verlauf: Das Ergebnis braucht eine Anfrage, auf die es antwortet.
  4. Das Programm führt das Tool gegen seine eigenen Daten aus. Das Modell fasst den Shop nie an: Es hat nur gefragt.
  5. Das Ergebnis geht als user-Nachricht zurück: tool_result mit derselben id, call_1.
  6. Aufruf 2 schickt den ganzen Verlauf noch einmal: ~45 Tokens statt ~34.
  7. Jetzt hat das Modell die Daten und antwortet mit end_turn. Zwei Aufrufe an das Modell für eine Frage.

Die ganze Schleife. Zwei Hilfsfunktionen sagen, was das Modell verlangt hat und was der Text der Antwort ist:

def describe(use):
return f"{use['name']}({use['input'].get('order_id', '')})"
def text_of(reply):
return next((b["text"] for b in reply["content"] if b["type"] == "text"), "")
def answer(client, question):
print(f"Frage: {question}")
messages = [{"role": "user",
"content": [{"type": "text", "text": question}]}]
for call in range(1, MAX_CALLS + 1):
reply = client.create(SYSTEM, messages, TOOLS)
print(f" Aufruf {call} (~{reply['input_tokens']} Tokens):",
reply["stop_reason"])
if reply["stop_reason"] != "tool_use":
print(f" Antwort: {text_of(reply)}")
return
messages.append({"role": "assistant",
"content": reply["content"]})
results = []
for block in reply["content"]:
if block["type"] == "tool_use":
result = run_tool(block)
shown = ("✗ " if result.get("is_error") else "")
shown += result["content"]
print(f" {block['id']} {describe(block)} → {shown}")
results.append(result)
messages.append({"role": "user", "content": results})
print(f" Stopp nach {MAX_CALLS} Aufrufen → zu einem Menschen")
client = FakeClient()
answer(client, "Wann kommt Bestellung 1042 an?")
Ausgabe
Frage: Wann kommt Bestellung 1042 an?
Aufruf 1 (~34 Tokens): tool_use
call_1 get_order_status(1042) → 1042 — versandt, Lieferung 3. November
Aufruf 2 (~45 Tokens): end_turn
Antwort: Ich habe nachgesehen: 1042 — versandt, Lieferung 3. November.

Eine Frage des Kunden macht zwei Aufrufe an das Modell. Der zweite schickt den ganzen Verlauf und ist größer: ~45 Tokens statt ~34.

Das Modell ist hier ein falscher Client, wie in den früheren Lektionen: Aus den Wörtern der Frage verlangt es die Tools, die es hat, und nach den Ergebnissen baut es daraus eine Antwort. Seine Regel steht unten unter „Genauer, für Neugierige“.

Probier es aus

Ergänze

Wann hält die Schleife an und gibt die Antwort aus? Was fehlt in der Prüfung?

if reply["stop_reason"] != "":

Probier es aus

Ausgabe vorhersagen

Der Kunde bedankt sich nur. Was gibt answer aus?

client = FakeClient()
answer(client, "Danke, das war alles.")
Antwort

Die Idee

Wenn ein Tool fehlschlägt

Tools schlagen fehl: Die Nummer gibt es nicht, ein Feld fehlt, die Datenbank antwortet nicht. Das Programm wirft keine Exception und versteckt den Fehler nicht. Es gibt ihn als tool_result mit is_error an das Modell zurück, und das Modell entscheidet, was es sagt:

client = FakeClient()
answer(client, "Wo ist Bestellung 9999?")
answer(client, "Wo ist mein Paket?")
Ausgabe
Frage: Wo ist Bestellung 9999?
Aufruf 1 (~33 Tokens): tool_use
call_1 get_order_status(9999) → ✗ keine Bestellung 9999
Aufruf 2 (~40 Tokens): end_turn
Antwort: Ich finde diese Bestellung nicht. Bitte prüfe die Nummer.
Frage: Wo ist mein Paket?
Aufruf 1 (~33 Tokens): tool_use
call_2 get_order_status() → ✗ fehlt order_id
Aufruf 2 (~38 Tokens): end_turn
Antwort: Wie lautet deine Bestellnummer?
  • Bestellung 9999 gibt es nicht. Das Modell sagt es dem Kunden, statt einen Status zu erfinden.
  • Die Frage hat keine Nummer. Das Modell hat das Tool trotzdem verlangt, aber ohne order_id. Die Prüfung fängt das fehlende Feld ab, und das Modell fragt den Kunden nach der Nummer.

Manche Modelle hören nicht auf. Dieses rät die Nummer und verlangt eine nach der anderen:

client = FakeClient()
answer(client, "Ich weiß die Nummer nicht mehr, es war ungefähr 1050.")
Ausgabe
Frage: Ich weiß die Nummer nicht mehr, es war ungefähr 1050.
Aufruf 1 (~40 Tokens): tool_use
call_1 get_order_status(1050) → ✗ keine Bestellung 1050
Aufruf 2 (~47 Tokens): tool_use
call_2 get_order_status(1051) → ✗ keine Bestellung 1051
Aufruf 3 (~54 Tokens): tool_use
call_3 get_order_status(1052) → ✗ keine Bestellung 1052
Aufruf 4 (~61 Tokens): tool_use
call_4 get_order_status(1053) → ✗ keine Bestellung 1053
Stopp nach 4 Aufrufen → zu einem Menschen

Darum hat die Schleife eine Grenze: höchstens 4 Aufrufe, dann geht das Gespräch an einen Menschen. Ohne die Grenze dreht sich das Programm, bis das Geld alle ist.

Probier es aus

Finde den Fehler

Ein Kollege schreibt ein kürzeres runTool. Das Programm schickt das Ergebnis, aber die API lehnt den zweiten Aufruf ab: Das tool_use hat keine Antwort. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Bei dir ausführen

Dieses Programm gehört nicht zur Prüfung der Lektion: Du brauchst ein Konto bei einem Modellanbieter, einen Schlüssel und Geld für jeden Aufruf. Der Schlüssel steht in LLM_API_KEY, nie im Code. Anbieter liefern offizielle SDKs für Java, C#, Python und JavaScript mit derselben Form (Nachrichten, Rollen, Tokens, Tools); die genauen Namen der Klassen und Methoden stehen in der Doku des Anbieters. Hier siehst du, was für die Frage zu 1042 über das Netz geht. Die erste Anfrage trägt die Tools, und der Name des Modells ist ein Platzhalter:

{
"model": "<Name des Modells>",
"max_tokens": 1024,
"system": "Du bist der Support-Assistent eines Online-Shops. Frag bei Bestellungen die Tools.",
"tools": [
{
"name": "get_order_status",
"description": "Status und Lieferdatum einer Bestellung anhand ihrer Nummer.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
}
],
"messages": [
{
"role": "user",
"content": "Wann kommt Bestellung 1042 an?"
}
]
}

Die Antwort ist eine Anfrage nach einem Tool:

{
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "get_order_status",
"input": {"order_id": "1042"}
}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 34, "output_tokens": 3}
}

Die zweite Anfrage trägt den ganzen Verlauf: die Frage, das tool_use des Modells und das tool_result des Programms mit derselben id:

{
"model": "<Name des Modells>",
"max_tokens": 1024,
"system": "Du bist der Support-Assistent eines Online-Shops. Frag bei Bestellungen die Tools.",
"tools": [
{
"name": "get_order_status",
"description": "Status und Lieferdatum einer Bestellung anhand ihrer Nummer.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
}
],
"messages": [
{
"role": "user",
"content": "Wann kommt Bestellung 1042 an?"
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "get_order_status",
"input": {"order_id": "1042"}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "1042 — versandt, Lieferung 3. November"
}
]
}
]
}
{
"content": [
{
"type": "text",
"text": "Ich habe nachgesehen: 1042 — versandt, Lieferung 3. November."
}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 45, "output_tokens": 12}
}

Bei einem Fehler hat das Ergebnis is_error:

{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "keine Bestellung 9999",
"is_error": true
}

Jede Anfrage geht mit dem Standard-HTTP-Client der Sprache raus, und die Schleife darum ist dieselbe wie in answer. Die Adresse ist ein Platzhalter:

# request_json ist das JSON der Anfrage oben.
# Der genaue Header für den Schlüssel steht in der Doku des Anbieters.
import os
import urllib.request
request = urllib.request.Request(
"https://<anbieter>/messages",
data=request_json.encode("utf-8"),
headers={"Authorization": "Bearer " + os.environ["LLM_API_KEY"],
"Content-Type": "application/json"},
)
with urllib.request.urlopen(request) as response:
print(response.read().decode("utf-8"))

Echte ids sind lange Zufallsstrings. Der falsche Client zählt sie einfach durch: call_1, call_2.

Die Idee

Wo es wehtut

  • Ein Tool, das handelt, statt zu lesen. „Storniere die Bestellung“ oder „erstatte das Geld“ bekommt das Modell nicht ohne einen Menschen, der bestätigt. Das Modell kann die Bestellung verwechseln, und hier kostet ein Fehler Geld.
  • Zu viele Tools. Jedes Tool ist eine Beschreibung, die bei jedem Aufruf mitgeschickt wird. Zwanzig Tools sind Hunderte Tokens und ein Modell, das öfter das falsche wählt.
  • Vage Beschreibungen. Das Modell wählt nach der Beschreibung. „Liefert Daten“ sagt nichts. „Status und Lieferdatum einer Bestellung anhand ihrer Nummer“ sagt, wann man es braucht.
  • Der Eingabe vertrauen. order_id kommt vom Modell. Das Programm prüft, dass die Bestellung existiert und zu diesem Kunden gehört, bevor es etwas zeigt. Wenn ein Tool fremden Text liest (eine E-Mail, eine Webseite), kann dieser Text Anweisungen an das Modell enthalten. Dieser Angriff (Prompt Injection) hat im Modul über Agenten seine eigene Lektion.
  • Eine Schleife ohne Grenze. Jeder Aufruf schickt den ganzen Verlauf und kostet mehr als der vorherige. Wie viel genau, steht in der Lektion über Kosten und Geschwindigkeit. Ob der Assistent hundert Fragen richtig beantwortet, nicht nur eine, misst du mit einem Testset.
Genauer, für Neugierige

Warum die Antwort des Modells in den Verlauf kommt. Die API hat kein Gedächtnis. Beim zweiten Aufruf sieht das Modell nur, was du ihm schickst. Ein tool_result ohne ein tool_use davor ist eine Antwort ohne Frage, und die API lehnt es ab.

Zwei Tools in einem Aufruf. Das Modell kann mehrere Tools auf einmal verlangen. Das Programm führt alle aus und gibt alle Ergebnisse in einer user-Nachricht zurück, jedes mit seiner eigenen tool_use_id. So gibt es weniger Aufrufe an das Modell.

Eine Wahl erzwingen. Die API hat auch ein Feld tool_choice. Der Standard, auto, lässt das Modell entscheiden, ob es ein Tool benutzt. Neuere Modelle lassen sich nicht zu einem Tool zwingen, darum macht diese Lektion das nicht.

Der falsche Client von innen. Gibt es keine Tools, erfindet er ein Datum. Lautet die Frage „ungefähr 1050“, rät er Nummer für Nummer. Enthält die letzte Nachricht Ergebnisse, baut er daraus eine Antwort, und bei einem Fehler fragt er nach der Nummer oder sagt, dass er die Bestellung nicht findet. Sonst verlangt er das Tool nach der Nummer in der Frage: 1042 und 9999 für den Status, 1187 für eine Rückgabe. Die Tokens sind Wörter × 1,3, und die Beschreibungen der Tools werden bei jedem Aufruf mitgezählt.

# Steht anstelle des echten Modells: aufgezeichnete Antworten,
# ohne Netz und jedes Mal dieselben.
class FakeClient:
MADE_UP = "Bestellung 1042 kommt bis Freitag an."
ASK_ID = "Wie lautet deine Bestellnummer?"
NOT_FOUND = "Ich finde diese Bestellung nicht. Bitte prüfe die Nummer."
THANKS = "Gern geschehen! Schreib uns, wenn du noch eine Frage hast."
def __init__(self):
self.last_id = 0
def create(self, system, messages, tools):
question = messages[0]["content"][0]["text"]
last = messages[-1]["content"]
content = []
if not tools:
content.append({"type": "text", "text": self.MADE_UP})
elif "ungefähr" in question:
# Rät die nächste Nummer, bis jemand es stoppt.
guess = 1050 + len(messages) // 2
self.ask(content, tools, "get_order_status", str(guess))
elif last[0]["type"] == "tool_result":
content.append({"type": "text", "text": self.after_results(last)})
else:
if "1042" in question:
self.ask(content, tools, "get_order_status", "1042")
if "9999" in question:
self.ask(content, tools, "get_order_status", "9999")
if "1187" in question:
self.ask(content, tools, "get_return_window", "1187")
if "Paket" in question:
self.ask(content, tools, "get_order_status", None)
if not content:
content.append({"type": "text", "text": self.THANKS})
input_tokens = tokens(system)
input_tokens += sum(tokens(t["name"] + " " + t["description"]) for t in tools)
input_tokens += sum(tokens(words(b)) for m in messages for b in m["content"])
output_tokens = sum(tokens(words(b)) for b in content)
stop = "tool_use" if content[0]["type"] == "tool_use" else "end_turn"
return {"content": content, "stop_reason": stop,
"input_tokens": input_tokens, "output_tokens": output_tokens}
def ask(self, content, tools, name, order_id):
if all(t["name"] != name for t in tools):
return
tool_input = {} if order_id is None else {"order_id": order_id}
self.last_id += 1
content.append({"type": "tool_use", "id": f"call_{self.last_id}",
"name": name, "input": tool_input})
# Ein echtes Modell formuliert um; dieses hängt die Ergebnisse nur aneinander.
def after_results(self, results):
parts = []
for r in results:
if r.get("is_error"):
return self.ASK_ID if "order_id" in r["content"] else self.NOT_FOUND
parts.append(r["content"])
return "Ich habe nachgesehen: " + "; ".join(parts) + "."
def words(block):
if block["type"] == "text":
return block["text"]
if block["type"] == "tool_use":
return block["name"] + " " + " ".join(block["input"].values())
return block["content"]
# Grob: Wörter × 1,3, aufgerundet.
def tokens(text):
return (len(text.split()) * 13 + 9) // 10

Übung

Die Rückgabefrist

Löse sie in deinem Editor.

Der Kunde fragt: „Wann kommt 1042 an, und bis wann kann ich 1187 zurückgeben?“ Jetzt hat das Modell nur get_order_status und beantwortet die Hälfte:

Ausgabe
Frage: Wann kommt 1042 an, und bis wann kann ich 1187 zurückgeben?
Aufruf 1 (~42 Tokens): tool_use
call_1 get_order_status(1042) → 1042 — versandt, Lieferung 3. November
Aufruf 2 (~53 Tokens): end_turn
Antwort: Ich habe nachgesehen: 1042 — versandt, Lieferung 3. November.

Füge ein zweites Tool get_return_window hinzu, mit der Beschreibung „Der letzte Tag, an dem eine Bestellung zurückgegeben werden kann, anhand ihrer Nummer.“ und der Eingabe order_id. Gib in runTool 1187 — Rückgabe bis … mit dem Rückgabefeld der Bestellung zurück. Das Modell verlangt beide Tools in einem Aufruf:

Ausgabe
Frage: Wann kommt 1042 an, und bis wann kann ich 1187 zurückgeben?
Aufruf 1 (~61 Tokens): tool_use
call_1 get_order_status(1042) → 1042 — versandt, Lieferung 3. November
call_2 get_return_window(1187) → 1187 — Rückgabe bis 9. November
Aufruf 2 (~83 Tokens): end_turn
Antwort: Ich habe nachgesehen: 1042 — versandt, Lieferung 3. November; 1187 — Rückgabe bis 9. November.

Weitere Aufgaben

  • Die Bestellung eines anderen. Gib jeder Bestellung den Kunden, dem sie gehört, und answer den Kunden, der fragt. runTool gibt für die Bestellung eines anderen Kunden einen Fehler zurück. Was sagt das Modell dann?
  • Ein Token-Budget. Halte die Schleife auch an, wenn die Summe der Eingabe-Tokens 100 übersteigt. Bei welchem Aufruf hält die Frage „ungefähr 1050“ an?

Test

Teste dich

1. Das Modell hat mit stop_reason tool_use und einem Block get_order_status mit order_id 1042 geantwortet. Wer schlägt die Bestellung in der Datenbank nach?
2. Eine Antwort hat zwei tool_use-Blöcke. Woher weiß das Modell, welches Ergebnis zu welcher Anfrage gehört?
3. Der Kunde fragt nach zwei Dingen, und das Modell verlangt beide Tools in einer Antwort. Wie viele Aufrufe an das Modell macht answer()?
4. Warum hat die Schleife eine Grenze, MAX_CALLS?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·