1. 从零搭建 Vue3 智能对话界面:为什么我选 axios 而不是 fetch
如果你正在搜「Vue3 接入大模型 API Key 教程」,大概率会遇到两个卡点:一是不知道请求该怎么封装,二是流式输出(打字机效果)总是调不出来。我试过用原生 fetch 手写 ReadableStream 解析,代码又长又容易在分段数据上翻车,后来换成 axios 配合responseType: 'stream',整个请求层清爽了很多。
先说清楚这个界面能做什么:一个纯前端的聊天窗口,输入问题后调用大模型接口,AI 回复逐字显示,支持通义千问、文心一言、OpenAI 以及兼容 OpenAI 协议的模型。适合谁?适合想快速做原型的前端同学、想练手 Vue3 组合式 API 的初学者,以及需要给内部工具加个 AI 入口的开发者。
核心检索词先摆出来:Vue3 组合式 API、axios 封装、API Key 配置、大模型流式输出。这四个词贯穿全文,你跟着做就能跑通。
为什么不用 fetch?fetch 处理流需要手动拿response.body.getReader(),再配合TextDecoder循环读取,遇到data:分段还要自己拼 buffer。axios 虽然底层也是 XHR,但它在浏览器端对流的处理更顺手,拦截器还能统一加请求头、统一处理 401。当然 axios 的responseType: 'stream'在浏览器里拿到的是 XHR 的 progress 事件流,不是 Node 的 Readable,这点后面排障会细说。
环境准备很简单,Vite 创建项目,装 axios:
npm create vite@latest vue3-chat -- --template vue cd vue3-chat npm install npm install axios --save npm run dev浏览器打开http://127.0.0.1:5173/看到默认页就说明环境 OK。接下来所有代码都围绕一个App.vue展开,不需要路由、不需要状态管理库,组合式 API 的ref和nextTick足够。
这里有个认知要先建立:纯前端直连大模型接口,API Key 会暴露在浏览器网络面板里。个人练手没问题,正式项目必须加一层后端转发。本文先把前端链路跑通,Key 的安全问题在第五节单独讲。
2. TaoToken 前置准备:统一 Base URL 与 API Key 管理
多模型切换最烦的是什么?每个平台一个域名、一套鉴权、一种请求体格式。通义是dashscope.aliyuncs.com,OpenAI 是api.openai.com,文心又是另一套aip.baidubce.com还要先换 access_token。代码里到处写 if-else 判断平台,维护起来很痛苦。
我的做法是找一个兼容 OpenAI 协议的统一入口,把 Base URL 收敛成一个变量。TaoToken 提供的就是这种 OpenAI 兼容接口,官网在https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=,API 地址是https://taotoken.net/api。它的价值在于:你只需要维护一份请求封装,换模型只改model字段,不用动 URL 和鉴权逻辑。
具体怎么拿 Key:登录后进控制台,在 API Keys 页面创建一个新 Key,复制出来形如sk-xxxx。这个 Key 就是请求头里Authorization: Bearer sk-xxxx的那串。注意创建后只显示一次,丢了只能重建。
模型 ID 怎么填?这是新手最容易错的地方。不是填「通义千问」这种中文名,而是填平台定义的模型标识,比如qwen-turbo、gpt-3.5-turbo这类。你可以在模型对话页面先手动试一条,确认模型 ID 能通,再写进代码。模型对话入口:https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
如果你后面要做长期编码或 Agent 类应用,可以了解下 Coding Plan,入口在https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。本文聚焦对话界面,用按量计费的 API Key 就够。
把三件套记牢:Base URL、API Key、Model ID。后面所有配置片段都围绕这三个值展开。控制台地址https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,API Keys 管理页https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite,接入文档https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
注意:不要把 Key 硬编码后提交到 Git 仓库。本文为了演示直接写在配置对象里,实际项目请用
.env文件配合import.meta.env.VITE_API_KEY读取,并把.env加入.gitignore。
3. 可复制配置:axios 封装与多模型 settings 片段
这一节是全文核心,给你能直接粘贴的配置。先建一个src/api/chat.js,把 axios 实例和请求逻辑抽出来,组件里只负责 UI。
// src/api/chat.js import axios from 'axios' // 统一 Base URL,换平台只改这里 const BASE_URL = 'https://taotoken.net/api' // 创建 axios 实例,统一超时和请求头 const client = axios.create({ baseURL: BASE_URL, timeout: 60000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截器:自动注入 API Key client.interceptors.request.use( (config) => { const key = import.meta.env.VITE_API_KEY || 'sk-你的Key' config.headers.Authorization = `Bearer ${key}` return config }, (error) => Promise.reject(error) ) // 响应拦截器:统一错误提示 client.interceptors.response.use( (res) => res, (error) => { const status = error.response?.status if (status === 401) { console.error('API Key 无效或已过期,请检查 Authorization 头') } else if (status === 429) { console.error('请求过于频繁,触发限流') } return Promise.reject(error) } ) export default client然后是模型配置,用一个 JSON 结构管理,切换模型只改activeModel:
{ "models": { "qwen": { "label": "通义千问", "model": "qwen-turbo", "baseURL": "https://taotoken.net/api" }, "ernie": { "label": "文心一言", "model": "ernie-lite-8k", "baseURL": "https://taotoken.net/api" }, "openai": { "label": "OpenAI", "model": "gpt-3.5-turbo", "baseURL": "https://taotoken.net/api" } }, "activeModel": "qwen" }如果你用 Vite 的环境变量,建一个.env.local:
# .env.local VITE_API_KEY=sk-你的真实Key VITE_BASE_URL=https://taotoken.net/api三件套对照表,照着填不会错:
| 配置项 | 值 | 说明 |
|---|---|---|
| Base URL | https://taotoken.net/api | 所有模型共用 |
| API Key | sk-xxxx | 控制台创建,Bearer 鉴权 |
| Model ID | qwen-turbo/gpt-3.5-turbo | 按模型填,不是中文名 |
流式请求的封装函数:
// src/api/stream.js import client from './chat' export async function streamChat({ model, messages, onDelta, onDone, onError }) { try { const response = await client.post( '/v1/chat/completions', { model, messages, stream: true }, { responseType: 'stream', onDownloadProgress: (e) => { // axios 浏览器端流式:通过 progress 事件拿增量 const chunk = e.event?.target?.responseText || '' const lines = chunk.split('\n').filter((l) => l.startsWith('data: ')) for (const line of lines) { const payload = line.replace('data: ', '').trim() if (payload === '[DONE]') { onDone && onDone() return } try { const json = JSON.parse(payload) const delta = json.choices?.[0]?.delta?.content || '' if (delta) onDelta && onDelta(delta) } catch (err) { // 分段数据可能不完整,跳过 } } } } ) return response } catch (err) { onError && onError(err) } }这里要说明一个坑:axios 在浏览器端并没有真正的response.data.on('data'),那是 Node 流才有的 API。网上很多教程直接抄 Node 写法,在浏览器里会报response.data.on is not a function。正确做法是用onDownloadProgress配合responseText增量解析,或者干脆用 fetch 的 reader。本文用 axios 的 progress 方案,代码更短。
组件里调用:
import { ref } from 'vue' import { streamChat } from './api/stream' const messages = ref([{ role: 'assistant', content: '你好,有什么可以帮你?' }]) const inputText = ref('') const loading = ref(false) const activeModel = ref('qwen') const sendMessage = async () => { const text = inputText.value.trim() if (!text || loading.value) return messages.value.push({ role: 'user', content: text }) inputText.value = '' loading.value = true const aiMsg = { role: 'assistant', content: '' } messages.value.push(aiMsg) await streamChat({ model: activeModel.value === 'qwen' ? 'qwen-turbo' : 'gpt-3.5-turbo', messages: messages.value.filter((m) => m.content), onDelta: (delta) => { aiMsg.content += delta }, onDone: () => { loading.value = false }, onError: (err) => { aiMsg.content = '请求失败:' + (err.response?.data?.error?.message || err.message) loading.value = false } }) }模板部分保持简洁,消息列表加输入框:
<template> <div class="chat-container"> <div class="chat-header"> Vue3 智能对话 <select v-model="activeModel"> <option value="qwen">通义千问</option> <option value="ernie">文心一言</option> <option value="openai">OpenAI</option> </select> </div> <div class="message-box" ref="messageBox"> <div v-for="(item, i) in messages" :key="i" :class="['message', item.role]"> <div class="avatar">{{ item.role === 'user' ? '我' : 'AI' }}</div> <div class="content">{{ item.content }}</div> </div> </div> <div class="input-box"> <textarea v-model="inputText" @keydown.enter.prevent="sendMessage" rows="2" /> <button @click="sendMessage" :disabled="loading"> {{ loading ? '思考中...' : '发送' }} </button> </div> </div> </template>样式部分按需调整,重点是.message.user靠右、.message.assistant靠左,气泡圆角和阴影让界面不那么生硬。自动滚动用nextTick把scrollTop设成scrollHeight。
4. 验证请求:从 401 到流式打字机效果的完整排查
配置写完了,怎么确认真的通了?分三步验证。
第一步,先用 curl 验证 Key 和 Base URL 是否正确,排除前端代码干扰:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "qwen-turbo", "messages": [{"role": "user", "content": "你好"}], "stream": false }'如果返回 JSON 里有choices[0].message.content,说明 Key 和模型 ID 都对。如果返回 401,看错误信息是invalid_api_key还是missing_authorization,前者是 Key 错,后者是请求头没带上。
第二步,在浏览器里发一条消息,打开 DevTools 的 Network 面板,找到/v1/chat/completions请求。看三个地方:Request Headers 里有没有Authorization: Bearer sk-xxx;Response Headers 的content-type是不是text/event-stream;Response 面板里是不是一行行data: {...}往下刷。如果 Response 是一次性返回的完整 JSON,说明stream: true没生效。
第三步,观察界面。成功的标志是 AI 气泡里的文字逐字增加,像打字机一样。如果文字一次性全出来,回到streamChat检查responseType: 'stream'和onDownloadProgress是否都写了。
我实测下来,最容易出问题的是onDownloadProgress里responseText的累积特性。axios 的e.event.target.responseText返回的是从请求开始到当前的完整响应文本,不是增量。所以如果你每次都从头解析,会导致内容重复拼接。正确做法是记录一个已处理长度,只解析新增部分:
let processedLen = 0 onDownloadProgress: (e) => { const full = e.event?.target?.responseText || '' const fresh = full.slice(processedLen) processedLen = full.length const lines = fresh.split('\n').filter((l) => l.startsWith('data: ')) // ...后续解析 }这个细节网上很少讲,但不处理就会出现「你好你好你好」这种重复。踩过一次就记住了。
验证模型切换:把下拉框切到 OpenAI,发一条消息,看 Network 里请求体的model字段是不是变成了gpt-3.5-turbo。如果没变,检查activeModel的绑定和streamChat里的映射逻辑。
验证错误处理:故意把 Key 改错一位,发消息,看界面是否显示「请求失败:401」而不是白屏或卡死。再故意断网,看是否走onError分支。
5. 常见报错排查:401、local proxy failed、reading choices 逐个击破
这一节按真实报错来,你遇到哪个查哪个。
报错一:401 Unauthorized或invalid_api_key
原因通常是三种:Key 复制时带了空格、Key 已过期或被删除、请求头格式不对。检查Authorization的值必须是Bearer sk-xxx,Bearer 和 Key 之间一个空格。如果你用环境变量,确认.env.local里没有引号包裹,VITE_API_KEY=sk-xxx而不是VITE_API_KEY="sk-xxx"。改完重启npm run dev,Vite 的环境变量需要重启才生效。
报错二:local proxy failed或net::ERR_CONNECTION_REFUSED
这个多半是 Base URL 写错或本地代理配置冲突。先确认BASE_URL是https://taotoken.net/api,没有多余斜杠。如果你本地开了抓包工具或系统代理,可能拦截了请求,临时关掉再试。还有一种情况是 Vite 的server.proxy配置了转发但目标地址写错,检查vite.config.js里有没有多余的 proxy 规则。
报错三:Cannot read properties of undefined (reading 'choices')
这是解析响应时choices不存在。原因通常是返回的不是标准 OpenAI 格式,或者流式数据里混入了非 JSON 行。加一层防御:
const json = JSON.parse(payload) const delta = json?.choices?.[0]?.delta?.content if (delta) onDelta(delta)用可选链避免直接崩。另外确认model字段填的是平台支持的 ID,填错模型有时会返回错误对象而不是标准结构。
报错四:response.data.on is not a function
前面提过,这是把 Node 流写法搬到浏览器了。浏览器端 axios 没有.on('data'),改用onDownloadProgress。如果你确实想用 reader 风格,换成 fetch:
const res = await fetch(url, { method: 'POST', headers, body }) const reader = res.body.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) break const text = decoder.decode(value) // 解析 text }报错五:OAuth 相关错误或invalid_grant
如果你接的是需要 OAuth 换 token 的平台(比如某些文心接口要先拿 access_token),会出现这类错误。本文用统一 Base URL 的 Bearer 鉴权绕开了 OAuth 流程,如果你坚持直连原平台,需要先调 token 接口换 access_token,再拼到 URL 参数里。建议直接用兼容 OpenAI 协议的入口,省掉这一步。
报错六:CORS 跨域blocked by CORS policy
纯前端直连时,如果目标接口没开 CORS 就会报这个。用统一 Base URL 的兼容接口通常已经配好 CORS。如果还报,检查是不是请求打到了错误的域名。线上部署时用 Nginx 反代同源路径可以彻底规避。
排查顺序建议:先 curl 验证 Key → 再看 Network 请求头 → 再看响应格式 → 最后查前端解析逻辑。从外到内,别一上来就改代码。
6. 语义一致 CTA:把对话界面接到你的真实工作流
界面跑通只是起点。接下来你可以做三件事让它真正有用。
第一,把 API Key 从代码里挪到环境变量,再挪到后端。前端直连适合原型,正式用一定要加一层 Node 或 Serverless 转发,Key 只存在服务端。转发层还能做限流、日志、敏感词过滤。
第二,把模型切换做成配置驱动。本文的 JSON 结构可以扩展成从接口拉取模型列表,用户在前端选,请求时带上对应 Model ID。三件套始终是 Base URL、API Key、Model ID,换任何模型都是改这三个值。
第三,如果你要做长期编码助手或 Agent,按量计费的 Key 可能不够划算,可以看看 Coding Plan,入口https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。需要管理多个 Key 或查看用量,去 API Keys 页面https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。接入过程中遇到协议细节,查接入文档https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。想先手动验证模型 ID 是否可用,用模型对话https://taotoken.net/api?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite。
最后留一个实用技巧:在streamChat里加一个AbortController,用户点「停止生成」时中断请求,避免长回复卡住界面。axios 支持signal参数,传进去即可。这个功能在真实使用中比想象中重要,尤其是模型抽风一直输出的时候。