山东大学软件学院项目实训-基于语言大模型的智能居家养老健康守护系统-个人博客(三)
会话持久化开发博客(一):文件存储层设计与实现
一、需求分析
在前一阶段,我们完成了用药安全审核 Agent 和情感陪伴 Agent 两个 AI 对话模块的开发。但在实际使用中发现了一个关键问题:每次对话都是独立的单轮交互,没有上下文记忆。
具体表现为:
- 老人第一次问"我在吃硝苯地平和华法林,能加阿司匹林吗?",AI 给出了详细分析
- 老人紧接着追问"那能换成氯吡格雷吗?"——AI 完全不知道上下文,无法理解"换"的是什么
同时,前端页面打开后也看不到之前的聊天记录,用户体验很差。
核心需求:
- 每次对话的消息要持久化到磁盘,重启服务不丢失
- 支持多轮对话,AI 能"记住"之前说过的内容
- 前端打开页面时能加载历史聊天记录
二、技术方案选型
为什么不用数据库?
| 方案 | 优点 | 缺点 |
|---|---|---|
| PostgreSQL | 项目已有数据库,查询灵活 | 聊天记录结构灵活,频繁改动表结构成本高 |
| Redis | 读写快 | 不适合持久化大量文本数据 |
| JSON 文件 | 零依赖、结构灵活、可读性强、易于调试 | 不适合高并发写入 |
考虑到 Agent 对话场景的特点——并发量低、单条数据量大(完整对话可达数千字)、结构可能频繁调整——选择 JSON 文件存储是最务实的方案。每个会话一个 .json 文件,直接用文本编辑器就能查看和调试。
三、数据模型设计
3.1 ChatMessage —— 单条消息
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String role; // "user" 或 "assistant"
private String content; // 消息内容
private LocalDateTime timestamp; // 发送时间
public ChatMessage(String role, String content) {
this.role = role;
this.content = content;
this.timestamp = LocalDateTime.now();
}
}
设计说明:
role字段与 OpenAI/DeepSeek 的 API 消息格式保持一致(user/assistant),这样从磁盘读取后可以直接构造 API 请求,无需额外转换。timestamp记录每条消息的精确时间,前端可用于展示时间线。
3.2 ChatSession —— 会话容器
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatSession {
private String sessionId; // UUID,会话唯一标识
private String agentType; // "medication-safety" 或 "companion"
private String mode; // 对话模式(companion/diagnosis)
private String title; // 会话标题,取首条用户消息
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ChatMessage> messages = new ArrayList<>();
public ChatSession(String sessionId, String agentType, String mode) {
this.sessionId = sessionId;
this.agentType = agentType;
this.mode = mode;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void addMessage(ChatMessage message) {
this.messages.add(message);
this.updatedAt = LocalDateTime.now();
// 自动取首条用户消息作为会话标题
if (this.title == null && "user".equals(message.getRole())) {
this.title = message.getContent().length() > 30
? message.getContent().substring(0, 30) + "..."
: message.getContent();
}
}
}
title 自动生成策略:类似微信聊天列表的"最近消息预览",取用户第一条消息的前 30 个字符作为标题。这样前端列表页不需要加载完整消息就能展示摘要。例如用户说"我在吃硝苯地平和华法林,能加阿司匹林吗?“,标题就是"我在吃硝苯地平和华法林,能加阿司匹林吗…”。
3.3 磁盘文件结构
chat-sessions/ # 根目录,在 application.yml 中可配置
├── medication-safety/ # 用药审核 Agent 的会话
│ ├── a1b2c3d4e5f6.json
│ └── f6e5d4c3b2a1.json
└── companion/ # 情感陪伴 Agent 的会话
├── 1a2b3c4d5e6f.json
└── 6f5e4d3c2b1a.json
按 agentType 分目录存放,每个会话一个文件,文件名即 sessionId。这样列举某个 Agent 的会话只需 Files.list() 一个目录,无需全局扫描。
3.4 单个会话文件示例
{
"sessionId": "a1b2c3d4e5f6789012345678",
"agentType": "medication-safety",
"mode": null,
"title": "患者(老年人)目前正在服用以下药物:【硝苯...",
"createdAt": "2026-04-18T14:30:00",
"updatedAt": "2026-04-18T14:31:15",
"messages": [
{
"role": "user",
"content": "患者(老年人)目前正在服用以下药物:【硝苯地平、华法林】,现准备加服【阿司匹林】...",
"timestamp": "2026-04-18T14:30:00"
},
{
"role": "assistant",
"content": "【审核结果】:存在风险\n\n【风险等级】:高危\n\n【详细分析】:...",
"timestamp": "2026-04-18T14:30:12"
},
{
"role": "user",
"content": "如果把阿司匹林换成氯吡格雷呢?",
"timestamp": "2026-04-18T14:31:00"
},
{
"role": "assistant",
"content": "【审核结果】:存在风险\n\n【风险等级】:中危\n\n【详细分析】:...",
"timestamp": "2026-04-18T14:31:15"
}
]
}
四、ChatSessionService 实现
4.1 配置
在 application.yml 中新增存储路径配置:
chat:
session:
storage-dir: ./chat-sessions # 会话文件存储目录
4.2 服务接口
public interface ChatSessionService {
ChatSession createSession(String agentType, String mode);
ChatSession getSession(String sessionId);
ChatSession saveSession(ChatSession session);
List<ChatSession> listSessions(String agentType);
void deleteSession(String sessionId);
}
五个方法覆盖会话的完整生命周期:创建 → 读取 → 更新 → 列举 → 删除。
4.3 实现详解
初始化:自动创建目录
@Slf4j
@Service
public class ChatSessionServiceImpl implements ChatSessionService {
@Value("${chat.session.storage-dir:./chat-sessions}")
private String storageDir;
private final ObjectMapper fileMapper;
public ChatSessionServiceImpl() {
this.fileMapper = new ObjectMapper();
this.fileMapper.registerModule(new JavaTimeModule());
this.fileMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.fileMapper.enable(SerializationFeature.INDENT_OUTPUT);
}
@PostConstruct
public void init() {
try {
Files.createDirectories(Path.of(storageDir, "medication-safety"));
Files.createDirectories(Path.of(storageDir, "companion"));
} catch (IOException e) {
log.error("创建会话存储目录失败", e);
}
}
为什么用独立的
fileMapper而不是 Spring 注入的ObjectMapper? Spring 全局的ObjectMapper配置了date-format: yyyy-MM-dd HH:mm:ss(见application.yml),会把LocalDateTime序列化为字符串。但我们需要更精确的控制:JavaTimeModule序列化为 ISO-8601 格式,INDENT_OUTPUT让文件可读。独立实例避免影响 API 的 JSON 序列化行为。
创建会话
@Override
public ChatSession createSession(String agentType, String mode) {
String sessionId = UUID.randomUUID().toString().replace("-", "");
ChatSession session = new ChatSession(sessionId, agentType, mode);
return saveSession(session);
}
使用 UUID 去掉横线后作为 sessionId(32 位十六进制字符串),既保证唯一性又适合做文件名。
保存会话(写文件)
@Override
public ChatSession saveSession(ChatSession session) {
Path dir = Path.of(storageDir, session.getAgentType());
Path file = dir.resolve(session.getSessionId() + ".json");
try {
Files.createDirectories(dir);
fileMapper.writeValue(file.toFile(), session);
return session;
} catch (IOException e) {
log.error("保存会话文件失败: {}", session.getSessionId(), e);
throw new RuntimeException("保存会话失败: " + e.getMessage());
}
}
每次保存都是全量覆写——将整个
ChatSession对象序列化后写入文件。对于对话场景(每轮最多追加两条消息),全量覆写的性能完全可以接受,且实现简单、不会产生脏数据。
读取会话
@Override
public ChatSession getSession(String sessionId) {
Path file = findSessionFile(sessionId);
if (file == null || !Files.exists(file)) {
throw new RuntimeException("会话不存在: " + sessionId);
}
try {
return fileMapper.readValue(file.toFile(), ChatSession.class);
} catch (IOException e) {
log.error("读取会话文件失败: {}", sessionId, e);
throw new RuntimeException("读取会话失败: " + e.getMessage());
}
}
列举会话(列表页)
@Override
public List<ChatSession> listSessions(String agentType) {
Path dir = Path.of(storageDir, agentType);
if (!Files.exists(dir)) {
return Collections.emptyList();
}
List<ChatSession> sessions = new ArrayList<>();
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.toString().endsWith(".json"))
.sorted(Comparator.comparing(f -> {
try {
return Files.getLastModifiedTime((Path) f);
} catch (IOException e) {
return null;
}
}).reversed())
.forEach(f -> {
try {
ChatSession session = fileMapper.readValue(
f.toFile(), ChatSession.class);
session.setMessages(null); // 列表不返回消息详情
sessions.add(session);
} catch (IOException e) {
log.warn("解析会话文件失败: {}", f.getFileName(), e);
}
});
} catch (IOException e) {
log.error("列举会话文件失败", e);
}
return sessions;
}
两个关键细节:
- 按文件修改时间倒序排列,最近活跃的会话排在最前面,符合用户直觉
session.setMessages(null)—— 列表接口不返回完整消息,只返回元数据(标题、时间等),减少数据传输量。前端点击某个会话时再调详情接口加载消息
查找会话文件(跨目录检索)
private Path findSessionFile(String sessionId) {
String[] subDirs = {"medication-safety", "companion"};
for (String sub : subDirs) {
Path file = Path.of(storageDir, sub, sessionId + ".json");
if (Files.exists(file)) {
return file;
}
}
return null;
}
由于 sessionId 是全局唯一的 UUID,但文件分布在不同 Agent 子目录中,需要遍历查找。当前只有两个 Agent,开销可忽略。
删除会话
@Override
public void deleteSession(String sessionId) {
Path file = findSessionFile(sessionId);
if (file != null && Files.exists(file)) {
try {
Files.delete(file);
} catch (IOException e) {
log.error("删除会话文件失败: {}", sessionId, e);
throw new RuntimeException("删除会话失败: " + e.getMessage());
}
}
}
五、Jackson 序列化配置详解
在 ChatSessionServiceImpl 的构造方法中,我们对 ObjectMapper 做了三项关键配置,每一项都解决一个具体问题:
public ChatSessionServiceImpl() {
this.fileMapper = new ObjectMapper();
this.fileMapper.registerModule(new JavaTimeModule());
this.fileMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.fileMapper.enable(SerializationFeature.INDENT_OUTPUT);
}
5.1 JavaTimeModule —— 支持 Java 8 时间类型
Jackson 默认不支持 LocalDateTime、LocalDate 等 Java 8 时间类型。不注册这个模块会抛出序列化异常。JavaTimeModule 来自 jackson-datatype-jsr310(Spring Boot Starter 已经引入了这个依赖)。
5.2 WRITE_DATES_AS_TIMESTAMPS: false —— ISO-8601 格式
不禁用这个特性,LocalDateTime 会被序列化为数组:
// WRITE_DATES_AS_TIMESTAMPS = true(默认)
"createdAt": [2026, 4, 18, 14, 30, 0]
// WRITE_DATES_AS_TIMESTAMPS = false
"createdAt": "2026-04-18T14:30:00"
ISO-8601 格式的优点是人类可读且前端 JavaScript 可直接解析(new Date("2026-04-18T14:30:00"))。数组形式虽然精确,但调试时难以阅读,前端解析也不方便。
5.3 INDENT_OUTPUT: true —— 格式化缩进
让生成的 JSON 文件具有缩进和换行,方便开发阶段用文本编辑器直接查看和调试。如果未来文件数量多且对磁盘空间敏感,可以在生产环境关闭此选项以减小文件体积。
5.4 为什么不用 Spring 全局 ObjectMapper?
项目 application.yml 中已经配置了全局 Jackson 格式:
spring:
jackson:
time-zone: Asia/Shanghai
date-format: yyyy-MM-dd HH:mm:ss
这个配置会让全局 ObjectMapper 将 LocalDateTime 序列化为 "2026-04-18 14:30:00" 格式。虽然也可读,但它:
- 不包含
T分隔符,不是标准 ISO-8601 - 不支持
INDENT_OUTPUT(API 响应不需要缩进,文件需要) - 改动全局配置会影响所有 REST API 的序列化行为
独立的 fileMapper 实例让文件存储和 API 响应各自独立配置,互不干扰。
六、会话生命周期与数据流
6.1 完整生命周期
创建会话 读取会话 追加消息+保存 列表展示
createSession() → getSession() → addMessage() → listSessions()
saveSession()
│ │ │ │
▼ ▼ ▼ ▼
生成 UUID 从文件反序列化 追加消息到 List 读取所有文件
写入空会话文件 返回 ChatSession 全量覆写文件 清空 messages
按时间倒序返回
6.2 单次对话的文件 I/O 时序
以用药审核 Agent 为例,一次完整的对话涉及以下文件操作:
1. 前端请求到达(携带 sessionId="abc123")
↓
2. getSession("abc123")
→ 读取 chat-sessions/medication-safety/abc123.json
→ 反序列化为 ChatSession(包含之前 N 条消息)
↓
3. session.addMessage(userMessage)
→ 内存中追加用户消息(尚未写文件)
↓
4. callDeepSeekApi(session)
→ 将 system + N+1 条消息发给 DeepSeek
→ 等待 AI 回复
↓
5. session.addMessage(assistantReply)
→ 内存中追加 AI 回复
↓
6. saveSession(session)
→ 将整个 ChatSession(N+2 条消息)序列化
→ 全量覆写 abc123.json
↓
7. 返回回复给前端
每次对话涉及 1 次文件读取 + 1 次文件写入。对于对话这种低频操作,IO 开销可以忽略不计。
6.3 与 DeepSeek API 消息格式的对齐
ChatMessage 的字段设计刻意与 OpenAI/DeepSeek 的消息格式保持一致:
ChatMessage DeepSeek API message
───────── ──────────────────
role: "user" ═══> "role": "user"
content: "..." ═══> "content": "..."
timestamp: "..." ──×── (API 不需要,仅前端展示用)
这意味着从磁盘读取会话后,只需遍历 messages 列表即可直接构造 API 请求数组,无需任何字段映射或格式转换。
七、.gitignore 配置
会话文件属于运行时数据,不应提交到 Git 仓库。在 .gitignore 中添加:
# Chat session files
chat-sessions/
八、测试验证
验证文件是否正确生成
发送一次对话请求后,检查磁盘文件:
# 查看会话文件列表
ls chat-sessions/medication-safety/
# 输出: a1b2c3d4e5f6789012345678.json
# 查看文件内容(格式化的 JSON,易于阅读)
cat chat-sessions/medication-safety/a1b2c3d4e5f6789012345678.json
验证列表接口
curl http://localhost:8080/elderlycare/api/medication-safety/sessions
预期返回:
{
"code": 200,
"msg": "success",
"data": [
{
"sessionId": "a1b2c3d4e5f6789012345678",
"agentType": "medication-safety",
"mode": null,
"title": "患者(老年人)目前正在服用以下药物:【硝苯...",
"createdAt": "2026-04-18T14:30:00",
"updatedAt": "2026-04-18T14:31:15",
"messages": null
}
]
}
注意 messages 为 null——列表接口只返回元数据。
验证详情接口
curl http://localhost:8080/elderlycare/api/medication-safety/sessions/a1b2c3d4e5f6789012345678
返回完整的聊天记录,包含所有 messages。
验证删除接口
curl -X DELETE http://localhost:8080/elderlycare/api/medication-safety/sessions/a1b2c3d4e5f6789012345678
# 验证文件已删除
ls chat-sessions/medication-safety/
# 输出: (空)
九、总结与思考
设计亮点
- 与 OpenAI 消息格式对齐:
ChatMessage的role/content字段直接对应 LLM API 的消息结构,读取后无需转换即可拼装为 API 请求。 - 读写分离的列表策略:列表接口返回元数据但清空
messages,详情接口返回完整对话。减少了不必要的 IO 和网络传输。 - 独立 ObjectMapper:避免文件序列化配置与 API 序列化配置相互干扰,各自独立演进。
- 自动标题生成:取首条用户消息作为会话标题,无需额外字段或用户手动输入。
- 全量覆写策略:简单可靠,避免了追加写入可能产生的数据不一致问题。
改进方向
- 文件锁:当前实现没有文件锁,如果同一会话被并发写入可能丢数据。可用
FileLock或synchronized加锁。 - 归档清理:会话文件会持续增长,可以增加定时清理策略(如 30 天前的自动归档或标记过期)。
- 全文搜索:基于文件的方案不支持跨会话搜索,如果有"搜索历史对话"需求,需要引入索引机制或迁移到数据库。
- 上下文窗口限制:随着对话轮次增多,发送给 DeepSeek 的消息数组会越来越长,最终超过模型的上下文窗口。后续可引入"滑动窗口"策略,只发送最近 N 轮对话。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)