AI 对话记录从文件存储迁移到 PostgreSQL 的实战记录

为什么要改?

我们的智慧养老系统有 5 个 AI Agent(情感陪伴、健康陪诊、健康干预、用药审核、家属辅诊),每个 Agent 都支持多轮对话。原来的对话记录存储方案是把每个会话序列化成一个 JSON 文件,按 Agent 类型分目录存放:

./chat-sessions/
  ├── companion/          # 情感陪伴
  │   ├── abc123.json
  │   └── def456.json
  └── medication-safety/  # 用药审核
      └── ghi789.json

跑了一段时间后暴露出一个严重 bug:健康陪诊、健康干预、家属辅诊这三个 Agent 的会话无法删除,也无法通过 sessionId 查询到。

原因出在 ChatSessionServiceImplfindSessionFile 方法:

private Path findSessionFile(String sessionId) {
    String[] subDirs = {"medication-safety", "companion"};  // 只写了2个!
    for (String sub : subDirs) {
        Path file = Path.of(storageDir, sub, sessionId + ".json");
        if (Files.exists(file)) {
            return file;
        }
    }
    return null;
}

硬编码了只搜索 2 个目录,后面新增的 3 个 Agent(health-companionhealth-interventionfamily-assist)的会话文件根本找不到。

与其继续修补文件方案,不如直接迁到数据库。

做了什么

1. 设计数据库表

两张表:chat_session(会话主表)+ chat_message(消息明细表)。

CREATE TABLE chat_session (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL UNIQUE,
    agent_type VARCHAR(32) NOT NULL,
    mode VARCHAR(32),
    title VARCHAR(200),
    elderly_id BIGINT,
    family_id BIGINT,
    created_at TIMESTAMP DEFAULT now(),
    updated_at TIMESTAMP DEFAULT now()
);

CREATE TABLE chat_message (
    id BIGSERIAL PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    role VARCHAR(16) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT now(),
    CONSTRAINT fk_msg_session FOREIGN KEY (session_id)
        REFERENCES chat_session(session_id) ON DELETE CASCADE
);

关键设计点:

  • session_id 用 UUID 字符串做唯一键,而不是自增 id,因为前端已经在用这个值做关联
  • 消息表通过外键关联会话,设置 ON DELETE CASCADE——删会话时自动清理所有消息
  • 新增 elderly_idfamily_id 字段,为后续"查看某个老人的所有对话记录"做准备
  • 索引覆盖了主要查询路径:按 agent_type 列表、按 session_id 查消息、按时间排序

2. 改造实体类

原来的 ChatSessionChatMessage 是纯 POJO,没有任何持久化注解。改造后加上 MyBatis-Plus 注解:

ChatSession.java 变更:

  • 新增 Long id(数据库自增主键)
  • 新增 Long elderlyIdLong familyId
  • messages 字段标记 @TableField(exist = false),不映射到表列
  • timestamp 字段统一改名 createdAt

ChatMessage.java 变更:

  • 新增 Long id(数据库自增主键)
  • 新增 String sessionId(关联字段)
  • 原来的 LocalDateTime timestamp 改名为 createdAt

3. 重写 ChatSessionServiceImpl

这是改动最大的文件。原来 200 行的文件 I/O 代码,替换为 100 行的数据库操作。

创建会话——直接 insert:

public ChatSession createSession(String agentType, String mode, Long elderlyId, Long familyId) {
    ChatSession session = new ChatSession();
    session.setSessionId(UUID.randomUUID().toString().replace("-", ""));
    session.setAgentType(agentType);
    session.setMode(mode);
    session.setElderlyId(elderlyId);
    session.setFamilyId(familyId);
    chatSessionMapper.insert(session);
    return session;
}

查询会话——一次查 session + 一次查 messages:

public ChatSession getSession(String sessionId) {
    ChatSession session = chatSessionMapper.selectOne(
        new LambdaQueryWrapper<ChatSession>()
            .eq(ChatSession::getSessionId, sessionId)
    );
    if (session == null) {
        throw new BusinessException(404, "会话不存在: " + sessionId);
    }
    List<ChatMessage> messages = chatMessageMapper.selectList(
        new LambdaQueryWrapper<ChatMessage>()
            .eq(ChatMessage::getSessionId, sessionId)
            .orderByAsc(ChatMessage::getCreatedAt)
    );
    session.setMessages(messages);
    return session;
}

保存会话——增量插入新消息,不全量覆盖:

public ChatSession saveSession(ChatSession session) {
    // 更新标题和时间
    dbSession.setTitle(session.getTitle());
    dbSession.setUpdatedAt(LocalDateTime.now());
    chatSessionMapper.updateById(dbSession);

    // 只插入新增的消息
    long existingCount = chatMessageMapper.selectCount(...);
    List<ChatMessage> newMessages = allMessages.subList((int) existingCount, allMessages.size());
    for (ChatMessage msg : newMessages) {
        msg.setSessionId(session.getSessionId());
        chatMessageMapper.insert(msg);
    }
    return session;
}

删除会话——一行搞定,不再有目录遍历问题:

public void deleteSession(String sessionId) {
    chatMessageMapper.delete(
        new LambdaQueryWrapper<ChatMessage>().eq(ChatMessage::getSessionId, sessionId)
    );
    chatSessionMapper.delete(
        new LambdaQueryWrapper<ChatSession>().eq(ChatSession::getSessionId, sessionId)
    );
}

4. 适配 5 个 Agent Service

接口签名从 createSession(String agentType, String mode) 改为 createSession(String agentType, String mode, Long elderlyId, Long familyId)

5 个 Agent 的 resolveSession 方法都需要传入 elderlyId:

// 以健康陪诊为例
session = chatSessionService.createSession("health-companion", null, request.getElderlyId(), null);

// 家属辅诊还要传 familyId
session = chatSessionService.createSession("family-assist", null, request.getElderlyId(), request.getFamilyId());

5. 清理旧配置

删除 application.yml 中的文件存储路径配置:

# 已删除
chat:
  session:
    storage-dir: ./chat-sessions

踩过的坑

坑 1:字段名 timestamp 和数据库保留字冲突

原来 ChatMessage 有个字段叫 timestamp,这在 PostgreSQL 中是类型关键字。虽然加反引号可以规避,但不如直接改名为 createdAt,和项目其他表保持一致。改名前需要确认没有任何代码直接调用 getTimestamp()

坑 2:增量保存的前提假设

增量保存靠"数据库已有消息数"对比"内存中消息列表长度"来判断哪些是新增的。这个方案的前提是消息只增不改。如果未来要做消息编辑或撤回,这套逻辑就不适用了。当前场景下够用。

坑 3:事务注解不要忘

saveSession 涉及"更新 session + 批量插入 messages",必须加 @Transactional。不然中间出错会导致 session 标题更新了但消息没插进去。

改造前后对比

维度 文件存储(改前) 数据库存储(改后)
删除会话 5 个 Agent 中有 3 个失效 全部正常
查询方式 遍历目录、解析文件名 SQL 按索引查询
按用户筛选 不支持 支持(elderly_id 索引)
并发安全 文件锁(未实现) 数据库事务保证
部署依赖 需要可写磁盘路径 只依赖数据库连接
数据一致性 无保证 CASCADE + 事务

部署步骤

  1. 在 PostgreSQL 中执行 sql/chat_session_tables.sql
  2. 重新打包部署
  3. 旧的 ./chat-sessions/ 目录可以删除(历史数据不迁移,新对话会写入数据库)
Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐