news 2026/4/23 15:53:30

第4章 Spring AI 创建具有记忆能力的对话助理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第4章 Spring AI 创建具有记忆能力的对话助理

4.4 案例:具有记忆能力的对话助理

在3.4.3小节中,我们介绍了如何使用 Assistant UI 简单实现通过页面与 DeepSeek API 进行对话。本节我们介绍如何使用 Assistant UI 和 Spring AI 实现一个有状态的智能对话系统。

(文末包含工程代码)

4.4.1 前端会话状态管理

我们要实现类似 DeepSeek 官网提供的对话管理系统,需要使用 Assistant UI 提供的状态管理功能,控制、维护聊天界面所需要的所有数据状态,包括:

  • 聊天消息的列表(用户发的、大模型回复的)。
  • 当前输入是否正在生成回复消息。
  • 多线程对话管理(切换线程、新建、归档、删除)。
  • 消息的编辑、重载、取消这些操作所需的状态。
  • 消息格式转换、工具调用结果、记忆使用等附加状态。

Assistant UI ExternalStoreRuntime 适合专业的可扩展、可持久化、可定制的智能聊天生产环境场景,提供以下功能:

  • 控制消息状态。支持使用 Redux、Zustand、TanStack Query 或任意 React 状态管理库来管理消息。
  • 自定义多线程实现。构建自定义线程管理系统并使用自定义的存储方式。
  • 自定义消息格式,使用自定义后端的消息结构,通过 convertMessage 函数将自定义消息格式转换为 assistant-ui 可展示格式。
  • 外部数据同步能力。与外部数据源、数据库或多客户端进行消息同步。
  • 自定义持久化逻辑。实现自定义存储模式与缓存策略。
  • 功能可配置。前端通过控制消息编辑、再生成、取消、工具调用等是否注册给 useExternalStoreRuntime 决定功能是否开启。 如果只提供 onNew,那 UI 最基本的 “发送新消息”功能可用,但 “编辑”按钮可能不会出现。

ExternalStoreRuntime 是前端对话系统状态管理器和 Assistant UI 对话页面之间的桥梁,把对话持有的状态和 assistant-ui 的聊天 UI 连接起来。我们只需提供一个 adapter(或称 handler) 来处理 “新消息” (onNew)、 “编辑消息”(onEdit)、 “切换线程”(onSwitchThread) 等行为。 ExternalStoreRuntime 状态管理架构如图4-11。

图4-11 ExternalStoreRuntime 对话状态管理组件架构

我们用3.4.1的方式新建前端工程 springai-chat-ui-chapter4。使用代码清单4-12定义前端 UI 组件,包括新消息、重新生成消息、取消消息生成等功能,并记录消息生成状态。

const runtime_ex_store = useExternalStoreRuntime<ThreadMessageLike>({ messages, setMessages, onNew, //新消息 convertMessage, //消息格式转换 onEdit, //重新编辑消息 onReload, //消息重载 onCancel, //取消消息生成 isRunning, //消息是否生成中 adapters: { threadList: threadListAdapter, //消息列表管理 }, }); return ( <AssistantRuntimeProvider runtime={runtime_ex_store}> ... <div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-x-0 h-[calc(100dvh-4rem)]"> <ThreadList onResetUserId={resetUserId} isDarkMode={isDarkMode} /> <Thread sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} onResetUserId={resetUserId} isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} /> </div> </AssistantRuntimeProvider> );

代码清单4-12 前端会话状态管理

代码清单4-13 threadListAdapter 定义了消息列表保存、新建会话列表、消息归档等功能。

const threadListAdapter: ExternalStoreThreadListAdapter = { threadId: currentThreadId, threads: threadList.filter( (t): t is ExternalStoreThreadData<"regular"> => t.status === "regular" ), archivedThreads: threadList.filter( (t): t is ExternalStoreThreadData<"archived"> => t.status === "archived" ), onArchive: (id) => { //消息归档 setThreadList((prev) => prev.map((t) => t.id === id ? { ...t, status: "archived" } : t, ), ); }, onSwitchToNewThread: async () => { //新建会话消息 setCurrentThreadId("default") setMessages([]) }, onSwitchToThread: async (threadId) => { //切换消息列表 setCurrentThreadId(threadId); let msgs = threads.get(threadId); if (!msgs) { msgs = await fetchMessages(threadId, setThreads); } setMessages(msgs); }, };

代码清单4-13 前端会话列表管理

运行前端程序,web 浏览器访问http://localhost:3000/。前端对话欢迎页如图4-12所示。

图4-12 web 前端对话欢迎页

Assistant UI 的 Sidebar 组件做了移动端适配,支持响应式设计。移动端模式下访问效果如图4-13。

图4-13 前端对话移动端展示

4.4.2 会话列表管理

MongoDB 是一种面向文档的 NoSQL 数据库,天然适合存储大模型对话记录。它以 JSON/BSON 形式管理非结构化和半结构化数据,对长文本、用户信息、上下文数据等存储非常友好;支持灵活的 Schema,可横向扩展、吞吐量高,并具备强大的全文检索与索引能力。对大模型对话数据量持续增长、结构不固定的场景,MongoDB 能在保证写入性能的同时支持快速查询和扩展,是一个可靠且成本友好的选择。后续章节中,我们将全部使用MongoDB 8.0作为后端数据库。

本节我们结合前后端端实现 AI 助理会话列表管理,实现效果如图4-14。

图4-14 AI 助理会话列表管理

初始化后端工程引入 MongoDB maven 依赖。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency>

创建 ai_demo 数据库,application.properties 添加 mongodb 数据库连接。

spring.data.mongodb.uri=mongodb://user:pwd@localhost:27017/ai_demo?authSource=admin

会话列表数据存储到 MongoDB chat_thread 集合中。通过代码清单4-14创建 ChatThread,ChatThread 是存储和管理 AI 助理对话会话列表信息的 MongoDB 文档实体类,实现程序与数据库之间的映射。

@Data @Document(collection = "chat_thread") public class ChatThread { @Id private String id; private String status; //会话状态 private String title; //会话标题 @Field(name = "user_name") private String userName; }

ChatThread 会话列表维护会话标题和会话状态信息,会话状态有 regular(正常状态)和 archived(归档状态)两种,正常状态的会话将展示到前端页面 。通过代码清单4-14创建 ChatThreadsRepository 做数据访问层接口,负责对 ChatThread 列表进行增、删、改、查等数据库操作。

public interface ChatThreadsRepository extends MongoRepository<ChatThread, String> { List<ChatThread> findByUserName(String userName); }

代码清单4-14 会话列表数据管理接口

通过代码清单4-15定义会话状态保存、查询接口。

@RestController @RequestMapping("/chat/threads") public class ChatThreadController { @Autowired private ChatThreadService chatService; @GetMapping public List<ChatThread> getAllThreads(String userName) { return chatThreadsRepository.findByUserName(userName); } @PostMapping public ChatThread saveThread(@RequestBody ChatThread thread, @RequestParam(value = "userName") String userName) { thread.setUserName(userName); return chatService.saveThread(thread); } }

代码清单4-15 会话列表管理 API 接口

前端页面进入对话框后先加载历史对话列表,获取历史对话列表代码清单4-16。

useEffect(() => { //在组件挂载时从后端获取线程列表 const fetchThreads = async () => { try { const response = await fetch(`${apiBaseUrl}/chat/threads?userName=${userId}`); if (!response.ok) { throw new Error(`加载对话列表失败: ${response.status}`); } const data = await response.json(); setThreadList(data); } catch (error) { console.error("获取对话列表失败:", error); } }; fetchThreads(); }, []); //仅在组件首次加载时调用一次

代码清单4-16 前端页面会话列表加载

实现代码清单4-12 assistant-ui 中 ThreadList 会话列表函数,ThreadList 组件允许用户在不同会话列表之间切换。

export const ThreadList: FC<ThreadListProps> = ({ onResetUserId }) => { const [open, setOpen] = useState(false); return ( ... <ThreadListNew /> ... <ThreadListItems /> </ThreadListPrimitive.Root> </div> ); };

代码清单4-17 前端会话列表切换管理

对于新建的会话,会话 ID 初始化为 default。进入对话后,我们选取会话中第一个问题的前20个字符作为会话标题,会话 ID 以当前时间戳作为标记。代码清单4-18展示了用户将对话列表数据保存到后端的 API 接口交互。

// 如果是 default thread,创建新 thread if (threadId === "default") { const newId = `thread-${Date.now()}`; const newTitle = input.slice(0, 20); const newThread = { id: newId, status: "regular", title: newTitle, }; setThreadList((prev) => [...prev, newThread]); setCurrentThreadId(newId); threadId = newId; try { const response = await fetch(`${apiBaseUrl}/chat/threads?userName=${userId}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(newThread), }); if (!response.ok) { throw new Error(`保存失败: ${response.status}`); } const result = await response.json(); console.log("保存成功:", result); } catch (error) { console.error("保存线程失败:", error); } }

代码清单4-18 前端会话列表保存

4.4.3 对话记忆管理

VectorStoreChatMemoryAdvisor、MessageChatMemoryAdvisor 能对聊天记忆进行自动保存、查询。记忆查询架构如下图所示。

本节我们实现使用 VectorStoreChatMemoryAdvisor 将对话记忆封装到对话消息中,并将对话使用的聊天记忆展示到前端。后端返回前端展示的聊天记忆我们封装成下面数据的格式:

{ "delta": { "memories": [ { "score": 0.9573355673135825, "memory": "将“张三”添加到列表中,返回列表所有用户", "user_id": "user1", "id": "42802d46-21dd-4982-95da-c3b2e4f004ec" } ], "type": "mem0-get" }, "id": "txt-0", "type": "mem" }

一次聊天包含多条聊天记忆,向量库查询的相关记忆存储数据赋给 memories 数组,数组每个记忆元素信息包含相关性得分(score),记忆内容(memory)等属性。

ChatClient API 调用 DeepSeek 对话模型前 VectorStoreChatMemoryAdvisor 会自动查询向量库相关聊天记忆,我们需要把聊天记忆提取出来返回给前端展示。如代码清单4-19,在 CustomSimpleVectorStore 类增加一个线程安全的回调接口,Advisor 每次执行 doSimilaritySearch() 时触发,将命中结果传给调用方。

public class CustomSimpleVectorStore extends SimpleVectorStore { ... //回调接口 public interface MemoryListener { void onMemoryRetrieved(List<Document> docs); } private MemoryListener listener; public void setMemoryListener(MemoryListener listener) { this.listener = listener; } @Override public List<Document> doSimilaritySearch(SearchRequest request) { ... List<Document> result = this.store.values() ... .toList(); //触发回调 if (listener != null) { listener.onMemoryRetrieved(result); } return result; } }

代码清单4-19 记忆数据回调返回

在 ChatClient API 调用前注入 Listener,编写代码清单4-20 ChatServiceImpl,获取对话记忆数据,将记忆数据、DeepSeek 对话模型生成内容封装成图3-10 Assistant UI 消息流格式返回。

public class ChatServiceImpl implements ChatService { private final ChatClient chatClient; private final CustomSimpleVectorStore vectorStore; public ChatServiceImpl(@Qualifier("deepSeekChatClient") ChatClient chatClient, CustomSimpleVectorStore vectorStore) { this.chatClient = chatClient; this.vectorStore = vectorStore; } @Override public Flux<String> chat(String message, String userName) { List<Document> memList = new ArrayList<>(); // 设置回调 vectorStore.setMemoryListener(memList::addAll); String textId = "txt-0"; return chatClient .prompt() .user(message) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userName)) .stream() .chatResponse() .transform(stream -> stream.contextWrite(ctx -> ctx.put("memList", memList))) .doFinally(s -> vectorStore.setMemoryListener(null)) .transform(resp -> new StreamEventFluxBuilder(textId, true).build(resp) //对话消息回复内容 ) .concatWith( Flux.defer(() -> { if (memList.isEmpty()) { return Flux.empty(); } return Flux.just(buildMemoryJson(textId, memList, userName)); //对话消息历史内容 }) ).concatWith( Flux.just( com.alibaba.fastjson2.JSONObject.toJSONString(new StreamEvent("text-end", textId, null)), com.alibaba.fastjson2.JSONObject.toJSONString(new StreamEvent("finish-step")), "[DONE]" ) ); } ... }

代码清单4-20 ChatServiceImpl 获取对话记忆数据

编写代码清单4-21 对话接口 ChatController,返回前端对话调用 API。

@PostMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody JSONObject message, @RequestParam(value = "userName") String userName) { return chatService.chat(message.toString(), userName); }

代码清单4-21 ChatController 对话调用 API

前端实现代码清单4-12 Assistant UI 中的消息处理函数,包括“新消息” (onNew)、 “编辑消息”(onEdit)、 “消息重载”(onEdit),在每个方法中分别调用对应后端对话接口;同时实现 Thread 会话界面,ThreadList 组件允许用户在不同线程之间切换,ThreadList 组件管理 Thread 会话。Assistant UI 聊天界面集成了消息渲染、自动滚动、输入框、附件、响应式界面,支持定制化组合使用。Thread 组件代码清单4-22。

<ThreadPrimitive.Root> ... <ScrollArea className="flex-1 w-full"> ... <ThreadPrimitive.Messages components={{ UserMessage: UserMessage, EditComposer: EditComposer, AssistantMessage: AssistantMessage, }} /> </ScrollArea> </ThreadPrimitive.Root> ); };

代码清单4-22 Assistant UI 对话页面

UserMessage,AssistantMessage 分别加载渲染用户消息和助理消息数据。对话记忆数据在助理消息里面渲染,实现为代码清单4-23。

const AssistantMessage: FC = () => { ... return ( <MessagePrimitive.Root"> <div> <MemoryUI /> <MarkdownRenderer markdownText={markdownText} showCopyButton={true} isDarkMode={document.documentElement.classList.contains("dark")} /> </div> <AssistantActionBar /> </MessagePrimitive.Root> ); };

代码清单4-23 前端助理消息数据渲染

MemoryUI 提取助理消息里面的记忆内容,根据记忆类型,将记忆数据加载展示到前端页面,如代码清单4-24所示。

const useMemories = (): Memory[] => { const annotations = useMessage((m) => m.metadata.unstable_annotations); return useMemo( () => annotations?.filter(isMemoryAnnotation).flatMap((a) => { if (a.type === "mem0-get") { return a.memories.map((m) => ({ event: "GET", id: m.id, memory: m.memory, score: m.score, })); } throw new Error("Unexpected annotation: " + JSON.stringify(a)); }) ?? [], [annotations] ); }; export const MemoryUI: FC = () => { const memories = useMemories(); return ( <div className="flex mb-1"> <MemoryIndicator memories={memories} /> </div> ); };

代码清单4-24 MemoryUI 展示对话记忆

浏览器访问http://localhost:3000/,我们先点击“开启新的对话”,发送消息:将“张三”添加到列表中,返回列表所有用户。页面展示效果如图4-15。

图4-15 不带聊天记忆的对话效果

再“开启新的对话”,发送消息:将“李四”添加到列表中,返回列表所有用户。页面展示效果如图4-16。

图4-16 有聊天记忆的新对话

Spring AI 服务返回了包含上一个会话列表的数据,将“张三”添加到列表中和“李四”一起返回。前端加载到了记忆,展示效果如图4-17。

图4-17 对话记忆数据展示

4.4.4 对话记录管理

本节我们结合前后端实现对话管理,对话记录数据存储到 MongoDB chat_message 集合中。每条对话消息都包含上游消息 ID,用于记录每条消息对应上游消息。

@Data @Document(collection = "chat_message") public class ChatMessage { @Id private String id; @Field(name = "message_id") //消息ID private String messageId; private String role; //消息角色 private String type; //消息类型 private String content; //消息内容 @Field(name = "parent_id") private String parentId; //上游消息ID @Field(name = "thread_id") //归属会话列表ID private String threadId; @Field(name = "created_at") private String createdAt; @Field(name = "user_name") private String userName; }

后端工程添加根据会话列表 ID 保存会话记录接口。

@PostMapping(value = "/{threadId}/messages") public ChatMessage saveMessage(@PathVariable String threadId, @RequestBody ChatMessage chatMessage, @RequestParam(value = "userName") String userName) { return chatService.saveMessage(threadId, chatMessage, userName); }

一轮对话包含两条消息,前端分别把两条消息提前出来,并设置好对应的上游 ID,调用两次保存消息接口保存数据。前端消息保存代码清单4-25。

/** * 保存当前 partialAssistantMessage 及其前一条消息,并建立 parent_id 关系 * @param threadId 线程 ID * @param baseMessages 当前已有的消息列表(不包含 partialAssistantMessage) * @param partialAssistantMessage 当前生成的助手消息 */ async function saveChatMessages(threadId, baseMessages, partialAssistantMessage) { const currentAssistantMsg = { ...partialAssistantMessage }; const prevMessage = baseMessages.length > 0 ? { ...baseMessages[baseMessages.length - 1] } : null; const prevPrevMessage = baseMessages.length > 1 ? baseMessages[baseMessages.length - 2] : null; // 设置 parent_id 链接关系 if (prevMessage) { currentAssistantMsg.parent_id = prevMessage.id; prevMessage.parent_id = prevPrevMessage ? prevPrevMessage.id : null; } else { currentAssistantMsg.parent_id = null; } // 并行保存到后端 const saveRequests = []; if (prevMessage) { saveRequests.push( fetch(`${apiBaseUrl}/chat/threads/${threadId}/messages?userName=${userId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(toBackendMessageFormat(prevMessage)), }) ); } saveRequests.push( fetch(`${apiBaseUrl}/chat/threads/${threadId}/messages?userName=${userId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(toBackendMessageFormat(currentAssistantMsg)), }) ); try { await Promise.all(saveRequests); console.log("消息保存成功", { prevMessage, currentAssistantMsg }); } catch (err) { console.error("保存消息失败:", err); } }

代码清单4-25 问、答消息保存到后端

消息保存完成后,我们可以根据会话列表 ID 查询所有会话消息。后端工程添加根据会话列表 ID 查询会话记录接口。

@GetMapping("/{threadId}/messages") public List<ChatMessage> getMessages(@PathVariable String threadId) { return chatService.getMessagesByThreadId(threadId); }

查询到会话列表下所有消息后,需要根据会话消息上游 ID 信息把所有对话消息组成有序的会话消息记录,这样前端能按照对话顺序展示历史对话。后端查询消息、还原消息如代码清单4-26所示。

public List<ChatMessage> getMessagesByThreadId(String threadId) { List<ChatMessage> all = chatMessageRepository.findByThreadId(threadId); if (all.isEmpty()) return List.of(); // 找到起始消息(没有 parentId) ChatMessage root = all.stream() .filter(m -> m.getContent() == null) .findFirst() .orElse(all.get(0)); // 按 parent_id 串联 List<ChatMessage> ordered = new ArrayList<>(); ChatMessage current = root; while (current != null) { ordered.add(current); // 找下一个以当前 id 为 parentId 的消息 ChatMessage finalCurrent = current; ChatMessage next = all.stream() .filter(m -> finalCurrent.getMessageId().equals(m.getParentId())) .findFirst() .orElse(null); current = next; } return ordered; }

代码清单4-26 后端会话消息有序还原

前端在线程管理适配器 threadListAdapter onSwitchToThread 方法中查询对应线程消息,onSwitchToThread 在用户点击会话列表时触发后端接口查询。

onSwitchToThread: async (threadId) => { setCurrentThreadId(threadId); let msgs = threads.get(threadId); if (!msgs) { msgs = await fetchMessages(threadId, setThreads); } // 更新 ExternalStore 的消息状态 setMessages(msgs); }

历史对话消息查询展示效果如图4-18所示。

图4-18 历史对话数据展示

https://github.com/jssanshi/Spring-AI-with-DeepSeek/tree/master/Chapter4

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/23 10:44:15

.net4和core的差异与iis部署差异

在.NET 生态中&#xff0c;.NET 4.5&#xff08;.NET Framework&#xff09;可以 “不发布直接部署”&#xff0c;但 **.NET 6&#xff08;.NET Core / 跨平台系列&#xff09;无法绕过发布流程直接部署 **&#xff0c;核心原因是两者的运行时模型、编译方式和 IIS 集成逻辑存在…

作者头像 李华
网站建设 2026/4/22 13:27:10

终极LaTeX论文清理方案:轻松应对arXiv提交挑战

终极LaTeX论文清理方案&#xff1a;轻松应对arXiv提交挑战 【免费下载链接】arxiv-latex-cleaner arXiv LaTeX Cleaner: Easily clean the LaTeX code of your paper to submit to arXiv 项目地址: https://gitcode.com/gh_mirrors/ar/arxiv-latex-cleaner arXiv LaTeX …

作者头像 李华
网站建设 2026/4/23 13:35:27

路径质量 + 计算速度双提升!PowerMill 2025 下载安装步骤五轴加工全解析

简介 PowerMill 2025 是 最新一代 CAM 软件&#xff0c;专为汽车、精密模具等领域的复杂零件高速加工和五轴联动加工场景设计。针对这类场景中 “编程效率低、路径精度差、程序管理混乱” 的痛点&#xff0c;软件重点提升三大核心能力&#xff1a;一是刀具路径质量&#xff0c…

作者头像 李华
网站建设 2026/4/23 12:10:18

国产云桌面产品中哪些?哪家比较好用?

在数字化转型日益深入的今天&#xff0c;云桌面技术已成为政府、金融、医疗、能源等行业实现高效办公、数据安全与IT集中管理的重要工具。随着信息技术应用创新产业的发展&#xff0c;国产化云桌面解决方案备受关注。本文将为您梳理当前国产云桌面市场的主要产品特点&#xff0…

作者头像 李华
网站建设 2026/4/23 15:00:14

古文智能修复:3步让残缺文字重现光彩

古文智能修复&#xff1a;3步让残缺文字重现光彩 【免费下载链接】ancient-text-restoration Restoring ancient text using deep learning: a case study on Greek epigraphy. 项目地址: https://gitcode.com/gh_mirrors/an/ancient-text-restoration Ancient Text Res…

作者头像 李华
网站建设 2026/4/23 12:11:36

5分钟掌握AI音乐分离:SpleeterGui零基础实战教程

5分钟掌握AI音乐分离&#xff1a;SpleeterGui零基础实战教程 【免费下载链接】SpleeterGui Windows desktop front end for Spleeter - AI source separation 项目地址: https://gitcode.com/gh_mirrors/sp/SpleeterGui 还在为无法提取纯净人声而烦恼&#xff1f;Spleet…

作者头像 李华