继上篇我们介绍了如何使用Spring AI+DeepSeek本地模型搭建AI聊天机器人,本期主要介绍如何实现聊天上下文以及聊天记录如何持久化。
上篇地址:Spring AI进阶:使用DeepSeek本地模型搭建AI聊天机器人(一)-CSDN博客
在智能对话系统的开发中,如何让AI记住用户的对话历史是一个关键挑战。想象一个电商客服场景:用户询问“我昨天看的那款手机有货吗?”——如果系统无法关联之前的对话,用户体验将大打折扣。本文将基于Spring AI框架,手把手实现一个支持多轮上下文记忆且具备Redis持久化能力的智能对话系统,助你构建真正具备记忆的AI助手。
一、ChatClient
Spring AI的ChatClient
是一个核心组件,旨在简化与大语言模型(如ChatGPT、Azure OpenAI等)的交互,支持同步和异步调用,并提供了高度抽象的API以适配多种模型提供商。
1. 核心功能与设计理念
-
统一接口:
ChatClient
通过流畅的API设计,允许开发者以一致的方式调用不同模型(如OpenAI、Azure OpenAI、Amazon Bedrock等),无需关注底层实现差异。 -
多模态支持:处理文本、图像、音频等多种输入类型,支持生成式对话、函数调用、结构化输出等场景。
-
同步与异步调用:
-
同步:通过
call()
方法直接返回完整响应,适用于简单请求。 -
异步流式响应:使用
stream()
结合Flux
实现流式传输,提升用户体验(如打字机效果)。
-
2. 核心API与使用方式
构建提示(Prompt)
-
用户消息(UserMessage)与系统消息(SystemMessage):
-
用户消息代表用户输入,系统消息用于引导模型行为(例如角色设定)。
-
支持参数化占位符,动态替换运行时变量(如
{voice}
)。
-
chatClient.prompt()
.system(sp -> sp.param("voice", "Pirate"))
.user("讲个笑话")
.call();
响应处理
-
多种返回格式:
-
字符串内容:
content()
直接返回模型生成的文本。 -
结构化对象:通过
entity()
将响应自动映射到Java类(如ActorFilms
)。 -
流式响应:使用
Flux<String>
逐步接收结果。
-
Flux<String> flux = streamingChatClient.stream("讲个笑话").content();
3. 配置与扩展
自动配置
-
默认配置:通过Spring Boot的
application.yml
设置API密钥、模型名称(如gpt-3.5-turbo
)及参数(如temperature
)。spring: ai: openai: api-key: sk-xxx chat: options: model: gpt-4 temperature: 0.7
编程式配置
-
自定义默认值:通过
ChatClient.Builder
预设系统消息、函数或参数,简化运行时调用。
/**
* @author xtwang
* @des AI聊天配置
* @date 2025/2/11 上午9:39
*/
@Configuration
public class ChatConfig {
@Bean
public ChatClient chatClient(OllamaChatModel ollamaChatModel, RedisChatMemory redisChatMemory) {
return ChatClient.builder(ollamaChatModel)
.defaultSystem("你是一个智能助手。能帮助用户解决各种问题,你很有礼貌,回答问题条理清晰。你的首选语言是中文。")
.defaultAdvisors(new MessageChatMemoryAdvisor(redisChatMemory))
.build();
}
}
检索增强生成(RAG)
-
结合向量数据库(如Azure Vector Search),通过
QuestionAnswerAdvisor
动态附加上下文到提示中,提升模型回答的准确性。
二、Spring AI Message
Message
接口的各种实现对应于 AI 模型可以处理的不同类别的消息。模型根据对话角色区分消息类别。
如下所述,这些角色实际上由 MessageType
映射。
1、角色
每个消息都被分配了一个特定的角色。这些角色对消息进行分类,为 AI 模型阐明提示词每个部分的上下文和目的。这种结构化的方法增强了与 AI 通信的细致性和有效性,因为提示词的每个部分都在交互中扮演着独特且明确的角色。
主要角色包括:
-
系统角色:指导 AI 的行为和响应风格,设置 AI 如何解释和回复输入的参数或规则。这类似于在开始对话之前向 AI 提供说明。
-
用户角色:表示用户的输入——他们对 AI 提出的问题、命令或陈述。此角色至关重要,因为它构成了 AI 响应的基础。
-
助手角色:AI 对用户输入的响应。它不仅仅是一个答案或反应,它对于保持对话的流畅性至关重要。通过跟踪 AI 之前的回复(其“助手角色”消息),系统确保交互连贯且与上下文相关。助手消息还可以包含函数工具调用请求信息。这就像 AI 中的一项特殊功能,在需要时用于执行特定功能,例如计算、获取数据或其他超出简单对话的任务。
-
工具/函数角色:工具/函数角色侧重于响应工具调用助手消息返回其他信息。
角色在 Spring AI 中表示为枚举,如下所示:
public enum MessageType {
USER("user"),
ASSISTANT("assistant"),
SYSTEM("system"),
TOOL("tool");
...
}
2、消息
那么消息也根据角色类型对应有以下几种消息类型。
- UserMessage:用户消息,指用户输入的消息,比如提问的问题。
- SystemMessage:系统限制性消息,这种消息比较特殊,权重很大,AI会优先依据SystemMessage里的内容进行回复。
- AssistantMessage:大模型回复的消息。
- FunctionMessage:函数调用消息,开发中一般使用不到,一般无需关心。
所以我们会将 UserMessage、SystemMessage、AssistantMessage 、FunctionMessage放在一个队列中,然后将整个队列发送给聊天模型,然后聊天模型就会根据整个聊天信息对回复内容进行判断。
三、ChatMemory Advisor
Spring AI 框架提供了一些内置的Advisor来增强您的 AI 交互。以下是可用Advisor的概述:
-
MessageChatMemoryAdvisor
检索记忆并将其作为消息集合添加到提示中。这种方法保持了对话历史记录的结构。请注意,并非所有 AI 模型都支持这种方法。
-
PromptChatMemoryAdvisor
检索内存中的记忆并将其合并到提示的系统文本中。
-
VectorStoreChatMemoryAdvisor
从向量数据库检索记忆并将其添加到提示的系统文本中。此Advisor可用于有效地搜索和检索大型数据集中的相关信息。
四、聊天记忆实现
通过对上述知识点的学习,我们对如何实现聊天上下文记忆有了一个初步的认知,那么接下来通过实践来实现我们的需求。创建工程及配置依赖包相关步骤请查看:Spring AI进阶:使用DeepSeek本地模型搭建AI聊天机器人(一)-CSDN博客
1、创建一个controller,引入OllamaChatModel、ChatClient、ChatMemory依赖
package com.wanganui.controller;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Map;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
/**
* @author xtwang
* @des 聊天记录实现
* @date 2025/2/6 上午10:47
*/
@RestController
@RequestMapping("/chat")
@OpenAPIDefinition(info = @Info(title = "Chat API", version = "1.0", description = "API for chat operations"))
@Tag(name = "AI聊天", description = "聊天记录实现")
public class ChatController {
private final OllamaChatModel ollamaChatModel;
private final ChatClient chatClient;
private final InMemoryChatMemory chatMemory = new InMemoryChatMemory();
public ChatController(OllamaChatModel ollamaChatModel) {
this.ollamaChatModel = ollamaChatModel;
this.chatClient = ChatClient.builder(ollamaChatModel)
.defaultSystem("你是一个生活助手,乐于帮助人解决问题,无论问什么都要礼貌回答,遇到代码问题一律回复不知道。")
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)).build();
}
}
2、编写聊天接口,设置两个参数,message(消息参数)、sessionId(会话参数),这里我们不使用流式输出,无法直接看到结果,后续会单独介绍如何在前端实现流式输出。
@Operation(summary = "普通聊天")
@GetMapping("/ai/generate")
public ResponseEntity<String> generate(@RequestParam(value = "message", defaultValue = "讲个笑话") String message, @RequestParam String sessionId) {
return ResponseEntity.ok(chatClient.prompt().user(message)
.advisors(advisorSpec -> advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 100))
.call().content());
}
Advisor参数说明:
- CHAT_MEMORY_CONVERSATION_ID_KEY:会话key,用于区分会话。
- CHAT_MEMORY_RETRIEVE_SIZE_KEY:发送给聊天模型的上下文长度。
3、编写获取聊天记录接口,用sessionId作为参数,设置返回最近10条聊天记录。
@Operation(summary = "获取聊天记录")
@GetMapping("/ai/messages")
public List<Message> getMessages(@RequestParam String sessionId) {
return chatMemory.get(sessionId, 10);
}
4、然后启动项目,测试大模型是否能记住我们发过的消息
可以看到,大模型已成功记录我们的聊天记录,并且根据聊天内容作出了回答。
5、测试调用一下聊天记录接口
可以看到我们的聊天记录已成功被记录。但是目前的聊天记录是存储在内存中,程序一旦关闭聊天记录就失效了,那么如何能让聊天记录能够永久存储呢,解决方法就是重写ChatMemory,结合Redis实现聊天记录的持久化。
五、重写ChatMemory
通过ChatMemory的源码可以看出它是一个接口,提供了将消息添加到对话、从对话中检索消息以及清除对话历史记录的方法,那么我们可以根据它的结构来实现一个自定义的ChatMemory。
1、新建一个类命名为RedisChatMemory 实现ChatMemory接口,并添加@Component注解将它注册成一个服务。引入RedisTemplate依赖并实现ChatMemory定义的方法。
package com.wanganui.chat;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* @author xtwang
* @des RedisChatMemory
*/
@Component
@RequiredArgsConstructor
public class RedisChatMemory implements ChatMemory {
private static final String REDIS_KEY_PREFIX = "chatmemory:";
private final RedisTemplate<String, Message> redisTemplate;
@Override
public void add(String conversationId, List<Message> messages) {
String key = REDIS_KEY_PREFIX + conversationId;
// 存储到 Redis
redisTemplate.opsForList().rightPushAll(key, messages);
}
@Override
public List<Message> get(String conversationId, int lastN) {
String key = REDIS_KEY_PREFIX + conversationId;
// 从 Redis 获取最新的 lastN 条消息
List<Message> serializedMessages = redisTemplate.opsForList().range(key, -lastN, -1);
if (serializedMessages != null) {
return serializedMessages;
}
return List.of();
}
@Override
public void clear(String conversationId) {
redisTemplate.delete(REDIS_KEY_PREFIX + conversationId);
}
}
2、可以看到这里的RedisTemplate定义的模版类型约束是Message,只用于Message的数据交互,那么就意味着在配置RedisTemplate时就要指定属性类型,其次Message是接口,在反序列化时需要明确指定其具体实现类,所以我们要根据Message来自定义一个序列化器。
package com.wanganui.config;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.data.redis.serializer.RedisSerializer;
import java.io.IOException;
/**
* @author xtwang
* @des 聊天消息序列化器
* @date 2025/2/11 下午2:22
*/
public class MessageRedisSerializer implements RedisSerializer<Message> {
private final ObjectMapper objectMapper;
private final JsonDeserializer<Message> messageDeserializer;
public MessageRedisSerializer(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.messageDeserializer = new JsonDeserializer<>() {
@Override
public Message deserialize(JsonParser jp, DeserializationContext ctx)
throws IOException {
ObjectNode root = jp.readValueAsTree();
String type = root.get("messageType").asText();
return switch (type) {
case "USER" -> new UserMessage(root.get("text").asText());
case "ASSISTANT" -> new AssistantMessage(root.get("text").asText());
default -> throw new UnsupportedOperationException("未知的消息类型");
};
}
};
}
@Override
public byte[] serialize(Message message) {
try {
return objectMapper.writeValueAsBytes(message);
} catch (JsonProcessingException e) {
throw new RuntimeException("无法序列化", e);
}
}
@Override
public Message deserialize(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
return messageDeserializer.deserialize(objectMapper.getFactory().createParser(bytes), objectMapper.getDeserializationContext());
} catch (Exception e) {
throw new RuntimeException("无法反序列化", e);
}
}
}
3、再将序列化器配置到RedisTemplate
package com.wanganui.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.ai.chat.messages.Message;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
/**
* @author xtwang
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Message> messageRedisTemplate(RedisConnectionFactory factory, Jackson2ObjectMapperBuilder builder) {
RedisTemplate<String, Message> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 使用String序列化器作为key的序列化方式
template.setKeySerializer(new StringRedisSerializer());
// 使用自定义的Message序列化器作为value的序列化方式
template.setValueSerializer(new MessageRedisSerializer(builder.build()));
// 设置hash类型的key和value序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new MessageRedisSerializer(builder.build()));
template.afterPropertiesSet();
return template;
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper().registerModule(new JavaTimeModule());
}
}
4、在完成上述步骤后,需要将我们的RedisChatMemory通过Spring AI的Advisor添加到聊天大模型中
package com.wanganui.chat;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author xtwang
* @des AI聊天配置
* @date 2025/2/11 上午9:39
*/
@Configuration
public class ChatConfig {
@Bean
public ChatClient chatClient(OllamaChatModel ollamaChatModel, RedisChatMemory redisChatMemory) {
return ChatClient.builder(ollamaChatModel)
.defaultSystem("你是一个智能助手。能帮助用户解决各种问题,你很有礼貌,回答问题条理清晰。你的首选语言是中文。")
.defaultAdvisors(new MessageChatMemoryAdvisor(redisChatMemory))
.build();
}
}
5、到目前我们已经完成了对ChatMemory的持久化存储,新建一个Controller,引入ChatClient、RedisChatMemory,并添加聊天及聊天记录接口
package com.wanganui.controller;
import com.github.xiaoymin.knife4j.core.util.StrUtil;
import com.wanganui.chat.ChatSession;
import com.wanganui.chat.ChatSessionService;
import com.wanganui.chat.RedisChatMemory;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.UUID;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
/**
* @author xtwang
* @des
* @date 2025/2/6 上午10:47
*/
@RestController
@RequestMapping("/api/chat")
@OpenAPIDefinition(info = @Info(title = "Chat API", version = "1.0", description = "API for chat operations"))
@Tag(name = "AI聊天", description = "集成机器人角色设定,会话存储,聊天记录持久化")
@RequiredArgsConstructor
public class AIChatController {
private final ChatClient chatClient;
private final RedisChatMemory redisChatMemory;
private final ChatSessionService chatSessionService;
@Operation(summary = "流式回答聊天")
@GetMapping(value = "/ai/generateStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ChatResponse> generateStream(@RequestParam(value = "message", defaultValue = "讲个笑话") String message, @RequestParam String sessionId, @RequestParam String userId) {
Assert.notNull(message, "message不能为空");
Assert.notNull(userId, "userId不能为空");
// 默认生成一个会话
if (StrUtil.isBlank(sessionId)) {
sessionId = UUID.randomUUID().toString();
ChatSession chatSession = new ChatSession().setSessionId(sessionId).setSessionName(message.length() >= 15 ? message.substring(0, 15) : message);
chatSessionService.saveSession(chatSession, userId);
}
String finalSessionId = sessionId;
return chatClient.prompt().user(message).advisors(advisorSpec -> advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalSessionId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 100)).stream().chatResponse();
}
@Operation(summary = "获取聊天记录")
@GetMapping("/ai/messages")
public ResponseEntity<List<Message>> getMessages(@RequestParam String sessionId) {
Assert.notNull(sessionId, "sessionId不能为空");
return ResponseEntity.ok(redisChatMemory.get(sessionId, 10));
}
@Operation(summary = "获取会话列表")
@GetMapping("/ai/sessions")
public ResponseEntity<List<ChatSession>> getSessions(@RequestParam String userId) {
Assert.notNull(userId, "userId不能为空");
return ResponseEntity.ok(chatSessionService.getSessions(userId));
}
@Operation(summary = "普通聊天")
@GetMapping(value = "/ai/generate")
public ResponseEntity<String> generate(@RequestParam(value = "message", defaultValue = "讲个笑话") String message, @RequestParam String sessionId, @RequestParam String userId) {
Assert.notNull(message, "message不能为空");
Assert.notNull(userId, "userId不能为空");
// 默认生成一个会话
if (StrUtil.isBlank(sessionId)) {
sessionId = UUID.randomUUID().toString();
ChatSession chatSession = new ChatSession().setSessionId(sessionId).setSessionName(message.length() >= 15 ? message.substring(0, 15) : message);
chatSessionService.saveSession(chatSession, userId);
}
String finalSessionId = sessionId;
return ResponseEntity.ok(chatClient.prompt().user(message).advisors(advisorSpec -> advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalSessionId).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 100)).call().content());
}
}
6、运行程序,查看结果
session的功能以及实现逻辑我就不再过多阐述,直接贴上代码:
package com.wanganui.chat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* @author xtwang
* @des 聊天会话
* @date 2025/2/11 下午3:03
*/
@Data
@Accessors(chain = true)
public class ChatSession {
@Schema(description = "会话id")
private String sessionId;
@Schema(description = "会话名称")
private String sessionName;
}
package com.wanganui.chat;
import com.alibaba.fastjson.JSON;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @author xtwang
* @des 会话服务
* @date 2025/2/11 下午3:10
*/
@Service
@RequiredArgsConstructor
public class ChatSessionService {
public static final String CHAT_SESSION_PREFIX = "chat_session:";
private final StringRedisTemplate stringRedisTemplate;
/**
* 保存会话
*
* @param chatSession 会话
*/
public void saveSession(ChatSession chatSession, String userId) {
String key = CHAT_SESSION_PREFIX + userId;
stringRedisTemplate.opsForList().leftPush(key, JSON.toJSONString(chatSession));
}
/**
* 获取会话列表
*
* @return 会话列表
*/
public List<ChatSession> getSessions(String userId) {
String key = CHAT_SESSION_PREFIX + userId;
List<String> strings = stringRedisTemplate.opsForList().range(key, 0, -1);
if (strings != null) {
return strings.stream().map(s -> JSON.parseObject(s, ChatSession.class)).toList();
}
return List.of();
}
}
六:总结
技术方案全景
1. 核心架构
Spring AI + DeepSeek模型 + Redis持久化
└─ ChatClient API层 → 记忆管理 → Redis存储 → 会话服务
2. 关键技术栈
- 多态序列化:Jackson自定义TypeResolver + 类型白名单
- 上下文管理:MessageChatMemoryAdvisor + 滑动窗口策略
- 持久化方案:Redis Hash结构 + TTL自动过期
核心实现要点
// 关键技术点代码映射
1. 多态序列化配置
ObjectMapper().activateDefaultTyping(...) // 启用类型推导
2. 记忆存储结构
redisTemplate.opsForList().rightPushAll(key, messages) // 对话历史存储
3. 上下文检索策略
redisTemplate.opsForList().range(key, -lastN, -1) // 滑动窗口读取
方案优势对比
特性 | 内存实现 | Redis持久化方案 |
---|---|---|
上下文容量 | 单机内存限制 | 支持TB级存储 |
会话恢复能力 | 重启失效 | 跨进程/设备持久化 |
性能表现 | 微秒级延迟 | <50ms读取延迟 |
扩展性 | 单节点部署 | 支持分布式架构 |
该方案通过深度整合Spring AI生态与Redis持久化能力,实现了具备企业级可用性的对话记忆系统,在保证低延迟响应的同时,为后续实现多模态对话、长期记忆演进奠定了技术基础。
参考资料:
基础架构文档:Spring AI进阶:使用DeepSeek本地模型搭建AI聊天机器人(一)-CSDN博客
Spring AI 文档:简介 :: Spring AI 参考 - Spring 框架
源码参考 :ai-chat: Spring AI 相关技术介绍
下篇我们将学习了解一下如何在前端实现聊天内容的流式输出,类似于ChatGPT的那种打字机效果,尽情期待!