ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Vue2的AI问答机器人实现方案:流式与非流式接口实战

基于Vue2的AI问答机器人实现方案:流式与非流式接口实战 对很多还在维护老项目的团队来说接AI能力最头疼的不是模型怎么调而是“手里的Vue2代码到底还能不能打”。这套用Vue2实现AI问答机器人的方案解决了用老技术栈对接新接口的核心问题覆盖流式与非流式两种接口方式完整跑通了从请求封装、流式解析、UI状态管理到异常处理的全流程。不管你是要快速给后台管理系统加个AI助手还是想在老项目里先做技术验证这套代码都能直接帮你省掉踩坑的时间。1. 为什么用Vue2做AI问答机器人反而比Vue3更考验功夫1.1 项目背景与核心需求这个项目的起因是一个企业内部管理后台需要集成AI问答助手而整个后台的技术栈还是Vue2 Element UI Webpack短期内并没有升级到Vue3的打算。需求倒不复杂用户在一个对话框里输入问题AI返回答案页面流式展示输出过程。但难点在于AI接口的调用方式和传统REST API差别很大尤其是流式返回需要前端做特殊处理才能实现“打字机”效果。很多人看到“Vue2”就先皱眉头觉得是不是过时了。但我个人认为Vue2在真实业务里的存量非常大尤其在企业中后台、低代码平台、老管理系统这些领域短期内根本换不掉。与其纠结框架版本不如踏踏实实把AI接入这套老技术栈里跑通这才是大多数团队真正需要的。另外Vue2的响应式原理和组件通信方式已经很成熟只要理清数据流向AI问答这种场景完全可以平稳落地。1.2 技术栈选型背后的考量整个方案的技术选型围绕“稳”和“通用”来定。核心栈是Vue2.6 Axios EventSource配合Element UI做消息列表展示。没有引入额外的流式解析库因为原生的ReadableStream和EventSource完全够用少一个依赖就少一份维护成本。这里需要特别解释一下为什么非流式接口用Axios而流式接口不用Axios。非流式接口是标准的JSON返回Axios的封装很友好拦截器、错误处理、超时控制都方便。但流式接口返回的是text/event-stream格式Axios其实也能请求但响应体拿不全因为它默认把整个响应体读完才回调没法做到“边收边显示”。所以流式这边我优先用了EventSource它对SSEServer-Sent Events协议是原生支持自动重连也是内置的省了很多事。不过EventSource有个限制它只支持GET请求不能自定义Header。如果后端要求鉴权Token放在Header里EventSource就力不从心了。这种情况我会改用fetch ReadableStream用POST请求发送问题然后手动读取流。两种方案在下面的正文里都会给出完整代码。1.3 整体设计思路与数据流向整个问答机器人模块的设计并不复杂我个人把它拆成了三个层次视图层对话消息列表、输入框、发送按钮、停止生成按钮。状态层messages列表数据、isLoading状态、当前流式内容缓存。请求层统一封装API方法区分流式与非流式处理响应数据和错误。核心数据流向是用户输入问题推入messages列表调用接口方法接口返回后如果是流式逐段更新最后一条消息的content字段如果是非流式一次性把content赋值好。整个过程没有复杂的Vuex状态管理因为对话数据只在当前页面使用用组件的data就足够。这样做的好处是逻辑清晰、调试方便而且后续如果要接多轮对话、会话历史、知识库只需要在请求层增加参数视图层和数据层不用大改。2. 非流式接口实现最简单的AI接入方式但有明显的体验短板2.1 什么是非流式接口非流式接口是大多数AI服务默认提供的调用方式。前端把问题通过HTTP请求发给后端后端等模型推理完了把完整答案一次性返回。这个“等”的过程可能是几秒也可能是几十秒具体取决于模型大小、输入长度和服务器算力。用生活化的类比来说非流式就像你去餐厅点餐后厨把菜全部做好了一次性端上来。你只能干等着中间什么反馈都没有。而流式就像吃回转寿司做好一盘上一盘你看着盘子走过来边看边吃体验上要直观得多。那既然流式体验更好为什么还需要非流式呢因为非流式接口实现简单、逻辑清晰适合回答长度较短、模型响应快的场景也有很多后端服务本身没做流式转发前端只能被动接受。另外在一些轻量级工具页面里非流式的稳定性更高排查问题也方便。2.2 非流式接口的完整代码实现整个非流式调用流程分四步封装请求方法、组件内调用、处理返回、更新页面。核心代码如下。首先在项目里新建src/api/chat.js统一管理接口import request from /utils/request // 非流式问答接口 export function getAnswer(data) { return request({ url: /api/chat, method: post, data, // 非流式接口响应时间可能较长适当放宽超时时间 timeout: 120000 }) }这里request是项目里基于Axios封装好的实例如果你们项目里还没有可以用下面的方式创建一个基础的// src/utils/request.js import axios from axios const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 30000 }) service.interceptors.response.use( response response.data, error Promise.reject(error) ) export default service然后在对话组件里发送消息的方法长这样// ChatPanel.vue import { getAnswer } from /api/chat export default { data() { return { inputValue: , chatList: [], // 消息列表 isLoading: false // 请求状态 } }, methods: { // 发送消息 handleSend() { if (!this.inputValue.trim()) return // 推入用户消息 this.chatList.push({ id: Date.now(), role: user, content: this.inputValue.trim(), status: done }) // 推入一个空的AI消息占位 const aiMsgId Date.now() 1 this.chatList.push({ id: aiMsgId, role: assistant, content: , status: loading }) this.isLoading true const question this.inputValue.trim() this.inputValue getAnswer({ question }).then(res { const aiMsg this.chatList.find(item item.id aiMsgId) aiMsg.content res.data.answer aiMsg.status done }).catch(err { console.error(问答接口报错:, err) const aiMsg this.chatList.find(item item.id aiMsgId) aiMsg.content 抱歉我遇到了一点问题请稍后再试。 aiMsg.status error }).finally(() { this.isLoading false }) } } }2.3 非流式方案的关键配置与注意事项非流式方案表面看着简单但有几个细节不到位生产环境就得出事。第一个是超时时间AI接口的响应时间波动很大普通接口30秒超时够用但AI问答不一定够。我实际测过一些大模型的接口高峰期响应时间能到60秒以上所以我把非流式的超时时间放宽到了120秒。如果你们后端有网关层限制记得网关也要同步调整。第二个是Axios响应拦截器的处理。不同项目的后端风格不一样有的直接返回{code, data, message}格式有的返回{answer: ...}。我建议在拦截器或API方法里统一把数据格式剥好组件只关注业务字段避免在模板里写一大串res.data.data.answer这种难看又易错的路径。第三个是重复点击问题。用户发消息后在接口返回前连点多次发送就会发出多个请求。我的做法是发送前判断isLoading为true时直接return同时给输入框加disabled状态。另外还应支持“停止生成”按钮实现思路是中断请求非流式场景下直接用Axios的CancelToken或AbortController来实现import axios from axios // 在data里定义 this.source axios.CancelToken.source() // 请求里传入 getAnswer({ question }, { cancelToken: this.source.token }) // 停止按钮 handleStop() { if (this.source) { this.source.cancel(用户手动停止) this.isLoading false } }虽然非流式本身没有流式那种持续输出的视觉反馈但“停止”按钮能给用户一个可控感。实测下来有了这个按钮之后用户误操作发错的概率和心理焦虑感会降低不少。3. 流式接口实现这才是AI问答机器人的灵魂3.1 流式接口的原理SSE与ReadableStream要写好流式功能先得把原理搞明白。目前市面上大多数大模型接口比如OpenAI兼容格式的接口都支持SSEServer-Sent Events协议。SSE是一种服务端主动推送的技术服务器可以持续向客户端发送数据块客户端通过EventSource或fetch读取。SSE的数据格式长这样浏览器收到的是一段段以换行分隔的文本data: {id:chatcmpl-123,choices:[{delta:{content:你好},index:0}]} data: {id:chatcmpl-123,choices:[{delta:{content:今天},index:0}]} data: [DONE]每两个换行之间就是一帧数据。data:后面跟的是JSON字符串最后以[DONE]标识结束。前端要做的事情就是不断读取data:后的内容解析JSON提取delta.content字段逐段追加到界面上。这里要区分两个概念一个是SSE协议一个是EventSource对象。EventSource是浏览器内置的SSE客户端用起来非常简单但它只支持GET请求且不能自定义Header。而fetch ReadableStream的方式更灵活支持POST和自定义Header兼容性也够好所以我个人更推荐在AI问答场景用fetch方式。3.2 基于EventSource的流式实现如果后端接口支持GET请求或者不需要额外鉴权Header用EventSource是实现流式最快捷的路径。直接在组件里使用// 使用EventSource接收SSE流 handleSendByEventSource() { const question this.inputValue.trim() if (!question) return this.chatList.push({ id: Date.now(), role: user, content: question, status: done }) const aiMsgId Date.now() 1 this.chatList.push({ id: aiMsgId, role: assistant, content: , status: loading }) this.isLoading true this.inputValue // 注意这里URL需要包含参数因为EventSource不支持POST body const url /api/chat/sse?question${encodeURIComponent(question)} const es new EventSource(url) let answer // 监听消息事件 es.onmessage (event) { if (event.data [DONE]) { es.close() this.isLoading false return } try { const parsed JSON.parse(event.data) const delta parsed.choices parsed.choices[0] parsed.choices[0].delta const content delta delta.content ? delta.content : if (content) { answer content const aiMsg this.chatList.find(item item.id aiMsgId) aiMsg.content answer aiMsg.status generating } } catch (e) { console.error(解析SSE数据出错:, e, event.data) } } es.onerror (err) { console.error(EventSource连接出错:, err) es.close() this.isLoading false const aiMsg this.chatList.find(item item.id aiMsgId) if (!aiMsg.content) { aiMsg.content 连接中断请稍后重试。 aiMsg.status error } } }3.3 基于fetch ReadableStream的流式实现最推荐项目实际落地时我更推荐fetch ReadableStream。它可以发POST请求、带Token适配绝大多数AI服务商的协议。核心思想是拿到响应体后通过response.body.getReader()拿到流读取器不断读取数据块用TextDecoder解码后解析出文本内容。直接贴一个完整可用的实现// src/api/chatStream.js export async function chatStream({ question, onMessage, onFinish, onError, onStop }) { const controller new AbortController() const { signal } controller try { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, // 如果有token Authorization: Bearer ${getToken()} }, body: JSON.stringify({ question }), signal }) if (!response.ok) { throw new Error(HTTP错误状态码: ${response.status}) } const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break // 把Uint8Array解码为字符串 buffer decoder.decode(value, { stream: true }) // 按换行符拆分防止半包粘包 const lines buffer.split(\n) buffer lines.pop() // 最后一段可能是不完整的留到下一轮 for (const line of lines) { const trimmed line.trim() if (!trimmed.startsWith(data:)) continue const dataStr trimmed.slice(5).trim() if (dataStr [DONE]) { onFinish onFinish() controller.abort() return } try { const parsed JSON.parse(dataStr) const delta parsed.choices parsed.choices[0] parsed.choices[0].delta const content delta delta.content ? delta.content : if (content) { onMessage onMessage(content) } } catch (e) { // 部分SDK可能返回非标准JSON跳过这一帧 console.warn(解析流式数据帧失败:, dataStr) } } } onFinish onFinish() } catch (err) { if (err.name AbortError) { // 这是主动取消不是错误 onStop onStop() } else { onError onError(err) } } // 返回一个取消函数供外部调用 return () controller.abort() }然后在Vue组件里调用import { chatStream } from /api/chatStream methods: { async handleSend() { const question this.inputValue.trim() if (!question || this.isLoading) return this.chatList.push({ id: Date.now(), role: user, content: question, status: done }) const aiMsgId Date.now() 1 this.chatList.push({ id: aiMsgId, role: assistant, content: , status: generating }) this.isLoading true this.inputValue // 记录本次请求的取消函数 this.cancelStream null let answer const aiMsg this.chatList.find(item item.id aiMsgId) this.cancelStream await chatStream({ question, onMessage: (content) { answer content aiMsg.content answer // 手动触发更新确保Vue2响应式刷新 this.$set(this.chatList, this.chatList.indexOf(aiMsg), aiMsg) }, onFinish: () { aiMsg.status done this.isLoading false }, onError: (err) { console.error(流式问答出错:, err) if (!aiMsg.content) { aiMsg.content 抱歉AI服务暂时不可用。 aiMsg.status error } this.isLoading false }, onStop: () { // 用户主动取消时保留已生成内容 aiMsg.status done this.isLoading false } }) }, handleStop() { if (this.cancelStream) { this.cancelStream() this.isLoading false } } }3.4 流式实现里的Vue2响应式陷阱与性能优化Vue2的响应式系统基于Object.defineProperty对于已存在的实例属性直接修改obj.content xxx是能触发更新的。但如果一开始content是空字符串后面不断拼长文本频繁更新会带来性能问题尤其当答案很长时页面会明显卡顿。实测下来我建议做“节流渲染”。思路是内容先存在一个临时变量里每隔50~100毫秒把最新的内容刷到视图上一次而不是每收到一个数据帧就更新一次DOM。下面是一个简单的节流实现可以放在onMessage回调里// 在组件的data里增加 data() { return { renderTimer: null, renderCache: } } // 在onMessage里 onMessage: (content) { answer content this.renderCache answer if (this.renderTimer) return this.renderTimer setTimeout(() { aiMsg.content this.renderCache this.$forceUpdate() // 谨慎使用这里用于确保更新 this.renderTimer null }, 80) } // 在onFinish里需要清掉定时器确保最后一段内容一定渲染出来 onFinish: () { if (this.renderTimer) { clearTimeout(this.renderTimer) this.renderTimer null } aiMsg.content answer aiMsg.status done this.isLoading false }另外一个坑是$set的使用。上面代码里我用了this.$set(this.chatList, this.chatList.indexOf(aiMsg), aiMsg)这是因为在forEach或find方法里直接改对象的属性Vue2是能检测到的但如果你改的是数组本身比如this.chatList[0] newObjVue2检测不到必须用$set。为了万无一失在流式更新场景直接用$set或者$forceUpdate是最稳妥的虽然不优雅但能保证不出现“数据变了页面不动”的诡异bug。4. 流式与非流式方案对比以及混合式策略4.1 两种方案对比一览既然两种都能实现那到底该怎么选我整理了一个对照表方便你在技术选型时快速决策对比维度非流式接口流式接口返回方式一次性返回完整JSON分块返回SSE文本首字响应时间等待完整推理完成通常1~3秒内输出首字用户体感有等待焦虑体验一般打字机效果体验顺畅实现复杂度简单Axios一把梭稍复杂需处理流解析超时控制Axios自带超时配置需自实现取消逻辑服务端压力请求保持时间短长连接占用时间长适用场景短问答、内部工具、稳定性优先长文本生成、对话助手、C端产品断线重连无需考虑可能需要考虑EventSource自带重连浏览器兼容性全兼容Fetch需Polyfill老浏览器4.2 混合策略怎么在同一个项目里灵活切换我在项目里做了个“混合模式”简单说就是先看后端接口支持不支持流式支持就优先走流式不支持就自动降级为非流式。通过一个环境变量或者后端接口返回值来控制比如在请求体里传一个stream: true参数如果后端不支持会返回一个非流式JSON前端捕获后走另一条解析逻辑。用代码来表示就是async handleSend() { const useStream this.backendConfig.allowStream // 从配置读取 if (useStream) { this.handleSendByFetch() } else { this.handleSendByAxios() } }这种设计在前期磨合阶段特别有用。因为很多时候后端支持流式是慢慢迭代上去的前端可以先写好两条路后端哪个通了就走哪条。而且遇到线上问题的时候有一个“降级开关”在手心头不慌。4.3 实测数据与体验对比我在自己的测试环境里分别压过两种模式的响应数据。拿一个中等长度的问答答案约500字举例在本地网络条件下非流式接口从发请求到收到完整回答大概需要8~12秒期间页面一直转圈流式接口1.5秒开始出字3秒出大概50%到10秒左右完整显示完。虽然总耗时差不多但用户的焦虑感差别太大了。如果做的是面向最终用户的C端产品我必须强烈推荐流式。如果是内部管理后台、辅助工具非流式其实也不是不能接受因为用的人少对体验没那么敏感。做技术选型不能只追潮流还得看你实际的用户场景。5. 常见问题与排查技巧实录5.1 后端返回非法JSON导致的解析中断流式解析里最常见的坑是拿到的数据帧不是标准JSON。有些后端网关会夹带日志、空格或者额外的提示信息直接JSON.parse就会抛错。我之前就遇到过某次调试时流式数据里混了一行data: !DOCTYPE html前端解析立刻崩了。解决办法是解析前加一层校验只处理以{开头的字符串其他的直接跳过。如果连续多个数据帧解析失败就触发一次告警或降级为非流式模式。同时用split(\n)切分时要注意buffer尾部可能残留半行数据必须在下次循环时拼上来这就是所谓的“粘包”问题处理不好会丢失文本。5.2 流式输出中文乱码的坑流式接口返回的字节流在传输过程中可能会把一个中文字符的UTF-8编码拆成两半一次读取的Uint8Array不一定正好落在字符边界上。直接一次性把所有分块拼成一个二进制buffer再统一解码会得出一堆乱码。正确的做法是使用TextDecoder的{ stream: true }参数它内部会维护缓冲区自动处理跨块字符。我给很多同事改过代码发现大多数乱码问题都是因为用了decoder.decode(value)而没传{ stream: true }这个细节太容易踩坑了一定要记住。5.3 连接超时与断线重连机制EventSource自带重连但fetch ReadableStream没有。对于后者需要在onError里加入重试逻辑。我的做法是用一个retryCount记录重试次数超过3次不再重连而是提示用户“网络不稳定请手动重发”。每次重试前延迟递增例如1秒、2秒、4秒避免服务端压力过大。另外还有一个细节AI生成过程中如果用户切换了路由或关闭了页面请求并不会自动断开。在Vue2的beforeDestroy钩子中要主动调用取消函数否则浏览器会一直在后台接收数据浪费流量不算还可能触发页面已销毁后的组件状态更新警告。beforeDestroy() { if (this.cancelStream) { this.cancelStream() } if (this.renderTimer) { clearTimeout(this.renderTimer) } }5.4 常见问题速查表问题现象可能原因排查思路与处理接口跨域报错后端未配置CORS让后端在响应头加Access-Control-Allow-Origin开发环境可用Vue CLI proxy流式接口一直不输出后端缓冲未关闭检查Nginx配置关闭proxy_buffering设置X-Accel-Buffering: no响应内容不完整字符串截断/粘包处理不对检查buffer拼接逻辑改用TextDecoder的stream模式首字延迟很高后端未开流式透传确认后端是否真的以text/event-stream返回可先用Postman测试页面卡顿频繁更新DOM加节流渲染50~100ms刷新一次数据更新但视图不变Vue2响应式限制使用$set或$forceUpdate请求被取消后又自动重发AbortController未清理在finally或catch里清空取消函数引用5.5 从Vue2迁移到Vue3的注意点如果你后续打算把项目升级到Vue3这套逻辑的迁移成本其实不高但有几处要改。首先$set和$forceUpdate不再需要Vue3的Proxy代理天然支持深度响应式。其次Options API可以继续用也可以逐步换成Composition API把流式请求逻辑抽成一个useChatStream的hook复用性会更好。另外Vue3里的beforeDestroy改名成了beforeUnmount组件卸载时清理请求的代码要记得同步改名。从Vue2转Vue3最大的变化不在API用法而在思维方式。Vue2习惯在data里声明一切Vue3更鼓励把逻辑按功能聚合比如把聊天相关的状态、方法、生命周期钩子封装到一个模块里。这样一来流式聊天逻辑可以复制到多个组件共用而不用每个页面都重复写一遍。最后说点实际的。我在这个项目里最大的体会是AI问答功能的技术难点不在模型也不在框架而在“怎么把字节流稳定地变成用户看得懂的文本”。无论是Vue2还是Vue3核心原理都是同一套框架只是壳。如果你正在做类似的功能把流式解析、节流渲染、异常重试这几个点抠扎实了不管以后换什么模型、换什么框架你都能很快上手。还有一个小技巧接口联调阶段可以先把流式数据打印到控制台用console.log观察每一帧的数据格式等格式摸清了再写解析逻辑能省下不少弯路的调试时间。
返回列表