Parallele Anfragen, Memory & Tools in der Praxis mit LangChain4j

Einleitung
Dies ist der zweite Artikel einer zweiteiligen Serie über LangChain4j – der Java-Bibliothek, die es ermöglicht, Large Language Models (LLMs) elegant in Java-Anwendungen einzubinden. Damit du den Einstieg in diesen Artikel findest, fassen wir zunächst kurz zusammen, was im ersten Teil bereits behandelt wurde.
Was im ersten Artikel behandelt wurde
Der erste Artikel hat gezeigt, wie man LLMs mit LangChain4j grundlegend in Java einbindet. Da LangChain4j einen konsequent Java-nativen Ansatz verfolgt, fühlen sich starke Typisierung, deklarative Annotationen und das durchgängige Builder-Pattern sofort vertraut an. Im Mittelpunkt standen dabei folgende Themen:
- ChatModel – die zentrale Abstraktion für alle LLM-Aufrufe, die ein einheitliches Interface für Ollama, Anthropic Claude, OpenAI GPT und Google Gemini bietet.
- LLM-Anbindung – pro LLM-Anbieter gibt es eine eigene Maven-Dependency und ChatModel-Implementierung, der eigentliche Aufruf via chat() ist jedoch immer gleich.
- Prompt-Strategien – drei Stufen: Plain String (einfach, fragil) → PromptTemplate (typisiert, wiederverwendbar) → @StructuredPrompt (typsicher, empfohlen für komplexe Prompts).
- AI Services – die höchste Abstraktionsstufe, bei der man das KI-Verhalten als Java-Interface deklariert und LangChain4j die gesamte Implementierung übernimmt.
- Logging – aktiviert via logRequests(true) und logResponses(true) im Builder; ausserdem wird ch.qos.logback als zusätzliche Maven-Dependency benötigt.
💡 Kernaussage: Weil LangChain4j jeden LLM-Anbieter hinter demselben ChatModel-Interface kapselt, erfordert der Wechsel zwischen verschiedenen Anbietern nur minimale Codeänderungen – ein grosser Vorteil für die Flexibilität und Wartbarkeit von Projekten.
Was in diesem Artikel behandelt wird
Aufbauend auf diesen Grundlagen widmet sich dieser zweite Artikel den fortgeschrittenen LangChain4j-Themen. Konkret werden folgende vier Themen abgedeckt:
- Parallele Anfragen – da LangChain4j keinen nativen RunnableParallel-Support bietet, zeigen wir, wie man mit CompletableFutures und Virtual Threads mehrere LLMs gleichzeitig befragen und die Ergebnisse konsolidieren kann.
- Memory (LLM Context) – weil LLMs zustandslos sind, erklären wir, wie man mit ChatMemory und AIServices einen Gesprächsverlauf über mehrere Anfragen hinweg aufrechterhält.
- Tools (Function Calling) – mithilfe der @Tool-Annotation können LLMs externe Funktionen aufrufen, sodass sie auf aktuelle oder externe Daten zugreifen können, die nicht in ihrem Training enthalten sind.
- Chatbot mit Web-UI – abschliessend führen wir alle Konzepte zusammen und implementieren damit einen vollständigen Chatbot mit Javalin-Web-Oberfläche, der Memory und Tools kombiniert.
Sofern du den ersten Artikel noch nicht gelesen hast, empfehlen wir, ihn zunächst zu lesen – obwohl die Konzepte hier auch einzeln nachvollziehbar sind. Los geht’s!
Parallele Anfragen
Im ersten LangChain4j Artikel haben wir gesehen, wie man einzelne LLM-Anfragen sequenziell verarbeitet. Da jedoch in der Praxis häufig mehrere Modelle gleichzeitig befragt werden sollen, stellt sich die Frage der Parallelisierung. Im Python-Pendant LangChain lässt sich das elegant mit RunnableParallel lösen, sodass die Ergebnisse anschliessend kombiniert und weiterverarbeitet werden können. Dieser Abschnitt zeigt daher, wie man dasselbe Muster in LangChain4j mit Standard-Java-Concurrency-Mitteln umsetzt.
Kontext des Beispiels
Claude, OpenAI und Gemini werden gleichzeitig gefragt, welche Libraries für den LLM-Zugriff in einer bestimmten Programmiersprache am beliebtesten sind. Sobald alle drei Antworten vorliegen, werden die Einzelergebnisse in einer übersichtlichen Markdown-Tabelle zusammengeführt.
💡 Hinweis: LangChain4j bietet – anders als Python-LangChain – keinen nativen Support für parallele Ausführung (RunnableParallel). Als Java-Entwickler greifen wir deshalb stattdessen auf bewährte Standard-Concurrency-Werkzeuge zurück, die seit Java 8 bzw. Java 21 zur Verfügung stehen.
Datenstrukturen
Da wir auf dem ersten Artikel aufbauen, entsprechen die Datenstrukturen weitgehend der seriellen Version. Ein Library-Objekt enthält dabei name, provider, url, language sowie version. Libraries ist zudem eine typsichere Liste dieser Objekte, sodass die Weiterverarbeitung komfortabel möglich ist:
public record Libraries(List<Library> libraries) {
@Override
public String toString() {
return libraries.stream()
.map(Library::toString)
.collect(Collectors.joining("\n"));
}
}
public record Library(String name, String provider, String url, String language, String version) {
@Override
public String toString() {
return name + " | " + provider +
", url='" + url + '\'' +
", language='" + language + '\'' +
", version='" + version + '\'' +
'}';
}
}
Damit wir die Ergebnisse der verschiedenen LLMs später klar unterscheiden können, brauchen wir zusätzlich ein LibraryResult. Dieses kombiniert die Bibliotheksliste mit dem Namen des aufrufenden LLM, sodass bei der Konsolidierung eindeutig klar ist, welche Antwort von welchem Modell stammt:
public record LibraryResult(String llmName, Libraries response) {
@Override
public String toString() {
return "--- " +
llmName +
" ---\n" +
response +
"\n\n";
}
}
LLM-Modelle
Für jedes der drei LLMs wird zunächst ein eigenes ChatModel konfiguriert. Da dieser Konfigurationscode mehrfach verwendet wird, ist er übersichtlichkeitshalber in die Hilfsklasse ModelHelper.java ausgelagert:
ModelHelper.java
public static ChatModel CLAUDE = AnthropicChatModel.builder()
.apiKey(System.getenv("ANTHROPIC_API_KEY"))
.modelName("claude-sonnet-4-5")
.temperature(0.3)
.build();
public static ChatModel OPENAI = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.temperature(0.3)
.build();
public static ChatModel GOOGLE = GoogleAiGeminiChatModel.builder()
.apiKey(System.getenv("GEMINI_API_KEY"))
.modelName("gemini-2.5-pro")
.temperature(0.3)
.build();
Services
FetchLibrariesService
Dieser Service kapselt den Abruf der Libraries von einem bestimmten LLM, sodass die Aufrufer sich nicht um die Details kümmern müssen. Das Herzstück ist dabei die LibraryService-Schnittstelle, die per LangChain4j-AIService automatisch implementiert wird. Der Prompt ist ausserdem mit der Anzahl gewünschter Ergebnisse und der Zielsprache parametrisiert, damit er flexibel wiederverwendet werden kann:
FetchLibrariesService.java
public class FetchLibrariesService {
@StructuredPrompt("""
What are the {{numOfItems}} most popular programming libraries in {{language}} for accessing LLMs?
Reply with name, provider, url, language, version for each library""")
record LibraryPrompt(int numOfItems, String language) {
}
interface LibraryService {
@SystemMessage("You are an ai developer expert.")
Libraries getLibraries(@UserMessage LibraryPrompt prompt);
}
public static Libraries getLLMLibraries(ChatModel model, String language, int noOfItems) {
LibraryService service = AiServices.builder(LibraryService.class)
.chatModel(model)
.build();
var prompt = new LibraryPrompt(noOfItems, language);
Libraries libraries = service.getLibraries(prompt);
println("Popular Programming Libraries [from " + model.toString() + "] in " + language + " for accessing LLMs:");
return libraries;
}
}
ConsolidateLibrariesService
Sobald alle LLM-Anfragen abgeschlossen sind, übernimmt dieser Service die Konsolidierung: Er fasst die Einzelantworten aller LLMs in einer Markdown-Tabelle zusammen. Der ConsolidatePrompt enthält dazu die Zielsprache sowie die vollständige Liste der LibraryResults, damit das LLM die Ergebnisse vergleichend gegenüberstellen kann:
ConsolidateLibrariesService.java
public class ConsolidateLibrariesService {
@StructuredPrompt("""
Summarize these LLM responses about {{language}} LLM libraries in one markdown table,
with a column per provider:
{{results}}
""")
record ConsolidatePrompt(String language, List<LibraryResult> results) {
}
interface ConsolidateService {
@SystemMessage("You are an ai developer expert.")
String consolidate(@UserMessage ConsolidatePrompt prompt);
}
public static String consolidate(ChatModel model, String language, List<LibraryResult> llmResults) {
ConsolidateService service = AiServices.builder(ConsolidateService.class)
.chatModel(model)
.build();
var prompt = new ConsolidatePrompt(language, llmResults);
return service.consolidate(prompt);
}
}
Parallelisierung
Variante A: CompletableFutures
Mit CompletableFuture.supplyAsync() werden die drei LLM-Anfragen gleichzeitig gestartet, was als Fan-out bezeichnet wird. Sobald alle drei Futures laufen, blockiert CompletableFuture.allOf(…).join() den Hauptthread, bis sämtliche Antworten eingetroffen sind. Erst danach werden die Ergebnisse gesammelt und zur Konsolidierung weitergegeben:
ParallelChainsWithCompletableFuture.java
var numberOfItems = 5;
var language = "Python";
var claudeFuture = CompletableFuture.supplyAsync(() ->
new LibraryResult("claude", getLLMLibraries(CLAUDE, language, numberOfItems)));
var openaiFuture = CompletableFuture.supplyAsync(() ->
new LibraryResult("openai", getLLMLibraries(OPENAI, language, numberOfItems)));
var geminiFuture = CompletableFuture.supplyAsync(() ->
new LibraryResult("gemini", getLLMLibraries(GOOGLE, language, numberOfItems)));
CompletableFuture.allOf(claudeFuture, openaiFuture, geminiFuture).join();
List<LibraryResult> results = new ArrayList<>();
results.add(new LibraryResult(claudeFuture.get().llmName(), claudeFuture.get().response()));
results.add(new LibraryResult(openaiFuture.get().llmName(), openaiFuture.get().response()));
results.add(new LibraryResult(geminiFuture.get().llmName(), geminiFuture.get().response()));
println("All results collected. Now consolidating...");
String consolidatedResult = consolidate(CLAUDE, language, results);
writeToFile(consolidatedResult, "llm_libraries.md");
Variante B: Virtual Threads (Java 21+)
Alternativ lässt sich derselbe Ablauf mit Virtual Threads (Java 21+) umsetzen, ohne dass dabei ein Thread-Pool erschöpft werden kann. Da Virtual Threads deutlich leichtgewichtiger sind als Plattform-Threads, skaliert dieser Ansatz ausserdem besser unter Last. Die Modelle und Services bleiben dabei vollständig unverändert – lediglich die Parallelisierungslogik wird angepasst:
- Mittels
Executors.newVirtualThreadPerTaskExecutor()wird ein Executor erstellt, der für jede eingereichte Aufgabe einen neuen Virtual Thread erzeugt.
- Durch
try-with-resourceswird der Executor beim Verlassen des Blocks automatisch heruntergefahren und abgewartet — kein manuellesshutdown()nötig.
- Jedes
submit()gibt sofort einFuturezurück und startet die Arbeit in einem eigenen Virtual Thread.
.get()blockiert den Haupt-Thread, bis das Ergebnis des jeweiligen Futures bereitsteht.
ParallelChainsWithVirtualThreads.java
var numberOfItems = 5;
var language = "Python";
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
var claudeFuture = executor.submit(() ->
new LibraryResult("claude", getLLMLibraries(CLAUDE, language, numberOfItems)));
var openaiFuture = executor.submit(() ->
new LibraryResult("openai", getLLMLibraries(OPENAI, language, numberOfItems)));
var geminiFuture = executor.submit(() ->
new LibraryResult("gemini", getLLMLibraries(GOOGLE, language, numberOfItems)));
var results = List.of(
claudeFuture.get(),
openaiFuture.get(),
geminiFuture.get()
);
println("All results collected. Now consolidating...");
String consolidatedResult = consolidate(CLAUDE, language, results);
writeToFile(consolidatedResult, "llm_libraries_vt.md");
}
💡 Hinweis: Virtual Threads blockieren keinen Plattform-Thread während der I/O-Wartezeit. Da LLM-Aufrufe typischerweise hohe Netzwerklatenz haben, ist dieser Ansatz deshalb besonders geeignet – der Plattform-Thread ist während des Wartens frei für andere Aufgaben.
Memory (LLM Context)
LLMs sind von Natur aus zustandslos: Jede Anfrage ist für das Modell ein Neustart, da es sich an frühere Nachrichten nicht erinnert. Um dennoch einen kohärenten Gesprächsverlauf zu ermöglichen, muss deshalb die gesamte Konversationshistorie bei jedem Aufruf mitgeschickt werden. LangChain4j bietet hierfür jedoch elegante Abstraktionen, sodass man das Context-Management nicht manuell implementieren muss.
Das Problem ohne Memory
Ohne explizites Context-Handling liefert das LLM bei Folgefragen keinen sinnvollen Bezug zur vorigen Antwort, weil es schlicht keinen Zugriff auf den bisherigen Verlauf hat (siehe LLM Context Artikel). Das folgende Beispiel verdeutlicht, weshalb das problematisch ist.
Wir möchten fragen, was die Hauptstadt von Frankreich ist. Und in einer zweiten Anfrage was die Hauptstadt von Schweden ist. Wenn wir uns nicht um den Kontext kümmern, werden wir eine Antwort in folgender Form bekommen:
PS: Implementierung im Repo: NoMemory.java
--- First Question ----
Prompt : What is the capital of France?
Antwort: The capital of France is Paris.
--- Second Question ----
Prompt : And Sweden?
Antwort: You're asking about the topic of Sweden! What would you like to know about Sweden? …
Das LLM weiss bei der zweiten Anfrage nicht, dass es um Hauptstädte geht. Es gibt zwei verschiedene Ansätze, dieses Problem zu lösen: mit ChatMemory oder AIServices.
Variante A: Manuelles ChatMemory
Das ChatMemory-Objekt verwaltet intern eine Liste von Nachrichten. Jede Benutzeranfrage (Prompt) sowie jede LLM-Antwort wird dabei manuell hinzugefügt, damit der Kontext vollständig erhalten bleibt. Bei jedem Aufruf der chat()-Methode wird anschliessend die gesamte Nachrichtenliste übergeben, sodass das LLM den Gesprächsverlauf kennt:
WithChatMemory.java
public class WithChatMemory {
private ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
void chatWithChatMemory(String prompt) {
ChatModel model = ...
memory.add(UserMessage.from(prompt));
ChatResponse response = model.chat(memory.messages());
memory.add(response.aiMessage());
print(prompt, response);
}
void main() {
var claude = new WithChatMemory();
claude.chatWithChatMemory("What is the capital of France.");
claude.chatWithChatMemory("And Sweden.");
}
}
Wenn man das Logging aktiviert, lässt sich gut nachvollziehen, wie der Kontext bei jeder weiteren Anfrage anwächst. So enthält die zweite Anfrage bereits die erste Frage samt der zugehörigen Antwort, sodass das Modell den Zusammenhang herstellen kann:
// Erste Anfrage
"messages": [
{ "role": "user", "content": "What is the capital of France." },
]
// Zweite Anfrage (enthält den gesamten bisherigen Verlauf)
"messages": [
{ "role": "user", "content": "What is the capital of France." },
{ "role": "assistant", "content": "The capital of France is Paris." },
{ "role": "user", "content": "And Sweden." }
]
Variante B: AIService mit Memory (empfohlen)
Noch eleganter ist jedoch die Verwendung eines AIService. Da LangChain4j das Handling der Nachrichten in diesem Fall vollständig automatisch übernimmt, muss man sich nicht mehr manuell um das Befüllen des Memory kümmern. Dadurch bleibt der eigene Code deutlich sauberer und kürzer:
AiServiceWithMemory.java
public class AiServiceWithMemory {
interface AssistantWithMemory {
String chat(String message);
}
private ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
void chatWithMemory(String prompt) {
ChatModel model = ...
AssistantWithMemory assistant = AiServices.builder(AssistantWithMemory.class)
.chatModel(model)
.chatMemory(memory)
.build();
String response = assistant.chat(prompt);
print(prompt, response);
}
void main() {
var claude = new AiServiceWithMemory();
claude.chatWithMemory("What is the capital of France.");
claude.chatWithMemory("And Sweden.");
}
}
Noch ein paar Punkte zum Code:
- Definition der Schnittstelle zum LLM: Im Beispiel möchten wir eine
chat()Methode, die als Input Parameter einen String hat für den Prompt. Und als Return Werte ebenfalls einen String für die Antwort des LLM.
- Wichtig ist, dass man im Builder für die Erstellung des AIService die
chatMemory()Methode benutzt und dieser das ChatMemory übergibt.
- Der entscheidende Unterschied zur manuellen Variante: Bei
chatMemory()übernimmt LangChain4j das vollständige Message-Handling, sodass man sich nicht selbst darum kümmern muss. Die Schnittstelle bleibt deshalb sauber und beschränkt sich auf ein einfaches String-Input/String-Output-Interface – also das Minimum, das man braucht.
LLM Context und Tools
Tools – auch als Function Calling bekannt – erlauben es dem LLM, externe Funktionen zu nutzen, ohne sie selbst ausführen zu können. Wichtig ist dabei: Das LLM ruft die Funktionen nicht direkt auf. Stattdessen entscheidet es lediglich, welche Funktion mit welchen Parametern aufgerufen werden soll, und delegiert den Aufruf an den Aufrufer zurück. Dieser führt die Funktion lokal aus und gibt das Ergebnis ans LLM weiter, damit es eine fundiertere Antwort erstellen kann.
Als konkretes Beispiel wollen wir wissen, welche Kleidung für einen Kurztrip nach Paris angemessen ist. Damit das LLM eine wettergerechte Empfehlung geben kann, soll es dazu ein Wetter-Tool nutzen. Der Prompt selbst ist dabei denkbar einfach:
“What kind of clothes do I need for a short trip to Paris?”
Tool-Definition
Tools werden in Java mit der @Tool-Annotation definiert, sodass LangChain4j sie automatisch registriert. Die Methodenbeschreibung im Annotation-Parameter ist dabei besonders entscheidend, weil das LLM sie liest, um zu entscheiden, wann das Tool eingesetzt werden soll. Eine präzise Beschreibung verbessert deshalb die Zuverlässigkeit erheblich.
In diesem Beispiel soll dem LLM ein Tool zur Abfrage des aktuellen Wetters zur Verfügung gestellt werden – die entsprechende Methode heißt getForecast(). Der Input ist der Name der Stadt (als String) und der Output die Wetterdaten der angefragten Stadt (als String)
💡 Hinweis: In diesem vereinfachten Beispiel werden hardcodierte Wetterdaten für 5 Städte verwendet. In einer realen Anwendung würde getForecast() stattdessen eine Live-Wetter-API aufrufen.
WeatherTool.java
public class WeatherTool {
@Tool("Get weather forecast for a specified city.")
public String getForecast(String city) {
Map<String, String> forecasts = new HashMap<>();
forecasts.put("Paris", "Temperature: 28°C, Conditions: Sunny, Wind: 10 km/h");
forecasts.put("Stockholm", "Temperature: 12°C, Conditions: Rainy, Wind: 15 km/h");
forecasts.put("London", "Temperature: 15°C, Conditions: Rain, Wind: 8 km/h");
forecasts.put("Berlin", "Temperature: 16°C, Conditions: Partly cloudy, Wind: 12 km/h");
forecasts.put("Madrid", "Temperature: 24°C, Conditions: Clear skies, Wind: 5 km/h");
return forecasts.getOrDefault(city, "Sorry, no forecast available for " + city);
}
}
Umsetzung mit Claude
Die Integration ist erfreulicherweise denkbar einfach: Der AIService aus dem Memory-Kapitel wird lediglich um den tools(new WeatherTool())-Aufruf im Builder ergänzt. LangChain4j kümmert sich anschliessend automatisch um das Tool-Calling-Protokoll, sodass kein zusätzlicher Boilerplate-Code nötig ist:
WithToolsClaude.java
public class WithToolsClaude {
private ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
interface Assistant {
String chat(String message);
}
void chatWithTools(String prompt) {
ChatModel model = ...
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.chatMemory(memory)
.tools(new WeatherTool())
.build();
String response = assistant.chat(prompt);
printRequestResponseInfo(prompt, MODEL_NAME.name(), response);
}
void main() {
print("=== Claude with Tools Example ===");
var claude = new WithToolsClaude();
claude.chatWithTools("What kind of clothes do I need for a short trip to Paris?");
claude.chatWithTools("And for London?");
}
}
Das Beispiel liefert folgende Ausgaben
Prompt: What kind of clothes do I need for a short trip to Paris?
-----------------------
*** get_forecast called with city: Paris
*** return forecast: Temperature: 28°C, Conditions: Sunny, Wind: 10 km/h
-----------------------
Based on the current forecast for Paris — **28°C, sunny, with a light breeze of 10 km/h** — here are some clothing recommendations for your short trip:
☀️ **Light & Breathable Clothing** ...
👟 **Comfortable Footwear** ...
🕶️ **Sun Protection** ...
🧥 **A Light Layer** ...
👜 **A Small Bag** ...
Prompt: And for London?
-----------------------
*** get_forecast called with city: London
*** return forecast: Temperature: 15°C, Conditions: Rain, Wind: 8 km/h
-----------------------
Based on the current forecast for London — **15°C, rainy, with a light breeze of 8 km/h** — here's what I'd recommend packing for your trip:
🌧️ **Rain Gear** ...
🧥 **Warm Layers** ...
👖 **Bottoms** ...
👢 **Waterproof Footwear** ...
🧣 **Accessories** ...
Umsetzung mit GPT – Besonderheit
Beim Austausch des Modells gegen GPT zeigte sich allerdings, dass GPT das Tool ohne explizite Anweisung nicht konsequent nutzte. Das verdeutlicht, dass Modelle nicht einfach austauschbar sind – obwohl der Code identisch ist, kann das Verhalten erheblich abweichen. Die Lösung ist in diesem Fall eine System Message, die das LLM ausdrücklich anweist, für wetterbezogene Anfragen immer das Tool einzusetzen.
WithToolsOpenAI.java
public class WithToolsOpenAI {
private ChatMemory memory = MessageWindowChatMemory.withMaxMessages(10);
private static final String SYSTEM_PROMPT = """
You are a helpful travel assistant.
IMPORTANT: When users ask about:
- What to pack for a trip
- What clothes to bring
- Weather conditions
- Temperature in a city
You MUST use the getForecast tool to check the current weather before providing advice. Never give generic packing advice without checking the actual weather forecast first.
""";
interface Assistant {
@SystemMessage(SYSTEM_PROMPT)
String chat(String message);
}
void chatWithTools(String prompt) {
ChatModel model = ...
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.chatMemory(memory)
.tools(new WeatherTool())
.build();
String response = assistant.chat(prompt);
printRequestResponseInfo(prompt, MODEL_NAME.name(), response);
}
void main() {
print("=== OpenAI with Tools Example ===");
var claude = new WithToolsOpenAI();
claude.chatWithTools("What kind of clothes do I need for a short trip to Paris?");
claude.chatWithTools("And for London?");
}
}
💡 Erkenntnis: Modelle sind nicht einfach austauschbar wie Stecker – obwohl der Code identisch ist, reagieren unterschiedliche LLMs unterschiedlich auf Tool-Definitionen. System Messages helfen deshalb dabei, das gewünschte Verhalten explizit zu erzwingen und somit plattformübergreifend konsistente Ergebnisse zu erzielen.
Chatbot mit Web-UI
Als abschliessendes Beispiel implementieren wir einen Chatbot mit einer echten Web-Oberfläche, damit auch Nicht-Entwickler das Modell bequem nutzen können. Im Python-Pendant LangChain wurde dafür Streamlit eingesetzt – ein Framework, das mit wenigen Zeilen Code ein vollständiges UI bereitstellt. Für Java gibt es zwar kein direktes Äquivalent, jedoch kommt Javalin dem Konzept sehr nahe und ist daher eine gute Wahl.
Was ist Javalin?
Javalin ist ein schlankes HTTP-Framework auf Basis von Jetty, das sich besonders gut für REST-APIs und einfache Webanwendungen eignet. Da es sehr wenige Abhängigkeiten mitbringt, lässt es sich zudem schnell in bestehende Projekte integrieren:
- Funktioniert mit Java und Kotlin
- Minimale Einrichtung – ein Server startet mit wenigen Zeilen Code
- Unterstützt REST-APIs, WebSockets und Server-Sent Events
- Sehr wenige transitive Abhängigkeiten
Das klassische Hello-World für Javalin:
- Es ist die Implementierung eines REST Enpoints.
- Ein Aufruf von
http://localhost:7070/helloim Browser schreibt ein “Hello, World!” auf den Bildschirm.
void main() {
var app = Javalin.create().start(7070);
app.get("/hello", ctx -> ctx.result("Hello, World!"));
}
REST-Endpunkte des Chatbots
Damit der Chatbot funktioniert, benötigt er drei klar getrennte REST-Endpunkte:
- GET / – Liefert die HTML-Seite mit der aktuellen Nachrichtenhistorie
- POST /chat – Empfängt den Prompt, ruft das LLM auf und aktualisiert die History
- POST /clear – Löscht die History und lädt die Seite neu
Implementierung
Die Logik für den Chatbot lässt sich in einer einzigen Klasse implementieren. Der Code ist ganz bewusst einfach gehalten:
- Auf AI Services wird verzichtet — stattdessen wird direkt die
chat()-Methode des Modells verwendet.
- Die
history(vom TypList<ChatMessage>) dient einem doppelten Zweck: einerseits als Argument für diechat()-Methode, andererseits für das Rendering der Chat-Nachrichten auf der HTML-Seite.
SimpleChatbotApp.java
public class SimpleChatbotApp {
private static final List<ChatMessage> history = new ArrayList<>();
private static final ChatModel model = ...
static void main() {
Javalin app = Javalin
.create(config -> config.staticFiles.add("/"))
.start(7070);
app.get("/", SimpleChatbotApp::buildPage);
app.post("/chat", SimpleChatbotApp::handleChat);
app.post("/clear", SimpleChatbotApp::handleClear);
}
// endpoint: GET /
private static void buildPage(Context ctx) {
ctx.html(HtmlBuilder.buildPage(history));
}
// endpoint: POST /chat
private static void handleChat(Context ctx) {
String prompt = ctx.formParam("prompt");
if (prompt != null && !prompt.isBlank()) {
history.add(UserMessage.from(prompt));
AiMessage response = model.chat(history).aiMessage();
history.add(response);
}
ctx.redirect("/");
}
// endpoint: POST /clear
private static void handleClear(Context ctx) {
history.clear();
ctx.redirect("/");
}
}
Code-Erklärung
Das Context Object
Das Context Object ist das zentrale Objekt in Javalin. Es stellt alles bereit, was für die Verarbeitung eines HTTP-Requests benötigt wird – einschließlich des Servlet-Requests, der Servlet-Response sowie einer Reihe von Gettern und Settern.
In der Praxis erhält jeder (Route-)Handler einen ctx-Parameter, der als einziger Einstiegspunkt dient – sowohl zum Lesen des Requests als auch zum Schreiben der Response. Im vorliegenden Beispiel kommen die Methoden html() und redirect() des Context Objects zum Einsatz.
Aufbau der Anwendung
Die Message-History ist als einfache Liste implementiert. Die Methode main() startet den Server und stellt drei REST-Endpunkte bereit:
buildPage()– Erstellt mithilfe desHtmlBuilderdie aktuelle HTML-Seite (inklusive aller vorhandenen Messages) als einfachen String und sendet diesen überctx.html()als HTML-Antwort an den Client.
handleChat()– Ruft auf dem Chat-Modell (hier ein GPT-Modell) die Methodechat()auf. Anschließend werden die User Message (Prompt) und die AI Message (LLM-Antwort) der History-Liste hinzugefügt. Abschließend erfolgt überctx.redirect()eine Weiterleitung zubuildPage(), welche die aktualisierte Seite neu rendert.
clearPage()– Löscht die History und rendert die HTML-Seite neu (ohne Meldungen).
Aufbau der HTML-Seite
Die HTML-Seite besteht aus zwei zentralen Elementen – einem <div> und einem <form>:
<div>– Besitzt eine eigene ID und wird dynamisch mit den Inhalt der Message-History befüllt.<form>– Dient zur Eingabe der Prompts durch den Benutzer.
Die HTML-Seite und das CSS File sind unter resoures abgelegt:
resources/chat.htmlresources/chat.css
Den vollständigen HTML- und CSS-Code findet man im Repo.
💡 Tipps:
- Message-Darstellung: Nachrichten können je nach Typ (User/AI) unterschiedlich gerendert werden – diese Funktionalität ist bereits in
MessageFormatter.javaimplementiert. - CSS ist optional, aber empfohlen: Die HTML-Seite funktioniert grundsätzlich auch ohne CSS – ein ansprechendes Stylesheet wertet das UI jedoch deutlich auf.
Chatbot mit Tools-Support
Den Chatbot nachträglich um Tools-Support zu erweitern, erfordert nur minimale Anpassungen, da LangChain4j das Meiste übernimmt.
Definition der Tools
Zunächst werden die Tools definiert, die dem LLM zur Verfügung gestellt werden sollen. In unserem Beispiel handelt es sich um hardcodierte Wetterdaten für verschiedene Städte.
WeatherTool.java
@Tool("Get weather forecast for a specified city.")
public String getForecast(String city) {
Map<String, String> forecasts = new HashMap<>();
forecasts.put("Paris", "Temperature: 28°C, Conditions: Sunny, Wind: 10 km/h");
forecasts.put("Stockholm", "Temperature: 12°C, Conditions: Rainy, Wind: 15 km/h");
forecasts.put("London", "Temperature: 15°C, Conditions: Rain, Wind: 8 km/h");
forecasts.put("Berlin", "Temperature: 16°C, Conditions: Partly cloudy, Wind: 12 km/h");
forecasts.put("Madrid", "Temperature: 24°C, Conditions: Clear skies, Wind: 5 km/h");
var forecast = forecasts.getOrDefault(city, "Sorry, no forecast available for " + city);
printUsage(city, forecast);
return forecast;
}
Anpassung des Chatbot-Codes
Statt die chat()-Methode des Modells direkt aufzurufen, kommt diesmal ein AI Service zum Einsatz. Die handleChat()-Methode muss so angepasst werden, dass sie den AI Service nutzt.
Der AI Service verwendet eine System Message, die sicherstellt, dass das LLM die Methode getForecast() gezielt für wetterbezogene Anfragen einsetzt.
private static final String SYSTEM_PROMPT = """
You are a helpful travel assistant.
IMPORTANT: When users ask about:
- What to pack for a trip
- What clothes to bring
- Weather conditions
- Temperature in a city
You MUST use the get_forecast tool to check the current weather before providing advice. Never give generic packing advice without checking the actual weather forecast first.
""";
interface Assistant {
@SystemMessage(SYSTEM_PROMPT)
String chat(String message);
}
Der AI Service stellt eine eigene chat()-Methode bereit, deren Ein- und Ausgabe jeweils vom Typ String sind. Er lässt sich mit folgendem Code erstellen — entscheidend ist dabei der Aufruf von .tools(new WeatherTool()), über den die Klasse mit der Tool-Definition und -Implementierung registriert wird.
private static Assistant assistantWithWeatherTool = AiServices.builder(Assistant.class)
.chatModel(model)
.tools(new WeatherTool())
.build();
Abschliessend muss in handleChat() die Methode assistantWithWeatherTool() verwendet werden.
private static void handleChat(Context ctx) {
String prompt = ctx.formParam("prompt");
if (prompt != null && !prompt.isBlank()) {
history.add(UserMessage.from(prompt)); // for rendering the full history, we still need to add user message to the history
String response = assistantWithWeatherTool.chat(prompt);
history.add(AiMessage.from(response)); // for rendering ....
}
ctx.redirect("/");
}
Hinweis: LangChain4j übernimmt dabei das gesamte Tool-Handling: Es sendet zunächst die Tool-Beschreibungen ans LLM, empfängt anschliessend den Funktionsaufruf, führt ihn lokal aus und schickt das Ergebnis zurück – sodass der gesamte Ablauf vollständig transparent für den Chatbot-Code bleibt.
Zum Abschluss stellt sich die Frage: Wie sieht der Chatbot eigentlich in der Praxis aus? Mit etwas CSS lässt er sich optisch ansprechend gestalten – hier drei Momentaufnahmen:
Beim Start – Die History ist noch leer, es wurden noch keine Messages ausgetauscht.

Erste Frage mit Tool Support – Das LLM empfängt die erste Anfrage und setzt das getForecast()-Tool erstmals ein.

Letzte Frage mit Tool Support – Der Chatverlauf nach der abschließenden Wetteranfrage, inklusive der Tool-Antwort des LLMs.

Fazit
LangChain4j beweist, dass LLM-Integration in Java nicht umständlich sein muss, sofern man die richtigen Abstraktionen nutzt. Durch den Einsatz von Standard-Java-Concurrency (CompletableFutures oder Virtual Threads), ChatMemory und dem @Tool-Pattern lassen sich auch komplexe Szenarien – darunter parallele Multi-LLM-Abfragen, zustandsbehaftete Konversationen sowie externe Werkzeugaufrufe – mit überschaubarem Code elegant umsetzen.
Alle Codebeispiele sind im zugehörigen Repository verfügbar. Die wichtigste Lektion lautet dabei: Modelle sind nicht einfach austauschbar. Obwohl der Code identisch aussehen mag, reagieren unterschiedliche LLMs unterschiedlich auf Prompts und Tools. System Messages sind deshalb ein verlässliches Mittel, um das gewünschte Verhalten explizit zu definieren und somit konsistente Ergebnisse zu erzielen.
Links
Artikel
Repository
Frameworks
Workshop
- Agentic Coding with Claude Code, Ken Kousen, O’Reilly Media, Inc.