第6篇:对话记忆怎么实现?Java版多轮上下文对话完整方案
(Java+AI落地实战系列 | 复制可用 | 生产可落地)
大家好,我是三石。
上一篇我们搞定了流式输出,让AI对话从“憋半天一次性吐答案”变成了逐字打字的丝滑体验。
但很多同学跑通之后马上发现一个致命问题: AI就像金鱼,只有7秒记忆——上一句刚让它写了单例模式,下一句说“用枚举优化一下”,它直接懵了,根本不知道你在说啥。
这不是大模型笨,是它天生“无状态”。 每一次接口调用,对它来说都是全新的对话,它根本不记得刚才和你聊过什么。
所谓的“对话记忆”,本质就是每次提问时,把历史聊天记录一起打包发给大模型。 今天我们就从最简单的本地缓存版,讲到生产可用的Redis分布式版,手把手带你实现完整的多轮上下文对话。
先上一张对比图,一眼看懂区别:
一、先搞懂核心原理:记忆到底存在哪?
很多人以为大模型会记住你的对话,其实完全不是。 大模型本身没有记忆能力,每一次调用都是独立的。
我们说的“对话记忆”,全程都是我们自己的后端服务在维护:
- 用户发第一条消息,后端存下来
- 调用大模型,把「系统人设+第一条用户消息」发过去
- 拿到AI回复,也存下来
- 用户发第二条消息,后端把「历史所有消息+第二条消息」一起打包发给大模型
- 大模型基于完整上下文生成答案
说白了就是:我们自己当“书记员”,每次把聊天记录全量复述一遍给大模型听。
这也是为什么对话越长,消耗的token越多、响应越慢——因为每次都在传完整的聊天记录。
二、基础版:本地内存实现会话记忆
先从最简单的版本开始,不用额外中间件,新建一个会话管理类就行,适合Demo、小工具、单机部署场景。
1. 会话上下文管理类
用ConcurrentHashMap存会话,key是会话ID,value是历史消息列表,同时做最大条数限制,防止token爆炸。
@Component
public class ChatContextHolder {
// 会话存储:key=sessionId,value=历史消息列表
private final Map<String, List<Message>> contextMap = new ConcurrentHashMap<>();
// 单会话最大保留10条消息(约5轮对话),避免token超限
private static final int MAX_HISTORY_COUNT = 10;
/**
* 获取指定会话的历史消息
*/
public List<Message> getHistory(String sessionId) {
return contextMap.computeIfAbsent(sessionId, k -> new ArrayList<>());
}
/**
* 添加消息到会话历史
*/
public void addMessage(String sessionId, Message message) {
List<Message> history = getHistory(sessionId);
history.add(message);
// 超出最大条数,移除最早的消息(保留system人设)
if (history.size() > MAX_HISTORY_COUNT) {
// 第0条是system,从第1条开始删
history.remove(1);
}
}
/**
* 清空指定会话
*/
public void clearSession(String sessionId) {
contextMap.remove(sessionId);
}
}
划重点:一定要保留第一条system消息! 它是AI的人设和规则,删掉之后AI就会“失控”,回答风格乱飘。裁剪历史的时候,永远动第一条。
2. 改造对话服务,支持多轮
在之前的单轮对话基础上,加入会话ID和历史消息逻辑。
@Service
public class QwenChatService {
@Value("${ai.qwen.api-key}")
private String apiKey;
@Value("${ai.qwen.model}")
private String model;
@Value("${ai.qwen.temperature}")
private Float temperature;
@Value("${ai.qwen.max-tokens}")
private Integer maxTokens;
@Autowired
private ChatContextHolder contextHolder;
/**
* 带上下文的多轮对话
* @param sessionId 会话ID,区分不同用户/对话
* @param userMessage 用户当前提问
* @return AI回答
*/
public String chatWithContext(String sessionId, String userMessage) {
// 1. 获取当前会话的历史消息
List<Message> messages = contextHolder.getHistory(sessionId);
// 2. 首次对话,初始化系统人设
if (messages.isEmpty()) {
messages.add(Message.builder()
.role(Role.SYSTEM)
.content("你是专业的Java开发助手,回答精准简洁,代码附带注释,优先给出最佳实践。")
.build());
}
// 3. 把用户当前提问加入历史
Message userMsg = Message.builder()
.role(Role.USER)
.content(userMessage)
.build();
messages.add(userMsg);
// 4. 构建请求,调用大模型
DashScope dashScope = new DashScope(apiKey);
GenerationRequest request = GenerationRequest.builder()
.model(model)
.input(MessageInput.builder().messages(messages).build())
.temperature(temperature)
.maxTokens(maxTokens)
.resultFormat(ResultFormat.MESSAGE)
.build();
try {
GenerationResult result = dashScope.call(request);
String reply = result.getOutput().getChoices().get(0).getMessage().getContent();
// 5. 把AI回复也加入历史,完成一轮对话
Message assistantMsg = Message.builder()
.role(Role.ASSISTANT)
.content(reply)
.build();
contextHolder.addMessage(sessionId, assistantMsg);
return reply;
} catch (Exception e) {
e.printStackTrace();
return "AI调用失败:" + e.getMessage();
}
}
}
3. 对外接口层
加一个sessionId参数,用来区分不同的对话。
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@Autowired
private QwenChatService qwenChatService;
/**
* 多轮对话接口
*/
@PostMapping("/sendWithContext")
public Result<String> chatWithContext(@RequestBody ChatRequest request) {
String reply = qwenChatService.chatWithContext(
request.getSessionId(),
request.getMessage()
);
return Result.success(reply);
}
/**
* 清空会话接口
*/
@PostMapping("/clear")
public Result<String> clearSession(String sessionId) {
contextHolder.clearSession(sessionId);
return Result.success("会话已清空");
}
// 请求实体
@Data
static class ChatRequest {
private String sessionId; // 会话ID,前端生成并维护
private String message;
}
}
4. 测试验证
用同一个sessionId连续发两条消息:
- 第一条:
用Java写一个单例模式 - 第二条:
用枚举方式改写一下
你会发现AI能理解上下文,基于上一轮的单例代码进行改写,多轮记忆就实现了。
三、进阶版:Redis实现分布式会话记忆
本地内存版虽然简单,但有两个致命问题:
- 服务重启,所有会话记忆全没了
- 集群部署时,请求落到不同实例上,会话就串了
生产环境集群部署,必须用Redis来存会话历史,所有实例共享数据。
1. 引入Redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.7.18</version>
</dependency>
2. Redis会话管理实现
用Redis的List结构存历史消息,设置过期时间,自动清理长时间不活跃的会话。
@Component
public class RedisChatContextHolder {
@Autowired
private StringRedisTemplate redisTemplate;
// 会话key前缀
private static final String SESSION_PREFIX = "ai:chat:session:";
// 会话过期时间:30分钟无交互自动失效
private static final long EXPIRE_MINUTES = 30;
// 最大历史消息数
private static final int MAX_HISTORY_COUNT = 10;
/**
* 获取历史消息
*/
public List<Message> getHistory(String sessionId) {
String key = SESSION_PREFIX + sessionId;
List<String> jsonList = redisTemplate.opsForList().range(key, 0, -1);
List<Message> messages = new ArrayList<>();
if (CollectionUtils.isEmpty(jsonList)) {
return messages;
}
// JSON反序列化为Message对象
for (String json : jsonList) {
messages.add(JSON.parseObject(json, Message.class));
}
return messages;
}
/**
* 添加消息并刷新过期时间
*/
public void addMessage(String sessionId, Message message) {
String key = SESSION_PREFIX + sessionId;
redisTemplate.opsForList().rightPush(key, JSON.toJSONString(message));
// 超出最大长度,移除最早的消息(保留第0条system)
Long size = redisTemplate.opsForList().size(key);
if (size != null && size > MAX_HISTORY_COUNT) {
redisTemplate.opsForList().remove(key, 1, 1);
}
// 刷新过期时间
redisTemplate.expire(key, EXPIRE_MINUTES, TimeUnit.MINUTES);
}
/**
* 清空会话
*/
public void clearSession(String sessionId) {
redisTemplate.delete(SESSION_PREFIX + sessionId);
}
}
好处很明显:
- 集群部署通用,所有实例共享会话
- 服务重启不丢数据
- 自带过期时间,自动清理无效会话,不会内存泄漏
- 可以做会话持久化,用户下次进来还能继续聊
四、新手必踩的4个坑,提前帮你避了
坑1:只存用户消息,不存AI回复
这是新手最常犯的低级错误。 只把用户的提问存进历史,AI的回答不存,结果大模型永远只看得到用户的问题,看不到自己之前的回答,上下文自然对不上。
正确做法:用户消息和AI回复必须成对存入历史,一轮对话两条消息。
坑2:历史消息不裁剪,token费用直接爆
有人觉得“记忆越长越好”,直接存几十条历史。 结果就是每次调用都传几千个token,账单蹭蹭涨,还容易触发大模型的长度限制直接报错。
解决方案:普通对话场景保留8-12条消息(4-6轮)完全够用,超出就裁剪最早的对话。
坑3:sessionId不隔离,不同用户串消息
测试的时候图省事,所有人都用同一个sessionId,结果A和B的对话串到一起了,AI回答乱七八糟。
解决方案:前端给每个用户、每个对话窗口生成唯一的sessionId(比如UUID),严格隔离。
坑4:本地版部署到集群,会话丢失
本地内存版在单机上跑得好好的,一上生产集群就时灵时不灵。 本质就是请求负载均衡到不同实例上,这个实例存了会话,下个请求落到另一个实例上就取不到了。
解决方案:生产环境直接上Redis版,不要用本地内存。
五、生产环境还能怎么优化?
1. 按用户配额限制会话数
防止恶意用户创建大量会话占用内存,每个用户最多同时保留10个会话。
2. 摘要式长记忆
如果真的需要很长的对话历史,不要全量传。 可以定期让大模型把之前的对话总结成一段摘要,用摘要代替完整历史,大幅节省token。
3. 敏感内容校验
历史消息入库前,用户提问和AI回复都要过敏感词校验,避免违规内容沉淀。
4. 流式输出+对话记忆结合
本篇为了方便理解用了同步接口,实际项目里可以把第三篇的流式输出和本篇的会话记忆结合起来,体验直接拉满。
六、本篇小结
今天我们彻底搞懂了对话记忆的本质,实现了两个版本:
- 本地内存版:简单快速,适合Demo和单机小工具
- Redis分布式版:生产可用,支持集群部署
核心逻辑永远不变:后端维护历史消息,每次全量传给大模型。 剩下的就是存储介质、裁剪策略、过期策略的工程化优化。
到这里,我们已经搞定了「基础对接→流式输出→多轮记忆」,一个完整可用的AI对话系统已经成型了。
下篇预告
对话功能虽然完整了,但还有个老大难问题: AI张嘴就胡说八道,问业务问题十句有八句是编的,根本不敢用在企业场景。
这就是RAG知识库要解决的问题——让AI只基于企业内部文档回答,从根源杜绝幻觉,而且全程可以内网部署,数据不流出。
所以下一篇我们讲:
第7篇:从零搭建本地RAG知识库,内网可用,零API费用
内容会覆盖:
- RAG核心原理,一句话讲明白
- 本地开源大模型部署
- 文档切片+向量化存储
- 相似度检索+增强生成
- 完整可复制代码
🎁 粉丝福利
本篇完整代码已整理进系列源码包,包含:
- 本地内存版会话记忆完整实现
- Redis分布式版会话记忆完整实现
- 历史消息自动裁剪逻辑
- 配套Controller接口
关注 Java-AI工程师,后台回复 系列源码 即可免费领取。
每更新一篇,我都会往资料包里新增对应源码,跟着系列就能从零搭出完整的企业级AI系统。