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:
@@ -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
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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'.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user