让 AI 记住你上一句:老码拆一套能落地的多轮记忆架构
记录人:老码
适用项目:Spring Boot + Spring AI + DeepSeek + MySQL 的恋爱咨询应用
相关文档:[[01-项目背景与技术选型]]、[[02-Spring AI接入DeepSeek实践]]、[[04-MySQL持久化与建表实践]]
本篇目录
- 一、开场先唠两句
- 二、先把结论摆桌上
- 三、整体架构:接口、分层、数据落在哪
- 四、记忆是怎么转起来的
- 五、代码落地:五段关键实现
- 六、为什么不直接用官方的记忆窗口
- 七、实测结果
- 八、坑点提醒
- 九、后面还能加什么
- 十、老码的总结
一、开场先唠两句
做客服系统的老同事都懂一个场景:用户第二句上来就问“我刚才不是跟你说过了吗”。你回头看聊天记录,还真说了,只是系统没记住。
大模型也一样。模型 API 不带记忆,你这一轮不把历史喂过去,它下一轮照样问“请问您遇到什么问题”。
所以这一篇讲两件事:恋爱大师这套业务怎么分层,以及“记住上一句”的代码到底写在哪。别慌,问题不大,我们一点一点拆。
二、先把结论摆桌上
三条,记住就够:
- 模型无状态,历史得你自己攒;
- 攒历史的活可以交给
MessageChatMemoryAdvisor,你只负责实现“存哪里”; - 表里存全量、喂给模型只看窗口,这两件事千万别混。
剩下的都是实现细节。
三、整体架构:接口、分层、数据落在哪
先看图,再看接口,最后看表。顺序别倒,不然容易一上来就陷进某个类的细节里。

对外就四个接口,够跑通整个闭环:
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/love/session | 建会话,返回 sessionId |
| GET | /api/love/session/{id} | 查会话信息 |
| GET | /api/love/session/{id}/history | 查完整聊天记录 |
| POST | /api/love/chat | 聊一轮 |
数据落在两张表里:love_session 记会话和阶段,love_message 按行记消息。建表语句和字段设计放在 [[04-MySQL持久化与建表实践]],这里不重复贴,免得一篇文档塞两件事。
四、记忆是怎么转起来的
这事像复诊。医生不会凭空记得你三个月前说过什么,他看的是病历本。病历写得全,判断就准;只留最近三次,那更早的细节还得靠你再讲一遍。
模型是医生,ChatMemory 是病历本,sessionId 是就诊卡号。卡号一换,等于换了个医生。
Spring AI 把这个动作拆成了清晰的步骤:

看懂这张图,多轮对话就没有神秘感了:请求前取历史、存用户消息;响应后存模型回复。三件事,一个不多。
五、代码落地:五段关键实现
5.1 装配:把记忆挂到 ChatClient 上
@Bean
public ChatClient loveChatClient(ChatModel chatModel, MySQLChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
模型还是那个模型,只是多挂了一个负责翻病历的助手。
5.2 调用:把 sessionId 传进 Advisor
String reply = loveChatClient.prompt()
.system(LovePrompts.systemPrompt(effectiveStage))
.user(message)
.advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, resolvedSessionId))
.call()
.content();
ChatMemory.CONVERSATION_ID 决定这次用哪本病历。前端不传 sessionId,服务端自动建一个新的;传了就接着往下聊。
5.3 存:只追加,不覆盖
@Override
public void add(String conversationId, List<Message> messages) {
for (Message message : messages) {
if (message == null) {
continue;
}
jdbcTemplate.update(
"INSERT INTO love_message (session_uuid, role, content) VALUES (?, ?, ?)",
conversationId,
messageRole(message),
messageText(message));
}
}
一条消息一行,role 区分用户还是助手。历史全量留在表里,查历史、做审计、以后换摘要策略,都有原料。
5.4 取:只给模型看最近 20 条
@Override
public List<Message> get(String conversationId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT role, content FROM love_message WHERE session_uuid = ? ORDER BY id DESC LIMIT ?",
conversationId,
MAX_MESSAGES);
Collections.reverse(rows);
List<Message> messages = new ArrayList<>(rows.size());
for (Map<String, Object> row : rows) {
messages.add(toMessage(
String.valueOf(row.get("role")),
String.valueOf(row.get("content"))));
}
return messages;
}
先倒序取 20 条,再翻回正序。数据库里躺着一万条也没关系,模型只吃这 20 条。
5.5 还原角色:别让模型分不清谁说的话
private Message toMessage(String role, String content) {
return switch (role.toLowerCase(Locale.ROOT)) {
case "assistant" -> new AssistantMessage(content);
case "system" -> new SystemMessage(content);
default -> new UserMessage(content);
};
}
这段看着不起眼,少了它,历史消息全变成同一种角色,模型读起来就是一笔糊涂账。
六、为什么不直接用官方的记忆窗口
Spring AI 自带 MessageWindowChatMemory 和 ChatMemoryRepository,看着现成,但有个语义要留意:Repository 的 saveAll 是覆盖整个会话,而记忆窗口会把消息列表裁剪到固定条数再存。
换句话说,直接拿它落库,聊天记录一超过窗口大小,前面的就被覆盖没了。用户想翻旧账,你只能摊手。
项目的做法是把两件事分开:数据库存全量,喂给模型看窗口。责任清楚,谁也不委屈。
七、实测结果
跑过两轮连续对话:
- 第一轮:用户说自己单身,不知道怎么自然认识喜欢的女生;
- 第二轮:用户要求“结合我上面说的情况,给一个本周可执行的小计划”;
- 模型回答里明确带上了第一轮的背景,没有重新问一遍;
- 历史接口返回 4 条消息:user、assistant 各两条。
链路是通的:写进库、下次读出来、拼进 Prompt。三步缺一步,模型就开始装傻。
八、坑点提醒
| 坑 | 后果 | 处理 |
|---|---|---|
| 窗口拍脑袋定 | 太大烧钱,太小失忆 | 演示用 20 条,正式按上下文和预算调 |
| sessionId 丢失 | 每次都是新会话 | 前端必须保存并回传 |
| 把全量历史塞给模型 | Token 飙升 | 表存全量,Prompt 只给窗口 |
| 阶段靠模型猜 | 答非所问 | 让用户选单身、恋爱、已婚 |
| 人设不写边界 | 遇到危机场景乱答 | 提示词里写明建议寻求线下专业帮助 |
| 升级 Spring AI 不回归 | 接口签名变化导致启动失败 | 升版本先看变更说明,再跑一遍对话 |
| 消息表只增不删 | 数据量越滚越大 | 提前规划归档或冷热分离 |
九、后面还能加什么
流式回复、会话列表与归档、长会话摘要、RAG 知识库、敏感内容前置识别。地基已经打好了,剩下是拧螺丝的活。
十、老码的总结
给模型加记忆,说穿了就是配一本病历本:记得全、拿得准、别一次全塞给医生。这三条做到,多轮对话就稳。
先看日志,别慌,问题不大。要是既要求记住全部历史,又要求成本不涨——这个需求得加钱。

983

被折叠的 条评论
为什么被折叠?



