LangChain4j
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against LangChain4j 1.20.2 (the stable modules; the -beta modules are at 1.20.2-beta30) on
Java 21, with the Quarkiverse quarkus-langchain4j extension at 1.14.0, as verified on 2026-09-29. The running
example is DocsAssistant, which answers questions over this site’s documentation with Ollama’s llama3.1:8b chat
model and the nomic-embed-text embedding model over the pgvector store from
Integrating with LangChain. Every snippet below was
compiled with Maven’s Java 21 toolchain and executed, except the ones that build a real Ollama or OpenAI client
(they were compiled only, because no Ollama server was available). The outputs shown come from a scripted fake model
described in the next section, so they are exactly what the code prints, not what a real model would say.
LangChain4j is the Java library that plays the role LangChain plays in Python: one API over many model providers, a
declarative way to build LLM-backed services out of plain interfaces, and building blocks for tools, memory, RAG,
guardrails and agents. It is an independent open-source project, not a port of the Python code, so names differ
(a ChatModel here is a BaseChatModel there, an AI Service has no exact Python twin). This page covers the core
library without Spring; the experimental agentic module and the MCP client are on
LangChain4j Agents & MCP. The framework-neutral background is in
Tool Design, Memory and
Agent Guardrails & Safety.
Modules and the BOM
LangChain4j is split into small artifacts under the group dev.langchain4j. You depend on the abstractions plus one
artifact per provider or store you use:
| Artifact | What it gives you |
|---|---|
|
The low-level API: |
|
The high-level API: |
|
One artifact per model provider, with the chat, streaming and embedding model classes for it. |
|
One artifact per embedding store (see the RAG section below). |
|
Beta-versioned modules: agents and workflows, the MCP client, Reactor adapters and test helpers. |
|
Spring Boot starters for Boot 4 and Boot 3 (beta-versioned). |
Not every module shares the core’s version number: the core line is at 1.20.2 while modules that are still settling
carry a -beta suffix (1.20.2-beta30). The Bill of Materials, langchain4j-bom, manages both kinds, so importing
it once lets you omit the version on every LangChain4j dependency, beta modules included. We resolved this exact
pom.xml with Maven and it downloaded langchain4j 1.20.2, langchain4j-ollama 1.20.2 and the -beta30 builds of
langchain4j-agentic, langchain4j-mcp, langchain4j-reactor and langchain4j-pgvector:
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>1.20.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-pgvector</artifactId>
</dependency>
</dependencies>
The library targets Java 17 as its baseline; the examples here use Java 21 language features (text blocks, records,
var). Older tutorials, and some books, target the 0.x line (0.35 and 0.36 are common); class names and builders have
changed since then, so treat that material as history and check names against the 1.x documentation. Official page:
LangChain4j — Get started.
The scripted models used on this page
Ollama was not available while this page was written, so the runnable examples use two small ChatModel and
StreamingChatModel implementations that replay a script and record every request. Implementing the interfaces is
also how you unit-test LangChain4j code without a model; ChatModel only requires doChat, and everything else
(the string overloads, listeners, retries) is inherited:
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.chat.listener.ChatModelListener;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import dev.langchain4j.model.chat.response.ChatModelStreamingEvent;
import dev.langchain4j.model.chat.response.CompleteResponse;
import dev.langchain4j.model.chat.response.PartialResponse;
import dev.langchain4j.model.chat.Capability;
import java.util.ArrayDeque;
import java.util.Set;
import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
/** Scripted stand-ins for Ollama: replies come from a queue and every request is recorded. */
final class ScriptedChatModel implements ChatModel {
final List<ChatRequest> requests = new ArrayList<>();
private final Deque<AiMessage> script = new ArrayDeque<>();
private List<ChatModelListener> listeners = List.of();
ScriptedChatModel(AiMessage... replies) {
script.addAll(List.of(replies));
}
static ScriptedChatModel saying(String... texts) {
return new ScriptedChatModel(java.util.Arrays.stream(texts).map(AiMessage::from).toArray(AiMessage[]::new));
}
private boolean jsonSchema;
ScriptedChatModel withJsonSchema() {
this.jsonSchema = true;
return this;
}
@Override
public Set<Capability> supportedCapabilities() {
return jsonSchema ? Set.of(Capability.RESPONSE_FORMAT_JSON_SCHEMA) : Set.of();
}
ScriptedChatModel withListeners(ChatModelListener... l) {
this.listeners = List.of(l);
return this;
}
@Override
public List<ChatModelListener> listeners() {
return listeners;
}
@Override
public ChatResponse doChat(ChatRequest request) {
requests.add(request);
return ChatResponse.builder().aiMessage(script.removeFirst()).build();
}
}
final class ScriptedStreamingChatModel implements StreamingChatModel {
private final List<String> tokens;
ScriptedStreamingChatModel(String... tokens) {
this.tokens = List.of(tokens);
}
@Override
public void doChat(ChatRequest request, StreamingChatResponseHandler handler) {
tokens.forEach(handler::onPartialResponse);
handler.onCompleteResponse(ChatResponse.builder().aiMessage(AiMessage.from(String.join("", tokens))).build());
}
}
/** Implements the Publisher-based doChat that Flow.Publisher AI Service methods require. */
final class ScriptedPublisherChatModel implements StreamingChatModel {
private final List<String> tokens;
ScriptedPublisherChatModel(String... tokens) {
this.tokens = List.of(tokens);
}
@Override
public Flow.Publisher<ChatModelStreamingEvent> doChat(ChatRequest request) {
return subscriber -> { // cold: each subscription starts one model call
SubmissionPublisher<ChatModelStreamingEvent> events = new SubmissionPublisher<>();
events.subscribe(subscriber);
new Thread(() -> {
tokens.forEach(t -> events.submit(new PartialResponse(t)));
events.submit(new CompleteResponse(
ChatResponse.builder().aiMessage(AiMessage.from(String.join("", tokens))).build()));
events.close();
}).start();
};
}
}
Two behaviours of the library show up in the fakes and are worth knowing early. First, doChat is the only method a
synchronous provider must implement. Second, the asynchronous entry points (chatAsync, and a Flow.Publisher
return type on StreamingChatModel) each have their own doChatAsync / publisher-based doChat hook: a model that
has not implemented that hook fails loudly instead of silently blocking, which the streaming section demonstrates.
ChatModel and StreamingChatModel
ChatModel is the synchronous interface: chat(String) for a quick question, chat(ChatRequest) when you need
messages, parameters, tools or a response format, and it returns a ChatResponse holding the AiMessage, the finish
reason and the token usage. StreamingChatModel is the same contract delivered through a callback
(StreamingChatResponseHandler) as tokens are generated. Providers implement both, with the same builder options:
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.Capability;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import dev.langchain4j.model.ollama.OllamaEmbeddingModel;
import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
import dev.langchain4j.model.openai.OpenAiChatModel;
import java.time.Duration;
final class S1Models {
static ChatModel ollamaChat() {
return OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.temperature(0.0)
.timeout(Duration.ofSeconds(120))
.build();
}
static ChatModel ollamaJsonSchema() {
return OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) // send a JSON schema, not prompt text
.build();
}
static StreamingChatModel ollamaStreaming() {
return OllamaStreamingChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.1:8b")
.build();
}
static EmbeddingModel ollamaEmbeddings() {
return OllamaEmbeddingModel.builder()
.baseUrl("http://localhost:11434")
.modelName("nomic-embed-text")
.build();
}
static ChatModel hostedChat() {
return OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY")) // never a literal key
.modelName("gpt-6-sol")
.build();
}
/** Works with any ChatModel: the one-liner and the explicit ChatRequest form. */
static void ask(ChatModel model) {
String quick = model.chat("Which extension stores embeddings in PostgreSQL?");
System.out.println("quick : " + quick);
ChatRequest request = ChatRequest.builder()
.messages(SystemMessage.from("Answer from the documentation only."),
UserMessage.from("Which extension stores embeddings in PostgreSQL?"))
.build();
ChatResponse response = model.chat(request);
System.out.println("explicit: " + response.aiMessage().text() + " | finish=" + response.finishReason());
}
}
Running S1Models.ask(…) against a scripted model, which replies pgvector both times:
ask against the scripted modelquick : pgvector
explicit: pgvector | finish=null
finish=null is an artifact of the fake, which builds its ChatResponse without metadata; a real provider fills in
the finish reason and token usage. Three details in the factories matter in practice. The OpenAI key comes from the
environment, never from a literal. The Ollama models talk to http://localhost:11434, the default of the Ollama
server described in Ollama, and a local 8B model can be slow to answer, hence the
explicit timeout. And supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) opts an Ollama model into
sending a JSON schema with the request, which the structured-output section relies on. Hosted model ids follow the
rest of this section: gpt-6-sol for OpenAI. Official pages:
LangChain4j — Chat and language models and
LangChain4j — Ollama.
AI Services
The low-level API leaves you to assemble messages, keep history, parse replies and run the tool loop. AI Services
is LangChain4j’s answer: you write a plain Java interface, AiServices.builder(…) generates an implementation
behind a dynamic proxy, and every method call becomes a model call with the prompt, memory, tools, guardrails and
output parsing you configured. The annotations on the interface are the prompt:
-
@SystemMessagesets the system prompt; text blocks keep multi-line prompts readable. -
@UserMessagesets a template for the user message. Variables are written{{name}}; a single parameter without an annotation is available as{{it}}. -
@V("name")binds a method parameter to a template variable. It is needed for multi-parameter methods unless the code is compiled with-parameters, which the examples here do.
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import dev.langchain4j.model.chat.ChatModel;
interface DocsAssistant {
@SystemMessage("""
You are DocsAssistant. Answer only from the documentation of the {{component}} component
and say so when you do not know.""")
@UserMessage("Question about {{topic}}: {{question}}")
String answer(@V("component") String component, @V("topic") String topic, @V("question") String question);
String chat(String message);
}
final class S2AiServices {
static DocsAssistant build(ChatModel model) {
return AiServices.builder(DocsAssistant.class)
.chatModel(model)
.build();
}
}
The builder wires everything in one place, and an interface without a @UserMessage (like chat above) sends the
single argument as the user message. Running answer("vector-rag", "pgvector", "Which store class do I use?")
against the scripted model prints the reply, and the request the model received shows how the templates rendered:
SYSTEM: SystemMessage { text = "You are DocsAssistant. Answer only from the documentation of the vector-rag component
and say so when you do not know." }
USER: UserMessage { name = null, contents = [TextContent { text = "Question about pgvector: Which store class do I use?" }], attributes = {} }
The diagram shows what one call does. Everything in the middle is optional and configured on the builder; the boxes outlined by the input and output guardrails and by the listeners are covered in their own sections:
Official page: LangChain4j — AI Services.
Tools
A method annotated with @Tool becomes a tool: LangChain4j reads its name, its description (the annotation value)
and its parameters, builds a ToolSpecification with a JSON schema, sends it with every request and executes the
method when the model asks for it. @P describes a parameter and required = false makes it optional. Tool
classes are passed to tools(…) on the builder as plain objects, so a tool can hold collaborators such as a
database client.
DocsAssistant’s two tools follow the guidance in Tool Design. The destructive one,
openIssue, does not act by itself: it asks an injected approver, and a rejection is returned to the model as text
it can act on. LangChain4j does not impose an approval mechanism for AI Services, so the gate is ordinary
application code; in production the Predicate would call your approval UI, and the credential for the GitHub API
would come from an environment variable or secret store and carry only the permission to create issues.
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import java.util.List;
import java.util.function.Predicate;
final class DocsTools {
private final Predicate<String> approver;
DocsTools(Predicate<String> approver) {
this.approver = approver;
}
@Tool("Search the documentation and return matching page paths with a one-line snippet")
List<String> searchDocs(@P("keywords to search for") String query,
@P(value = "maximum number of results", required = false) Integer limit) {
int max = limit == null ? 3 : limit;
return List.of("ai/langchain/tools.adoc -- Tools", "database/vector-rag/agentic-rag.adoc -- Agentic RAG")
.stream().limit(max).toList();
}
@Tool("Open a GitHub issue in the docs repository. Requires human approval and returns the outcome.")
String openIssue(@P("issue title") String title) {
if (!approver.test(title)) {
return "REJECTED: a human declined to open \"" + title + "\"; do not retry.";
}
return "OPENED: issue \"" + title + "\" (simulated, no network call)";
}
}
Wiring them and scripting a model that calls searchDocs and then openIssue, with an approver that always says no:
var toolAssistant = AiServices.builder(DocsAssistant.class)
.chatModel(model)
.tools(new DocsTools(title -> false)) // approver: a human declines
.build();
toolAssistant.chat("Find pgvector docs and open an issue about HNSW tuning.");
The specifications the model received, and the tool messages the library appended between the two model calls, come straight from the recorded requests (long lines trimmed):
spec: ToolSpecification { name = "searchDocs", description = "Search the documentation and return matching page paths with a one-line snippet", parameters = JsonObjectSchema { properties = {query=JsonStringSchema {description = "keywords to search for" }, limit=JsonIntegerSchema {description = "maximum number of results" }}, required = [query], ... } }
spec: ToolSpecification { name = "openIssue", description = "Open a GitHub issue in the docs repository. Requires human approval and returns the outcome.", ... required = [title] ... }
AI: AiMessage { text = null, toolExecutionRequests = [ToolExecutionRequest { id = "call-1", name = "searchDocs", arguments = "{"query":"pgvector","limit":1}" }] }
TOOL_EXECUTION_RESULT: ToolExecutionResultMessage{id='call-1', toolName='searchDocs', contents=[TextContent { text = "["ai/langchain/tools.adoc -- Tools"]" }], isError=false}
AI: AiMessage { text = null, toolExecutionRequests = [ToolExecutionRequest { id = "call-2", name = "openIssue", arguments = "{"title":"Document HNSW tuning"}" }] }
TOOL_EXECUTION_RESULT: ToolExecutionResultMessage{id='call-2', toolName='openIssue', contents=[TextContent { text = "REJECTED: a human declined to open "Document HNSW tuning"; do not retry." }], isError=false}
Note the schema: limit is described but absent from required, because of required = false, and query is
required. The final answer is the third scripted model reply, The issue was not opened.
Other tool features from the official page, not run here: a @ToolMemoryId parameter receives the memory id of the
current conversation (so tools can be per-user), toolArgumentsErrorHandler and toolExecutionErrorHandler on the
builder decide whether a bad tool call is sent back to the model as text or aborts the service,
hallucinatedToolNameStrategy handles calls to tools that do not exist, and tools can be registered without
annotations through AiServiceTool when the set of tools is only known at runtime. Tools that come from an MCP
server (see the LangChain4j MCP page) are added with a ToolProvider and
are covered in LangChain4j Agents & MCP. Official page:
LangChain4j — Tools (function calling).
Chat memory
An LLM is stateless, so an AI Service that should remember the conversation needs a ChatMemory. It is a container
that stores the messages exchanged, applies an eviction policy, and hands the retained messages back with every
request. LangChain4j ships two: MessageWindowChatMemory keeps the last N messages, and TokenWindowChatMemory
keeps the last N tokens (it needs a TokenCountEstimator). Messages are never split, so an evicted message goes
whole, and evicting an AiMessage that requested tools also evicts the tool results that followed it. The
system message is special: only one is kept, and it is retained when older messages are evicted. The official page
also points out that memory is not history: memory is what the model sees, history is what the user saw, and only
memory is provided.
Two configurations cover most cases. chatMemory(…) on the builder gives the service one shared memory, fine for a
single-user tool. For a multi-user application, chatMemoryProvider(…) takes a function from a memory id to a
ChatMemory, and a @MemoryId parameter on the AI Service method selects the conversation. The provider below is the
same idea as LangGraph’s thread_id (see
Short-Term Memory & Checkpointers).
ChatMemoryStore is the persistence hook. MessageWindowChatMemory keeps its messages in a store, in memory by
default; implementing the three methods (getMessages, updateMessages, deleteMessages) moves them anywhere. Note
that updateMessages receives the complete list every time, so a message evicted from the window disappears from the
store too. ChatMessageSerializer and ChatMessageDeserializer convert messages to and from JSON:
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.ChatMessageDeserializer;
import dev.langchain4j.data.message.ChatMessageSerializer;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
interface MemoryAssistant {
String chat(@MemoryId String userId, @UserMessage String message);
}
/** Keeps one JSON document per conversation; swap the map for a database table in production. */
final class JsonChatMemoryStore implements ChatMemoryStore {
final Map<Object, String> rows = new ConcurrentHashMap<>();
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String json = rows.get(memoryId);
return json == null ? List.of() : ChatMessageDeserializer.messagesFromJson(json);
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
rows.put(memoryId, ChatMessageSerializer.messagesToJson(messages));
}
@Override
public void deleteMessages(Object memoryId) {
rows.remove(memoryId);
}
}
final class S4Memory {
static MemoryAssistant build(ChatModel model, ChatMemoryStore store) {
ChatMemoryProvider provider = userId -> MessageWindowChatMemory.builder()
.id(userId)
.maxMessages(10)
.chatMemoryStore(store)
.build();
return AiServices.builder(MemoryAssistant.class)
.chatModel(model)
.chatMemoryProvider(provider)
.build();
}
static ChatMemory singleUser() {
return MessageWindowChatMemory.withMaxMessages(10);
}
}
Running it with two users, "ana" and "ben", interleaved, and a scripted model:
Your name is Ana
rows=[ana, ben]
messages in 3rd request=3
stored ana=4
The third request, Ana’s second question, reached the model with three messages (her first question, its reply and the new question), not
Ben’s, and the store holds one JSON row per memory id. After Ana’s second exchange the store has her four messages.
Replace the ConcurrentHashMap with a table keyed by memory id for a durable version; long-term facts that should
outlive the window belong in a store or vector index instead, as discussed in
Agent Memory & Semantic Cache. Official page:
LangChain4j — Chat memory.
Structured outputs
An AI Service method can return more than String. LangChain4j maps the model’s reply to the declared return type:
primitives and wrappers, enum (the model must pick a constant), List / Set, records and POJOs, and
Result<T>, which wraps the parsed value together with the token usage, the finish reason, the RAG sources and the
tool executions of the call. The mechanism depends on the provider, from most to least reliable: a JSON schema sent
as a dedicated request attribute; JSON mode; and, for providers with neither, instructions appended to the prompt.
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.Result;
import dev.langchain4j.service.UserMessage;
import java.util.List;
enum Audience { BEGINNER, PRACTITIONER, EXPERT }
record PageMetadata(@JsonProperty(required = true) String title, Audience audience, List<String> keywords) { }
interface MetadataExtractor {
@UserMessage("Extract metadata from this documentation page:\n{{it}}")
PageMetadata extract(String pageText);
@UserMessage("Classify the audience of this page: {{it}}")
Audience audienceOf(String pageText);
@UserMessage("Extract metadata from this documentation page:\n{{it}}")
Result<PageMetadata> extractWithUsage(String pageText);
}
final class S5Structured {
static MetadataExtractor build(ChatModel model) {
return AiServices.create(MetadataExtractor.class, model);
}
}
With a model that advertises RESPONSE_FORMAT_JSON_SCHEMA (the scripted one in this run), the request carries the
schema, and the prompt is left alone. The enum became JsonEnumSchema with its constants:
response format sent=ResponseFormat { type = JSON, jsonSchema = JsonSchema { name = "PageMetadata", rootElement = JsonObjectSchema {description = null, properties = {title=JsonStringSchema {description = null }, audience=JsonEnumSchema {description = null, enumValues = [BEGINNER, PRACTITIONER, EXPERT] }, keywords=JsonArraySchema {description = null, items = JsonStringSchema {description = null } }}, required = [title], additionalProperties = null, definitions = {} } } }
With a model that does not advertise it, response format stays null and the library appends the format
instructions to the user message instead. Same interface, same call, different wire behaviour:
UserMessage { contents = [TextContent { text = "Extract metadata from this documentation page:
...page text...
You must answer strictly in the following JSON format: {
"title": (type: string),
"audience": (type: enum, must be one of [BEGINNER, PRACTITIONER, EXPERT]),
"keywords": (type: array of string)
}" }] }
The three calls returned PageMetadata[title=Tool Design, audience=PRACTITIONER, keywords=[tools, schemas]], EXPERT
and, through Result<PageMetadata>, the keyword list [tools, schemas] plus the call’s metadata. By default
the generated schema marks no field as required; @JsonProperty(required = true), as on title above, adds it to
the schema’s required list (see required = [title] in the output). Local 8B models follow schemas less reliably
than hosted ones, which is one reason Result<T> and post-parse validation are worth having. The wire formats behind
all this are in
Structured Output & Tool Calling; the Python
equivalents are in Structured Output. Official page:
LangChain4j — Structured outputs.
Streaming
Returning a TokenStream from an AI Service method turns the call into a stream. Nothing happens until start();
callbacks deliver each token (onPartialResponse), retrieved RAG content (onRetrieved), tool activity
(beforeToolExecution, onToolExecuted), the final ChatResponse (onCompleteResponse) and failures (onError).
The service must be built with a StreamingChatModel. Blocking on a CompletableFuture at the end is the
simplest way to wait, and is what a main method or a test does; a web handler would forward tokens instead. The
same interface also supports cancelling a stream from a callback through the StreamingHandle in
onPartialResponseWithContext.
Since LangChain4j 1.20, AI Service methods can also be non-blocking and reactive by changing only the return type:
CompletableFuture<T> or CompletionStage<T> for one answer, Flow.Publisher<String> for the answer streamed in
chunks, and Flow.Publisher<AiServiceStreamingEvent> for every event of the interaction (partial responses, tool
calls, retrieved content, the final response). The publishers are cold: nothing runs until you subscribe, and each
subscription starts a new interaction. The official page marks these asynchronous and reactive APIs experimental,
while TokenStream is unaffected. The reactive types are plain JDK types, so no library is imposed; the
langchain4j-reactor module adds Mono and Flux adapters, and Mutiny’s Uni and Multi are not supported yet.
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.chat.response.ChatResponse;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.TokenStream;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Flow;
interface StreamingAssistant {
TokenStream chat(String message);
}
interface ReactiveAssistant {
Flow.Publisher<String> chatStreaming(String message);
}
final class S6Streaming {
static ChatResponse tokenStream(StreamingChatModel model, String question) {
StreamingAssistant assistant = AiServices.create(StreamingAssistant.class, model);
CompletableFuture<ChatResponse> done = new CompletableFuture<>();
assistant.chat(question)
.onPartialResponse(token -> System.out.print(token + "|"))
.onCompleteResponse(done::complete)
.onError(done::completeExceptionally)
.start();
return done.join();
}
static void publisher(StreamingChatModel model, String question) throws InterruptedException {
ReactiveAssistant assistant = AiServices.create(ReactiveAssistant.class, model);
CompletableFuture<Void> done = new CompletableFuture<>();
assistant.chatStreaming(question).subscribe(new Flow.Subscriber<String>() {
@Override public void onSubscribe(Flow.Subscription s) { s.request(Long.MAX_VALUE); }
@Override public void onNext(String chunk) { System.out.print(chunk + "|"); }
@Override public void onError(Throwable t) { done.completeExceptionally(t); }
@Override public void onComplete() { done.complete(null); }
});
done.join();
}
}
Running both against scripted models that emit Use `, `Pg, Vector and . as four chunks:
Use |Pg|Vector|.|
final=Use PgVector.
Use |Pg|Vector|.|
The last line of the streaming run shows the failure mode. The publisher path needs a StreamingChatModel that
implements the publisher-based doChat; the callback-only fake does not, so subscribing fails immediately with a
clear message instead of blocking a thread:
callback-only model: AsyncNotSupportedException -- doChat() is not implemented by docs.ScriptedStreamingChatModel
That is not only a testing detail. Inspecting the compiled 1.20.2 classes, OpenAiStreamingChatModel implements the
publisher-based doChat while OllamaStreamingChatModel implements only the callback one, so for DocsAssistant on
Ollama use TokenStream (which works with every provider), and reach for Flow.Publisher when the provider you use
supports it. The same holds for chatAsync and CompletableFuture methods, which need doChatAsync: the
same experiment with CompletableFuture<String> failed with doChatAsync() is not implemented. The official page
lists, layer by layer, which components (chat memory, stores, guardrails, tools, RAG, MCP, the HTTP client) have
asynchronous counterparts, and notes that tools run concurrently in the asynchronous and reactive modes and that
streamingBufferSize bounds the event buffer. The Python side of this topic is in
Streaming. Official pages:
LangChain4j — Response streaming and
LangChain4j — Non-blocking and reactive.
Guardrails
Guardrails validate what goes into and comes out of an AI Service. They exist only at the AI Service level (a bare
ChatModel has none), and each one should check exactly one thing. An InputGuardrail runs after RAG and before the
model is called; an OutputGuardrail runs on the model’s answer. The helper methods on the interfaces express the
outcome:
| Outcome | Effect |
|---|---|
|
Validation passed; the next guardrail runs. |
|
Passed, and the message or answer is rewritten for the next step. |
|
Failed, but the remaining guardrails still run so all problems are collected. |
|
Failed and stopped: an |
|
Ask the model again with a corrective message, or simply ask again. |
Guardrails can be set on the builder (inputGuardrails, outputGuardrails, or the …Classes variants), or with
@InputGuardrails / @OutputGuardrails annotations on a method or the interface; the builder wins over a method
annotation, which wins over the interface annotation. Order matters: put cheap checks first and any guardrail that
calls another model last.
DocsAssistant uses one of each. The input guardrail blocks an obvious prompt-injection phrase without spending a model call. The output guardrail insists that answers cite a documentation page and, when they do not, re-prompts once:
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.guardrail.InputGuardrail;
import dev.langchain4j.guardrail.InputGuardrailResult;
import dev.langchain4j.guardrail.OutputGuardrail;
import dev.langchain4j.guardrail.OutputGuardrailResult;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import java.util.Locale;
/** Blocks obvious prompt-injection phrases before the model is called. */
final class NoInstructionOverride implements InputGuardrail {
@Override
public InputGuardrailResult validate(UserMessage userMessage) {
String text = userMessage.singleText().toLowerCase(Locale.ROOT);
return text.contains("ignore previous instructions")
? fatal("The message tries to override the system prompt")
: success();
}
}
/** Requires every answer to cite a documentation page; if not, asks the model once more. */
final class MustCiteAPage implements OutputGuardrail {
@Override
public OutputGuardrailResult validate(AiMessage responseFromLLM) {
return responseFromLLM.text().contains(".adoc")
? success()
: reprompt("The answer cites no page", "Repeat the answer and cite the .adoc page it comes from.");
}
}
final class S7Guardrails {
static DocsAssistant build(ChatModel model) {
return AiServices.builder(DocsAssistant.class)
.chatModel(model)
.inputGuardrails(new NoInstructionOverride())
.outputGuardrails(new MustCiteAPage())
.build();
}
}
The scripted model first answers without a page, then with one. The second call is the reprompt, so the model was called twice for one question; the injection attempt never reached the model, so the counter stays at two:
Use pgvector; see database/vector-rag/postgresql-pgvector.adoc.
model calls=2
blocked: The guardrail docs.NoInstructionOverride failed with this message: The message tries to override the system prompt
model calls after blocked message=2
A string match is a demonstration, not a defence: real injection protection combines least-privilege tools,
approval gates and, for untrusted content, isolation. That larger picture is in
Agent Guardrails & Safety and
Guardrails & Security. Unit-testing helpers for guardrails are in the
langchain4j-test module. Official page: LangChain4j — Guardrails.
Observability
LangChain4j exposes two complementary hooks.
AI Service listeners observe an invocation of an AI Service from start to finish. Each event carries an
InvocationContext (invocation id, interface and method name, method arguments, memory id, timestamp), which lets you
correlate everything that belongs to one call. The events are AiServiceStartedEvent, AiServiceRequestIssuedEvent,
AiServiceResponseReceivedEvent, AiServiceCompletedEvent, AiServiceErrorEvent, ToolExecutedEvent and the
input and output guardrail events. Each has a listener interface, and registerListeners(…) on the builder
attaches them. Chat model listeners (ChatModelListener) sit on the model itself: they see every raw request,
response and error, including the extra model calls made by the tool loop and by guardrail reprompts, with the
OpenTelemetry generative-AI attributes (model, parameters, token usage, finish reason). The provider’s builder takes
them through listeners(…).
import dev.langchain4j.model.chat.listener.ChatModelErrorContext;
import dev.langchain4j.model.chat.listener.ChatModelListener;
import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
import dev.langchain4j.observability.api.event.AiServiceCompletedEvent;
import dev.langchain4j.observability.api.event.ToolExecutedEvent;
import dev.langchain4j.observability.api.listener.AiServiceCompletedListener;
import dev.langchain4j.observability.api.listener.ToolExecutedEventListener;
import java.util.List;
final class LoggingCompletedListener implements AiServiceCompletedListener {
final List<String> log = new java.util.concurrent.CopyOnWriteArrayList<>();
@Override
public void onEvent(AiServiceCompletedEvent event) {
var ctx = event.invocationContext();
log.add("completed " + ctx.interfaceName() + "." + ctx.methodName() + " memoryId=" + ctx.chatMemoryId());
}
}
final class LoggingToolListener implements ToolExecutedEventListener {
final List<String> log = new java.util.concurrent.CopyOnWriteArrayList<>();
@Override
public void onEvent(ToolExecutedEvent event) {
log.add("tool " + event.request().name() + " -> " + event.resultText());
}
}
/** Sees every raw model call, including the ones made by tool loops and guardrail reprompts. */
final class CountingModelListener implements ChatModelListener {
int requests;
int responses;
@Override public void onRequest(ChatModelRequestContext ctx) { requests++; }
@Override public void onResponse(ChatModelResponseContext ctx) { responses++; }
@Override public void onError(ChatModelErrorContext ctx) { }
}
Registered on the tool-calling service from before and run with the scripted model:
var completed = new LoggingCompletedListener();
var toolLog = new LoggingToolListener();
var counting = new CountingModelListener();
var observed = AiServices.builder(DocsAssistant.class)
.chatModel(model) // built with .listeners(List.of(counting))
.tools(new DocsTools(title -> false))
.registerListeners(completed, toolLog)
.build();
observed.chat("Find pgvector docs");
completed docs.DocsAssistant.chat memoryId=default
tool searchDocs -> ["ai/langchain/tools.adoc -- Tools"]
raw model calls: requests=2 responses=2
One service call, one completed event, one tool event and two raw model calls (the tool request and the final answer): the two levels answer different questions. Forward the events to your tracing or metrics backend rather than collecting them in a list; the Python counterpart of that backend is described in Observability with LangSmith. The vendor-neutral view is in Observability for Agents. Official page: LangChain4j — Observability.
Spring Boot and Quarkus
Everything above works in a plain main method. Frameworks add configuration by properties and dependency injection.
Spring Boot. The starters follow a naming convention: langchain4j-{integration}-spring-boot4-starter for
Spring Boot 4 and langchain4j-{integration}-spring-boot-starter for Spring Boot 3, all at 1.20.2-beta30. An
integration starter (for example langchain4j-ollama-spring-boot4-starter) creates the model from properties
such as langchain4j.ollama.chat-model.base-url and langchain4j.ollama.chat-model.model-name, and the
general langchain4j-spring-boot4-starter adds declarative AI Services: annotate an interface with @AiService and
the starter scans for it, builds it from the ChatModel, StreamingChatModel, ChatMemory, ChatMemoryProvider,
ContentRetriever, RetrievalAugmentor and ToolProvider beans it finds, adds every @Tool method of your
@Component and @Service beans, and registers the result as a bean. With several candidate beans of the same
type, switch to wiringMode = EXPLICIT and name the beans. We resolved both starters with Maven and compiled this
against them:
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.spring.AiService;
@AiService
interface SbAssistant {
@SystemMessage("You are DocsAssistant. Answer only from this site's documentation.")
String chat(String userMessage);
}
The property values are ordinary Spring configuration, so the API key of a hosted model belongs in
${OPENAI_API_KEY} style placeholders resolved from the environment, as the official page shows. This page does not
start a Spring application; the Spring Boot side of tools and MCP servers is in
Spring Boot MCP Servers. Spring AI, the other Java option, has its own
Spring AI sub-section in this site’s AI section.
Quarkus. Quarkus uses the Quarkiverse extension io.quarkiverse.langchain4j:quarkus-langchain4j-*, maintained
separately (1.14.0 at the time of writing; for example quarkus-langchain4j-ollama for Ollama). Its declarative form is @RegisterAiService on an interface, injected with
CDI, and it is what the official LangChain4j Quarkus page points to. The extension and the
Quarkus platform’s version pinning are described in
Extensions & the Platform. Official pages:
LangChain4j — Spring Boot integration,
LangChain4j — Quarkus integration and
Quarkus LangChain4j — AI Services.
RAG in LangChain4j
Embedding models, EmbeddingStore, EmbeddingStoreIngestor, EmbeddingStoreContentRetriever,
PgVectorEmbeddingStore and the RetrievalAugmentor that plugs retrieval into an AI Service are already documented,
with a PostgreSQL example for DocsAssistant, in
Integrating with LangChain: LangChain4j. This
page does not repeat them. What this page adds to that one is the surrounding machinery: the onRetrieved callback and
Result.sources() expose the retrieved content, an input guardrail runs after retrieval, and retrieval can be exposed
to the model as a tool instead of being injected on every call. The Python side is bridged in
Retrieval & the RAG Bridge.
From LangChain to LangChain4j
The concepts line up more than the names do:
| LangChain (Python) | LangChain4j | Difference to watch |
|---|---|---|
|
|
Explicit provider class and builder; no string-based model factory in the core. |
|
|
Prompts live on the interface; double-brace variables. |
|
|
The tool loop is part of every AI Service; there is no separate agent object until the agentic module. |
|
Return type of the AI Service method, |
The schema is derived from the Java type. |
Checkpointer and |
|
Memory is a message window, not graph state. |
Middleware, |
Guardrails, listeners, application code |
No built-in approval step; write it into the tool. |
|
|
Reactive AI Services are experimental in 1.20. |
LangSmith tracing |
|
Wire them to your own telemetry backend. |
The LangGraph-style workflows, supervisors and MCP client have their own page: LangChain4j Agents & MCP. For the JVM-side choice against Spring AI and other frameworks, see Frameworks Landscape.