news 2026/4/14 10:30:40

AI聊天界面开发实战:流式输出与多轮对话

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI聊天界面开发实战:流式输出与多轮对话

这是一份关于AI聊天界面开发全流程的实战教程。我们将从零开始,手把手构建一个支持流式输出真多轮对话的现代聊天界面。

我会用口语化的方式,穿插大量实战代码和核心知识点,帮你彻底搞懂。

一、 项目蓝图:我们要做什么?

想象一下ChatGPT的聊天界面。我们的目标就是做一个简化版,核心功能如下:

  1. 一个漂亮的聊天窗口:能显示对话历史(用户消息和AI回复)。
  2. 支持流式输出:AI的回复不是“憋”好几秒然后一下子全出来,而是一个字一个字“流”出来,像真人打字一样。
  3. 支持多轮对话:AI能记住我们之前的对话上下文,而不是“金鱼记忆”,每次只回答当前问题。
  4. 一个输入框和发送按钮:让用户可以输入问题。

技术栈选择

  • 前端:Vue 3 + TypeScript + Element Plus (UI库)。Vue的响应式特性非常适合处理实时流式数据。
  • 后端:Node.js + Express。轻量,适合快速构建API。
  • AI能力:调用大模型API(如 OpenAI GPT, 国内可选用智谱、月之暗面等)。本教程将模拟一个流式接口,并讲解如何对接真实API。

二、 核心概念口语化解读

在写代码前,必须搞懂两个核心概念,不然就是瞎写。

1. 流式输出 (Streaming Output)

  • 传统方式 (非流式):你问“讲个笑话”。前端发送请求,后端调用AI,AI在后台想啊想,花了3秒把整个笑话编完,然后后端把完整的笑话文本一次性返回给前端。这3秒内,用户屏幕是空白的,体验很差。
  • 流式方式:同样的问题,AI想到第一个词“从前”,就立刻把这个词传回前端显示出来;想到“有个”,再传回并显示... 这样,用户几乎感觉不到等待,就看到文字一个个“流”出来,体验流畅自然。
  • 技术本质:后端和前端建立一个长连接(比如SSE或WebSocket),后端可以在这个连接上多次、分段地发送数据。前端则像接水管里的水滴一样,一段段接收并实时渲染。

2. 多轮对话 (Multi-turn Conversation)

  • 伪多轮:前端只是把当前这一条用户消息发给AI。AI完全不知道之前聊过什么。你问“《三体》的作者是谁?”,它答“刘慈欣”。你再问“他还有哪些作品?”,它就懵了,因为不知道“他”指代谁。
  • 真多轮:前端需要把整个对话历史(一个消息数组)发给AI。AI模型看到整个上下文,才能理解指代关系,进行连贯的对话。这就是“真多轮”与“伪多轮”的本质区别。
  • 关键数据结构messages: Array<{role: ‘user’ | ‘assistant’, content: string}>role表明说话者是用户还是AI,content是内容。

三、 后端实战:构建流式对话API

我们先搭建一个Node.js后端,它有两个核心任务:1) 处理多轮对话的历史管理;2) 模拟(或真实对接)流式AI响应。

1. 项目初始化与依赖安装

mkdir ai-chat-backend && cd ai-chat-backend npm init -y npm install express cors body-parser # 我们用一个简单的库来模拟流式响应,实际项目中你可能需要安装 openai, @zhipuai/sdk 等 npm install eventsource-parser # 用于解析SSE流

2. 核心服务器代码 (server.js)

const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); const app = express(); const PORT = 3000; // 启用CORS,允许前端跨域访问 app.use(cors()); app.use(bodyParser.json()); // 在内存中存储对话会话(简单示例,生产环境需用数据库) const sessions = new Map(); // key: sessionId, value: { messages: [...] } // 1. 创建新会话或获取历史消息的接口 app.post('/api/session', (req, res) => { const { sessionId } = req.body; if (!sessionId) { const newSessionId = `session_${Date.now()}`; sessions.set(newSessionId, { messages: [] }); return res.json({ sessionId: newSessionId, messages: [] }); } const session = sessions.get(sessionId) || { messages: [] }; res.json({ sessionId, messages: session.messages }); }); // 2. 核心:流式对话接口 (模拟SSE - Server-Sent Events) app.post('/api/chat/stream', (req, res) => { const { sessionId, message } = req.body; if (!sessionId || !message) { return res.status(400).json({ error: 'Missing sessionId or message' }); } // 获取或创建会话 let session = sessions.get(sessionId); if (!session) { session = { messages: [] }; sessions.set(sessionId, session); } // **多轮对话核心:将用户新消息加入历史** session.messages.push({ role: 'user', content: message }); console.log(`[Session ${sessionId}] 用户消息: ${message}`); console.log(`当前对话历史:`, JSON.stringify(session.messages, null, 2)); // 设置SSE响应头 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // 模拟AI的流式思考过程 const aiThinkingText = `这是关于“${message}”的思考回复:`; const simulatedResponse = aiThinkingText + ` 流式输出让我们看到了每个字是如何产生的。多轮对话的关键在于维护完整的 messages 数组上下文。`; let index = 0; const streamInterval = setInterval(() => { if (index < simulatedResponse.length) { // SSE格式: data: {内容} const chunk = simulatedResponse.charAt(index); // 发送一个数据块,前端会收到这个事件 res.write(`data: ${JSON.stringify({ content: chunk })} `); index++; } else { // 流式输出结束 const finalMessage = { role: 'assistant', content: simulatedResponse }; // **多轮对话核心:将AI的完整回复也加入历史** session.messages.push(finalMessage); console.log(`[Session ${sessionId}] AI回复已加入历史。`); // 发送结束信号 res.write(`data: ${JSON.stringify({ done: true })} `); clearInterval(streamInterval); res.end(); // 结束响应 } }, 50); // 每50毫秒发送一个字,模拟打字效果 // 如果客户端断开连接,清理定时器 req.on('close', () => { clearInterval(streamInterval); console.log(`客户端断开连接,停止流式输出。`); }); }); // 3. 清空会话历史接口 app.post('/api/session/clear', (req, res) => { const { sessionId } = req.body; if (sessionId && sessions.has(sessionId)) { sessions.set(sessionId, { messages: [] }); } res.json({ success: true }); }); app.listen(PORT, () => { console.log(`后端服务器运行在 http://localhost:${PORT}`); });

知识点与实战解析

  • 会话管理:我们用Map在内存中模拟了一个简单的会话存储。每个sessionId对应一个对话messages数组。这是实现多轮对话的基石。生产环境要用Redis或数据库。
  • SSE (Server-Sent Events):这是一种服务器向客户端推送数据的简单协议。我们设置了Content-Type: text/event-stream,然后通过res.write不断发送 `data: ...

格式的数据。前端用EventSourcefetch` 来接收。

  • 流式模拟:我们用setInterval逐字发送模拟的AI回复,让前端能看到“打字”效果。真实项目中,这里应该调用大模型API(如OpenAI的createChatCompletion并设置stream: true),并将API返回的流实时转发给前端。
  • 上下文维护:注意看代码,在流式输出开始前,我们把用户消息push进历史。在流式输出结束后,我们把AI的完整回复push进历史。这样,下次用户再提问时,发送的整个messages数组就包含了之前的所有对话,AI便能理解上下文。

四、 前端实战:构建响应式聊天界面

现在我们来构建一个能看到效果的前端界面。

1. 初始化Vue项目并安装依赖

npm create vue@latest ai-chat-frontend # 根据提示选择: TypeScript, Vue Router(否), Pinia(否), ESLint(是) cd ai-chat-frontend npm install npm install element-plus axios npm run dev

2. 核心组件代码 (src/components/ChatWindow.vue)
我们将创建一个完整的聊天组件。

<template> <div class="chat-container"> <el-container direction="vertical" style="height: 100vh;"> <!-- 头部 --> <el-header style="border-bottom: 1px solid #eee; display: flex; align-items: center;"> <h2>🤖 AI聊天助手 (支持流式+多轮)</h2> <el-button @click="clearHistory" size="small" style="margin-left: auto;" type="warning" plain> 清空对话 </el-button> <span style="margin-left: 10px; font-size: 12px; color: #666;">会话ID: {{ sessionId }}</span> </el-header> <!-- 聊天消息区域 --> <el-main ref="messageContainer" style="overflow-y: auto; padding: 20px;"> <div v-for="(msg, index) in messages" :key="index" class="message-wrapper"> <!-- 用户消息 --> <div v-if="msg.role === 'user'" class="message user-message"> <div class="avatar">👤</div> <div class="bubble">{{ msg.content }}</div> </div> <!-- AI消息 --> <div v-else class="message ai-message"> <div class="avatar">🤖</div> <div class="bubble"> <!-- 关键:如果是当前正在接收的流式消息,显示动态内容 --> <span v-if="index === messages.length - 1 && isStreaming"> {{ streamingContent }} <span class="cursor">|</span> <!-- 打字光标 --> </span> <span v-else> {{ msg.content }} </span> </div> </div> </div> <!-- 加载指示器 --> <div v-if="isLoading && !isStreaming" class="loading">AI正在思考...</div> </el-main> <!-- 底部输入区域 --> <el-footer style="border-top: 1px solid #eee; padding: 20px;"> <el-form @submit.prevent="sendMessage"> <el-input v-model="inputMessage" :disabled="isLoading" placeholder="输入您的问题...(按Enter发送,Shift+Enter换行)" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" @keydown.enter.exact.prevent="sendMessage" /> <div style="margin-top: 10px; display: flex; justify-content: flex-end;"> <el-button type="primary" @click="sendMessage" :loading="isLoading" :disabled="!inputMessage.trim()" > {{ isLoading ? '发送中...' : '发送' }} </el-button> </div> </el-form> <div style="margin-top: 10px; font-size: 12px; color: #999;"> 提示:这是一个模拟演示。流式输出是逐字生成的,多轮对话历史保存在后端。 </div> </el-footer> </el-container> </div> </template> <script setup lang="ts"> import { ref, onMounted, nextTick, watch } from 'vue' import axios from 'axios' import { ElMessage } from 'element-plus' // --- 核心状态定义 --- const sessionId = ref<string>('') // 会话ID,用于标识多轮对话 const messages = ref<Array<{role: string, content: string}>>([]) // 对话消息历史 const inputMessage = ref('') // 用户输入 const isLoading = ref(false) // 是否正在加载(非流式请求时用) const isStreaming = ref(false) // 是否正在流式接收中 const streamingContent = ref('') // 当前流式接收到的内容 const messageContainer = ref<HTMLElement>() // 用于自动滚动到最新的消息 // 后端API地址 const API_BASE = 'http://localhost:3000/api' // --- 生命周期:初始化时创建或获取会话 --- onMounted(async () => { // 尝试从本地存储获取已有的sessionId const savedSessionId = localStorage.getItem('ai_chat_session_id') if (savedSessionId) { sessionId.value = savedSessionId // 获取该会话的历史消息 try { const resp = await axios.post(`${API_BASE}/session`, { sessionId: savedSessionId }) messages.value = resp.data.messages || [] } catch (error) { console.error('获取历史消息失败:', error) // 如果失败,创建新会话 createNewSession() } } else { createNewSession() } }) // 创建新会话的函数 const createNewSession = async () => { try { const resp = await axios.post(`${API_BASE}/session`, {}) sessionId.value = resp.data.sessionId messages.value = resp.data.messages || [] localStorage.setItem('ai_chat_session_id', sessionId.value) } catch (error) { console.error('创建会话失败:', error) ElMessage.error('无法连接服务器,请检查后端是否运行') } } // --- 核心函数:发送消息并处理流式响应 --- const sendMessage = async () => { const userMessage = inputMessage.value.trim() if (!userMessage || isLoading.value) return // 1. 更新UI:将用户消息添加到列表,清空输入框 messages.value.push({ role: 'user', content: userMessage }) inputMessage.value = '' // 为AI回复占位 messages.value.push({ role: 'assistant', content: '' }) isLoading.value = true isStreaming.value = true streamingContent.value = '' // 滚动到底部 scrollToBottom() try { // 2. 关键:使用 fetch 来处理 Server-Sent Events (SSE) 流 const response = await fetch(`${API_BASE}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ sessionId: sessionId.value, message: userMessage, }), }) if (!response.ok || !response.body) { throw new Error(`HTTP error! status: ${response.status}`) } // 3. 读取流式响应的数据 const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') let done = false while (!done) { const { value, done: doneReading } = await reader.read() done = doneReading if (value) { // 解码流数据块 const chunk = decoder.decode(value, { stream: true }) // 4. 解析SSE格式的数据行 (data: {...} ) const lines = chunk.split(' ').filter(line => line.startsWith('data: ')) for (const line of lines) { const dataStr = line.replace('data: ', '') if (dataStr.trim() === '') continue try { const parsedData = JSON.parse(dataStr) // 5. 处理流式数据块 if (parsedData.done) { // 流式输出结束 isStreaming.value = false // 注意:后端已经在流结束时将完整消息加入了历史,这里我们只需更新本地最后一条消息的显示 // 实际上,streamingContent已经包含了完整内容 // 我们更新messages中最后一条(即占位的那条)为完整内容 messages.value[messages.value.length - 1].content = streamingContent.value } else if (parsedData.content) { // 接收到一个内容块,追加到当前流式内容中 streamingContent.value += parsedData.content // 实时更新占位消息的内容,用于显示 messages.value[messages.value.length - 1].content = streamingContent.value // 每次收到新内容都滚动一下,提升体验 scrollToBottom() } } catch (e) { console.error('解析SSE数据失败:', e, '原始数据:', dataStr) } } } } } catch (error) { console.error('请求失败:', error) ElMessage.error('发送消息失败,请检查网络或后端服务') // 出错时,移除AI的占位消息 messages.value.pop() } finally { isLoading.value = false // 确保流式状态被重置 if (isStreaming.value) { isStreaming.value = false } scrollToBottom() } } // --- 工具函数 --- // 清空对话历史 const clearHistory = async ---- ## 参考来源 - [GLM-4.7-Flash多轮对话实战:打造智能聊天机器人](https://blog.csdn.net/weixin_42230607/article/details/157629159) - [鸿蒙系统基于大模型的界面开发与实践](https://developer.huawei.com/consumer/cn/blog/topic/03208714748606057) - [AI 智能分类 + 实时反馈:打造高效客服培训新范式](https://www.cnblogs.com/weimaoyun/p/18796402)
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/14 10:29:37

LinkSwift网盘直链下载助手:JavaScript技术方案深度解析与实践指南

LinkSwift网盘直链下载助手&#xff1a;JavaScript技术方案深度解析与实践指南 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 &#xff0c;支持 百度网盘 / 阿里云盘 / 中国移动…

作者头像 李华
网站建设 2026/4/14 10:28:51

AI Agent实习面试高频问题100道

这些实际上更像工程问题&#xff0c;公司愿意给30k月薪的原因就在这里&#xff0c;Agent开发不是玩具技术人&#xff0c;是能把玩具变成生产力的人。这环节最直接有效的方法就是跟着项目完整走一遍&#xff0c;如果你无从下手&#xff0c;趁着有大佬带队&#xff0c;你直接跟着…

作者头像 李华
网站建设 2026/4/14 10:25:49

避开开关电源的坑:AP值计算中3个易错点实测复盘

避开开关电源的坑&#xff1a;AP值计算中3个易错点实测复盘 在开关电源设计中&#xff0c;AP值&#xff08;Area Product&#xff09;作为磁芯选择的核心参数&#xff0c;直接关系到变压器的功率处理能力和整体效率。然而&#xff0c;即使经验丰富的工程师&#xff0c;在实际项…

作者头像 李华
网站建设 2026/4/14 10:25:47

解锁QQ音乐加密音频:qmc-decoder全面解决方案指南

解锁QQ音乐加密音频&#xff1a;qmc-decoder全面解决方案指南 【免费下载链接】qmc-decoder Fastest & best convert qmc 2 mp3 | flac tools 项目地址: https://gitcode.com/gh_mirrors/qm/qmc-decoder 还在为QQ音乐下载的加密音频文件无法在其他播放器或设备上播放…

作者头像 李华
网站建设 2026/4/14 10:25:47

D3KeyHelper终极指南:5分钟掌握暗黑3宏工具,游戏效率翻倍提升

D3KeyHelper终极指南&#xff1a;5分钟掌握暗黑3宏工具&#xff0c;游戏效率翻倍提升 【免费下载链接】D3keyHelper D3KeyHelper是一个有图形界面&#xff0c;可自定义配置的暗黑3鼠标宏工具。 项目地址: https://gitcode.com/gh_mirrors/d3/D3keyHelper D3KeyHelper是一…

作者头像 李华
网站建设 2026/4/14 10:22:44

从 Blender 建模到 Gazebo 仿真:海上工业平台仿真场景搭建全流程

从 Blender 建模到 Gazebo 仿真&#xff1a;海上工业平台仿真场景搭建全流程前言 这篇文章记录了在Blender, Gazebo中搭建仿真环境的全流程。本项目研究的是机器人在工业场景下的应用&#xff0c;需要一个尽可能真实的仿真环境来验证机器人的自主巡检能力。 建模对象是一个海上…

作者头像 李华