用SSE服务器发送事件优化AI智能客服系统的打字机流式交互效果
用SSE服务器发送事件优化AI智能客服系统的打字机流式交互效果流式交互的价值AI智能客服的核心体验在于实时感。当用户发送一条消息后系统需要展示AI模型逐字生成回复的过程这种打字机效果能显著降低用户的等待焦虑。传统的WebSocket全双工通信虽然功能强大但对于单方向的流式文本传输来说SSEServer-Sent Events是更轻量、更高效的选择。SSE vs WebSocket vs 轮询特性轮询(Polling)WebSocketSSE通信方向客户端→服务端全双工服务端→客户端协议HTTPWS/WSSHTTP浏览器兼容性全部全部主流浏览器除IE自动重连需手动实现需手动实现原生支持消息格式任意任意纯文本UTF-8连接开销每次请求都有开销握手后持续连接单次HTTP长连接适用场景低实时性双向实时交互服务端推送事件流SSE基础使用服务端实现Node.jsconst express require(express); const app express(); app.get(/api/chat/stream, (req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: * }); const messageId Date.now(); const sendEvent (event, data) { res.write(id: ${messageId}\n); res.write(event: ${event}\n); res.write(data: ${JSON.stringify(data)}\n\n); }; sendEvent(connected, { message: SSE连接已建立 }); const words [您好, , 我, 是, AI, 助, 手, , 请, 问, 有, 什, 么, 可, 以, 帮, 您, ]; words.forEach((word, index) { setTimeout(() { sendEvent(token, { text: word, index }); if (index words.length - 1) { sendEvent(done, { fullText: words.join() }); res.end(); } }, (index 1) * 100); }); req.on(close, () { console.log(客户端断开连接); }); }); app.listen(3000);客户端实现class SSEChatClient { constructor(options) { this.url options.url; this.onToken options.onToken || (() {}); this.onDone options.onDone || (() {}); this.onError options.onError || (() {}); this.eventSource null; this.messageQueue []; this.isProcessing false; } sendMessage(message) { this.messageQueue.push(message); if (!this.isProcessing) { this.processQueue(); } } processQueue() { if (this.messageQueue.length 0) { this.isProcessing false; return; } this.isProcessing true; const message this.messageQueue.shift(); this.startStream(message); } startStream(message) { this.close(); const url new URL(this.url); url.searchParams.set(message, message); url.searchParams.set(session_id, this.getSessionId()); this.eventSource new EventSource(url.toString()); this.eventSource.addEventListener(connected, (event) { const data JSON.parse(event.data); console.log(SSE连接已建立:, data.message); }); this.eventSource.addEventListener(token, (event) { const data JSON.parse(event.data); this.onToken(data.text, data.index); }); this.eventSource.addEventListener(done, (event) { const data JSON.parse(event.data); this.onDone(data.fullText); this.close(); this.processQueue(); }); this.eventSource.addEventListener(error, (event) { if (event.eventPhase EventSource.CLOSED) { console.log(SSE连接正常关闭); } else { this.onError(new Error(SSE连接错误)); } }); } getSessionId() { let sessionId sessionStorage.getItem(chat_session_id); if (!sessionId) { sessionId crypto.randomUUID(); sessionStorage.setItem(chat_session_id, sessionId); } return sessionId; } close() { if (this.eventSource) { this.eventSource.close(); this.eventSource null; } } }打字机效果UI实现class TypewriterEffect { constructor(container, options {}) { this.container container; this.options { speed: 30, cursor: true, cursorChar: |, ...options }; this.currentText ; this.cursorVisible true; this.setupCursor(); } setupCursor() { if (this.options.cursor) { this.cursorElement document.createElement(span); this.cursorElement.className cursor; this.cursorElement.textContent this.options.cursorChar; this.container.appendChild(this.cursorElement); setInterval(() { this.cursorVisible !this.cursorVisible; this.cursorElement.style.opacity this.cursorVisible ? 1 : 0; }, 530); } } appendToken(token) { this.currentText token; this.updateDisplay(); } updateDisplay() { this.container.innerHTML ; const textNode document.createTextNode(this.currentText); this.container.appendChild(textNode); if (this.options.cursor this.cursorElement) { this.container.appendChild(this.cursorElement); } this.container.scrollTop this.container.scrollHeight; } getFullText() { return this.currentText; } reset() { this.currentText ; this.updateDisplay(); } }完整的AI客服聊天组件class AIChatComponent { constructor(containerId) { this.container document.getElementById(containerId); this.messages []; this.initUI(); this.initSSE(); } initUI() { this.container.innerHTML div classchat-container div classchat-header h3AI智能客服/h3 span classstatus idconnectionStatus已断开/span /div div classchat-messages idchatMessages/div div classchat-input textarea idmessageInput placeholder请输入您的问题... rows2/textarea button idsendButton发送/button /div /div ; this.messagesContainer this.container.querySelector(#chatMessages); this.messageInput this.container.querySelector(#messageInput); this.sendButton this.container.querySelector(#sendButton); this.statusIndicator this.container.querySelector(#connectionStatus); this.sendButton.addEventListener(click, () this.handleSend()); this.messageInput.addEventListener(keydown, (e) { if (e.key Enter !e.shiftKey) { e.preventDefault(); this.handleSend(); } }); } initSSE() { this.sseClient new SSEChatClient({ url: /api/chat/stream, onToken: (text) { this.typewriter.appendToken(text); this.updateStatus(响应中...); }, onDone: (fullText) { this.addMessage(ai, fullText); this.typewriter null; this.updateStatus(已连接); }, onError: (error) { console.error(SSE错误:, error); this.updateStatus(连接错误); this.showError(网络连接异常请重试); } }); } handleSend() { const text this.messageInput.value.trim(); if (!text) return; this.addMessage(user, text); this.messageInput.value ; this.typewriter new TypewriterEffect(this.messagesContainer); this.addTypingIndicator(); this.sseClient.sendMessage(text); } addMessage(role, content) { const messageDiv document.createElement(div); messageDiv.className message ${role}; const avatar document.createElement(div); avatar.className avatar; avatar.textContent role user ? U : AI; const contentDiv document.createElement(div); contentDiv.className content; contentDiv.textContent content; if (role ai) { contentDiv.innerHTML ; const typewriter new TypewriterEffect(contentDiv, { cursor: false }); typewriter.currentText content; typewriter.updateDisplay(); } else { contentDiv.textContent content; } messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); this.messagesContainer.appendChild(messageDiv); this.messagesContainer.scrollTop this.messagesContainer.scrollHeight; } addTypingIndicator() { const indicator document.createElement(div); indicator.className message ai typing; indicator.innerHTML div classavatarAI/div div classcontent div classtyping-dots span/spanspan/spanspan/span /div /div ; indicator.id typingIndicator; this.messagesContainer.appendChild(indicator); } removeTypingIndicator() { const indicator this.messagesContainer.querySelector(#typingIndicator); if (indicator) { indicator.remove(); } } updateStatus(text) { this.statusIndicator.textContent text; } showError(message) { const errorDiv document.createElement(div); errorDiv.className error-message; errorDiv.textContent message; this.messagesContainer.appendChild(errorDiv); setTimeout(() errorDiv.remove(), 5000); } }流式响应的服务端架构优化const express require(express); const { OpenAI } require(openai); const app express(); const openai new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); app.post(/api/chat/stream, async (req, res) { const { message, session_id } req.body; res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no }); const sessionHistory await getSessionHistory(session_id); try { const stream await openai.chat.completions.create({ model: gpt-3.5-turbo, messages: [ { role: system, content: 你是一个专业的智能客服助手。 }, ...sessionHistory, { role: user, content: message } ], stream: true, temperature: 0.7 }); let fullResponse ; for await (const chunk of stream) { const content chunk.choices[0]?.delta?.content || ; if (content) { fullResponse content; sendSSEEvent(res, token, { text: content }); } } await saveSessionHistory(session_id, [ ...sessionHistory, { role: user, content: message }, { role: assistant, content: fullResponse } ]); sendSSEEvent(res, done, { fullText: fullResponse }); } catch (error) { sendSSEEvent(res, error, { message: AI服务调用失败 }); } res.end(); }); function sendSSEEvent(res, event, data) { res.write(event: ${event}\n); res.write(data: ${JSON.stringify(data)}\n\n); } async function getSessionHistory(sessionId) { return []; } async function saveSessionHistory(sessionId, history) { console.log(保存会话历史: ${sessionId}); }前端交互优化中断与重试class AdvancedSSEChatClient extends SSEChatClient { constructor(options) { super(options); this.currentAbortController null; } startStream(message) { this.close(); this.currentAbortController new AbortController(); this.startFetchStream(message); } async startFetchStream(message) { try { const response await fetch(this.url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message, session_id: this.getSessionId() }), signal: this.currentAbortController.signal }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events this.parseSSEBuffer(buffer); buffer events.remainder; for (const event of events.parsed) { this.handleSSEEvent(event); } } } catch (error) { if (error.name AbortError) { console.log(用户中断了响应); } else { this.onError(error); this.offerRetry(); } } } parseSSEBuffer(buffer) { const lines buffer.split(\n); const parsed []; let currentEvent { event: message, data: }; let remainder ; for (let i 0; i lines.length; i) { const line lines[i]; if (line.startsWith(event: )) { currentEvent.event line.slice(7).trim(); } else if (line.startsWith(data: )) { currentEvent.data line.slice(6); } else if (line currentEvent.data) { parsed.push({ ...currentEvent }); currentEvent { event: message, data: }; } } if (currentEvent.data) { remainder event: ${currentEvent.event}\ndata: ${currentEvent.data}\n; } return { parsed, remainder }; } handleSSEEvent(event) { const data JSON.parse(event.data); switch (event.event) { case token: this.onToken(data.text); break; case done: this.onDone(data.fullText); break; case error: this.onError(new Error(data.message)); break; } } abort() { if (this.currentAbortController) { this.currentAbortController.abort(); } this.close(); } offerRetry() { const retryButton document.createElement(button); retryButton.className retry-button; retryButton.textContent 重新生成; retryButton.onclick () this.retryLastMessage(); document.querySelector(.chat-messages).appendChild(retryButton); } retryLastMessage() { this.processQueue(); } }CSS样式示例.chat-container { max-width: 600px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 12px; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; } .chat-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px 20px; display: flex; justify-content: space-between; align-items: center; } .chat-messages { height: 400px; overflow-y: auto; padding: 16px; background: #f8f9fa; } .message { display: flex; margin-bottom: 16px; gap: 10px; } .message.user { flex-direction: row-reverse; } .avatar { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold; color: white; } .message.user .avatar { background: #667eea; } .message.ai .avatar { background: #764ba2; } .content { max-width: 70%; padding: 10px 14px; border-radius: 12px; line-height: 1.6; font-size: 14px; } .message.user .content { background: #667eea; color: white; border-bottom-right-radius: 4px; } .message.ai .content { background: white; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; } .chat-input { display: flex; padding: 12px; gap: 8px; border-top: 1px solid #e0e0e0; background: white; } .chat-input textarea { flex: 1; padding: 8px 12px; border: 1px solid #d0d0d0; border-radius: 8px; resize: none; font-size: 14px; } .chat-input button { padding: 8px 20px; background: #667eea; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; } .cursor { animation: blink 1s step-end infinite; color: #666; } keyframes blink { 50% { opacity: 0; } }总结优势说明轻量级原生HTTP协议无需额外协议握手自动重连EventSource内置断线重连机制单工推送完美匹配AI流式输出的单向场景浏览器兼容除IE外全部现代浏览器支持资源节约相比WebSocket节省服务端连接资源SSE是AI智能客服打字机效果的最佳技术选型。它利用HTTP长连接实现服务端到客户端的单向流式推送配合Fetch API的ReadableStream可以实现更精细的控制。在实际项目中建议使用EventSource做快速原型追求更完善的错误处理和中断控制时切换到Fetch流式方案。