Tool Calling mit Microsoft.Extensions.AI
- Matt
Ein Sprachmodell weiß nichts über die eigene Datenbank und nichts über die aktuelle Uhrzeit. Tool Calling schließt diese Lücke: Man reicht dem Modell eine Liste aufrufbarer Funktionen mit, es entscheidet, ob und welche es braucht, und bekommt das Ergebnis zurück in den Kontext.
Die Grundlage dafür ist IChatClient. Der unangenehme Teil war bisher die Schleife drumherum. Antwort entgegennehmen, prüfen ob ein Funktionsaufruf drinsteht, Argumente aus JSON deserialisieren, Methode aufrufen, Ergebnis anhängen, erneut fragen. Alles davon ist Fleißarbeit, und alles davon macht Microsoft.Extensions.AI inzwischen selbst.
Eine Funktion bereitstellen
Aus einer gewöhnlichen Methode wird über AIFunctionFactory.Create ein Werkzeug:
using System.ComponentModel;
using Microsoft.Extensions.AI;
[Description("Liefert die Anzahl offener Rechnungen eines Kunden.")]
static async Task<int> OffeneRechnungen([Description("Kundennummer, fünfstellig")] string kundennummer)
{
return await db.Rechnungen
.CountAsync(r => r.Kundennummer == kundennummer && !r.Bezahlt);
}
var optionen = new ChatOptions
{
Tools = [AIFunctionFactory.Create(OffeneRechnungen)]
};
Signatur und Beschreibungen ergeben das Schema, das das Modell zu sehen bekommt. Die Description-Attribute sind dabei nicht schmückendes Beiwerk, sondern die eigentliche Schnittstelle. Steht dort nichts Brauchbares, ruft das Modell die Funktion entweder gar nicht auf oder mit Unsinn als Argument.
Die Schleife übernehmen lassen
Damit die Aufrufe auch ausgeführt werden, braucht die Pipeline einen Baustein:
IChatClient client = new ChatClientBuilder(innerClient)
.UseFunctionInvocation()
.Build();
var antwort = await client.GetResponseAsync("Wie viele offene Rechnungen hat Kunde 40815?", optionen);
Das war es. UseFunctionInvocation fängt Funktionsaufrufe in der Antwort ab, ruft die passende Methode auf, hängt das Ergebnis an den Verlauf und fragt erneut, bis das Modell fertig ist.
Wo man die Zügel anziehen sollte
Genau diese Automatik ist der Punkt, an dem man aufpassen muss. Das Modell entscheidet, wie oft es aufruft, und in ungünstigen Fällen dreht es sich im Kreis. Deshalb würde ich die Grenzen nie auf den Standardwerten lassen:
.UseFunctionInvocation(configure: f =>
{
f.MaximumIterationsPerRequest = 5;
f.MaximumConsecutiveErrorsPerRequest = 2;
f.AllowConcurrentInvocation = false;
})
MaximumIterationsPerRequest deckelt die Runden. Ohne diese Grenze kann eine einzige Nutzeranfrage zwanzig Modellaufrufe auslösen, und die stehen am Monatsende auf der Rechnung.
AllowConcurrentInvocation würde ich nur einschalten, wenn wirklich alle Werkzeuge nebenläufig sicher sind. Ein DbContext ist es nicht, und der Fehler dabei ist unangenehm sporadisch.
Der wichtigste Punkt steht in keiner Konfiguration: Was hier aufgerufen wird, entscheidet ein Sprachmodell auf Basis eines Textes, der vom Nutzer stammen kann. Eine Funktion, die löscht, überweist oder Mails verschickt, gehört nicht ohne Rückfrage in diese Liste. Lesende Operationen sind unkritisch, alles andere braucht eine Bestätigung dazwischen. Der Rahmen nimmt einem die Schleife ab, nicht die Verantwortung dafür, was in der Schleife passieren darf.