项目实训(6) - 流式输出(Streaming)的初次尝试

165 阅读4分钟

项目实训(6) - 流式输出(Streaming)的初次尝试

之前 AI 回复的时候,总得等上那么几秒钟,然后“啪”一下,一大段话全出来。用户体验其实不太好,尤其是在等待 AI 思考和组织语言的时候,界面上一点反馈都没有,感觉像卡住了一样。

后端同学说,他们那个 DeepSeek 内部接口是支持流式输出 (Streaming)  的,就像 ChatGPT 那样,AI 的回复可以一个字一个字地“打”出来。这个必须安排上!

流式输出是个啥?

简单说,就是服务器不是一次性把所有数据都准备好再发送,而是一边生成数据,一边像水流一样持续地把数据块发送给客户端。客户端也得配合,接收到一块就处理一块。

对于大语言模型来说,它生成 token (可以理解为单词或字的一部分) 就是一个接一个的。流式输出能让用户几乎实时地看到这些 token,大大提升交互感。

前端怎么接这个“水流”?

fetch API 本身就支持处理流式响应。response.body 是一个 ReadableStream 对象。我们可以通过它的 getReader() 方法来一块一块地读取数据。

代码开搞:

// AIChatWindow.vue - 修改 actuallySendToAI 函数

const actuallySendToAI = async (currentMessagesThread: Message[]) => {
  isLoading.value = true; // 这个isLoading可能要换个含义,比如表示“AI正在思考第一句话”
  let apiMessages = [];
  // ... (构造 apiMessages 和 system prompt 的逻辑同前) ...

  // 为流式输出准备一个临时的AI消息对象,先加到列表里,然后逐步填充它的text
  const tempAiMessageId = Date.now().toString() + '-ai-streaming';
  const streamingAiMessage: Message = {
    id: tempAiMessageId,
    text: '', // 初始为空
    sender: 'ai',
    timestamp: new Date(),
  };
  messages.value.push(streamingAiMessage);
  scrollToBottom(); // 先滚一次,让空消息占位

  try {
    const response = await fetch(DEEPSEEK_API_URL, {
      method: 'POST',
      headers: { /* ... */ },
      body: JSON.stringify({
        model: MODEL_NAME,
        messages: apiMessages,
        stream: true, // 关键!告诉后端我要流式输出
      }),
    });

    if (!response.ok) {
      // ... (错误处理同前,但这里可能无法直接 response.json() 了) ...
      // 对于流式错误,服务器可能在流的开始就返回错误状态,也可能在流中间出错
      // 需要更健壮的错误处理
      const errorText = await response.text(); // 尝试读取文本错误
      throw new Error(errorText || `AI服务错误,状态码: ${response.status}`);
    }

    if (!response.body) {
      throw new Error('Response body is null');
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8'); // 用来把 Uint8Array 转成字符串
    let buffer = ''; // 用于处理不完整的JSON对象(如果API返回的是JSON Lines)

    // eslint-disable-next-line no-constant-condition
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        console.log('Stream finished.');
        break;
      }

      const chunk = decoder.decode(value, { stream: true }); // stream: true 很重要,处理多字节字符被分割的情况
      buffer += chunk;
      
      // DeepSeek流式输出的具体格式是什么?
      // 可能是 Server-Sent Events (SSE) 格式,形如 "data: {...JSON...}\n\n"
      // 也可能是 JSON Lines,每个JSON对象占一行,用 \n 分隔
      // 假设是 JSON Lines,每个对象包含增量内容,例如:{ "delta": { "content": "你好" } }
      // 或者直接是文本片段?后端同学说他们的DeepSeek接口封装后,流式吐的是JSON对象字符串,每个对象代表一个chunk,里面有增量内容。

      // 这里需要根据实际的流式协议来解析 buffer
      // 简陋的按行处理 (假设是 JSON Lines, 每个JSON在一行)
      let lines = buffer.split('\n');
      buffer = lines.pop() || ''; // 最后一行可能不完整,放回buffer

      for (const line of lines) {
        if (line.trim() === '' || line.startsWith('data: ')) { // 简单处理下可能的SSE格式或空行
            // 如果是SSE,需要解析 "data: " 后面的JSON
            let jsonData = line.trim();
            if (jsonData.startsWith('data: ')) {
                jsonData = jsonData.substring(5).trim();
            }
            if (jsonData === '[DONE]') { // 有些API用 [DONE] 标记结束
                console.log('Stream marked as DONE');
                // reader.cancel(); // 可以主动关闭reader
                // break; // 这里break只跳出内层循环,外层while(true)的done会处理
                return; // 直接结束处理
            }
            if(!jsonData) continue;

            try {
                const parsed = JSON.parse(jsonData);
                // 假设DeepSeek返回的增量内容在 parsed.choices[0].delta.content
                const deltaContent = parsed.choices?.[0]?.delta?.content;
                if (deltaContent) {
                  const aiMsgIndex = messages.value.findIndex(m => m.id === tempAiMessageId);
                  if (aiMsgIndex !== -1) {
                    messages.value[aiMsgIndex].text += deltaContent;
                    scrollToBottom(); // 每次更新都滚动
                  }
                }
                if(parsed.choices?.[0]?.finish_reason === 'stop'){
                    console.log('Stream finished due to stop reason.');
                    return; // 也可以在这里结束
                }
            } catch (e) {
                console.warn('Failed to parse JSON chunk:', line, e);
                // 可能是buffer里包含了不完整的JSON,或者API格式变了
                // 理论上,如果buffer.pop()正确,不应该有不完整的JSON行
                // 但如果API发的不是严格的JSON Lines,就可能出问题
            }
        }
      }
    }
  } catch (error: any) {
    console.error('流式调用AI接口失败:', error);
    const aiMsgIndex = messages.value.findIndex(m => m.id === tempAiMessageId);
    if (aiMsgIndex !== -1) {
      messages.value[aiMsgIndex].text += `\n(AI开小差了: ${error.message})`;
    } else { // 如果连临时消息都没加上
        const errorMessage: Message = { /* ... */ };
        messages.value.push(errorMessage);
    }
  } finally {
    isLoading.value = false; // 整个流处理完了,才算最终结束
    // 确保滚动条在最底部
    scrollToBottom();
  }
};

fea25f7b94f2cf784d8b12347a13b864.png

调试过程中的痛点:

  1. 流协议对齐:后端同学说是“JSON对象字符串,每个对象一个chunk”,但具体长啥样?{"delta": {"content": "..."}}?还是别的?文档没写那么细,只能抓包或者让他们给个例子。我这里先按类似OpenAI的流式格式猜的。如果猜错,解析逻辑就全废了。  (后来确认了,确实是类似OpenAI的SSE格式,每一行是 data: {...},最后是 data: [DONE]
  2. 不完整的UTF-8字符decoder.decode(value, { stream: true }) 这个 { stream: true } 很重要,不然如果一个汉字(比如3字节UTF-8)被数据块从中间劈开,就会乱码。
  3. buffer 的处理:数据块不是按行来的,所以得自己拼 buffer,再按行(或者其他分隔符)切分。lines.pop() 那个操作是为了把可能不完整的最后一行放回 buffer 等待下一个数据块。
  4. 错误处理和流结束:流中间出错了怎么办?reader.read() 的 done 为 true 是正常结束。但API也可能在流里发一个特殊的标记(比如 data: [DONE])来表示内容结束。这些都得兼容。
  5. Vue 的响应式更新:我是直接修改 messages.value[aiMsgIndex].text += deltaContent。Vue3 的 ref 对数组元素的属性修改是能侦测到的,所以UI会更新。但如果性能敏感,过于频繁地修改 text 可能会有优化空间(比如用一个局部变量攒一小段再更新,或者用更底层的DOM操作,但不推荐)。目前看还好。

效果初体验:  折腾半天,当看到AI的回复真的像打字一样一个字一个字蹦出来的时候,那种感觉——爽!等待的焦虑感明显降低了。虽然代码复杂度上去了,但这用户体验的提升是实打实的。

感觉这个AI模拟面试项目越来越像个正经东西了。下一步,得想想怎么让AI的提问更有“面试”的智慧,而不只是个会打字的复读机。

#AI模拟面试 #流式输出 #Streaming #FetchAPI #ReadableStream #DeepSeek #Vue3 #用户体验提升 #前端技术细节