Vom Textvorhersager zur Softwarekomponente: Schemas und Function Calling
Wie JSON Schema, strukturierte Ausgabe und Constrained Decoding den freien Text eines LLM in typisierte Daten verwandeln, denen Ihr Code vertrauen kann – bis hin zum Function Calling.
Vor Agenten, RAG und MCP steht eine grundlegendere Frage: Was ist ein LLM, und was tut es eigentlich? Ein großes Sprachmodell macht genau eine Sache. Es sagt das nächste Token voraus. Es liest den bisherigen Text, wählt das wahrscheinlichste nächste Token, hängt es an und sagt erneut voraus – in einer Schleife, bis es fertig ist.
Diese Schleife ist der gesamte Mechanismus. Alles andere, was rund um LLMs gebaut wird, setzt darauf auf. Daraus folgen zwei Dinge. Erstens weiß ein LLM nichts über Typen, Zahlen, Objekte oder Datenbanken. Es erzeugt Text und nur Text. Zweitens beruhen seine Vorhersagen auf Trainingsdaten, also auf einer Momentaufnahme der Vergangenheit; Antworten können daher veraltet sein, solange Sie keine frischen Informationen selbst in den Prompt geben.
Ein reiner Textgenerator zu sein wird zum echten Problem, sobald man Software darauf baut. Software ist deterministisch und typisiert, mit Objekten, Methoden und Zahlen. Ein rohes LLM liefert Ihnen einen String.
Das Problem ganz konkret
Angenommen, Sie fragen ein Modell nach dem Wetter. Jede dieser Antworten wäre möglich:
- „Es sind 22 °C und sonnig“
- „Etwa 22 Grad, klarer Himmel“
- „Ich würde sagen, ungefähr 22°“
Alle drei sind richtig, und alle drei sind für Ihren Code nutzlos. Ihr Code will, dass response.temp_c jedes Mal eine Zahl ist, damit er sie speichern, damit rechnen oder sie plotten kann. Mit einem Satz geht nichts davon, und auf natürliche Sprache lässt sich kein JSON.parse() anwenden. Ein regulärer Ausdruck mag heute funktionieren und an der Formulierung von morgen scheitern.
Achten Sie darauf, wie das hier schiefgeht. Nichts ist kaputt, und niemand hat einen Fehler gemacht; das Modell hat seine Arbeit getan, und dabei kam eben Prosa heraus, wo Ihr Programm einen Wert wollte. Das Problem ist also nicht, dass das Modell falsch liegt. Die Ausgabe hat keine feste Form. Um darauf aufzubauen, brauchen Sie vorhersagbare Ausgabe, nicht nur korrekte. Die Lösung erforderte einige Ideen, die aufeinander aufbauen.
JSON Schema
Beginnen wir mit JSON Schema. Es ist ein Rezept dafür, wie ein JSON-Objekt aussehen soll, wenn es ausgefüllt ist. Es enthält keine Daten. Es beschreibt nur die Form: welche Felder es gibt, welchen Typ jedes hat und welche Pflicht sind.
{
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. Istanbul" },
"temp_c": { "type": "number", "description": "Temperature in Celsius" },
"is_sunny": { "type": "boolean" }
},
"required": ["city", "temp_c"]
}
Jedes Feld bekommt einen type wie string, number oder boolean. Eine description gibt dem Modell mehr Kontext zu einem Feld, und die Liste required legt fest, welche Felder vorhanden sein müssen. In diesem Beispiel ist is_sunny optional.
Genau so etwas braucht eine Wetterabfrage: eine Stadt als String, eine Temperatur als Zahl und ein Flag, das da sein kann oder nicht. Ein Schema allein bewirkt nichts. Betrachten Sie es als Vertrag, der sagt: „So sehen gültige Daten aus.“ Im nächsten Schritt wird dieser Vertrag dem Modell übergeben.
Strukturierte Ausgabe
2024 bekamen LLMs eine Funktion namens strukturierte Ausgabe. Sie geben dem Modell ein JSON Schema, und es muss dieses ausfüllen, nicht mehr und nicht weniger. (Eine frühere, schwächere Variante garantierte nur gültiges JSON, ohne Zusage zu Feldern oder Typen. Die strukturierte Ausgabe hat diese Lücke geschlossen.)
Das funktioniert durch zwei Dinge zusammen, und keines reicht allein.
Das erste ist Training. Ab 2024 wurden neue Modelle darauf trainiert, Schemas zu verstehen und passende Ausgaben zu erzeugen. Aber ein Modell rät immer noch Token für Token, sodass selbst ein gut trainiertes dem Schema nur in etwa 90 % der Fälle folgte. Für echte Software sind 90 % ein Bug, der nur darauf wartet, aufzutreten.
Das zweite ist schlichter Code in der Inferenz-Engine, genannt Constrained Decoding. Das Modell gibt Tokens einzeln aus, und bei jedem Schritt prüft dieser Code das Schema und entfernt jedes Token, das es verletzen würde, indem er dessen Wahrscheinlichkeit auf null setzt. Das Schema wird in eine Grammatik übersetzt, also in Regeln dafür, welche Tokens als Nächstes kommen dürfen, und das Modell kann nur aus dem Rest wählen.
Das trainierte Modell erledigt den Großteil der Arbeit, und Constrained Decoding macht daraus eine nahezu hundertprozentige Garantie. Man kann sich die Aufteilung so vorstellen: Training macht die richtige Antwort wahrscheinlich, der Decoding-Schritt macht eine falsche Form unmöglich. Das Modell gibt weiterhin einen String aus, aber einen, der immer zum Rezept passt. Den parsen Sie dann in ein echtes Objekt und können ihm vertrauen.
// the recipe, the same kind of JSON Schema we saw above
const schema = {
type: "object",
properties: {
city: { type: "string" },
temp_c: { type: "number" }
},
required: ["city", "temp_c"]
};
// ask the model, and pass the schema to force the shape
const report = await askModel({
prompt: "Weather in Istanbul, sunny, 22°",
schema: schema,
});
report.temp_c; // 22, always a number. no parsing, no regex.
Jetzt können typisierter Code und das Modell endlich zusammenarbeiten, weil die Ausgabe eine Form hat, auf die Verlass ist.
Es gibt eine noch größere Konsequenz. Die Parameter einer Funktion sind nichts anderes als Felder mit Typen, und genau das ist eine strukturierte Ausgabe. Wenn das Modell jedes Schema ausfüllen kann, kann es auch die Argumente einer Funktion ausfüllen. Und sobald es korrekte Argumente erzeugen kann, kann es Ihrem Code praktisch sagen, welche Funktion mit welchen Werten laufen soll. Das liefert mehr als saubere Daten. Es liefert Methodenaufrufe, und dafür gibt es einen Namen.
Function Calling
Beim Function Calling senden Sie den Prompt zusammen mit einer Liste benannter Funktionen. Jede hat einen Namen, eine description, die sagt, was sie tut, und ein JSON Schema für ihre Parameter.
{
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
Die description trägt viel Gewicht, denn anhand von ihr entscheidet das Modell, dass diese Funktion die richtige ist. Das Modell liest Ihren Prompt, kommt zu dem Schluss, dass es das Wetter braucht, und antwortet mit dem Aufruf, der ausgeführt werden soll:
{ "name": "get_weather", "input": { "city": "Istanbul" } }
Diese Antwort ist nichts anderes als eine strukturierte Ausgabe. input enthält die Argumente, und name bezeichnet die Funktion. Das Modell hat nichts ausgeführt und kann es auch nicht. Auf Ihrer Seite parsen Sie das JSON, finden anhand von name die passende Funktion, übergeben input als Argumente, führen sie aus und schicken das Ergebnis zurück an das Modell. Einfach gesagt ist Function Calling eine strukturierte Ausgabe mit den Argumenten, kombiniert mit der Entscheidung des Modells, welche Funktion passt.
Genau das öffnet die Türen. Alles, was sich in eine Funktion verpacken lässt, wird erreichbar: eine Wetter-API, ein Zahlungssystem, eine Suchmaschine, eine Datenbank, Ihre eigenen Dienste. Jede Fähigkeit, die als Funktion mit Schema beschrieben ist, lässt sich an ein LLM anschließen. Bemerkenswert ist, wie wenig das Modell dafür braucht. Es greift nie direkt auf Ihre Systeme zu; es schlägt nur einen Aufruf vor, und Ihr Code entscheidet, ob und wie er ausgeführt wird. So wird aus einem Textfeld etwas, das über Ihren Code in der echten Welt handelt.
Das Schwierige war nie, zu beschreiben, was man will. Schwierig war, einen Textgenerator dazu zu bringen, sich an Regeln zu halten. Seit das gelöst ist, kann ein LLM ein ganz normaler Baustein in Ihrem Code sein, wie jedes andere Werkzeug.