BodySense 会话与消息流设计

AI 聊天应用的会话生命周期设计:懒创建策略、分层 ID 体系、SSE 流式事件协议、消息持久化模型、幂等与断线恢复。

#type / concept #status / complete #tech / dev / backend #tech / dev / frontend #tech / ai

[!info] related notes

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.createdrouter.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. 关键工程规则

  1. 新聊天点击不入库,除非有附件上传/多人协作/分享空会话需求
  2. 正式 conversationId 只由服务端生成(UUIDv7)
  3. 前端可以有 clientDraftIdclientMessageId,但它们只是临时 ID
  4. 每次发送必须带 requestId,用于幂等
  5. user message 收到请求后立即落库
  6. assistant message 开始生成前先创建 streaming placeholder
  7. SSE 第一批事件返回 conversationId 和正式 messageId
  8. 前端收到 conversation.createdrouter.replace 到正式 URL
  9. assistant 生成完成后更新 message.status = completed
  10. 失败时保留 failed message,不要静默删除
  11. 会话标题不阻塞主回复,第一轮完成后异步生成
  12. 自己的 DB 是 source of truth,provider 的 id 只是辅助字段

12. 推荐架构一句话总结

前端草稿态 + 首条消息懒创建 conversation + 服务端 UUIDv7 统一发号 + requestId 幂等 + user message 立即落库 + assistant message streaming placeholder + SSE 返回正式 ID + 最终持久化完整 UIMessage parts + 可选 Redis/事件表支持断线恢复。

创建于 2026/6/27 更新于 2026/6/27