Add ai-services and spring-boot-ai-service modules: prompt templates, per-id chat memory, tools, RAG, the @MemoryId trap, the message-window probe, and @AiService on Spring Boot 4.1.1, each with a captured transcript

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
2026-10-11 07:48:34 +00:00
co-authored by Claude Sonnet 5.5
commit f87573fcb8
22 changed files with 670 additions and 0 deletions
@@ -0,0 +1,6 @@
Messages LangChain4j sent to the model:
SYSTEM: You are a concise support agent for Acme Vault. Answer in one sentence.
USER: Customer asks: How do I reset my password?
Reply: Reset it from Settings > Security.
Implementation is a java.lang.reflect.Proxy: true
+11
View File
@@ -0,0 +1,11 @@
Messages the model saw on each call (memory window = 4 stored messages):
call 1: 1 messages
call 2: 3 messages
call 3: 1 messages
call 4: 5 messages
call 5: 5 messages
Second call, in full:
USER: My name is Alice
AI: noted (1 messages seen)
USER: What is my name?
+10
View File
@@ -0,0 +1,10 @@
Tool specification sent with the first request: [orderStatus - Looks up the shipping status of an order by its id]
Second request (after the tool ran):
USER: Where is order A-1001?
AI: tool calls [orderStatus{"orderId":"A-1001"}]
TOOL_EXECUTION_RESULT: Order A-1001 shipped on 2026-10-08, arriving 2026-10-13
Java methods actually executed: [orderStatus(A-1001)]
Model round trips: 2
Final reply: Your order is on its way and arrives on 2026-10-13.
+5
View File
@@ -0,0 +1,5 @@
What the model received after retrieval (maxResults=1):
USER: How long do refunds take?
Answer using the following information:
Refunds are issued to the original payment method within 5 business days.
+2
View File
@@ -0,0 +1,2 @@
Building an interface where ONE method has @MemoryId, with no ChatMemoryProvider:
IllegalConfigurationException: In order to use @MemoryId, please configure the ChatMemoryProvider on the 'com.ankurm.lc4j.aiservices.AiServicesTest$MemoryIdMixed'.
+8
View File
@@ -0,0 +1,8 @@
Memory window = 4. For each call: messages the model received, messages stored at that moment, messages stored afterwards.
call 1: model received 1, stored 1, stored afterwards 2
call 2: model received 3, stored 3, stored afterwards 4
call 3: model received 5, stored 4, stored afterwards 4
call 4: model received 5, stored 4, stored afterwards 4
call 5: model received 5, stored 4, stored afterwards 4
Largest number of messages the memory object held after any add, used on its own: 4
@@ -0,0 +1,5 @@
Command: mvn dependency:tree -pl ai-services (lines mentioning the embedding model and what it brings in)
+- dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2:jar:1.22.0-beta32:compile
| +- com.microsoft.onnxruntime:onnxruntime:jar:1.20.0:compile
| +- ai.djl:api:jar:0.36.0:compile
| \- ai.djl.huggingface:tokenizers:jar:0.36.0:compile
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.langchain4j</groupId>
<artifactId>langchain4j-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>ai-services</artifactId>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
<version>${langchain4j.beta.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,15 @@
package com.ankurm.lc4j.aiservices;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.UserMessage;
/**
* Kept separate from {@link SupportAgent} on purpose: a single @MemoryId parameter anywhere in an
* interface makes AiServices demand a ChatMemoryProvider for the whole interface (see the
* memoryIdTrap test and output/05-memoryid-trap.txt).
*/
public interface ChatAgent {
/** With a ChatMemoryProvider, each @MemoryId gets its own conversation history. */
String chat(@MemoryId String customerId, @UserMessage String message);
}
@@ -0,0 +1,6 @@
package com.ankurm.lc4j.aiservices;
/** Used for the RAG example: same idea, the interface does not mention retrieval at all. */
public interface KnowledgeAgent {
String ask(String question);
}
@@ -0,0 +1,23 @@
package com.ankurm.lc4j.aiservices;
import dev.langchain4j.agent.tool.P;
import dev.langchain4j.agent.tool.Tool;
import java.util.ArrayList;
import java.util.List;
/** A plain Java object. The model never runs this code; it only asks LangChain4j to run it. */
public class OrderTools {
private final List<String> calls = new ArrayList<>();
@Tool("Looks up the shipping status of an order by its id")
public String orderStatus(@P("the order id, for example A-1001") String orderId) {
calls.add("orderStatus(" + orderId + ")");
return "Order " + orderId + " shipped on 2026-10-08, arriving 2026-10-13";
}
public List<String> calls() {
return calls;
}
}
@@ -0,0 +1,63 @@
package com.ankurm.lc4j.aiservices;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* A deterministic stand-in for a real LLM. It lets every example run offline and lets the
* tests assert exactly which messages LangChain4j sent, which is the thing worth looking at.
* Swap it for OpenAiChatModel (or any other ChatModel) and the AI Service code does not change.
*/
public class ScriptedChatModel implements ChatModel {
private final Function<ChatRequest, AiMessage> script;
private final List<ChatRequest> requests = new ArrayList<>();
public ScriptedChatModel(Function<ChatRequest, AiMessage> script) {
this.script = script;
}
@Override
public ChatResponse doChat(ChatRequest request) {
requests.add(request);
return ChatResponse.builder().aiMessage(script.apply(request)).build();
}
/** Every request this model has received, oldest first. */
public List<ChatRequest> requests() {
return requests;
}
public ChatRequest lastRequest() {
return requests.get(requests.size() - 1);
}
/** Renders the messages of a request as one line per message, for transcripts. */
public static String render(List<ChatMessage> messages) {
StringBuilder sb = new StringBuilder();
for (ChatMessage m : messages) {
sb.append(m.type()).append(": ").append(text(m)).append('\n');
}
return sb.toString();
}
private static String text(ChatMessage m) {
return switch (m) {
case dev.langchain4j.data.message.SystemMessage s -> s.text();
case dev.langchain4j.data.message.UserMessage u -> u.singleText();
case AiMessage a -> a.hasToolExecutionRequests()
? "tool calls " + a.toolExecutionRequests().stream()
.map(r -> r.name() + r.arguments()).toList()
: a.text();
case dev.langchain4j.data.message.ToolExecutionResultMessage t -> t.text();
default -> m.toString();
};
}
}
@@ -0,0 +1,16 @@
package com.ankurm.lc4j.aiservices;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/**
* The whole point of an AI Service: you write an interface, LangChain4j writes the implementation.
* Annotations become the system message and the user message template.
*/
public interface SupportAgent {
@SystemMessage("You are a concise support agent for {{product}}. Answer in one sentence.")
@UserMessage("Customer asks: {{question}}")
String answer(@V("product") String product, @V("question") String question);
}
@@ -0,0 +1,195 @@
package com.ankurm.lc4j.aiservices;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AiServicesTest {
@Test
void promptTemplateBecomesSystemAndUserMessages() {
ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("Reset it from Settings > Security."));
SupportAgent agent = AiServices.create(SupportAgent.class, model);
String reply = agent.answer("Acme Vault", "How do I reset my password?");
String sent = ScriptedChatModel.render(model.lastRequest().messages());
Transcript.write("01-prompt-template.txt", "Messages LangChain4j sent to the model:\n" + sent + "\nReply: " + reply
+ "\nImplementation is a java.lang.reflect.Proxy: " + java.lang.reflect.Proxy.isProxyClass(agent.getClass()) + "\n");
assertThat(java.lang.reflect.Proxy.isProxyClass(agent.getClass())).isTrue();
assertThat(sent).contains("SYSTEM: You are a concise support agent for Acme Vault. Answer in one sentence.");
assertThat(sent).contains("USER: Customer asks: How do I reset my password?");
assertThat(reply).isEqualTo("Reset it from Settings > Security.");
}
@Test
void memoryReplaysEarlierMessagesAndIsKeptPerMemoryId() {
ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("noted (" + r.messages().size() + " messages seen)"));
ChatAgent agent = AiServices.builder(ChatAgent.class)
.chatModel(model)
.chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(4))
.build();
agent.chat("alice", "My name is Alice");
agent.chat("alice", "What is my name?");
agent.chat("bob", "Hello");
agent.chat("alice", "third alice message");
agent.chat("alice", "fourth alice message");
StringBuilder sb = new StringBuilder("Messages the model saw on each call (memory window = 4 stored messages):\n");
int i = 1;
for (var req : model.requests()) {
sb.append("call ").append(i++).append(": ").append(req.messages().size()).append(" messages\n");
}
sb.append("\nSecond call, in full:\n").append(ScriptedChatModel.render(model.requests().get(1).messages()));
Transcript.write("02-memory.txt", sb.toString());
// call 2 replays call 1 (user + ai) and adds the new user message
assertThat(model.requests().get(1).messages()).hasSize(3);
// bob has his own history, so his first call sees exactly one message
assertThat(model.requests().get(2).messages()).hasSize(1);
// The window caps what is STORED (4). The request carries the stored window plus the incoming
// user message, so the model sees 5. Observed on 1.22.0; see output/02-memory.txt.
assertThat(model.requests().get(4).messages()).hasSize(5);
}
@Test
void windowCapsStoredMessagesButTheRequestCarriesOneMore() {
MessageWindowChatMemory memory = MessageWindowChatMemory.withMaxMessages(4);
StringBuilder sb = new StringBuilder("Memory window = 4. For each call: messages the model received, messages stored at that moment, messages stored afterwards.\n");
int[] call = {0};
ScriptedChatModel model = new ScriptedChatModel(r -> {
sb.append("call ").append(++call[0]).append(": model received ").append(r.messages().size())
.append(", stored ").append(memory.messages().size());
return AiMessage.from("ok");
});
KnowledgeAgent agent = AiServices.builder(KnowledgeAgent.class).chatModel(model).chatMemory(memory).build();
int maxReceived = 0;
int maxStored = 0;
for (int i = 1; i <= 5; i++) {
agent.ask("m" + i);
sb.append(", stored afterwards ").append(memory.messages().size()).append('\n');
maxStored = Math.max(maxStored, memory.messages().size());
maxReceived = Math.max(maxReceived, model.requests().get(i - 1).messages().size());
}
// the memory class on its own, no AI Service involved
MessageWindowChatMemory alone = MessageWindowChatMemory.withMaxMessages(4);
int aloneMax = 0;
for (int i = 1; i <= 6; i++) {
alone.add(i % 2 == 1 ? UserMessage.from("U" + i) : AiMessage.from("A" + i));
aloneMax = Math.max(aloneMax, alone.messages().size());
}
sb.append("\nLargest number of messages the memory object held after any add, used on its own: ").append(aloneMax).append('\n');
Transcript.write("06-window-probe.txt", sb.toString());
assertThat(maxStored).isEqualTo(4);
assertThat(maxReceived).isEqualTo(5);
assertThat(aloneMax).isEqualTo(4);
}
@Test
void memoryIdTrap() {
ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("ok"));
Throwable failure = org.assertj.core.api.Assertions.catchThrowable(
() -> AiServices.create(MemoryIdMixed.class, model));
Transcript.write("05-memoryid-trap.txt",
"Building an interface where ONE method has @MemoryId, with no ChatMemoryProvider:\n"
+ failure.getClass().getSimpleName() + ": " + failure.getMessage() + "\n");
assertThat(failure).hasMessageContaining("@MemoryId").hasMessageContaining("ChatMemoryProvider");
}
/** One method uses @MemoryId, the other does not. The build still fails for both. */
interface MemoryIdMixed {
String plain(String message);
String remembered(@dev.langchain4j.service.MemoryId String id, @dev.langchain4j.service.UserMessage String message);
}
@Test
void toolCallIsRunByLangChain4jNotByTheModel() {
int[] step = {0};
ScriptedChatModel model = new ScriptedChatModel(r -> {
if (step[0]++ == 0) {
return AiMessage.from(dev.langchain4j.agent.tool.ToolExecutionRequest.builder()
.id("call-1").name("orderStatus").arguments("{\"orderId\":\"A-1001\"}").build());
}
return AiMessage.from("Your order is on its way and arrives on 2026-10-13.");
});
OrderTools tools = new OrderTools();
ChatAgent agent = AiServices.builder(ChatAgent.class)
.chatModel(model)
.chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(10))
.tools(tools)
// 1.22.0 logs a notice that the defaults will change; set both explicitly
.toolArgumentsErrorHandler(dev.langchain4j.service.tool.ToolArgumentsErrorHandler.sendExceptionMessageToLlm())
.toolExecutionErrorHandler(dev.langchain4j.service.tool.ToolExecutionErrorHandler.failInvocationUnlessVisibleToLlm())
.build();
String reply = agent.chat("carol", "Where is order A-1001?");
StringBuilder sb = new StringBuilder();
sb.append("Tool specification sent with the first request: ")
.append(model.requests().get(0).toolSpecifications().stream().map(t -> t.name() + " - " + t.description()).toList())
.append('\n');
sb.append("\nSecond request (after the tool ran):\n").append(ScriptedChatModel.render(model.requests().get(1).messages()));
sb.append("\nJava methods actually executed: ").append(tools.calls()).append('\n');
sb.append("Model round trips: ").append(model.requests().size()).append('\n');
sb.append("Final reply: ").append(reply).append('\n');
Transcript.write("03-tools.txt", sb.toString());
assertThat(tools.calls()).containsExactly("orderStatus(A-1001)");
assertThat(model.requests()).hasSize(2);
assertThat(reply).contains("2026-10-13");
}
@Test
void ragInjectsRetrievedTextIntoTheUserMessage() {
EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel();
InMemoryEmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>();
List<String> facts = List.of(
"Refunds are issued to the original payment method within 5 business days.",
"The Acme Vault desktop app supports Windows 11 and macOS 15.",
"Support is available Monday to Friday, 9am to 6pm IST.");
for (String f : facts) {
TextSegment seg = TextSegment.from(f);
Embedding e = embeddingModel.embed(seg).content();
store.add(e, seg);
}
ScriptedChatModel model = new ScriptedChatModel(r -> AiMessage.from("ok"));
KnowledgeAgent agent = AiServices.builder(KnowledgeAgent.class)
.chatModel(model)
.contentRetriever(EmbeddingStoreContentRetriever.builder()
.embeddingStore(store)
.embeddingModel(embeddingModel)
.maxResults(1)
.build())
.build();
agent.ask("How long do refunds take?");
String sent = ScriptedChatModel.render(model.lastRequest().messages());
Transcript.write("04-rag.txt", "What the model received after retrieval (maxResults=1):\n" + sent);
assertThat(sent).contains("How long do refunds take?");
assertThat(sent).contains("Refunds are issued to the original payment method");
assertThat(sent).doesNotContain("Windows 11");
}
}
@@ -0,0 +1,23 @@
package com.ankurm.lc4j.aiservices;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Writes what a test observed to output/NN-name.txt so every figure in the post comes from a file. */
final class Transcript {
private static final Path DIR = Path.of("output");
private Transcript() {
}
static void write(String name, String content) {
try {
Files.createDirectories(DIR);
Files.writeString(DIR.resolve(name), content);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}