摘要
用户、会话和消息是 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_partsJSON 字段; - 二进制文件放对象存储,数据库只保存元数据和引用。
不要把所有内容都序列化成 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 时,再分别补充引用、工具调用和运行明细表。