LangChain4j Java AI 应用开发实战(三十):Agentic 智能客服实战(下)—— RAG + 记忆 + 流式集成
目录
前言
上篇我们打造了 Agent 的 “大脑”——一个 @AiService 接口、两组按能力类型分组的 @Tool、以及一份精心设计的系统提示词。但大脑需要身体才能行动。Agent 要真正跑起来,还需要三大基础设施:
- RAG 检索增强生成:知识型工具(KnowledgeTools)的数据来源。Agent 说"根据政策,7天内可退货"——这个"政策"是从哪来的?
- ChatMemory 对话记忆:Agent 记住"刚才说了什么"的能力。用户说"13812345678"——Agent 怎么知道这是回复上一轮"请问您的手机号?"?
- SSE 流式输出:让用户看到逐字打印的效果,而不是盯着空白页面等 5 秒。
这三者不是独立存在的,而是和上篇的 Tool Calling 紧密协同:Tool Calling 调用 RAG 检索 → 检索结果注入对话上下文 → ChatMemory 记住上下文 → Streaming 流式输出最终回复。本文逐层拆解这个协同过程。
一、RAG 在 Agentic 场景中的集成
1.1 RAG 不再是独立的问答系统
在传统的 RAG 应用中(如专栏第 12-17 篇),RAG 的定位是"用户提问 → 检索文档 → LLM 回答"的独立问答管道。但在 Agentic 架构中,RAG 的角色发生了变化:
传统 RAG:
用户提问 → ContentRetriever → LLM → 回答
Agentic 中的 RAG:
用户提问 → @AiService → LLM 推理 → 决定调用 KnowledgeTools
→ KnowledgeTools.searchReturnPolicy() → ContentRetriever → 返回文档片段
→ 片段注入 LLM 上下文 → LLM 基于片段生成回答
关键区别:RAG 不再是一个独立的问答系统,而是作为 Tool 的数据源。LLM 自主决定什么时候需要检索文档、检索什么内容、检索结果如何与数据库查询结果组合。这就是 Agentic RAG——让 AI 来决定"现在该不该查文档"。
1.2 文档准备:4 个政策文件的设计
src/main/resources/policies/ 目录下有四个 .txt 文件:
policies/
├── return-policy.txt 退换货政策(退货条件、期限、流程、退款规则)
├── warranty-policy.txt 保修政策(保修期限、范围、不保修情况、延保服务)
├── shipping-policy.txt 运费政策(退货运费承担规则、运费标准、包邮条件)
└── faq.txt 常见问题(如何退货、需要什么材料、退款到账时间)
两个设计决策值得注意:
(1)按主题分文件,而非全放一个文件
为什么拆成四个?一方面是方便增量更新——改运费政策时只需要动一个文件,不影响其他政策。另一方面,每个文件的主题明确,LLM 在检索时能获得更好的语义聚焦。如果把所有内容塞进一个文件,检索精度会下降。
(2)用 .txt 而不是 PDF 或 Markdown
教学场景用纯文本最简单——不需要 Apache Tika 这样的文档解析器。纯文本也避免了 PDF 解析时可能出现的格式丢失、乱码等问题。生产环境中如果文档是 PDF 格式,可以引入 Apache Tika 做预处理(见第 17 篇《企业知识库实战》)。
1.3 RAG 管线全流程
┌──────────────────────────────────────────────────────────────┐
│ 启动时(自动构建 RAG 索引) │
│ │
│ policies/*.txt │
│ │ │
│ ▼ │
│ DocumentLoader (FileSystemDocumentLoader) │
│ │ 加载 .txt 文件为 Document 对象 │
│ ▼ │
│ DocumentSplitter (recursive, chunk=300, overlap=50) │
│ │ 按 300 字切分成 TextSegment,相邻段 50 字重叠 │
│ ▼ │
│ EmbeddingModel (text-embedding-v4, 阿里 DashScope) │
│ │ 每个 TextSegment 转为 1024 维向量 │
│ ▼ │
│ EmbeddingStore (InMemoryEmbeddingStore) │
│ │ 向量 + 原始文本 成对存储 │
│ ▼ │
│ ✅ 索引就绪!共 N 个文档段落 │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 运行时(RAG 检索) │
│ │
│ 用户问题:"耳机能退吗?" │
│ │ │
│ ▼ │
│ ContentRetriever (EmbeddingStoreContentRetriever) │
│ │ · 用户问题 → embeddingModel.embed(question) │
│ │ · 向量相似度搜索 → 返回 top-2 段落 │
│ │ · minScore=0.7 过滤低相关度结果 │
│ ▼ │
│ 返回匹配段落: │
│ ["7天内无理由退货,商品完好即可申请...", │
│ "数码产品拆封后不支持无理由退货,除非存在质量问题..."] │
│ │ │
│ ▼ │
│ LLM 基于这些段落 + 订单信息,生成回答 │
└──────────────────────────────────────────────────────────────┘
1.4 RAGConfig 源码详解
@Configuration
@Slf4j
public class RAGConfig {
// ① 内存向量存储(教学环境)
@Bean
EmbeddingStore<TextSegment> embeddingStore() {
return new InMemoryEmbeddingStore<>();
}
// ② 内容检索器
@Bean
ContentRetriever contentRetriever(
EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel) {
return EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(2) // 每次最多返回 2 个匹配段落
.minScore(0.7) // 相似度低于 0.7 的结果直接过滤
.build();
}
// ③ 启动时自动构建 RAG 索引
@Bean
ApplicationRunner initRagIndex(
EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore) {
return args -> {
Path policiesDir = Path.of("src/main/resources/policies");
if (!policiesDir.toFile().exists()) {
log.warn("policies 目录不存在,跳过 RAG 索引构建");
return;
}
log.info("开始构建 RAG 索引...");
// 文档分割器:递归分割,每段 300 字,重叠 50 字
DocumentSplitter splitter = DocumentSplitters.recursive(300, 50);
File[] files = policiesDir.toFile()
.listFiles((dir, name) -> name.endsWith(".txt"));
if (files == null || files.length == 0) return;
for (File file : files) {
// 加载文档
Document document = FileSystemDocumentLoader.loadDocument(
file.toPath(), new TextDocumentParser());
// 切分
List<TextSegment> segments = splitter.split(document);
// 向量化 + 存储
Response<List<Embedding>> embeddings =
embeddingModel.embedAll(segments);
embeddingStore.addAll(embeddings.content(), segments);
log.info(" indexed: {} → {} segments",
file.getName(), segments.size());
}
log.info("RAG 索引构建完成!共加载 {} 个文档", files.length);
};
}
}
逐段解读三个配置项:
(1)InMemoryEmbeddingStore——教学环境的"零依赖"选择
这是 LangChain4j 提供的最简单的向量存储实现,所有数据存在内存中。它的定位是快速原型验证——不需要安装任何外部服务,启动即可用。代价是应用重启后索引丢失,需要重新构建。
对于生产环境,LangChain4j 提供了多种持久化方案:
langchain4j-redis:基于 Redis 的向量存储langchain4j-milvus:基于 Milvus 的高性能向量数据库langchain4j-elasticsearch:基于 Elasticsearch 的向量存储langchain4j-pinecone:Pinecone 托管向量数据库
切换方式非常简单——只需改 EmbeddingStore Bean 的实现,不改任何业务代码。
(2)ContentRetriever——两个关键参数的调优
maxResults(2) 和 minScore(0.7) 是 RAG 质量的两个关键旋钮:
| 参数 | 含义 | 调大 | 调小 |
|---|---|---|---|
maxResults |
最多返回几个匹配段落 | 更完整的信息,但 Token 消耗增加 | 更少 Token,但可能遗漏关键信息 |
minScore |
相似度阈值(0-1) | 更严格过滤,但可能无结果 | 更宽松,但可能混入无关内容 |
本项目的选择逻辑:
maxResults=2:客服场景中,2 个匹配段落足够回答大部分政策问题minScore=0.7:经过测试,0.7 能有效过滤掉明显不相关的内容,同时不会把相关但措辞不同的内容误过滤
实际调优时,建议先跑一批测试问题,看它们的相似度分布:
"耳机能退吗" → return-policy.txt 段落,score: 0.89 ✅ 应该命中
"运费谁出" → shipping-policy.txt 段落,score: 0.76 ✅ 应该命中
"今天天气" → 最高分段落,score: 0.32 ✅ 应该过滤
然后根据分布选择阈值——让该命中的都命中,不该命中的都过滤。
(3)ApplicationRunner——启动时自动建索引
这是一个教学便利设计。每次应用启动时自动遍历 policies/ 目录、加载所有 .txt 文件、切分、向量化、存入 EmbeddingStore。文档量小(4 个文件,几十个段落),整个过程秒级完成。
生产环境中,文档量可能是成百上千个文件,启动时全量重建就不现实了。生产方案通常有两种:
- 增量索引:仅对新文档或修改过的文档建索引
- 定时任务 + 外部索引服务:用独立的索引服务(如 Milvus)管理向量数据,应用启动时不再重建
(4)DocumentSplitter 参数:recursive(300, 50)
这是面向中文文本的推荐配置:
chunkSize=300:300 个字符一段。中文文本中,300 字大约 3-5 个段落,信息密度适中overlap=50:相邻段之间重叠 50 字。这确保在段边界附近的信息不会因为硬切分而丢失
为什么用 recursive 而不是 fixed?Recursive 分割器会优先按段落(\n\n)分割,如果段落太长再按句子(。)分割,尽可能保持语义完整性。相比之下,Fixed 分割器硬按字符数切割,可能在句子中间截断。
1.5 ContentRetriever 在 Tool 中的调用方式
回顾上篇 KnowledgeTools 的实现:
@Component("knowledgeTools")
@Slf4j
public class KnowledgeTools {
@Autowired
private ContentRetriever contentRetriever;
private List<String> retrieve(String query) {
log.info("RAG 检索: {}", query);
return contentRetriever.retrieve(new Query(query))
.stream()
.map(content -> content.textSegment().text())
.toList();
}
@Tool("查询退换货政策...")
public List<String> searchReturnPolicy(@P("用户关于退换货的问题") String query) {
return retrieve(query);
}
// ... 其他三个工具同理
}
三个要点:
- 薄封装:四个 @Tool 方法底层共用一个
retrieve()。封装的价值不在于代码复用,在于给 LLM 提供语义明确的界面——searchReturnPolicy比searchPolicy(topic="return")更直观 - 返回
List<String>:每个元素是一个匹配的文档段落。LLM 收到后会自行判断哪些段落相关、如何整合 - 日志:
log.info("RAG 检索: {}", query)让你能追踪每次检索的关键词,用于调试和优化
二、ChatMemory:多轮对话的上下文管理
2.1 对话记忆为什么对 Agent 至关重要
看一个两轮对话的例子:
第 1 轮:
User: "我刚买的耳机有杂音,能退吗?"
Agent: "请问您的手机号是多少?我需要先查一下您的订单。"
第 2 轮:
User: "13812345678"
如果没有 ChatMemory,第 2 轮 LLM 看到的只有"13812345678"——一个孤立的手机号。它完全不知道这是在回复"耳机有杂音能不能退"的上下文。有了 ChatMemory,LLM 看到的是:
历史消息:
User: "我刚买的耳机有杂音,能退吗?"
Agent: "请问您的手机号是多少?我需要先查一下您的订单。"
当前消息:
User: "13812345678"
现在 LLM 明白了:“哦,他在回答我上一轮的问题,我应该用这个手机号查订单,然后继续处理退货流程。”
ChatMemory 就是把对话历史"喂"回给 LLM 的机制。
2.2 ChatMemoryConfig 源码详解
@Configuration
public class ChatMemoryConfig {
// 线程安全的 ChatMemory 注册表
private final ConcurrentHashMap<Object, ChatMemory> memoryRegistry =
new ConcurrentHashMap<>();
@Bean
ChatMemoryProvider chatMemoryProvider() {
return memoryId -> memoryRegistry.computeIfAbsent(memoryId, id ->
MessageWindowChatMemory.builder()
.id(id)
.chatMemoryStore(new InMemoryChatMemoryStore())
.maxMessages(20) // 保留最近 20 条消息
.build()
);
}
// 供 Controller 查询历史
public List<ChatMessage> getMessages(Object memoryId) {
ChatMemory memory = memoryRegistry.get(memoryId);
return memory != null ? memory.messages() : List.of();
}
// 供 Controller 清除记忆
public void clear(Object memoryId) {
ChatMemory memory = memoryRegistry.get(memoryId);
if (memory != null) {
memory.clear();
}
}
}
逐层解读设计要点:
(1)ChatMemoryProvider:memoryId → ChatMemory 的工厂
LangChain4j 在每次收到用户消息时调用这个 Provider:
LangChain4j 框架内部:
1. 从 @MemoryId String userId 获取 "user123"
2. 调用 chatMemoryProvider.get("user123")
3. 获取该用户的 ChatMemory
4. 将 ChatMemory 中的历史消息注入 LLM 上下文
5. LLM 回复后,将本轮对话追加到 ChatMemory
你不需要手动调用 chatMemory.add(message)——LangChain4j 自动完成。
(2)ConcurrentHashMap 注册表:多用户隔离的核心
private final ConcurrentHashMap<Object, ChatMemory> memoryRegistry = new ConcurrentHashMap<>();
每个 userId 对应一个独立的 ChatMemory 实例。computeIfAbsent 保证同一 userId 总是获取到同一个 ChatMemory。不同用户之间的对话完全隔离——用户 A 的聊天不会泄露给用户 B。
(3)MessageWindowChatMemory:滑动窗口机制
MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
这个配置保留了最近 20 条消息(User + Agent 各算一条)。当对话超过 20 条时,最早的消息被自动"遗忘"。为什么是 20 条?
- 太少(比如 5 条):Agent 记不住多轮对话的上下文,用户说手机号时已经忘了上一轮说耳机的事
- 太多(比如 100 条):上下文过长,Token 消耗大,而且 LLM 对长上下文中部的信息利用效率下降
- 20 条:在客服场景中,20 条消息覆盖了大约 3-5 个回合的完整对话(用户提问 → Agent 追问 → 用户回复 → Agent 处理 → 结果反馈),是一个合理的平衡点
(4)InMemoryChatMemoryStore:教学环境的权宜之计
和 InMemoryEmbeddingStore 一样,InMemoryChatMemoryStore 是纯内存实现。应用重启后历史消息全部丢失。生产环境应该替换为持久化方案——可以参考 langchain4j-spring-boot-04-inMysqlStore 模块中基于 MySQL 的 ChatMemoryStore 实现。
2.3 Controller 层的历史查询与清除
ChatMemory 不仅是 LLM 的内部机制,前端也需要感知它:
// 查询聊天历史(前端页面加载时调用)
@GetMapping("/chat-history")
@ResponseBody
public List<Map<String, String>> getChatHistory(
@RequestParam(defaultValue = "user") String userId) {
List<ChatMessage> messages = chatMemoryConfig.getMessages(userId);
return messages.stream()
.map(msg -> Map.of(
"role", msg.type().name(),
"content", msg.toString()
))
.collect(Collectors.toList());
}
// 清除聊天记忆(用户点击"新对话"按钮时调用)
@DeleteMapping("/chat-history")
@ResponseBody
public void clearChatHistory(
@RequestParam(defaultValue = "user") String userId) {
chatMemoryConfig.clear(userId);
}
这提供了两个能力:
- 前端加载页面时,通过
GET /chat-history拉取历史消息并渲染——用户刷新页面后对话还在 - 用户想开始新话题时,通过
DELETE /chat-history清除记忆——Agent"忘记"之前的对话
三、SSE 真流式:Flux<String> 逐 Token 输出
3.1 流式输出 vs 同步响应的体验差异
这是两种模式的对比:
同步模式:
User: "能退货吗?"
⏳ 5 秒等待...
✅ "您好!根据我们的退换货政策,7天内无理由退货..."
流式模式:
User: "能退货吗?"
✅ "您" → "好" → "!" → "根" → "据" → "我" → "们" → "的" → ...
(0.5 秒后开始逐字显示)
在客服场景中,流式输出特别重要:用户感到"有人在回复我",等待焦虑大大降低。研究表明,流式输出可以将用户的感知等待时间减少 40-60%。
3.2 Flux<String> 的实现原理
上篇已经展示了 CustomerServiceAgent 返回 Flux<String>。这里展开讲原理:
// AiService 接口
Flux<String> chat(@MemoryId String userId, @UserMessage String message);
LangChain4j 检测到返回类型是 Flux<String> 后,不再使用 chatModel 执行同步请求,而是切换到 streamingChatModel 进行流式调用。底层流程:
LangChain4j 框架:
1. 检测返回类型为 Flux<String>
2. 切换到 streamingChatModel
3. 调用 streamingChatModel.generate(userMessage, ...)
4. 注册 StreamingResponseHandler 回调
5. 每收到一个 Token,回调 onNext(token)
6. langchain4j-reactor 桥接模块将回调转为 Flux 发射
7. Controller 收到 Flux,Spring MVC 自动转为 SSE 格式
关键依赖:langchain4j-reactor
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-reactor</artifactId>
</dependency>
这个模块是 LangChain4j 的"回调 → Reactive"桥接层。它把传统的 StreamingResponseHandler(onNext、onComplete、onError 回调)转换为 Project Reactor 的 Flux。没有它,Flux<String> 返回类型不会生效。
3.3 Controller SSE 端点设计
@GetMapping(value = "/chat-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ResponseBody
public Flux<String> chatStream(
@RequestParam(defaultValue = "user") String userId,
@RequestParam String message) {
return agent.chat(userId, message);
}
只有 5 行代码,但有几个关键决策:
(1)不引入 spring-boot-starter-webflux
这是很多开发者的认知误区——以为 Flux 需要 WebFlux 支持。实际上,reactor-core 已经包含在 spring-boot-starter-web 中(Spring MVC 的 Reactive 支持),Spring MVC 原生支持将 Flux 作为返回值类型。
当你设置 produces = MediaType.TEXT_EVENT_STREAM_VALUE 时,Spring MVC 自动将 Flux 的每个发射元素包装为 SSE 消息格式:
data: 您\n\n
data: 好\n\n
data: !\n\n
data: 根\n\n
...
不引入 WebFlux 意味着:更少的依赖、更简单的配置、和 Spring MVC 生态更好的兼容性。
(2)@ResponseBody 而非 @RestController
因为 CustomerServiceController 是 @Controller(不是 @RestController),所以 SSE 端点需要 @ResponseBody 注解——它告诉 Spring MVC “这个方法的返回值是 HTTP 响应体,不是视图名”。而 GET / 端点返回 "index" 字符串时,Spring MVC 会将其解析为 Thymeleaf 视图名。
(3)userId 默认值 “user”
@RequestParam(defaultValue = "user") String userId
前端在没有实现多用户切换时,默认使用 “user” 作为用户 ID。后续可以扩展为多用户系统——每个登录用户有自己的 userId,ChatMemory 自动隔离。
3.4 前端 SSE 消费
前端 index.html 使用原生 JavaScript 的 EventSource API 接收 SSE 流:
// 伪代码展示核心流程
const eventSource = new EventSource(
`/chat-stream?userId=${userId}&message=${encodeURIComponent(message)}`
);
eventSource.onmessage = (event) => {
// 每个 Token 到达时追加到 DOM
appendToChatBubble(event.data);
};
eventSource.onerror = () => {
eventSource.close();
};
EventSource 是浏览器原生 API,不需要任何第三方库。每次服务端发射一个 Flux<String> 元素,浏览器端的 onmessage 回调就被触发一次,拿到一个 Token 并追加到聊天气泡中。
四、Spring Boot 自动配置揭秘
很多人用 LangChain4j Spring Boot Starter 时只是"配了能用",但不清楚背后发生了什么。这里用本项目的配置来揭开面纱。
4.1 langchain4j-spring-boot-starter 自动做了什么
引入 Starter 后,Spring Boot 自动完成以下工作:
启动时:
① 扫描 classpath,找到所有 @AiService 接口
→ 为每个接口自动生成代理类(类似 MyBatis Mapper 代理)
② 读取 application.properties 中的模型配置
→ 自动创建 ChatModel Bean(名称:"openAiChatModel")
→ 自动创建 StreamingChatModel Bean(名称:"openAiStreamingChatModel")
→ 自动创建 EmbeddingModel Bean
③ 扫描 @Component 注解的 Bean 名称
→ 将 "transactionTools" 和 "knowledgeTools" 注册为可用工具
④ 装配 @AiService 代理:
→ 注入 chatModel、streamingChatModel、chatMemoryProvider
→ 注册 tools 列表中的工具
→ 从 classpath 加载 @SystemMessage 指定的提示词文件
运行时:
用户调用 agent.chat(userId, message) →
① ChatMemoryProvider 获取该用户的 ChatMemory
② 将历史消息 + 系统提示词 + 用户消息 + 工具列表组装为 LLM 请求
③ LLM 响应 → 判断是否需要调用工具 → 执行工具 → 结果注入上下文
④ 最终的文本回复通过 Flux<String> 流式返回
4.2 多模型配置策略
# Chat Model —— 推理 + 工具调用(同步模式)
langchain4j.open-ai.chat-model.api-key=${DEEPSEEK_API_KEY}
langchain4j.open-ai.chat-model.base-url=https://api.deepseek.com
langchain4j.open-ai.chat-model.model-name=deepseek-chat
langchain4j.open-ai.chat-model.log-requests=true
langchain4j.open-ai.chat-model.log-responses=true
# Streaming Chat Model —— 最终流式输出
langchain4j.open-ai.streaming-chat-model.api-key=${DEEPSEEK_API_KEY}
langchain4j.open-ai.streaming-chat-model.base-url=https://api.deepseek.com
langchain4j.open-ai.streaming-chat-model.model-name=deepseek-chat
# Embedding Model —— 文本向量化
langchain4j.open-ai.embedding-model.base-url=https://dashscope.aliyuncs.com/compatible-mode/v1
langchain4j.open-ai.embedding-model.api-key=${DASHSCOPE_API_KEY}
langchain4j.open-ai.embedding-model.model-name=text-embedding-v4
几个选型决策的解释:
为什么 Chat 和 Embedding 用不同的服务商?
- DeepSeek:中文对话能力强,API 价格低(约 ¥1/百万 Token),适合客服这种大量对话的场景
- 阿里 DashScope text-embedding-v4:中文向量效果好,在很多中文语义相似度评测中排名靠前
两个服务商都支持 OpenAI 兼容的 API 协议,所以都使用 langchain4j-open-ai-spring-boot-starter——一套 Starter,接入所有兼容服务。
为什么 chatModel 和 streamingChatModel 可以指向同一个模型?
是的,两者都指向 deepseek-chat。区别在于调用方式:chatModel 用于同步的推理(包括工具调用决策),streamingChatModel 用于最终的流式输出。同一个模型支持两种调用模式,不需要两个不同的模型。
为什么开启 log-requests 和 log-responses?
这是调试 Agent 行为的必备开关。开启后,你可以在日志中看到每次发给 LLM 的完整请求(包括系统提示词、历史消息、可用工具列表)和 LLM 的完整响应。在开发阶段务必开启,生产环境可以考虑关闭以节省日志存储。
4.3 H2 开发模式 vs MySQL 生产模式
# === 开发模式:H2 内存数据库 ===
spring.datasource.url=jdbc:h2:mem:customer_service_db;MODE=MySQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
# === 生产模式:MySQL(取消注释即可切换)===
# spring.datasource.url=jdbc:mysql://localhost:3306/customer_service_db?useSSL=false&...
# spring.datasource.username=root
# spring.datasource.password=root
# spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
H2 内存模式的好处:零安装启动。读者 clone 项目后直接 mvn spring-boot:run 就能跑,不需要安装 MySQL、创建数据库、执行建表脚本。data.sql 在启动时自动加载种子数据。
切换到 MySQL 只需要:取消注释 MySQL 配置、注释 H2 配置、确保 MySQL 中已创建数据库。表结构由 spring.jpa.hibernate.ddl-auto=update 自动创建。
小坑提醒:退货表名叫
returns_table而不是returns,因为 MySQL 中return是保留字。用 H2 时表名可以是任何名称,但为了 MySQL 兼容,Entity 上标注了@Table(name = "returns_table")。
五、前端界面
本文重点是后端架构,前端只做简述。templates/index.html 是一个纯内联的单页面应用——所有 CSS 和 JavaScript 都写在一个 HTML 文件中,不依赖任何外部库。
关键特性:
- 紫渐变主题:头部区域使用紫蓝色渐变,简洁专业
- SSE 逐 Token 显示:使用
EventSourceAPI 接收流式响应,追加到聊天气泡 - 用户选择器:顶部可以切换 userId,演示多用户隔离
- 历史加载:页面打开时通过
GET /chat-history加载之前的对话记录 - 清除记忆:提供"新对话"按钮,调用
DELETE /chat-history清除当前用户的记忆 - 键盘支持:Enter 发送消息
六、常见问题与避坑
坑 1:InMemoryEmbeddingStore 每次重启数据丢失
这是最常见的"开发正常、重启就废"的问题。InMemoryEmbeddingStore 的数据完全在内存中,应用重启后需要重新构建索引。
解决:开发阶段可以接受(文档少、构建快)。切换到生产时,将 EmbeddingStore Bean 替换为持久化实现(Redis/Milvus/Pinecone 等)。
坑 2:InMemoryChatMemoryStore 同样会丢失历史
同上,ChatMemory 重启后清空。用户刷新页面看到历史消息还在(从 ChatMemory 读的),但应用重启后历史全部丢失。
解决:生产环境参考 langchain4j-spring-boot-04-inMysqlStore 模块,实现基于 MySQL 的 ChatMemoryStore。langchain4j-spring-boot-starter 提供了 JpaChatMemoryStore 开箱即用。
坑 3:忘记引入 langchain4j-reactor 依赖
症状:@AiService 方法返回 Flux<String>,但实际行为是同步返回——所有 Token 一次性输出,没有流式效果。
原因:缺少 langchain4j-reactor 依赖,LangChain4j 无法将 StreamingResponseHandler 回调转为 Flux。虽然不会报错,但表现退化为同步模式。
解决:检查 pom.xml 中是否有 langchain4j-reactor 依赖。
坑 4:H2 切换到 MySQL 时表名冲突
症状:H2 上正常运行,切换到 MySQL 后 JPA 建表失败。
原因:MySQL 中某些词是保留字(如 return、order),不能直接作为表名。如果 Entity 的 @Table(name = "return"),MySQL 会报语法错误。
解决:建表时注意使用反引号或改用非保留字表名。本项目使用 @Table(name = "returns_table") 避开了这个问题。
坑 5:同时配置了 chatModel 和 streamingChatModel 但 API Key 只配了一个
症状:Agent 的工具调用(推理阶段)正常,但最终回复(流式输出阶段)报错。
原因:两个模型的配置是独立的。即使它们指向同一个服务商和同一个模型,也需要分别配置各自的 api-key。
解决:确保 langchain4j.open-ai.chat-model.api-key 和 langchain4j.open-ai.streaming-chat-model.api-key 都已正确配置。
七、进阶:从教学到生产的迁移路径
本文(及上篇)构建的是一个教学原型——架构设计是生产级的,但存储实现是教学级的。以下是从原型到生产的迁移清单:
7.1 存储持久化
| 组件 | 教学方案 | 生产方案 |
|---|---|---|
| EmbeddingStore | InMemoryEmbeddingStore | Redis (langchain4j-redis) 或 Milvus (langchain4j-milvus) |
| ChatMemoryStore | InMemoryChatMemoryStore | MySQL (JpaChatMemoryStore) 或 Redis |
| 数据库 | H2 内存 | MySQL 8.0+ |
| API Key | 环境变量 | Vault / K8s Secrets / 配置中心 |
7.2 可观测性
- LLM 调用链追踪:接入 Langfuse 或 LangSmith,追踪每次工具调用的输入输出、Token 消耗、响应时间
- 业务指标监控:对话量、工具调用成功率、用户满意度、对话轮数分布
- 告警:Token 用量异常、LLM 调用失败率上升、某个工具返回空结果的频率过高
7.3 安全加固
- API Key 加密存储:不要明文放在配置文件或环境变量中
- 输入校验与过滤:防止 Prompt 注入攻击(用户说"忽略之前的指令…")
- 速率限制:防止单个用户大量调用导致成本失控
- 多用户认证:引入 Spring Security,将 userId 与登录用户绑定
7.4 Prompt 评估与迭代
- A/B 测试:同一场景下对比两套不同 System Prompt 的客服质量
- 自动化回归测试:准备一组标准测试问题和预期结果,每次改 Prompt 后自动跑一遍
- 用户反馈闭环:收集用户满意度数据,针对性优化 Prompt
八、结语
上篇和下篇一起,构建了一套完整的 Agentic AI 应用架构。回顾两篇文章的核心内容:
| 上篇(第 29 篇) | 下篇(第 30 篇) |
|---|---|
| Agent 设计范式:1 个 @AiService + 多组 @Tool | RAG 管线:加载→切分→向量化→存储→检索 |
| 工具分组哲学:按能力类型,不按业务流程 | ChatMemory:@MemoryId 多用户隔离 |
| 系统提示词:Agent 的"人格"与行为约束 | SSE 流式:Flux<String> 逐 Token 输出 |
| LLM 自主路由:零 Java 路由代码 | Spring Boot 自动配置揭秘 |
这套架构的核心理念可以概括为:让 LLM 做决策,让 Java 做执行。LLM 决定"该做什么"(调用哪些工具、按什么顺序),Java 负责"怎么做"(查数据库、检索文档、生成回复)。这不是一个客服系统的专属架构——你可以把 TransactionTools 替换为任何操作型工具(发邮件、创建工单、调用第三方 API),把 KnowledgeTools 替换为任何 RAG 场景(产品文档、法律条文、医疗指南),Agent 的设计范式完全不变。
这个项目的 15 个 Java 类就是你下一个 AI Agent 应用的起点模板。
九、延伸阅读

🎯 更多专栏系列文章:LangChain4j Java AI应用开发实战、🔥 其他专栏可以查看博客主页
🔔 关于作者:资深程序老猿,10年+架构经验,现专注 AIGC 探索与实践。
👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏!AI 浪潮已至,愿与你同行。
更多推荐
所有评论(0)