这是一个真实的线上故障:用户点击"New Chat"按钮后,第一条消息经常发到旧会话里。看起来是个小 bug,实际上暴露了整个多会话管理架构的根本缺陷。
故障现场
用户操作很简单:
- 在会话 A 里聊了几轮
- 点击"New Chat"按钮
- 输入第一句话,回车
预期:消息出现在新会话里
实际:消息跑到会话 A 里了
或者更诡异的情况:
- 点"New Chat"
- 界面还显示着旧消息
- 输入框是灰色的,提示"系统繁忙"
原因是上个会话还在流式输出,把新会话的输入框也锁住了。
根因:三本账本的战争
账本一:服务器生成的 ID
app/(chat)/page.tsx 是服务器组件,每次渲染都会生成一个新的 UUID:
tsx
export default function ChatPage() {
const sessionId = generateUUID(); // 服务器说:这是新会话的 ID
return <ChatClient initialSessionId={sessionId} />;
}
账本二:客户端组件的状态
components/chat.tsx 维护自己的 currentSessionId:
tsx
const [currentSessionId, setCurrentSessionId] = useState(initialSessionId);
同时还维护了一个消息列表的副本:
tsx
const [messagesState, setMessagesState] = useState<Message[]>([]);
const storeMessages = useChatStore(state => state.messages[currentSessionId]);
useEffect(() => {
setMessagesState(storeMessages); // 手动同步
}, [storeMessages]);
账本三:Zustand 全局 store
Store 里存着所有会话数据,还有一个全局的 isStreaming 标志:
tsx
interface ChatStore {
sessions: Record<string, Session>;
messages: Record<string, Message[]>;
isStreaming: boolean; // 全局共享
}
为什么会出问题
当用户点"New Chat"时:
router.push('/')触发路由跳转- 服务器生成新 ID:
uuid-123 - 客户端组件挂载,
currentSessionId被设置为uuid-123 - 但用户快速输入消息,此时
handleSubmit闭包里捕获的可能还是旧 ID:uuid-456 - 消息发到了错误的会话
或者另一种情况:
- 会话 A 正在流式输出,
isStreaming = true - 用户点"New Chat"切换到会话 B
- 会话 B 的输入框检查
isStreaming,发现是true - 输入框被禁用
三本账本各说各话,系统行为变得不可预测。
重构策略:确立唯一主权
不是修修补补,而是明确回答一个问题:谁拥有 session ID 的生成和管理权?
答案是:客户端。
服务器只负责持久化数据,不参与 ID 的生命周期管理。所有状态的变更都以客户端的 currentSessionId 为准。
重构步骤
第一步:ID 主权收归客户端
tsx
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const { chatId } = useParams(); // 从 URL 读取
useEffect(() => {
if (chatId) {
// URL 有 ID,就用它
setCurrentSessionId(chatId);
} else {
// 没有 ID,客户端自己生成
const newId = generateUUID();
setCurrentSessionId(newId);
}
}, [chatId]);
关键点:
客户端在挂载时立即决定用哪个 ID,不等服务器,不依赖 props。URL 是唯一的真相来源。
同时调用 ensureSessionExists(newId),在 store 里创建会话记录(标题"New Chat"、默认模型、私有可见性)。这是原子操作,不会出现"ID 有了但数据还没准备好"的中间状态。
第二步:删除状态副本
直接删掉 messagesState 和同步的 useEffect:
tsx
// ❌ 删掉
// const [messagesState, setMessagesState] = useState<Message[]>([]);
// useEffect(() => { setMessagesState(storeMessages); }, [storeMessages]);
// ✅ 直接从 store 读
const messages = useChatStore(state => state.getSessionMessages(currentSessionId));
为什么这样做:
维护两份数据(store + local state)必然会出现同步问题。useEffect 的执行时机由 React 调度,切换会话时可能延迟,导致 UI 显示旧数据。
现在 getSessionMessages 是一个 selector,从 store 直接读取。Store 更新 → 组件自动重渲染。单向数据流,没有中间状态。
同时 sendChatMessage(lib/services/chat-service.ts,64-91 行)直接往 store 写消息:
- 添加用户消息
- 添加 assistant 占位消息(content 为空)
- 流式数据到达时,逐字追加到占位消息
第三步:状态隔离到会话级
把全局的 isStreaming 改成会话级的 streamingStatus:
tsx
interface Session {
id: string;
title: string;
streamingStatus: 'idle' | 'streaming' | 'awaiting_message';
}
// 新 API
setStreaming(sessionId: string, isStreaming: boolean): void;
使用位置:
- Line 506:
handleSubmit开始时调用setStreaming(currentSessionId, true) - Line 643:流式结束时调用
setStreaming(currentSessionId, false) - Line 678:异常处理时也会重置状态
为什么这样做:
会话 A 的流式状态不应该影响会话 B。每个会话是独立的状态机。
输入框的禁用逻辑改成:
tsx
const status = useChatStore(state => state.sessions[currentSessionId]?.streamingStatus);
<Input disabled={status === 'streaming'} />
第四步:数据流动态绑定
把 DataStreamHandler 的初始化挪进 useEffect:
tsx
useEffect(() => {
if (!currentSessionId) return;
const handler = new DataStreamHandler({
sessionId: currentSessionId,
onArtifact: (data) => {
useChatStore.getState().addArtifact(currentSessionId, data);
},
onGuardrail: (data) => {
useChatStore.getState().addGuardrail(currentSessionId, data);
}
});
return () => handler.cleanup(); // 清理旧 handler
}, [currentSessionId]); // 依赖当前 ID
为什么这样做:
AI 响应不只有文本,还有 artifacts(代码块)、guardrails(安全检查)、suggestions(推荐问题)。这些数据通过 SSE 流式传输,必须写入正确的会话。
之前 DataStreamHandler 在模块加载时创建,绑定的是静态 ID。切换会话后,文本内容是对的(因为 sendMessage 用的是新 ID),但附加数据还在往旧会话写。
现在 useEffect 监听 currentSessionId,ID 变化时:
- 旧 handler 执行
cleanup() - 新 handler 创建,闭包捕获最新 ID
- 所有回调都写入正确的会话
修复后的完整流程
用户点"New Chat"会发生什么:
T1 :点击按钮 → router.push('/')
T2 :URL 变成根路径 → chatId 变为 undefined
T3 :useEffect 检测到 chatId 为空 → 生成新 ID abc-123 → setCurrentSessionId('abc-123') → ensureSessionExists('abc-123') 在 store 创建空会话
T4:用户输入消息,点击发送
T5 :handleSubmit 执行 → 检查 sessions['abc-123'].streamingStatus 是 'idle' → setStreaming('abc-123', true) → router.replace('/chat/abc-123') 更新 URL → sendChatMessage({ sessionId: 'abc-123', content: '...' })
T6 :sendChatMessage 内部 → 添加用户消息 → 添加 assistant 占位 → 发起 API 请求
T7 :SSE 数据到达 → DataStreamHandler 事件触发 → onText 追加文本 → onArtifact 添加代码块 → 都写入 'abc-123'
T8 :流式结束 → setStreaming('abc-123', false)
整个过程中,ID 始终是 'abc-123'。没有竞态,没有数据写错。
对比:重构前后的架构差异
重构前
css
服务器生成 ID-A
↓
客户端维护 ID-B(可能不同步)
↓
handleSubmit 用 ID-B 发送
↓
消息写入 store,但 UI 渲染 local state(可能延迟)
↓
全局 isStreaming 锁住所有会话
↓
DataStreamHandler 绑定静态 ID-C(不会更新)
每一层都可能拿到不同的 ID,状态分散在多个地方。
重构后
markdown
客户端生成 ID(唯一真相源)
↓
所有操作都用这个 ID
↓
Store 是唯一数据源
↓
UI 订阅 store(自动更新)
↓
会话级状态(互不影响)
↓
DataStreamHandler 动态绑定(跟随 ID)
单向数据流,每一层都从上一层获取 ID。
以前的代码在 components/chat.tsx 里这样做:
tsx
const [messages, setMessagesState] = useState(initialMessages);
// ...
useEffect(() => {
if (autoResume) {
const latestStoreMessages = getSessionMessages(currentSessionId);
if (latestStoreMessages.length > 0) {
setMessagesState(latestStoreMessages); } }
else { setStoreMessages(currentSessionId, []);
}
}, [...]);
messages 是组件私有 state,而真正的真相还在 useChatStore 里。React 的 useEffect 受调度影响:切换到新会话后,在 effect 将 store 数据写回本地 state 之前,UI 仍然渲染旧的 messages,于是"新对话"短暂显示上一场的内容;如果 effect 被打断或依赖变化顺序不一致,还会出现 store 和 UI 互相覆盖的问题。
现在的版本删掉了这层本地 state,在 components/chat.tsx (lines 145-188) 直接用 selector:
tsx
const messages = useChatStore((state) => getSessionMessages(currentSessionId), );
getSessionMessages 自身就是 store 方法,内部返回 sessions.get(id)?.messages ?? [],所以"store 更新 → selector 触发 → React 重渲染",数据流是单向的,没有任何 useEffect 负责同步,自然也就不会出现"短暂显示旧会话"的窗口。
配合这一点,消息的写入全部集中在 lib/services/chat-service.ts 的 sendChatMessage(64-91 行开头):
tsx
const userMessage: UIMessage = { ... };
addMessage(sessionId, userMessage);
const assistantMessage: UIMessage = {
id: generateUUID(),
role: 'assistant',
content: '',
parts: [],
};
addMessage(sessionId, assistantMessage);
setStreaming(sessionId, true, assistantMessage.id);
也就是说:
- 用户提交表单,sendChatMessage 先 addMessage 一条 role: 'user' 的消息,所以 UI 会立刻显示"我"刚输入的文字。
- 紧接着再插入一条 role: 'assistant' 且 content 为空的占位气泡,UI 上会看到一个空的助手消息(即"Thinking...")。
- SSE 流回来的每个增量在 for (const line of lines) 里解析后,通过 updateMessage(sessionId, assistantMessage.id, { parts: [...] }) 逐步把文本追加到同一个消息对象上,直到流结束再 setStreaming(sessionId, false)。
整个过程中组件不再维护本地副本,所有变更都通过 store 的 addMessage / updateMessage / setStreaming 完成。这样"新对话"一创建,store 里就是一份干净的消息数组;而 UI 只需要订阅 getSessionMessages,无需等待 useEffect,也就彻底避免了"同步时机"带来的脏读。
三个可复用的架构原则
原则一:客户端拥有 ID 主权
服务器只负责持久化,不参与 ID 的生命周期管理。URL 是唯一真相源,useEffect 监听 URL 参数变化,动态创建或激活实例。
原则二:不维护状态副本
如果你发现自己在写 const [localData, setLocalData] = useState(storeData),停下来想想:为什么需要副本?直接从 store 读,用 selector 或 memoization 优化性能,而不是复制数据。
原则三:异步状态绑定到实例
isLoading、isError、progress 不能是全局的。存在实例对象里(instances[id].isLoading),或用 Map 结构(loadingStates.get(id)),总之要能区分"哪个实例在加载"。
任何需要管理"多实例"的系统都会遇到:
多文档编辑器(Google Docs、Notion):在文档 A 输入,内容出现在文档 B
多标签浏览器:在标签 A 提交表单,数据发到标签 B 的 API
游戏房间:在房间 A 发消息,消息出现在房间 B
实时协作白板:在画布 A 画图,笔迹出现在画布 B
核心问题一样:实例 ID 的管理权不清晰,状态归属混乱,数据流绑定是静态的而非动态的。
最后一个细节
你可能注意到 handleSubmit 里有:
tsx
startTransition(() => {
router.replace(`/chat/${currentSessionId}`);
});
为什么用 startTransition?
因为它告诉 React:"这个更新不着急,别阻塞用户输入"。URL 更新是次要的,消息发送才是主要的。startTransition 让 React 把这次更新标记为"可中断",优先处理用户操作。
这是 React 18 并发特性的典型用法,不起眼但能显著改善体验。