第1737篇:对话式界面的状态管理——前端如何优雅地处理多轮对话
第1737篇:对话式界面的状态管理——前端如何优雅地处理多轮对话
多轮对话的状态管理,是 AI 应用里被低估复杂度最高的工程问题之一。
我见过很多团队,早期做的时候图省事,把对话历史直接存在组件的 state 里。一开始没问题,后来功能越来越多——需要支持多会话切换、需要支持对话搜索、需要支持跨设备同步、需要支持对话分支——然后整个状态管理就崩了,代码像意大利面条一样绕在一起,改一个地方出三个 bug。
这篇文章我们从 Java 后端视角切入,讲清楚服务端的状态设计如何支撑一个健壮的多轮对话前端,以及前后端状态同步的正确姿势。
先想清楚:多轮对话有哪些状态
多轮对话涉及的状态,比大多数人想象的要多:
会话级状态:
- 当前会话ID
- 会话标题(可能是自动生成的)
- 会话创建/更新时间
- 会话归属用户
- 会话设置(比如当前用的是哪个AI模型、温度参数等)
消息列表状态:
- 消息历史(每条消息的内容、角色、时间戳)
- 正在生成的消息(流式状态)
- 失败的消息(发送失败、生成失败)
- 消息的元信息(token数、模型版本、质量评分)
交互状态:
- 输入框内容(草稿)
- 是否正在等待AI回复
- 是否有消息正在流式生成
- 滚动位置
- 是否有未读新消息
全局状态:
- 多个会话的列表
- 当前激活的会话
- 全局设置(主题、语言等)
这些状态都有自己的生命周期和更新来源,混在一起管理是灾难。
后端状态设计:作为前端状态的"真相来源"
后端要成为所有会话状态的权威来源(Source of Truth),前端的状态只是后端状态的视图层缓存。
数据库设计是基础:
@Entity
@Table(name = "chat_sessions")
@Data
@Builder
public class ChatSession {
@Id
private String sessionId;
private String userId;
private String title; // 会话标题(自动生成)
@Enumerated(EnumType.STRING)
private SessionStatus status; // ACTIVE / ARCHIVED / DELETED
private String modelId; // 使用的模型
private String systemPrompt; // 自定义系统提示词
private Float temperature; // 温度参数
private long createdAt;
private long updatedAt;
private long lastMessageAt;
private int messageCount; // 消息数量(冗余字段,加速列表查询)
private String lastMessagePreview; // 最后一条消息预览(同上)
// 版本号,用于乐观锁和前端状态同步
@Version
private long version;
}
@Entity
@Table(name = "chat_messages")
@Data
@Builder
public class ChatMessage {
@Id
private String messageId;
private String sessionId;
private String userId;
@Enumerated(EnumType.STRING)
private MessageRole role; // USER / ASSISTANT / SYSTEM
@Column(columnDefinition = "TEXT")
private String content;
@Enumerated(EnumType.STRING)
private MessageStatus status; // PENDING / STREAMING / DONE / ERROR / CANCELLED
private String errorMessage; // 如果status=ERROR
private String partialContent; // 如果被取消,保存已生成的部分
// 元信息
private String modelVersion;
private Integer inputTokens;
private Integer outputTokens;
private Integer latencyMs;
private long createdAt;
private long updatedAt;
// 消息在会话中的顺序(不用自增ID,用显式的顺序字段)
private int sequenceNumber;
}关键的 API 设计——增量同步接口,这是前端状态管理的核心支撑:
@RestController
@RequestMapping("/api/sessions")
public class SessionSyncController {
@Autowired
private SessionService sessionService;
/**
* 增量同步接口:前端传入上次同步时间戳,服务端返回变更
* 这是前端状态同步的核心
*/
@GetMapping("/sync")
public SessionSyncResult sync(
@RequestHeader("X-User-Id") String userId,
@RequestParam(defaultValue = "0") long since,
@RequestParam(required = false) String sessionId) {
if (sessionId != null) {
// 同步特定会话的变更
return sessionService.syncSession(userId, sessionId, since);
} else {
// 同步所有会话列表的变更
return sessionService.syncAllSessions(userId, since);
}
}
@Data
@Builder
public static class SessionSyncResult {
private long syncTimestamp; // 本次同步的时间戳,下次传给 since
private List<SessionChange> changes;
private boolean hasMore; // 是否还有更多变更需要分页
@Data
@Builder
public static class SessionChange {
private ChangeType type; // CREATE / UPDATE / DELETE
private String sessionId;
private ChatSession session; // CREATE/UPDATE 时有内容
private List<MessageChange> messageChanges;
}
@Data
@Builder
public static class MessageChange {
private ChangeType type;
private String messageId;
private ChatMessage message; // CREATE/UPDATE 时有内容
}
}
}@Service
public class SessionService {
@Autowired
private ChatSessionRepository sessionRepo;
@Autowired
private ChatMessageRepository messageRepo;
public SessionSyncController.SessionSyncResult syncSession(
String userId, String sessionId, long since) {
long syncTimestamp = System.currentTimeMillis();
// 查询自 since 以来的变更
List<ChatMessage> changedMessages = messageRepo
.findBySessionIdAndUpdatedAtAfter(sessionId, since);
ChatSession session = sessionRepo.findByIdAndUserId(sessionId, userId)
.orElseThrow(() -> new NotFoundException("会话不存在"));
List<SessionSyncController.SessionSyncResult.MessageChange> messageChanges =
changedMessages.stream()
.map(msg -> SessionSyncController.SessionSyncResult.MessageChange.builder()
.type(msg.getCreatedAt() > since ? ChangeType.CREATE : ChangeType.UPDATE)
.messageId(msg.getMessageId())
.message(msg)
.build())
.collect(Collectors.toList());
return SessionSyncController.SessionSyncResult.builder()
.syncTimestamp(syncTimestamp)
.changes(List.of(
SessionSyncController.SessionSyncResult.SessionChange.builder()
.type(ChangeType.UPDATE)
.sessionId(sessionId)
.session(session)
.messageChanges(messageChanges)
.build()
))
.hasMore(false)
.build();
}
}乐观更新与冲突解决
前端为了流畅体验,往往采用乐观更新策略:用户发送消息后,前端立刻在界面上显示这条消息,不等服务器确认。一旦服务器返回错误,再回滚显示。
这对后端的要求是:API 响应必须携带足够的信息来支持前端的乐观更新和回滚。
@PostMapping("/{sessionId}/messages")
public MessageSendResult sendMessage(
@PathVariable String sessionId,
@RequestHeader("X-User-Id") String userId,
@RequestBody SendMessageRequest request) {
// 验证权限
sessionService.validateAccess(sessionId, userId);
// 生成消息ID(前端在发送前就知道这个ID,用于本地乐观更新)
// 如果前端传来了本地生成的 tempId,我们保留它作为 messageId
String messageId = StringUtils.hasText(request.getTempId())
? request.getTempId()
: UUID.randomUUID().toString();
// 存储用户消息
ChatMessage userMessage = saveUserMessage(sessionId, userId, request, messageId);
// 触发异步AI回复
String aiMessageId = triggerAIReply(sessionId, userId, request, userMessage);
return MessageSendResult.builder()
.success(true)
.userMessageId(messageId) // 确认的消息ID
.aiMessageId(aiMessageId) // AI回复消息ID(可以用它来订阅流式更新)
.sessionVersion(getSessionVersion(sessionId)) // 用于前端检测版本冲突
.build();
}多设备同步:WebSocket 实时推送
用户可能同时在手机和电脑上打开同一个AI助手。一台设备发了消息,另一台设备要能实时看到。
这需要 WebSocket 做实时推送。注意 WebSocket 这里不是用来做流式输出的(那用 SSE),而是用来做跨设备状态同步的:
@Component
public class SessionStateWebSocketHandler extends TextWebSocketHandler {
// userId -> 所有活跃的 WebSocket 连接
private final Map<String, Set<WebSocketSession>> userConnections =
new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userId = extractUserId(session);
userConnections.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet())
.add(session);
log.info("用户 {} 建立了新的 WebSocket 连接,总连接数: {}",
userId, userConnections.get(userId).size());
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String userId = extractUserId(session);
Set<WebSocketSession> sessions = userConnections.get(userId);
if (sessions != null) {
sessions.remove(session);
}
}
/**
* 向指定用户的所有设备推送状态变更
*/
public void pushToUser(String userId, StateChangeEvent event) {
Set<WebSocketSession> sessions = userConnections.get(userId);
if (sessions == null || sessions.isEmpty()) return;
String payload = serializeEvent(event);
sessions.removeIf(session -> {
if (!session.isOpen()) return true; // 清理已关闭的连接
try {
session.sendMessage(new TextMessage(payload));
return false;
} catch (IOException e) {
log.warn("推送失败,移除连接: {}", session.getId());
return true;
}
});
}
}
@Data
@Builder
public class StateChangeEvent {
private String eventType; // SESSION_UPDATED / MESSAGE_ADDED / MESSAGE_STREAMING / TITLE_CHANGED
private String sessionId;
private String messageId;
private Object payload;
private long timestamp;
}在消息发送和AI回复完成的关键节点,触发推送:
@Service
public class SessionEventBroadcaster {
@Autowired
private SessionStateWebSocketHandler wsHandler;
@EventListener
public void onMessageAdded(MessageAddedEvent event) {
wsHandler.pushToUser(event.getUserId(), StateChangeEvent.builder()
.eventType("MESSAGE_ADDED")
.sessionId(event.getSessionId())
.messageId(event.getMessage().getMessageId())
.payload(event.getMessage())
.timestamp(System.currentTimeMillis())
.build());
}
@EventListener
public void onSessionTitleChanged(SessionTitleChangedEvent event) {
wsHandler.pushToUser(event.getUserId(), StateChangeEvent.builder()
.eventType("TITLE_CHANGED")
.sessionId(event.getSessionId())
.payload(Map.of("title", event.getNewTitle()))
.timestamp(System.currentTimeMillis())
.build());
}
}自动生成会话标题
这是个经常被忽视但用户很在意的功能。没有人喜欢在对话列表里看到"未命名对话1"、"未命名对话2"。
最好的体验是:用户发出第一条消息后,自动根据这条消息生成一个简短的标题:
@Service
public class SessionTitleGenerator {
@Autowired
private LLMService llmService;
@Autowired
private ChatSessionRepository sessionRepo;
@Async
public void generateTitleAsync(String sessionId, String firstUserMessage) {
try {
String title = generateTitle(firstUserMessage);
sessionRepo.updateTitle(sessionId, title);
// 广播标题变更事件
applicationEventPublisher.publishEvent(
new SessionTitleChangedEvent(sessionId, title)
);
} catch (Exception e) {
log.warn("标题生成失败,sessionId: {}", sessionId, e);
// 失败了用默认标题,不影响主流程
String fallbackTitle = generateFallbackTitle(firstUserMessage);
sessionRepo.updateTitle(sessionId, fallbackTitle);
}
}
private String generateTitle(String message) {
String prompt = String.format("""
根据用户的这条消息,生成一个简短的对话标题。
要求:
- 不超过15个字
- 概括消息的核心主题
- 不要加引号
- 直接输出标题文字
用户消息:%s
""", message.length() > 200 ? message.substring(0, 200) : message);
String title = llmService.complete(prompt).trim();
// 长度兜底
if (title.length() > 20) {
title = title.substring(0, 17) + "...";
}
return title;
}
private String generateFallbackTitle(String message) {
// 不用LLM,直接取前10个字
String preview = message.replaceAll("[\\s\\n]+", " ").trim();
return preview.length() > 10
? preview.substring(0, 10) + "..."
: preview;
}
}对话搜索:前端需要的后端能力
随着对话越来越多,搜索功能变得很重要。用户要能在历史对话里搜索某个关键词。
全文搜索对 MySQL 压力很大,推荐用 Elasticsearch:
@Service
public class ConversationSearchService {
@Autowired
private ElasticsearchRestTemplate esTemplate;
/**
* 全文搜索用户的历史对话
*/
public SearchResult search(String userId, String keyword, int page, int size) {
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(
QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("userId", userId)) // 只搜自己的
.should(QueryBuilders.matchQuery("content", keyword).boost(1.0f))
.should(QueryBuilders.matchQuery("title", keyword).boost(2.0f)) // 标题匹配权重更高
.minimumShouldMatch("1")
)
.withHighlightFields(
new HighlightBuilder.Field("content")
.preTags("<mark>") // 搜索结果高亮
.postTags("</mark>")
.fragmentSize(100) // 高亮摘要长度
.numOfFragments(2)
)
.withPageable(PageRequest.of(page, size))
.withSort(SortBuilders.scoreSort().order(SortOrder.DESC))
.build();
SearchHits<ConversationDocument> hits = esTemplate.search(
query, ConversationDocument.class
);
return buildSearchResult(hits, keyword);
}
}防丢失:草稿自动保存
用户在输入框写了很长一段问题,突然网络断开,刷新后内容消失——这是很差的体验。
后端提供草稿 API,前端定时保存:
@RestController
@RequestMapping("/api/drafts")
public class DraftController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final int DRAFT_EXPIRE_DAYS = 7;
@PutMapping("/{sessionId}")
public ResponseEntity<Void> saveDraft(
@PathVariable String sessionId,
@RequestHeader("X-User-Id") String userId,
@RequestBody DraftContent draft) {
String key = String.format("draft:%s:%s", userId, sessionId);
if (StringUtils.hasText(draft.getContent())) {
redisTemplate.opsForValue().set(
key, draft.getContent(),
Duration.ofDays(DRAFT_EXPIRE_DAYS)
);
} else {
// 内容为空时删除草稿
redisTemplate.delete(key);
}
return ResponseEntity.noContent().build();
}
@GetMapping("/{sessionId}")
public ResponseEntity<DraftContent> getDraft(
@PathVariable String sessionId,
@RequestHeader("X-User-Id") String userId) {
String key = String.format("draft:%s:%s", userId, sessionId);
String content = redisTemplate.opsForValue().get(key);
if (content == null) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(new DraftContent(content));
}
}状态机:消息的生命周期管理
消息有多个状态,这些状态转换有明确的规则:
用状态机来管理,防止非法状态转换:
@Service
public class MessageStateMachine {
// 合法的状态转换
private static final Map<MessageStatus, Set<MessageStatus>> VALID_TRANSITIONS = Map.of(
MessageStatus.PENDING, Set.of(MessageStatus.STREAMING, MessageStatus.ERROR),
MessageStatus.STREAMING, Set.of(MessageStatus.DONE, MessageStatus.ERROR, MessageStatus.CANCELLED),
MessageStatus.DONE, Set.of(), // 终态
MessageStatus.ERROR, Set.of(MessageStatus.PENDING), // 允许重试
MessageStatus.CANCELLED, Set.of(MessageStatus.PENDING) // 允许重新发送
);
public void transition(String messageId, MessageStatus targetStatus) {
ChatMessage message = messageRepo.findById(messageId)
.orElseThrow(() -> new NotFoundException("消息不存在"));
MessageStatus currentStatus = message.getStatus();
if (!VALID_TRANSITIONS.get(currentStatus).contains(targetStatus)) {
throw new IllegalStateTransitionException(
String.format("消息状态不能从 %s 转换到 %s", currentStatus, targetStatus)
);
}
message.setStatus(targetStatus);
message.setUpdatedAt(System.currentTimeMillis());
messageRepo.save(message);
// 广播状态变更
broadcastMessageStateChange(message);
}
}小结
多轮对话的状态管理,后端要做好这几件事:
- 数据模型:设计好 Session 和 Message 的数据结构,版本号支持乐观锁
- 增量同步:提供
since参数的增量 API,而不是每次全量返回 - 实时推送:WebSocket 广播跨设备状态同步
- 草稿保存:Redis 暂存输入框内容
- 标题生成:异步调用 LLM 自动生成会话标题
- 状态机:消息状态转换要有明确规则,防止乱来
这些在项目早期看起来是"可以以后再做的事",但一旦用户规模上来,没有做好这些的产品,技术债会让整个团队寸步难行。
