Spring AI 1.0: Ollama mit Llama von Meta AI einsetzen und mit Spring AI Prompts erstellen

Seit der Veröffentlichung von ChatGPT ist das Thema Large Language Models (LLMs) in aller Munde. Auch frei verfügbare Modelle haben stark aufgeholt, sodass mittlerweile auch private, lokal geschützte Anwendungen ohne Cloud-Anbindung möglich sind.
KI-Modelle werden in der Regel über REST-APIs angebunden, um die Kommunikation unabhängig von Plattform und Sprache zu ermöglichen. Zwar hat jeder Anbieter eigene APIs, doch es gibt inzwischen abstrahierte APIs, die eine einheitliche Schnittstelle bereitstellen. Damit lassen sich verschiedene Modelle und Anbieter über einen gemeinsamen Zugang nutzen.
Wichtig ist die Unterscheidung zwischen APIs zur Ausführung eines trainierten Modells zur Vorhersage (sogenannte Inference) und solchen, mit denen man Modelle selbst trainieren oder feintunen kann. Letzteres ist typischerweise Python-basiert und setzt oft spezialisierte Hardware wie GPUs voraus.
Für Java-Entwickler bieten sich für das Ansprechen von Modellen zwei Bibliotheken an:
- "LangChain4j" [1] ist eine Java-Variante des bekannten LangChain-Frameworks aus der Python-Welt. Mit dem zugehörigen "Spring Boot Starter" [2] lässt es sich gut in moderne Java-Anwendungen integrieren.
- "Spring AI" [3] ist ein relativ neues Projekt aus dem Spring-Ökosystem, das sich speziell auf die Integration von KI-Funktionalität in Spring-Boot-Anwendungen konzentriert.
Diese Bibliotheken bieten Abstraktionen über unterschiedliche KI-Anbieter hinweg und ermöglichen dadurch den einfachen Wechsel zwischen Modellen und das mit minimalem Codeaufwand.
Spring AI wurde seit Juli 2023 entwickelt und durchlief viele API-Änderungen, bevor im Mai 2025 die stabile Version 1.0 erschien. In diesem Artikel wollen wir Ollama mit Llama von Meta AI einsetzen, um mit Spring AI einen einfachen Prompt zu realisieren und Funktionen aufzurufen.
Verwaltung von KI-Modellen mit Ollama
Ollama ist eine Plattform zur lokalen Ausführung und Verwaltung von KI-Modellen. Nach der Installation [4] kann ein Modell wie Llama3 mit folgendem Befehl geladen werden:
ollama pull llama3.2
Danach lassen sich Modelle direkt über die Shell starten und für Prompts nutzen:
ollama run llama3.2 "write a dating profile for a pirate, 20 words"
Spring AI Ollama Integration mit Spring Boot
Jedes Modell (oder jede abstrahierte API) benötigt einen spezifischen Spring Boot Starter. Am einfachsten geht das über den "Spring Initializr" [5]. Da wir mit Ollama arbeiten möchten, wählt man bei den Dependencies "Ollama" aus, und der Starter spring-ai-ollama-spring-boot-starter wird automatisch hinzugefügt.
In der application.properties wird das gewünschte Modell konfiguriert, zum Beispiel:
spring.ai.ollama.chat.model=llama3.2
spring.main.web-application-type=none
Die zweite Einstellung deaktiviert den eingebetteten Webserver, da wir nur einfache Konsolenanwendungen realisieren. Das Modell lässt sich grundsätzlich auch in Code setzen, sodass es möglich ist, verschiedene Modelle in einem Spring AI Programm einzusetzen.
Spring Boot stellt über Autokonfiguration eine Spring-managed Bean vom Typ ChatClient.Builder bereit. Diese kann verwendet werden, um ein ChatClient-Objekt zu erstellen und eine einfache Anfrage zu formulieren:
@SpringBootApplication
public class DemoApplication {
DemoApplication(ChatClient.Builder chatClientBuilder) {
ChatClient chatClient = chatClientBuilder.build();
System.out.println(
chatClient.prompt("write a dating profile for a pirate,
20 words").call().content()
);
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Eine mögliche Ausgabe könnte lauten:
"Swashbucklin' buccaneer seeks treasure in love. Sailing the seas
and searching for a mate to share me booty."
Eine grundlegende Besonderheit der Spring AI API ist der konsequente Einsatz des Builder Patterns in Kombination mit einer Fluent API. Nahezu alle Objekte werden über einen Builder erstellt. In den letzten Milestones und Entwicklungsphasen wurde das Design mehrfach überarbeitet, mit dem Ziel, eine nahezu vollständige und konsistente Verwendung des Builder Patterns zu erreichen. Deswegen sind viele alte Beispiele nicht mehr compilierbar.
Das folgende Diagramm verdeutlicht den typischen Fluss der beteiligten Typen:
Über den Builder erhalten wir ein ChatClient-Objekt. Mit der Methode prompt(...) wird ein ChatClientRequestSpec erzeugt, das die Konfiguration der Anfrage ermöglicht. Ein anschließender Aufruf von call() liefert ein CallResponseSpec. Von dort aus stehen verschiedene Zugriffsmöglichkeiten auf das Ergebnis zur Verfügung:
- Die Methode content() gibt einen String zurück. Diese ist dann geeignet, wenn keine komplexen Inhalte wie Bilder oder Audiodaten generiert werden sollen, sondern die Antwort einfach Text ist.
- Allgemeiner kann man mit der Methode chatResponse() arbeiten. Diese liefert ein ChatResponse-Objekt zurück, über das mehrere Antworten, Medien oder strukturierte Inhalte abgerufen werden können.
Zugriff auf lokale Daten – manuelles Einfügen von Daten in den Prompt
LLMs werden in der Regel nur in unregelmäßigen Abständen veröffentlicht. Dies bedeutet, dass zwischen den Veröffentlichungen erhebliche Zeiträume liegen können, in denen neue Informationen nicht erfasst werden. Fragt man z. B. ChatGPT 4o: "Auf welchem Stand sind deine Informationen?", kommt als Antwort (Stand Juli 2025): “Mein Informationsstand reicht bis Juni 2024."
Das Training eines LLMs erfordert erhebliche Zeit, Rechenleistung und damit verbundene Kosten. Es kann mehrere Wochen dauern, benötigt Tausende GPU-Stunden und verursacht – je nach Modellgröße und Datenmenge – Kosten in Millionenhöhe.
Daher ist es wichtig zu verstehen, dass ein LLM niemals den aktuellen Zeitpunkt berücksichtigen kann. Es kann niemals alle Informationen enthalten, die zu einem bestimmten Zeitpunkt auf der Welt verfügbar sind. Insbesondere sind eigene Unternehmensdaten, die lokal und spezifisch sind, niemals Teil eines öffentlichen LLMs. Diese Daten sind dynamisch und ändern sich ständig, was bedeutet, dass sie nicht in den statischen Datensatz eines LLMs integriert werden können.
Trotz dieser Einschränkungen gibt es verschiedene Ansätze, um solche Informationen dennoch für das Modell zugänglich zu machen und in die Verarbeitung einzubinden.
- Eine Möglichkeit besteht darin, wichtige Informationen als Systemnachricht vor dem ersten Prompt zu übergeben. Beispiel: Dem Modell wird vorab mitgeteilt, dass alle Beträge in Euro angegeben sind.
- Alternativ lassen sich lokale Daten manuell in den Prompt einfügen. Beispiel: Ein Support-Mitarbeiter überträgt den Text eines aktuellen Kundenvorgangs direkt in die Eingabeaufforderung, damit das Modell auf dieser Basis eine passende Antwort generieren kann. Diese Methode ist einfach, aber begrenzt, denn das Kontextfenster moderner LLMs wie GPT-4 ist auf etwa 32.000 Token beschränkt. Ist der Text zu lang, kann ein Teil davon abgeschnitten oder ignoriert werden.
- Effizienter und skalierbarer ist der Einsatz einer Vektordatenbank in Kombination mit RAG (Retrieval Augmented Generation), bei dem ähnliche Inhalte zur Anfrage ermittelt und automatisch in den Prompt eingebettet werden. Beispiel: Bei einer juristischen Frage identifiziert die KI automatisch relevante Paragrafen und fügt nur diese gezielt in den Prompt ein, anstatt den kompletten Gesetzestext zu verarbeiten.
- Eine leistungsfähige Methode ist das Function Calling. Dabei erkennt das Modell, wann externe Funktionen – etwa für Datenbankabfragen – erforderlich sind und löst entsprechende Aufrufe aus. Das schauen wir uns im Folgenden genauer an.
Fun Fact: Wenn der Automat das FBI ruft
In der Studie "Vending-Bench" wurde ein KI-Agent damit beauftragt, einen virtuellen Getränkeautomaten eigenständig zu betreiben – inklusive Einkauf, Preisstrategie und Buchhaltung. Zunächst lief alles gut, doch nach einiger Zeit eskalierte die Lage: Eine tägliche Mietgebühr von 2 USD wurde vom Modell fälschlich als Diebstahl interpretiert, weil die erklärende Notiz dazu inzwischen aus dem begrenzten Kontextfenster verschwunden war. Die Konsequenz: Der Agent schlug vor, das FBI einzuschalten und stellte den Betrieb ein. Das zeigt deutlich, wie wichtig ein funktionierendes Gedächtnismanagement und ein bewusster Umgang mit dem Kontextfenster bei LLMs sind.
Function Calling in LLMs: externe Datenquellen und APIs dynamisch einbinden
Function Calling ermöglicht es einem LLM, externe Funktionen oder APIs aufzurufen, um zusätzliche Informationen zu beschaffen oder bestimmte Aktionen auszuführen. Anwendungsbeispiele dafür sind das Abrufen aktueller Wetter- oder Börsendaten, die Durchführung von Berechnungen, das Abfragen von Datenbanken oder auch die Steuerung von Smart-Home-Geräten. Die Ausführung dieser Aktionen erfolgt stets außerhalb des Modells.
Der typische Ablauf umfasst folgende Schritte:
Zuerst erkennt das LLM, dass eine externe Funktion (Tool genannt) benötigt wird. Anschließend extrahiert es die erforderlichen Parameter aus dem Prompt und gegebenenfalls aus dem Kontext. Danach generiert das Modell einen strukturierten Funktionsaufruf, der von Spring AI verarbeitet und ausgeführt wird. Das Ergebnis der Funktion wird an das LLM zurückgesendet und dort in die Antwort eingebettet.
Im Folgenden wollen wir sehen, wie wir eine Funktion aufrufen können. Llama 3 erlaubt das. Es ist jedoch wichtig zu verstehen, dass nicht jedes Modell über diese Fähigkeit verfügt. Wer ein anderes Modell verwendet, sollte vorab unter [6] prüfen, ob dieses Function Calling unterstützt.
In Spring AI gibt es drei Hauptwege, um eine Funktion als Tool bereitzustellen. Erstens kann man eine Methode als Spring Bean definieren und dem ChatClient ihren Namen mitteilen. Zweitens lässt sich ein FunctionToolCallback verwenden, um die Methode direkt zu registrieren. Drittens besteht die Möglichkeit, ein ToolCallback selbst zu implementieren. Wählen wir die erste Variante.
Zunächst wird eine Funktion realisiert – beispielsweise als Implementierung der Schnittstellen Function, Supplier, Consumer oder BiFunction – und als Spring Bean bereitgestellt. Der Name dieser Bean wird automatisch als Tool-Name verwendet, kann aber bei Bedarf überschrieben werden. Mit der Annotation @Description kann man dem Modell erklären, was die Funktion macht. Schließlich wird der Tool-Name über toolNames("beanName") im ChatClient oder ChatModel registriert.
Das folgende Beispiel zeigt, wie eine Funktion in Spring AI definiert wird, die anhand einer ID ausgewählte Informationen zu einem Piraten – konkret dessen Spitznamen und Schwertlänge – zurückliefert:
@Configuration
public class FindProfileByIdSpringAiFunction {
@JsonClassDescription("API request to retrieve a pirate profile
by its unique ID.")
public record Request(
@JsonPropertyDescription("A unique identifier for the pirate profile.
"Must be a positive integer (>0).")
int id
) {}
@JsonClassDescription("API response containing the pirate's"
nickname and sword length.")
public record Response(
@JsonPropertyDescription("The pirate's nickname.")
String nickname,
@JsonPropertyDescription("Length of the pirate's sword,
specified in units, ranging from 0 to 100.")
int swordlength
) {}
@Bean
@Description("Retrieve the profile of a pirate by its given ID,
including the pirate's nickname and sword length.")
public Function<Request, Response> findProfileById() {
return request -> switch (request.id) {
case 1 -> new Response("BigBalls", 10);
case 2 -> new Response("CandyCane", 3);
default -> throw new IllegalArgumentException();
};
}
}
Eingabe- und Ausgabetypen sind als Java Records modelliert, ergänzt durch @JsonClassDescription und @JsonPropertyDescription, um das JSON-Schema semantisch anzureichern. Diese Angaben sind nicht für den Menschen gedacht, sondern werden vom Sprachmodell benötigt, um korrekt die Eingabe der Funktion und das Ergebnis zu verstehen. Die Methode selbst liefert eine Spring-managed Bean vom Typ Function<Request, Response>. In dem Beispiel sind die Typen Request und Response genannt, um die ausgetauschten Behälter besser zu kennzeichnen. Aber selbstverständlich sind beliebige Typnamen erlaubt.
Um die Funktion aufzurufen, reicht ein einfacher Prompt, der mit der entsprechenden Tool-Konfiguration kombiniert wird. Das Modell erkennt automatisch, dass es den findProfileById-Funktionsaufruf benötigt, führt diesen aus und verarbeitet die Antwort in der Ausgabe:
Prompt prompt = new Prompt(
"Create a dating profile based on the nickname of a pirate
profile with ID 1. 20 words.",
OllamaOptions.builder().toolNames("findProfileById").build());
String content = chatClient.prompt(prompt).call().content();
System.out.println(content);
Die Ausgabe könnte wie folgt lauten:
"Swashbuckler seeks treasure in love! Big Balls at your service, seeking partner for high seas adventures and cozy nights by the fire. Must appreciate good rum and a trusty sword"
In der Ausgabe kann man erkennen, dass plötzlich der Nickname “Big Balls” auftaucht, der im Prompt überhaupt nicht genannt wurde.
Dass tatsächlich die Funktion aufgerufen wird, kann man auch im Log-Strom ablesen, wenn der Log Level vom Spring-AI runtergesetzt wird:
logging.level.org.springframework.ai=DEBUG
Auf der Konsole erscheint:
… DEBUG … --- [...] o.s.a.t.r.StaticToolCallbackResolver : ToolCallback resolution attempt from static registry
… DEBUG … --- [...] o.s.a.t.r.SpringBeanToolCallbackResolver : ToolCallback resolution attempt from Spring application context
Alternativ zur Bean-Definition kann eine Funktion auch direkt über die Annotation @Tool als Tool registriert werden. Dabei wird der Funktionsname sowie eine Beschreibung mitgegeben. Die Eingabeparameter werden mit @ToolParam beschrieben:
@Component
public class FindProfileTools {
public record Response(
@JsonPropertyDescription("The pirate's nickname.")
String nickname,
@JsonPropertyDescription("Length of the pirate's sword,
specified in units, ranging from 0 to 100.")
int swordlength
) {}
@Tool(
name = "findProfileById",
description = "Retrieve the profile of a pirate by its
given ID, including the pirate's nickname and sword length."
)
public Response findProfileById(
@ToolParam(description = "A unique identifier for the pirate
profile. Must be a positive integer (>0).")
int id) {
return switch (id) {
case 1 -> new Response("BigBalls", 10);
case 2 -> new Response("CandyCane", 3);
default -> throw new IllegalArgumentException("No pirate
found with id " + id);
};
}
}
Wird @Tool verwendet, erfolgt die Registrierung über toolCallbacks(...). Dabei wird eine Instanz der Tool-Klasse übergeben:
OllamaOptions options =
OllamaOptions.builder()
.toolCallbacks(ToolCallbacks.from(new FindProfileTools()))
.build();
Prompt prompt = new Prompt( "...", options );
Function Calling eignet sich besonders gut für strukturierte, aktuelle oder gezielt abfragbare Daten – etwa aus Datenbanken, REST-Services oder sonstigen externen APIs. Das LLM erkennt dabei automatisch, wann der Zugriff auf ein solches externes Tool erforderlich ist und stößt den Aufruf an. Voraussetzung dafür ist allerdings eine klar definierte Schnittstelle sowie gegebenenfalls eine Validierung der Eingaben und Ausgaben.