长对话状态管理的架构设计
开篇:你以为的状态管理 vs AI对话的状态管理
传统的状态管理解决的是"数据在组件间共享"的问题。AI长对话场景下的状态管理,面对的是完全不同量级的挑战:
arduino
// 一个50轮对话的会话,每轮平均500字
// 消息列表DOM节点数:50 × ~50 = ~2500个节点
// 流式输出期间每秒更新50次
// 用户切换会话时需要快照恢复
// 你要管理的不只是"数据",而是:
// 1. 三种状态阶段的生命周期
// 2. 2500+个DOM节点的渲染性能
// 3. 上下文窗口的Token预算
// 4. 多会话的切换与持久化
这不是加一个Redux就能解决的事。
一、长对话的三个状态阶段
每一条AI消息都有三种状态,而传统聊天(如即时通讯)只有两种:
传统聊天:发送中 → 已发送
AI对话:输入中 → 流式输出中 → 输出完成
"流式输出中"这个中间状态,是AI对话状态管理的核心复杂性来源:
ini
enum MessageStatus {
PENDING = 'pending', // 用户已发送,等待AI响应
STREAMING = 'streaming', // AI正在流式输出
PAUSED = 'paused', // 用户暂停了生成
COMPLETED = 'completed', // 生成完成
ERROR = 'error', // 生成失败
}
interface AIMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
status: MessageStatus;
model?: string;
tokenCount?: number;
createdAt: number;
// 流式输出特有的状态
streamingContent?: string; // 当前正在流式输出的内容(可能与content不同步)
isStopRequested?: boolean; // 用户是否请求了停止
// 元数据
metadata?: {
finishReason?: string; // 'stop' | 'length' | 'function_call'
functionCall?: FunctionCall;
usage?: {
promptTokens: number;
completionTokens: number;
};
};
}
关键设计决策 :content vs streamingContent
csharp
// 方案A:content就是流式内容,每次追加都更新
// 问题:组件每次content变化都重渲染,性能差
// 方案B(推荐):content只在完成时更新,streamingContent实时追加
// 优势:流式输出只触发StreamingRenderer的重渲染,不影响其他组件
interface AIMessage {
content: string; // 最终内容(完成后才更新)
streamingContent?: string; // 流式内容(实时追加)
}
二、对话状态领域模型
typescript
// === 会话(Session)===
interface ChatSession {
id: string;
title: string;
messages: AIMessage[];
createdAt: number;
updatedAt: number;
// 会话级状态
status: SessionStatus;
currentModel: string;
// Token预算
tokenUsage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
// 配置
config: {
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
};
}
enum SessionStatus {
IDLE = 'idle', // 空闲,等待用户输入
GENERATING = 'generating', // AI正在生成
ERROR = 'error', // 错误状态
}
// === 会话管理器(SessionManager)===
interface SessionManager {
// 会话CRUD
createSession(): ChatSession;
deleteSession(id: string): void;
switchSession(id: string): void;
// 当前会话
activeSessionId: string | null;
// 消息操作
sendMessage(content: string): Promise<void>;
stopGeneration(): void;
regenerateMessage(messageId: string): Promise<void>;
editMessage(messageId: string, newContent: string): Promise<void>;
}
三、消息列表虚拟化:只渲染可视区域
50轮对话 = 100条消息 = ~2500个DOM节点。如果每条消息都渲染完整的Markdown(含代码块、表格),DOM节点数轻松破万。
虚拟滚动的核心思想:只渲染用户能看到的10-15条消息,其余用空白占位。
typescript
// 虚拟滚动的关键参数
interface VirtualScrollConfig {
overscan: number; // 可视区域上下额外渲染的条数(默认5)
estimatedItemHeight: number; // 每条消息的预估高度(用于计算滚动条)
scrollToBottomOnNewMessage: boolean; // 新消息时自动滚到底部
}
// 消息高度的计算挑战
// AI消息高度不确定:50字的消息可能只有1行,5000字的消息可能占满整个屏幕
// 代码块、表格的高度更难预估
// 策略:先按预估高度布局,实际渲染后修正
class MessageHeightCache {
private heights = new Map<string, number>();
private estimatedHeight = 100; // 默认预估高度
getHeight(messageId: string): number {
return this.heights.get(messageId) ?? this.estimatedHeight;
}
setHeight(messageId: string, height: number): void {
this.heights.set(messageId, height);
}
updateEstimatedHeight(): void {
if (this.heights.size === 0) return;
const sum = Array.from(this.heights.values()).reduce((a, b) => a + b, 0);
this.estimatedHeight = sum / this.heights.size;
}
}
四、上下文窗口裁剪
LLM有上下文窗口限制,对话过长时必须裁剪历史消息。前端需要管理裁剪策略:
ini
type TrimStrategy =
| { type: 'sliding_window'; maxMessages: number }
| { type: 'token_budget'; maxTokens: number }
| { type: 'summary'; summaryModel: string };
class ContextWindowManager {
private strategy: TrimStrategy;
constructor(strategy: TrimStrategy) {
this.strategy = strategy;
}
/** 裁剪消息列表,确保不超出上下文窗口 */
trim(messages: AIMessage[]): AIMessage[] {
// 始终保留System Prompt
const systemMsg = messages.filter(m => m.role === 'system');
// 非System消息
const chatMessages = messages.filter(m => m.role !== 'system');
switch (this.strategy.type) {
case 'sliding_window':
return [
...systemMsg,
...chatMessages.slice(-this.strategy.maxMessages),
];
case 'token_budget': {
// 从最新消息开始保留,直到接近Token预算
const budget = this.strategy.maxTokens;
let tokenCount = 0;
const kept: AIMessage[] = [];
for (let i = chatMessages.length - 1; i >= 0; i--) {
const msgTokens = this.estimateTokens(chatMessages[i].content);
if (tokenCount + msgTokens > budget) break;
tokenCount += msgTokens;
kept.unshift(chatMessages[i]);
}
return [...systemMsg, ...kept];
}
case 'summary': {
// 保留最近10轮 + 对更早内容的摘要
const recent = chatMessages.slice(-20);
const oldMessages = chatMessages.slice(0, -20);
if (oldMessages.length === 0) {
return [...systemMsg, ...recent];
}
// 生成摘要(实际会调用LLM)
const summaryMsg: AIMessage = {
id: 'summary',
role: 'system',
content: `[以下是之前对话的摘要]\n${this.generateSummary(oldMessages)}`,
status: MessageStatus.COMPLETED,
createdAt: oldMessages[0].createdAt,
};
return [...systemMsg, summaryMsg, ...recent];
}
}
}
private estimateTokens(text: string): number {
const chineseChars = (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
const otherChars = text.length - chineseChars;
return Math.ceil(chineseChars * 1.5 + otherChars * 0.25);
}
private generateSummary(messages: AIMessage[]): string {
// 实际项目中调用LLM生成摘要
return messages
.map(m => `${m.role}: ${m.content.slice(0, 100)}...`)
.join('\n');
}
}
五、多会话管理:切换时的状态快照与恢复
php
interface SessionSnapshot {
sessionId: string;
scrollPosition: number;
inputText: string; // 未发送的输入框内容
selectedModel: string;
timestamp: number;
}
class SessionSwitchManager {
private snapshots = new Map<string, SessionSnapshot>();
/** 切换会话前保存当前状态快照 */
saveSnapshot(sessionId: string, state: {
scrollPosition: number;
inputText: string;
selectedModel: string;
}): void {
this.snapshots.set(sessionId, {
sessionId,
scrollPosition: state.scrollPosition,
inputText: state.inputText,
selectedModel: state.selectedModel,
timestamp: Date.now(),
});
}
/** 切换到目标会话时恢复状态 */
restoreSnapshot(sessionId: string): SessionSnapshot | null {
return this.snapshots.get(sessionId) ?? null;
}
}
六、状态管理库选型对比

结论:React项目选Zustand,Vue项目选Pinia。不要在AI对话场景中强行用Redux------它太重了。
实践任务
任务:设计一个AI对话状态管理的TypeScript接口定义,涵盖消息、会话、流式状态三大领域模型。
要求:
- 完整的TypeScript接口/类型/枚举定义
- 消息状态机的合法转换关系图
- 会话操作的方法签名(发送/停止/重新生成/编辑)
- 虚拟滚动配置接口
- 上下文裁剪策略接口
面试题解析
Q:在React/Vue中如何优雅地管理AI长对话的状态?
答题要点:
- 核心挑战:消息列表膨胀、流式状态过渡、上下文窗口裁剪、多会话切换
- 流式状态:content与streamingContent分离,避免全量重渲染
- 虚拟滚动:只渲染可视区域的消息,预估高度+实际修正
- 上下文裁剪:滑动窗口/Token预算/摘要压缩三种策略
- 多会话:切换时保存快照(输入框内容、滚动位置),恢复时重建
- 选型:React选Zustand(selector细粒度订阅),Vue选Pinia(reactivity天然适配)
5.2 React/Vue长对话状态管理实战
开篇:从接口到实现
上一期我们设计了AI对话状态管理的领域模型和接口。这一期,我们分别用React(Zustand)和Vue(Pinia)来实现。
一、React + Zustand实现
1.1 Store设计:Slice模式
typescript
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { subscribeWithSelector } from 'zustand/middleware';
// === 消息Slice ===
interface MessageSlice {
messages: AIMessage[];
addMessage: (message: AIMessage) => void;
updateMessage: (id: string, updates: Partial<AIMessage>) => void;
appendStreamingContent: (id: string, token: string) => void;
completeMessage: (id: string, finalContent: string) => void;
removeMessage: (id: string) => void;
}
const createMessageSlice: StateCreator<ChatStore, [], [], MessageSlice> = (set, get) => ({
messages: [],
addMessage: (message) => set((state) => ({
messages: [...state.messages, message],
})),
updateMessage: (id, updates) => set((state) => ({
messages: state.messages.map(msg =>
msg.id === id ? { ...msg, ...updates } : msg
),
})),
// 核心优化:appendStreamingContent只更新streamingContent,不触发其他组件重渲染
appendStreamingContent: (id, token) => set((state) => ({
messages: state.messages.map(msg =>
msg.id === id
? { ...msg, streamingContent: (msg.streamingContent ?? '') + token }
: msg
),
})),
completeMessage: (id, finalContent) => set((state) => ({
messages: state.messages.map(msg =>
msg.id === id
? {
...msg,
content: finalContent,
streamingContent: undefined,
status: MessageStatus.COMPLETED,
}
: msg
),
})),
removeMessage: (id) => set((state) => ({
messages: state.messages.filter(msg => msg.id !== id),
})),
});
// === 会话Slice ===
interface SessionSlice {
sessions: ChatSession[];
activeSessionId: string | null;
sessionStatus: SessionStatus;
createSession: () => string;
switchSession: (id: string) => void;
deleteSession: (id: string) => void;
setSessionStatus: (status: SessionStatus) => void;
}
const createSessionSlice: StateCreator<ChatStore, [], [], SessionSlice> = (set, get) => ({
sessions: [],
activeSessionId: null,
sessionStatus: SessionStatus.IDLE,
createSession: () => {
const id = `session-${Date.now()}`;
const session: ChatSession = {
id,
title: '新对话',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
status: SessionStatus.IDLE,
currentModel: 'gpt-4',
tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
config: {},
};
set((state) => ({
sessions: [...state.sessions, session],
activeSessionId: id,
}));
return id;
},
switchSession: (id) => set({ activeSessionId: id }),
deleteSession: (id) => set((state) => ({
sessions: state.sessions.filter(s => s.id !== id),
activeSessionId: state.activeSessionId === id
? (state.sessions[0]?.id ?? null)
: state.activeSessionId,
})),
setSessionStatus: (status) => set({ sessionStatus: status }),
});
// === 组合Store ===
type ChatStore = MessageSlice & SessionSlice & SSESlice;
const useChatStore = create<ChatStore>()(
subscribeWithSelector(
persist(
(...a) => ({
...createMessageSlice(...a),
...createSessionSlice(...a),
}),
{
name: 'ai-chat-store',
storage: createJSONStorage(() => localStorage),
// 只持久化部分数据
partialize: (state) => ({
sessions: state.sessions.map(s => ({
...s,
messages: s.messages.map(m => ({
...m,
streamingContent: undefined, // 不持久化流式内容
status: m.status === MessageStatus.STREAMING
? MessageStatus.COMPLETED
: m.status,
})),
})),
activeSessionId: state.activeSessionId,
}),
}
)
)
);
1.2 细粒度订阅:避免不必要的重渲染
javascript
// ❌ 错误用法:订阅整个messages数组
const messages = useChatStore(state => state.messages);
// 每次任何message更新都会触发重渲染
// ✅ 正确用法:只订阅需要的数据
const messageCount = useChatStore(state => state.messages.length);
// ✅ 只订阅特定消息的streamingContent
function useStreamingContent(messageId: string): string | undefined {
return useChatStore(
subscribeWithSelector(
state => state.messages.find(m => m.id === messageId)?.streamingContent
)
);
}
// ✅ 只订阅当前会话的消息
function useActiveMessages(): AIMessage[] {
const activeSessionId = useChatStore(state => state.activeSessionId);
return useChatStore(
state => state.sessions.find(s => s.id === activeSessionId)?.messages ?? []
);
}
二、Vue + Pinia实现
2.1 Store设计:组合式API
ini
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
export const useChatStore = defineStore('chat', () => {
// === 状态 ===
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<string | null>(null);
const sessionStatus = ref<SessionStatus>(SessionStatus.IDLE);
// === 计算属性 ===
const activeSession = computed(() =>
sessions.value.find(s => s.id === activeSessionId.value) ?? null
);
const activeMessages = computed(() =>
activeSession.value?.messages ?? []
);
const isGenerating = computed(() =>
sessionStatus.value === SessionStatus.GENERATING
);
// === 操作 ===
function createSession(): string {
const id = `session-${Date.now()}`;
sessions.value.push({
id,
title: '新对话',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
status: SessionStatus.IDLE,
currentModel: 'gpt-4',
tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
config: {},
});
activeSessionId.value = id;
return id;
}
function addMessage(message: AIMessage): void {
activeSession.value?.messages.push(message);
}
function appendStreamingContent(messageId: string, token: string): void {
const msg = activeMessages.value.find(m => m.id === messageId);
if (msg) {
msg.streamingContent = (msg.streamingContent ?? '') + token;
}
}
function completeMessage(messageId: string, finalContent: string): void {
const msg = activeMessages.value.find(m => m.id === messageId);
if (msg) {
msg.content = finalContent;
msg.streamingContent = undefined;
msg.status = MessageStatus.COMPLETED;
}
}
function switchSession(id: string): void {
activeSessionId.value = id;
}
function deleteSession(id: string): void {
const index = sessions.value.findIndex(s => s.id === id);
if (index !== -1) {
sessions.value.splice(index, 1);
if (activeSessionId.value === id) {
activeSessionId.value = sessions.value[0]?.id ?? null;
}
}
}
// === 持久化 ===
// 使用 pinia-plugin-persistedstate
// 配置在defineStore的第三个参数中
return {
sessions,
activeSessionId,
sessionStatus,
activeSession,
activeMessages,
isGenerating,
createSession,
addMessage,
appendStreamingContent,
completeMessage,
switchSession,
deleteSession,
};
}, {
persist: {
key: 'ai-chat-store',
storage: localStorage,
pick: ['sessions', 'activeSessionId'],
// 自定义序列化:不持久化流式内容
serializer: {
serialize: (state) => JSON.stringify({
...state,
sessions: (state as any).sessions?.map((s: ChatSession) => ({
...s,
messages: s.messages.map(m => ({
...m,
streamingContent: undefined,
status: m.status === MessageStatus.STREAMING
? MessageStatus.COMPLETED
: m.status,
})),
})),
}),
deserialize: JSON.parse,
},
},
});
2.2 Vue的reactivity天然优势
Vue的响应式系统基于Proxy,对对象的局部修改天然只触发依赖该属性的组件重渲染:
xml
<template>
<!-- 只有streamingContent变化时才触发重渲染 -->
<div class="message">
<StreamRenderer :content="message.streamingContent ?? message.content" />
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
const chatStore = useChatStore();
// storeToRefs确保解构后的数据保持响应式
const { activeMessages } = storeToRefs(chatStore);
// 不需要像Zustand那样手动写selector
// Vue的模板编译器自动追踪了message.streamingContent的依赖
</script>
三、虚拟滚动集成
React + react-virtuoso
ini
import { Virtuoso } from 'react-virtuoso';
function MessageList() {
const messages = useActiveMessages();
return (
<Virtuoso
data={messages}
itemContent={(index, message) => (
<MessageItem key={message.id} message={message} />
)}
followOutput="smooth" // 新消息自动滚到底部
overscan={200} // 额外渲染200px的内容
/>
);
}
Vue + vue-virtual-scroller
ini
<template>
<RecycleScroller
:items="messages"
:item-size="null" // null表示动态高度
:buffer="200"
key-field="id"
>
<template #default="{ item }">
<MessageItem :message="item" />
</template>
</RecycleScroller>
</template>
动态高度的坑:AI消息高度差异极大,虚拟滚动组件需要先渲染才能知道实际高度。解决方案:
- 初始使用预估高度,渲染后修正
- 使用ResizeObserver监听消息高度变化
- 缓存已渲染消息的高度
四、会话持久化策略
kotlin
class SessionPersistenceManager {
private storage: Storage;
constructor(storage: Storage = localStorage) {
this.storage = storage;
}
/** 保存会话 */
save(session: ChatSession): void {
const key = `chat-session-${session.id}`;
// 策略:大消息内容压缩存储
const compressed = this.compressSession(session);
this.storage.setItem(key, JSON.stringify(compressed));
}
/** 加载会话 */
load(sessionId: string): ChatSession | null {
const key = `chat-session-${sessionId}`;
const data = this.storage.getItem(key);
if (!data) return null;
return this.decompressSession(JSON.parse(data));
}
/** 获取所有会话ID */
getAllSessionIds(): string[] {
const ids: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key?.startsWith('chat-session-')) {
ids.push(key.replace('chat-session-', ''));
}
}
return ids;
}
/** 压缩:对长消息只保留摘要 */
private compressSession(session: ChatSession): any {
return {
...session,
messages: session.messages.map(msg => {
if (msg.content.length > 2000) {
return {
...msg,
content: msg.content.slice(0, 2000),
isCompressed: true,
originalLength: msg.content.length,
};
}
return msg;
}),
};
}
private decompressSession(data: any): ChatSession {
// 压缩过的消息需要从服务端重新加载完整内容
return data;
}
}
五、流式状态机实现
css
import { createMachine, interpret, assign } from 'xstate';
const chatMachine = createMachine({
id: 'chat',
initial: 'idle',
context: {
currentMessageId: null as string | null,
error: null as Error | null,
},
states: {
idle: {
on: {
SEND_MESSAGE: {
target: 'waiting',
actions: assign({
currentMessageId: (_: any, event: any) => event.messageId,
}),
},
},
},
waiting: {
on: {
STREAM_START: 'streaming',
ERROR: 'error',
},
},
streaming: {
on: {
STREAM_END: 'idle',
PAUSE: 'paused',
STOP: 'stopping',
ERROR: 'error',
},
},
paused: {
on: {
RESUME: 'streaming',
STOP: 'idle',
},
},
stopping: {
on: {
STREAM_END: 'idle',
},
after: {
5000: 'idle', // 5秒超时强制回到idle
},
},
error: {
on: {
RETRY: 'waiting',
DISMISS: 'idle',
},
},
},
});
为什么用状态机? 流式输出的状态流转有严格的时序约束(不能从idle直接到streaming,不能从streaming直接到waiting)。状态机让非法转换不可能发生,调试时也能清晰看到状态轨迹。
实践任务
任务:任选React或Vue,实现完整的AI对话状态管理+虚拟滚动列表,支持多会话切换与本地持久化。
验收标准:
- 发送消息 → 流式输出 → 完成,状态流转正确
- 流式输出期间,只有当前消息组件重渲染
- 虚拟滚动:100+条消息不卡顿
- 多会话切换:输入框内容、滚动位置正确恢复
- 本地持久化:刷新页面后会话不丢失
面试题解析
Q:AI对话的状态管理与传统聊天的状态管理有什么区别?
答题要点:
- 流式中间态:AI消息有streaming状态,内容实时追加,需要分离content与streamingContent避免全量重渲染
- Token预算:上下文窗口有限,需要裁剪策略(滑动窗口/摘要压缩)
- DOM性能:Markdown渲染的DOM节点数远超纯文本,必须虚拟滚动
- 异步生命周期:生成过程可暂停/停止/重新生成,状态流转需要严格管理
- 持久化复杂度:流式内容不需要持久化,已完成消息需要,需要区分处理
👤 关于作者
JavaAgent架构师 --- 十年Java分布式架构老兵,专注AI Agent企业级落地。
主导过数字员工、SOP智能引擎等项目,开发过RPC框架、消息中间件、ORM框架。
正在输出:
专栏一:《前端AI工程化》10期适合前端 SSE/流式渲染/.../企业级AI架构/AI前端面试深度解析
专栏二:《Java体系也能玩转AI》24期加急中,适合java深度玩家
专栏三:《从0构建Agent系统》 15期加急中,适合所以玩家
让Java开发者不转Python也能构建企业级AI应用。
点赞+关注+评论 走一波。
-------------------------淘汰自己不是别人,是自己!!!------------------------------------
下期预告:前端AI工程化(六):Function Calling与RAG前端实践,我们将从状态管理进入AI能力编排层,拆解前端如何驱动LLM的工具调用与知识检索增强。