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:
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
|
||||
// From the original TokenBudgetAdvisor.
|
||||
class OldCallAroundAdvisor implements CallAroundAdvisor {
|
||||
@Override
|
||||
public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) {
|
||||
return chain.nextAroundCall(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() { return "x"; }
|
||||
|
||||
@Override
|
||||
public int getOrder() { return 0; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
// The original ChatService, unchanged: this one is expected to still compile.
|
||||
class OldChatService {
|
||||
private final ChatClient chatClient;
|
||||
|
||||
OldChatService(ChatClient.Builder builder) {
|
||||
this.chatClient = builder
|
||||
.defaultSystem("You are a senior Java architect. Be precise and concise.")
|
||||
.build();
|
||||
}
|
||||
|
||||
public String ask(String userMessage) {
|
||||
return chatClient.prompt().user(userMessage).call().content();
|
||||
}
|
||||
|
||||
public Flux<String> stream(String userMessage) {
|
||||
return chatClient.prompt().user(userMessage).stream().content();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public record ArchitectureReview(String verdict, List<String> issues, List<String> suggestions, int score) { }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.document.Document;
|
||||
|
||||
// From the original RAGService: Document::getContent
|
||||
class OldDocumentGetContent {
|
||||
String text(Document d) {
|
||||
return d.getContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
|
||||
// From the original AgentConfig.
|
||||
class OldLangChain4jBuilder {
|
||||
interface Agent { String chat(String m); }
|
||||
|
||||
Agent agent(OpenAiChatModel model) {
|
||||
return AiServices.builder(Agent.class).chatLanguageModel(model).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
|
||||
// From the original TokenBudgetAdvisor.estimateTokens: m.getContent()
|
||||
class OldMessageGetContent {
|
||||
int length(Message m) {
|
||||
return m.getContent().length();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
|
||||
|
||||
// From the original ObservabilityConfig.
|
||||
class OldMeterRegistryCustomizer {
|
||||
MeterRegistryCustomizer<?> unused;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
|
||||
|
||||
// From the original DocumentIngestionService.
|
||||
class OldPdfConfig {
|
||||
Object config() {
|
||||
return PdfDocumentReaderConfig.builder().withPageTopMargin(0).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
|
||||
// From the original RAGService and SemanticCacheService.
|
||||
class OldSearchRequest {
|
||||
Object build(String question, int topK) {
|
||||
return SearchRequest.query(question).withTopK(topK).withSimilarityThreshold(0.75);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
|
||||
|
||||
// From the original DocumentIngestionService: this one is expected to still compile.
|
||||
class OldTokenTextSplitter {
|
||||
TokenTextSplitter splitter = new TokenTextSplitter(512, 50, 5, 10000, true);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import dev.langchain4j.agent.tool.P;
|
||||
import dev.langchain4j.agent.tool.Tool;
|
||||
|
||||
/** The same tool as LangChain4j sees it: same body, different annotations. */
|
||||
public class Lc4jTools {
|
||||
|
||||
private final OrderService orders;
|
||||
|
||||
public Lc4jTools(OrderService orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@Tool("Get current order status by order ID")
|
||||
public String getOrderStatus(@P("The unique identifier of the order") String orderId) {
|
||||
return orders.describe(orderId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.model.tool.ToolExecutionEligibilityChecker;
|
||||
|
||||
/**
|
||||
* A round limit for Spring AI's tool loop. The advisor has no "max iterations" setting; what it has is this hook,
|
||||
* asked once per model reply whether the tool calls in it should be executed. One instance counts one call,
|
||||
* so build one per request.
|
||||
*/
|
||||
public final class MaxToolRounds implements ToolExecutionEligibilityChecker {
|
||||
|
||||
private final int limit;
|
||||
private final AtomicInteger rounds = new AtomicInteger();
|
||||
|
||||
public MaxToolRounds(int limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Boolean apply(ChatResponse response) {
|
||||
return response != null && response.hasToolCalls() && rounds.incrementAndGet() <= limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** The service both frameworks' tools call. Plain Java: it knows nothing about either framework. */
|
||||
public class OrderService {
|
||||
|
||||
public record Order(String id, String status, String eta) { }
|
||||
|
||||
private final Map<String, Order> orders = Map.of(
|
||||
"ORD-1001", new Order("ORD-1001", "SHIPPED", "2026-10-14"),
|
||||
"ORD-1002", new Order("ORD-1002", "PROCESSING", "2026-10-18"));
|
||||
|
||||
public final AtomicInteger calls = new AtomicInteger();
|
||||
|
||||
public Optional<Order> find(String id) {
|
||||
calls.incrementAndGet();
|
||||
if (id.equals("ORD-500")) {
|
||||
throw new IllegalStateException("order database unavailable");
|
||||
}
|
||||
return Optional.ofNullable(orders.get(id));
|
||||
}
|
||||
|
||||
public String describe(String id) {
|
||||
return find(id).map(o -> "Order %s: Status=%s, ETA=%s".formatted(o.id(), o.status(), o.eta()))
|
||||
.orElse("Order not found: " + id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
|
||||
/** The order tool as Spring AI sees it. */
|
||||
public class SpringAiTools {
|
||||
|
||||
private final OrderService orders;
|
||||
|
||||
public SpringAiTools(OrderService orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import dev.langchain4j.service.SystemMessage;
|
||||
|
||||
/** The LangChain4j side of the assistant: an interface whose implementation LangChain4j generates. */
|
||||
public interface Support {
|
||||
|
||||
@SystemMessage("You are a customer support agent. Use the tools for order questions. Be concise.")
|
||||
String chat(String userMessage);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import dev.langchain4j.model.chat.listener.ChatModelErrorContext;
|
||||
import dev.langchain4j.model.chat.listener.ChatModelListener;
|
||||
import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
|
||||
import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
|
||||
import dev.langchain4j.model.output.TokenUsage;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
/**
|
||||
* The listener from the original version of this article, with Lombok and the Spring annotations removed.
|
||||
* It is the same logic: a timer for latency, counters for input and output tokens, a counter for errors.
|
||||
*/
|
||||
public class TracingChatInterceptor implements ChatModelListener {
|
||||
|
||||
private final MeterRegistry meterRegistry;
|
||||
|
||||
private static final String START_TIME_KEY = "start_time";
|
||||
|
||||
public TracingChatInterceptor(MeterRegistry meterRegistry) {
|
||||
this.meterRegistry = meterRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequest(ChatModelRequestContext ctx) {
|
||||
ctx.attributes().put(START_TIME_KEY, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
@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();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(ChatModelErrorContext ctx) {
|
||||
meterRegistry.counter("llm.request.errors", "model", ctx.chatRequest().parameters().modelName()).increment();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
|
||||
import dev.langchain4j.service.AiServices;
|
||||
|
||||
/** The two frameworks pointed at the same server, each wired by hand so nothing is auto-configured. */
|
||||
public final class Wiring {
|
||||
|
||||
public static final String SYSTEM = "You are a customer support agent. Use the tools for order questions. Be concise.";
|
||||
|
||||
private Wiring() { }
|
||||
|
||||
public static OpenAiChatModel springModel(String baseUrl) {
|
||||
return OpenAiChatModel.builder().options(OpenAiChatOptions.builder()
|
||||
.baseUrl(baseUrl + "/v1").apiKey("k").model("gpt-6.1-sol").maxRetries(0).build()).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* With both langchain4j-http-client-jdk and the Spring starter's RestClient adapter on the classpath, building a
|
||||
* model by hand without naming an HTTP client throws; the explicit builder below avoids it.
|
||||
*/
|
||||
public static dev.langchain4j.model.openai.OpenAiChatModel lc4jModel(String baseUrl) {
|
||||
return dev.langchain4j.model.openai.OpenAiChatModel.builder()
|
||||
.httpClientBuilder(dev.langchain4j.http.client.jdk.JdkHttpClient.builder())
|
||||
.baseUrl(baseUrl + "/v1").apiKey("k").modelName("gpt-6.1-sol").maxRetries(0).build();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
class BothStartersTest {
|
||||
|
||||
@Configuration
|
||||
@EnableAutoConfiguration
|
||||
static class BothApp { }
|
||||
|
||||
record Outcome(String result, List<String> beans) { }
|
||||
|
||||
static Outcome start(FakeOpenAi fake, String... extra) {
|
||||
List<String> props = new ArrayList<>(List.of(
|
||||
"spring.ai.openai.base-url=" + fake.url(), "spring.ai.openai.api-key=k",
|
||||
"spring.ai.openai.chat.options.model=gpt-6.1-sol",
|
||||
"spring.ai.mcp.client.enabled=false", "spring.ai.mcp.server.enabled=false",
|
||||
"langchain4j.open-ai.chat-model.base-url=" + fake.url() + "/v1",
|
||||
"langchain4j.open-ai.chat-model.api-key=k",
|
||||
"langchain4j.open-ai.chat-model.model-name=gpt-6.1-sol"));
|
||||
props.addAll(List.of(extra));
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(BothApp.class)
|
||||
.web(WebApplicationType.NONE).properties(props.toArray(String[]::new)).run()) {
|
||||
List<String> beans = new ArrayList<>();
|
||||
for (String n : ctx.getBeanDefinitionNames()) {
|
||||
Class<?> c = null;
|
||||
try {
|
||||
c = ctx.getType(n);
|
||||
} catch (Exception ignored) {
|
||||
// not resolvable without creating it
|
||||
}
|
||||
if (c != null && (c.getName().endsWith("OpenAiChatModel") || c.getName().endsWith("ChatClient$Builder")))
|
||||
beans.add(n + " -> " + c.getName());
|
||||
}
|
||||
beans.add("(count) beans of type dev.langchain4j.model.chat.ChatModel: " + ctx.getBeanNamesForType(dev.langchain4j.model.chat.ChatModel.class).length);
|
||||
beans.add("(count) beans of type org.springframework.ai.chat.model.ChatModel: " + ctx.getBeanNamesForType(org.springframework.ai.chat.model.ChatModel.class).length);
|
||||
return new Outcome("context started", beans);
|
||||
} catch (Exception e) {
|
||||
Throwable root = e;
|
||||
while (root.getCause() != null) root = root.getCause();
|
||||
String msg = root.getMessage();
|
||||
if (root.getClass().getSimpleName().equals("BeanDefinitionOverrideException")) {
|
||||
Matcher m = Pattern.compile("factoryBeanName=([\\w.$]+)").matcher(msg);
|
||||
List<String> owners = new ArrayList<>();
|
||||
while (m.find()) owners.add(m.group(1));
|
||||
msg = "bean 'openAiChatModel' is defined twice, by " + String.join(" and by ", owners);
|
||||
}
|
||||
return new Outcome("FAILED: " + root.getClass().getSimpleName() + ": " + msg, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothStartersInOneApplication() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
Outcome both = start(fake);
|
||||
Outcome excluded = start(fake, "spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration");
|
||||
Outcome overriding = start(fake, "spring.main.allow-bean-definition-overriding=true");
|
||||
try (var t = new Transcript("01-both-starters.txt", "Both starters in one Spring Boot 4.1.1 application")) {
|
||||
t.line("Spring AI: spring-ai-starter-model-openai 2.0.1. LangChain4j: langchain4j-open-ai-spring-boot4-starter 1.22.0-beta32.");
|
||||
t.blank();
|
||||
t.line("1. Both starters, nothing else:");
|
||||
t.line(" %s", both.result());
|
||||
t.blank();
|
||||
t.line("2. spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration:");
|
||||
t.line(" %s", excluded.result());
|
||||
excluded.beans().forEach(b -> t.line(" %s", b.startsWith("(count)") ? b.substring(8) : "bean " + b));
|
||||
t.blank();
|
||||
t.line("3. spring.main.allow-bean-definition-overriding=true:");
|
||||
t.line(" %s", overriding.result());
|
||||
overriding.beans().forEach(b -> t.line(" %s", b.startsWith("(count)") ? b.substring(8) : "bean " + b));
|
||||
}
|
||||
assertThat(both.result()).startsWith("FAILED: BeanDefinitionOverrideException");
|
||||
assertThat(excluded.result()).isEqualTo("context started");
|
||||
assertThat(overriding.result()).isEqualTo("context started");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* A local server speaking the OpenAI chat-completions wire format, so the REAL Spring AI and LangChain4j OpenAI
|
||||
* models talk to it. It is a rule, not a language model: if the last message is a tool result it answers with
|
||||
* that result; if the user mentions an order id and a tool is on offer it asks for the tool; otherwise it
|
||||
* greets. Every request body is recorded.
|
||||
*/
|
||||
public final class FakeOpenAi implements AutoCloseable {
|
||||
|
||||
public enum Mode {
|
||||
/** Ask for the tool once, then answer from the tool result. */
|
||||
NORMAL,
|
||||
/** Ask for the tool again after every tool result, like a model that never gives up. */
|
||||
INSISTENT
|
||||
}
|
||||
|
||||
private static final JsonMapper JSON = JsonMapper.builder().build();
|
||||
private static final Pattern ORDER = Pattern.compile("ORD-\\d+");
|
||||
|
||||
private final HttpServer server;
|
||||
public final List<String> bodies = new CopyOnWriteArrayList<>();
|
||||
public volatile Mode mode = Mode.NORMAL;
|
||||
/** After this many requests the server answers 500, so a loop that never ends still ends in a test. */
|
||||
public volatile int cutoff = Integer.MAX_VALUE;
|
||||
|
||||
public FakeOpenAi() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/v1/chat/completions", this::serve);
|
||||
server.start();
|
||||
}
|
||||
|
||||
public String url() { return "http://127.0.0.1:" + server.getAddress().getPort(); }
|
||||
|
||||
public void reset() {
|
||||
bodies.clear();
|
||||
mode = Mode.NORMAL;
|
||||
cutoff = Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
private void serve(HttpExchange ex) throws IOException {
|
||||
String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
bodies.add(body);
|
||||
String reply;
|
||||
try {
|
||||
reply = bodies.size() > cutoff ? "{\"error\":{\"message\":\"cutoff reached\"}}" : decide(JSON.readTree(body));
|
||||
} catch (Exception e) {
|
||||
reply = "{\"error\":{\"message\":\"fake could not read the request: " + e.getMessage().replace("\"", "'") + "\"}}";
|
||||
}
|
||||
byte[] out = reply.getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().add("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(reply.startsWith("{\"error") ? 500 : 200, out.length);
|
||||
try (OutputStream os = ex.getResponseBody()) {
|
||||
os.write(out);
|
||||
}
|
||||
}
|
||||
|
||||
private String decide(JsonNode req) {
|
||||
JsonNode messages = req.get("messages");
|
||||
JsonNode last = messages.get(messages.size() - 1);
|
||||
boolean toolsOffered = req.has("tools") && req.get("tools").size() > 0;
|
||||
String lastRole = last.get("role").asText();
|
||||
int promptChars = req.toString().length();
|
||||
|
||||
if (lastRole.equals("tool")) {
|
||||
if (mode == Mode.INSISTENT && toolsOffered) {
|
||||
return toolCall(lastUserText(messages), promptChars);
|
||||
}
|
||||
return text("Done: " + last.get("content").asText(), promptChars);
|
||||
}
|
||||
String user = lastUserText(messages);
|
||||
Matcher m = ORDER.matcher(user);
|
||||
if (toolsOffered && m.find()) {
|
||||
return toolCall(user, promptChars);
|
||||
}
|
||||
return text("Hello. I can look up orders.", promptChars);
|
||||
}
|
||||
|
||||
private static String lastUserText(JsonNode messages) {
|
||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||
JsonNode m = messages.get(i);
|
||||
if (m.get("role").asText().equals("user")) {
|
||||
JsonNode c = m.get("content");
|
||||
return c.isString() ? c.asString() : c.toString();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String toolCall(String user, int promptChars) {
|
||||
Matcher m = ORDER.matcher(user);
|
||||
String id = m.find() ? m.group() : "ORD-0";
|
||||
return "{\"id\":\"chatcmpl-t\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-6.1-sol\","
|
||||
+ "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_1\","
|
||||
+ "\"type\":\"function\",\"function\":{\"name\":\"getOrderStatus\",\"arguments\":\"{\\\"orderId\\\":\\\"" + id + "\\\"}\"}}]},"
|
||||
+ "\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":" + promptChars / 4 + ",\"completion_tokens\":12,\"total_tokens\":"
|
||||
+ (promptChars / 4 + 12) + "}}";
|
||||
}
|
||||
|
||||
private static String text(String text, int promptChars) {
|
||||
return "{\"id\":\"chatcmpl-x\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"gpt-6.1-sol\","
|
||||
+ "\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":" + JSON.writeValueAsString(text)
|
||||
+ "},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":" + promptChars / 4 + ",\"completion_tokens\":"
|
||||
+ Math.max(1, text.length() / 4) + ",\"total_tokens\":" + (promptChars / 4 + Math.max(1, text.length() / 4)) + "}}";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() { server.stop(0); }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
|
||||
import org.springframework.ai.mcp.annotation.McpTool;
|
||||
import org.springframework.ai.mcp.annotation.McpToolParam;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import dev.langchain4j.mcp.McpToolProvider;
|
||||
import dev.langchain4j.mcp.client.DefaultMcpClient;
|
||||
import dev.langchain4j.mcp.client.transport.http.StreamableHttpMcpTransport;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
|
||||
/** The order tool written once, behind an MCP server, and used from both frameworks as a client. */
|
||||
class McpFirstTest {
|
||||
|
||||
/** Nothing here imports either framework's tool annotation: only MCP's. */
|
||||
public static class McpOrderTools {
|
||||
|
||||
private final OrderService orders;
|
||||
|
||||
public McpOrderTools(OrderService orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@McpTool(name = "getOrderStatus", description = "Get current order status by order ID")
|
||||
public String getOrderStatus(@McpToolParam(description = "The unique identifier of the order", required = true) String orderId) {
|
||||
return orders.describe(orderId);
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableAutoConfiguration
|
||||
static class ServerApp {
|
||||
|
||||
static final OrderService ORDERS = new OrderService();
|
||||
|
||||
@Bean
|
||||
McpOrderTools mcpOrderTools() {
|
||||
return new McpOrderTools(ORDERS);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theToolBehindMcpServesBothFrameworks() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi();
|
||||
ConfigurableApplicationContext server = new SpringApplicationBuilder(ServerApp.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties("server.port=0", "spring.ai.openai.api-key=unused",
|
||||
"spring.ai.mcp.client.enabled=false",
|
||||
"spring.autoconfigure.exclude=dev.langchain4j.openai.spring.OpenAiAutoConfiguration",
|
||||
"spring.ai.mcp.server.name=orders", "spring.ai.mcp.server.version=1.0.0",
|
||||
"spring.ai.mcp.server.protocol=streamable")
|
||||
.run()) {
|
||||
int port = server.getEnvironment().getProperty("local.server.port", Integer.class);
|
||||
String base = "http://127.0.0.1:" + port;
|
||||
|
||||
// Spring AI as an MCP client: the tools arrive as ToolCallbacks.
|
||||
var sdkClient = McpClient.sync(HttpClientStreamableHttpTransport.builder(base).endpoint("/mcp").build())
|
||||
.requestTimeout(Duration.ofSeconds(10)).build();
|
||||
sdkClient.initialize();
|
||||
var springProvider = SyncMcpToolCallbackProvider.builder().mcpClients(sdkClient).build();
|
||||
ChatClient spring = ChatClient.builder(Wiring.springModel(fake.url())).defaultSystem(Wiring.SYSTEM)
|
||||
.defaultToolCallbacks(springProvider).build();
|
||||
String springAnswer = spring.prompt().user("Where is ORD-1002?").call().content();
|
||||
int springRequests = fake.bodies.size();
|
||||
String springTool = SameAssistantTest.summarise(fake.bodies.get(0)).stream().filter(l -> l.contains("tool getOrderStatus")).findFirst().orElse("(no tool offered)");
|
||||
int callsAfterSpring = ServerApp.ORDERS.calls.get();
|
||||
fake.reset();
|
||||
|
||||
// LangChain4j as an MCP client.
|
||||
var lcMcp = DefaultMcpClient.builder().key("orders")
|
||||
.transport(StreamableHttpMcpTransport.builder().url(base + "/mcp").build()).build();
|
||||
Support lc = AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url()))
|
||||
.toolProvider(McpToolProvider.builder().mcpClients(List.of(lcMcp)).build()).build();
|
||||
String lcAnswer = lc.chat("Where is ORD-1002?");
|
||||
int lcRequests = fake.bodies.size();
|
||||
String lcTool = SameAssistantTest.summarise(fake.bodies.get(0)).stream().filter(l -> l.contains("tool getOrderStatus")).findFirst().orElse("(no tool offered)");
|
||||
int callsAfterBoth = ServerApp.ORDERS.calls.get();
|
||||
|
||||
try (var t = new Transcript("05-mcp-first.txt", "The order tool served once over MCP (Streamable HTTP), used from Spring AI and from LangChain4j")) {
|
||||
t.line("MCP server: Spring AI 2.0.1 spring-ai-starter-mcp-server-webmvc, one @McpTool method, on port %d.", port);
|
||||
t.line("Spring AI client : McpClient.sync(HttpClientStreamableHttpTransport) -> SyncMcpToolCallbackProvider -> ChatClient.defaultToolCallbacks");
|
||||
t.line("LangChain4j client: DefaultMcpClient + StreamableHttpMcpTransport -> McpToolProvider -> AiServices.toolProvider");
|
||||
t.blank();
|
||||
t.line("Spring AI answer : %s (%d requests to the model)", springAnswer, springRequests);
|
||||
t.line(" tool offered to the model: %s", springTool);
|
||||
t.line("LangChain4j answer : %s (%d requests to the model)", lcAnswer, lcRequests);
|
||||
t.line(" tool offered to the model: %s", lcTool);
|
||||
t.blank();
|
||||
t.line("Times the one tool method on the server ran: %d for Spring AI, then %d more for LangChain4j.", callsAfterSpring, callsAfterBoth - callsAfterSpring);
|
||||
}
|
||||
sdkClient.close();
|
||||
lcMcp.close();
|
||||
assertThat(springAnswer).contains("PROCESSING").isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
|
||||
import dev.langchain4j.micrometer.metrics.listeners.MicrometerMetricsChatModelListener;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
import io.micrometer.core.instrument.Meter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler;
|
||||
|
||||
class ObservabilityTest {
|
||||
|
||||
static List<String> describe(MeterRegistry r) {
|
||||
return r.getMeters().stream().map(m -> {
|
||||
Meter.Id id = m.getId();
|
||||
String tags = id.getTags().stream().filter(t -> List.of("gen_ai.token.type", "gen_ai.request.model", "gen_ai.response.model",
|
||||
"gen_ai.operation.name", "gen_ai.provider.name", "spring.ai.kind", "model", "gen_ai.system", "tool.name", "error", "outcome")
|
||||
.contains(t.getKey())).map(t -> t.getKey() + "=" + t.getValue()).sorted().collect(Collectors.joining(", "));
|
||||
return id.getName() + " [" + id.getType() + "]" + (tags.isEmpty() ? "" : " " + tags);
|
||||
}).distinct().sorted().toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatEachFrameworkRecordsWithoutBeingAsked() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
// Spring AI: a model and a client that share an ObservationRegistry; a handler turns observations into meters.
|
||||
SimpleMeterRegistry springMeters = new SimpleMeterRegistry();
|
||||
ObservationRegistry observations = ObservationRegistry.create();
|
||||
observations.observationConfig().observationHandler(new DefaultMeterObservationHandler(springMeters));
|
||||
// the handler Spring Boot registers for you; it turns the model's token usage into a meter
|
||||
observations.observationConfig().observationHandler(new org.springframework.ai.chat.observation.ChatModelMeterObservationHandler(springMeters));
|
||||
OrderService sOrders = new OrderService();
|
||||
OpenAiChatModel model = OpenAiChatModel.builder().observationRegistry(observations).options(OpenAiChatOptions.builder()
|
||||
.baseUrl(fake.url() + "/v1").apiKey("k").model("gpt-6.1-sol").maxRetries(0).build()).build();
|
||||
ChatClient spring = ChatClient.builder(model, observations, null, null).defaultSystem(Wiring.SYSTEM)
|
||||
.defaultTools(new SpringAiTools(sOrders)).build();
|
||||
spring.prompt().user("Where is ORD-1001?").call().content();
|
||||
|
||||
// LangChain4j: nothing is recorded unless a listener is registered on the model.
|
||||
SimpleMeterRegistry bare = new SimpleMeterRegistry();
|
||||
AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url())).tools(new Lc4jTools(new OrderService())).build()
|
||||
.chat("Where is ORD-1001?");
|
||||
List<String> lcNone = describe(bare);
|
||||
|
||||
SimpleMeterRegistry lcOwn = new SimpleMeterRegistry(), lcShipped = new SimpleMeterRegistry();
|
||||
var withOwn = dev.langchain4j.model.openai.OpenAiChatModel.builder()
|
||||
.httpClientBuilder(dev.langchain4j.http.client.jdk.JdkHttpClient.builder())
|
||||
.baseUrl(fake.url() + "/v1").apiKey("k").modelName("gpt-6.1-sol")
|
||||
.listeners(List.of(new TracingChatInterceptor(lcOwn))).build();
|
||||
AiServices.builder(Support.class).chatModel(withOwn).tools(new Lc4jTools(new OrderService())).build().chat("Where is ORD-1001?");
|
||||
var withShipped = dev.langchain4j.model.openai.OpenAiChatModel.builder()
|
||||
.httpClientBuilder(dev.langchain4j.http.client.jdk.JdkHttpClient.builder())
|
||||
.baseUrl(fake.url() + "/v1").apiKey("k").modelName("gpt-6.1-sol")
|
||||
.listeners(List.of(new MicrometerMetricsChatModelListener(lcShipped))).build();
|
||||
AiServices.builder(Support.class).chatModel(withShipped).tools(new Lc4jTools(new OrderService())).build().chat("Where is ORD-1001?");
|
||||
|
||||
try (var t = new Transcript("04-observability.txt", "What each framework records for one question that uses one tool (2 model requests)")) {
|
||||
t.line("Spring AI 2.0.1: model and client given an ObservationRegistry plus DefaultMeterObservationHandler and ChatModelMeterObservationHandler (the two Spring Boot registers for you).");
|
||||
describe(springMeters).forEach(l -> t.line(" %s", l));
|
||||
t.blank();
|
||||
t.line("LangChain4j 1.22.0, no listener registered: %d meters.", lcNone.size());
|
||||
t.blank();
|
||||
t.line("LangChain4j 1.22.0 with TracingChatInterceptor, the listener from the original article (it still compiles and runs):");
|
||||
describe(lcOwn).forEach(l -> t.line(" %s", l));
|
||||
t.blank();
|
||||
t.line("LangChain4j 1.22.0 with the shipped MicrometerMetricsChatModelListener (langchain4j-micrometer-metrics 1.22.0-beta32):");
|
||||
describe(lcShipped).forEach(l -> t.line(" %s", l));
|
||||
}
|
||||
assertThat(springMeters.getMeters()).isNotEmpty();
|
||||
assertThat(lcNone).isEmpty();
|
||||
assertThat(lcOwn.getMeters()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class SameAssistantTest {
|
||||
|
||||
static final JsonMapper JSON = JsonMapper.builder().build();
|
||||
|
||||
/** One request body reduced to what a reader compares: top-level fields, messages and tools. */
|
||||
static List<String> summarise(String body) {
|
||||
JsonNode req = JSON.readTree(body);
|
||||
List<String> out = new ArrayList<>();
|
||||
TreeSet<String> keys = new TreeSet<>();
|
||||
req.propertyNames().forEach(keys::add);
|
||||
out.add("fields: " + keys);
|
||||
for (JsonNode m : req.get("messages")) {
|
||||
String role = m.get("role").asText();
|
||||
String detail;
|
||||
if (m.has("tool_calls") && m.get("tool_calls").size() > 0) {
|
||||
JsonNode f = m.get("tool_calls").get(0).get("function");
|
||||
detail = "tool_call " + f.get("name").asText() + " " + f.get("arguments").asText();
|
||||
} else {
|
||||
JsonNode c = m.get("content");
|
||||
detail = c == null || c.isNull() ? "" : c.isString() ? "\"" + c.asString() + "\"" : c.toString();
|
||||
}
|
||||
out.add(" " + role + ": " + detail);
|
||||
}
|
||||
if (req.has("tools")) {
|
||||
for (JsonNode tool : req.get("tools")) {
|
||||
JsonNode f = tool.get("function");
|
||||
out.add(" tool " + f.get("name").asText() + " - " + f.path("description").asText("(no description)") + " params " + f.path("parameters"));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String names(JsonNode n) {
|
||||
TreeSet<String> k = new TreeSet<>();
|
||||
n.propertyNames().forEach(k::add);
|
||||
return k.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameOrderAssistantInBothFrameworks() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
OrderService springOrders = new OrderService(), lcOrders = new OrderService();
|
||||
String springAnswer = Wiring.springAssistant(fake.url(), springOrders).prompt().user("Where is ORD-1001?").call().content();
|
||||
List<String> springBodies = new ArrayList<>(fake.bodies);
|
||||
fake.reset();
|
||||
String lcAnswer = Wiring.lc4jAssistant(fake.url(), lcOrders).chat("Where is ORD-1001?");
|
||||
List<String> lcBodies = new ArrayList<>(fake.bodies);
|
||||
|
||||
try (var t = new Transcript("02-same-assistant.txt", "The same order assistant in Spring AI and in LangChain4j, one question: \"Where is ORD-1001?\"")) {
|
||||
t.line("Spring AI : ChatClient.builder(model).defaultSystem(..).defaultTools(new SpringAiTools(orders))");
|
||||
t.line("LangChain4j: AiServices.builder(Support.class).chatModel(model).tools(new Lc4jTools(orders))");
|
||||
t.blank();
|
||||
t.line("Spring AI answer : %s", springAnswer);
|
||||
t.line("LangChain4j answer : %s", lcAnswer);
|
||||
t.line("HTTP requests sent : Spring AI %d, LangChain4j %d. Tool body executed: Spring AI %d, LangChain4j %d.",
|
||||
springBodies.size(), lcBodies.size(), springOrders.calls.get(), lcOrders.calls.get());
|
||||
t.blank();
|
||||
t.line("What differs on the wire (computed from the four bodies below):");
|
||||
JsonNode s1 = JSON.readTree(springBodies.get(0)), l1 = JSON.readTree(lcBodies.get(0));
|
||||
JsonNode s2 = JSON.readTree(springBodies.get(1)), l2 = JSON.readTree(lcBodies.get(1));
|
||||
t.line(" top-level fields : Spring AI %s, LangChain4j %s", names(s1), names(l1));
|
||||
t.line(" tool schema : Spring AI has $schema=%s additionalProperties=%s; LangChain4j has $schema=%s additionalProperties=%s",
|
||||
s1.at("/tools/0/function/parameters").has("$schema"), s1.at("/tools/0/function/parameters").has("additionalProperties"),
|
||||
l1.at("/tools/0/function/parameters").has("$schema"), l1.at("/tools/0/function/parameters").has("additionalProperties"));
|
||||
t.line(" tool result message: Spring AI sent %s, LangChain4j sent %s", s2.at("/messages/3/content"), l2.at("/messages/3/content"));
|
||||
for (int i = 0; i < springBodies.size(); i++) {
|
||||
t.blank();
|
||||
t.line("--- Spring AI, request %d", i + 1);
|
||||
summarise(springBodies.get(i)).forEach(l -> t.line("%s", l));
|
||||
}
|
||||
for (int i = 0; i < lcBodies.size(); i++) {
|
||||
t.blank();
|
||||
t.line("--- LangChain4j, request %d", i + 1);
|
||||
summarise(lcBodies.get(i)).forEach(l -> t.line("%s", l));
|
||||
}
|
||||
}
|
||||
assertThat(springAnswer.replace("\"", "")).isEqualTo(lcAnswer);
|
||||
assertThat(springAnswer).isNotEqualTo(lcAnswer);
|
||||
assertThat(springBodies).hasSize(2);
|
||||
assertThat(lcBodies).hasSize(2);
|
||||
assertThat(springOrders.calls.get()).isEqualTo(1);
|
||||
assertThat(lcOrders.calls.get()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
class ToolFailureTest {
|
||||
|
||||
static final JsonMapper JSON = JsonMapper.builder().build();
|
||||
|
||||
record Run(String result, int requests, int toolBodies, String toolMessage) { }
|
||||
|
||||
static Run run(FakeOpenAi fake, OrderService orders, Supplier<String> call) {
|
||||
fake.bodies.clear();
|
||||
orders.calls.set(0);
|
||||
String result;
|
||||
try {
|
||||
result = "answer: " + call.get();
|
||||
} catch (Exception e) {
|
||||
Throwable root = e;
|
||||
while (root.getCause() != null) root = root.getCause();
|
||||
result = "exception " + e.getClass().getSimpleName() + (root != e ? " (root: " + root.getClass().getSimpleName() + ")" : "")
|
||||
+ ": " + String.valueOf(root.getMessage()).lines().findFirst().orElse("");
|
||||
}
|
||||
String toolMsg = "(none sent)";
|
||||
for (String b : fake.bodies) {
|
||||
JsonNode msgs = JSON.readTree(b).get("messages");
|
||||
for (JsonNode m : msgs) if (m.get("role").asText().equals("tool")) toolMsg = m.get("content").toString();
|
||||
}
|
||||
return new Run(result, fake.bodies.size(), orders.calls.get(), toolMsg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatEachFrameworkDoesWhenAToolThrows() throws Exception {
|
||||
try (FakeOpenAi fake = new FakeOpenAi()) {
|
||||
OrderService sOrders = new OrderService(), lOrders = new OrderService();
|
||||
var spring = Wiring.springAssistant(fake.url(), sOrders);
|
||||
var lc = Wiring.lc4jAssistant(fake.url(), lOrders);
|
||||
|
||||
Run s1 = run(fake, sOrders, () -> spring.prompt().user("Where is ORD-500?").call().content());
|
||||
Run l1 = run(fake, lOrders, () -> lc.chat("Where is ORD-500?"));
|
||||
|
||||
// A model that keeps asking for the tool after every result, stopped by the server after 30 requests.
|
||||
fake.mode = FakeOpenAi.Mode.INSISTENT;
|
||||
fake.cutoff = 30;
|
||||
Run s2 = run(fake, sOrders, () -> spring.prompt().user("Where is ORD-500?").call().content());
|
||||
Run l2 = run(fake, lOrders, () -> lc.chat("Where is ORD-500?"));
|
||||
// The same insistent model, now with each framework's own way of capping the rounds at 5.
|
||||
fake.cutoff = 30;
|
||||
var cappedSpring = org.springframework.ai.chat.client.ChatClient.builder(Wiring.springModel(fake.url()))
|
||||
.defaultSystem(Wiring.SYSTEM).defaultTools(new SpringAiTools(sOrders))
|
||||
.defaultAdvisors(org.springframework.ai.chat.client.advisor.ToolCallingAdvisor.builder()
|
||||
.toolExecutionEligibilityChecker(new MaxToolRounds(5)).build()).build();
|
||||
var cappedLc = dev.langchain4j.service.AiServices.builder(Support.class).chatModel(Wiring.lc4jModel(fake.url()))
|
||||
.tools(new Lc4jTools(lOrders)).maxSequentialToolsInvocations(5).build();
|
||||
Run s3 = run(fake, sOrders, () -> String.valueOf(cappedSpring.prompt().user("Where is ORD-500?").call().content()));
|
||||
Run l3 = run(fake, lOrders, () -> cappedLc.chat("Where is ORD-500?"));
|
||||
fake.reset();
|
||||
|
||||
try (var t = new Transcript("03-tool-throws.txt", "A tool that throws (ORD-500 raises IllegalStateException: order database unavailable)")) {
|
||||
t.line("Case 1: a well-behaved model. It asks for the tool once, then answers from whatever the tool message says.");
|
||||
t.line(" Spring AI : %d requests, tool body ran %d time(s). Tool message the model received: %s", s1.requests(), s1.toolBodies(), s1.toolMessage());
|
||||
t.line(" %s", s1.result());
|
||||
t.line(" LangChain4j : %d requests, tool body ran %d time(s). Tool message the model received: %s", l1.requests(), l1.toolBodies(), l1.toolMessage());
|
||||
t.line(" %s", l1.result());
|
||||
t.blank();
|
||||
t.line("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.");
|
||||
t.line(" Spring AI : %d requests, tool body ran %d time(s)", s2.requests(), s2.toolBodies());
|
||||
t.line(" %s", s2.result());
|
||||
t.line(" LangChain4j : %d requests, tool body ran %d time(s)", l2.requests(), l2.toolBodies());
|
||||
t.line(" %s", l2.result());
|
||||
t.blank();
|
||||
t.line("Case 3: the same insistent model, each framework asked to stop after 5 tool rounds.");
|
||||
t.line(" Spring AI (ToolCallingAdvisor + our MaxToolRounds(5) checker): %d requests, tool body ran %d time(s)", s3.requests(), s3.toolBodies());
|
||||
t.line(" %s", s3.result());
|
||||
t.line(" LangChain4j (maxSequentialToolsInvocations(5)) : %d requests, tool body ran %d time(s)", l3.requests(), l3.toolBodies());
|
||||
t.line(" %s", l3.result());
|
||||
}
|
||||
assertThat(s1.requests()).isGreaterThan(0);
|
||||
assertThat(l1.requests()).isGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ankurm.interop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Writes a numbered transcript under {@code output/} and echoes it. Every console block in the article is one of these files. */
|
||||
final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user