通过DeepSeek大模型官网的示例可以看出:我们提出一个问题,只要服务器完成一点点,客户端就会接收一点点。 接下来,我们就来学习使用流式传输减少用户等待时间,使用NodeJS服务模拟SSE通信,给我们的VUE项目调用大模型。
准备工作
首先,我们准备一个最基础的vue项目,我这里通过Trae builder直接生成了。
在一级目录下增加一个文件.env.local,增加调用DeepSeek的API KEY:
VITE_DEEPSEEK_API_KEY="你的API KEY"
第一个知识点:简单调用DeepSeek的API,完成流式传输:
我们直接在APP.vue里Fetch请求,完整代码如下:
<script setup lang="ts">
import { ref } from "vue";
const question = ref("你好");
const content = ref("");
const stream = ref(true);
const update = async () => {
if (!question) return;
content.value = "思考中...";
const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
};
const response = await fetch(endpoint, {
method: "POST",
headers: headers,
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: question.value }],
stream: stream.value, // 这里 stream.value 值如果是 true,采用流式传输
}),
});
if (stream.value) {
// https://developer.mozilla.org/zh-CN/docs/Web/API/Streams_API
content.value = "";
const reader = response.body?.getReader(); // 利用 ReadableStream API 通过 getReader() 获取一个读取器
const decoder = new TextDecoder(); // 创建 TextDecoder 准备对二进制数据进行解码
let done = false; //控制流标志 done
let buffer = ""; //buffer 变量来缓存数据,因为 Stream 数据返回给前端时,不一定传输完整。
// 开始循环读取数据,通过 TextDecoder 解析数据,将数据转换成文本并按行拆分。
while (!done) {
const { value, done: doneReading } = await (reader?.read() as Promise<{
value: any;
done: boolean;
}>);
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = "";
// 因为 API 返回流式数据的协议是每一条数据以 “data:” 开头,后续是一个有效的 JSON 或者[DONE]表示传输结束,
// 所以我们要对每一行以"data:"开头的数据进行处理。
const lines = chunkValue
.split("\n")
.filter((line) => line.startsWith("data: "));
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === "[DONE]") {
done = true;
break;
}
// 如果数据传输完整,且不是[DONE],那么它就是合法 JSON,我们从中读取 data.choices[0].delta.content,就是需要增量更新的内容,
// 否则说明数据不完整,将它存入缓存,以便后续继续处理。
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) content.value += delta;
} catch (ex) {
buffer += `data: ${incoming}`;
}
}
}
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
};
</script>
<template>
<div class="container">
<div>
<label>输入:</label><input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label><input type="checkbox" v-model="stream" />
</div>
<div>{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
其中,endpoint是我们请求DeepSeek的API地址,如果我们是本地企业级项目,显然直接访问外网是不合理的;headers是请求头参数,Authorization中携带了API KEY,这意味着这个接口一定要POST请求,才能加密传输;请求参数body中的stream如果设置为true,你就能在控制台看到该接口的响应Content-Type是text/event-stream。所以,所谓的流式传输是要求服务端返回。
const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
};
const response = await fetch(endpoint, {
method: "POST",
headers: headers,
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: question.value }],
stream: stream.value, // 这里 stream.value 值如果是 true,采用流式传输
}),
});
对于服务器返回的流式,我们通过 HTML5 标准的Streams API来处理数据主要逻辑如下:
const reader = response.body?.getReader(); // 利用 ReadableStream API 通过 getReader() 获取一个读取器
const decoder = new TextDecoder(); // 创建 TextDecoder 准备对二进制数据进行解码
let done = false; //控制流标志 done
let buffer = ""; //buffer 变量来缓存数据,因为 Stream 数据返回给前端时,不一定传输完整。
// 开始循环读取数据,通过 TextDecoder 解析数据,将数据转换成文本并按行拆分。
while (!done) {
const { value, done: doneReading } = await (reader?.read() as Promise<{
value: any;
done: boolean;
}>);
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = "";
// 因为 API 返回流式数据的协议是每一条数据以 “data:” 开头,后续是一个有效的 JSON 或者[DONE]表示传输结束,
// 所以我们要对每一行以"data:"开头的数据进行处理。
const lines = chunkValue
.split("\n")
.filter((line) => line.startsWith("data: "));
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === "[DONE]") {
done = true;
break;
}
// 如果数据传输完整,且不是[DONE],那么它就是合法 JSON,我们从中读取 data.choices[0].delta.content,就是需要增量更新的内容,
// 否则说明数据不完整,将它存入缓存,以便后续继续处理。
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) content.value += delta;
} catch (ex) {
buffer += `data: ${incoming}`;
}
}
}
那流式传输是如何结束的呢?打开请求,可以看到最后会有一个Done,这就代表该回答结束了,我们需要监听并处理。
以上,就是最简单实现前端直接调用大模型API的示例。
第二个知识点:客户端使用SSE监听,完成通信;
首先,执行pnpm i dotenv express安装dotenv和express,在根目录下创建server.js,执行node server.js启动服务。完整代码如下:
// 在 server 端处理大模型 API 的流式响应,并将数据仍以兼容 SSE(以"data: "开头)的形式逐步发送给浏览器端。
import * as dotenv from "dotenv";
import express from "express";
dotenv.config({
path: [".env.local", ".env"],
});
const openaiApiKey = process.env.VITE_DEEPSEEK_API_KEY;
const app = express();
const port = 3000;
const endpoint = "https://api.deepseek.com/v1/chat/completions";
const systemPrompt = `你是前端工程师,熟悉vue和react、nodejs、python、java、c++、go等语言,请根据用户提问,给出简洁、准确的回答。
输出以下JSON格式内容:
{
“question”:"vue和React的区别?",
"answer":"vue和React的区别是..."
}
`;
// SSE 端点
app.get("/stream", async (req, res) => {
// 设置响应头部
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders(); // 发送初始响应头
try {
// 发送 OpenAI 请求
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${openaiApiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat", // 选择你使用的模型
// messages: [{ role: 'user', content: req.query.question }],
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: req.query.question },
],
stream: true, // 开启流式响应
// response_format: { type: "json_object" }, //JSON 格式来输出
}),
});
if (!response.ok) {
throw new Error("Failed to fetch from OpenAI");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = "";
// 读取流数据并转发到客户端
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value, { stream: true });
buffer = "";
// 按行分割数据,每行以 "data: " 开头,并传递给客户端
const lines = chunkValue
.split("\n")
.filter((line) => line.trim() && line.startsWith("data: "));
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === "[DONE]") {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) res.write(`data: ${delta}\n\n`); // 发送数据到客户端
} catch (ex) {
buffer += `data: ${incoming}`;
}
}
}
res.write("event: end\n"); // 发送结束事件
res.write("data: [DONE]\n\n"); // 通知客户端数据流结束
res.end(); // 关闭连接
} catch (error) {
console.error("Error fetching from OpenAI:", error);
res.write("data: Error fetching from OpenAI\n\n");
res.end();
}
});
// 启动服务器
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
修改APP.vue如下:
<script setup lang="ts">
import { ref } from "vue";
// const question = ref("你好");
const time1 = ref(0); //计时器
const spend = ref(0);
const question = ref("vue和react的区别?");
const content = ref("");
const answer = ref("");
const update = async () => {
if (!question) return;
content.value = "思考中...";
time1.value = new Date().getTime();
const endpoint = "/api/stream";
content.value = "";
// server 端处理转发关键代码
/**
* SSE 在浏览器内置了自动重连机制。
* 这意味着当网络、服务器或者客户端连接出现问题,恢复后将自动完成重新连接,
* 不需要用户主动刷新页面,这让 SSE 特别适合长时间保持连接的应用场景。
* 此外,SSE 还支持通过 lastEventId 来支持数据的续传,
* 这样在错误恢复时,能大大节省数据传输的带宽和接收数据的响应时间。
* **/
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.onmessage = (e: any) => {
content.value += e.data;
};
eventSource.addEventListener("end", () => {
eventSource.close();
spend.value = new Date().getTime() - time1.value;
answer.value = JSON.parse(content.value).answer;
});
eventSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
};
</script>
<template>
<div class="container">
<div>
<label>输入:</label><input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div>从提交到开始显示消耗时长(ms):{{ spend }}</div>
<div>实际显示在页面上:{{ answer }}</div>
<div>接收到的流式结果:{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
再修改vite.config.js,这样 server 请求就被转发到了 /api/stream。:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/
export default defineConfig({
server: {
allowedHosts: true,
port: 7773,
proxy: {
'/api': {
target: 'http://localhost:3000',
secure: false,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
plugins: [
vue()
],
});
可以看到,前端的代码简单多了,只需要关注SSE——Server-Sent Events 是一种允许服务器主动推送实时更新给浏览器的技术,属于 Web 标准,它除了实时推送数据外,还可以支持自定义事件 (Custom Events)和内容的续传。
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.onmessage = (e: any) => {
content.value += e.data;
};
eventSource.addEventListener("end", () => {
eventSource.close();
spend.value = new Date().getTime() - time1.value;
answer.value = JSON.parse(content.value).answer;
});
eventSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
做到这里,咱们先总结一下关键点:
- 通过流式传输减少用户等待时间的方法是通过设置stream参数为true,实现AI以流式传输的方式输出内容,从而减少用户的等待时间。
- Server-Sent Events (SSE) 是一种允许服务器主动推送实时更新给浏览器的技术,除了实时推送数据外,还可以支持自定义事件和内容的续传,适合长时间保持连接的应用场景。
上面的这个例子,运行之后可以看到,我在提示词中要求DeepSeek返回格式是个对象,前端实际显示到页面上只需要answer属性,但实际效果是,前端对 JSON 的解析必须等待 JSON 数据全部传输完成,否则会因为 JSON 数据不完整而导致解析报错。这就导致一个问题,即使我们在前端用流式获取 JSON 数据,我们也得等待 JSON 完成后才能解析数据并更新 UI,这就让原本流式数据快速响应的特性失效了。
所以我们需要对不完整的json做parser解析,然后利用这个 parser 来动态解析返回的数据流。
第三个知识点:传递JSON格式并要求前端实时解析
这里parser代码参考极客时间月影老师的,咱们就不二次开发了。在 src 目录的外边建立一个 parser 目录,将 github.com/WeHomeBot/l… 的内容复制过来。目录如下:
执行pnpm i jsonuri jiti安装jsonuri和jiti(为了兼容一个项目里ts和js),修改server.js:
// 在 server 端处理大模型 API 的流式响应,并将数据仍以兼容 SSE(以"data: "开头)的形式逐步发送给浏览器端。
import * as dotenv from "dotenv";
import express from "express";
import { JSONParser } from "./parser/index.ts";
dotenv.config({
path: [".env.local", ".env"],
});
const openaiApiKey = process.env.VITE_DEEPSEEK_API_KEY;
const app = express();
const port = 3001;
const endpoint = "https://api.deepseek.com/v1/chat/completions";
// 系统提示词
const systemPrompt = `你是前端工程师,熟悉vue和react、nodejs、python、java、c++、go等语言,请根据用户提问,给出简洁、准确的回答。
输出以下JSON格式内容:
{
“question”:"vue和React的区别?",
"answer":"vue和React的区别是..."
}
`;
// SSE 端点
app.get("/stream", async (req, res) => {
// 设置响应头部
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders(); // 发送初始响应头
try {
// 发送 OpenAI 请求
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${openaiApiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat", // 选择你使用的模型
// messages: [{ role: 'user', content: req.query.question }],
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: req.query.question },
],
stream: true, // 开启流式响应
response_format: { type: "json_object" }, //JSON 格式来输出
}),
});
if (!response.ok) {
throw new Error("Failed to fetch from OpenAI");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = "";
const jsonParser = new JSONParser({
autoFix: true,
onError: (error) => {
console.error("JSON Parser Error:", error);
},
});
// 在每次接收到新的流数据片段时触发(比如 SSE 的一行行 JSON 增量数据)。
// 是最原始的事件,可能只是一个片段,还没拼成完整的 JSON。
jsonParser.on("data", (data) => {
if (data.uri) res.write(`data: ${JSON.stringify(data)}\n\n`); // 发送数据到客户端
});
// 当解析器解析出完整的 JSON 对象时触发。
jsonParser.on("string-resolve", ({ uri, delta }) => {
console.log("string-resolve",uri,delta);
});
// 读取流数据并转发到客户端
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value, { stream: true });
buffer = "";
// 按行分割数据,每行以 "data: " 开头,并传递给客户端
const lines = chunkValue
.split("\n")
.filter((line) => line.trim() && line.startsWith("data: "));
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === "[DONE]") {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
jsonParser.trace(delta);
// if (delta) res.write(`data: ${delta}\n\n`); // 发送数据到客户端
} catch (ex) {
// buffer += `data: ${incoming}`;
buffer += incoming;
}
}
}
res.write("event: end\n"); // 发送结束事件
res.write("data: [DONE]\n\n"); // 通知客户端数据流结束
res.end(); // 关闭连接
} catch (error) {
console.error("Error fetching from OpenAI:", error);
res.write("data: Error fetching from OpenAI\n\n");
res.end();
}
});
// 启动服务器
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
大模型输出的 JSON 内容,我们通过 jsonParser 进行处理,发送给客户端。
jsonParser.on('data', (data) => {
if (data.uri) res.write(`data: ${JSON.stringify(data)}\n\n`); // 发送数据到客户端
});
在package.json中增加"jiti": "jiti server.js",然后使用pnpm jiti启动Server。
APP.vue修改如下:
<script setup lang="ts">
import { ref, watch } from "vue";
import { set, get } from "jsonuri";
const time1 = ref(0);//计时器
const spend = ref(0);
const question = ref("vue和react的区别?");
const content = ref({
question: "",
answer: "",
});
const answer = ref("");
watch(
() => content.value.answer,
(newVal, oldVal) => {
if (oldVal === "" && newVal !== "") {
spend.value = new Date().getTime() - time1.value;
}
answer.value = newVal;
}
);
const update = async () => {
if (!question) return;
time1.value = new Date().getTime();
const endpoint = "/api/stream";
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.onmessage = (e: any) => {
// content.value += e.data;
const { uri, delta } = JSON.parse(e.data);
const str = get(content.value, uri);
set(content.value, uri, (str || "") + delta);
};
eventSource.addEventListener("end", () => {
eventSource.close();
});
eventSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
};
</script>
<template>
<div class="container">
<div>
<label>输入:</label><input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div>从提交到开始显示消耗时长(ms):{{ spend }}</div>
<div>实际显示在页面上:{{ answer }}</div>
<div>接收到的流式结果:{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
在 EventSource 中,我们使用 jsonuri 处理服务端返回的数据。
const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.onmessage = (e: any) => {
// content.value += e.data;
const { uri, delta } = JSON.parse(e.data);
const str = get(content.value, uri);
set(content.value, uri, (str || "") + delta);
};
运行之后,就可以看到我们实现了即时Stream流传给我们不完整的json,前端依然能够实时解析并立刻显示在页面上。