会话持久化开发博客(一):文件存储层设计与实现

一、需求分析

在前一阶段,我们完成了用药安全审核 Agent情感陪伴 Agent 两个 AI 对话模块的开发。但在实际使用中发现了一个关键问题:每次对话都是独立的单轮交互,没有上下文记忆

具体表现为:

  • 老人第一次问"我在吃硝苯地平和华法林,能加阿司匹林吗?",AI 给出了详细分析
  • 老人紧接着追问"那能换成氯吡格雷吗?"——AI 完全不知道上下文,无法理解"换"的是什么

同时,前端页面打开后也看不到之前的聊天记录,用户体验很差。

核心需求

  1. 每次对话的消息要持久化到磁盘,重启服务不丢失
  2. 支持多轮对话,AI 能"记住"之前说过的内容
  3. 前端打开页面时能加载历史聊天记录

二、技术方案选型

为什么不用数据库?

方案优点缺点
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;
}

两个关键细节

  1. 按文件修改时间倒序排列,最近活跃的会话排在最前面,符合用户直觉
  2. 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 默认不支持 LocalDateTimeLocalDate 等 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

这个配置会让全局 ObjectMapperLocalDateTime 序列化为 "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
    }
  ]
}

注意 messagesnull——列表接口只返回元数据。

验证详情接口

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/
# 输出: (空)

九、总结与思考

设计亮点

  1. 与 OpenAI 消息格式对齐ChatMessagerole/content 字段直接对应 LLM API 的消息结构,读取后无需转换即可拼装为 API 请求。
  2. 读写分离的列表策略:列表接口返回元数据但清空 messages,详情接口返回完整对话。减少了不必要的 IO 和网络传输。
  3. 独立 ObjectMapper:避免文件序列化配置与 API 序列化配置相互干扰,各自独立演进。
  4. 自动标题生成:取首条用户消息作为会话标题,无需额外字段或用户手动输入。
  5. 全量覆写策略:简单可靠,避免了追加写入可能产生的数据不一致问题。

改进方向

  • 文件锁:当前实现没有文件锁,如果同一会话被并发写入可能丢数据。可用 FileLocksynchronized 加锁。
  • 归档清理:会话文件会持续增长,可以增加定时清理策略(如 30 天前的自动归档或标记过期)。
  • 全文搜索:基于文件的方案不支持跨会话搜索,如果有"搜索历史对话"需求,需要引入索引机制或迁移到数据库。
  • 上下文窗口限制:随着对话轮次增多,发送给 DeepSeek 的消息数组会越来越长,最终超过模型的上下文窗口。后续可引入"滑动窗口"策略,只发送最近 N 轮对话。

更多推荐