个人网站建设公,wordpress kswapd0,昆山住房和城乡建设局网站,扶贫网站建设优势4.4 案例#xff1a;具有记忆能力的对话助理
在3.4.3小节中#xff0c;我们介绍了如何使用 Assistant UI 简单实现通过页面与 DeepSeek API 进行对话。本节我们介绍如何使用 Assistant UI 和 Spring AI 实现一个有状态的智能对话系统。
(文末包含工程代码) 4.4.1 前端会话状…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 useExternalStoreRuntimeThreadMessageLike({ messages, setMessages, onNew, //新消息 convertMessage, //消息格式转换 onEdit, //重新编辑消息 onReload, //消息重载 onCancel, //取消消息生成 isRunning, //消息是否生成中 adapters: { threadList: threadListAdapter, //消息列表管理 }, }); return ( AssistantRuntimeProvider runtime{runtime_ex_store} ... div classNamegrid 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 ExternalStoreThreadDataregular t.status regular ), archivedThreads: threadList.filter( (t): t is ExternalStoreThreadDataarchived 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependency创建 ai_demo 数据库application.properties 添加 mongodb 数据库连接。spring.data.mongodb.urimongodb://user:pwdlocalhost:27017/ai_demo?authSourceadmin会话列表数据存储到 MongoDB chat_thread 集合中。通过代码清单4-14创建 ChatThreadChatThread 是存储和管理 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 MongoRepositoryChatThread, String { ListChatThread findByUserName(String userName); }代码清单4-14 会话列表数据管理接口通过代码清单4-15定义会话状态保存、查询接口。RestController RequestMapping(/chat/threads) public class ChatThreadController { Autowired private ChatThreadService chatService; GetMapping public ListChatThread 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: FCThreadListProps ({ 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(ListDocument docs); } private MemoryListener listener; public void setMemoryListener(MemoryListener listener) { this.listener listener; } Override public ListDocument doSimilaritySearch(SearchRequest request) { ... ListDocument 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 FluxString chat(String message, String userName) { ListDocument 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 FluxString 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 classNameflex-1 w-full ... ThreadPrimitive.Messages components{{ UserMessage: UserMessage, EditComposer: EditComposer, AssistantMessage: AssistantMessage, }} / /ScrollArea /ThreadPrimitive.Root ); };代码清单4-22 Assistant UI 对话页面UserMessageAssistantMessage 分别加载渲染用户消息和助理消息数据。对话记忆数据在助理消息里面渲染实现为代码清单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 classNameflex 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 ListChatMessage getMessages(PathVariable String threadId) { return chatService.getMessagesByThreadId(threadId); }查询到会话列表下所有消息后需要根据会话消息上游 ID 信息把所有对话消息组成有序的会话消息记录这样前端能按照对话顺序展示历史对话。后端查询消息、还原消息如代码清单4-26所示。public ListChatMessage getMessagesByThreadId(String threadId) { ListChatMessage 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 串联 ListChatMessage 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