25 min

Werkzeuge, die man auch beschreiben kann

Die Beschreibung ist kein Kommentar, sondern der Teil des Werkzeugs, der im Prompt landet.

Meilenstein Das Schema weist eine falsche Kategorie mit einer Meldung ab, aus der hervorgeht, welche richtig wären.

Ein Werkzeug hat drei Teile, und zwei davon sieht das Modell nie. Den Rumpf nicht, die Datenbank dahinter nicht. Was es sieht, ist der Name, die Beschreibung und das Schema.

Das klingt nach einer Kleinigkeit und ist die Stelle, an der die meisten Server scheitern. Ein Werkzeug, das perfekt funktioniert und schlecht beschrieben ist, wird entweder nie aufgerufen oder immer. Beides fällt niemandem auf.

Die Beschreibung ist Prompt

Wo landet der Text aus description? In keiner Doku. Er wandert bei tools/list in den Kontext des Modells und steht dort neben dem Systemprompt, bei jedem einzelnen Aufruf, für die ganze Sitzung.

Daraus folgen zwei Dinge, die einander widersprechen. Erstens ist die Beschreibung der billigste Hebel, den du hast. Ein Satz mehr, und ein Werkzeug wird richtig benutzt. Zweitens kostet sie: Die Werkzeugliste des fertigen Helpdesks aus Kapitel 3 misst 4 206 Zeichen, also grob tausend Token, und die reisen in jedem Aufruf mit. Bei drei angeschlossenen Servern bist du schnell bei Zehntausend, bevor die erste Frage gestellt ist.

Der Baustein Tool-Calling im Betrieb rechnet das weiter aus. Für den Server hier reicht die praktische Fassung. Schreib die Beschreibung so, wie du einem neuen Kollegen den Unterschied zu dem Werkzeug daneben erklären würdest. Kein Satz mehr.

Was hineingehört

Vergleich zweier Fassungen desselben Werkzeugs.

// Fassung A
description: "Listet Tickets."
// Fassung B
description:
  "Listet die Tickets des Helpdesks - Betreffzeile, Absender, Eingang, Kategorie, Status. " +
  "`status` und `kategorie` grenzen ein, `limit` und `offset` blättern (Vorgabe 25, Höchstwert 100). " +
  "Der Volltext eines Tickets steht hier NICHT, den holt `get_ticket`."

Fassung B ist viermal so lang und beantwortet drei Fragen, die ein Modell sonst durch Ausprobieren beantwortet. Was kommt zurück? Wie grenzt du ein? Und vor allem: was kommt nicht zurück.

Der letzte Satz ist der wichtigste, und er ist der, den du beim Schreiben vergisst. Ein Modell, das den Volltext braucht und ihn in der Liste nicht findet, ruft die Liste erfahrungsgemäß noch zweimal auf, bevor es get_ticket probiert. Wer sein Werkzeug vom Nachbarwerkzeug abgrenzt, spart damit mehr Aufrufe als mit jeder Formulierungskunst.

Drei Dinge gehören in jede Beschreibung:

  • Was zurückkommt, in Feldern, nicht in Adjektiven.
  • Was die Argumente bewirken, samt Vorgabewert, wenn es einen gibt.
  • Die Abgrenzung zu dem Werkzeug, mit dem es verwechselt werden kann.

Und eines gehört nicht hinein: der Befehl. „Nutze dieses Werkzeug immer zuerst“ beschreibt nichts, sondern weist das Modell an, und diese Anweisung steht dann ungeprüft neben deinem Systemprompt, obwohl sie von einem ganz anderen Autor stammt als der Rest dessen, was das Modell dort liest. Bei einem fremden Server wird daraus eine Angriffsfläche; der Deep-Dive des Bausteins nennt sie Tool Poisoning.

Das Schema ist die Fehlermeldung

inputSchema ist ein Objekt aus zod-Feldern, kein zod-Objekt. Das SDK setzt es selbst zusammen, macht daraus JSON Schema für die Werkzeugliste und prüft damit jeden eingehenden Aufruf.

inputSchema: {
  key: z.string().regex(/^T-\d{4}$/, "Vorgangsnummer, etwa T-1041"),
  kategorie: z.enum(["frage", "stoerung", "rechnung", "recht", "spam"]),
},

Was soll bei einer erfundenen Kategorie passieren? Der Rumpf läuft gar nicht erst an. Zurück geht das hier, wörtlich:

MCP error -32602: Input validation error: Invalid arguments for tool
categorize_ticket: Invalid enum value.
Expected 'frage' | 'stoerung' | 'rechnung' | 'recht' | 'spam', received 'dringend'
at kategorie

Diese Meldung ist besser als das meiste, was du von Hand schreiben würdest. Sie nennt das Feld, den falschen Wert und die Werte, die gegolten hätten. Ein Modell kann daraus den nächsten Aufruf bauen, ohne zu raten, und tut es meistens auch.

Deshalb solltest du z.enum nehmen, wann immer ein Feld ein begrenztes Vokabular hat. Ein z.string() mit Prüfung im Rumpf lehnt dasselbe ab, nur ohne die Liste der gültigen Werte. Für .min(), .max() und .regex() gilt es genauso. Was ins Schema wandert, wird zur Selbstauskunft. Was im Rumpf steht, bleibt ein Rätsel.

Pflicht und Kür

Ein Feld ohne .optional() ist Pflicht. Fehlt es, sieht das Modell:

MCP error -32602: Input validation error: Invalid arguments for tool
answer_ticket: Required at antwort

Klingt trivial und entscheidet mit, wie dein Werkzeug benutzt wird. Jedes optionale Feld lädt dazu ein, es wegzulassen, und was weggelassen wird, muss dein Rumpf mit einem Vorgabewert auffüllen. Bei limit stimmt das so. Bei grund in escalate_ticket wäre es falsch: Wer ein Ticket abgibt, ohne zu begründen warum, hinterlässt einen Vorgang, den drei Wochen später niemand mehr einordnen kann.

Die Regel dahinter passt in einen Satz. Pflicht ist, was du hinterher im Protokoll lesen willst.

Zwei Werkzeuge oder eines mit Schalter?

Eine Frage, die beim dritten Werkzeug kommt. list_tickets mit einem Feld volltext: boolean, oder list_tickets und get_ticket getrennt?

Getrennt, fast immer. Ein Schalter, der die Ausgabeform umwirft, wird für ein Modell zur Falle, weil er in der Beschreibung als Nebensatz steht und du erst am Kontextverbrauch merkst, was er angerichtet hat. Zwei Werkzeuge mit klaren Namen kosten hundert Zeichen mehr in der Liste und ersparen dir die ganze Klasse von Fehlern, bei denen etwas funktioniert und trotzdem teuer ist.

Umgekehrt darf ein Schalter, der nur filtert, ruhig dabei sein. status und kategorie ändern nichts an der Form der Antwort, sie machen sie kürzer. Entscheidend ist also nicht die Anzahl der Argumente. Entscheidend ist, ob die Antwort noch dieselbe Gestalt hat.