资讯详情

资讯详情

Vue3+Node代理实现大模型流式聊天(通义千问)

简介本资源是一套基于 Vue 3 实现阿里通义千问聊天机器人集成的可部署示例项目面向前端开发者及 Vue 进阶学习者解决在真实业务场景中将大模型对话能力嵌入 Web 应用的核心问题。项目已适配公网打包部署流程涵盖 API 调用封装、响应式消息管理、主题样式定制含 gruvbox/papercolor/snazzy 等 12 预设 CSS 主题及轻量级聊天组件实现兼顾功能完整性与工程实践性。压缩包共 2000 个文件主体为 1054 个 JavaScript 逻辑文件、514 个 CSS 样式文件、256 个 SCSS 主题变量文件及 39 个 TypeScript 类型定义总大小 7.51MB结构清晰便于按模块理解通信层、UI 层与配置层设计。目前已有 312 人学习下载读者可直接复用聊天组件、参考多主题切换方案、借鉴 Axios 封装调用范式并获得完整可构建的生产级 Vue 3 项目骨架。1. 这不是个“调用 API”的 Hello World一个能真正在公网跑起来的 Vue3 聊天界面背后藏着服务端代理、流式响应处理、会话状态管理三道硬坎你肯定试过在 Vue3 项目里fetch通义千问的 OpenAPI填上Authorization: Bearer sk-xxx点发送——然后浏览器控制台弹出跨域错误CORS或者直接返回403 Forbidden。这不是你代码写错了是阿里云官方接口压根不支持前端直连。这个项目之所以值得拆是因为它绕开了所有“教学 Demo”惯用的取巧方案它没用 mock 数据没关本地开发服务器的 CORS更没把 API Key 硬编码进前端包里。它用一个轻量 Node.js 代理层仅 3 个文件把鉴权、请求转发、流式 SSE 解析全收口Vue3 前端只管发消息、收 chunk、拼接 DOM打包后扔到任意静态托管平台比如 GitHub Pages、Vercel、甚至 Nginx 目录就能连上真实千问模型。适合两类人一是正卡在“本地能跑、一部署就挂”阶段的 Vue3 初学者二是需要快速交付一个可演示、可定制、不暴露密钥的客户侧聊天界面的前端工程师。它不教你大模型原理但教会你怎么让 Vue3 在生产环境里“体面地”和大模型对话。2. 架构拆解三层结构如何各司其职为什么不能把代理逻辑塞进 Vue 的setup()里这个项目的骨架非常清晰只有三个物理层Vue3 前端纯静态、Node.js 代理服务运行在独立端口、阿里云通义千问 OpenAPI远程。很多人第一反应是“既然 Vue3 能fetch为啥还要多套一层代理”——这是本项目最核心的设计前提必须掰开讲透。2.1 前端层Vue3 Composition API Tailwind CSS只做 UI 和状态流转整个前端就是一个单页应用SPA没有路由跳转核心组件是ChatView.vue。它不碰任何密钥、不构造完整 URL、不处理 HTTP 状态码细节。它的职责被严格限定为三件事维护本地会话历史messages: RefMessage[]每条Message包含role: user | assistant和content: string监听用户输入触发sendMessage()方法接收从/api/chat流式返回的文本块并实时追加到当前assistant消息的content中。提示这里不用v-html直接渲染富文本而是用pre classwhitespace-pre-wrap保证换行和空格原样显示避免 XSS 风险。所有内容都经过DOMPurify.sanitize()处理项目已预装该库。2.2 代理层Express axiosstream承担鉴权、转发与流式桥接代理服务由server/index.js启动监听3001端口可配置。它不是简单的proxyMiddleware而是一个有状态的中间件收到前端POST /api/chat请求时校验请求体中的model字段是否在白名单内如qwen-max,qwen-plus防止恶意调用非授权模型从环境变量QWEN_API_KEY读取密钥绝不写死在代码里拼装成标准Bearer头用axios发起Content-Type: application/json的 POST 请求到https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation关键一步设置responseType: stream并监听response.data的data事件将原始 SSEServer-Sent Events流逐 chunk 解析每个 chunk 形如data: {output:{text:你好},usage:{...}}\n\n代理层剥离data:前缀JSON.parse()后提取output.text再以text/event-stream格式回传给前端。这个设计让前端完全无感后端协议细节——它只认event: message和data: ...就像在和一个本地 SSE 服务通信。2.3 远程层阿里云 DashScope OpenAPI只暴露能力不暴露密钥项目使用的实际接口是 DashScope 的 Text Generation 接口。注意两点硬约束必须使用POST方法且body是 JSON 格式包含model、input.messages数组、parameters.temperature等字段官方明确禁止前端直连因为密钥一旦泄露攻击者可无限调用你的账号配额产生费用。所以QWEN_API_KEY必须藏在服务端环境变量中由代理层动态注入请求头。这解释了为什么所有“Vue3 直连千问”的教程在部署后必然失败——它们违反了云厂商最基本的安全契约。2.4 为什么不能把代理逻辑塞进 Vue 的setup()有人会想“我用window.fetch(/api/chat, { method: POST, body: JSON.stringify({...}) })再配个vue.config.js的devServer.proxy不就完事了”——这是典型开发环境陷阱。devServer.proxy只在npm run serve时生效打包后的dist/目录是纯静态文件没有 Webpack Dev Serverproxy 规则彻底失效。此时浏览器发起的/api/chat请求目标是https://your-domain.com/api/chat而你的 Nginx 或 Vercel 根本没这个路径直接 404。真正的代理必须是一个独立、可部署、带业务逻辑的 HTTP 服务和前端生命周期解耦。这也是本项目选择 Express 而非 Vite 插件的原因它能被打包成server.js用node server.js启动或部署到任何支持 Node.js 的 PaaS 平台。3. 核心模块代码逐行解析从点击发送到文字逐字出现链路到底怎么走我们聚焦最关键的交互链路用户在输入框敲下回车 → 前端发请求 → 代理转发 → 千问返回流 → 前端接收并渲染。这段代码决定了体验是否“丝滑”也是最容易翻车的地方。3.1 前端ChatView.vue中的sendMessage()与流式接收!-- ChatView.vue -- script setup import { ref, onMounted } from vue const messages ref([ { role: assistant, content: 你好我是通义千问有什么可以帮您 } ]) const inputText ref() const isStreaming ref(false) const sendMessage async () { if (!inputText.value.trim()) return // 1. 添加用户消息到本地状态 messages.value.push({ role: user, content: inputText.value }) messages.value.push({ role: assistant, content: }) // 预占位content 为空等待流式填充 isStreaming.value true inputText.value try { // 2. 发起流式请求关键Accept header 必须是 text/event-stream const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: qwen-max, messages: messages.value.filter(m m.role ! assistant || m.content), // 过滤空 assistant 消息 parameters: { temperature: 0.8 } }) }) if (!response.ok) throw new Error(HTTP ${response.status}) // 3. 使用 ReadableStream 逐 chunk 处理 SSE const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) // 4. 按 \n\n 分割完整 eventSSE 标准格式 const lines buffer.split(\n\n) buffer lines.pop() // 保留未完成的 chunk for (const line of lines) { if (!line.trim()) continue // 解析 data: {...} 行 const dataMatch line.match(/^data:\s*(.*)$/m) if (dataMatch dataMatch[1]) { try { const parsed JSON.parse(dataMatch[1]) // 5. 提取 output.text追加到最新一条 assistant 消息 const lastAssistantMsg messages.value.findLast(m m.role assistant) if (lastAssistantMsg) { lastAssistantMsg.content parsed.output?.text || } } catch (e) { console.warn(SSE parse failed:, e, line) } } } } } catch (err) { console.error(Stream error:, err) // 6. 出错时替换最后一条 assistant 消息为错误提示 const lastAssistantMsg messages.value.findLast(m m.role assistant) if (lastAssistantMsg) { lastAssistantMsg.content ❌ 请求失败${err.message} } } finally { isStreaming.value false } } onMounted(() { // 回车发送 const input document.getElementById(chat-input) input?.addEventListener(keydown, (e) { if (e.key Enter !e.shiftKey) { e.preventDefault() sendMessage() } }) }) /script逻辑说明与参数说明messages.value.push({ role: assistant, content: })是关键技巧预占位避免 DOM 闪烁。后续所有流式文本都追加到这个空content上Vue 的响应式系统会自动触发视图更新fetch的Accept: text/event-stream由浏览器自动添加因为响应头是Content-Type: text/event-stream无需手动设buffer变量用于处理 TCP 分包一个reader.read()可能只读到半个 JSON必须缓存未完成的 chunklines.pop()保留未闭合的\n\n防止 JSON 解析失败findLast是 ES2023 新方法兼容性需检查项目已 polyfill若用旧版 Vue可改用messages.value.slice().reverse().find(...)错误处理不是简单alert而是修改content字段保持 UI 结构一致用户看到的是“消息气泡里的错误提示”而非弹窗打断。3.2 代理层server/index.js中的流式桥接实现// server/index.js const express require(express) const axios require(axios) const app express() const PORT process.env.PORT || 3001 // 解析 JSON body app.use(express.json({ limit: 10mb })) app.use(express.urlencoded({ extended: true, limit: 10mb })) // 白名单模型 const ALLOWED_MODELS [qwen-max, qwen-plus, qwen-turbo] app.post(/api/chat, async (req, res) { try { const { model, messages, parameters {} } req.body // 1. 模型白名单校验 if (!ALLOWED_MODELS.includes(model)) { return res.status(400).json({ error: Unsupported model }) } // 2. 构造千问 API 请求 const dashscopeUrl https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation const apiKey process.env.QWEN_API_KEY if (!apiKey) { return res.status(500).json({ error: Server misconfigured: QWEN_API_KEY missing }) } // 3. 设置流式响应头 res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no // Nginx 关键禁用缓冲 }) // 4. 转发请求到千问启用流式响应 const dashscopeRes await axios({ method: post, url: dashscopeUrl, headers: { Authorization: Bearer ${apiKey}, Content-Type: application/json }, data: { model, input: { messages }, parameters: { // 默认参数允许前端覆盖 temperature: parameters.temperature ?? 0.8, top_p: parameters.top_p ?? 0.8, max_tokens: parameters.max_tokens ?? 2048 } }, responseType: stream // ⚠️ 核心必须设为 stream }) // 5. 管道式转发dashscopeRes.data 流 → res客户端 dashscopeRes.data.on(data, (chunk) { const str chunk.toString() // 6. SSE 格式转换将 dashscope 的原始响应可能是 JSON 或 SSE统一为标准 SSE // DashScope 实际返回的是 JSON 数组但文档说支持 SSE此处按 JSON 流处理 // 更健壮做法检查 dashscopeRes.headers[content-type]动态适配 try { const parsed JSON.parse(str) const text parsed.output?.text || // 转为 SSE 格式event: message\ndata: {...}\n\n res.write(event: message\n) res.write(data: ${JSON.stringify({ text })}\n\n) } catch (e) { // 若解析失败原样透传如遇到 error 字段 res.write(str) } }) dashscopeRes.data.on(end, () { res.end() }) dashscopeRes.data.on(error, (err) { console.error(DashScope stream error:, err) res.write(event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n) res.end() }) } catch (err) { console.error(Proxy error:, err) res.status(500).json({ error: err.message }) } }) app.listen(PORT, () { console.log(Proxy server running on http://localhost:${PORT}) })逻辑说明与参数说明res.writeHead(200, {...})中的X-Accel-Buffering: no是为 Nginx 部署准备的防止 Nginx 缓冲流式响应导致前端卡顿responseType: stream是axios的关键开关不设此项dashscopeRes.data是 Buffer无法监听data事件当前代码假设 DashScope 返回 JSON 对象流实际响应体是单个 JSON但responseType: stream会按 chunk 分割因此JSON.parse(str)可能失败。生产环境应改为监听dashscopeRes.data的readable事件累积完整 JSON 后再解析res.write()发送的event: message允许前端用addEventListener(message, ...)监听比通用message事件更精准catch块中的res.status(500).json(...)仅在初始请求失败时触发如网络不通、密钥错误不影响流式传输过程中的错误。4. 部署与运行从本地调试到公网可用三步走通附常见问题排查这个项目最大的价值在于“能跑通”。下面给出从零开始的完整路径每一步都对应一个真实场景。4.1 本地开发npm run devnode server.js双进程启动安装依赖# 前端 cd client npm install # 代理服务 cd ../server npm install配置密钥在server/.env文件中写入QWEN_API_KEYsk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx PORT3001注意.env文件必须放在server/目录下且不能提交到 Git已加入.gitignore。client/目录下不需要.env。启动双服务# 终端 1启动 Vue 开发服务器默认 http://localhost:5173 cd client npm run dev # 终端 2启动代理服务默认 http://localhost:3001 cd server node index.js此时访问http://localhost:5173输入问题即可对话。前端请求/api/chat会被浏览器发往http://localhost:5173/api/chat但因vite.config.js中配置了server.proxy开发服务器会自动将/api/chat转发到http://localhost:3001/api/chat。4.2 打包构建生成dist/静态文件代理服务独立部署构建前端cd client npm run build输出目录client/dist/里面是纯 HTML/CSS/JS 文件。部署代理服务将server/目录整个上传到服务器如腾讯云轻量应用服务器安装 Node.js推荐 v18运行npm install用pm2 start index.js --name qwen-proxy启动守护进程关键确保服务器防火墙开放3001端口或改用80/443需 root 权限。配置反向代理Nginx 示例将client/dist/目录设为 Nginx 的root并添加location /api/chat代理规则server { listen 80; server_name your-domain.com; root /path/to/client/dist; index index.html; # 前端路由 fallback location / { try_files $uri $uri/ /index.html; } # 代理 API 请求到 Node 服务 location /api/chat { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_cache_bypass $http_upgrade; # 流式关键 proxy_buffering off; proxy_cache off; proxy_send_timeout 300; proxy_read_timeout 300; } }重启 Nginxsudo nginx -t sudo systemctl reload nginx。4.3 公网验证用 curl 模拟前端请求确认代理链路畅通不要等前端页面先用命令行验证代理是否工作curl -X POST http://your-domain.com/api/chat \ -H Content-Type: application/json \ -d { model: qwen-max, messages: [{role: user, content: 你好}], parameters: {temperature: 0.5} } \ -H Accept: text/event-stream如果返回类似event: message data: {text:你好很高兴见到你。} event: message data: {text:有什么我可以帮您的吗}说明代理层到千问的链路完全打通。此时再打开网页成功率极高。4.4 常见问题排查血泪经验总结每一条都来自真实翻车现场现象 1本地npm run dev正常但打包后页面空白控制台报Failed to fetch /api/chat原因vite.config.js的server.proxy只在开发时生效打包后dist/中的 JS 仍试图请求https://your-domain.com/api/chat而 Nginx 未配置该 location。解决检查 Nginx 配置确认location /api/chat块存在且proxy_pass地址正确用curl -I http://your-domain.com/api/chat看是否返回200。现象 2消息发送后前端一直转圈assistant消息内容始终为空原因代理服务未正确设置Content-Type: text/event-stream响应头或 Nginx 启用了缓冲proxy_buffering on。解决在server/index.js的res.writeHead中显式添加Content-Type: text/event-streamNginx 配置中加入proxy_buffering off;。现象 3收到消息但内容乱码如 符号或中文显示为 Unicode 转义\u4f60\u597d原因TextDecoder编码指定错误或代理层res.write()未声明 UTF-8。解决前端decoder new TextDecoder(utf-8)必须写明代理层res.write()前加res.write(\uFEFF)BOM 头或确保res.setHeader(Content-Encoding, utf-8)。现象 4首次发送正常第二次发送报429 Too Many Requests原因阿里云 DashScope 对免费额度有严格频控如 1 次/秒前端未做防抖用户连续点击。解决在sendMessage()开头加节流let lastSendTime 0 const sendMessage async () { const now Date.now() if (now - lastSendTime 1000) return // 1 秒内只允许一次 lastSendTime now // ...原有逻辑 }现象 5部署后QWEN_API_KEY报错日志显示undefined原因server/.env文件未被dotenv加载或 Node.js 版本过低不支持process.env。解决在server/index.js顶部显式加载require(dotenv).config({ path: ./.env }) console.log(Loaded API key:, process.env.QWEN_API_KEY?.slice(0, 5) ...) // 调试用5. 二次开发实战从“能用”到“好用”三个可立即落地的改造技巧这个项目不是终点而是起点。我把它部署到某高校实验室的官网后导师提了三个需求支持多轮上下文记忆、允许用户切换不同模型、增加清空对话按钮。下面是我一周内完成的改造全部基于现有结构不重写核心逻辑。5.1 技巧一用localStorage持久化会话实现关闭页面后消息不丢失默认messages存在内存里刷新即清空。要持久化只需两处改动初始化时从localStorage读取// ChatView.vue setup() 内 const savedMessages localStorage.getItem(qwen-chat-history) messages.value savedMessages ? JSON.parse(savedMessages) : [ { role: assistant, content: 你好我是通义千问... } ]每次messages变化后写入// 在 sendMessage() 的 finally 块内或用 watch import { watch } from vue watch(messages, (newVal) { localStorage.setItem(qwen-chat-history, JSON.stringify(newVal)) }, { deep: true })注意localStorage有 5MB 限制messages数组不宜过大。生产环境可加清理逻辑只保存最近 10 轮对话。5.2 技巧二前端模型选择器 代理层动态路由一行代码切换qwen-max和qwen-turbo前端加下拉框select v-modelselectedModel classpx-3 py-1 border rounded option valueqwen-maxQwen-Max强推理/option option valueqwen-turboQwen-Turbo快响应/option /select script setup const selectedModel ref(qwen-max) // sendMessage() 中 body 改为 // model: selectedModel.value, /script代理层白名单扩展将ALLOWED_MODELS数组增加新模型名即可无需改其他逻辑。DashScope 会自动路由到对应实例。5.3 技巧三增加“清空对话”按钮安全重置状态而不影响localStoragebutton clickclearChat classpx-4 py-1 bg-red-500 text-white rounded hover:bg-red-600 ️ 清空对话 /button script setup const clearChat () { messages.value [ { role: assistant, content: 你好我是通义千问... } ] // 仅清除内存不清除 localStorage避免误操作 // 如需同步清除 localStorage加localStorage.removeItem(qwen-chat-history) } /script5.4 进阶支持 Markdown 渲染与代码块高亮提升专业感用户常问技术问题返回内容含代码块。原生pre不够美观。我引入marked和highlight.js安装npm install marked highlight.js在ChatView.vue中import { marked } from marked import hljs from highlight.js/lib/core import javascript from highlight.js/lib/languages/javascript hljs.registerLanguage(javascript, javascript) // 渲染函数 const renderMarkdown (text) { marked.setOptions({ highlight: (code, lang) { return hljs.highlight(code, { language: lang }).value } }) return marked.parse(text) }模板中div v-htmlrenderMarkdown(lastAssistantMsg.content) classprose prose-sm max-w-none/divprose是 Tailwind 的 Markdown 样式类开箱即用。从那以后我每次新增功能都强制走一遍“本地调试 → 构建验证 → Nginx 配置检查 → curl 测试 → 页面测试”五步流程哪怕只是改一个按钮颜色。因为线上环境的不可见性远超本地console.log的确定性。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →