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

一、需求分析

在前一阶段,我们完成了用药安全审核 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 轮对话。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐