Ein gutes User Interface zu designen und umzusetzen, ist schwierig. Wir als Full Stack EntwicklerInnen wissen nur zu gut, dass der Teufel im Detail steckt. Einmal ist die Animation schlecht getimed, ein Input schiebt sich über den nächsten, ein Bild will einfach nicht in der richtigen Auflösung angezeigt werden und, und, und... Passt dann der erste Entwurf, kommen mal so nebenbei noch Responsive Webdesign, Accessibility oder sogar Native Apps hinzu. Als ich das erste Mal mit ChatGPT gearbeitet habe, ist mir sofort aufgefallen, dass deren Interface denkbar einfach gestaltet ist: ein Texteingabefeld, ein bisschen Dialogdarstellung und viel mehr ist da eigentlich nicht. Ein einfaches UI für ein mächtiges Tooling.
Doch in der realen Welt sind nun mal nicht alle Anwendungen Chatbots. Daher stelle ich mir die Frage: Können wir das Interface der Sprache vielleicht auch nutzen, um die Komplexität unserer Anwendung vor den Nutzern zu verstecken? Denn LLMs sind letztendlich nichts anderes, als ein Sprachinterface. Lassen sich damit einfachere Interfaces für höhere Barrierefreiheit erstellen?
Im Folgenden beschreibe ich, wie generative KI-Modelle auch für mehr als Chat-ähnliche Anwendungen verwendet werden können. Wie aus komplizierten Eingabemasken mit Schiebereglern, Textfeldern und Kalenderauswahlen ein einfaches Textfeld wird, um Nutzern jeder Sprache und Vorerfahrung die Nutzung zu vereinfachen. Um das zu bewerkstelligen, werden wir die Technik des Function Callings nutzen. Wie gewohnt implementieren wir von der Backend-Logik bis zum Frontend alles in TypeScript. Vielleicht wirst auch du nach dem Lesen ein paar Ideen für vereinfachte Interfaces haben, mir gehen diese seither nicht mehr aus dem Kopf!
Das Szenario
Einmal angenommen, du bist ein fleißiger Full Stacker. Du stehst jeden Tag um 8 Uhr auf, setzt dich an deinen Schreibtisch und bearbeitest schon mal die ersten Mails. Nach einer halben Stunde gibt es den ersten Kaffee, dann um 10:30 vielleicht eine zweite kleine Pause, bevor dann um 12 Uhr Mittag ist. Am Nachmittag musst du nochmal kurz zum Friseur und am Abend stehst du dann vor dem üblichen Dilemma: Hast du heute schon deinen Teil zum Bruttosozialprodukt beigetragen oder fehlt noch eine Stunde? Um das herauszufinden, möchtest du nun ein Tool bauen, das dir das Eintragen der Arbeitszeit erleichtert. Doch wie ätzend ist es, jede einzelne Arbeitsphase und jede Pause manuell einzutragen? Du denkst über die Texteingaben nach, machst es vielleicht mit Excel oder Jira, aber so richtig intuitiv fühlt es sich nicht an.
Unser Ziel heute ist, statt dieser traditionellen Mischung aus Buttons, Zahlen- und Texteingaben nur noch ein Textfeld anzuzeigen, in das man dann eine Beschreibung des Arbeitstags eingibt. Das LLM wird dann auf „magische“ Weise in der Lage sein, die Eingaben zu verarbeiten und zuverlässig (das ist hier das wichtige Stichwort) unsere „aufwendige“ Arbeitszeitberechnung zu starten.
Was ist Function Calling?
Zugegeben, würde man dasselbe heutzutage einfach mit ChatGPT machen, käme wahrscheinlich meistens das gleiche Ergebnis dabei heraus. Doch das große Problem dabei ist, dass diese Antworten unvorhersehbar wären.
Die Antwort von ChatGPT ist zwar gut aber leider unvorhersehbar
Man könnte dann versuchen, die Ausgabe mit Prompt Engineering zu kontrollieren, dem Modell zum Beispiel mitteilen, immer im gleichen Format zu antworten, doch auch das würde nur bei vielleicht 80 % der Fälle zum Erfolg führen.
Diese Problematik haben auch die Hersteller der LLMs verstanden und uns mit Function Calling ein Werkzeug an die Hand gegeben, um zuverlässigere Ausgaben mit vorgegebenem Schema zu erhalten. Im Prinzip funktioniert die Technik wie folgt:
- Wir definieren ein Set an Werkzeugen, die das LLM nutzen kann.
- Wir teilen dem LLM mit jeder unserer Anfragen mit, welche Werkzeuge es gibt und wie diese zu nutzen sind.
- Das LLM bekommt eine Aufgabe in Textform und entscheidet selbstständig, welches Tool am besten geeignet wäre, die Aufgabe zu bewerkstelligen. Es antwortet in einem vorgegebenen Textformat, das sich dann von uns interpretieren lässt.
- Mit der Antwort des Modells können wir nun unsere implementierten Funktionalitäten mit den vom Modell vorgeschlagenen Parametern ausführen.
- Optional: Die Antworten unserer Funktionen werden zurück an das LLM geschickt, damit dieses dann die initiale Fragestellung final beantworten kann. Diesen Schritt lassen wir für dieses Mal allerdings explizit aus, wir brauchen ihn nicht für unsere Anwendung.
Projekt Setup
Wenn du dem Artikel auch in deiner IDE folgen willst, kannst du einfach das Referenzprojekt von GitHub clonen.
Ganz ähnlich wie im zweiten Artikel zum Thema RAG nutzen wir LangChain und backroad als einfaches Chat-Interface.
Zur Definition des Schemas unseres Werkzeugs nutzen wir das kleine Tool zod
.
Installieren kannst du diese zum Beispiel mit yarn:
1yarn add @langchain/core @langchain/openai @backroad/backroad zod
Auch dieses Mal werden wir wieder eine Azure OpenAI Instanz nutzen. Denn leider unterstützen nicht alle LLMs Function Calling und ich habe die Erfahrung gemacht, dass gerade unsere lokalen Winzlinge ein wenig Probleme damit haben, zuverlässig die gegebenen Werkzeuge zu nutzen. Wie du das aufsetzt, habe ich im letzten Artikel dieser Serie erklärt.
Der Code
Im Prinzip hat unsere Anwendung drei Komponenten. Es gibt einerseits die Interaktion mit dem LLM. Das ist der wahrscheinlich interessanteste Teil, denn hier wird das Werkzeug definiert und Antworten interpretiert. Daneben gibt es das UI mit backroad und unsere "fancy" Rechenlogik, um auf Basis von Stunden und Pausen die tatsächliche Arbeitszeit zu berechnen. Letztere werde ich nur exemplarisch anreißen, denn das Zahlengeschubse ist wirklich einfachste Mathematik und für diese Serie eher irrelevant. Falls es dich näher interessiert, findest du alles auf GitHub.
Das Werkzeug
Zuerst müssen wir uns ein Werkzeug überlegen, welches alle Parameter enthält, die wir benötigen. Der Ansatz ist, den Start und das Ende der Arbeitszeit anzugeben. Zusätzlich benötigen wir noch die gemachten Pausen mit Start- und Endzeit. Als LangChain Tool sieht das so aus:
1import { DynamicStructuredTool } from "@langchain/core/tools"
2import { z } from "zod"
3
4const workdayTool = new DynamicStructuredTool({
5 name: "workDay",
6 description: "Returns start and end time of a work day. Only use once per day.",
7 schema: z.object({
8 start: z.string().describe("The start time when the work day started"),
9 end: z.string().describe("The time when the work day ended"),
10 breaks: z
11 .array(
12 z.object({
13 start: z.string().describe("The start time of the break"),
14 end: z.string().describe("The end time of the break"),
15 }),
16 ).describe("The breaks that were taken during the work day"),
17 }),
18 func: async () => "",
19})
Jedes Werkzeug und jeder definierte Parameter benötigt einen Beschreibungstext. Dieser ist besonders wichtig, denn anhand dessen muss das Modell entscheiden, ob das Werkzeug und der Parameter genutzt wird. Ich habe das in Englisch definiert, um noch ein wenig bessere Ergebnisse zu erzielen, denn die meisten der bekannten Modelle sind etwas besser im Englischen.
Die Parameter start
und end
sind jeweils Strings.
Meine Idee dabei ist, das für Zeiten übliche Format HH:MM
zu nutzen, das sich später auch einfach in ein JavaScript Date
-Objekt umwandeln lässt.
Ein Beispielaufruf dieses Tools sähe dann so aus:
1{ 2 name: "workDay", 3 args: { 4 start: "8:05", 5 end: "16:00", 6 breaks: [ 7 { start: "11:05", end: "11:35" }, 8 { start: "13:00", end: "14:00" }, 9 ], 10 }, 11 id: "1", 12}
Der Name des Tools, hier workDay
, wird als erstes genannt.
Im Feld args
nutzt das LLM dann unser vorgegebenes Schema.
Jede Antwort bekommt auch eine eindeutige ID, mithilfe derer sich mehrere parallele Aufrufe wiederfinden lassen.
Function Calling Prompt mit Beispiel
Um die Zuverlässigkeit der Ausgabe eines LLMs zu erhöhen, können Beispiele helfen. Das gilt einerseits für traditionelles Prompt Engineering und gleichzeitig eben auch für Function Calling. Wir tun einfach so, als wäre unsere Anfrage nicht die erste im Chatverlauf gewesen. Das motiviert das Modell später, sich ähnlich zu verhalten wie in unserem Beispiel.
1import { AIMessage, HumanMessage, SystemMessage, ToolMessage} from "@langchain/core/messages"
2
3const promptWithExample = [
4 new SystemMessage(
5 `Deine Aufgabe ist es, die Arbeitszeit zu berechnen. Nutze die Tools für die Berechnung.`,
6 ),
7 new HumanMessage(
8 "Ich habe um fünf nach 8 gestartet und dann 3 Stunden gearbeitet. Nach einer halben Stunde Pause habe ich nochmal geschuftet. Um 13 Uhr hatte ich eine Stunde Mittagspause nur um dann nochmal 2h ranzuglotzen.",
9 ),
10 new AIMessage({
11 content: "",
12 tool_calls: [
13 {
14 name: "workDay",
15 args: {
16 start: "8:05",
17 end: "16:00",
18 breaks: [
19 { start: "11:05", end: "11:35" },
20 { start: "13:00", end: "14:00" },
21 ],
22 },
23 id: "1",
24 },
25 ],
26 }),
27 new ToolMessage({
28 tool_call_id: "1",
29 content: "",
30 }),
31 new AIMessage({
32 content: `Done`,
33 }),
34]
Das Beispiel hat alles, was man sich wünscht. Es enthält Start- und Endzeit und zwei Pausen.
Die simulierte Antwort des Modells enthält keinen content
wie üblich, sondern stattdessen im tool_calls
-Feld einen Eintrag, der uns dazu auffordert, das Werkzeug workDay
aufzurufen.
Da die LangChain Implementierung des Function Callings vorsieht, dass zwingend die Antwort des Tools zurück an das Modell geschickt wird, müssen wir für einen vollständigen Beispiel-Call so tun, als wäre das geschehen. Anderenfalls lässt die Bibliothek nicht zu, erneut die KI anzufragen. Da uns jedoch völlig egal ist, was das Modell im Nachhinein mit der Antwort des Werkzeugs macht, ist dieser Teil des Beispiels auf ein Minimum reduziert.
Function Calling mit LangChain in TypeScript
Verknüpft wird das Ganze in der extractWorkTimeInMinutes
-Funktion.
Das Modell wird instanziiert und durch bindTools
mit unserem kleinen Werkzeug bereichert.
1async function extractWorkTimeInMinutes(text: string): Promise<number> {
2 const model = new AzureChatOpenAI().bindTools([workdayTool])
3 const result = await model.invoke([
4 ...promptWithExample,
5 new HumanMessage(text),
6 ])
7 const { start, end, breaks } = result.tool_calls[0].args
8 return calculateTotalWorkDayTime(start, end, breaks)
9}
Da wir in unserem kleinen Use Case davon ausgehen, dass nur ein Tool Call gemacht wird, greifen wir genau diesen ab und rufen damit die Berechnungslogik auf. Das ist natürlich überhaupt nicht fehlertolerant, reicht für jetzt aber erstmal aus.
Das UI
Ähnlich wie im RAG-Artikel können wir mit Backroad ganz einfach ein UI für unsere Arbeitszeitberechnung erstellen.
1function startChatUI() {
2 run(async (br) => {
3 const messages = br.getOrDefault("messages", [
4 { by: "ai", content: "Beschreibe deinen Arbeitstag!" },
5 ])
6
7 messages.forEach((message) => {
8 br.chatMessage({ by: message.by }).write({ body: message.content })
9 })
10
11 const input = br.chatInput({ id: "input" })
12 if (input) {
13 const response = await extractWorkTimeInMinutes(input)
14 br.setValue("messages", [
15 ...messages,
16 { by: "human", content: input },
17 { by: "ai", content: `Du hast ${response/60} Stunden gearbeitet` },
18 ])
19 }
20 })
21}
Im Browser sieht das Ergebnis dann so aus:
Das System erkennt korrekt, dass ich 7,75h gearbeitet habe
Es sieht also so aus, als ob dir noch eine Viertelstunde konzentrierten Codings fehlen. Zufällig genau so viel Zeit, wie du benötigst, um diesen Artikel zu lesen. 😉 Der von ChatGPT generierte Toolcall dafür war:
1[
2 {
3 "name": "workDay",
4 "args": {
5 "start": "07:00",
6 "end": "16:00",
7 "breaks": [
8 {
9 "start": "12:00",
10 "end": "12:30"
11 },
12 {
13 "start": "14:00",
14 "end": "14:45"
15 }
16 ]
17 },
18 "id": "call_Tt33yfzlfOrPxLK4ujlPoPis"
19 }
20]
Stolpersteine im Umgang mit Function Calling
Auch wenn es mir seit Wochen nicht gelingt, meinen Enthusiasmus über diesen Ansatz im Zaum zu halten, gibt es noch ein paar Kanten, die beachtet werden sollten.
Unvorhersehbar
Für Function Calling gilt, wie bei allen anderen GenAI-Ansätzen auch, dass die Ausgabe letztlich nicht deterministisch ist. Durch das Einpflegen von Beispielen, das Verfeinern von Prompts und einer klaren Toolbeschreibung, lassen sich die Ergebnisse zwar verbessern, dennoch ist es wichtig, eine ausgereifte Fehlerbehandlung zu etablieren. Das kann jedoch auch vor dem Nutzer versteckt werden, zum Beispiel könnte man Nutzereingaben zuvor in ein besser interpretierbares Format gießen mithilfe eines weiteren LLM-Prompts. Eine andere Möglichkeit sind automatisierte Reruns bei fehlerhafter oder nicht ausreichender Ausgabe.
Nicht lokal?
Ich habe einfachheitshalber Azure OpenAI genutzt, dessen Function Calling im Vergleich zu anderen Modellen zu einem der Besten gehört.
Das hat den Nachteil des nötigen Online-Zugangs. Gerade das zuvor genutzte llama3
(8B) glänzt nicht gerade mit Zuverlässigkeit in diesem Bereich.
Dennoch kommen immer wieder auch kleinere Modelle wie zum Beispiel Gorillas OpenFunctions in der Bestenliste vor.
Für den heutigen Use Case war dieses leider nicht gut zu gebrauchen, denn die quantisierte Version konnte lokal nicht zuverlässig Ergebnisse für die deutsche Sprache liefern.
Außerdem ist deren Syntax eine etwas andere.
Hat man die nötige Hardware, lassen sich aber natürlich auch größere Modelle lokal aufsetzen und Function Calling ohne Cloud realisieren.
Vorteile von Function Calling
Nachdem wir nun über ein paar Nachteile gesprochen haben, kann ich endlich ins Schwärmen kommen. Function Calling hat die folgenden größten Vorteile.
Einfachheit
Wie schon zu Beginn angedeutet, ist unser Interface nun maximal klar gehalten. Wir haben ein einfaches Textfeld, um selbst komplexere Eingaben für die NutzerInnen verständlich anzubieten. Es liegt nun nur noch die Sprache zwischen Nutzer und Logik.
Sprachverständnis
Das System hat durch ChatGPT ein überragendes Textverständnis. Es lässt sich nicht betonen, wie mächtig das eigentlich ist. Ich habe im Beispiel unterschiedlichste Wege genutzt, um Zeitspannen zu bezeichnen. Mal habe ich "3/4" geschrieben, mal die Zahl ausgeschrieben oder nur auf eine zuvor genannte Zahl referenziert. Mit LLMs kann man heute echtes Sprachverständnis nutzen und muss nicht wie früher auf eine mehr oder weniger strukturierte Keywordsuche hoffen. Das bedeutet auch, dass unsere App automatisch in jeder von ChatGPT verstandenen Sprache nutzbar ist – das kommt einfach so "umsonst" dazu.
Plötzlich versteht das System deutlich mehr Sprachen als ich
Automatisierung
Dadurch, dass die Ausgabe nun zwingend im gleichen Format kommt, lässt sich jedes Tool einfach automatisieren. Wir müssen nicht mehr darauf hoffen, dass ChatGPT das korrekte Format trifft, und gerade für komplexere Anwendungsfälle als den heutigen bekommt das System so eine deutlich höhere Verlässlichkeit.
Entwicklungsansätze
Um die Zuverlässigkeit und Qualität des Systems weiter zu erhöhen, gibt es wieder einige Ansätze. Unter anderen:
- Durch eine Aufbereitung der Eingabe mit LLMs könnten komplexere oder wenig verständliche Texte zunächst auf ihre wichtigsten Aussagen reduziert werden.
- Noch barrierefreier wäre eine Spracheingabe. Das ließe sich wiederum auch mit entsprechenden GenAI-Modellen umsetzen und wir hätten selbst das letzte Eingabefeld abstrahiert. Das würde sicher die Barrierefreiheit noch weiter erhöhen, besonders durch die wesentlich niedrigere Sprachbarriere.
- Mit einer Chathistorie könnte das LLM sogar noch mehr Kontext der Anfrage verstehen und selbst gestückelte Texte bearbeiten.
The Sky is the Limit
Mit Function Calling lassen sich zahlreiche Interfaces neu denken. Seitdem ich diesen Ansatz kenne, kommt mir fast täglich ein neuer Anwendungsfall in den Sinn. Ich bin der Meinung, dass hier noch sehr viel Potenzial auf der Straße liegt. Vielleicht habe ich es geschafft, in den letzten Minuten auch bei dir den Funken überspringen zu lassen. Die Full Stack Welt und nicht zuletzt sicher auch die Anbieter der großen LLM-Modelle 💰 könnten davon nur profitieren.
Nachdem wir dieses Mal einen aufregenden Schritt in Richtung Automatisierung genommen haben, werde ich in meinem nächsten Artikel sogar noch eine Schippe drauflegen. Ich betrachte, wie mithilfe von Function Calling aktiv handelnde Agenten implementiert werden können. Systeme, die selbst entscheiden, welche die besten nächsten Schritte sind und diese sogar ausführen. Mit diesem etwas gruseligen Ausblick entlasse ich dich nun in deine IDE. Viel Spaß!
Weitere Beiträge
von Robin Schlenker
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Robin Schlenker
Full Stack Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.