Skip to main content

Spring AI vs LangChain4j in 2026: Is the Pairing Still Worth It?

The same order assistant built in Spring AI 2.0.1 and LangChain4j 1.22.0 on Spring Boot 4.1.1, run against a fake model server: the startup bean collision, the wire differences, tool failures and loops, metrics, MCP-shared tools, which 2025 code still compiles, and a decision matrix.

The first time I shipped an AI feature in a Spring Boot service I reached for LangChain4j, because I knew Python’s LangChain and wanted the Java equivalent. Later I tried Spring AI and had streaming chat running in forty minutes. In 2025 I wrote down why I ended up using both: Spring AI for the chat client, retrieval and metrics, LangChain4j for the agent loop and tools. That article said Spring AI’s tool calling was too limited for what I needed. This is the 2026 rewrite, and it does something the original did not: it runs the claims. Both frameworks are now pointed at the same fake model server, given the same tool, the same question and the same failures, and the results are quoted from files committed with the code. Some of the 2025 advice survives. Some of it I could not reproduce, and I say so where it happens. The old article’s own code is kept in expandable sections, each with a verdict on whether it still compiles today. If you are new to this: Spring AI is the Spring team’s library for talking to language models from a Spring Boot application. LangChain4j is an independent Java library with the same job, modelled on Python’s LangChain. Both let you send a prompt, let the model call your Java methods (tools), and read the answer. The question is whether you need one or both.
The short answer. For most applications, pick one. On the one workload I ran in both, the tool-calling loop behaved the same, and the 2025 reason for pairing them (Spring AI’s tool calling being too limited) did not show up. Use both only if you want something specific from each, such as Spring AI’s advisors and observation setup together with LangChain4j’s declarative AiServices interfaces or its agentic module, and then accept the costs measured below: a startup collision to resolve, two sets of beans, two HTTP stacks. The decision matrix near the end maps situations to choices. What I did not test is listed in the box that follows.
Versions, and honest limits. Spring Boot 4.1.1, Spring AI 2.0.1, LangChain4j 1.22.0 (its Spring Boot 4 starters are 1.22.0-beta32, beta only), Java 25. The code is the langchain4j-interop module of asmhatre/spring-ai, and every console block is quoted from a file under langchain4j-interop/output/. Limits. The “model” is a small fake OpenAI server inside the tests, with rules I wrote, so the tests show how the frameworks behave around a model and say nothing about how a real model behaves. I did not test streaming, retrieval quality, chat memory, real latency or cost, or LangChain4j’s agentic module. The original article’s numbers and prose claims that I could not reproduce are labelled not verified rather than corrected from memory. Timings, where they appear, come from a cloud sandbox with 2 virtual CPUs.

The same assistant, built twice

The fairest comparison is the smallest one that exercises both frameworks: a support assistant with one tool that looks up an order, asked “Where is ORD-1001?”. The tool body is shared, and only the annotations differ. First the Spring AI version of the tool, then LangChain4j’s (SpringAiTools.java, Lc4jTools.java):
    @Tool(description = "Get current order status by order ID")
    public String getOrderStatus(@ToolParam(description = "The unique identifier of the order") String orderId) {
        return orders.describe(orderId);
    }
    @Tool("Get current order status by order ID")
    public String getOrderStatus(@P("The unique identifier of the order") String orderId) {
        return orders.describe(orderId);
    }
And the wiring, from Wiring.java. Spring AI builds a ChatClient and hands it the tool object; LangChain4j generates an implementation of an interface you write (here Support, which carries the system prompt in an annotation):
    public static ChatClient springAssistant(String baseUrl, OrderService orders) {
        return ChatClient.builder(springModel(baseUrl)).defaultSystem(SYSTEM).defaultTools(new SpringAiTools(orders)).build();
    }

    public static Support lc4jAssistant(String baseUrl, OrderService orders) {
        return AiServices.builder(Support.class).chatModel(lc4jModel(baseUrl)).tools(new Lc4jTools(orders)).build();
    }
Run against the fake server (SameAssistantTest.java), both produce the same answer in two model requests and run the tool body once. From 02-same-assistant.txt:
Spring AI answer    : Done: "Order ORD-1001: Status=SHIPPED, ETA=2026-10-14"
LangChain4j answer  : Done: Order ORD-1001: Status=SHIPPED, ETA=2026-10-14
HTTP requests sent  : Spring AI 2, LangChain4j 2.   Tool body executed: Spring AI 1, LangChain4j 1.
Going deeper: what differs on the wire
The two frameworks send slightly different JSON to the model. The test computes the differences from the four captured request bodies instead of eyeballing them:
What differs on the wire (computed from the four bodies below):
  top-level fields   : Spring AI [messages, model, tools], LangChain4j [messages, model, stream, tools]
  tool schema        : Spring AI has $schema=true additionalProperties=true; LangChain4j has $schema=false additionalProperties=false
  tool result message: Spring AI sent "\"Order ORD-1001: Status=SHIPPED, ETA=2026-10-14\"", LangChain4j sent "Order ORD-1001: Status=SHIPPED, ETA=2026-10-14"
None of these is a bug, and for a standard OpenAI-compatible server none should matter. They matter when you point either framework at a server that is strict about the shape of a request: the JSON-quoted tool result in Spring AI’s second request is the sort of thing a custom gateway might choke on. The full bodies are in 02-same-assistant.txt. The test also had to work around one thing. With both LangChain4j’s JDK HTTP adapter and the Spring-based adapter on the classpath, building a LangChain4j model by hand without naming an HTTP client throws a “multiple HTTP clients” conflict, so Wiring.lc4jModel passes JdkHttpClient.builder() explicitly. That is the sort of cost of having both that does not show in a comparison table.

What happens when you put both starters in one project

The 2025 article warned about a startup bean conflict and described it as a NoUniqueBeanDefinitionException. In the setup I tested, both starters’ OpenAI auto-configurations register a bean with the same name, openAiChatModel, and the failure is a different exception that happens earlier. From 01-both-starters.txt (BothStartersTest.java):
1. Both starters, nothing else:
   FAILED: BeanDefinitionOverrideException: bean 'openAiChatModel' is defined twice, by org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration and by dev.langchain4j.openai.spring.OpenAiAutoConfiguration
So I could not reproduce the exception name the original gave; what I measured is BeanDefinitionOverrideException. The fix the original proposed, excluding one auto-configuration, does work (01-both-starters.txt, second case):
2. spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration:
   context started
   bean chatClientBuilder -> org.springframework.ai.chat.client.ChatClient$Builder
   bean openAiChatModel -> org.springframework.ai.openai.OpenAiChatModel
   beans of type dev.langchain4j.model.chat.ChatModel: 0
   beans of type org.springframework.ai.chat.model.ChatModel: 1
A tempting shortcut is worse than the error. Setting spring.main.allow-bean-definition-overriding=true makes the error go away, and the application starts, but one of the two beans silently replaces the other:
3. spring.main.allow-bean-definition-overriding=true:
   context started
   bean openAiChatModel -> org.springframework.ai.openai.OpenAiChatModel
   bean chatClientBuilder -> org.springframework.ai.chat.client.ChatClient$Builder
   beans of type dev.langchain4j.model.chat.ChatModel: 0
   beans of type org.springframework.ai.chat.model.ChatModel: 1
The context started, and there are zero LangChain4j chat models in it. Anything that injects one fails later, or never gets wired. I would take the loud startup failure over this, so exclude the auto-configuration (spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration) and build the LangChain4j model by hand in a @Configuration class, as Wiring.java does.
Going deeper: the 2025 dependencies, and what they became
The original pom.xml and application.yml are below, unchanged. They were already a snapshot of a moving target: the BOM was 1.0.0-M1 while the prose said 1.0.0-M6. I looked up each artifact on Maven Central (compile-old-snippets.sh does it and writes 06-old-code-vs-current.txt):
org/springframework/ai/spring-ai-openai-spring-boot-starter: latest release none, latest of any kind 1.0.0-M6
org/springframework/ai/spring-ai-pgvector-store-spring-boot-starter: latest release none, latest of any kind 1.0.0-M6
org/springframework/ai/spring-ai-starter-model-openai: latest release 2.0.1, latest of any kind 2.1.0-M1
org/springframework/ai/spring-ai-starter-vector-store-pgvector: latest release 2.0.1, latest of any kind 2.1.0-M1
dev/langchain4j/langchain4j-spring-boot-starter: latest release 0.36.2, latest of any kind 1.22.0-beta32
dev/langchain4j/langchain4j-open-ai: latest release 1.22.0, latest of any kind 1.22.0
dev/langchain4j/langchain4j-spring-boot4-starter: latest release none, latest of any kind 1.22.0-beta32
Two of the original Spring AI artifacts have no release at all, only milestones that stop at 1.0.0-M6. They were renamed, not retired: spring-ai-starter-model-openai and spring-ai-starter-vector-store-pgvector, both 2.0.1. On the LangChain4j side, langchain4j-spring-boot-starter stops at 0.36.2 as a release; for Spring Boot 4 there is a separate langchain4j-spring-boot4-starter family, and at the time of writing those artifacts are 1.22.0-beta32 only. The original files, for the record (the lookup that judges them is 06-old-code-vs-current.txt). First the Maven dependencies:
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0-M1</version> <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j</artifactId>
        <version>0.33.0</version> </dependency>

    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-open-ai</artifactId>
        <version>0.33.0</version>
    </dependency>

    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-spring-boot-starter</artifactId>
        <version>0.33.0</version>
    </dependency>
</dependencies>
And the configuration:
# application.yml
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.2
          max-tokens: 1024
      embedding:
        options:
          model: text-embedding-3-small
  datasource:
    url: jdbc:postgresql://localhost:5432/aidb
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: org.postgresql.Driver
These are the 2025 files. Each is not a working setup for Spring Boot 4: the old starter artifacts do not resolve to a release, and the keys in the YAML belong to the versions of that time. The working equivalents are in the module’s pom.xml.
The three problems from the 2025 article, each with a verdict
The original opened with a section called “What Broke When I First Used Both Frameworks Together”. Its three items are quoted in full below, each followed by what the tests above say.

Original, 1. Bean conflict at startup. Spring AI’s auto-configuration registers an OpenAiChatModel bean. LangChain4j’s Spring Boot starter also registers one. The result is a NoUniqueBeanDefinitionException on startup — two beans of the same type, neither qualified. Fix: exclude LangChain4j’s auto-configuration for the chat model and wire it manually in a @Configuration class, which is what AgentConfig.java in this post does.

Verdict: the cause and the fix are right, the exception name is not. I measured BeanDefinitionOverrideException for the bean name openAiChatModel (01-both-starters.txt).

Original, 2. The agent loop ran indefinitely on tool errors. When a @Tool method threw an unchecked exception, LangChain4j’s default agent resubmitted the same tool call on the next iteration rather than propagating the error. The agent burned through its maxSteps limit before failing. Fix: catch exceptions inside @Tool methods and return a structured error string — “ERROR: Order not found: ORD-999” — so the LLM can reason about the failure and decide what to do next.

Verdict: not reproduced as described. With a sensible model both frameworks pass the exception message to the model and stop (2 requests each). A model that insists on calling the tool again gets 30 executions in both frameworks, so the loop comes from the model. I did not find a setting called maxSteps; the cap I used in LangChain4j is maxSequentialToolsInvocations (03-tool-throws.txt).

Original, 3. Semantic cache threshold needed tuning. I initially set CACHE_HIT_THRESHOLD = 0.85. That was too low — semantically similar but subtly different questions (“What is the status of order 123?” vs “Has order 123 shipped yet?”) were hitting the cache and returning the wrong cached answer. I raised it to 0.92, which reduced false hits to near zero at the cost of a lower cache-hit rate. For your use case, run 200 representative queries and tune this threshold empirically before going to production.

Verdict: the advice to tune on your own labelled queries is right and is exactly what the semantic-caching article does with 30 intents and a threshold sweep. The specific numbers 0.85 and 0.92 I cannot reproduce, and in Spring AI’s Redis store the score is (1 + cosine) / 2, so they do not mean what a cosine of that size would.

When a tool fails, and when the model will not stop calling it

The 2025 article said LangChain4j’s agent “resubmitted the same tool call” when a tool threw, burned through its step limit, and that the cure was to catch the exception and return an error string. I tested three situations (ToolFailureTest.java) with a tool that throws IllegalStateException: order database unavailable. A well-behaved model. It asks for the tool once and then answers from whatever comes back. From 03-tool-throws.txt:
Case 1: a well-behaved model. It asks for the tool once, then answers from whatever the tool message says.
  Spring AI    : 2 requests, tool body ran 1 time(s). Tool message the model received: "order database unavailable"
                 answer: Done: order database unavailable
  LangChain4j  : 2 requests, tool body ran 1 time(s). Tool message the model received: "order database unavailable"
                 answer: Done: order database unavailable
Both frameworks catch the exception and send its message to the model as the tool result, with no loop. I could not reproduce “resubmitted the same call” as framework behaviour; and the “return an error string yourself” advice is not needed for this case, though a message you wrote is still better than an exception’s text for the model to read. An insistent model. This one asks for the tool again after every result, as a confused real model sometimes does. The server cuts it off with an HTTP 500 after 30 requests so the test ends:
Case 2: an insistent model. It asks for the tool again after every result. The server stops it with an HTTP 500 after 30 requests.
  Spring AI    : 31 requests, tool body ran 30 time(s)
                 exception InternalServerException: 500: cutoff reached
  LangChain4j  : 31 requests, tool body ran 30 time(s)
                 exception InternalServerException (root: HttpException): {"error":{"message":"cutoff reached"}}
Same in both (03-tool-throws.txt, case 2): 30 tool executions, nothing in either framework stopped it by default within 30 rounds. So the loop in the original is real, but it comes from the model and not from one framework. What differs is how you cap it. Capping the loop. LangChain4j has a setting for it, maxSequentialToolsInvocations. In Spring AI 2.0.1 the tool-calling advisor has no max-iterations setting that I found; what it has is a ToolExecutionEligibilityChecker hook, asked once per model reply whether the tool calls in it should run. MaxToolRounds.java is a counter in that hook:
    public Boolean apply(ChatResponse response) {
        return response != null && response.hasToolCalls() && rounds.incrementAndGet() <= limit;
    }
With a cap of 5 on the same insistent model (03-tool-throws.txt, case 3):
Case 3: the same insistent model, each framework asked to stop after 5 tool rounds.
  Spring AI    (ToolCallingAdvisor + our MaxToolRounds(5) checker): 6 requests, tool body ran 5 time(s)
                 answer: 
  LangChain4j  (maxSequentialToolsInvocations(5))                  : 6 requests, tool body ran 5 time(s)
                 exception RuntimeException: Something is wrong, exceeded 5 tool calling round trips (maxToolCallingRoundTrips)
Both stop after 6 requests and 5 tool runs. The difference is what the caller sees. LangChain4j throws, so you find out. With my checker, Spring AI returns an empty answer and carries on, so a bug in the loop looks like a model that said nothing. If you use that hook, make the checker throw or log when it trips.
Going deeper: why the checker uses hasToolCalls()
My first version of the checker called the framework’s own isToolCallResponse helper inside the hook and overflowed the stack, because the helper consults the same checker. Using response.hasToolCalls() avoids that. It is a small trap, and exactly the kind that is easy to hit with a hook that is called from inside the code that it configures.

Observability: what each framework records

The original said Spring AI “ships Micrometer integration out of the box” and LangChain4j needs a custom listener. That held up. For the one-question, one-tool exchange (ObservabilityTest.java) Spring AI recorded these, from 04-observability.txt:
  gen_ai.client.operation [TIMER]  error=none, gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai
  gen_ai.client.operation.active [LONG_TASK_TIMER]  gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=none, gen_ai.system=openai
  gen_ai.client.token.usage [COUNTER]  gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai, gen_ai.token.type=input
  gen_ai.client.token.usage [COUNTER]  gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai, gen_ai.token.type=output
  gen_ai.client.token.usage [COUNTER]  gen_ai.operation.name=chat, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.system=openai, gen_ai.token.type=total
  okhttp.requests [TIMER]  error=none, outcome=SUCCESS
  okhttp.requests.active [LONG_TASK_TIMER]  outcome=UNKNOWN
  spring.ai.advisor [TIMER]  error=none, gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=advisor
  spring.ai.advisor.active [LONG_TASK_TIMER]  gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=advisor
  spring.ai.chat.client [TIMER]  error=none, gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=chat_client
  spring.ai.chat.client.active [LONG_TASK_TIMER]  gen_ai.operation.name=framework, gen_ai.system=spring_ai, spring.ai.kind=chat_client
  spring.ai.tool [TIMER]  error=none, gen_ai.operation.name=execute_tool, gen_ai.system=spring_ai, spring.ai.kind=tool_call
Note the header of that output: the token-usage counters appeared only after the observation handlers that Spring Boot registers for you were registered by hand. In a real application you get them from Boot; in my hand-wired test I had to add them, along with using the four-argument ChatClient.builder. LangChain4j with no listener recorded nothing (04-observability.txt):
LangChain4j 1.22.0, no listener registered: 0 meters.
There are two ways to change that. The original article’s TracingChatInterceptor still compiles and runs against LangChain4j 1.22.0 (the module’s copy only drops Lombok), and there is now a shipped listener, MicrometerMetricsChatModelListener in langchain4j-micrometer-metrics, which produces the same gen_ai.client.token.usage name that Spring AI uses. From 04-observability.txt again:
LangChain4j 1.22.0 with TracingChatInterceptor, the listener from the original article (it still compiles and runs):
  llm.request.latency [TIMER]  model=gpt-6.1-sol
  llm.tokens.input [COUNTER]  model=gpt-6.1-sol
  llm.tokens.output [COUNTER]  model=gpt-6.1-sol
LangChain4j 1.22.0 with the shipped MicrometerMetricsChatModelListener (langchain4j-micrometer-metrics 1.22.0-beta32):
  gen_ai.client.token.usage [DISTRIBUTION_SUMMARY]  gen_ai.operation.name=chat, gen_ai.provider.name=openai, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.token.type=input
  gen_ai.client.token.usage [DISTRIBUTION_SUMMARY]  gen_ai.operation.name=chat, gen_ai.provider.name=openai, gen_ai.request.model=gpt-6.1-sol, gen_ai.response.model=gpt-6.1-sol, gen_ai.token.type=output
Going deeper: the original observability code, kept, and what changed
This is the 2025 code (the file has a MeterRegistryCustomizer configuration and the custom listener). The compile check (06-old-code-vs-current.txt) found that the MeterRegistryCustomizer import no longer exists in Spring Boot 4 (the package org.springframework.boot.actuate.autoconfigure.metrics is gone), while the listener part is the one I ran above in TracingChatInterceptor.java, minus Lombok.
// ObservabilityConfig.java
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ObservabilityConfig {
    // Custom meter to track LLM token usage and cost
    @Bean
    public MeterRegistryCustomizer<MeterRegistry> aiMetrics() {
        return registry -> {
            // Adding common tags for all metrics in this microservice
            registry.config().commonTags("app", "ai-microservice");
        };
    }
}

// TracingChatInterceptor.java - custom listener for LangChain4j
import dev.langchain4j.model.chat.listener.*;
import dev.langchain4j.model.output.TokenUsage;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Slf4j
@Component
@RequiredArgsConstructor
public class TracingChatInterceptor implements ChatModelListener {
    private final MeterRegistry meterRegistry;
    
    // Key used to correlate request and response in the context attributes
    private static final String START_TIME_KEY = "start_time";

    @Override
    public void onRequest(ChatModelRequestContext ctx) {
        // Store start time in context attributes (shared between request/response)
        ctx.attributes().put(START_TIME_KEY, System.currentTimeMillis());
        
        log.debug("LLM Request | model={} | messages={}",
            ctx.chatRequest().parameters().modelName(),
            ctx.chatRequest().messages().size());
    }

    @Override
    public void onResponse(ChatModelResponseContext ctx) {
        Object startTimeObj = ctx.attributes().get(START_TIME_KEY);
        if (!(startTimeObj instanceof Long startTime)) return;

        long latencyMs = System.currentTimeMillis() - startTime;
        TokenUsage usage = ctx.chatResponse().metadata().tokenUsage();

        // Record metrics
        String modelName = ctx.chatRequest().parameters().modelName();
        
        meterRegistry.timer("llm.request.latency", "model", modelName)
            .record(latencyMs, TimeUnit.MILLISECONDS);
        
        if (usage != null) {
            meterRegistry.counter("llm.tokens.input", "model", modelName)
                .increment(usage.inputTokenCount());
            meterRegistry.counter("llm.tokens.output", "model", modelName)
                .increment(usage.outputTokenCount());
        }

        log.info("LLM Response | model={} | latency={}ms | inputTokens={} | outputTokens={}",
            modelName, latencyMs,
            usage != null ? usage.inputTokenCount() : 0,
            usage != null ? usage.outputTokenCount() : 0);
    }

    @Override
    public void onError(ChatModelErrorContext ctx) {
        log.error("LLM Error | model={} | error={}",
            ctx.chatRequest().parameters().modelName(),
            ctx.error().getMessage());
        meterRegistry.counter("llm.request.errors",
            "model", ctx.chatRequest().parameters().modelName()).increment();
    }
}
How the original explained it:
  • ChatModelListener is LangChain4j’s interceptor interface — register it on the model builder with .listeners(List.of(tracingInterceptor)).
  • ConcurrentHashMap startTimes correlates request start time with response for accurate latency measurement in a multi-threaded environment.
  • Cost estimation is done per request using current published pricing — wire this to a time-series database (InfluxDB, Prometheus) to build monthly cost dashboards.
  • Spring AI’s Micrometer integration auto-publishes spring.ai.chat.client.* metrics; combine both for full coverage.

Its claim that Spring AI “auto-publishes spring.ai.chat.client.* metrics” is accurate: spring.ai.chat.client is in the output above, together with spring.ai.advisor and spring.ai.tool. Its cost estimate (“using current published pricing”) is not something I verified, and prices change; treat any figure in that code as a placeholder.

Share tools over MCP instead of choosing a framework

Order toolone @McpTool methodMCP serverStreamable HTTPSpring AI clientor LangChain4j clientModelasks, then answers
There is a way to make the either/or question smaller: put the tool behind the Model Context Protocol (MCP), a standard way for a model client to discover and call tools over HTTP, and let either framework consume it. The test (McpFirstTest.java) serves the one order tool from a Spring AI MCP server over Streamable HTTP and calls it from a Spring AI MCP client and from a LangChain4j one. From 05-mcp-first.txt:
Spring AI answer   : Done: [{"text":"Order ORD-1002: Status=PROCESSING, ETA=2026-10-18"}]   (2 requests to the model)
  tool offered to the model:   tool getOrderStatus - Get current order status by order ID params {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"]}
LangChain4j answer : Done: Order ORD-1002: Status=PROCESSING, ETA=2026-10-18   (2 requests to the model)
  tool offered to the model:   tool getOrderStatus - Get current order status by order ID params {"type":"object","properties":{"orderId":{"type":"string","description":"The unique identifier of the order"}},"required":["orderId"]}

Times the one tool method on the server ran: 1 for Spring AI, then 1 more for LangChain4j.
Both frameworks found the tool, called it once and answered. A visible difference: Spring AI’s tool result reaches the model as [{"text":"..."}], LangChain4j’s as the plain text. That is the kind of thing to remember before you write a prompt that quotes tool output. The practical point is that tools written once as MCP tools do not tie you to either framework, which lowers the cost of picking one and changing your mind. (The server needs spring.ai.mcp.server.protocol=streamable for this transport.)

Does the 2025 code still compile?

I took each distinctive API line from the original article, one per file in src/broken/, and compiled them against Spring AI 2.0.1 and LangChain4j 1.22.0. From 06-old-code-vs-current.txt:
OldCallAroundAdvisor: DOES NOT COMPILE
    OldCallAroundAdvisor.java:1: error: cannot find symbol
      symbol:   class AdvisedRequest
      location: package org.springframework.ai.chat.client.advisor.api
    OldCallAroundAdvisor.java:2: error: cannot find symbol
      symbol:   class AdvisedResponse
      location: package org.springframework.ai.chat.client.advisor.api
OldChatService: compiles
OldDocumentGetContent: DOES NOT COMPILE
    OldDocumentGetContent.java:6: error: cannot find symbol
      symbol:   method getContent()
      location: variable d of type Document
OldLangChain4jBuilder: DOES NOT COMPILE
    OldLangChain4jBuilder.java:9: error: cannot find symbol
      symbol:   method chatLanguageModel(OpenAiChatModel)
      location: class AiServices<Agent>
OldMessageGetContent: DOES NOT COMPILE
    OldMessageGetContent.java:6: error: cannot find symbol
      symbol:   method getContent()
      location: variable m of type Message
OldMeterRegistryCustomizer: DOES NOT COMPILE
    OldMeterRegistryCustomizer.java:1: error: package org.springframework.boot.actuate.autoconfigure.metrics does not exist
    OldMeterRegistryCustomizer.java:5: error: cannot find symbol
      symbol:   class MeterRegistryCustomizer
      location: class OldMeterRegistryCustomizer
OldPdfConfig: compiles
OldSearchRequest: DOES NOT COMPILE
    OldSearchRequest.java:6: error: cannot find symbol
      symbol:   method query(String)
      location: class SearchRequest
OldTokenTextSplitter: DOES NOT COMPILE
    OldTokenTextSplitter.java:5: error: no suitable constructor found for TokenTextSplitter(int,int,int,int,boolean)
Two of nine compile. The rest are renames and removals: Document.getContent() and Message.getContent() are now getText(); the old around-advisor types (AdvisedRequest, AdvisedResponse) are gone in favour of CallAdvisor and ChatClientRequest; SearchRequest.query(String) and the five-argument TokenTextSplitter constructor are gone; and LangChain4j’s chatLanguageModel(...) is now chatModel(...). A search of the jars for the class names the original used found RequestResponseAdvisor and TokenBudgetAdvisor in none of them (the second is named in the original’s pitfalls table, below). If you are carrying 1.x code forward, the Spring AI 1.x to 2.0 migration guide covers the Spring AI side.

The original topics: chat, retrieval, agent and cost

The original had five code sections. Each is kept below in full, with a verdict. I want to be clear about the level of evidence: for most of them the verdict is what the compiler says (06-old-code-vs-current.txt), not that I ran the feature. I ran the tool loop, bean wiring, metrics and MCP above. I did not run retrieval, streaming or the agent’s chat memory in this rewrite.
Chat client with streaming and structured output (original code)
The original ChatService uses ChatClient.Builder, defaultSystem, a synchronous call, stream().content() and .entity(...). Verdict: the compile check passes (OldChatService in 06-old-code-vs-current.txt). I did not run streaming or the structured-output conversion against a model.
// ChatService.java - Spring AI ChatClient wrapper
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.List;

@Service
public class ChatService {
    private final ChatClient chatClient;

    // Constructor injection with builder pattern
    public ChatService(ChatClient.Builder builder) {
        this.chatClient = builder
            .defaultSystem("You are a senior Java architect. Be precise and concise.")
            .build();
    }

    // Synchronous call (for simple use cases)
    public String ask(String userMessage) {
        return chatClient.prompt()
            .user(userMessage)
            .call()
            .content();
    }

    // Streaming response (for real-time UIs)
    public Flux<String> stream(String userMessage) {
        return chatClient.prompt()
            .user(userMessage)
            .stream()
            .content();
    }

    // Structured output - map response to a Java record
    public ArchitectureReview reviewCode(String codeSnippet) {
        return chatClient.prompt()
            .user(u -> u.text("Review this Java code for production readiness: {code}")
                        .param("code", codeSnippet))
            .call()
            .entity(ArchitectureReview.class); // Spring AI converts JSON -> POJO
    }
}

// ArchitectureReview.java - structured output record
// Ensure Jackson/Gson is on classpath for automatic deserialization
public record ArchitectureReview(
    String verdict,           // e.g., "APPROVED", "NEEDS_CHANGES"
    List<String> issues,      // List of specific problems found
    List<String> suggestions, // Actionable fixes
    int score                 // 1-10 production readiness score
) {}
How the original explained it:
  • ChatClient.Builder is auto-configured by Spring AI’s starter — you inject the builder, not the client directly, allowing per-instance configuration.
  • defaultSystem() sets a persistent system prompt for all calls through this client instance.
  • stream().content() returns a Flux<String> (Project Reactor) that emits tokens as they arrive — wire this to SSE for a streaming chat UI.
  • .entity(ArchitectureReview.class) uses Spring AI’s built-in JSON-to-POJO conversion — the model is prompted to return JSON matching your record schema.
RAG: ingesting PDFs into a vector store (original code)
The original DocumentIngestionService reads a PDF, splits it with TokenTextSplitter, adds the chunks to a PGVector store, and searches with a similarity threshold. Verdict: part of it no longer compiles. The PDF reader configuration (OldPdfConfig) compiles; the five-argument TokenTextSplitter constructor and SearchRequest.query(String) do not, and anything calling getContent() on a document needs getText(). I did not run retrieval, so the claims about thresholds below are the original’s, not verified here. The compile results are in 06-old-code-vs-current.txt.
// DocumentIngestionService.java
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentReader;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

@Slf4j
@Service
public class DocumentIngestionService {
    private final VectorStore vectorStore;          // Spring AI PGVector store
    private final TokenTextSplitter textSplitter;   // Chunk documents by token count

    public DocumentIngestionService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
        // Split documents into 512-token chunks with 50-token overlap
        this.textSplitter = new TokenTextSplitter(512, 50, 5, 10000, true);
    }

    // Ingest a PDF or text document into the vector store
    public void ingestDocument(Resource resource, Map<String, Object> metadata) {
        // 1. Load the document
        DocumentReader reader = new PagePdfDocumentReader(resource,
            PdfDocumentReaderConfig.builder()
                .withPageTopMargin(0)
                .build());

        List<Document> pages = reader.get();

        // 2. Split into chunks
        List<Document> chunks = textSplitter.apply(pages);

        // 3. Attach metadata (source, date, category)
        chunks.forEach(doc -> doc.getMetadata().putAll(metadata));

        // 4. Embed and store (Spring AI calls embedding API automatically)
        vectorStore.add(chunks);

        log.info("Ingested {} chunks from {}", chunks.size(), resource.getFilename());
    }
}

// RAGService.java - retrieval + generation
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class RAGService {
    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public RAGService(ChatClient.Builder builder, VectorStore vectorStore) {
        this.chatClient = builder.build();
        this.vectorStore = vectorStore;
    }

    // Retrieve top-K relevant chunks and inject into prompt
    public String queryWithContext(String question, int topK) {
        // 1. Semantic search against vector store
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.query(question)
                .withTopK(topK)
                .withSimilarityThreshold(0.75)  // discard low-relevance results
        );

        // 2. Build context string from retrieved chunks
        String context = relevantDocs.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("nn---nn")); // clear separator between chunks

        // 3. Prompt with injected context (RAG pattern)
        return chatClient.prompt()
            .system(s -> s.text("""
                You are a knowledgeable assistant.
                Answer questions using ONLY the provided context.
                If the context does not contain enough information, say so clearly.
                Do NOT invent or assume facts not present in the context.

                Context:
                {context}
                """)
                .param("context", context))
            .user(question)
            .call()
            .content();
    }
}
How the original explained it:
  • TokenTextSplitter(512, 50, …) — 512 tokens per chunk, 50-token overlap ensures semantic continuity across chunk boundaries.
  • vectorStore.add(chunks) — Spring AI’s PGVector store calls the embedding API (text-embedding-3-small) for each chunk and stores vectors in PostgreSQL.
  • withSimilarityThreshold(0.75) — cosine similarity threshold; chunks below 0.75 similarity are discarded, preventing noise injection into the prompt.
  • Collectors.joining("\n\n---\n\n") — a clear separator between chunks helps the LLM distinguish individual source passages.

One correction I can make from a different measurement: the semantic-caching article found that in Spring AI’s Redis store the score is (1 + cosine) / 2, not the cosine. If your store does the same, a threshold of 0.75 means a cosine near 0.5. Check what your own store returns before trusting the word “cosine” in a threshold. For choosing a store, see Choosing a Vector Store for Spring AI.

The LangChain4j agent and its tools (original code)
The original tool class and agent wiring. Verdict: tools still work, one call is renamed. The annotation pair @Tool and @P is what Lc4jTools.java uses and ran in every test above; chatLanguageModel(...) in the wiring no longer exists and is chatModel(...) (see 06-old-code-vs-current.txt). The original also used Lombok’s @RequiredArgsConstructor; the module uses a plain constructor.
// OrderManagementTools.java - tools exposed to the AI agent
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.agent.tool.P;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderManagementTools {
    private final OrderRepository orderRepo;
    private final InventoryService inventoryService;

    // Each @Tool method becomes a function the LLM can call
    @Tool("Get current order status by order ID")
    public String getOrderStatus(@P("The unique identifier of the order") String orderId) {
        return orderRepo.findById(orderId)
            .map(o -> "Order %s: Status=%s, ETA=%s".formatted(
                orderId, o.getStatus(), o.getEstimatedDelivery()))
            .orElse("Order not found: " + orderId);
    }

    @Tool("Check inventory availability for a product SKU")
    public int checkInventory(@P("The product SKU to check") String sku) {
        return inventoryService.getAvailableStock(sku);
    }

    @Tool("Cancel an order β€” only call after explicit user confirmation")
    public String cancelOrder(
            @P("The order ID to cancel") String orderId, 
            @P("The reason for cancellation provided by the user") String reason) {
        orderRepo.updateStatus(orderId, OrderStatus.CANCELLED, reason);
        return "Order " + orderId + " cancelled. Reason: " + reason;
    }
}

// AgentConfig.java - wiring the agent
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AgentConfig {

    @Bean
    public CustomerSupportAgent customerSupportAgent(OrderManagementTools tools) {
        return AiServices.builder(CustomerSupportAgent.class)
            .chatLanguageModel(
                OpenAiChatModel.builder()
                    .apiKey(System.getenv("OPENAI_API_KEY"))
                    .modelName("gpt-4o-mini")
                    .temperature(0.1)   // low temp for high tool-calling accuracy
                    .build()
            )
            .tools(tools)               // register tool class
            .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
            .build();
    }
}

// CustomerSupportAgent.java - the interface (LangChain4j generates the proxy)
import dev.langchain4j.service.SystemMessage;

public interface CustomerSupportAgent {

    @SystemMessage("""
        You are a helpful customer support agent.
        Help customers with order status, inventory checks, and order cancellations.
        Always ask for explicit confirmation before executing cancellations or other irreversible actions.
        Be concise, professional, and empathetic.
        """)
    String chat(String userMessage);
}
How the original explained it:
  • @Tool annotation on plain methods — LangChain4j reflects over them, generates JSON schema for the LLM, and dispatches calls automatically when the model decides to invoke a tool.
  • AiServices.builder(CustomerSupportAgent.class) — generates a dynamic proxy at startup; calling agent.chat() runs the full agent loop (LLM → tool call → LLM → final response).
  • MessageWindowChatMemory(20) — maintains a sliding window of 20 messages for conversation context without unbounded token growth.
  • temperature(0.1) — tool-calling scenarios need deterministic behavior; low temperature reduces hallucinated tool arguments.

For what LangChain4j’s declarative interface style looks like on Spring Boot 4, and for its newer agentic module (sequential, parallel, loop and supervisor agents), see LangChain4j AI Services with Spring Boot 4 and Agentic Workflows with LangChain4j. Neither is verified against this article’s fake server.

Semantic cache for cost (original code and claims)
The original SemanticCacheService stored answers in a second vector store and used a threshold of 0.92. Verdict: I could not verify two of its claims, and its advisor type no longer exists. RequestResponseAdvisor is not in the Spring AI 2.0.1 jars (06-old-code-vs-current.txt); the 2.0 shape is a CallAdvisor. The claim that “semantic caching can reduce LLM costs by 40-60%” has no measurement behind it in this article. The 0.85 and 0.92 numbers in the original’s “what broke” list are also not reproduced here.
// SemanticCacheService.java - avoid duplicate LLM calls for similar questions
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.List;
import java.util.Map;

@Slf4j
@Service
public class SemanticCacheService {
    private final EmbeddingModel embeddingModel;      // Spring AI
    private final VectorStore cacheStore;              // separate cache vector DB
    private final ChatClient chatClient;

    private static final double CACHE_HIT_THRESHOLD = 0.92; // very high similarity

    public SemanticCacheService(EmbeddingModel embeddingModel, VectorStore cacheStore, ChatClient.Builder chatClientBuilder) {
        this.embeddingModel = embeddingModel;
        this.cacheStore = cacheStore;
        this.chatClient = chatClientBuilder.build();
    }

    public String queryWithCache(String userQuestion) {
        // 1. Check cache for semantically similar past questions
        // (Spring AI similaritySearch handles the embedding generation internally if configured)
        List<Document> hits = cacheStore.similaritySearch(
            SearchRequest.query(userQuestion)
                .withTopK(1)
                .withSimilarityThreshold(CACHE_HIT_THRESHOLD)
        );

        if (!hits.isEmpty()) {
            // Cache hit β€” return stored answer without LLM call
            log.info("Semantic cache HIT for: {}", userQuestion);
            return hits.get(0).getMetadata().get("cached_answer").toString();
        }

        // 2. Cache miss β€” call LLM
        String answer = chatClient.prompt()
            .user(userQuestion)
            .call()
            .content();

        // 3. Store question + answer in cache
        Document cacheEntry = new Document(userQuestion,
            Map.of("cached_answer", answer,
                   "cached_at", Instant.now().toString()));
        cacheStore.add(List.of(cacheEntry));

        return answer;
    }
}

// TokenBudgetAdvisor.java - Spring AI Advisor to enforce token limits
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@Slf4j
@Component
public class TokenBudgetAdvisor implements CallAroundAdvisor {
    private static final int MAX_CONTEXT_TOKENS = 3000;

    @Override
    public AdvisedResponse advise(AdvisedRequest request, CallAroundAdvisorChain chain) {
        // Prune the request before passing it down the chain
        AdvisedRequest processedRequest = before(request);
        return chain.next(processedRequest);
    }

    private AdvisedRequest before(AdvisedRequest request) {
        List<Message> messages = new ArrayList<>(request.messages());
        int estimatedTokens = estimateTokens(messages);

        if (estimatedTokens > MAX_CONTEXT_TOKENS) {
            // Logic to keep system message (usually at index 0) and last few exchanges
            messages = pruneHistory(messages);
            log.warn("Context pruned from ~{} to ~{} tokens", 
                estimatedTokens, estimateTokens(messages));
        }

        return AdvisedRequest.from(request)
                .withMessages(messages)
                .build();
    }

    private List<Message> pruneHistory(List<Message> messages) {
        if (messages.size() <= 1) return messages;
        // Simple strategy: Keep the first message (System) and the last 4
        List<Message> pruned = new ArrayList<>();
        pruned.add(messages.get(0)); 
        int start = Math.max(1, messages.size() - 4);
        pruned.addAll(messages.subList(start, messages.size()));
        return pruned;
    }

    private int estimateTokens(List<Message> messages) {
        // Rule of thumb: 1 token β‰ˆ 4 characters
        return messages.stream()
            .filter(m -> m.getContent() != null)
            .mapToInt(m -> m.getContent().length() / 4)
            .sum();
    }
    
    @Override
    public String getName() {
        return "TokenBudgetAdvisor";
    }

    @Override
    public int getOrder() {
        return 0;
    }
}
How the original explained it:
  • CACHE_HIT_THRESHOLD = 0.92 — very high similarity means only near-identical questions get a cache hit, avoiding wrong answers from loosely related queries.
  • RequestResponseAdvisor is Spring AI’s interceptor chain for the ChatClient — it can mutate the request before the LLM call and the response after.
  • estimateTokens() uses the 4-chars-per-token rule as a fast approximation; for precision, use a proper tokenizer like jtokkit (Java implementation of tiktoken).
  • Semantic caching can reduce LLM costs by 40-60% in FAQ-style applications where many users ask similar questions.

The measured version is Semantic Caching for LLM Calls in Spring Boot with Redis Vector Search: a real CallAdvisor, a labelled question set, a threshold sweep that shows hit rate against wrong answers, and a replay with an illustrative cost model. Its main finding is that a plain exact-match cache on the normalised question caught most repeated questions on that data, and that a loose threshold returns wrong answers without any error. Use that article for numbers, and treat 40-60% as unverified.

The production architecture diagram (original, unverified)
This is the 2025 diagram, kept as it was. It is a design sketch, not something I ran or measured in this rewrite; the pieces it shows are the ones above (a chat layer, tools, retrieval, a cache, metrics). For a gateway-shaped version of the same idea, see The LLM Gateway Pattern for Java Microservices, whose code is also in asmhatre/spring-ai.
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     Client / API Gateway                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚ REST / WebSocket / SSE
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚      AI Microservice (Spring Boot)  β”‚
          β”‚                                    β”‚
          β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
          β”‚  β”‚  Spring AI ChatClient       β”‚   β”‚
          β”‚  β”‚  + SemanticCacheAdvisor     β”‚   β”‚
          β”‚  β”‚  + TokenBudgetAdvisor       β”‚   β”‚
          β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
          β”‚               β”‚ cache miss only     β”‚
          β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
          β”‚  β”‚  LangChain4j Agent Loop     β”‚   β”‚
          β”‚  β”‚  - Tool Registry            β”‚   β”‚
          β”‚  β”‚  - Memory (sliding window)  β”‚   β”‚
          β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                 β”‚                     β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”
β”‚  OpenAI /  β”‚  β”‚  PGVector      β”‚  β”‚  Micrometer   β”‚
β”‚  Azure OAI β”‚  β”‚  VectorStore   β”‚  β”‚  + Prometheus β”‚
β”‚  (LLM API) β”‚  β”‚  (RAG + Cache) β”‚  β”‚  + Grafana    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Decision matrix

The 2025 case for using both (original text)
The original argued: Why Spring AI and LangChain4j Together? Spring AI (from the Spring team) provides opinionated, auto-configured abstractions over popular LLM providers (OpenAI, Azure OpenAI, Ollama, Mistral). LangChain4j is a lower-level, Java-native framework inspired by Python’s LangChain, giving you fine-grained control over chains, agents, memory, and tool use. Used together, you get Spring Boot’s dependency injection and production ecosystem plus LangChain4j’s composable AI primitives.

It also said the system it described ran Spring AI 1.0.0-M6 with LangChain4j 0.33.0 on Spring Boot 3.4.1, Java 21.0.3, PostgreSQL 16 and PGVector 0.7.0, with Spring AI handling the ChatClient, RAG pipeline and Micrometer observability and LangChain4j the agent loop and tool registry, and that “the division is deliberate, not accidental”. And it advised: if you are adding a chat endpoint to an existing Spring Boot service, go to the ChatClient section; if you are building a document Q&A feature, go straight to the RAG pipeline; if you need a fully autonomous agent that calls your existing REST APIs, skip to the LangChain4j agent section.

Verdict: the descriptions of the two libraries are fair. “Lower-level” and “fine-grained control” are not something the tests show: for the same assistant LangChain4j needed an interface and a builder, Spring AI a builder and an object. The versions named are the 2025 ones; this rewrite uses Spring Boot 4.1.1, Spring AI 2.0.1, LangChain4j 1.22.0 and Java 25, and no database.

The 2025 article had a feature table with six rows. Here is each row again, with what I found, followed by a table of choices. Evidence is in the output files named in the right-hand column; “not tested” means exactly that.
2025 row2025 claim2026 findingEvidence
Auto-configurationSpring AI yes, LangChain4j manualBoth have Spring Boot 4 starters now (LangChain4j’s are beta). With both present they collide on openAiChatModel.01
StreamingBothNot tested.—
RAG pipelineVectorStore plus embedding model, against a retrieverNot tested here; several of the original’s retrieval calls no longer compile.06
Tool/function callingSpring AI “limited”, LangChain4j full agentSame request count and tool runs for the same assistant. LangChain4j has a built-in round cap that throws; Spring AI needs a hook you write.02, 03
ObservabilitySpring AI Micrometer, LangChain4j custom listenerStill true for Spring AI. LangChain4j now ships a Micrometer listener (beta artifact) with the same token metric name.04
Best forSpring AI: quick start; LangChain4j: complex agentsNot something one workload can show. See the choices below.—
If you…Lean towardsBecause (measured)
Build chat, retrieval and advisors in a Spring Boot app and want metrics without extra codeSpring AI aloneBoot’s observation setup gives gen_ai.client.token.usage and per-advisor and per-tool timers (04). One set of beans, no collision (01).
Prefer to write an interface and let the framework implement itLangChain4j AiServicesThe Support interface is the whole assistant in 02; the Spring AI side builds a client and passes tools.
Need a loud failure when a model loops on toolsLangChain4j’s maxSequentialToolsInvocations, or your own checker that throws in Spring AICase 3 in 03: LangChain4j throws at the cap, Spring AI with a counter returns an empty answer.
Have tools used by several servicesMCP server, with either framework as clientOne tool, two clients, one execution each (05).
Want both frameworks in one applicationPossible; plan for the collision and the HTTP-client conflictStartup fails with both OpenAI starters (01); overriding hides a bean; model built by hand needs an explicit HTTP client (see 02).
Need LangChain4j’s agentic workflows (sequential, parallel, loop, supervisor)LangChain4j, possibly with Spring AI for the restNot tested here; see the linked article.

Should you even use both?

A fair answer. Probably not, unless you can name what you want from each. Running two frameworks means two sets of auto-configuration to reconcile, two ways to declare a tool, two metrics setups, two ways a tool error is handled, and two upgrade schedules, and the table above shows the places those costs surface. Start with one. If you later find yourself writing a piece that the other does better, add it behind a small interface and share your tools over MCP so the choice stays reversible. The strongest reason in the 2025 article, that Spring AI’s tool calling was too limited, did not hold up when both ran the same assistant.

Pitfalls and gotchas, corrected

The original pitfalls table, row by row, with a status on each. “Original” means unchanged and untested here.
PitfallSymptomFixStatus in 2026
Model hallucination in tool callsAgent calls tools with made-up argumentsLower temperature to 0.0–0.1; add strict validation in @Tool methodsOriginal. Validation advice is sound; not tested, since the fake model does not hallucinate.
Context window overflowAPI error: max_tokens exceededUse TokenBudgetAdvisor; implement history summarization for long sessionsCorrected: no class named TokenBudgetAdvisor in the Spring AI 2.0.1 jars on this build (06). Summarising or trimming history is still the approach.
Vector store cold startPoor RAG answers on fresh deploymentPre-populate vector store in a @PostConstruct or batch job at startupOriginal, not tested.
Embedding model mismatchRetrieval returns wrong documentsAlways use the same embedding model for ingestion and query phasesOriginal. The semantic-caching article reproduced the loud version of this (a dimension mismatch is refused by Redis).
Spring AI + LangChain4j config collisionDuplicate bean errors at startupUse @ConditionalOnMissingBean or separate @Configuration classesCorrected: the fix that worked was excluding the LangChain4j auto-configuration (01). Bean-definition overriding makes the error disappear and loses a bean.
Rate limiting at scale429 errors under loadUse Resilience4j retry + exponential backoff; batch embedding callsOriginal, not tested.
Tool loop that never endsDozens of identical tool callsCap the roundsNew: reproduced in both frameworks; cap behaves differently (03).

AI prompts for this topic

The three prompts from the original, unchanged. They are suggestions for working with an AI assistant, not measured claims.

Prompt 1: Generate RAG pipeline
What it does: Scaffolds a complete Spring AI RAG service with vector store configuration for a given domain.
When to use it: When starting a new knowledge-base chat feature.

Prompt. “Generate a production-ready Spring AI RAG service in Java that ingests PDF documents, stores embeddings in PGVector, and answers questions with source citations. Include exception handling and logging.”

Prompt 2: LangChain4j agent debugging
What it does: Helps diagnose unexpected tool-calling loops or hallucinated tool arguments.
When to use it: When your agent calls the wrong tool or loops indefinitely.

Prompt. “I have a LangChain4j agent that keeps calling the same tool in a loop even after receiving the result. Here is my tool definition and the conversation history. What is causing this behavior and how do I fix it?”

Prompt 3: Token cost audit
What it does: Analyzes your system prompts and estimates monthly LLM cost at a given request volume.
When to use it: Before deploying to production to budget AI infrastructure costs.

Prompt. “Given this system prompt [paste prompt], a typical user message of 50 words, and a response of 200 words, calculate the monthly token cost at 10,000 requests/day using GPT-4o-mini pricing. Suggest 3 ways to reduce the token count without losing answer quality.”

See also

Frequently asked questions

Can I use Spring AI and LangChain4j in the same Spring Boot application?
Yes, with a cost. Measured: with both OpenAI starters the context fails at startup with a BeanDefinitionOverrideException on openAiChatModel; excluding one auto-configuration fixes it; allowing bean overriding starts the application but leaves no LangChain4j chat model (01-both-starters.txt). I did not test @Qualifier or @ConditionalOnMissingBean, and the remark that many teams split the work this way is an observation about other teams that I cannot verify.

Original answer: Yes, but you need to manage bean conflicts carefully. Keep LangChain4j beans in a dedicated @Configuration class and use @Qualifier or @ConditionalOnMissingBean to avoid collisions with Spring AI’s auto-configured beans. Many teams use Spring AI for simple chat and RAG, and LangChain4j only for the agent/tool-calling layer.

Which vector database should I use for production RAG in Java?

Original answer: PostgreSQL with PGVector is the most operationally straightforward choice β€” you likely already run Postgres, it handles millions of vectors, and Spring AI has a first-class PGVector store. For very high scale (>100M vectors) or multi-tenancy, consider Qdrant (has a Java client) or Weaviate. Avoid Redis Vector Search for RAG unless your documents are already in Redis.

Status: not tested in this rewrite, and the last point is a preference, not a finding. The semantic-caching article runs Redis as a vector store for a cache and it worked there. The comparison article Choosing a Vector Store for Spring AI is the better place for this question. Note that the original’s Maven coordinates for the PGVector starter have been renamed (spring-ai-starter-vector-store-pgvector, 2.0.1).

How do I prevent the LLM from making destructive tool calls?

Original answer: Three-layer defence: (1) guard clauses inside each @Tool method that validate inputs, (2) a system prompt instruction that requires explicit user confirmation for irreversible actions, and (3) a human-in-the-loop interrupt step for high-risk operations. Never expose a β€œdelete all records” tool without at least a dry-run mode.

Status: design advice, not tested; the fake model in this rewrite does not make dangerous choices. One related measured point: a model that loops on a tool will run it dozens of times unless something caps the rounds (03), so a tool with side effects needs to be idempotent or guarded as well.

Is Spring AI production-ready in 2026?

Original answer: Yes. Spring AI 1.0 GA shipped in 2024 and has been running in production workloads since. The API is stable. The main gap versus LangChain4j is agent/tool-calling sophistication β€” Spring AI’s @Tool support is improving but LangChain4j gives you more control over the agent loop, retries, and tool result processing.

Status: two parts of this do not hold. I have not verified the 2024 date and would not repeat it. And “the API is stable” is contradicted by the compile check in this article: seven of nine API lines from the 2025 version fail against 2.0.1 (06), so moving from 1.x to 2.0 takes real edits. On the tool-calling gap, the same assistant behaved the same in both frameworks (02, 03). Whether Spring AI is right for your production workload is something this article does not test.

What is the typical latency of an LLM call in a microservice?

Original answer: For GPT-4o-mini with a 500-token response, expect 800ms–2.5s (p95). For streaming responses, first-token latency is 200–600ms. Factor this into your SLA β€” AI microservices need different timeout and circuit-breaker thresholds than traditional REST services. Use Resilience4j’s TimeLimiter with a 10–15s timeout for non-streaming calls.

Status: not verified. The fake server in this rewrite makes latency meaningless, and the figures depend on model, region, load and prompt. Measure your own p95 and set timeouts from it. The advice to treat model calls like any other remote I/O, with timeouts and circuit breakers, is sound.

Conclusion

The 2025 conclusion was that Spring AI provides the Spring developer experience, LangChain4j gives the agentic primitives, and a production setup needs a robust RAG pipeline, real cost metrics and token budgeting. After running it, the part I would keep is the last one: treat model calls like any other remote I/O, with retries, timeouts, caps and metrics. The part I would drop is the division of labour. On the same assistant the two frameworks did the same work in the same number of requests, and the differences that showed up were about defaults: what is metered without being asked, what stops a looping model, and whether a cap announces itself. So start with one framework, share your tools over MCP, and add the second only for a feature you can name. All the code and outputs are in the module.
The 2025 conclusion (original text)
Building production-ready AI microservices in Java is no longer experimental territory. Spring AI provides the familiar Spring Boot developer experience for LLM integration, while LangChain4j gives you the agentic primitives needed for autonomous workflows. The key differentiators of a production-grade implementation are: a robust RAG pipeline with similarity thresholding, observability wired to real cost metrics, and token budgeting that prevents runaway spending. Start with Spring AI for your first feature, layer in LangChain4j agents when your use case needs tool calling, and treat AI calls like any other I/O — with retries, timeouts, and circuit breakers.

What I would change: “layer in LangChain4j agents when your use case needs tool calling” does not follow from the tests, because Spring AI handled the same tool calling. The rest is kept as written; the last clause is the part I would repeat.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.