山东大学软件学院项目实训-基于语言大模型的智能居家养老健康守护系统-个人博客(三)
会话持久化开发博客(一):文件存储层设计与实现
一、需求分析
在前一阶段,我们完成了用药安全审核 Agent和情感陪伴 Agent两个 AI 对话模块的开发。但在实际使用中发现了一个关键问题:每次对话都是独立的单轮交互,没有上下文记忆。
具体表现为:
- 老人第一次问"我在吃硝苯地平和华法林,能加阿司匹林吗?",AI 给出了详细分析
- 老人紧接着追问"那能换成氯吡格雷吗?"——AI 完全不知道上下文,无法理解"换"的是什么
同时,前端页面打开后也看不到之前的聊天记录,用户体验很差。
核心需求:
- 每次对话的消息要持久化到磁盘,重启服务不丢失
- 支持多轮对话,AI 能"记住"之前说过的内容
- 前端打开页面时能加载历史聊天记录
二、技术方案选型
为什么不用数据库?
| 方案 | 优点 | 缺点 |
|---|---|---|
| PostgreSQL | 项目已有数据库,查询灵活 | 聊天记录结构灵活,频繁改动表结构成本高 |
| Redis | 读写快 | 不适合持久化大量文本数据 |
| JSON 文件 | 零依赖、结构灵活、可读性强、易于调试 | 不适合高并发写入 |
考虑到 Agent 对话场景的特点——并发量低、单条数据量大(完整对话可达数千字)、结构可能频繁调整——选择 JSON 文件存储是最务实的方案。每个会话一个.json文件,直接用文本编辑器就能查看和调试。
三、数据模型设计
3.1 ChatMessage —— 单条消息
@Data@NoArgsConstructor@AllArgsConstructorpublicclassChatMessage{privateStringrole;// "user" 或 "assistant"privateStringcontent;// 消息内容privateLocalDateTimetimestamp;// 发送时间publicChatMessage(Stringrole,Stringcontent){this.role=role;this.content=content;this.timestamp=LocalDateTime.now();}}设计说明:
role字段与 OpenAI/DeepSeek 的 API 消息格式保持一致(user/assistant),这样从磁盘读取后可以直接构造 API 请求,无需额外转换。timestamp记录每条消息的精确时间,前端可用于展示时间线。
3.2 ChatSession —— 会话容器
@Data@NoArgsConstructor@AllArgsConstructorpublicclassChatSession{privateStringsessionId;// UUID,会话唯一标识privateStringagentType;// "medication-safety" 或 "companion"privateStringmode;// 对话模式(companion/diagnosis)privateStringtitle;// 会话标题,取首条用户消息privateLocalDateTimecreatedAt;privateLocalDateTimeupdatedAt;privateList<ChatMessage>messages=newArrayList<>();publicChatSession(StringsessionId,StringagentType,Stringmode){this.sessionId=sessionId;this.agentType=agentType;this.mode=mode;this.createdAt=LocalDateTime.now();this.updatedAt=LocalDateTime.now();}publicvoidaddMessage(ChatMessagemessage){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 服务接口
publicinterfaceChatSessionService{ChatSessioncreateSession(StringagentType,Stringmode);ChatSessiongetSession(StringsessionId);ChatSessionsaveSession(ChatSessionsession);List<ChatSession>listSessions(StringagentType);voiddeleteSession(StringsessionId);}五个方法覆盖会话的完整生命周期:创建 → 读取 → 更新 → 列举 → 删除。
4.3 实现详解
初始化:自动创建目录
@Slf4j@ServicepublicclassChatSessionServiceImplimplementsChatSessionService{@Value("${chat.session.storage-dir:./chat-sessions}")privateStringstorageDir;privatefinalObjectMapperfileMapper;publicChatSessionServiceImpl(){this.fileMapper=newObjectMapper();this.fileMapper.registerModule(newJavaTimeModule());this.fileMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);this.fileMapper.enable(SerializationFeature.INDENT_OUTPUT);}@PostConstructpublicvoidinit(){try{Files.createDirectories(Path.of(storageDir,"medication-safety"));Files.createDirectories(Path.of(storageDir,"companion"));}catch(IOExceptione){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 序列化行为。
创建会话
@OverridepublicChatSessioncreateSession(StringagentType,Stringmode){StringsessionId=UUID.randomUUID().toString().replace("-","");ChatSessionsession=newChatSession(sessionId,agentType,mode);returnsaveSession(session);}使用 UUID 去掉横线后作为 sessionId(32 位十六进制字符串),既保证唯一性又适合做文件名。
保存会话(写文件)
@OverridepublicChatSessionsaveSession(ChatSessionsession){Pathdir=Path.of(storageDir,session.getAgentType());Pathfile=dir.resolve(session.getSessionId()+".json");try{Files.createDirectories(dir);fileMapper.writeValue(file.toFile(),session);returnsession;}catch(IOExceptione){log.error("保存会话文件失败: {}",session.getSessionId(),e);thrownewRuntimeException("保存会话失败: "+e.getMessage());}}每次保存都是全量覆写——将整个
ChatSession对象序列化后写入文件。对于对话场景(每轮最多追加两条消息),全量覆写的性能完全可以接受,且实现简单、不会产生脏数据。
读取会话
@OverridepublicChatSessiongetSession(StringsessionId){Pathfile=findSessionFile(sessionId);if(file==null||!Files.exists(file)){thrownewRuntimeException("会话不存在: "+sessionId);}try{returnfileMapper.readValue(file.toFile(),ChatSession.class);}catch(IOExceptione){log.error("读取会话文件失败: {}",sessionId,e);thrownewRuntimeException("读取会话失败: "+e.getMessage());}}列举会话(列表页)
@OverridepublicList<ChatSession>listSessions(StringagentType){Pathdir=Path.of(storageDir,agentType);if(!Files.exists(dir)){returnCollections.emptyList();}List<ChatSession>sessions=newArrayList<>();try(Stream<Path>files=Files.list(dir)){files.filter(f->f.toString().endsWith(".json")).sorted(Comparator.comparing(f->{try{returnFiles.getLastModifiedTime((Path)f);}catch(IOExceptione){returnnull;}}).reversed()).forEach(f->{try{ChatSessionsession=fileMapper.readValue(f.toFile(),ChatSession.class);session.setMessages(null);// 列表不返回消息详情sessions.add(session);}catch(IOExceptione){log.warn("解析会话文件失败: {}",f.getFileName(),e);}});}catch(IOExceptione){log.error("列举会话文件失败",e);}returnsessions;}两个关键细节:
- 按文件修改时间倒序排列,最近活跃的会话排在最前面,符合用户直觉
session.setMessages(null)—— 列表接口不返回完整消息,只返回元数据(标题、时间等),减少数据传输量。前端点击某个会话时再调详情接口加载消息
查找会话文件(跨目录检索)
privatePathfindSessionFile(StringsessionId){String[]subDirs={"medication-safety","companion"};for(Stringsub:subDirs){Pathfile=Path.of(storageDir,sub,sessionId+".json");if(Files.exists(file)){returnfile;}}returnnull;}由于 sessionId 是全局唯一的 UUID,但文件分布在不同 Agent 子目录中,需要遍历查找。当前只有两个 Agent,开销可忽略。
删除会话
@OverridepublicvoiddeleteSession(StringsessionId){Pathfile=findSessionFile(sessionId);if(file!=null&&Files.exists(file)){try{Files.delete(file);}catch(IOExceptione){log.error("删除会话文件失败: {}",sessionId,e);thrownewRuntimeException("删除会话失败: "+e.getMessage());}}}五、Jackson 序列化配置详解
在ChatSessionServiceImpl的构造方法中,我们对ObjectMapper做了三项关键配置,每一项都解决一个具体问题:
publicChatSessionServiceImpl(){this.fileMapper=newObjectMapper();this.fileMapper.registerModule(newJavaTimeModule());this.fileMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);this.fileMapper.enable(SerializationFeature.INDENT_OUTPUT);}5.1JavaTimeModule—— 支持 Java 8 时间类型
Jackson 默认不支持LocalDateTime、LocalDate等 Java 8 时间类型。不注册这个模块会抛出序列化异常。JavaTimeModule来自jackson-datatype-jsr310(Spring Boot Starter 已经引入了这个依赖)。
5.2WRITE_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.3INDENT_OUTPUT: true—— 格式化缩进
让生成的 JSON 文件具有缩进和换行,方便开发阶段用文本编辑器直接查看和调试。如果未来文件数量多且对磁盘空间敏感,可以在生产环境关闭此选项以减小文件体积。
5.4 为什么不用 Spring 全局 ObjectMapper?
项目application.yml中已经配置了全局 Jackson 格式:
spring:jackson:time-zone:Asia/Shanghaidate-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/八、测试验证
验证文件是否正确生成
发送一次对话请求后,检查磁盘文件:
# 查看会话文件列表lschat-sessions/medication-safety/# 输出: a1b2c3d4e5f6789012345678.json# 查看文件内容(格式化的 JSON,易于阅读)catchat-sessions/medication-safety/a1b2c3d4e5f6789012345678.json验证列表接口
curlhttp://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——列表接口只返回元数据。
验证详情接口
curlhttp://localhost:8080/elderlycare/api/medication-safety/sessions/a1b2c3d4e5f6789012345678返回完整的聊天记录,包含所有messages。
验证删除接口
curl-XDELETE http://localhost:8080/elderlycare/api/medication-safety/sessions/a1b2c3d4e5f6789012345678# 验证文件已删除lschat-sessions/medication-safety/# 输出: (空)九、总结与思考
设计亮点
- 与 OpenAI 消息格式对齐:
ChatMessage的role/content字段直接对应 LLM API 的消息结构,读取后无需转换即可拼装为 API 请求。 - 读写分离的列表策略:列表接口返回元数据但清空
messages,详情接口返回完整对话。减少了不必要的 IO 和网络传输。 - 独立 ObjectMapper:避免文件序列化配置与 API 序列化配置相互干扰,各自独立演进。
- 自动标题生成:取首条用户消息作为会话标题,无需额外字段或用户手动输入。
- 全量覆写策略:简单可靠,避免了追加写入可能产生的数据不一致问题。
改进方向
- 文件锁:当前实现没有文件锁,如果同一会话被并发写入可能丢数据。可用
FileLock或synchronized加锁。 - 归档清理:会话文件会持续增长,可以增加定时清理策略(如 30 天前的自动归档或标记过期)。
- 全文搜索:基于文件的方案不支持跨会话搜索,如果有"搜索历史对话"需求,需要引入索引机制或迁移到数据库。
- 上下文窗口限制:随着对话轮次增多,发送给 DeepSeek 的消息数组会越来越长,最终超过模型的上下文窗口。后续可引入"滑动窗口"策略,只发送最近 N 轮对话。
