BodySense 会话与消息流设计
AI 聊天应用的会话生命周期设计:懒创建策略、分层 ID 体系、SSE 流式事件协议、消息持久化模型、幂等与断线恢复。
[!info] related notes
- 知识地图: BodySense 项目 MOC
- 数据库: BodySense 数据库设计
- SSE 管道: 多服务 SSE 管道
- 前端 SSE 消费: 前端 SSE 消费
- 聊天 UI: AI 聊天 UI 设计
- Agent 工作流: 咨询 Agent 工作流设计
- Function Calling: Function Calling 流式累积
BodySense 会话与消息流设计
用户点击”新聊天”到 AI 回答完成,整个生命周期怎么建模?覆盖懒创建策略、ID 体系、SSE 事件协议、数据库表结构、幂等与断线恢复。
核心设计决策
| 决策点 | 推荐方案 | 理由 |
|---|---|---|
| 新聊天何时入库 | 懒创建——首条消息才创建 conversation | 不产生空会话,侧边栏干净 |
| 正式 ID 谁生成 | 服务端生成 UUIDv7 | 前端只有临时 draftId/clientMessageId |
| 幂等保障 | 前端生成 requestId + 服务端唯一约束 | 防止重复提交、网络重试 |
| 消息持久化时机 | user 立即落库,assistant 先建 placeholder | 刷新不丢消息,失败可追溯 |
| 上下文管理 | 自己 DB 为 source of truth | 多模型兼容,provider id 仅作辅助 |
1. 核心对象模型
不要只设计一个 messages 表,至少分成四层:
Conversation / Session 一整个聊天会话
Message 一条消息(user / assistant / tool / system)
Run / Turn 一次用户输入触发的一轮模型执行
Stream / Event 流式生成过程中的事件(text_delta / tool_call / done)
Conversation 是容器,Message 是内容,Run 是一次执行,Stream/Event 是过程。
2. 懒创建会话流程
阶段 A:用户点击”新聊天”
前端进入空白草稿态,不请求后端,不入库:
const clientDraftId = crypto.randomUUID();
setCurrentConversationId(null);
setDraftId(clientDraftId);
setMessages([]);
router.push('/chat/new');
侧边栏不出现这个会话。刷新页面可回到空白新聊天或直接丢弃。
阶段 B:用户发送第一条消息
前端乐观渲染 + 请求后端:
POST /api/chat/send
Content-Type: application/json
Idempotency-Key: <requestId>
{
"conversationId": null,
"clientDraftId": "draft_xxx",
"clientMessageId": "tmp_xxx",
"requestId": "req_xxx",
"message": {
"role": "user",
"parts": [{ "type": "text", "text": "帮我分析一下..." }]
},
"model": "qwen-max"
}
conversationId = null 告诉服务端:这是新会话的第一条消息。
阶段 C:服务端在一个事务里创建会话
BEGIN
→ 创建 conversation(UUIDv7)
→ 创建 user message(status = completed)
→ 创建 assistant placeholder message(status = streaming)
→ 创建 run(status = running)
COMMIT
→ 开始调用 LLM,SSE 流式返回
阶段 D:SSE 返回正式 ID
event: conversation.created
data: {"conversationId":"conv_0197...", "replacesDraftId":"draft_abc"}
event: message.created
data: {"clientMessageId":"tmp_user_abc", "messageId":"msg_user_0197...", "role":"user"}
event: message.created
data: {"messageId":"msg_asst_0197...", "role":"assistant", "status":"streaming"}
event: text.delta
data: {"messageId":"msg_asst_0197...", "delta":"你好"}
event: message.completed
data: {"messageId":"msg_asst_0197...", "finishReason":"stop",
"usage":{"inputTokens":1234,"outputTokens":567}}
event: done
data: {}
前端收到 conversation.created 后 router.replace('/chat/{conversationId}'),用户视觉上从 /chat/new 自动变成 /chat/conv_xxx。
3. 分层 ID 体系
| ID | 生成方 | 生命周期 | 格式示例 |
|---|---|---|---|
clientDraftId | 前端 | 临时,不入库 | draft_01J... |
conversationId | 服务端 | 永久 | conv_0197f3c2-...(UUIDv7) |
messageId | 服务端(前端有临时 tmp_xxx) | 永久 | msg_0197f3c3... |
turnId | 服务端 | 永久,串联一轮的所有产物 | turn_0197f3c4... |
requestId | 前端/网关 | 请求级,用于幂等 | req_abc |
为什么需要 turnId?
一轮对话可能包含:user message → assistant streaming → tool calls → tool results → final answer → run → trace → usage。turnId 把它们串起来,后续才能实现:
- 重新生成某一轮回答
- 编辑用户消息并分叉
- 展示”这一轮调用了哪些工具”
- 统计 token 消耗
- 回放 agent 执行轨迹
为什么需要 requestId?
防止重复提交。用户点两次发送、网络重试、浏览器自动重发时,服务端靠 unique(user_id, request_id) 识别重复,不重复调用模型,直接返回已有 run/stream。
4. 三种会话创建策略
方案 A:完全懒创建(推荐默认)
点击新聊天 → 不入库
发送第一条消息 → 创建 conversation
优点:不产生空会话,逻辑符合用户行为。缺点:发送前没有可分享 URL。
方案 B:点击时预创建 draft conversation
点击新聊天 → 创建 status=draft 的 conversation
发送第一条消息 → status 改为 active
长期没发送 → 定时清理 draft
适合:有附件上传、多人协作、需要分享空会话链接。
方案 C:前端 draft + 后端临时 upload scope
折中方案:新聊天不创建 conversation,但上传文件时创建 upload_session,发送时绑定到正式 conversation。适合后续有”体态图片分析""视频片段”的场景。
5. 消息持久化时机
| 对象 | 时机 | 理由 |
|---|---|---|
| user message | 收到请求后立即落库 | 刷新不丢消息 |
| assistant message | 开始生成前创建 placeholder | 有记录可追踪 |
| 生成失败 | 保留 failed message,不删除 | 用户可见错误,审计完整 |
| 会话标题 | 第一轮完成后异步生成 | 不阻塞主回复 |
6. 继续对话
打开 /chat/{conversationId} 时:
GET /api/conversations/{conversationId}
返回 conversation + messages。继续发送时:
{
"conversationId": "conv_0197...",
"message": { "role": "user", "parts": [{ "type": "text", "text": "继续讲" }] },
"requestId": "req_..."
}
后端根据 conversationId 加载历史消息拼接上下文。持久化后可只发最后一条消息,服务端加载历史,减少请求体大小。
7. 上下文管理策略
推荐:应用自己管理上下文
const modelMessages = [
systemPrompt,
...summaryIfNeeded, // 长对话摘要
...recentMessages, // 最近 N 条
currentUserMessage,
];
优点:多模型兼容、可控、方便换 OpenAI / Qwen / Claude / Gemini。
不推荐:完全依赖 provider 原生 conversation/thread
provider 的 conversation_id / previous_response_id 只是优化字段。自己的 DB 永远是 source of truth。
8. Agent 场景的额外考量
普通聊天只存 messages 就够了。AI agent 还需要:
checkpoint = agent 中间状态(计划、工具调用、人工确认、失败恢复)
store = 用户长期画像 / 偏好 / 健康档案 / 知识记忆
映射到 BodySense:
conversationId = UI 层会话 ID
threadId = agent 执行线程 ID(= conversationId)
checkpoint = LangGraph checkpointer(短期会话记忆)
store = 用户健康档案、偏好(长期记忆)
9. 断线恢复(可选但推荐)
轻量方案:只在内存拼接 delta,结束后一次性更新 assistant message。
稳健方案:delta 同步写入 Redis 或 message_events 事件表,断线后可恢复。需要:
- 消息持久化
- 活跃 stream 追踪(
conversations.active_stream_id) - Redis 存储 UIMessage stream
- POST 创建流 + GET 恢复流两个端点
10. 最小 API 设计
GET /api/conversations 侧边栏会话列表(active 且有消息)
GET /api/conversations/:id 会话详情 + messages
POST /api/chat/send 发送消息(conversationId=null 则创建新会话)
GET /api/chat/:id/stream 恢复正在生成的 stream(可选)
POST /api/conversations/:id/title 重新生成标题(可选)
DELETE /api/conversations/:id 软删除会话
11. 关键工程规则
- 新聊天点击不入库,除非有附件上传/多人协作/分享空会话需求
- 正式
conversationId只由服务端生成(UUIDv7) - 前端可以有
clientDraftId、clientMessageId,但它们只是临时 ID - 每次发送必须带
requestId,用于幂等 - user message 收到请求后立即落库
- assistant message 开始生成前先创建 streaming placeholder
- SSE 第一批事件返回
conversationId和正式messageId - 前端收到
conversation.created后router.replace到正式 URL - assistant 生成完成后更新
message.status = completed - 失败时保留 failed message,不要静默删除
- 会话标题不阻塞主回复,第一轮完成后异步生成
- 自己的 DB 是 source of truth,provider 的 id 只是辅助字段
12. 推荐架构一句话总结
前端草稿态 + 首条消息懒创建 conversation + 服务端 UUIDv7 统一发号 + requestId 幂等 + user message 立即落库 + assistant message streaming placeholder + SSE 返回正式 ID + 最终持久化完整 UIMessage parts + 可选 Redis/事件表支持断线恢复。