用户、会话与消息表应该如何设计

摘要

用户、会话和消息是 AI 后端最基础、也最容易被低估的数据对象。Demo 阶段只需要把一段问题和答案保存到一张表中,但进入真实项目后,系统还需要处理多用户、多租户、多端登录、会话归档、消息状态、流式生成、重新生成、失败重试、Token 统计、权限校验和数据删除。

如果数据模型设计得过于简单,后续接入知识库、Agent、消息渠道和审计能力时,往往需要大规模迁移。本文围绕"企业智能客服与知识库助手"项目,设计用户、会话、消息和消息事件的关系模型,重点说明:

  • 用户、租户、成员和会话之间如何关联;
  • 为什么会话和消息不能混在一张表;
  • 如何支持流式响应和中断;
  • 如何保存用户消息、助手消息、工具调用和错误;
  • 如何处理幂等、并发、分页和软删除;
  • 如何为后续 RAG、Agent、统计和审计预留扩展空间。

本文使用 PostgreSQL 风格 SQL,MySQL 可以根据类型和索引语法做相应调整。

一、背景与问题

1. 从一张聊天表开始会有什么问题

最初的聊天 Demo 可能只有一张表:

sql 复制代码
CREATE TABLE chat_record (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    question TEXT NOT NULL,
    answer TEXT,
    created_at TIMESTAMP NOT NULL
);

这张表可以支撑最简单的问答,但很快会遇到以下问题:

  • 一个用户无法拥有多个独立会话;
  • 无法区分用户消息和助手消息;
  • 流式输出过程中无法表示生成状态;
  • 工具调用和工具结果没有位置可存;
  • 无法保存模型、Prompt 版本和 Token;
  • 重新生成回答时无法追踪版本;
  • 群聊和多渠道消息难以接入;
  • 软删除、归档和权限边界不清晰;
  • 消息内容和统计字段不断膨胀。

更合理的思路是拆分核心对象:

text 复制代码
租户
  └─ 用户 / 成员
       └─ 会话
            └─ 消息
                 └─ 消息事件 / 工具调用 / Token 统计

2. AI 对话和普通业务记录的差异

普通业务记录通常一次提交就完成,而 AI 对话具有明显的异步和多阶段特征:

text 复制代码
用户发送问题
    ↓
创建用户消息
    ↓
创建助手消息:RUNNING
    ↓
模型开始生成
    ↓
持续写入流式事件或临时缓存
    ↓
生成完成 / 失败 / 取消
    ↓
更新助手消息最终状态

因此,消息表不能只考虑"最终文本",还要记录生成生命周期。

3. 数据模型需要支持的业务能力

本项目的数据模型至少要支持:

能力 说明
多租户 不同企业的数据必须隔离
多会话 同一用户可以创建多个主题会话
多渠道 Web、移动端、企业 IM 可共用会话模型
流式响应 助手消息要有生成中状态
重试和重新生成 同一用户问题可以产生多个回答版本
工具调用 保存工具名称、参数、结果和状态
RAG 记录引用文档和检索信息
统计分析 保存模型、Token、延迟和费用
审计删除 支持软删除、数据导出和合规清理

二、核心概念

1. Tenant、User 和 Membership

如果系统面向企业客户,不能只在用户表上增加一个 tenant_id 就结束。更灵活的模型是:

text 复制代码
Tenant
  ├─ Membership ─ User
  ├─ Conversation
  ├─ Knowledge Base
  └─ Billing / Quota

User 表示平台身份,Membership 表示用户属于哪个租户以及在租户中的角色。

这样设计可以支持:

  • 一个用户加入多个企业;
  • 同一个企业中拥有管理员、成员、访客等角色;
  • 用户离开租户但保留平台身份;
  • 企业级权限和个人资料分开管理。

2. Conversation 是业务会话,不是模型内存

会话是用户看到的业务对象,通常包含:

  • 会话标题;
  • 所属用户和租户;
  • 使用的 Agent 或应用;
  • 当前状态;
  • 最近一条消息;
  • 归档状态;
  • 创建和更新时间。

而模型内存是"本次调用要带入多少历史消息"的运行策略。两者应该分开:

text 复制代码
Conversation
  ├─ 完整消息历史
  ├─ 会话摘要
  ├─ 用户可见元数据
  └─ 权限与归档状态

Chat Memory
  ├─ 最近消息窗口
  ├─ 摘要
  └─ 当前调用上下文

一个会话可以有几百条历史消息,但某次模型调用只需要最近 10 条和一份摘要。

3. Message 的角色

消息不应该只设计成"问题"和"答案"。常见角色包括:

角色 含义
system 系统规则或运行时指令
user 用户输入
assistant 助手生成的回答
tool 工具执行返回值
developer 应用开发侧的控制消息,是否保存取决于实现
event 非模型文本事件,例如状态、引用、进度

对于前台聊天页面,通常展示 user 和 assistant;对于调试、审计和 Agent 运行记录,还需要保留 tool 和 event。

4. Message Status

助手消息应该有明确状态:

text 复制代码
PENDING
  ↓
RUNNING
  ├─ COMPLETED
  ├─ FAILED
  ├─ CANCELLED
  └─ TIMEOUT

用户消息一般创建后就是 COMPLETED,但也可以设置发送状态以支持离线同步和消息渠道重试。

5. 消息事件与最终消息

流式响应会产生大量增量片段。如果每个字符都写一行消息,会造成数据库压力和查询复杂度。

推荐将"最终消息"和"过程事件"分开:

text 复制代码
ai_message
  └─ ai_message_event
       ├─ generation_started
       ├─ content_delta
       ├─ citation
       ├─ tool_call
       ├─ usage
       └─ generation_completed

生产环境可以只保存关键事件,或者将高频 delta 放入缓存和对象存储,最终结果写入消息表。

三、工作原理

1. 创建会话

用户点击"新建会话"时,服务端执行:

text 复制代码
1. 从认证上下文获取 user_id 和 tenant_id
2. 校验用户是否属于租户
3. 校验应用或 Agent 是否可用
4. 生成不可预测的 conversation_id
5. 创建会话记录
6. 返回会话摘要信息

不要信任客户端提交的 tenant_id 和 user_id。这两个字段必须从服务端认证上下文中获得。

2. 发送一条消息

一次发送请求可以拆成以下事务和异步阶段:

text 复制代码
HTTP 请求
  ↓
校验会话归属
  ↓
幂等检查
  ↓
保存 user message
  ↓
创建 assistant message = RUNNING
  ↓
提交模型任务
  ↓
流式返回或异步通知
  ↓
完成后更新 assistant message

"保存用户消息"和"创建助手消息占位记录"可以放在一个短事务中完成,避免模型调用期间长时间占用数据库事务。

3. 流式消息状态

流式接口不应该等回答完成后才第一次创建助手消息。推荐在开始生成前就创建占位记录:

text 复制代码
assistant_message:
  status = RUNNING
  content = ""
  request_id = "req_xxx"
  started_at = 当前时间

生成过程中:

text 复制代码
客户端收到 delta
    ↓
服务端追加到内存或缓存
    ↓
定时持久化部分内容
    ↓
生成结束时一次性写入最终正文
    ↓
status = COMPLETED

如果客户端断开,不代表模型一定停止。服务端需要根据业务决定:

  • 继续生成并保存结果;
  • 主动取消模型请求;
  • 继续生成但停止向该连接发送;
  • 将状态改为 CANCELLED。

4. 多版本回答

重新生成回答时,不建议覆盖原来的助手消息。可以为同一个用户问题创建新的回答版本:

text 复制代码
user message U1
  ├─ assistant message A1:FAILED
  ├─ assistant message A2:COMPLETED
  └─ assistant message A3:COMPLETED

可以在消息表中增加:

  • parent_message_id:回答对应的用户消息;
  • attempt_no:第几次尝试;
  • is_visible:当前前台默认展示版本;
  • superseded_by:被哪个版本替代。

这样既能支持用户切换回答版本,也能保留问题排查和成本记录。

5. 工具调用和 RAG 引用

工具调用不应该把参数和结果全部拼到 content 字段中。建议使用专门字段或子表:

text 复制代码
assistant message
  └─ tool call
       ├─ tool_name
       ├─ arguments_json
       ├─ status
       ├─ result_json
       ├─ started_at
       └─ completed_at

RAG 引用也应保留结构化信息:

text 复制代码
assistant message
  └─ citation
       ├─ document_id
       ├─ chunk_id
       ├─ title
       ├─ page
       ├─ score
       └─ quoted_text

这样前端可以展示引用,后台可以分析召回质量,审计人员也能追踪答案依据。

四、实战示例

下面给出一套适合 PostgreSQL 的基础表设计。字段可以根据现有用户中心、认证系统和存储规范调整。

1. 租户表

sql 复制代码
CREATE TABLE ai_tenant (
    id              BIGSERIAL PRIMARY KEY,
    tenant_key      VARCHAR(64) NOT NULL UNIQUE,
    name            VARCHAR(200) NOT NULL,
    status          VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

tenant_key 可以作为对外展示或跨系统引用的稳定标识,内部主键仍然使用数值或 UUID。

2. 用户与租户成员关系

sql 复制代码
CREATE TABLE ai_user (
    id              BIGSERIAL PRIMARY KEY,
    external_id     VARCHAR(128) NOT NULL,
    display_name    VARCHAR(120),
    email           VARCHAR(320),
    status          VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (external_id)
);

CREATE TABLE ai_membership (
    id              BIGSERIAL PRIMARY KEY,
    tenant_id       BIGINT NOT NULL REFERENCES ai_tenant(id),
    user_id         BIGINT NOT NULL REFERENCES ai_user(id),
    role            VARCHAR(32) NOT NULL DEFAULT 'MEMBER',
    status          VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (tenant_id, user_id)
);

CREATE INDEX idx_membership_user
    ON ai_membership (user_id, status);

生产环境还可以加入:

  • 邀请状态;
  • 部门和组织路径;
  • 权限版本;
  • 最后登录时间;
  • 数据保留策略;
  • 外部身份提供方。

3. 会话表

sql 复制代码
CREATE TABLE ai_conversation (
    id                  BIGSERIAL PRIMARY KEY,
    public_id           UUID NOT NULL UNIQUE,
    tenant_id           BIGINT NOT NULL REFERENCES ai_tenant(id),
    user_id             BIGINT NOT NULL REFERENCES ai_user(id),
    app_key             VARCHAR(64) NOT NULL,
    title               VARCHAR(300),
    status              VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
    channel             VARCHAR(32) NOT NULL DEFAULT 'WEB',
    summary             TEXT,
    last_message_at     TIMESTAMPTZ,
    archived_at         TIMESTAMPTZ,
    deleted_at          TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_conversation_owner_time
    ON ai_conversation (tenant_id, user_id, updated_at DESC);

CREATE INDEX idx_conversation_channel
    ON ai_conversation (tenant_id, channel, updated_at DESC);

这里同时保留内部 id 和对外 public_id:

  • 内部主键适合数据库关联;
  • 对外 ID 不暴露连续递增规律;
  • 日志、接口和消息渠道可以使用 public_id;
  • 数据库内部仍然可以使用高效数值索引。

4. 消息表

sql 复制代码
CREATE TABLE ai_message (
    id                  BIGSERIAL PRIMARY KEY,
    public_id           UUID NOT NULL UNIQUE,
    tenant_id           BIGINT NOT NULL REFERENCES ai_tenant(id),
    conversation_id     BIGINT NOT NULL REFERENCES ai_conversation(id),
    parent_message_id   BIGINT REFERENCES ai_message(id),
    role                VARCHAR(32) NOT NULL,
    status              VARCHAR(32) NOT NULL DEFAULT 'COMPLETED',
    content             TEXT,
    content_format      VARCHAR(32) NOT NULL DEFAULT 'MARKDOWN',
    sequence_no         BIGINT NOT NULL,
    attempt_no          INTEGER NOT NULL DEFAULT 1,
    is_visible          BOOLEAN NOT NULL DEFAULT TRUE,
    request_id          VARCHAR(128),
    prompt_key          VARCHAR(128),
    prompt_version      VARCHAR(64),
    model_provider      VARCHAR(64),
    model_name          VARCHAR(128),
    input_tokens        INTEGER,
    output_tokens       INTEGER,
    total_tokens        INTEGER,
    latency_ms          BIGINT,
    first_token_ms      BIGINT,
    error_code          VARCHAR(64),
    error_message       TEXT,
    started_at          TIMESTAMPTZ,
    completed_at        TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX uk_message_conversation_sequence
    ON ai_message (conversation_id, sequence_no);

CREATE INDEX idx_message_conversation_time
    ON ai_message (conversation_id, created_at, id);

CREATE INDEX idx_message_request
    ON ai_message (tenant_id, request_id);

CREATE INDEX idx_message_parent
    ON ai_message (parent_message_id, attempt_no);

sequence_no 用于保证会话内的稳定顺序。不要只依赖 created_at 排序,因为高并发下多个消息可能拥有相同时间戳。

5. 消息事件表

sql 复制代码
CREATE TABLE ai_message_event (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES ai_tenant(id),
    conversation_id     BIGINT NOT NULL REFERENCES ai_conversation(id),
    message_id          BIGINT NOT NULL REFERENCES ai_message(id),
    event_type          VARCHAR(64) NOT NULL,
    event_seq           INTEGER NOT NULL,
    payload             JSONB NOT NULL,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (message_id, event_seq)
);

CREATE INDEX idx_message_event_message
    ON ai_message_event (message_id, event_seq);

事件类型可以包括:

text 复制代码
generation_started
content_delta
tool_call_started
tool_call_completed
citation_added
usage_recorded
generation_completed
generation_failed
generation_cancelled

不要在高频 content_delta 事件上无限制写数据库。可以设置采样、批量写入或只保留关键阶段事件。

6. 工具调用表

sql 复制代码
CREATE TABLE ai_tool_call (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES ai_tenant(id),
    conversation_id     BIGINT NOT NULL REFERENCES ai_conversation(id),
    message_id          BIGINT NOT NULL REFERENCES ai_message(id),
    tool_name           VARCHAR(128) NOT NULL,
    call_id             VARCHAR(128),
    arguments_json      JSONB,
    result_json         JSONB,
    status              VARCHAR(32) NOT NULL DEFAULT 'PENDING',
    error_code          VARCHAR(64),
    error_message       TEXT,
    started_at          TIMESTAMPTZ,
    completed_at        TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_tool_call_message
    ON ai_tool_call (message_id, created_at);

工具参数和结果可能包含隐私或密钥。生产环境应控制存储范围,必要时只保存摘要、哈希或经过脱敏的结构化结果。

7. 引用表

sql 复制代码
CREATE TABLE ai_message_citation (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL REFERENCES ai_tenant(id),
    message_id          BIGINT NOT NULL REFERENCES ai_message(id),
    document_id         VARCHAR(128) NOT NULL,
    chunk_id            VARCHAR(128),
    title               VARCHAR(500),
    source_uri          TEXT,
    page_no             INTEGER,
    score               NUMERIC(8, 6),
    quoted_text         TEXT,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_citation_message
    ON ai_message_citation (message_id, id);

8. Java 实体和状态枚举

java 复制代码
public enum MessageRole {
    SYSTEM,
    USER,
    ASSISTANT,
    TOOL,
    EVENT
}

public enum MessageStatus {
    PENDING,
    RUNNING,
    COMPLETED,
    FAILED,
    CANCELLED,
    TIMEOUT
}

请求对象不要直接暴露数据库实体:

java 复制代码
public record SendMessageRequest(
        String conversationId,
        String content,
        String clientRequestId
) {
}

public record SendMessageResponse(
        String messageId,
        String conversationId,
        String status
) {
}

这样可以避免客户端直接传递 tenantId、userId、modelProvider 和消息状态。

9. 发送消息的服务流程

java 复制代码
@Transactional
public PreparedMessage prepareMessage(
        CurrentUser currentUser,
        String publicConversationId,
        String content,
        String clientRequestId) {

    Conversation conversation = conversationRepository
            .findOwnedConversation(
                    currentUser.tenantId(),
                    currentUser.userId(),
                    publicConversationId
            )
            .orElseThrow(() -> new NotFoundException("conversation"));

    Optional<Message> existing = messageRepository
            .findByRequestId(
                    currentUser.tenantId(),
                    clientRequestId
            );
    if (existing.isPresent()) {
        return PreparedMessage.from(existing.get());
    }

    long nextSequence = messageRepository
            .nextSequenceForUpdate(conversation.id());

    Message userMessage = messageRepository.save(
            Message.user(
                    currentUser.tenantId(),
                    conversation.id(),
                    nextSequence,
                    content,
                    clientRequestId
            )
    );

    Message assistantMessage = messageRepository.save(
            Message.running(
                    currentUser.tenantId(),
                    conversation.id(),
                    nextSequence + 1,
                    userMessage.id()
            )
    );

    conversationRepository.touch(conversation.id());
    return new PreparedMessage(userMessage, assistantMessage);
}

这里的关键点是:

  • 从认证上下文获得租户和用户;
  • 查询会话时同时校验归属;
  • 使用客户端请求 ID 做幂等;
  • 在短事务中创建用户消息和助手占位消息;
  • 模型调用放在事务外执行。

10. 完成助手消息

java 复制代码
@Transactional
public void completeAssistantMessage(
        long messageId,
        String content,
        Usage usage,
        long latencyMs,
        long firstTokenMs) {

    messageRepository.markCompleted(
            messageId,
            content,
            usage.inputTokens(),
            usage.outputTokens(),
            usage.totalTokens(),
            latencyMs,
            firstTokenMs,
            Instant.now()
    );
}

失败和取消也要更新最终状态:

java 复制代码
@Transactional
public void failAssistantMessage(
        long messageId,
        String errorCode,
        String errorMessage,
        MessageStatus finalStatus) {

    if (!EnumSet.of(
            MessageStatus.FAILED,
            MessageStatus.CANCELLED,
            MessageStatus.TIMEOUT
    ).contains(finalStatus)) {
        throw new IllegalArgumentException("invalid final status");
    }

    messageRepository.markFinalFailure(
            messageId,
            finalStatus,
            errorCode,
            errorMessage,
            Instant.now()
    );
}

更新语句应该带上状态条件,防止迟到的完成回调覆盖已经取消的消息:

sql 复制代码
UPDATE ai_message
SET status = 'COMPLETED',
    content = :content,
    completed_at = CURRENT_TIMESTAMP,
    updated_at = CURRENT_TIMESTAMP
WHERE id = :messageId
  AND status = 'RUNNING';

11. 分页查询消息

消息列表建议使用基于游标的分页,而不是大页码分页:

sql 复制代码
SELECT *
FROM ai_message
WHERE conversation_id = :conversationId
  AND deleted_at IS NULL
  AND (
        created_at < :cursorTime
        OR (created_at = :cursorTime AND id < :cursorId)
      )
ORDER BY created_at DESC, id DESC
LIMIT :pageSize;

如果表数据量很大,游标分页通常比 OFFSET 更稳定。前端展示时可以再将结果反转成正序。

五、常见问题与实践建议

1. 是否应该把所有消息内容放在一张表

基础项目可以将常规文本消息放在一张表,但以下内容建议拆表:

  • 工具调用;
  • RAG 引用;
  • 高速流式事件;
  • 附件和文件;
  • 审计记录;
  • 模型调用明细。

拆表不是为了追求复杂,而是为了让核心消息查询保持稳定。

2. 消息表要不要保存完整 Prompt

不建议默认把完整 Prompt 直接存到消息表。可以保存:

  • Prompt key;
  • Prompt version;
  • Prompt hash;
  • 上下文策略;
  • 输入和输出 Token;
  • 脱敏后的调试样本。

如果确实有审计要求,可以将完整 Prompt 加密后写入专门的受控存储,并设置访问权限和保留期限。

3. content 字段使用 TEXT 还是 JSON

建议:

  • 普通文本使用 TEXT;
  • 结构化工具参数和结果使用 JSONB;
  • 多模态内容可以增加 content_parts JSON 字段;
  • 二进制文件放对象存储,数据库只保存元数据和引用。

不要把所有内容都序列化成 JSON。这样会降低全文查询、局部更新和数据分析的便利性。

4. 如何处理附件

消息表不要直接保存大文件:

text 复制代码
ai_message
  └─ ai_attachment
       ├─ object_key
       ├─ media_type
       ├─ size_bytes
       ├─ checksum
       └─ extraction_status

文件放对象存储,数据库保存对象键、大小、哈希、归属、权限和解析状态。

5. 如何保证多租户隔离

所有租户相关表建议都带 tenant_id,并在服务端查询时强制带上:

sql 复制代码
SELECT *
FROM ai_conversation
WHERE tenant_id = :currentTenantId
  AND public_id = :publicId
  AND deleted_at IS NULL;

不能只根据 public_id 查询后再在业务代码中判断租户。更稳妥的方式是把租户条件直接放到数据访问层,并在数据库层考虑 Row-Level Security。

6. 如何处理删除

普通删除可以使用软删除:

sql 复制代码
UPDATE ai_conversation
SET deleted_at = CURRENT_TIMESTAMP,
    updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = :tenantId
  AND id = :conversationId
  AND deleted_at IS NULL;

但软删除不等于数据已经消失。合规删除还需要清理:

  • 消息表;
  • 消息事件;
  • 工具调用;
  • 引用记录;
  • 附件和对象存储;
  • 向量库中的文档和片段;
  • 搜索索引;
  • 备份和归档。

7. 如何防止重复发送

客户端可能因为网络重试重复提交同一条消息。建议要求客户端生成 clientRequestId,并建立租户范围内的唯一约束:

sql 复制代码
CREATE UNIQUE INDEX uk_message_client_request
    ON ai_message (tenant_id, request_id)
    WHERE request_id IS NOT NULL;

重复请求直接返回已有消息,不要再次调用模型。

8. 如何防止迟到回调覆盖新状态

模型调用、流式连接和异步任务可能以不同顺序返回。更新消息时要使用状态条件或版本号:

sql 复制代码
UPDATE ai_message
SET status = :nextStatus,
    version = version + 1
WHERE id = :id
  AND version = :expectedVersion;

对于更复杂的任务,可以使用事件版本、乐观锁和任务执行 ID。

六、进阶思考

1. 消息表是否需要分区

当消息量达到较大规模时,可以考虑按时间或租户分区:

  • 按月分区:便于归档和删除;
  • 按租户分区:适合大型租户隔离;
  • 按业务线分区:适合多个 AI 应用共用平台。

分区会增加迁移和运维复杂度,不应在数据量还很小时提前引入。先确认查询模式、保留周期和归档策略。

2. 会话摘要如何存储

可以在会话表保存当前摘要,也可以单独建表:

sql 复制代码
CREATE TABLE ai_conversation_summary (
    id                  BIGSERIAL PRIMARY KEY,
    conversation_id     BIGINT NOT NULL REFERENCES ai_conversation(id),
    summary_version     INTEGER NOT NULL,
    content             TEXT NOT NULL,
    source_until_seq    BIGINT NOT NULL,
    token_count         INTEGER,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE (conversation_id, summary_version)
);

单独建表可以保留摘要演进过程,便于回滚和调试;只在会话表保存最新摘要则更简单。

3. 消息模型如何支持 Agent

Agent 任务可能包含多个步骤:

text 复制代码
用户消息
  ↓
助手计划
  ↓
工具调用 A
  ↓
工具结果 A
  ↓
工具调用 B
  ↓
工具结果 B
  ↓
最终回答

可以增加 run_id 和 parent_message_id:

text 复制代码
conversation
  └─ user message
       └─ assistant run
            ├─ plan
            ├─ tool call
            ├─ tool result
            └─ final answer

如果 Agent 任务复杂,建议把运行过程抽象为独立的 ai_run 表,消息只保存用户可见内容,运行表保存内部步骤和状态。

4. 观测数据与业务消息分离

Token、延迟、模型名称和错误信息既可以放在消息表,也可以拆成调用记录:

sql 复制代码
CREATE TABLE ai_model_call (
    id                  BIGSERIAL PRIMARY KEY,
    tenant_id           BIGINT NOT NULL,
    conversation_id     BIGINT,
    message_id          BIGINT,
    request_id          VARCHAR(128) NOT NULL,
    provider            VARCHAR(64) NOT NULL,
    model_name          VARCHAR(128) NOT NULL,
    input_tokens        INTEGER,
    output_tokens       INTEGER,
    latency_ms          BIGINT,
    first_token_ms      BIGINT,
    status              VARCHAR(32) NOT NULL,
    error_code          VARCHAR(64),
    created_at          TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_model_call_tenant_time
    ON ai_model_call (tenant_id, created_at DESC);

业务消息负责产品功能,模型调用记录负责成本、性能和运维分析,分开后更容易扩展。

5. 数据库、缓存和事件流如何分工

推荐的职责划分:

存储 适合保存
PostgreSQL / MySQL 会话、最终消息、权限、审计索引
Redis 流式临时内容、分布式锁、短期状态
对象存储 附件、长文本、完整 Prompt 归档
消息队列 异步任务、事件通知、重试
向量数据库 文档片段和语义检索索引
OLAP Token、费用和质量分析

不要把 Redis 当成唯一消息存储,也不要把所有运行日志都写进核心消息表。

6. 迁移策略

生产级表设计必须考虑演进:

text 复制代码
新增字段
  ↓
先兼容旧代码
  ↓
双写或回填
  ↓
切换读取逻辑
  ↓
验证数据
  ↓
删除旧字段或旧逻辑

例如将 question、answer 单表迁移到消息表时,可以先保留旧表,新增消息表并双写,确认读取流量切换完成后再归档旧数据。

结论

用户、会话与消息表是 AI 后端的基础骨架。一个可持续演进的设计应当做到:

  • 用户身份、租户关系和会话归属清晰;
  • 会话作为业务对象,Chat Memory 作为运行时上下文;
  • 消息支持多角色、多状态和多版本;
  • 流式生成有明确的占位记录和最终状态;
  • 工具调用、RAG 引用和模型统计使用结构化数据;
  • 所有查询都带上租户和用户权限边界;
  • 使用幂等键、乐观锁和状态条件处理并发;
  • 将数据库、缓存、对象存储、消息队列和向量库分工;
  • 为 Agent、摘要、观测和数据删除预留扩展空间。

后续实现模型服务抽象时,可以直接使用本文的 conversation_id、message_id、request_id 和 run_id 作为跨服务关联键;实现知识库和 Agent 时,再分别补充引用、工具调用和运行明细表。

参考资料

相关推荐
maozhenzhong1 小时前
接口加解密与安全通信方案
后端
god_cvz1 小时前
第三章:5、MCP协议入门
后端·ai编程
RaaS1001 小时前
AI网关选型指南:MAI Gateway(魔芋企业级AI网关)vs 开源方案全面对比
前端·后端
内存漫游1 小时前
Checkpoint 与 Crash Recovery:数据库恢复机制入门
数据库
明月_清风1 小时前
ClickHouse 实战指南:为什么它能做到"亿级数据秒级查询"?
后端·数据分析
2301_786756601 小时前
中科院分区表停发后,期刊评价的技术逻辑:从期刊超越指数到多指标选刊方案
java·数据库·windows
闲云野鹤在人间1 小时前
Ubuntu 24.04 安装过程
服务器·数据库·ubuntu
Wx-bishekaifayuan1 小时前
springboot铜仁非遗之家76938-计算机课程设计、毕业设计
java·spring boot·后端·spring·django·课程设计·旅游