Vue3+通义千问SSE流式聊天:从开发到公网部署全流程 简介一份面向 Vue 3 前端开发者的阿里通义千问聊天机器人集成示例。它演示了在 Vue 3 项目中通过 API 接入通义千问、管理消息收发、处理接口返回数据并最终打包部署到公网的全过程适合需要为应用增加实时交互能力的初中级开发者参考。压缩包共 2000 个文件约 7.51MB主要包含 js、css、scss、ts、vue 等源码与样式文件以及 json、md 等配置和说明文档目录结构清晰便于按需查阅。目前已有 304 人学习下载。配套示例提供了完整的聊天组件代码、HTTP 请求封装思路、消息状态管理方式、接口鉴权与错误处理示例以及 Vite/Vue CLI 打包相关配置可帮助读者快速复现“Vue 3 通义千问”的聊天场景。资源内还包含多种主题样式文件可直接用于调整聊天界面外观省去从零搭建的成本。无论是学习集成流程还是直接改造复用都有较高参考价值。 前几天又有朋友来问Vue3项目里怎么接通义千问的聊天接口而且接完还得能打包丢到公网上给别人用。这个需求听起来简单实际走一圈会发现它牵扯到接口鉴权、流式输出、跨域、打包配置、公网部署好几件互不相干的事任何一个环节断了页面上就一个字都转不出来。这篇文章我就按自己实际改过几轮的完整流程来写先用Vue3 Vite把项目拉起来把通义千问的流式聊天接口跑通再把API地址、模型名、密钥这些拆到环境变量里处理好打包配置最后给出公网部署的具体方案包括nginx怎么托管打包产物、怎么把API请求转发到服务端、密钥怎么不暴露在公网上。整个过程不需要复杂的后端框架但为了让公网访问真正安全可用我会在最后给出一个轻量代理的兜底方案。1. 这个聊天项目适合谁以及动手前必须想清楚的边界1.1 三种典型场景个人工具、团队内测、对外Demo把通义千问封装成一个聊天页面最常见的就是这三种场景。第一种是个人工具自己写一个带历史记录的对话页面放到服务器上手机上随手打开就能用比每次去官方控制台调试要舒服。第二种是团队内测公司内部做一个AI问答助手扔到内网或者公网测试服上让同事、客户试用反馈问题。第三种是对外Demo给领导汇报、给投资人演示、或者做一个开源项目让大家都能体验一把大模型对话的效果。这三种场景的共同点是都不需要完整的后端业务系统只需要一个能跑起来、能对话、还能被公网访问的页面。所以Vue3 通义千问 打包 公网这四件事其实是绑在一起的它们就是一个最小可用产品的完整链路。你在动手之前先确认自己属于哪一类因为这会直接决定你采用哪种部署方式、密钥能不能放前端我们后面会展开讲。1.2 浏览器直连的边界CORS与密钥暴露很多第一次做这种项目的人会直接把API Key写死在代码里然后在浏览器里fetch通义千问的接口。开发环境这么干通常没问题因为通义千问的DashScope OpenAI兼容端点允许浏览器跨域调用本地npm run dev一下对话就能跑通。但这里有一条边界必须提前搞清楚把API Key打包进前端产物、部署到公网等于把密钥公开送人。任何人打开你的站点F12看一眼网络请求或者源码就能把你的Key挖出来然后拿你的额度去刷接口。所以我的建议很明确本地开发、个人调试可以前端直连Key放环境变量方便快速验证。公网部署、给别人用不要在前端打包Key要么做一个轻量后端代理要么让用户输入自己的Key临时使用。这篇文章会按这个思路来组织开发阶段怎么省事怎么来上线阶段怎么稳妥怎么来。2. 前置准备申请API Key、选模型、用curl验证接口2.1 开通DashScope控制台并创建API Key第一步是去阿里云百炼控制台开通DashScope模型服务。用你的阿里云账号登录找到通义千问相关的模型服务开通之后在API-KEY管理页面创建一个新的API Key。这个Key是一个sk-开头的字符串创建之后只显示一次记得立刻复制保存。如果在控制台找不到创建入口去模型广场或者开通管理里先完成服务开通一般几秒钟就生效。Key拿到手之后我强烈建议先在系统环境变量里临时挂一下方便后面curl验证export DASHSCOPE_API_KEYsk-你的keyWindows PowerShell用户用$env:DASHSCOPE_API_KEYsk-你的key2.2 模型选择与OpenAI兼容接口格式通义千问DashScope提供了OpenAI兼容接口也就是说你不需要去学一套新的调用方式/chat/completions这个路径、请求体结构、返回结构跟OpenAI的Chat Completions基本一致。模型名需要自己选常见的有这几个模型名定位适用场景qwen-turbo入门级速度最快、成本最低简单问答、翻译、摘要qwen-plus均衡型能力与成本平衡日常聊天、通用助手、推荐使用qwen-max旗舰级综合能力最强复杂推理、长文本生成qwen-long长文本专项处理超长上下文、文档分析聊天项目我一般默认用qwen-plus响应质量足够价格也合理。如果只是做个Demo或者高频测试用qwen-turbo更省钱。你可以在代码里把这个模型名做成环境变量后面想换随时改不用动逻辑。2.3 先用curl验证接口别急着写代码我踩过不少次代码写完了才发现Key不对、模型名不对的坑所以现在养成一个习惯所有第三方接口接入先用curl验证一遍再动前端代码。这样一旦有问题能明确区分是接口问题还是前端问题。用下面的命令直接发一个非流式请求curl -X POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \ -H Authorization: Bearer $DASHSCOPE_API_KEY \ -H Content-Type: application/json \ -d { model: qwen-plus, messages: [ {role: user, content: 你好请用一句话介绍你自己} ], stream: false }如果网络正常、Key没问题你会收到一个JSON响应里面choices[0].message.content就是模型的回复。这一步能通后面所有工作都有基础。再验证一下流式接口把请求体的stream改成true响应会变成一坨data: {...}格式的文本最后一行是data: [DONE]。data: {choices:[{delta:{role:assistant,content:你好}}]} data: {choices:[{delta:{content:}}]} data: [DONE]我看到这个[DONE]的时候就知道流式链路是通的可以放心进入下一步了。3. Vue3聊天核心实现消息状态、SSE流式解析与完整对话闭环3.1 消息结构和上下文截断策略我推荐把聊天逻辑封装成一个组合式函数不要在组件里堆一堆方法。先建一个src/config.js统一管理接口地址和模型名export const API_BASE import.meta.env.VITE_APP_API_BASE || /api; export const API_KEY import.meta.env.VITE_APP_DASHSCOPE_KEY || ; export const MODEL import.meta.env.VITE_APP_MODEL || qwen-plus; // 开发环境如果配了 Key就直接连 DashScope否则走代理 export const ENDPOINT API_KEY ? https://dashscope.aliyuncs.com/compatible-mode/v1 : API_BASE;消息结构我建议用最简单的一种{ role: user, content: 你好 } { role: assistant, content: 你好有什么可以帮你, reasoning: }role只有user和assistant两种系统提示词在发送时临时拼到messages最前面。reasoning字段是给推理模型用的普通模型用不到但建议先留一个空字段后面我们会用到。上下文不能无限往上堆否则Token很快就超了。我实际处理的办法是每次请求只带上最近10条消息。这个数量对日常对话来说足够又能控制Token成本。const history messages.value .filter(m m.role ! system) .slice(-10) .map(({ role, content }) ({ role, content }));3.2 fetch按行解析SSE流式输出流式输出这块是重头戏。很多教程会直接让你用axios但axios对流式支持不好而浏览器原生的fetch能拿到response.body这个ReadableStream配合TextDecoder正好可以逐段读取流式数据。核心逻辑是这样的async function send(text) { const input text.trim(); if (!input || loading.value) return; const history messages.value .filter(m m.role ! system) .slice(-10) .map(({ role, content }) ({ role, content })); history.push({ role: user, content: input }); messages.value.push({ role: user, content: input }); messages.value.push({ role: assistant, content: , reasoning: }); loading.value true; const controller new AbortController(); abortController.value controller; const assistant messages.value[messages.value.length - 1]; try { const response await fetch(${ENDPOINT}/chat/completions, { method: POST, headers: { Content-Type: application/json, ...(API_KEY ? { Authorization: Bearer ${API_KEY} } : {}) }, body: JSON.stringify({ model: MODEL, messages: [ { role: system, content: 你是通义千问请用简洁友好的方式回答用户问题。 }, ...history ], stream: true }), signal: controller.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; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { const data line.trim(); if (!data.startsWith(data:)) continue; const payload data.slice(5).trim(); if (payload [DONE]) continue; try { const json JSON.parse(payload); const delta json.choices?.[0]?.delta; if (delta?.content) { assistant.content delta.content; } } catch (_) { // 忽略解析不完整的片段 } } } } catch (e) { if (e.name ! AbortError) { assistant.content \n\n[请求失败] ${e.message}; } } finally { loading.value false; abortController.value null; } }这里有几个细节值得展开第一read()返回的二进制块要用TextDecoder(utf-8)来解码否则中文会乱码。第二SSE数据是按行传输的但一次网络包可能包含多个data:行也可能在一行的中间断开所以一定要先用split(\n)按行切再把最后一块可能不完整的buffer留下来跟下一轮的数据拼在一起。第三AbortController用来实现停止生成按钮用户点停止时调用controller.abort()请求就会终止我们只在e.name ! AbortError的时候才提示请求失败避免停止操作被视为报错。3.3 处理reasoning_content这类特殊字段如果你选用的模型是qwq-32b这类推理模型流式返回里会多一个字段delta.reasoning_content这是模型的思考过程。它和正常的回答内容content是分开的如果不管它你会在页面上看到思考内容和回答混在一起观感很差。处理方式是在解析时把两类内容分流if (delta?.reasoning_content) { assistant.reasoning delta.reasoning_content; } if (delta?.content) { assistant.content delta.content; }然后在模板里把reasoning单独渲染成灰色小字或者折叠起来。普通模型不会返回这个字段所以这份代码对两种模型都兼容。3.4 自动滚动和Markdown渲染流式输出过程中消息列表需要跟着内容自动往下滚不然用户看到的永远是上半屏。我用一个watch监听所有消息内容拼接后的字符串内容一变就滚到底部const listEl ref(null); watch( () messages.value.map(m m.content (m.reasoning || )).join(), async () { await nextTick(); if (listEl.value) { listEl.value.scrollTop listEl.value.scrollHeight; } } );另一个必须处理的是大模型输出的Markdown格式。模型默认返回的是带#、-、的Markdown文本如果你直接用{{ content }}展示用户看到的就是一坨带井号的原始文本很劝退。我是用marked把Markdown转成HTML再用DOMPurify做一层过滤防止模型输出恶意脚本npm install marked dompurifyimport { marked } from marked; import DOMPurify from dompurify; function renderMarkdown(text) { return DOMPurify.sanitize(marked.parse(text)); }模板里这样用div classassistant-content v-htmlrenderMarkdown(msg.content)/div提示v-html渲染的内容一定过DOMPurify不要直接把模型输出当成可信HTML。模型受提示词影响可能会输出一些意料之外的标签安全过滤是底线。4. 打包前必改的四处配置base、环境变量、路由与体积4.1 把base改成相对路径部署到哪里都不慌很多Vue3项目在本地跑得好好的一打包部署到服务器子目录页面就白屏。原因多半是Vite默认的base是/所有静态资源都从根路径加载而你的站点可能跑在https://域名/chat/这样的子路径下。解决方式是在vite.config.js里把base改成相对路径import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ base: ./, plugins: [vue()] });./意味着所有资源引用都变成相对当前路径无论你把dist放在域名根目录、子目录还是反代到任意路径都不会白屏。这是打包部署第一步要改的东西。4.2 环境变量拆分开发直连、生产走代理我的环境变量配置习惯是拆成两个文件。.env.developmentVITE_APP_API_BASE/api VITE_APP_MODELqwen-plus VITE_APP_DASHSCOPE_KEYsk-你的开发key.env.productionVITE_APP_API_BASE/api VITE_APP_MODELqwen-plus # 注意生产环境不写 VITE_APP_DASHSCOPE_KEYKey 放到服务端这样开发环境因为有Key会直连DashScope调试方便生产环境没有Key前端请求统一走/api由nginx或后端转发到真实接口。这个开发直连、生产代理的模式是前后端分离项目里比较稳妥的做法。Vite有一个特点只有以VITE_开头的环境变量才会被打进前端代码所以你的生产构建产物里不会有服务端的那把Key。4.3 history路由和nginx的try_files回退如果聊天页面用到了Vue Router而且用的是createWebHistory()模式打包部署后会遇到一个问题用户访问https://域名/chat没问题但刷新一下https://域名/chat/conversation/123就404了。原因是前端是单页应用服务器上并没有conversation/123这个真实文件请求打到nginx后找不到对应资源自然返回404。解决办法是在nginx的location /里加一段location / { try_files $uri $uri/ /index.html; }这段配置的意思是先试着找真实的文件找不到就统统回退到index.html把路由交给前端去处理。这是SPA部署的标配忘了写必踩坑。如果你坚持要省心也可以直接用createWebHashHistory()URL里多个#但不会出现刷新404的问题。4.4 打包产物检查改完以上配置执行npm run build打包完成后打开dist目录检查几个地方看index.html里的资源路径是不是./assets/...而不是/assets/...。看有没有dist/assets目录里面的JS、CSS文件是否生成了带hash的文件名。如果项目里有dist/config.js之类的文件检查有没有把生产Key打进去如果打进去了立刻删掉并重新配置。我自己习惯在打包前先把dist目录删掉避免旧文件混进新产物影响判断。5. 公网部署实战三种路线、nginx反向代理与密钥安全兜底5.1 三种部署路线对比拿到dist产物后公网部署有几种常见路线我按适用场景整理了一张表部署方案成本是否支持API反代适合场景云服务器 nginx约30-100元/月支持生产环境、长期使用Vercel / Netlify免费额度支持Serverless函数个人项目、临时演示对象存储 / CDN静态托管按量计费不支持纯静态Demo、前端展示如果是公司项目或者你自己长期用我推荐云服务器 nginx因为后面加接口代理、HTTPS证书、日志监控都方便。如果只是临时给朋友看个效果Vercel这种平台分钟级就能上线成本为零。5.2 nginx托管页面并转发API代理假设你有一台云服务器把dist目录上传到服务器上比如放到/var/www/qwen-chat/dist然后写一份nginx配置server { listen 80; server_name ai.example.com; root /var/www/qwen-chat/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_buffering off; proxy_read_timeout 300s; } }重点说三个配置项。第一个是proxy_pass http://127.0.0.1:3000;它把前端请求的/api/xx转发到本机3000端口服务。这样前端代码里VITE_APP_API_BASE/api、请求路径/api/chat/completions就会被代理到http://127.0.0.1:3000/chat/completions由服务端再转发给DashScope实现浏览器永远不知道真实API地址和Key的效果。第二个是proxy_buffering off;这行至关重要。SSE流式输出是边生成边推送的如果nginx开启了缓冲区它会攒一批数据再一次性发给浏览器聊天体验就从打字机效果变成卡顿一下出一大段。关闭缓冲后数据一到就转发流式效果才正常。第三个是proxy_read_timeout 300s;。大模型生成长回答时后端可能几十秒没有返回数据nginx默认的60秒超时会导致请求被掐断生成到一半就报错。调到300秒是个比较稳妥的数值。改完配置执行nginx -t检查语法没问题就nginx -s reload重载。记得域名要解析到这台服务器如果是裸IP访问server_name可以写IP但HTTPS证书会麻烦一些。5.3 轻量后端代理参考nginx转发指向的127.0.0.1:3000需要起一个服务。如果你不想引入Java、Go这些重型框架用Node.js自带的能力就能搞定一个最小代理十几行代码// server.mjs import express from express; const app express(); const UPSTREAM https://dashscope.aliyuncs.com/compatible-mode/v1; const DASHSCOPE_KEY process.env.DASHSCOPE_KEY; app.use(express.json({ limit: 1mb })); app.post(/chat/completions, async (req, res) { if (!DASHSCOPE_KEY) { res.status(500).json({ error: DASHSCOPE_KEY not set }); return; } const upstream await fetch(${UPSTREAM}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${DASHSCOPE_KEY} }, body: JSON.stringify(req.body) }); res.status(upstream.status); upstream.body.pipe(res); }); app.listen(3000, () { console.log(proxy running at http://127.0.0.1:3000); });启动时把Key放到环境变量里DASHSCOPE_KEYsk-你的key node server.mjs提示这个代理代码没有做任何鉴权意思是任何知道你这个接口地址的人都可以直接调用。公网环境下至少加一个简单的访问令牌比如前端请求头带一个自定义token、或者限制只允许你自己的域名来源。别嫌麻烦公网扫描器比你想的勤快得多。5.4 上线后的验证清单部署完成后我每次都会按这套清单走一遍缺一不可浏览器无痕模式访问公网地址确认页面能打开、静态资源加载正常。发一条消息确认模型回复是逐字输出的不是等待很久后一次性出现。刷新页面确认当前对话路径不会404。打开开发者工具Network面板确认没有直接发起对dashscope.aliyuncs.com的请求如果看到了说明前端还在直连Key可能已经暴露。用手机流量再访问一次排除内网环境导致的错觉。确认Key是在服务端环境变量里设置的而不是写在前端代码或仓库里。这套流程我第一次全走完大概花了半小时但之后就再没出现过本地正常、上线白屏、聊天断流这类经典问题。一点实际操作体会这个项目最让我舒坦的地方在于整个链路没有一项是黑科技但每一项都值得认真对待。前端流式解析那一块TextDecoder和按行切分的处理方式换成别的接口也能复用nginx那段proxy_buffering off和try_files几乎能平移到任何SPA项目上。如果你打算正式对外用我个人的建议是别嫌麻烦花一个小时把那个轻量代理部署上去把Key从打包产物里摘出来。公网环境里一切自动化扫描都比你想的活跃密钥一旦泄漏损失的也不只是几块钱的Token费用。先把安全底线守住后面这个聊天页面才能真正变成能放心丢给别人用的东西。本文还有配套的精品资源点击获取