Node.js+Vue3.5 实战:豆包快速 / 深度思考模型的流式调用方案
开篇:为什么要做这个封装?
前阵子做 AI 对话功能时,直接调用豆包 API 遇到了些小麻烦 ------ 比如快速模型和深度模型的参数要区分;想做前端逐字渲染,得自己处理 SSE 流式数据。
索性花了一天把这些逻辑整理成可复用的方案,后端用 Node.js 统一处理双模型的差异,前端 Vue3.5 做简洁的交互界面。现在不管是基础问答还是复杂解题,切换模型点个按钮就行,流式渲染的打字效果还挺直观。这篇就把整个思路拆解开,从参数哪里找、代码怎么写,到遇到的小问题,都跟大家聊一聊。
一、先搞定核心配置:这些参数从哪来?
要调用豆包模型,首先得拿到「钥匙」和「地址」,这些都从火山引擎平台获取,关键配置整理如下:
配置项 | 示例值 | 从哪找? |
---|---|---|
快速思考模型 ID | doubao-seed-1-6-flash-250615 | 火山引擎控制台 → 豆包开发者平台 → 模型管理 → 复制对应模型 ID www.volcengine.com/docs/82379/... |
深度思考模型 ID | doubao-seed-1-6-thinking-250715 | 同上 |
API 密钥(MODEL_API_KEY) | sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | 密钥管理 → 新建 / 复制 Access Key(console.volcengine.com/ark/region:ark+cn-beijing/apiKey) |
基础地址(BASE_URL) | ark.cn-beijing.volces.com/api/v3 | www.volcengine.com/docs/82379/1494384#RxN8G2nH |
对话接口路径 | /chat/completions | 同上 |
重要提醒 :API 密钥千万别硬写在代码里!建议建.env
文件存储,用dotenv
加载,既安全又方便切换环境:
ini
\# .env文件(需加入.gitignore)
QUICK\_MODEL\_ID='doubao-seed-1-6-flash-250615'
DEEP\_MODEL\_ID='doubao-seed-1-6-thinking-250715'
MODEL\_API\_KEY='你的密钥'
BASE\_URL='https://ark.cn-beijing.volces.com/api/v3'
CHAT\_ENDPOINT='/chat/completions'
快速思考(基础对话)模型与深度思考模型请求 / 响应参数差异表
对比维度 | 快速思考(基础对话)模型 | 深度思考模型 |
---|---|---|
核心请求参数 | 包含model 、messages 、temperature 、top_p 、max_tokens 、stream |
基础参数 + 3 个特殊参数:- thinking :控制思考模式(enabled/disabled/auto)- max_output_tokens :控制输出总长度(含思维链)- instructions :替换系统提示词 |
messages 参数 |
仅需包含对话角色(system/user/assistant)与基础内容,无需思维过程记录 | 可额外添加模型思考过程的历史记录(如 assistant 角色的 "我需要先回忆公式再计算..."),辅助复杂推理 |
模型 ID 示例 | doubao-seed-1-6-250615 | doubao-seed-1-6-thinking-250715 |
响应结构 | 核心字段:id 、object 、created 、choices (含message 和finish_reason )、usage |
核心字段:output 数组,含两类数据:- type: reasoning :思维过程总结- type: message :最终回答内容 |
响应内容组成 | 仅返回最终回答(choices[0].message.content ) |
同时返回思维链(推理过程)和最终回答,明确展示模型思考逻辑 |
Token 控制 | 仅通过max_tokens 控制输出长度,不区分 "回答" 与 "思维链" |
通过max_output_tokens 控制 "思维链 + 回答" 总长度,支持更大取值(如最大 65536) |
二、后端封装:用 Node.js 搞定双模型差异
后端选用 Express 框架,核心思路:将双模型差异隐藏在内部,给前端暴露统一调用接口(如/api/chat/quick
对应快速模型,/api/chat/deep
对应深度模型),无需前端关心参数拼接。
1. 第一步:搭基础架子(配置 + 工具类)
写config.js
统一管理配置和通用方法(参数验证、流式数据解析等),后续修改更便捷:
javascript
// config.js
require('dotenv').config(); // 加载.env文件
// 环境配置
const CONFIG = {
models: {
quick: process.env.QUICK_MODEL_ID,
deep: process.env.DEEP_MODEL_ID
},
api: {
baseUrl: process.env.BASE_URL,
chatPath: process.env.CHAT_ENDPOINT,
key: process.env.MODEL_API_KEY
}
};
// 工具类:封装通用逻辑
class ApiTool {
// 验证必填参数(如messages不可为空)
static checkParams(params, required) {
const missing = required.filter(key => !params[key]);
if (missing.length) throw new Error(`缺参数:${missing.join(', ')}`);
}
// 解析流式数据:区分双模型返回格式
static parseStreamChunk(chunk, buffer, modelType) {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保存不完整行,下次继续解析
const contentList = [];
lines.forEach(line => {
if (line.trim().startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
let content = '';
// 深度模型从output取,快速模型从choices取
if (modelType === 'deep') {
content = data.output?.[0]?.content?.[0]?.text || '';
} else {
content = data.choices?.[0]?.delta?.content || '';
}
if (content) contentList.push(content);
} catch (e) {
console.log('解析流式数据错误:', e.message); // 单条错误不影响整体
}
}
});
return { contentList, buffer };
}
// 发送SSE数据:给前端统一格式
static sendSSE(res, data) {
res.write(`data: ${JSON.stringify({ code: 0, data })}\n\n`);
}
}
module.exports = { CONFIG, ApiTool };
2. 第二步:写核心处理器(双模型逻辑)
核心解决两个问题:按模型类型动态拼参数、处理流式响应并转发给前端,用modelType
区分双模型逻辑:
javascript
// chatHandler.js
const axios = require('axios');
const { CONFIG, ApiTool } = require('./config');
// 快速模型接口
exports.quickChat = (req, res) => handleChat(req, res, 'quick');
// 深度模型接口
exports.deepChat = (req, res) => handleChat(req, res, 'deep');
// 统一处理双模型的核心方法
async function handleChat(req, res, modelType) {
try {
const { messages, temperature = 0.7, maxTokens = 1000 } = req.body;
// 1. 验证参数:messages不可为空
ApiTool.checkParams({ messages }, ['messages']);
// 2. 配置SSE响应头:声明流式数据,禁止缓存
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 3. 动态拼接请求参数:处理双模型差异
const requestData = {
model: CONFIG.models[modelType], // 自动选择模型ID
messages,
stream: true, // 开启流式响应(必传)
temperature, // 随机性(0-2)
// 深度模型额外参数,快速模型用max_tokens
...(modelType === 'deep'
? {
thinking: { type: 'auto' }, // 自动判断是否需要思考
max_output_tokens: maxTokens // 含思维链的总长度
}
: { max_tokens: maxTokens }) // 快速模型仅限制输出长度
};
// 4. 调用豆包API,获取流式响应
const response = await axios.post(
`${CONFIG.api.baseUrl}${CONFIG.api.chatPath}`,
requestData,
{
headers: {
Authorization: `Bearer ${CONFIG.api.key}`,
'Content-Type': 'application/json'
},
responseType: 'stream' // 声明流式响应(关键)
}
);
// 5. 处理流式数据,转发给前端
let buffer = '';
response.data.on('data', (chunk) => {
const { contentList, buffer: newBuffer } = ApiTool.parseStreamChunk(
chunk, buffer, modelType
);
buffer = newBuffer;
contentList.forEach(content => ApiTool.sendSSE(res, { content }));
});
// 6. 响应结束,通知前端
response.data.on('end', () => {
ApiTool.sendSSE(res, { status: 'done' });
res.end();
});
} catch (err) {
// 错误处理:统一格式返回
ApiTool.sendSSE(res, {
status: 'error',
msg: err.response?.data?.msg || err.message || '服务器异常'
});
res.end();
}
}
三、前端实现:Vue3.5 做流式交互
前端核心需求:切换模型、逐字渲染。注意:原生EventSource
仅支持 GET 请求,需自定义支持 POST 的 SSE 客户端。
1. 组件结构:简洁直观
模板分「聊天记录区」和「输入区」,模型切换用单选按钮:
ini
<template>
<div class="chat-page">
<div class="chat-history">
<div
v-for="(msg, idx) in chatHistory"
:key="idx"
:class="['chat-item', msg.role === 'user' ? 'user-item' : 'ai-item']"
>
<div class="chat-role">{{ msg.role === 'user' ? '我' : msg.model }}</div>
<div class="chat-content">{{ msg.content }}</div>
</div>
</div>
<div class="chat-input-area">
<textarea
v-model="inputContent"
placeholder="输入问题,比如'解方程式3x+5=20'..."
@keydown.enter.prevent="sendMessage"
:disabled="isLoading"
></textarea>
<div class="model-switch">
<label class="switch-item">
<input
type="radio"
v-model="selectedModel"
value="quick"
checked
>
快速对话(快)
</label>
<label class="switch-item">
<input
type="radio"
v-model="selectedModel"
value="deep"
>
深度思考(细)
</label>
</div>
<button
class="send-btn"
@click="sendMessage"
:disabled="isLoading || !inputContent.trim()"
>
{{ isLoading ? '发送中...' : '发送' }}
</button>
</div>
</div>
</template>
2. 核心逻辑:SSE 连接 + 逐字渲染
脚本重点处理 SSE 连接和逐字渲染,自定义 SSE 客户端内置组件:
ini
<script setup>
import { ref, onUnmounted } from 'vue';
// 状态管理
const inputContent = ref(''); // 输入框内容
const chatHistory = ref([]); // 聊天记录
const selectedModel = ref('quick'); // 当前选中模型
const isLoading = ref(false); // 发送状态
let sseInstance = null; // SSE连接实例
// 发送消息
const sendMessage = async () => {
const content = inputContent.value.trim();
if (!content || isLoading.value) return;
// 1. 添加用户消息到历史
chatHistory.value.push({ role: 'user', content, model: '' });
// 2. 添加AI回复占位符(后续逐字填充)
const aiMsgIdx = chatHistory.value.length;
chatHistory.value.push({
role: 'ai',
model: selectedModel.value === 'quick' ? '快速模型' : '深度模型',
content: ''
});
// 3. 重置输入与状态
inputContent.value = '';
isLoading.value = true;
try {
// 4. 准备请求参数(聊天历史)
const requestData = {
messages: chatHistory.value.map(item => ({
role: item.role === 'user' ? 'user' : 'assistant',
content: item.content
})),
maxTokens: 2000 // 最大输出长度
};
// 5. 关闭旧连接(避免多开)
if (sseInstance) sseInstance.close();
// 6. 建立SSE连接(调用自定义客户端)
sseInstance = new CustomEventSource(
`/api/chat/${selectedModel.value}`,
{ method: 'POST', body: JSON.stringify(requestData) }
);
// 7. 监听SSE消息:逐字渲染
sseInstance.addEventListener('message', (event) => {
const { code, data } = JSON.parse(event.data);
if (code !== 0) return;
// 填充AI回复
if (data.content) {
chatHistory.value[aiMsgIdx].content += data.content;
}
// 响应结束:关闭状态与连接
if (data.status === 'done' || data.status === 'error') {
isLoading.value = false;
sseInstance.close();
}
// 错误提示
if (data.status === 'error') {
chatHistory.value[aiMsgIdx].content += `\n\n❌ ${data.msg}`;
}
});
// 8. 监听SSE错误
sseInstance.addEventListener('error', () => {
isLoading.value = false;
chatHistory.value[aiMsgIdx].content += '\n\n❌ 连接失败,请重试';
sseInstance.close();
});
} catch (err) {
isLoading.value = false;
chatHistory.value[aiMsgIdx].content += `\n\n❌ 发送失败:${err.message}`;
}
};
// 组件卸载:关闭SSE连接(避免内存泄漏)
onUnmounted(() => {
if (sseInstance) sseInstance.close();
});
// 自定义SSE客户端:支持POST请求
class CustomEventSource {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.listeners = {};
this.abortCtrl = new AbortController();
this.connect();
}
// 建立连接
async connect() {
try {
const response = await fetch(this.url, {
method: this.options.method || 'GET',
headers: { 'Content-Type': 'application/json', ...this.options.headers },
body: this.options.body,
signal: this.abortCtrl.signal,
keepalive: true
});
if (!response.ok) throw new Error(`HTTP错误:${response.status}`);
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析SSE格式,触发message事件
const chunk = decoder.decode(value, { stream: true });
chunk.split('\n').forEach(line => {
if (line.trim().startsWith('data: ')) {
this.dispatchEvent('message', { data: line.slice(6) });
}
});
}
// 连接关闭:触发close事件
this.dispatchEvent('close', {});
} catch (err) {
this.dispatchEvent('error', err);
}
}
// 添加事件监听
addEventListener(type, callback) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(callback);
}
// 触发事件
dispatchEvent(type, data) {
(this.listeners[type] || []).forEach(cb => cb(data));
}
// 关闭连接
close() {
this.abortCtrl.abort();
this.dispatchEvent('close', {});
}
}
</script>
3. 样式:简洁舒适(Tailwind CSS)
重点区分用户 / AI 消息,优化加载状态提示:
xml
<style scoped>
.chat-page {
max-width: 800px;
margin: 20px auto;
padding: 0 15px;
font-family: 'Inter', sans-serif;
}
.chat-history {
min-height: 500px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
overflow-y: auto;
background-color: #f9fafb;
}
.chat-item {
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
max-width: 80%;
}
.user-item {
margin-left: auto;
background-color: #3b82f6;
color: white;
}
.ai-item {
margin-right: auto;
background-color: white;
border: 1px solid #e5e7eb;
}
.chat-role {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.chat-content {
font-size: 15px;
line-height: 1.6;
white-space: pre-wrap; /* 保留换行 */
}
.chat-input-area {
display: flex;
flex-direction: column;
gap: 10px;
}
textarea {
height: 100px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
resize: vertical;
font-size: 15px;
}
.model-switch {
display: flex;
gap: 20px;
padding: 4px 0;
font-size: 14px;
color: #4b5563;
}
.switch-item {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.send-btn {
align-self: flex-end;
padding: 10px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
transition: background-color 0.2s;
}
.send-btn:disabled {
background-color: #94a3b8;
cursor: not-allowed;
}
</style>
四、可以优化的点
-
聊天记录持久化:当前刷新页面记录丢失,可加后端接口存数据库,前端加载时拉取;
-
参数可配置 :目前
temperature
和maxTokens
固定,可加前端设置面板让用户自定义; -
加载状态优化:在 AI 回复末尾加 "..." 打字动画,提升交互体验;
-
生产环境配置:限制 CORS 来源(避免全跨域),加接口限流防止被刷。
五、最后
这个方案从开发到调试约 2 天,封装后后续项目调用豆包双模型,只需拷贝后端代码、修改.env
配置、引入前端组件即可使用。
如果您也需要做豆包模型的流式调用,希望这篇能帮您少走弯路。若有更好的实现方式,欢迎在评论区交流~