在前端开发 AI 应用时,最令人着迷的体验莫过于流式输出------看着文字像打字机一样逐字蹦出来,而不是对着一个转圈的 Loading 干瞪眼。
但在开发阶段,我们往往面临一个尴尬的境地:后端接口还没好,或者不想消耗昂贵的 API 额度。这时候,我们需要在 Vite 层面"伪造"一个流式接口。
今天,我们就通过一段核心代码,从网络协议 到流处理,彻底拆解如何在前端 Mock 层实现一个标准的流式 AI 对话接口。
🏗️ 核心架构:数据是如何流动的?
要实现"打字机"效果,数据流必须满足以下条件:
- 前端:发送请求,保持连接打开,监听数据流。
- Vite Mock :充当"中间人"。接收前端请求 -> 转发给 DeepSeek -> 流式接收 DeepSeek 的响应 -> 流式转发 给前端。
- DeepSeek :开启
stream: true,逐字生成 Token。
关键技术栈:
- Node.js Stream:处理 HTTP 请求体。
- Fetch API & ReadableStream:处理上游(DeepSeek)的流式响应。
- SSE (Server-Sent Events) :服务端推送协议,实现浏览器端的实时更新。
💻 代码实战:逐行深度解析
这是基于 vite-plugin-mock 的实现方案。
1. 环境准备与配置
首先,我们需要加载环境变量,并配置 Mock 规则。注意这里使用的是 rawResponse,这是实现流式传输的关键。
javascript
import { config } from 'dotenv';
config(); // 加载 .env 文件
export default [
{
url: '/api/ai/chat',
method: 'post',
// 核心点:使用 rawResponse 获取原生 req 和 res 对象
rawResponse: async (req, res) => {
// ... 后续逻辑
}
}
]
💡 为什么不用普通的
response?普通的
response通常用于返回 JSON 对象,Vite 会将其序列化后一次性返回。而rawResponse允许我们操作底层的 Node.jsres对象,手动控制write和end,这是实现流式输出的基础。
2. 手动收集请求体 (Body Parser)
Node.js 原生的 req 是一个流。与 Express/Koa 不同,Vite Mock 不会自动帮我们解析 req.body,我们需要手动拼接。
javascript
let body = '';
req.on('data', (chunk) => {
body += chunk; // 接收二进制流并拼接
});
req.on('end', async () => {
// 数据接收完毕,开始处理业务
const { messages } = JSON.parse(body);
// ...
});
3. 设置响应头:开启"水龙头"
在转发请求前,必须告诉浏览器:"我要开始流式传输了,别急着关闭连接"。
arduino
// 设置响应头
res.setHeader('Content-Type', 'text/plain;charset=utf-8');
// 核心:分块传输编码,告诉浏览器数据是一块一块来的
res.setHeader('Transfer-Encoding', 'chunked');
// 兼容 Vercel AI SDK 的特定头(如果你前端用了这个库)
res.setHeader('x-vercel-ai-data-stream', 'v1');
4. 调用 DeepSeek 并开启流模式
这是连接大模型的关键一步。注意 stream: true 参数。
javascript
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 从环境变量读取 Key,避免硬编码
'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: messages,
stream: true // 🔥 开启流式输出
})
});
if (!response.body) throw new Error("No response body");
5. 核心逻辑:管道传输 (Pipe)
这是最精彩的部分。我们需要读取 DeepSeek 返回的 ReadableStream,解析 SSE 格式,然后写入到前端的响应流中。
javascript
// 1. 获取读取器
const reader = response.body.getReader();
// 2. 创建解码器 (二进制 -> 字符串)
const decoder = new TextDecoder();
while (true) {
// 3. 读取流数据
const { done, value } = await reader.read();
if (done) break; // 读取完毕
// 4. 解码二进制数据
const chunk = decoder.decode(value);
// 5. 解析 SSE 格式 (data: {...})
const lines = chunk.split('\n');
for (let line of lines) {
// 过滤有效数据行,排除 [DONE] 结束信号
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
try {
// 去掉 'data: ' 前缀并解析 JSON
const data = JSON.parse(line.slice(6));
// 提取增量内容
const content = data.choices[0]?.delta?.content || '';
if (content) {
// 6. 写入响应流给前端
// 格式 0:content 是 Vercel AI SDK 的约定格式,可根据实际情况调整
res.write(`0:${JSON.stringify(content)}\n`);
}
} catch (err) {
console.error('解析 SSE 失败', err);
}
}
}
}
// 7. 结束响应
res.end();
🔍 深度解析:为什么要这么写?
1. 关于 Transfer-Encoding: chunked
在 HTTP/1.1 中,如果服务器不知道内容的总长度(因为 AI 是无限生成的),就不能使用 Content-Length。
chunked 编码允许服务器将数据分割成多个部分发送。浏览器接收到一个 chunk,就会触发一次前端的 onmessage 或流读取事件,从而实现"边生成边显示"。
2. 关于 SSE (Server-Sent Events) 协议
DeepSeek 返回的数据格式是标准的 SSE:
css
data: {"id":"...","choices":[{"delta":{"content":"你"}}]}
data: {"id":"...","choices":[{"delta":{"content":"好"}}]}
data: [DONE]
我们在代码中通过 line.startsWith('data: ') 来识别有效载荷,并利用 JSON.parse 提取出真正的文字内容。
3. 关于 TextDecoder
网络传输的是二进制流 (Uint8Array)。如果直接用 String(value) 转换,遇到中文可能会出现乱码(因为一个中文字符可能占用 3 个字节,可能会被切分在两个 chunk 中)。TextDecoder 能够智能处理 UTF-8 编码的边界问题,确保中文不乱码。
🛠️ 优化建议:生产环境怎么用?
虽然这段代码在 Mock 阶段很完美,但直接用于生产环境有几个隐患:
- API Key 泄露:代码运行在服务端(Node.js)是安全的,但如果这段逻辑被打包到浏览器端,Key 就会裸奔。
- 缺少超时控制:如果 AI 卡住了,连接会一直挂着。
- 缺少错误透传:如果 DeepSeek 报 500 错误,我们需要透传给前端。
优化思路(使用 AbortController):
ini
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000); // 30秒超时
try {
const response = await fetch('...', { signal: controller.signal });
// ...
} catch (e) {
if (e.name === 'AbortError') {
res.write('data: [Error: Request Timeout]\n\n');
}
} finally {
clearTimeout(timeout);
res.end();
}
📌 总结
通过这段代码,我们不仅实现了一个好用的 Mock 接口,更重要的是掌握了 HTTP 流式传输 的底层原理。
- 前端 :利用
fetch的ReadableStream读取数据。 - 中间层 :利用 Node.js 的
req/res做流的中转和格式转换。 - 协议 :理解了
chunked编码和 SSE 格式