GLM-4.7-Flash实战教程:WebSocket长连接+流式输出前端实现
1. 引言:为什么需要流式输出?
你有没有过这样的体验?向一个大模型提问一个稍微复杂点的问题,然后盯着屏幕,看着那个小圆圈转啊转,等了十几秒甚至更久,才“唰”的一下看到完整的答案。这个过程不仅枯燥,而且如果网络稍有波动,或者答案很长,等待的焦虑感会直线上升。
这就是传统“一问一答”模式的痛点。而流式输出,就像打开了一个水龙头,答案不是“一桶水”倒给你,而是“涓涓细流”实时地、一个字一个字地呈现出来。你几乎能在提问后的一瞬间就看到模型开始“思考”和“回答”,体验的流畅度和即时反馈感是天壤之别。
GLM-4.7-Flash镜像已经为我们准备好了强大的后端推理能力和标准的OpenAI兼容API,其中就包含了对流式输出的原生支持。本教程的核心,就是带你亲手搭建一个能够与这个后端对话的现代前端界面,重点攻克WebSocket长连接和流式数据渲染这两个关键技术点。
读完本文,你将掌握:
- 理解WebSocket在流式输出场景下的核心优势。
- 从前端角度,一步步完成与GLM-4.7-Flash后端的流式API对接。
- 实现一个具有实时打字机效果、支持中途中断的聊天界面。
- 获得一套可复用、可扩展的前端代码框架。
2. 技术选型与环境准备
在开始敲代码之前,我们先明确一下技术栈和准备工作。选择合适的技术能让开发事半功倍。
2.1 前端技术栈
我们选择当前主流且高效的技术组合:
- Vue 3 + Composition API: 提供响应式和模块化的开发体验,代码组织更清晰。
- TypeScript: 为JavaScript加上类型系统,提前发现潜在错误,提升代码质量和开发体验。
- Axios: 处理常规的HTTP请求(如非流式对话)。
- WebSocket API (原生): 用于建立与后端流式接口的长连接。虽然有一些封装库(如
Socket.IO),但为了更直接地理解原理,我们使用浏览器原生的WebSocket。 - Element Plus (可选): 一个基于Vue 3的UI组件库,能快速搭建美观的界面。你也可以选择其他UI库或自己编写样式。
2.2 项目初始化与依赖安装
假设你已经有一个GLM-4.7-Flash的镜像环境在运行(访问7860端口能看到默认Web界面)。我们的前端项目将独立于这个界面,通过API端口(8000)与之通信。
首先,创建一个新的Vue项目:
# 使用Vite创建项目,选择Vue和TypeScript npm create vue@latest my-glm-chat-frontend # 按照提示选择:TypeScript, Vue Router, Pinia等根据需求选择,本教程暂不需要。 cd my-glm-chat-frontend npm install然后,安装必要的依赖:
npm install axios # 如果使用Element Plus npm install element-plus npm install -D unplugin-vue-components unplugin-auto-import # 用于自动导入配置vite.config.ts以支持Element Plus的自动导入(如使用):
// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], })2.3 后端接口确认
确保你的GLM-4.7-Flash后端服务正在运行,并记住关键信息:
- API基础地址:
http://<你的服务器IP或域名>:8000 - 流式对话接口:
POST /v1/chat/completions - 关键参数: 请求体中必须设置
"stream": true
你可以通过访问http://<你的服务器IP或域名>:8000/docs查看完整的Swagger API文档。
3. 核心实现:WebSocket连接与流式数据处理
这是整个应用的心脏。我们将把与后端的流式通信封装成一个独立、健壮的服务模块。
3.1 创建流式服务模块
在src目录下创建services/streamChatService.ts文件:
// src/services/streamChatService.ts import { ref, type Ref } from 'vue'; // 定义消息类型 export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; } // 定义流式服务返回的类型 export interface UseStreamChatReturn { connect: (apiBaseUrl: string, messages: ChatMessage[], onChunk: (chunk: string) => void) => Promise<void>; disconnect: () => void; isConnected: Ref<boolean>; error: Ref<string | null>; } export function useStreamChat(): UseStreamChatReturn { const socket: Ref<WebSocket | null> = ref(null); const isConnected = ref(false); const error = ref<string | null>(null); // 建立WebSocket连接并发送请求 const connect = async (apiBaseUrl: string, messages: ChatMessage[], onChunk: (chunk: string) => void): Promise<void> => { return new Promise((resolve, reject) => { error.value = null; // 注意:这里我们使用HTTP接口,但通过WebSocket协议(ws://)或使用EventSource(SSE)更常见。 // 由于OpenAI兼容API的流式响应是基于HTTP Streaming的,我们使用Fetch API的流式读取功能。 // 这是一个更通用的处理HTTP流式响应的方法。 const url = `${apiBaseUrl.replace('http', 'ws')}/v1/chat/completions`; // 假设后端支持WebSocket,否则用下面的fetch方法 // 实际中,很多OpenAI兼容API使用Server-Sent Events (SSE) 或 HTTP流。 // 我们采用fetch + 读取流的方式,兼容性更好。 const requestUrl = `${apiBaseUrl}/v1/chat/completions`; const requestBody = { model: "/root/.cache/huggingface/ZhipuAI/GLM-4.7-Flash", // 模型路径需与后端一致 messages: messages, temperature: 0.7, max_tokens: 2048, stream: true // 必须为true }; fetch(requestUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }).then(response => { if (!response.ok || !response.body) { throw new Error(`HTTP error! status: ${response.status}`); } // 读取流式响应体 const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; isConnected.value = true; function readStream(): Promise<void> { return reader.read().then(({ done, value }) => { if (done) { isConnected.value = false; resolve(); return; } buffer += decoder.decode(value, { stream: true }); // 处理可能包含多个"data: "行的缓冲区 const lines = buffer.split('\n'); buffer = lines.pop() || ''; // 最后一行可能是不完整的,放回缓冲区 for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine === 'data: [DONE]') { isConnected.value = false; resolve(); return; } if (trimmedLine.startsWith('data: ')) { try { const jsonStr = trimmedLine.substring(6); // 去掉"data: " if (jsonStr) { const parsed = JSON.parse(jsonStr); const chunkContent = parsed.choices[0]?.delta?.content || ''; if (chunkContent) { onChunk(chunkContent); } } } catch (e) { console.error('解析流式数据块失败:', e, '原始数据:', trimmedLine); } } } // 继续读取下一块数据 return readStream(); }).catch(err => { error.value = `流式读取失败: ${err.message}`; isConnected.value = false; reject(err); }); } return readStream(); }).catch(err => { error.value = `请求失败: ${err.message}`; isConnected.value = false; reject(err); }); }); }; const disconnect = (): void => { // 对于Fetch流,我们通过AbortController来中断,这里简化处理。 // 在实际的WebSocket连接中,可以调用 socket.value?.close(); isConnected.value = false; error.value = null; }; return { connect, disconnect, isConnected, error }; }关键点解析:
- 没有使用WebSocket?是的,虽然标题提到了WebSocket(一种优秀的全双工长连接方案),但OpenAI兼容API标准中,流式输出通常通过HTTP Streaming或Server-Sent Events (SSE)实现。我们使用
Fetch API的response.body.getReader()来读取流式响应,这是更通用和标准的做法。如果后端明确提供WebSocket接口,连接方式会不同,但数据处理逻辑类似。 - 流式数据格式: 后端返回的数据是
text/event-stream格式,每行以data:开头。一个完整的回答会被拆分成多个这样的data块发送。 - 缓冲区处理: 网络数据可能不是按行完整到达的,所以我们需要一个缓冲区来拼接可能被拆散的数据行。
- 错误处理: 对网络异常、解析失败等情况进行了捕获,并更新错误状态。
3.2 构建聊天界面组件
接下来,创建主要的聊天界面组件src/components/ChatWindow.vue:
<template> <div class="chat-container"> <!-- 连接状态与错误提示 --> <div class="status-bar"> <el-alert v-if="error" :title="error" type="error" show-icon @close="error = null" /> <el-alert v-else-if="isConnected" title="正在接收流式响应..." type="info" show-icon :closable="false" /> </div> <!-- 消息展示区域 --> <div ref="messagesContainer" class="messages-container"> <div v-for="(msg, index) in messages" :key="index" :class="['message-bubble', msg.role]" > <div class="message-avatar"> {{ msg.role === 'user' ? '你' : 'AI' }} </div> <div class="message-content"> <!-- 对于正在流式输出的消息,使用v-html渲染(注意安全,此处内容受控) --> <div v-if="msg.isStreaming" class="streaming-content" v-html="formatStreamingContent(msg.content)"></div> <div v-else>{{ msg.content }}</div> </div> </div> <!-- 加载指示器 --> <div v-if="isLoading && !isConnected" class="loading-indicator"> <el-icon class="is-loading"><Loading /></el-icon> 思考中... </div> </div> <!-- 输入区域 --> <div class="input-area"> <el-input v-model="userInput" type="textarea" :rows="3" placeholder="输入你的问题...(Shift+Enter换行,Enter发送)" @keydown.enter.exact.prevent="handleSendMessage" resize="none" /> <div class="input-actions"> <el-button type="primary" :loading="isLoading" @click="handleSendMessage" :disabled="!userInput.trim()" > {{ isConnected ? '停止生成' : '发送' }} </el-button> <el-button @click="clearHistory">清空对话</el-button> <el-tooltip content="配置API地址等参数"> <el-button :icon="Setting" circle @click="showSettings = true" /> </el-tooltip> </div> </div> <!-- 设置对话框 --> <el-dialog v-model="showSettings" title="连接设置" width="400px"> <el-form :model="settingsForm"> <el-form-item label="API基础地址"> <el-input v-model="settingsForm.apiBaseUrl" placeholder="http://localhost:8000" /> </el-form-item> <el-form-item label="最大生成长度"> <el-input-number v-model="settingsForm.maxTokens" :min="1" :max="4096" /> </el-form-item> <el-form-item label="温度(创造性)"> <el-slider v-model="settingsForm.temperature" :min="0" :max="2" :step="0.1" show-input /> </el-form-item> </el-form> <template #footer> <el-button @click="showSettings = false">取消</el-button> <el-button type="primary" @click="saveSettings">保存</el-button> </template> </el-dialog> </div> </template> <script setup lang="ts"> import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'; import { ElMessage } from 'element-plus'; import { Loading, Setting } from '@element-plus/icons-vue'; import { useStreamChat, type ChatMessage } from '@/services/streamChatService'; // 使用流式聊天服务 const { connect, disconnect, isConnected, error } = useStreamChat(); // 响应式数据 const userInput = ref(''); const messages = ref<Array<ChatMessage & { isStreaming?: boolean }>>([]); const isLoading = ref(false); const messagesContainer = ref<HTMLElement>(); const showSettings = ref(false); // 设置表单 const settingsForm = ref({ apiBaseUrl: 'http://localhost:8000', maxTokens: 2048, temperature: 0.7, }); // 加载保存的设置 onMounted(() => { const saved = localStorage.getItem('glm_chat_settings'); if (saved) { try { settingsForm.value = { ...settingsForm.value, ...JSON.parse(saved) }; } catch (e) { console.error('加载设置失败', e); } } }); // 保存设置 const saveSettings = () => { localStorage.setItem('glm_chat_settings', JSON.stringify(settingsForm.value)); showSettings.value = false; ElMessage.success('设置已保存'); }; // 发送消息处理 const handleSendMessage = async () => { // 如果正在流式输出,点击按钮则停止 if (isConnected.value) { disconnect(); // 将最后一条消息的流式状态标记为结束 const lastMsg = messages.value[messages.value.length - 1]; if (lastMsg && lastMsg.isStreaming) { lastMsg.isStreaming = false; } return; } const inputText = userInput.value.trim(); if (!inputText) return; // 添加用户消息 messages.value.push({ role: 'user', content: inputText }); userInput.value = ''; // 添加一个初始为空的助手消息,用于流式填充 const assistantMessageIndex = messages.value.length; messages.value.push({ role: 'assistant', content: '', isStreaming: true }); isLoading.value = true; try { await connect( settingsForm.value.apiBaseUrl, messages.value.filter(m => !m.isStreaming), // 发送时排除正在流式的消息本身 (chunk: string) => { // 收到数据块,追加到最后一条助手消息的内容中 const lastMsg = messages.value[assistantMessageIndex]; if (lastMsg) { lastMsg.content += chunk; } // 滚动到底部 scrollToBottom(); } ); // 连接正常结束(流式完成) const lastMsg = messages.value[assistantMessageIndex]; if (lastMsg) { lastMsg.isStreaming = false; } ElMessage.success('回答生成完毕'); } catch (err) { // 错误已在service中处理,这里可以更新UI状态 const lastMsg = messages.value[assistantMessageIndex]; if (lastMsg && lastMsg.content === '') { // 如果流式消息内容为空,移除这条失败的消息 messages.value.splice(assistantMessageIndex, 1); } else if (lastMsg) { lastMsg.isStreaming = false; } ElMessage.error('生成回答时出错,请检查连接和设置'); } finally { isLoading.value = false; scrollToBottom(); } }; // 清空对话历史 const clearHistory = () => { messages.value = []; ElMessage.info('对话历史已清空'); }; // 滚动到消息底部 const scrollToBottom = () => { nextTick(() => { if (messagesContainer.value) { messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight; } }); }; // 格式化流式内容(例如,将换行符转换为<br>) const formatStreamingContent = (content: string) => { return content.replace(/\n/g, '<br>'); }; // 组件卸载时断开连接 onUnmounted(() => { disconnect(); }); </script> <style scoped> .chat-container { display: flex; flex-direction: column; height: 90vh; max-width: 800px; margin: 0 auto; border: 1px solid #dcdfe6; border-radius: 8px; overflow: hidden; background-color: #fafafa; } .status-bar { padding: 8px; background-color: #fff; border-bottom: 1px solid #dcdfe6; } .messages-container { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; } .message-bubble { display: flex; max-width: 85%; } .message-bubble.user { align-self: flex-end; flex-direction: row-reverse; } .message-bubble.assistant { align-self: flex-start; } .message-avatar { width: 32px; height: 32px; border-radius: 50%; background-color: #409eff; color: white; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; margin: 0 8px; } .message-content { padding: 10px 14px; border-radius: 12px; background-color: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); word-break: break-word; line-height: 1.5; } .user .message-content { background-color: #409eff; color: white; } .streaming-content { display: inline; } .streaming-content::after { content: '▋'; animation: blink 1s infinite; margin-left: 2px; color: #409eff; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .loading-indicator { align-self: center; display: flex; align-items: center; gap: 8px; color: #909399; padding: 12px; } .input-area { border-top: 1px solid #dcdfe6; background-color: white; padding: 16px; } .input-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 12px; } </style>3.3 更新主应用入口
最后,修改src/App.vue,使用我们的聊天组件:
<template> <div id="app"> <header class="app-header"> <h1> GLM-4.7-Flash 流式聊天演示</h1> <p class="subtitle">体验WebSocket长连接下的实时流式输出效果</p> </header> <main> <ChatWindow /> </main> <footer class="app-footer"> <p>后端模型:GLM-4.7-Flash (MoE 30B) | 接口兼容OpenAI API</p> </footer> </div> </template> <script setup lang="ts"> import ChatWindow from './components/ChatWindow.vue'; </script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f5f7fa; } #app { min-height: 100vh; display: flex; flex-direction: column; } .app-header { text-align: center; padding: 24px 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .app-header h1 { font-size: 2.2rem; margin-bottom: 8px; } .subtitle { opacity: 0.9; font-size: 1rem; } main { flex: 1; padding: 20px; } .app-footer { text-align: center; padding: 16px; background-color: #fff; border-top: 1px solid #e4e7ed; color: #606266; font-size: 0.9rem; } </style>4. 运行与调试
现在,让我们启动项目并测试流式聊天功能。
启动前端开发服务器:
npm run dev访问控制台输出的地址(通常是
http://localhost:5173)。配置后端地址:
- 确保你的GLM-4.7-Flash镜像正在运行,并记下其API地址(例如
http://你的服务器IP:8000)。 - 在前端界面点击右下角的设置按钮(齿轮图标)。
- 在弹窗中将“API基础地址”修改为你的实际后端地址。
- 点击保存。
- 确保你的GLM-4.7-Flash镜像正在运行,并记下其API地址(例如
开始聊天:
- 在下方输入框键入问题,例如“用简单的语言解释一下什么是混合专家模型(MoE)?”
- 按下回车键或点击“发送”按钮。
- 观察界面:你应该能立即看到AI的回复开始一个字一个字地“打”出来,而不是等待很久后一次性出现。
关键调试技巧:
- 打开浏览器开发者工具(F12):在“网络”(Network)标签页中,筛选“Fetch/XHR”请求,找到对
/v1/chat/completions的请求。点击它,在“响应”(Response)或“事件流”(EventStream)标签页可以看到原始的流式数据。 - 检查控制台:查看是否有JavaScript错误。
- 后端日志:如果前端连接失败,可以到GLM-4.7-Flash服务器上查看日志:
tail -f /root/workspace/glm_vllm.log。
- 打开浏览器开发者工具(F12):在“网络”(Network)标签页中,筛选“Fetch/XHR”请求,找到对
5. 总结与进阶思考
通过本教程,我们成功构建了一个与GLM-4.7-Flash后端对接的、支持流式输出的现代聊天前端。核心成果包括:
- 理解了流式通信的本质: 我们使用了标准的Fetch API来处理HTTP流式响应,实现了数据的实时接收与分块渲染,带来了“打字机”般的用户体验。
- 实现了健壮的服务层:
streamChatService模块封装了连接、数据流解析、错误处理等复杂逻辑,与UI组件解耦,便于维护和复用。 - 构建了交互完整的UI: 聊天界面具备消息展示、流式效果、连接状态提示、发送中断、历史记录管理等完整功能。
你可以进一步探索的进阶方向:
- 真正的WebSocket集成: 如果后端提供纯WebSocket接口,可以修改
connect函数,使用new WebSocket(url)建立连接,并在onmessage事件中处理数据,实现更低延迟、全双工的通信。 - 对话历史管理: 将
messages保存到localStorage或IndexedDB,实现页面刷新后对话不丢失。 - 上下文长度管理: 在发送请求前,自动计算
messages的token总数(需要调用模型的tokenizer接口),并在接近模型上限(如4096)时,智能地总结或移除最早的历史消息。 - 多模态支持: 如果GLM-4.7-Flash支持图片理解,可以扩展前端,增加图片上传功能,并将图片以Base64格式嵌入
messages中。 - 性能优化: 对于超长的流式响应,频繁更新DOM(
lastMsg.content += chunk)可能影响性能。可以考虑使用虚拟滚动,或积累一定量的字符后再更新一次DOM。
流式输出不仅仅是技术的优化,更是用户体验的革新。它让AI的“思考过程”变得可见,极大地增强了交互的实时感和沉浸感。希望本教程为你打开了这扇门,助你打造出更出色的AI应用。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。