Add langchain4j-interop: Spring AI 2.0 and LangChain4j 1.22 side by side

Same order assistant in both frameworks against a local fake OpenAI
server: starter bean collision, wire differences, tool failure and loop
bounds, observability meters, MCP-first tools, and a compile check of
the 2025 article's snippets and Maven coordinates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
Claude
2026-10-11 10:09:45 +00:00
parent d51ef56468
commit e79b75a105
35 changed files with 1330 additions and 0 deletions
@@ -0,0 +1,20 @@
# Both starters in one Spring Boot 4.1.1 application
Spring AI: spring-ai-starter-model-openai 2.0.1. LangChain4j: langchain4j-open-ai-spring-boot4-starter 1.22.0-beta32.
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
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
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
@@ -0,0 +1,41 @@
# The same order assistant in Spring AI and in LangChain4j, one question: "Where is ORD-1001?"
Spring AI : ChatClient.builder(model).defaultSystem(..).defaultTools(new SpringAiTools(orders))
LangChain4j: AiServices.builder(Support.class).chatModel(model).tools(new Lc4jTools(orders))
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.
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"
--- Spring AI, request 1
fields: [messages, model, tools]
system: "You are a customer support agent. Use the tools for order questions. Be concise."
user: "Where is ORD-1001?"
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"],"additionalProperties":false}
--- Spring AI, request 2
fields: [messages, model, tools]
system: "You are a customer support agent. Use the tools for order questions. Be concise."
user: "Where is ORD-1001?"
assistant: tool_call getOrderStatus {"orderId":"ORD-1001"}
tool: ""Order ORD-1001: Status=SHIPPED, ETA=2026-10-14""
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"],"additionalProperties":false}
--- LangChain4j, request 1
fields: [messages, model, stream, tools]
system: "You are a customer support agent. Use the tools for order questions. Be concise."
user: "Where is ORD-1001?"
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"]}
--- LangChain4j, request 2
fields: [messages, model, stream, tools]
system: "You are a customer support agent. Use the tools for order questions. Be concise."
user: "Where is ORD-1001?"
assistant: tool_call getOrderStatus {"orderId":"ORD-1001"}
tool: "Order ORD-1001: Status=SHIPPED, ETA=2026-10-14"
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"]}
@@ -0,0 +1,19 @@
# A tool that throws (ORD-500 raises IllegalStateException: order database unavailable)
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
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"}}
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)
@@ -0,0 +1,27 @@
# What each framework records for one question that uses one tool (2 model requests)
Spring AI 2.0.1: model and client given an ObservationRegistry plus DefaultMeterObservationHandler and ChatModelMeterObservationHandler (the two Spring Boot registers for you).
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
spring.ai.tool.active [LONG_TASK_TIMER] gen_ai.operation.name=execute_tool, gen_ai.system=spring_ai, spring.ai.kind=tool_call
LangChain4j 1.22.0, no listener registered: 0 meters.
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
@@ -0,0 +1,12 @@
# The order tool served once over MCP (Streamable HTTP), used from Spring AI and from LangChain4j
MCP server: Spring AI 2.0.1 spring-ai-starter-mcp-server-webmvc, one @McpTool method, on port 33237.
Spring AI client : McpClient.sync(HttpClientStreamableHttpTransport) -> SyncMcpToolCallbackProvider -> ChatClient.defaultToolCallbacks
LangChain4j client: DefaultMcpClient + StreamableHttpMcpTransport -> McpToolProvider -> AiServices.toolProvider
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.
@@ -0,0 +1,56 @@
# The original article's code, compiled against Spring AI 2.0.1 and LangChain4j 1.22.0 (one file per API line)
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)
# The original article's Maven coordinates, looked up on Maven Central
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
# Class names the original article used, searched in the jars this build resolves (mvn dependency:build-classpath)
TokenBudgetAdvisor: not found in any jar
RequestResponseAdvisor: not found in any jar
CallAroundAdvisor: not found in any jar
CallAdvisor: found
ToolCallingAdvisor: found
AdvisedRequest: not found in any jar
ChatClientRequest: found
EmbeddingClient: not found in any jar
EmbeddingModel: found