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