Bereits vor einem Jahr hat Michael Hunger hier im Heft den damaligen Stand, 0.2.0-SNAPSHOT, von Spring AI vorgestellt. Seitdem hat sich die Welt weitergedreht und sowohl die Fähigkeiten von KI, hier im spezielleren LLMs, als auch Spring AI haben sich weiterentwickelt. Ich denke, es ist somit an der Zeit, sich anzuschauen, wie der aktuelle Stand aussieht.
Ollama
Mittlerweile haben wir bei der LLM-Auswahl die Qual der Wahl. Neben, dem ersten und wohl bekanntesten, OpenAI gibt es mit Gemini von Google, Bedrock von Amazon und Azure OpenAI von Microsoft und vielen weiteren eine Menge Optionen, aus denen wir wählen können.
Für diesen Artikel nutzen wir jedoch mit Ollama die Möglichkeit, freie LLMs lokal bei uns auf dem eigenen Rechner auszuführen. Somit sind wir beim Ausprobieren unabhängig von der Cloud und brauchen uns weder mit der Erstellung von Konten noch mit möglichen Kosten auseinanderzusetzen.
Der Nachteil, dass wir damit kleinere LLMs nutzen, welche etwas schwächere Antworten geben und, je nach Hardwareausstattung, auch meistens etwas länger für eine Antwort brauchen, können wir beim Herumspielen mit Spring AI beziehungsweise für die eigentliche Integration getrost ignorieren. Um allerdings in realen Anwendungen das beste Ergebnis zu erhalten, sollte beim Optimieren, beispielsweise durch bessere Prompts, dann auch das final gewählte LLM genutzt werden. Nur so lassen sich die Ergebnisse qualitativ wirklich bewerten.
Nach der Installation und dem Starten von Ollama können wir über die Kommandozeile LLMs herunterladen und nutzen (s. Listing 1). Alternativ lässt sich auch mit Open WebUI eine an ChatGPT angelehnte, im Browser erreichbare Nutzeroberfläche starten. Diese, wie auch Spring AI, nutzt die zusätzlich von Ollama angebotene HTTP-Schnittstelle, um mit den LLMs zu interagieren (s. Listing 2).
Ollama in Spring AI anbinden
Für die Anbindung von Ollama in Spring AI binden wir in unserem Spring-Boot-Projekt den Spring AI Starter für Ollama ein (s. Listing 3). Außerdem konfigurieren wir, welches Modell standardmäßig verwendet werden soll. In Listing 4 haben wir uns für das, zum Redaktionsschluss recht neue, Llama 3.2 in der Variante 3b entschieden.
Nun steht der Nutzung nichts mehr im Weg und wir können ein erstes Prompt erzeugen und an das LLM schicken (s. Listing 5). Hierzu lassen wir uns den vom Starter zur Verfügung gestellten ChatClient.Builder
als Abhängigkeit injizieren. Über diesen können wir dann, wie beim Builder-Muster üblich, die zu erzeugende Instanz erst konfigurieren und dann erzeugen. In diesem Falle geben wir damit eine Systemnachricht vor, die bei jeder Anfrage mit an das LLM geschickt wird und damit dem LLM einen Kontext zur Beantwortung vorgibt. Der so erzeugte ChatClient kann anschließend genutzt werden. Dieser stellt dabei eine Fluent-API zur Verfügung, bei der wir über eine Verkettung von Methoden unsere Anfrage formulieren, abschicken und die Antwort verarbeiten können.
Das LLM um ein Gedächtnis erweitern
Stellen wir an unseren angebotenen Endpunkt eine zweite Frage, bei der wir uns auf die vorherige beziehen, stellen wir fest, dass diese nicht mehr im Kontext vorhanden ist. Das liegt daran, dass LLMs selbst, zumindest üblicherweise, keinen Zustand halten. Wir müssen also selbst dafür sorgen, diesen bei jeder Anfrage wieder mitzuschicken.
Wir könnten uns nun also, beispielsweise innerhalb einer Session, die Frage und Antwort speichern und bei der nächsten Anfrage wieder mit an das LLM schicken. Glücklicherweise unterstützt uns Spring AI dabei seit Kurzem mit einem MessageChatMemoryAdvisor
(s. Listing 6).
Ein Advisor implementiert dabei RequestResponseAdvisor
und damit die beiden Methoden adviseRequest
und adviseResponse
. Diese werden dann vom ChatClient
vor beziehungsweise nach dem Verschicken an das LLM aufgerufen und erhalten neben der Anfrage beziehungsweise Antwort auch einen zweiten Parameter, um einen Kontext durchzureichen. In diesem Fall merkt sich der Advisor mithilfe einer ChatMemory
-Implementierung alle Fragen und Antworten unter einem Key und fügt diese automatisch bei der nächsten Anfrage mit in das Prompt ein.
Es darf auch mehr als Text sein
Bisher haben wir nur den Fall betrachtet, bei dem wir dem LLM einen Text als Input schicken und auch Text als Antwort erwarten. Neben Text unterstützt Spring AI auch noch Bilder und Ton, sowohl als Ein- als auch als Ausgabe. Allerdings hängt die Unterstützung hier vom gewählten LLM-Provider ab. Im Falle von Ollama ist dieser, Stand heute, auf Text als Ausgabe beschränkt.
Neben Text als Eingabe können wir aber auch Bilder mitliefern. Hierfür müssen wir allerdings auch ein Modell wählen, welches Bilder als Eingabe unterstützt. Hierzu wechseln wir auf das llava-llama3-Modell und können anschließend Fragen zu Bildern (s. Listing 7) stellen. Dabei müssen wir in dieser Kombination noch darauf achten, dass das sogenannte Kontextfenster des LLM nur eine gewisse Größe hat. Größere Bilder lassen sich in unserer Kombination dementsprechend nicht analysieren, da diese das Fenster sprengen.
Für die Generierung von Bildern bietet uns Spring AI, quasi parallel zum ChatClient
, ein ImageModel
an, für das es diverse Implementierungen gibt. Auch die Verarbeitung oder Erzeugung von Ton ist möglich. Hier gibt es aktuell noch keine Abstraktion, aber zwei OpenAI spezifische Implementierungen, die genutzt werden können.
Retrieval Augmented Generation
Neben der bereits gesehenen Zustandslosigkeit ist eine weitere Eigenschaft von LLMs, dass diese generisch sind und damit kein spezifisches Wissen über unsere eigenen Daten haben. Diese müssen wir deswegen bereits mit in der Anfrage an das LLM inkludieren. Die primäre Aufgabe des LLM besteht somit primär darin, aus den gegebenen Daten und der Anfrage einen Text als Ausgabe zu erzeugen.
Diesen Prozess nennt man Retrieval Augmented Generation oder kurz RAG. Das Hauptproblem besteht somit darin, wie wir an die passenden Daten zur Anfrage kommen, um diese mit an das LLM zu übergeben. An dieser Stelle kommen Vektor basierte Datenbanken mit ins Spiel. Vereinfacht gesagt, sind dies spezielle Key-Value-Datenbanken, bei denen der Schlüssel ein Vektor mir sehr vielen Dimensionen ist. Um nun passende Daten zu einer Anfrage aus dieser Datenbank zu laden, erzeugen wir auch aus der Anfrage einen Vektor und laden anschließend eine Anzahl von Informationen, bei denen die Entfernung zwischen den Vektoren am geringsten ist. Oder anders ausgedrückt, alle Daten, die in der Nähe der Anfrage sind, werden geladen.
Spring AI unterstützt uns bei diesem Prozess nun an drei Stellen. Zum Ersten stellt Spring AI mit dem Interface VectorStore
eine Abstraktion über aktuell fast 20 Implementierungen zur Verfügung. Diese Abstraktion bietet uns dabei die Möglichkeit, Dokumente hinzuzufügen, zu löschen und zu einem gegebenen Suchterm ähnliche Dokumente zu finden. Für das Suchen und Hinzufügen wird intern ein EmbeddingModel
genutzt. Dieses wird zur Generierung der Vektoren genutzt und verwendet hierzu wiederum ein LLM.
Bevor wir jedoch Dokumente aus der Vektordatenbank herausholen können, müssen diese erst mal dort hineingelangen. Dieses, Data Ingestion genannte, Verfahren ist das zweite, bei dem uns Spring AI unterstützt. Da es sich hierbei um eine Pipeline handelt, die Daten aus einer Quelle extrahiert, diese transformiert und anschließend in die Vektordatenbank lädt, wird dieser Bereich in Spring AI als ETL-Pipeline bezeichnet. Dabei gibt es für jede Aufgabe ein Interface DocumentReader
, DocumentTransformer
und DocumentWriter
und für jeden Schritt auch bereits fertige Implementierungen. Listing 8 zeigt, wie wir beim Start der Anwendung Daten, hier Informationen zu Büros einer Firma, aus einer JSON-Datei laden und ohne Transformation in einen VectorStore
laden.
Für die Berechnung der Vektoren haben wir, über die Konfigurationsoption spring.ai.ollama.embedding.model
, als Modell nomic-embed-text gewählt. Neben der JSON-Implementierung bringt Spring AI auch Unterstützung für weniger strukturierte Formate wie Text, Markdown, PDFs und über Apache Tika für viele andere Formate mit. Für solche Daten bietet sich dann auch eine Transformation an, um die Daten in kleinere Chunks zu splitten oder diese mit Metadaten anzureichern.
Zuletzt unterstützt uns Spring AI dann auch dabei, ähnliche Daten aus der Vektordatenbank zu laden und diese mit an das LLM zu übertragen. Hierzu lässt sich der QuestionAnswerAdvisor
(s. Listing 9) nutzen. Dieser führt dabei automatisch, bevor der Aufruf an das LLM geht, eine Suche in der Vektordatenbank durch und übergibt die Ergebnisse in der Variable question_answer_context an unser Prompt. In diesem können wir diese also an der passenden Stelle einfügen und geben die Daten somit mit in das LLM zur Beantwortung.
Function Calling
Neben RAG gibt es mit Function Calling eine zweite Option, um dem LLM anwendungsspezifischen Kontext mitzugeben. Technisch gesehen ruft dabei jedoch nicht das LLM selbst diese Funktionen auf, sondern teilt uns in seiner Antwort mit, dass wir das tun sollen (s. Listing 10). Nach dem Aufruf der Funktion rufen wir dann das LLM erneut auf und übergeben neben der ersten Frage und Antwort auch das Ergebnis der Funktion. Somit kann das LLM nun dieses Ergebnis nutzen, um eine finale Antwort zu erzeugen.
Auch hierfür gibt es Unterstützung von Spring AI und wir müssen uns nicht selbst darum kümmern, die Antwort auszuwerten, die Funktion auszuführen und das LLM anschließend erneut aufzurufen. Wir müssen lediglich eine Spring-Bean vom Typ Function<In, Out>
definieren, den Typ für In
mittels Annotationen mit Dokumentation erweitern (s. Listing 11) und die Funktionen am ChatClient
registrieren (s. Listing 12).
Dabei ist auch zu sehen, dass das LLM vor dem Aufruf der Funktion bereits Dinge verarbeiten kann. So würde es bei einer Anfrage der Art „Wie wird das Wetter morgen in Berlin?“ sowohl den Ort Berlin in Längen- und Breitengrad umwandeln als auch die Zeitangabe morgen in das passende Format. Bei den lokalen LLMs von Ollama ist dabei zu beachten, dass diese nicht wissen, welcher Tag heute ist, und wir diese Information zusätzlich als Kontext mitgeben müssten.
Weil ich bei meinen ersten Versuchen von Function Calling so fasziniert war, habe ich noch eine zweite Funktion (s. Listing 13) geschrieben, welche SQL als Input erwartet und die Struktur von drei Tabellen beschreibt. Registrieren wir nun diese Funktion und rufen das LLM mit der Frage „Wer kann Java?“ auf, können wir sehen, dass das LLM tatsächlich unsere Funktion mit einer SQL-Abfrage aufruft. Bei mehreren Aufrufen wechselt die Art der Abfrage bei mir zwischen Join und Subselect. Und ab und an fuscht das LLM auch und setzt erfundene IDs in die Abfrage mit ein. Das liegt aber, wie bereits gesagt, an der Größe und Qualität der lokalen LLMs und sollte bei den großen kommerziellen Modellen deutlich besser und zuverlässiger funktionieren.
Observability
Neben den bisher gezeigten direkt nutzbaren Funktionalitäten enthält Spring AI auch eine eingebaute Unterstützung für Observability in der Form von Metriken und Tracing. Für die Ansicht der Metriken reicht es, wie aus normalen Spring-Boot-Projekten gewohnt, den Starter für Spring Actuator hinzuzufügen (s. Abb. 1).
Fügen wir nun auch noch die passenden Abhängigkeiten und Konfiguration für das Exportieren von Traces hinzu (s. Listings 14 und 15) werden auch diese aufgezeichnet und können in passenden Observability-Anwendungen (s. Abb. 2) betrachtet werden.
Möchte man zusätzlich einmal sehen, welche Anfragen von Spring AI an das LLM verschickt werden, kann der Advisor SimpleLoggerAdvisor
aktiviert werden. Anschließend loggt der Logger org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor auf dem Level DEBUG alle ausgehenden Anfragen und einkommenden Antworten vom LLM.