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 pull llama3.2:3b
pulling manifest
pulling dde5aa3fc5ff...100% | 2.0 GB
pulling 966de95ca8a6...100% | 1.4 KB
pulling fcc5a6bec9da...100% | 7.7 KB
pulling a70ff7e570d9...100% | 6.0 KB
pulling 56bb8bd477a5...100% | 96 B
pulling 34bb5ab01051...100% | 561 B
verifying sha256 digest
writing manifest
success

$ ollama run llama3.2:3b
>>> Was ist die Hauptstadt von Deutschland?
Die Hauptstadt Deutschlands ist Berlin.
>>> /bye
Listing 1: Nutzung von Ollama auf der Kommandozeile
$ curl http://localhost:11434/api/chat -d '
{
    "model": "llama3.2:3b",
    "stream": false,
    "messages": [
        {
            "role": "user",
            "content": "Was ist die Hauptstadt von Deutschland?"
        }
    ]
}'
{
    "model":"llama3.2:3b",
    "created_at": "2024-10-03T13:40:55.508555Z",
    "message": {
        "role": "assistant",
        "content":"Die Hauptstadt Deutschlands ist Berlin."
    },
...
}
Listing 2: Nutzung von Ollama über HTTP-Schnittstelle

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.

...
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0-M2</version> <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    ...
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    </dependency>
    ...
</dependencies>
...
Listing 3: Abhängigkeiten für Spring AI
spring.ai.ollama.chat.options.model=llama3.2:3b
Listing 4: Konfiguration des zu nutzenden Ollama-Modells

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.

void simplePrompt(ChatClient.Builder clientBuilder) {
    var client = clientBuilder
            .defaultSystem("Antworte in Dialekt und Reimform.")
            .build();
    var answer = client.prompt()
            .user("Was ist die Hauptstadt von Deutschland?")
            .call()
            .content();
    System.out.println(answer);
}
Listing 5: Erzeugung und Nutzung eines ChatClient

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).

@RestController
class ChatController {
    private final ChatClient client;

    public ChatController(ChatClient.Builder clientBuilder) {
        var memory = new InMemoryChatMemory();
        this.client = clientBuilder
                .defaultAdvisors(new MessageChatMemoryAdvisor(memory))
                .build();
    }

    @GetMapping("")
    public String chat(@RequestParam String session,
            @RequestParam String question) {
        return client.prompt()
                .user(question)
                .advisors(a -> a
                    .param(CHAT_MEMORY_CONVERSATION_ID_KEY, session))
                .call()
                .content();
    }
}
Listing 6: Verwendung des MessageChatMemoryAdvisor

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.

var answer = client.prompt()
        .user(u -> u
            .text("Beschreibe was du auf dem Bild siehst?")
            .media(IMAGE_JPEG, new ClassPathResource("/image.jpg")))
        .call()
        .content();
Listing 7: Prompt mit Bild als Input

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.

@Bean
public VectorStore vectorStore(EmbeddingModel model) {
    return new SimpleVectorStore(model);
}

@Bean
public ApplicationRunner ingestion(VectorStore vectorStore,
        @Value("classpath:/offices.json") Resource resource) {
    return args -> {
        var documents = new JsonReader(resource, "zip", "city", "address").get();
        vectorStore.add(documents);
    };
}
Listing 8: Data Ingestion mit ETL-Unterstützung

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.

var answer = client.prompt()
        .advisors(new QuestionAnswerAdvisor(store, defaults(), """
            Du bist ein virtueller Assistant.
            Du beantwortest Fragen zu INNOQ Büros.
            Nutze dazu nur Informationen aus dem DOCUMENTS Abschnitt.
            Wenn du etwas nicht weißt gebe das zu und erfinde nichts.

            DOCUMENTS:
            {question_answer_context}"""))
        .user("Wo befindet sich das INNOQ Büro in Hamburg?")
        .call()
        .content();
Listing 9: Nutzung des QuestionAnswerAdvisor für RAG

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.

$ curl http://localhost:11434/api/chat -d '
{
    "model": "llama3.2:3b",
    "stream": false,
    "messages": [
        {
            "role": "user",
            "content": "Which employees knows Java?"
        }
    ],
    "tools": [
        {
            "type": "function",
            "function": {
                "name": "employeesForSkill",
                "description": "Get the employees that have a given skill",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "skill": {
                            "description": "The skill to search employees for.",
                            "type": "string"
                        }
                    },
                    "required": [
                        "skill"
                    ]
                }
            }
        }
    ]
}’
{
    "model": "llama3.2:3b",
    "created_at": "2024-10-03T14:07:45.318127Z",
    "message": {
        "role": "assistant",
        "content": "",
        "tool_calls": [
            {
                "function": {
                    "name": "employeesForSkill",
                    "arguments": {
                        "skill": "Java"
                    }
                }
            }
        ]
    },
...
}
Listing 10: Aufruf der HTTP-Schnittstelle mit Function Calling

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).

public class WeatherFunction
        implements Function<WeatherRequest, WeatherResponse> {

    @JsonClassDescription("Returns weather for given location and date")
    public record WeatherRequest(
            @JsonProperty(required = true) @JsonPropertyDescription("The latitude of the city") String latitude,
            @JsonProperty(required = true) @JsonPropertyDescription("The longitude of the city") String longitude,
            @JsonProperty(required = true) @JsonPropertyDescription("The date to retrieve the weather for, e.g. 2024-02-29") String date) {
    }

    public record WeatherResponse(
            String weather) {
    }

    @Override
    public WeatherResponse apply(WeatherRequest request) {
        System.out.println("WeatherFunction.apply");
        System.out.println("request = " + request);
        return new WeatherResponse("lots of rain");
    }
}

@Bean
public Function<WeatherRequest, WeatherResponse> weatherFunction() {
    return new WeatherFunction();
}
Listing 11: Definition der Funktion für Function Calling
var answer = client.prompt()
        .functions("weatherFunction")
        .user("How is today's weather in Berlin?")
        .call()
        .content();
Listing 12: Registrierung der Funktion beim Aufruf des LLM

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.

class DatabaseFunction
        implements Function<DatabaseRequest, DatabaseResponse> {

    @JsonClassDescription("""
        Use this function to answer questions about employee skills.
        Input should be a fully formed SQL query.
        """)
    public record DatabaseRequest(
            @JsonProperty(required = true) @JsonPropertyDescription("""
                SQL query for extracting data to answer the question.
                SQL should be written using the given schema:
                    Table: employees
                    Columns: id, name

                    Table: skills
                    Columns: id, name

                    Table: employee_skills
                    Columns: employee_id, skill_id

                    The query should be plain text and not JSON.""") String query) {
    }

    public record DatabaseResponse(
            List<Map<String, String>> result) {
    }

    @Override
    public DatabaseResponse apply(DatabaseRequest request) {
        System.out.println("DatabaseFunction.apply");
        System.out.println("request = " + request);
        return new DatabaseResponse(List.of(
                Map.of("e.name", "Michael"),
                Map.of("e.name", "Stefan")));
    }
}
Listing 13: Function Calling für vom LLM generierte SQL-Befehle

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).

Abb. 1: Metriken für Spring AI

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.

...
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
    <scope>runtime</scope>
</dependency>
...
Listing 14: Abhängigkeiten für Traces mittels OpenTelemetry
# Erzeuge Trace für jeden Request
management.tracing.sampling.probability=1.0
# Erweitere Traces um zusätzliche Information über die Anfragen
spring.ai.chat.client.observations.include-input=true
spring.ai.chat.observations.include-completion=true
spring.ai.chat.observations.include-prompt=true
Listing 15: Konfiguration für Traces
Abb. 2: Trace mit Informationen zum LLM-Aufruf

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.

Fazit

In diesem Artikel haben wir uns angeschaut, wie sich LLMs mit Spring AI in die eigene Anwendung integrieren lassen. Dazu haben wir lokale mit Ollama betriebene Modelle angesprochen und uns in diesem Rahmen angeschaut, wie der zugehörige Spring-AI-Code aussieht.

Neben normalen Textabfragen haben wir dabei auch gesehen, wie Bilder mit im Prompt verwendet werden können, und auch erweiterte Konzepte wie RAG oder Function Calling, um den Kontext unserer Abfragen zu erweitern, haben wir betrachtet. Die mitgelieferte Integration in die aus Spring Boot bekannten Observability-Mechanismen haben wir uns ebenfalls angeschaut.

Außerdem gibt es in Spring AI auch noch Unterstützung, um strukturierten Ausgaben aus einem LLM an Java-Objekte zu binden oder für das Testen von auf LLM basierenden Funktionalitäten. Zudem wird der in Spring Boot enthaltene Mechanismus für Docker Compose und Testcontainers um Container für LLMs und Vektordatenbanken erweitert. Möchte man diese nutzen, bietet sich auch ein Blick auf von Thomas Vitale fertig zur Verfügung gestellte Ollama Container an. Da sich sowohl die KI-Welt als auch Spring AI aktuell unter Umständen noch stark verändern und kontinuierlich erweitert werden, lohnt auch ein regelmäßiger Blick in die Reference-Dokumentation.

Der Platz in einer Kolumne ist immer beschränkt, daher bietet sich, bei Bedarf oder Interesse, auch ein Blick auf von Thomas Vitale zur Verfügung gestellte Beispiele für die Nutzung von Spring AI oder die von Christian Tzolov, Spring AI Committer, zur Verfügung gestellte Flugbuchungsanwendung an.