This module provides integration between Micronaut and Langchain4j.
Note
This module is regarded as experimental and subject to change since the underlying technology (AI) is volatile and subject to change.
Various modules are provided that allow automatically configuring common Langchain4j types like ChatModel, ImageModel etc. Refer to the sections below for the supported Langchain4j extensions.
2 Quick Start
Add the following annotation processor dependency:
Add the necessary configuration to configure the model name you want to use:
Configuring the Model Name
langchain4j.ollama.model-name=orca-mini
langchain4j.ollama.model-name: orca-mini
"langchain4j.ollama.model-name" = "orca-mini"
langchain4j.ollama.modelName = 'orca-mini'
{langchain4j.ollama.model-name = "orca-mini"}
{ "langchain4j.ollama.model-name": "orca-mini"}
3 Response Streaming
It is possible to use response streaming.
First, you need to configure a streaming chat model with langchain4j.*.streaming-chat-model.*. For example, with OpenAI:
Then, you will be able to inject a bean of type dev.langchain4j.model.chat.StreamingChatModel.
Additionally, you can use an AI Service with a method whose return type uses Project Reactor.
For example, an @AIService interface with a method whose return type is Flux<String>.
In order to do this, you will need to add the following dependency:
In Python an AI service is an abstract class (abc.ABC) whose methods are declared with … bodies; the decorators mirror the LangChain4j annotations.
Note
LangChain4j builds the service implementation reflectively from the Java interface generated for the Python class and reads the @SystemMessage/@UserMessage annotations from it, so the annotations must be copied onto the generated interface: declare the @AllowsReflection hint (micronaut.core.annotation) on the AI service, or name its package in the micronaut.introspection.allow-reflection compiler option (micronautBuild.python.compilerArgs.add("-Amicronaut.introspection.allowReflection=example.micronaut.*") in the Gradle build, as the examples of this guide do).
You can now inject the @AiService definition into any Micronaut component including tests:
@MicronautTest(startApplication=False, environments=["ollama"])class AiServiceTest: friend: Annotated[Friend, Inject] language_model: Annotated[ChatModel, Inject] @Test def test_ai_service(self): result = self.friend.chat("Hello") assert result is not None assert self.language_model is not None
Retrieval is opt-in. An @AiService performs retrieval augmented generation (RAG) only when the application declares one of:
a dev.langchain4j.rag.RetrievalAugmentor bean, or
a dev.langchain4j.rag.content.retriever.ContentRetriever bean, which LangChain4j wraps in a DefaultRetrievalAugmentor.
A RetrievalAugmentor bean takes precedence over a ContentRetriever bean. For either type, a bean @Named after the service (for example @Named("expert") for @AiService("expert")) is preferred over the default bean. If several candidates exist and none is named after the service or marked @Primary, no retrieval is configured.
Defining an @AiService that returns Result<String>
Declaring a ContentRetriever bean
Retrieved content reaches the model and the result
To configure a single service, declare an AiServiceCustomizer bean for it and call contentRetriever(…) or retrievalAugmentor(…) on AiServiceCreationContext.aiServices().
Note
Before 2.3.0 an EmbeddingStoreContentRetriever was attached to every @AiService automatically whenever an EmbeddingModel bean and an EmbeddingStore bean existed. Because provider modules expose an embedding model and the in-memory embedding store is enabled by default, that was the case in most applications, causing an embedding round trip on every call and failures against models without embedding support. Declare a ContentRetriever bean as shown above to restore the previous behaviour.
5 Agentic Service
Agentic Service
Micronaut lets you declare LangChain4j Agentic services and have concrete agents generated at runtime.
Annotate an interface with AgenticService and declare methods using LangChain4j Agentic annotations.
Note
In Python an agentic service is an abstract class (abc.ABC) whose methods are declared with … bodies; the decorators mirror the LangChain4j annotations.
Note
LangChain4j builds the agents reflectively from the Java interface generated for the Python class and reads the @Agent, @UserMessage, @SequenceAgent, … annotations from it, so the annotations must be copied onto the generated interface: declare the @AllowsReflection hint (micronaut.core.annotation) on the agentic service, or name its package in the micronaut.introspection.allow-reflection compiler option (micronautBuild.python.compilerArgs.add("-Amicronaut.introspection.allowReflection=example.micronaut.*") in the Gradle build, as the examples of this guide do).
The integration interprets your annotations and uses Micronaut DI to wire the underlying LangChain4j builders.
It supports:
Typed agents built via AgenticServices.agentBuilder(Class) with @Agent methods.
Declarative workflow agents via AgenticServices.createAgenticSystem(…).
Micronaut model, memory, RAG, tool, and lifecycle integration for each generated agent builder.
/** * Minimal typed Agentic service used by the test-suite to validate Micronaut integration. */@AgenticServicepublic interface GreeterAgent { @UserMessage("Say hello to {{name}}") @Agent(description = "Greets a person by name") String greet(@V("name") String name);}
# Minimal typed Agentic service used by the test-suite to validate Micronaut integration.@AgenticServiceclass GreeterAgent(ABC): @UserMessage("Say hello to {{name}}") @Agent(description="Greets a person by name") @abstractmethod def greet(self, name: Annotated[str, V("name")]) -> str: ...
/** * Minimal typed Agentic service used by the test-suite to validate Micronaut integration. */@AgenticServiceinterface GreeterAgent { @UserMessage("Say hello to {{name}}") @Agent(description = "Greets a person by name") fun greet(@V("name") name: String): String}
/** * Minimal typed Agentic service used by the test-suite to validate Micronaut integration. */@AgenticServiceinterface GreeterAgent { @UserMessage("Say hello to {{name}}") @Agent(description = "Greets a person by name") String greet(@V("name") String name)}
@MicronautTest(startApplication=False, environments=["agentic-test"])class AgenticServiceTest: agent: Annotated[GreeterAgent, Inject] @Test def test_agentic_greeter(self): result = self.agent.greet("John") assert result is not None
@MicronautTest(startApplication = false, environments = ["agentic-test"])@TestInstance(TestInstance.Lifecycle.PER_CLASS)internal class AgenticServiceTest { @Test fun testAgenticGreeter(agent: GreeterAgent) { val result = agent.greet("John") assertNotNull(result) }}
You can build workflows using LangChain4j’s declarative annotations. The integration routes construction through Micronaut DI so that supported workflow builders are Micronaut-managed beans (allowing listeners and customization).
Supervisor-style and planner-style agents can still be modeled using LangChain4j typed agents or composed workflows, but the Micronaut-managed workflow builder lifecycle currently applies to the workflow types listed above.
/** * Validates declarative ParallelAgent workflow wiring through @AgenticService. * Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan. */@MicronautTest(startApplication = false, environments = "agentic-test")@TestInstance(TestInstance.Lifecycle.PER_CLASS)class ParallelPlanningAgentTest { @Test void testDeclarativeParallel(EveningPlanner agent) { String plan = agent.plan("jazz", "romantic"); System.out.println("parallel plan = " + plan); assertFalse(plan.isEmpty()); } @AgenticService public interface MusicPlanner { @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey = "band") String suggestBand(@V("style") String style); } @AgenticService public interface DinnerPlanner { @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey = "menu") String suggestMenu(@V("mood") String mood); } /** * Demonstrates declarative ParallelAgent orchestration using Micronaut DI. */ @AgenticService(outputKey = "plan") public interface EveningPlanner { @ParallelAgent( subAgents = { MusicPlanner.class, DinnerPlanner.class }, outputKey = "plan", name = "planEvening" ) String plan(@V("style") String style, @V("mood") String mood); // Use a shared executor for parallel execution @ParallelExecutor static Executor executor() { return ForkJoinPool.commonPool(); } // Aggregate the parallel outputs into a single "plan" string @Output static String aggregate(@V("band") String band, @V("menu") String menu) { String c = band == null ? "" : band.trim(); String r = menu == null ? "" : menu.trim(); if (c.isEmpty() && r.isEmpty()) { return ""; } if (c.isEmpty()) { return "Play: " + r; } if (r.isEmpty()) { return "Menu: " + c; } return "Play: " + c + " | Menu: " + r; } }}
@AgenticServiceclass MusicPlanner(ABC): @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey="band") @abstractmethod def suggest_band(self, style: Annotated[str, V("style")]) -> str: ...@AgenticServiceclass DinnerPlanner(ABC): @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey="menu") @abstractmethod def suggest_menu(self, mood: Annotated[str, V("mood")]) -> str: ...# Demonstrates declarative ParallelAgent orchestration using Micronaut DI.@AgenticService(outputKey="plan")class EveningPlanner(ABC): @ParallelAgent( subAgents=[ MusicPlanner, DinnerPlanner ], outputKey="plan", name="planEvening" ) @abstractmethod def plan(self, style: Annotated[str, V("style")], mood: Annotated[str, V("mood")]) -> str: ... # Use a shared executor for parallel execution @ParallelExecutor @staticmethod def executor() -> Executor: return ForkJoinPool.commonPool() # Aggregate the parallel outputs into a single "plan" string @Output @staticmethod def aggregate(band: Annotated[str, V("band")], menu: Annotated[str, V("menu")]) -> str: c = "" if band is None else band.strip() r = "" if menu is None else menu.strip() if c == "" and r == "": return "" if c == "": return f"Play: {r}" if r == "": return f"Menu: {c}" return f"Play: {c} | Menu: {r}"# Validates declarative ParallelAgent workflow wiring through @AgenticService.# Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan.@MicronautTest(startApplication=False, environments=["agentic-test"])class ParallelPlanningAgentTest: agent: Annotated[EveningPlanner, Inject] @Test def test_declarative_parallel(self): plan = self.agent.plan("jazz", "romantic") print(f"parallel plan = {plan}") assert plan != ""
/** * Validates declarative ParallelAgent workflow wiring through @AgenticService. * Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan. */@MicronautTest(startApplication = false, environments = ["agentic-test"])@TestInstance(TestInstance.Lifecycle.PER_CLASS)internal class ParallelPlanningAgentTest { @Test fun testDeclarativeParallel(agent: EveningPlanner) { val plan = agent.plan("jazz", "romantic") println("parallel plan = $plan") assertFalse(plan.isEmpty()) } @AgenticService interface MusicPlanner { @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey = "band") fun suggestBand(@V("style") style: String): String } @AgenticService interface DinnerPlanner { @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey = "menu") fun suggestMenu(@V("mood") mood: String): String } /** * Demonstrates declarative ParallelAgent orchestration using Micronaut DI. */ @AgenticService(outputKey = "plan") interface EveningPlanner { @ParallelAgent( subAgents = [ MusicPlanner::class, DinnerPlanner::class ], outputKey = "plan", name = "planEvening" ) fun plan(@V("style") style: String, @V("mood") mood: String): String companion object { // Use a shared executor for parallel execution @JvmStatic @ParallelExecutor fun executor(): Executor = ForkJoinPool.commonPool() // Aggregate the parallel outputs into a single "plan" string @JvmStatic @Output fun aggregate(@V("band") band: String?, @V("menu") menu: String?): String { val c = band?.trim() ?: "" val r = menu?.trim() ?: "" if (c.isEmpty() && r.isEmpty()) { return "" } if (c.isEmpty()) { return "Play: $r" } if (r.isEmpty()) { return "Menu: $c" } return "Play: $c | Menu: $r" } } }}
/** * Validates declarative ParallelAgent workflow wiring through @AgenticService. * Ensures Micronaut DI correctly builds the agentic system and executes the parallel plan. */@MicronautTest(startApplication = false, environments = "agentic-test")@TestInstance(TestInstance.Lifecycle.PER_CLASS)class ParallelPlanningAgentTest { @Test void testDeclarativeParallel(EveningPlanner agent) { String plan = agent.plan("jazz", "romantic") println("parallel plan = " + plan) assertFalse(plan.isEmpty()) } @AgenticService static interface MusicPlanner { @UserMessage(""" Choose a band which plays music in the {{style}} style. Answer with the name of the band only: no details, no explanation. """) @Agent(outputKey = "band") String suggestBand(@V("style") String style) } @AgenticService static interface DinnerPlanner { @UserMessage(""" Choose a menu for dinner for the following mood: {{mood}} Answer with the menu only: no details, no explanations. """) @Agent(outputKey = "menu") String suggestMenu(@V("mood") String mood) } /** * Demonstrates declarative ParallelAgent orchestration using Micronaut DI. */ @AgenticService(outputKey = "plan") static interface EveningPlanner { @ParallelAgent( subAgents = [ MusicPlanner, DinnerPlanner ], outputKey = "plan", name = "planEvening" ) String plan(@V("style") String style, @V("mood") String mood) // Use a shared executor for parallel execution @ParallelExecutor static Executor executor() { ForkJoinPool.commonPool() } // Aggregate the parallel outputs into a single "plan" string @Output static String aggregate(@V("band") String band, @V("menu") String menu) { String c = band == null ? "" : band.trim() String r = menu == null ? "" : menu.trim() if (c.isEmpty() && r.isEmpty()) { return "" } if (c.isEmpty()) { return "Play: " + r } if (r.isEmpty()) { return "Menu: " + c } return "Play: " + c + " | Menu: " + r } }}
/** * Demonstrates declarative LoopAgent orchestration using Micronaut DI. */@AgenticService(outputKey = "translation")public interface LoopingPlannerAgent { @LoopAgent( subAgents = { TranslatorAgent.class }, outputKey = "text", maxIterations = 3 ) String translatesInLoop(@V("text") String text); interface TranslatorAgent { @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey = "text") String translate(@V("text") String text); } @Singleton @Requires(property = "spec.name", value = "LoopingPlannerAgentTest") class LoopBuilderListener implements BeanCreatedEventListener<LoopAgentService<?>> { @Override public LoopAgentService<?> onCreated(@NonNull BeanCreatedEvent<LoopAgentService<?>> event) { LoopAgentService<?> builder = event.getBean(); builder.exitCondition((scope, idx) -> idx == 3); return builder; } }}
class TranslatorAgent(ABC): @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey="text") @abstractmethod def translate(self, text: Annotated[str, V("text")]) -> str: ...# Demonstrates declarative LoopAgent orchestration using Micronaut DI.@AgenticService(outputKey="translation")class LoopingPlannerAgent(ABC): @LoopAgent( subAgents=[ TranslatorAgent ], outputKey="text", maxIterations=3 ) @abstractmethod def translates_in_loop(self, text: Annotated[str, V("text")]) -> str: ...@Singleton@Requires(property="spec.name", value="LoopingPlannerAgentTest")class LoopBuilderListener(BeanCreatedEventListener[LoopAgentService]): def onCreated(self, event: BeanCreatedEvent[LoopAgentService]) -> LoopAgentService: builder = event.getBean() builder.exitCondition(lambda scope, idx: idx == 3) return builder
/** * Demonstrates declarative LoopAgent orchestration using Micronaut DI. */@AgenticService(outputKey = "translation")interface LoopingPlannerAgent { @LoopAgent( subAgents = [ TranslatorAgent::class ], outputKey = "text", maxIterations = 3 ) fun translatesInLoop(@V("text") text: String): String interface TranslatorAgent { @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey = "text") fun translate(@V("text") text: String): String } @Singleton @Requires(property = "spec.name", value = "LoopingPlannerAgentTest") class LoopBuilderListener : BeanCreatedEventListener<LoopAgentService<*>> { override fun onCreated(event: BeanCreatedEvent<LoopAgentService<*>>): LoopAgentService<*> { val builder = event.bean builder.exitCondition { _, idx -> idx == 3 } return builder } }}
/** * Demonstrates declarative LoopAgent orchestration using Micronaut DI. */@AgenticService(outputKey = "translation")interface LoopingPlannerAgent { @LoopAgent( subAgents = [ TranslatorAgent ], outputKey = "text", maxIterations = 3 ) String translatesInLoop(@V("text") String text) interface TranslatorAgent { @UserMessage(""" You are a translator. If the text is in English, translate to French. If the text is in French, translate to German. If the text is in German, translate to Spanish. Translate this: "{{text}}". Answer with the translation only, no explanations, no details. """) @Agent(outputKey = "text") String translate(@V("text") String text) } @Singleton @Requires(property = "spec.name", value = "LoopingPlannerAgentTest") class LoopBuilderListener implements BeanCreatedEventListener<LoopAgentService<?>> { @Override LoopAgentService<?> onCreated(BeanCreatedEvent<LoopAgentService<?>> event) { LoopAgentService<?> builder = event.bean builder.exitCondition { scope, idx -> idx == 3 } builder } }}
Configuration
You can select which ChatModel bean to use per agent via configuration. The agent id is derived from the interface name by:
stripping a trailing "Agent" suffix
converting UpperCamel to lower-kebab (e.g. CreativeWriterAgent → creative-writer)
If the property is not set and there is exactly one ChatModel bean in the context, it will be used automatically.
Memory
By default, agentic services reuse the core MessageWindowChatMemory built from the configured ChatMemoryStore. The core module publishes a MessageWindowChatMemory.Builder per available ChatMemoryStore (via @EachBean(ChatMemoryStore)), so agents automatically use the default builder when a single memory store is configured.
Notes:
- If memory.store is not set, the default MessageWindowChatMemory.Builder is used (as resolved by Micronaut DI).
- If multiple memory stores are available, set memory.store to select the store for an agent.
- If memory.max-messages is not set, the global core setting langchain4j.chat-memory-store.message-window.max-messages applies.
Retrieval is opt-in. An agent performs retrieval augmented generation only when the application declares a dev.langchain4j.rag.RetrievalAugmentor bean or a dev.langchain4j.rag.content.retriever.ContentRetriever bean. A RetrievalAugmentor takes precedence over a ContentRetriever. For either type, a bean @Named after the agent id (for example @Named("greeter") for GreeterAgent) is preferred over the default bean. Embedding model and embedding store beans are never paired automatically.
Tools
You can request specific tool beans to be registered with the agent builder using the tools attribute.
Tool classes should be Micronaut beans containing methods annotated with dev.langchain4j.agent.tool.Tool.
Customization
You can customize builders using Micronaut lifecycle listeners. Because workflow builders are created as Micronaut beans during declarative system construction, your listeners can adjust names, exit conditions, parallelism, etc., before the system executes.
Typed agents can also be customized via BeanCreatedEventListener<AgentBuilder<?, ?>>; the LoopingPlannerAgent snippet above shows the same Micronaut lifecycle technique applied to LoopAgentService.
For example, this listener customizes every generated declarative agent builder before LangChain4j builds the agentic system:
Workflow builders can be customized the same way. This example changes the loop exit condition for all LoopAgentService builders created by the agentic integration:
The same approach applies to Micronaut-managed workflow services: SequentialAgentService, ParallelAgentService, ParallelMapperService, ConditionalAgentService, and LoopAgentService.
6 Structured Outputs
When an @AiService or @AgenticService method returns a POJO (or a List or Set of POJOs), LangChain4j asks the model for a JSON answer: it derives a JSON schema from the return type reflectively and sends it as the response format, or appends format instructions to the user message when the model does not support JSON schemas.
With Micronaut JSON Schema, the schema is generated at compile time instead, and the generated schema is the one the model receives. Compared with the schema LangChain4j derives:
the descriptions come from the Javadoc (or KDoc, or Python docstrings) of the type and its properties, and the validation constraints (format, pattern, minimum, …) are passed on to the model in the descriptions;
no reflection is needed to build the schema, which suits GraalVM native images;
the schema only contains the properties of the type, whereas the reflective schema of a class generated by a compiler (for example, the Java class of a Python class) can contain synthetic or internal fields.
Add the JSON Schema annotation processor and the runtime support:
Then annotate the types returned by your services with io.micronaut.jsonschema.JsonSchema:
A structured output type
Note
The Groovy compiler does not keep the Groovydoc of the classes, so set the description with the description member of @JsonSchema.
Note
The class is declared @Introspected so that LangChain4j, which parses the answer of the model with Jackson, can construct it. In Python, do not name the @JsonSchema types in the micronaut.introspection.allow-reflection compiler option (the schema would be generated twice, failing the compilation): the integration does not need the annotation at runtime.
Note
Micronaut JSON Schema describes the properties of a type: the components of a record, the accessors of a bean, or the fields of a class annotated with @Introspected(accessKind = Introspected.AccessKind.FIELD). LangChain4j also reads and writes plain fields, which are not properties for Micronaut. For a class whose state is only held in such fields, the generated schema has no properties: a warning is logged, and the schema LangChain4j derives is sent instead.
No other change is needed: the AI services and agents returning these types send the generated schemas.
An @AiService returning structured outputs
Checking the schema received by the model
The schema of a type returned directly, in a List or a Set, or wrapped in Result, CompletableFuture or CompletionStage is replaced. References to the schemas of other @JsonSchema types are resolved and inlined, except recursive ones, which are sent as definitions ($defs). LangChain4j still parses the answer and decides whether the response format is sent; streaming methods are unaffected.
LangChain4j only sends the schema to the chat models that declare the RESPONSE_FORMAT_JSON_SCHEMA capability. For the other models, it appends format instructions derived from the type to the user message, and the generated schema is not used.
Some models accept a JSON schema on each request but do not always declare the capability. Google AI Gemini declares it only when a JSON response format is configured on the model, yet it honors the response format of each request: with the micronaut-langchain4j-googleai-gemini module, the services returning a type with a generated schema send it to Gemini without further configuration. Providers declare such models with a bean of type JsonSchemaResponseFormatSupport.
For other models, set langchain4j.structured-output.force-json-schema-response-format to true to send the schema anyway, or to false to never send it to the models that do not declare the capability:
Only use it with models that support a JSON schema response format: a model that does not may ignore it or reject the request.
To use the schemas LangChain4j derives instead of the generated ones, set langchain4j.structured-output.enabled to false.
7 Testing
Micronaut LangChain4j includes a small evaluation API for asserting AI responses in tests.
The EvaluationRequest record captures the original user text, optional grounding context, and generated response.
An Evaluator consumes that request and returns an EvaluationResult.
Built-in evaluators include:
RelevancyEvaluator for checking whether the response answers the user request.
FactCheckingEvaluator for checking whether the response is grounded in the supplied context.
When an AI service returns dev.langchain4j.service.Result<T>, you can reuse retrieved sources as evaluation context:
Defining an @AiService that returns Result<String>
@Requires(property = "spec.name", value = "AiServiceEvaluationExample")@AiServicepublic interface EvaluatingFriend { @SystemMessage("You are a good friend of mine. Answer using slang.") Result<String> chat(String userMessage);}
@Requires(property="spec.name", value="AiServiceEvaluationExample")@AiServiceclass EvaluatingFriend(ABC): @SystemMessage("You are a good friend of mine. Answer using slang.") @abstractmethod def chat(self, user_message: str) -> Result[str]: ...
@Requires(property = "spec.name", value = "AiServiceEvaluationExample")@AiServiceinterface EvaluatingFriend { @SystemMessage("You are a good friend of mine. Answer using slang.") fun chat(userMessage: String): Result<String>}
@Requires(property = "spec.name", value = "AiServiceEvaluationExample")@AiServiceinterface EvaluatingFriend { @SystemMessage("You are a good friend of mine. Answer using slang.") Result<String> chat(String userMessage)}
@Property(name = "spec.name", value = "AiServiceEvaluationExample")@Testcontainers(disabledWithoutDocker = true)@MicronautTest(startApplication = false)@TestInstance(TestInstance.Lifecycle.PER_CLASS)internal class AiServiceEvaluationExample : OllamaTestPropertyProvider { @Test fun evaluatesAiServiceResponse(friend: EvaluatingFriend, chatModel: ChatModel) { val userText = "Reply with exactly: Micronaut is a JVM framework." val response = friend.chat(userText) val evaluator = RelevancyEvaluator(chatModel) val evaluation = evaluator.evaluate(EvaluationRequest.from(userText, response)) assertNotNull(evaluation) assertFalse(evaluation.feedback().isBlank()) }}
@Property(name = "spec.name", value = "AiServiceEvaluationExample")@Testcontainers(disabledWithoutDocker = true)@MicronautTest(startApplication = false)@TestInstance(TestInstance.Lifecycle.PER_CLASS)class AiServiceEvaluationExample implements OllamaTestPropertyProvider { @Test void evaluatesAiServiceResponse(EvaluatingFriend friend, ChatModel chatModel) { String userText = "Reply with exactly: Micronaut is a JVM framework." Result<String> response = friend.chat(userText) RelevancyEvaluator evaluator = new RelevancyEvaluator(chatModel) EvaluationResult evaluation = evaluator.evaluate(EvaluationRequest.from(userText, response)) assertNotNull(evaluation) assertFalse(evaluation.feedback().isBlank()) }}
FactCheckingEvaluator requires non-empty context, which makes it a good fit for RAG-style responses backed by retrieved sources.
8 Tools
Tools allow AI models to request specific actions that extends beyond their built-in capabilities.
A model would have no way to know the last date when the PRIVACY document was updated. Hence, it is a good candidate for a tool.
<3> The dates are harcoded for the purpose of this example, but they could have been retrieved from a database or external API.
LangChain4j discovers @Tool methods reflectively on the Java class of the tool bean, so the annotations of Python tool classes and AI services must be copied onto the generated Java classes: declare the @AllowsReflection hint (micronaut.core.annotation) on them, or name their packages in the micronaut.introspection.allow-reflection compiler option (micronautBuild.python.compilerArgs.add("-Amicronaut.introspection.allowReflection=example.micronaut.*") in the Gradle build, as the examples of this guide do). The tool is registered under the method name, which the model sees, so the Python example keeps the Java name lastUpdatePrivacy.
9 Chat Language Models
The following modules provide integration with Langchain4j Language Models.
Each module configures one or more ChatLanguageModel beans, making them available for dependency injection based on configuration.
9.1 ChatModel Example
This example, asks a chat model to generate the list of the top 3 albums of a Jazz musician.
imports
package example.micronaut;
public record Musician(String name, String albums) {}
imports
from dataclasses import dataclassfrom micronaut.core.annotation import Introspected
And a local SLM (Small Language Model) such as Ollama in the test classpath:
src/test/resources/application-test.properties
langchain4j.open-ai.enabled=falselangchain4j.ollama.model-name=tinyllamalangchain4j.ollama.chat-model.timeout=5m# Ollama 0.32.10 changed the default from 1.1 to 1.0; without the penalty tinyllama ignores the chat historylangchain4j.ollama.chat-model.repeat-penalty=1.1langchain4j.ollama.chat-model.log-requests=truelangchain4j.ollama.chat-model.log-responses=true
Moreover, you can also register a bean of type BeanCreatedEventListener to configure the Chat Model builder programmatically if configuration is not enough.
Models are stateless by design. Chat memory serves as container for previous messages,
helping you maintain context in a conversation, but the model itself is not aware of this memory;
it relies on you to include the relevant messages in each request for coherent and contextually relevant responses.
Langchain4J provides an API ChatMemory to help you manage chat memory. You can provide your own implementation or use one of the provided implementations.
The default implementation of ChatMemory, dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore, stores ChatMessage instances in memory.
To use the Redis implementation dev.langchain4j.community.store.memory.chat.redis.RedisChatMemoryStore, add the following dependency:
The table must already exist. By default, the expected schema is CHAT_MEMORY(MEMORY_ID, CONTENT).
The segment under oracle (for example default) maps to the datasource name.
For multiple datasources, configure multiple entries such as
langchain4j.chat-memory-store.oracle.reporting.*.
The following example shows how to use the ChatMemory:
You could invoke the previous class as illustrated in the following test:
@Testvoid chatWithMemory(AssistantWithMemory assistant) { MemoryIdAndResponse johnConversation = assistant.chat("Let me introduce myself. My name is John"); String johnConversationId = johnConversation.memoryId(); assertNotNull(johnConversationId); MemoryIdAndResponse aegonConversation = assistant.chat("Let me introduce myself. My name is Dan"); String aegonConversationId = aegonConversation.memoryId(); assertNotNull(aegonConversationId); MemoryIdAndResponse answer = assistant.chat(johnConversationId, "What's my name?"); assertTrue(answer.response().toLowerCase().contains("john"), answer.response()); answer = assistant.chat(aegonConversationId, "What's my name?"); assertTrue(answer.response().toLowerCase().contains("dan"), answer.response());}
@Testdef chat_with_memory(self): john_conversation = self.assistant.chat("Let me introduce myself. My name is John") john_conversation_id = john_conversation.memory_id assert john_conversation_id is not None aegon_conversation = self.assistant.chat("Let me introduce myself. My name is Dan") aegon_conversation_id = aegon_conversation.memory_id assert aegon_conversation_id is not None answer = self.assistant.chat("What's my name?", john_conversation_id) assert "john" in answer.response.lower(), answer.response answer = self.assistant.chat("What's my name?", aegon_conversation_id) assert "dan" in answer.response.lower(), answer.response
@Testfun chatWithMemory(assistant: AssistantWithMemory) { val johnConversation = assistant.chat("Let me introduce myself. My name is John") val johnConversationId = johnConversation.memoryId assertNotNull(johnConversationId) val aegonConversation = assistant.chat("Let me introduce myself. My name is Dan") val aegonConversationId = aegonConversation.memoryId assertNotNull(aegonConversationId) var answer = assistant.chat(johnConversationId, "What's my name?") assertTrue(answer.response.lowercase().contains("john"), answer.response) answer = assistant.chat(aegonConversationId, "What's my name?") assertTrue(answer.response.lowercase().contains("dan"), answer.response)}
@Testvoid chatWithMemory(AssistantWithMemory assistant) { MemoryIdAndResponse johnConversation = assistant.chat("Let me introduce myself. My name is John") String johnConversationId = johnConversation.memoryId assertNotNull(johnConversationId) MemoryIdAndResponse aegonConversation = assistant.chat("Let me introduce myself. My name is Dan") String aegonConversationId = aegonConversation.memoryId assertNotNull(aegonConversationId) MemoryIdAndResponse answer = assistant.chat(johnConversationId, "What's my name?") assertTrue(answer.response.toLowerCase().contains("john"), answer.response) answer = assistant.chat(aegonConversationId, "What's my name?") assertTrue(answer.response.toLowerCase().contains("dan"), answer.response)}
Provider modules use Micronaut’s HTTP client API and exclude LangChain4j’s JDK HTTP client.
Add a concrete Micronaut HTTP client implementation to your application, for example the default Netty implementation:
To provide explicit Google Cloud credentials, register a GoogleCredentials bean. When no such bean is present, the Vertex AI client uses Application Default Credentials.
To provide explicit Google Cloud credentials, register a GoogleCredentials bean. When no such bean is present, the Vertex AI Gemini client uses Application Default Credentials.
An in-memory embedding store is enabled by default, set the following property
langchain4j.in-memory.embedding-store.enabled with value false to disable it.
The store is not attached to AI services automatically. See the Retrieval Augmented Generation section of the AI Service guide to declare a ContentRetriever bean.
datasources.default.dialect: postgreslangchain4j.pgvector.embedding-stores.default.table: "mytable"langchain4j.pgvector.embedding-stores.default.dimension: 384# Add this if you plan to use testresourcestest-resources.containers.postgres.image-name: pgvector/pgvector:pg16
# Omitt the following 2 properties if you use Test resourceslangchain4j.qdrant.embedding-store.host: localhostlangchain4j.qdrant.embedding-store.port: 6334# Minimal configuration required for Test resourceslangchain4j.qdrant.embedding-store.collection-name: mycollection