简介:一份面向 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_KEY="sk-你的key"Windows PowerShell用户用:
$env:DASHSCOPE_API_KEY="sk-你的key"2.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 class="assistant-content" v-html="renderMarkdown(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.development:
VITE_APP_API_BASE=/api VITE_APP_MODEL=qwen-plus VITE_APP_DASHSCOPE_KEY=sk-你的开发key.env.production:
VITE_APP_API_BASE=/api VITE_APP_MODEL=qwen-plus # 注意:生产环境不写 VITE_APP_DASHSCOPE_KEY,Key 放到服务端这样开发环境因为有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_KEY=sk-你的key node server.mjs提示:这个代理代码没有做任何鉴权,意思是任何知道你这个接口地址的人都可以直接调用。公网环境下至少加一个简单的访问令牌,比如前端请求头带一个自定义token、或者限制只允许你自己的域名来源。别嫌麻烦,公网扫描器比你想的勤快得多。
5.4 上线后的验证清单
部署完成后,我每次都会按这套清单走一遍,缺一不可:
- 浏览器无痕模式访问公网地址,确认页面能打开、静态资源加载正常。
- 发一条消息,确认模型回复是逐字输出的,不是等待很久后一次性出现。
- 刷新页面,确认当前对话路径不会404。
- 打开开发者工具Network面板,确认没有直接发起对
dashscope.aliyuncs.com的请求;如果看到了,说明前端还在直连,Key可能已经暴露。 - 用手机流量再访问一次,排除内网环境导致的错觉。
- 确认Key是在服务端环境变量里设置的,而不是写在前端代码或仓库里。
这套流程我第一次全走完大概花了半小时,但之后就再没出现过"本地正常、上线白屏、聊天断流"这类经典问题。
一点实际操作体会
这个项目最让我舒坦的地方在于:整个链路没有一项是"黑科技",但每一项都值得认真对待。前端流式解析那一块,TextDecoder和按行切分的处理方式,换成别的接口也能复用;nginx那段proxy_buffering off和try_files,几乎能平移到任何SPA项目上。
如果你打算正式对外用,我个人的建议是:别嫌麻烦,花一个小时把那个轻量代理部署上去,把Key从打包产物里摘出来。公网环境里一切自动化扫描都比你想的活跃,密钥一旦泄漏,损失的也不只是几块钱的Token费用。先把安全底线守住,后面这个聊天页面才能真正变成能放心丢给别人用的东西。
本文还有配套的精品资源,点击获取