
简介面向Vue.js开发者的阿里通义千问聊天机器人集成示例旨在帮助在Vue3项目中快速接入智能问答能力并实现可打包部署到公网的前端应用覆盖从聊天界面到消息处理的常见场景。资源包共2000个文件压缩后约7.51MB以js、css、scss等前端构建产物为主同时包含Vue单文件组件、TypeScript类型声明、JSON配置文件与Markdown说明文档源码和编译输出结构完整便于对照学习与二次修改。已有309人浏览学习适合具备Vue3基础、希望了解第三方聊天API对接及前端打包上线流程的中级开发者也可作为团队内部技术实践参考。从中可以学习聊天组件的封装方式、基于响应式数据的消息发送与接收处理、HTTP请求调用与Axios简化写法还能参考密钥与API地址的配置方法理解从接口联调到生成静态文件并部署公网服务器的完整链路。示例代码可直接在自有项目中复用或改造省去大量基础搭建与排错时间快速拥有可交互的智能聊天能力。1. Vue 3 里接通义千问别把密钥写在前端再打包在 Vue 3 项目里接通义千问大模型最常见的入门姿势是把 API Key 直接写进代码再把请求发到那个 OpenAI 兼容的接口上。这个流程在本地跑通很快但一旦npm run build之后部署到公网打包产物里用搜索就能直接搜到密钥等于把账单挂在墙上任人刷。这次拆的是一套可以打包公网正常使用的 Vue 3 聊天 Demo前端用组合式 API 封装会话状态请求统一打给后端代理由 Nginx 反向代理转发到通义千问密钥只存在服务器环境变量里。适合想给已有 Vue 项目快速加 AI 对话能力的前端开发者也适合第一次接触大模型 API、想直接上线而不是只停在本地 demo 的从业者。2. 通义千问 API 调用链路鉴权、请求体与响应解析2.1 鉴权与模型选择Key 从哪来、参数边界在哪通义千问对外提供的是 DashScope 兼容接口鉴权方式沿用最通用的 Bearer Token。开通服务之后在控制台创建 API-KEY拿到一串以sk-开头的字符串就是后续所有请求的凭证。注意一个关键事实这个 key 的权限范围是整个账号的不是单个模型或单个应用一旦泄漏别人可以拿它跑各种模型费用算在账号头上。模型的取舍也直接影响成本和体验。常见的有 qwen-turbo、qwen-plus、qwen-max、qwen-long 四档模型定位适用场景代价qwen-turbo轻量快响应高频简单问答、客服机器人复杂推理容易答偏qwen-plus质量与速度平衡通用聊天、内容总结速度和成本居中qwen-max高质量复杂推理代码分析、长文写作慢且贵qwen-long长上下文大文档理解单次输入大响应偏慢我做的聊天 Demo 默认用的 qwen-plus追求的是演示时「看起来聪明但不迟钝」的效果。如果线上场景只做闲聊换成 qwen-turbo 每千 token 成本能降不少如果做代码审查qwen-max 更稳。这里的取舍永远是钱换质量、速度换深度。接口地址是compatible-mode路径下的 chat/completions流式与非流式都走同一个端点。选型上我的建议很直接闲聊或对延迟敏感的交互一律stream: true只有离线批处理这类场景才用非流式否则用户盯着空白界面等 5 秒体验直接归零。2.2 先跑通一个最小请求fetch 发起非流式对话在动手写 Vue 组件之前先做一个不依赖任何框架的最小连通性测试这是排查问题的锚点。我用 Node 脚本跑错误信息比浏览器控制台干净得多// scripts/test-qwen.mjs验证 API 连通性不进入浏览器环境 const API_URL https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions const API_KEY process.env.QWEN_API_KEY // 从环境变量读取不要硬编码 const res await fetch(API_URL, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${API_KEY} }, body: JSON.stringify({ model: qwen-plus, messages: [ { role: system, content: 你是一个调试助手回答保持在两句话以内。 }, { role: user, content: 你好请介绍下你自己。 } ], stream: false, temperature: 0.7 }) }) const data await res.json() console.log(data.choices[0].message.content)这段脚本前面几行是固定套路URL 拼在常量里key 从环境变量取请求体里只有model和messages是必填的。重点看响应结构data.choices[0]是第一条回复message.content是文本内容。官方接口的响应里还有usage字段里面是 prompt_tokens、completion_tokens 和 total_tokens观察成本全靠它。参数上最值得记住的是temperature。它控制生成随机性范围 0 到 2默认值一般在 0.85 左右。写客服脚本时可以调低到 0.3让回复稳定写文案创意可以调到 1.2让表述更跳。但别和top_p同时反复调这两个参数是互相影响的同时乱调的结果就是怎么调都不对劲属于典型的“参数玄学”区。2.3 多轮对话为什么 messages 不能只发当前一句话第一次接大模型的人最容易犯的错用户问一句就发一句不携带历史消息。大模型本身没有记忆每次请求都是独立的你以为它在“记住”对话其实是前端一直在把整段聊天记录原样发给接口。正确做法是把对话历史拼成一个 messages 数组按时间顺序排列role 取值含义典型内容system系统设定人设、规则、回答风格放数组最前面user用户输入每次提问追加一条assistant模型回复接口返回后原样存下来下轮再带回在 Vue 组件里我会用一个 ref 数组维护这三类消息用户发送时 push user 消息拿到响应后 push assistant 消息。下轮请求时把这个数组完整传给 API模型才能“看起来记得”之前聊过什么。这里有个边界要提示消息不是越多越好。每次请求的历史都会重新计 token对话越长单次消耗越大响应越慢。所以封装请求时我会把最近 20 条作为上限超过的从中间丢弃。为什么不丢第一条因为 system 设定在第一条它是全局行为约束丢了人设就崩了。后面第 6 章会给出更细的裁剪策略。注意API Key 的额度是按 token 计费的历史消息和回复都会计费上下文越长费用越高不是只算当次提问。3. Vue 3 组合式封装useChat 状态与聊天界面绑定3.1 useChat 组合式函数把接入、状态、异常收进一个函数先回答一个绕不开的问题为什么不直接在组件里写 fetch因为聊天逻辑至少涉及消息列表、请求状态、异常信息、SSE 解析四块状态组件里堆这些代码还会跟着模板混在一起后续想换模型或者加停止生成功能都有得改。用组合式函数把这些逻辑抽出来组件只负责渲染和交互。我把核心 hook 拆成这样// src/composables/useChat.js import { ref } from vue // 构造发送给接口的消息体保留 system再取最近 20 条非 system 消息 function buildMessages(source) { const system source.find(m m.role system) const recent source .filter(m m.role ! system) .slice(-20) return [system, ...recent].filter(Boolean) } export function useChat() { const messages ref([ { role: system, content: 你是一个友好的前端技术助手回答尽量简短。 } ]) const loading ref(false) const errorMsg ref() async function sendMessage(text) { if (!text.trim()) return messages.value.push({ role: user, content: text.trim() }) loading.value true errorMsg.value try { // 请求统一走同源 /api/qwen由代理转发到通义千问避免跨域和密钥暴露 const res await fetch(/api/qwen, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: qwen-plus, messages: buildMessages(messages.value), stream: true }) }) if (!res.ok) { const err await res.text() throw new Error(HTTP ${res.status}: ${err}) } // 先推一条空的 assistant 消息占位后续流式内容往里追加 const assistantMsg { role: assistant, content: } messages.value.push(assistantMsg) const reader res.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 }) let idx while ((idx buffer.indexOf(\n)) ! -1) { const line buffer.slice(0, idx).trim() buffer buffer.slice(idx 1) if (!line.startsWith(data:)) continue const data line.slice(5).trim() if (data [DONE]) continue try { const chunk JSON.parse(data) const delta chunk.choices?.[0]?.delta?.content || assistantMsg.content delta } catch { // 半截 JSON 直接跳过等下一段拼进来 } } } } catch (e) { errorMsg.value e.message } finally { loading.value false } } return { messages, loading, errorMsg, sendMessage } }这段代码有四个关键设计。第一buildMessages保证 system 消息永远在数组头部不会被 20 条上限冲掉。第二请求打到/api/qwen而不是直连通义千问这样浏览器端根本不出现跨域问题key 也不需要进前端代码。第三先 push 一条空的 assistant 消息流式返回时直接给这条消息的 content 追加内容Vue 的响应式系统会自动刷新界面。第四SSE 解析用了缓冲和逐行处理避免一个 data 块被拆成两半时 JSON.parse 直接崩溃。这里的assistantMsg.content delta是对象属性的原地修改。Vue 3 的 ref 在深层做了响应式代理数组里嵌套对象的内容变更一样能被侦测到不需要重新赋值整个数组这点比 Vue 2 省心很多。3.2 ChatPanel 组件消息渲染、发送交互与滚动定位hook 只是状态层还得有个界面壳子把消息列出来。组件不承担任何业务逻辑只做三件事渲染 messages、响应输入框的发送动作、把滚动条钉在底部。!-- src/components/ChatPanel.vue -- script setup import { nextTick, ref, watch } from vue import { useChat } from ../composables/useChat const { messages, loading, errorMsg, sendMessage } useChat() const input ref() const listRef ref(null) async function handleSend() { const text input.value.trim() if (!text || loading.value) return await sendMessage(text) input.value } // 监听最后一条消息的内容变化流式输出时持续滚到底部 watch( () messages.value[messages.value.length - 1]?.content, () { nextTick(() { const el listRef.value if (el) el.scrollTop el.scrollHeight }) } ) /script template div classchat-panel div reflistRef classmessage-list div v-for(msg, index) in messages :keyindex :class[message, msg.role] span classrole{{ msg.role user ? 我 : AI }}/span p{{ msg.content }}/p /div p v-ifloading classloading正在输入…/p /div div classinput-area input v-modelinput typetext placeholder输入你的问题 keydown.enterhandleSend / button :disabledloading || !input.trim() clickhandleSend发送/button /div p v-iferrorMsg classerror{{ errorMsg }}/p /div /template模板里的v-for用 index 做 key是因为流式输出时 assistant 消息的 content 一直在变用 id 或内容做 key 反而容易触发不必要的 DOM 重建。watch监听最后一条消息的 content每次 delta 到达都触发滚动这样打字机效果出现时列表底部始终可见不会出现“AI 在下面写、用户盯着上方空白”的窘境。输入框的keydown.enter已经在 handleSend 里做了防重复判断loading 期间按钮和回车都被禁用避免连续点击产生多条并发请求。3.3 为什么用 fetch 而不是官方 SDK官方提供的 Node SDK 在纯服务端很好用但放进 Vite 前端打包时依赖处理相对麻烦SDK 里还带着一堆 Node 专用的网络模块浏览器端要么 polyfill 要么各种编译报错。而所谓 SDK 的内部本质上就是请求刚才那个compatible-mode/v1/chat/completions接口。直接用 fetch 的好处是零额外依赖、代码路径透明、出错时能直接看到网络层信息。对前端工程来说少一个黑匣子就少一类玄学问题。在这个 Demo 里我特意选择stream: true并手写 SSE 解析。原因是大模型生成一段 200 字回复通常需要 2 到 5 秒非流式模式下用户只能干等着 loading 转圈不知道是卡了还是在生成流式模式下文字逐字蹦出来用户感受到的是“正在打字”的反馈体验差距非常大。手写解析也就二十来行比引入一个 SSE 库更可控。4. 打包公网部署从 Vite 代理到 Nginx 反代的完整链路4.1 开发环境 Vite 代理一条路径绕开跨域开发阶段最省事的方案是 Vite 的 server.proxy。它本质是一个 Node 代理服务前端请求打到同源的/api/qwen代理再把请求转发到通义千问并把响应原样拿回来。浏览器全程只跟本地开发服务器打交道跨域问题从根源上消失。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: https://dashscope.aliyuncs.com, changeOrigin: true, rewrite: (path) path.replace(/^\/api\/qwen/, /compatible-mode/v1/chat/completions) } } } })这个配置里真正起作用的是 rewrite。前端请求的是/api/qwen代理收到后把这段前缀替换成真实的接口路径然后再拼上通义千问的主机名发出去。changeOrigin: true会修改请求头里的 Host 字段避免目标服务器因 Host 不匹配返回 403。开发环境里 Authorization 头可以直接由 fetch 携带因为 Vite 代理不会打包进生产产物key 写在.env.local里只属于本地开发环境。但生产环境必须换策略让代理服务器注入密钥前端代码里连 key 的影子都不能有。这条边界我在 4.2 里严格落地。4.2 生产环境 Nginx 反向代理转发、密钥注入与 SSE 三兄弟生产环境我一般用 Nginx 承担域名服务和反向代理。前端打包出的 dist 由 Nginx 托管请求/api/qwen时由 Nginx 转发到通义千问同时由 Nginx 侧注入 Authorization 头密钥只存在于服务器环境变量中。server { listen 80; server_name chat.example.com; root /var/www/qwen-chat; index index.html; # 前端静态资源与 SPA 路由回退 location / { try_files $uri $uri/ /index.html; } # 通义千问代理只处理 /api/qwen 这一个精确路径 location /api/qwen { proxy_pass https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions; proxy_set_header Host dashscope.aliyuncs.com; proxy_set_header Authorization Bearer ${QWEN_API_KEY}; # SSE 流式必需的三个参数 proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header X-Accel-Buffering no; } }这里最容易翻车的是proxy_buffering off。Nginx 默认会缓冲上游响应攒满缓冲区才发给客户端。如果开着缓冲前端 fetch 的流式消息会变成“一堆垃圾数据最后一次性到达”打字机效果没了整个 SSE 机制形同虚设。X-Accel-Buffering no是为了让上游通义千问知道中间链路不要额外缓冲这个头对 SSE 服务很重要。${QWEN_API_KEY}并不是 Nginx 原生支持的环境变量写法需要在部署时用 envsubst 做一次模板替换。这是我把密钥从配置文件中剥离的关键步骤# 导出密钥到当前 shell export QWEN_API_KEYsk-xxxxxxxx # 把模板里的占位符替换成真实值生成正式配置 envsubst ${QWEN_API_KEY} nginx.conf.template /etc/nginx/conf.d/qwen-chat.conf # 先测配置再重载避免语法错误把线上服务搞挂 nginx -t nginx -s reload这样磁盘上的 nginx 配置和 nginx.conf.template 都不包含真实密钥只有 envsubst 生成的那一份里有而且它还在服务器内存里。就算 dist 目录被拖走、源码被扒开也找不到任何 key 痕迹。4.3 打包与发布从本地到公网的最小流程发布流程其实就三件事构建、传文件、重载配置。# 1. 打包 npm run build # 2. 上传 dist 目录到服务器指定位置 scp -r dist root你的服务器地址:/var/www/qwen-chat/ # 3. 重载 Nginx如果配置有改动先执行 nginx -t nginx -t nginx -s reload有个细节值得单独说dist 目录里所有文件名都带哈希是因为 Vite 默认把静态资源加上内容指纹。这个行为对发布很有用想回退旧版本时直接把 dist 回滚到上一个构建产物即可。另一个坑是如果用 Vue Router 并开启了 history 模式路径里没有 # 号Nginx 必须配try_files $uri $uri/ /index.html否则用户直接刷新/chat这样的深层路由会 404这是 SPA 部署最常见的问题之一。如果服务器上同时跑了 HTTPS 证书Nginx 侧按常规配 ssl 证书即可反向代理代码不用改动。没配 HTTPS 的至少在上层负载均衡或云网关处终结 HTTPS避免请求被明文截获。5. 避坑记录五个真实踩过的坑5.1 浏览器直接请求 DashScope 被 CORS 拦死现象前端 fetch 直接发给https://dashscope.aliyuncs.com/...控制台报跨域错误请求根本没发出。原因通义千问的服务端没有开放浏览器跨域访问所需的 CORS 响应头浏览器安全策略把响应拦截在应用层之外。这不是接口坏是浏览器规则不允许。解决不走浏览器直连。开发环境用 Vite proxy 转发生产环境用 Nginx 反向代理转发让浏览器始终请求同源地址。代理转发不受同源策略限制这是服务端的权利。5.2 打包产物里搜得到 API Key现象npm run build之后在 dist 的 JS 文件里用 grep 搜sk-直接搜出一长串真实密钥。原因把 API Key 写在以VITE_开头的环境变量里Vite 会在构建时把它当作可公开常量直接替换进打包产物。.env里的非 VITE 变量不会进入产物但很多人默认写了VITE_QWEN_API_KEY就忘了这事。解决前端打包产物里永远不出现密钥字段。生产环境密钥由 Nginx 或后端服务注入前端只发请求、不碰鉴权。已经泄漏的 key 立即在控制台删除重建因为打包产物一旦上线任何人都能扒出这个 key 拿你的额度跑模型。5.3 SSE 流式输出攒到最后一次性出现现象接口日志显示通义千问确实在逐字返回内容但前端界面 3 秒内一动不动最后突然整段文字刷出来。原因Nginx 默认开启了响应缓冲上游数据会先在 Nginx 侧攒一批攒到 buffer 满了才发给客户端。流式响应被缓冲后打字机效果直接被吃掉。解决在代理 location 里加三个关键配置proxy_buffering off、proxy_cache off、proxy_set_header X-Accel-Buffering no。改完记得nginx -t nginx -s reload再测。5.4 流式请求 60 秒左右被强行断开现象长回复或复杂推理时前端 fetch 在 60 秒左右抛网络错误用户对话进行到一半失败。原因Nginx 默认的proxy_read_timeout是 60 秒上游响应超过这个时间没有返回任何数据代理就判定超时并主动断开连接。通义千问生成长文本时首字延迟在某些情况下会超过这个值。解决把proxy_read_timeout调到 300 秒或更长proxy_send_timeout也同步调。这两个参数要在 Nginx 代理层改Vue 里的 fetch 配不配超时都没用连接是在中间层断的。5.5 多轮对话越聊越“失忆”甚至人设失效现象前几轮回答正常聊到第 8 轮左右 AI 开始答非所问把之前明确说过的约束忘了个干净。原因前端在组装 messages 时没有区分 system 和普通历史消息直接用slice(-N)截取最近 N 条。对话一长system 消息被截掉人设约束随之消失而模型不理解前文是因为历史消息也没带全。解决组装请求时把 system 单独提取出来固定放第一位再取最近 20 条非 system 消息拼接。我在 3.1 的buildMessages里就是这么做的那段逻辑不是随便写的就是为了钉死这个坑。6. 两个进阶技巧流式渲染平滑化与上下文裁剪流式输出跑通只是及格线要做到手感顺畅还得处理两个细节渲染频率和上下文长度。先说渲染频率。SSE 每推送一个 delta 就触发一次 Vue 响应式更新而网络 push 频率远高于浏览器渲染帧率结果就是 UI 线程挤满微任务页面掉帧、滚动卡顿。我一般会加一个 requestAnimationFrame 批处理把一小段时间内的多个 delta 攒起来在下一帧统一写入// 在 useChat 中增加的批处理逻辑 let pendingContent let frameScheduled false function flushDelta() { frameScheduled false assistantMsg.content pendingContent pendingContent } // 收到 delta 时不直接写 content先缓存 function appendDelta(text) { pendingContent text if (!frameScheduled) { frameScheduled true requestAnimationFrame(flushDelta) } }这样随渲染帧率节流而不是被网络推送牵着走。效果上文字依然是逐帧出现视觉流畅度明显提升。再说上下文裁剪。我的经验是按字符数粗估 token中文大约一个字符算 1 到 2 个 token英文一个 token 约 4 个字符。维护一个预算值比如 4000 token每次组装 payload 时从后往前加消息加满就停。以下是一个更稳的裁剪策略system 始终保留最近的用户消息和 assistant 回复优先保留中间太老的对话直接丢弃。这保证了模型看到的是最新的对话意图而不是一大坨历史噪音。这套 demo 源码我建议直接拿回去改把模型名、system 提示词、上下文条数三个参数替换成自己的业务场景就能用。那一次我在演示时被长对话卡到白屏才意识到流式渲染节流不是优化是刚需从那以后我每次接大模型聊天都强制走一遍“密钥隔离、代理转发、流式解析、上下文裁剪”这四步检查。希望帮到你。本文还有配套的精品资源点击获取