从零手写 Vite Mock 流式 AI 对话,搞懂 LLM 的“打字机”特效

在前端开发 AI 应用时,最令人着迷的体验莫过于流式输出------看着文字像打字机一样逐字蹦出来,而不是对着一个转圈的 Loading 干瞪眼。

但在开发阶段,我们往往面临一个尴尬的境地:后端接口还没好,或者不想消耗昂贵的 API 额度。这时候,我们需要在 Vite 层面"伪造"一个流式接口。

今天,我们就通过一段核心代码,从网络协议流处理,彻底拆解如何在前端 Mock 层实现一个标准的流式 AI 对话接口。

🏗️ 核心架构:数据是如何流动的?

要实现"打字机"效果,数据流必须满足以下条件:

  1. 前端:发送请求,保持连接打开,监听数据流。
  2. Vite Mock :充当"中间人"。接收前端请求 -> 转发给 DeepSeek -> 流式接收 DeepSeek 的响应 -> 流式转发 给前端。
  3. 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.js res 对象,手动控制 writeend,这是实现流式输出的基础。

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 阶段很完美,但直接用于生产环境有几个隐患:

  1. API Key 泄露:代码运行在服务端(Node.js)是安全的,但如果这段逻辑被打包到浏览器端,Key 就会裸奔。
  2. 缺少超时控制:如果 AI 卡住了,连接会一直挂着。
  3. 缺少错误透传:如果 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 流式传输 的底层原理。

  • 前端 :利用 fetchReadableStream 读取数据。
  • 中间层 :利用 Node.js 的 req/res 做流的中转和格式转换。
  • 协议 :理解了 chunked 编码和 SSE 格式
相关推荐
张龙6871 小时前
前端错误监控生产级方案:从 onerror 捕获到 Source Map 精准还原
前端·javascript
温柔过期啦1 小时前
网站改了标题但是搜索引擎却没改?怎么样才能快速更新?
前端·搜索引擎·百度
cxxcode1 小时前
JavaScript 模块化:从模块规范到打包原理
前端·webpack
驰子1051 小时前
不重写、不换框架:让十年 jQuery 老项目写出 Vue 手感的渐进式改造
前端
月弦笙音1 小时前
# SSE 连接可靠性方案
前端
weedsfly1 小时前
从表单校验到订单流转,彻底搞懂前端开发中的策略模式与状态模式
前端·javascript·面试
qq_589666051 小时前
npm install npm install -g @vue/cli 分别是什么意思以及安装路径
前端·vue.js·npm
用户0207199207722 小时前
Kafka 与事务 Outbox
前端
IT_陈寒2 小时前
Vite的静态资源路径搞得我头秃,原来这样配才对
前端·人工智能·后端