欢迎光临
我们一直在努力

Vue 3 + DeepSeek 实现 AI 流式对话的完整指南

在当今的大模型(LLM)应用中,“流式输出”(Streaming)已经成为标配。用户不再愿意盯着空白屏幕等待几秒甚至几十秒后的“一次性全量回复”,而是希望像看着对方打字一样,实时看到思维的流淌。

本文将结合 Vue 3、Fetch API 以及底层的二进制编码知识,带你从零构建一个类似 ChatGPT 的流式对话 Demo,并深入解析其背后的技术细节。

一、 溯源:一切皆为二进制 (Buffer)

在进入 Vue 和 AI 之前,我们需要补一课:浏览器是如何处理数据的?

我们在屏幕上看到的“你好”,在计算机底层和网络传输中,本质上都是二进制(0和1)。当后端大模型一点点“吐”出数据时,浏览器接收到的不是完整的字符串,而是字节流。

1.1 编码与解码 (Encoder & Decoder)

HTML5 提供了原生的 API 来处理这种转换,这比传统的字符操作更高效。让我们看一段底层的实验代码(源自 buffer.html):

// 1. 编码:字符串 -> Uint8Array (二进制)
const encoder = new TextEncoder();
const myBuffer = encoder.encode("你好 HTML5");
console.log(myBuffer);
// 输出: Uint8Array(10) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, 76, 53]
// 注意:中文通常占3个字节,英文占1个字节

// 2. 解码:ArrayBuffer -> 字符串
const decoder = new TextDecoder();
const originalText = decoder.decode(myBuffer);
console.log(originalText); // 输出: "你好 HTML5"

1.2 缓冲区 (ArrayBuffer) 与视图 (View)

在处理流式数据时,我们经常听到 Buffer 这个词。ArrayBuffer 代表内存中一段固定长度的原始二进制数据缓冲区。

// 创建一个 12 字节的缓冲区
const buffer = new ArrayBuffer(12);
// 我们不能直接操作 ArrayBuffer,必须通过“视图(View)”
const view = new Uint8Array(buffer);

// 将之前编码的数据逐个字节写入缓冲区
for(let i = 0; i < myBuffer.length; i++){
view[i] = myBuffer[i];
}

为什么要懂这个?

因为在使用 fetch 获取流式响应时,reader.read() 读出来的 value 正是 Uint8Array。如果我们不懂解码,看到的就是一堆乱码数字。

二、 架构:Vue 3 响应式驱动

接下来,我们进入应用层。利用 Vue 3 的 Composition API,我们可以轻松实现数据驱动的 UI 更新。

2.1 核心响应式数据

在 App.vue 中,我们定义了应用的状态:

import { ref } from 'vue';

// 双向绑定:用户的问题
let question = ref('讲一个喜羊羊和灰太狼的故事,20字');
// 单向流:是否开启流式模式
const stream = ref(true);
// 核心展示:AI 的回复内容
const content = ref('');

2.2 数据流向

  • 输入: 通过 v-model="question" 将 Input 框与变量绑定。
  • 触发: 点击按钮调用 askLLM 函数。
  • 输出: AI 的回复被追加到 content.value 中,Vue 的响应式系统会自动检测变化并更新 DOM。

三、 核心深潜:处理 SSE (Server-Sent Events) 风格的流

这是本文最核心的部分。如何将大模型返回的 continuous stream 转换成屏幕上的文字?

通常 LLM 的流式接口(如 DeepSeek, OpenAI)采用类似 Server-Sent Events (SSE) 的格式,数据长这样:

data: {"choices":[{"delta":{"content":"喜"}}]}
data: {"choices":[{"delta":{"content":"羊"}}]}
data: {"choices":[{"delta":{"content":"羊"}}]}
data: [DONE]

我们需要处理三个挑战:

  • 网络分片(Chunking): 网络不是按行发送的,一个数据包可能包含半行 JSON,或者两行半 JSON。
  • 解码: 将二进制流转为文本。
  • 解析: 提取 JSON 中的 content 字段。
  • 3.1 开启流式读取

    首先,发起请求并获取读取器:

    const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
    model: 'deepseek-chat',
    stream: true, // 关键:告诉服务器我要流式
    messages: [{ role: 'user', content: question.value }]
    })
    });

    // 获取 ReadableStream 的读取器
    const reader = response.body?.getReader();

    3.2 复杂的读取与拼接逻辑 (Buffer 机制)

    这里有一个极其重要的缓冲区处理逻辑(注意代码中的 buffer 变量)。由于 TCP 分包的存在,我们可能会收到截断的 JSON 字符串。

    错误场景模拟:

    假设服务器发送 {“content”: “hello”},但网络原因,我们分两次收到:

  • 第一次收到:{"con
  • 第二次收到:tent": "hello"}
  • 如果直接 JSON.parse("{"con") 肯定会报错。所以我们需要一个临时字符串 buffer 来拼接。

    深度代码解析:

    const decoder = new TextDecoder();
    let buffer = ''; // 这里的 buffer 是字符串缓存,用于处理分片
    let done = false;

    while (!done) {
    // 1. 读取原始二进制数据
    const { value, done: doneReading } = await reader?.read();
    done = doneReading;

    // 2. 解码并追加到临时 buffer
    // chunkValue 可能包含上一次循环遗留的半截数据 + 本次新收到的数据
    const chunkValue = buffer + decoder.decode(value, { stream: true });

    // 3. 按行分割 (SSE 协议通常以 \\n 分隔事件)
    // 这里的逻辑有点巧妙:它先把 buffer 清空,准备存处理失败的碎片
    buffer = '';
    const lines = chunkValue.split('\\n').filter(line => line.startsWith('data: '));

    for (const line of lines) {
    const incoming = line.slice(6); // 去掉 "data: " 前缀

    if (incoming === '[DONE]') {
    done = true;
    break;
    }

    try {
    // 4. 尝试解析 JSON
    const data = JSON.parse(incoming);
    const delta = data.choices[0].delta.content;
    if (delta) {
    // 5. 成功解析,上屏!
    content.value += delta;
    }
    } catch (err) {
    // 6. 关键容错:解析失败说明数据不完整(被截断了)
    // 将这行残缺的数据放回 buffer,等待下一次循环拼接
    buffer += `data: ${incoming}`;
    }
    }
    }

    3.3 流程图解

    为了更直观地理解这个过程:

    总流程.png

    四、 总结:从 Demo 到生产环境

    通过 buffer.html 我们理解了数据的本质是二进制,通过 App.vue 我们实现了复杂的流式解析逻辑。

    核心知识点回顾:

  • 用户体验 (UX): 流式输出极大地降低了用户的感知延迟(Perceived Latency)。
  • Fetch API: 使用 response.body.getReader() 能够访问底层网络流,而不是等待整个 Body 下载完成。
  • 二进制处理: TextDecoder 是连接网络二进制流和 JS 字符串的桥梁。
  • 鲁棒性解析: 网络传输的不确定性要求我们在前端必须实现“拼接-尝试解析-回退缓存”的机制,以防止 JSON 解析错误导致程序崩溃。
  • 五、流式输出全代码示例

    <script setup>
    import { ref } from 'vue';

    // v-model 指令,响应式绑定表单的数据
    // v-model 双向数据绑定指令
    // v-model 绑定 question stream 变量
    let question = ref('讲一个喜羊羊和灰太狼的故事,20字');
    const stream = ref(true);
    const content = ref('') // 单向绑定,常用的

    // 调用大模型
    const askLLM = async () => {
    if (!question.value) {
    console.log('question 不能为空');
    return;
    }

    // 用户体验
    content.value = '思考中…';
    // 请求行
    // 请求头
    // 请求体
    const endpoint = 'https://api.deepseek.com/chat/completions';
    const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
    }

    const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
    model: 'deepseek-chat',
    stream: stream.value,
    messages: [
    {
    role: 'user',
    content: question.value
    }
    ]
    })
    })
    if(stream.value){
    // 流式输出
    content.value = ""; // 把上次的生成清空
    // HTML5 流式响应体
    // 响应体的读对象
    const reader = response.body?.getReader();
    // 流出来的是二进制流 buffer
    const decoder = new TextDecoder();
    let done = false; // 流是否结束,没有
    let buffer = '';
    while(!done) {
    // 只要没有完成,就一直拼接buffer
    // 解构重命名,外面有同名done,这里重命名为doneReading
    const { value, done: doneReading } = await reader?.read();
    console.log(value, doneReading);
    done = doneReading;
    // chunk 内容块 包含多行data: 有多少行不确定
    // data: {} 能不能传完也不确定
    const chunkValue = buffer + decoder.decode(value); // 字符串
    console.log(chunkValue);
    buffer = '';
    const lines = chunkValue.split('\\n').filter((line => line.startsWith('data: ')));
    for(const line of lines){
    const incoming = line.slice(6); // 干掉数据标志 data:
    if(incoming === '[DONE]'){
    done = true;
    break;
    }
    try{
    // 大模型流式生成,tokens 长度不定的
    const data = JSON.parse(incoming);
    const delta = data.choices[0].delta.content;
    if(delta){
    content.value += delta;
    }
    }catch(err){
    // JSON.parse 解析失败
    buffer += `data: ${incoming}`
    }
    }
    }

    }
    else{
    const data = await response.json();
    console.log(data);
    content.value = data.choices[0].message.content;
    }

    }
    </script>

    <template>
    <div class="container">
    <div>
    <label>输入: </label>
    <input class="input" vmodel="question" />
    <button @click="askLLM">提交</button>
    </div>
    <div class="output">
    <div>
    <label>Streaming</label>
    <input type="checkbox" vmodel="stream" />
    <div>{{ content }}</div>
    </div>
    </div>
    </div>
    </template>

    <style scoped>
    * {
    margin: 0;
    padding: 0;
    }

    .container {
    display: flex;
    /* 主轴、次轴 */
    flexdirection: column; /* 设置主轴 */
    alignitems: start; /* 次轴对齐方式 */
    justifycontent: start; /* 主轴对齐方式 */
    height: 100vh;
    fontsize: 0.85rem;
    }

    .input {
    width: 200px;
    }

    button {
    padding: 0 10px;
    marginleft: 6px;
    }

    .output{
    margintop: 10px;
    minheight: 300px;
    width: 100%;
    textalign: left;
    }
    </style>

    赞(0)
    未经允许不得转载:171主机测评 » Vue 3 + DeepSeek 实现 AI 流式对话的完整指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址