《60天AI学习计划启动 | Day 04: 流式响应实现 - 打造流畅的AI对话体验》

Day 04: 流式响应实现 - 打造流畅的AI对话体验

📋 学习目标


📚 核心学习内容

1. 流式响应 vs 普通响应

普通响应(阻塞式):

用户发送消息 → 等待AI完整生成 → 一次性返回全部内容
问题:等待时间长,用户体验差

流式响应(Streaming):

用户发送消息 → AI逐字生成 → 实时返回每个字符
优势:即时反馈,体验流畅,类似ChatGPT

对比:

// 普通响应:等待10秒后一次性返回
response: "这是一个很长的回答..."

// 流式响应:每秒返回一部分
chunk1: "这是"
chunk2: "一个"
chunk3: "很长的"
chunk4: "回答..."

2. Server-Sent Events (SSE)

什么是 SSE?

  • 服务器主动向客户端推送数据
  • 基于 HTTP 长连接
  • 单向通信(服务器 → 客户端)
  • 比 WebSocket 更简单

SSE vs WebSocket:

SSE: 单向,HTTP协议,自动重连,简单易用
WebSocket: 双向,TCP协议,需要手动处理,功能强大

SSE 格式:

data: 这是第一段内容\n\n
data: 这是第二段内容\n\n
data: [DONE]\n\n

3. OpenAI Stream API

流式调用:

const stream = await openai.chat.completions.create({
  model: "gpt-3.5-turbo",
  messages: messages,
  stream: true  // 关键:启用流式
});

for await (const chunk of stream) {
  const content = chunk.choices[0]?.delta?.content || '';
  if (content) {
    // 实时处理每个chunk
    console.log(content);
  }
}

🏗️ 实践作业

作业1:后端实现流式接口

src/routes/chat.js(添加流式路由):

import express from 'express';
import { streamChatWithAI } from '../services/openai.js';
import { logger } from '../utils/logger.js';

export const chatRouter = express.Router();

// POST /api/chat/stream - 流式聊天接口
chatRouter.post('/stream', async (req, res) => {
  try {
    const { message, conversationHistory = [] } = req.body;

    // 参数验证
    if (!message || typeof message !== 'string') {
      return res.status(400).json({
        success: false,
        error: '消息内容不能为空'
      });
    }

    logger.info(`收到流式请求: ${message.substring(0, 50)}...`);

    // 设置 SSE 响应头
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.setHeader('Access-Control-Allow-Origin', '*');

    // 调用流式 AI 服务
    await streamChatWithAI(message, conversationHistory, {
      onChunk: (content) => {
        // 发送数据块
        res.write(`data: ${JSON.stringify({ content })}\n\n`);
      },
      onComplete: (usage) => {
        // 发送完成信号
        res.write(`data: ${JSON.stringify({ done: true, usage })}\n\n`);
        res.end();
      },
      onError: (error) => {
        // 发送错误信息
        res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
        res.end();
      }
    });
  } catch (error) {
    logger.error('流式接口错误:', error);
    
    if (!res.headersSent) {
      res.status(500).json({
        success: false,
        error: error.message || '流式服务异常'
      });
    }
  }
});

作业2:实现流式 OpenAI 服务

src/services/openai.js(添加流式方法):

import OpenAI from 'openai';
import { logger } from '../utils/logger.js';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY
});

/**
 * 流式聊天
 * @param {string} message - 用户消息
 * @param {Array} conversationHistory - 对话历史
 * @param {Object} callbacks - 回调函数
 */
export async function streamChatWithAI(
  message, 
  conversationHistory = [],
  callbacks = {}
) {
  const { onChunk, onComplete, onError } = callbacks;
  
  try {
    // 构建消息列表
    const messages = [
      {
        role: 'system',
        content: '你是一个友好的AI助手,擅长回答各种问题。'
      },
      ...conversationHistory,
      {
        role: 'user',
        content: message
      }
    ];

    logger.info(`开始流式请求,消息数: ${messages.length}`);

    // 创建流式请求
    const stream = await openai.chat.completions.create({
      model: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
      messages: messages,
      temperature: parseFloat(process.env.TEMPERATURE || '0.7'),
      max_tokens: parseInt(process.env.MAX_TOKENS || '2000'),
      stream: true  // 启用流式
    });

    let fullContent = '';
    let usage = null;

    // 处理流式数据
    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta;
      const content = delta?.content || '';

      if (content) {
        fullContent += content;
        
        // 实时回调每个chunk
        if (onChunk) {
          onChunk(content);
        }
      }

      // 记录使用情况
      if (chunk.usage) {
        usage = chunk.usage;
      }
    }

    logger.success(`流式响应完成,总长度: ${fullContent.length}`);

    // 完成回调
    if (onComplete) {
      onComplete(usage || {
        prompt_tokens: 0,
        completion_tokens: 0,
        total_tokens: 0
      });
    }

    return {
      content: fullContent,
      usage
    };
  } catch (error) {
    logger.error('流式请求失败:', error);
    
    if (onError) {
      onError(error);
    }
    
    throw error;
  }
}

作业3:前端实现 SSE 接收

utils/api.js(添加流式方法):

import axios from 'axios';

const api = axios.create({
  baseURL: 'http://localhost:3000/api',
  timeout: 60000
});

/**
 * 流式聊天
 * @param {string} message - 用户消息
 * @param {Array} conversationHistory - 对话历史
 * @param {Object} callbacks - 回调函数
 */
export function streamChatWithAI(message, conversationHistory = [], callbacks = {}) {
  const { onChunk, onComplete, onError } = callbacks;

  return new Promise((resolve, reject) => {
    // 使用 fetch + ReadableStream 实现 SSE
    fetch('http://localhost:3000/api/chat/stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        message,
        conversationHistory
      })
    })
    .then(response => {
      if (!response.ok) {
        throw new Error('请求失败');
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      function readStream() {
        reader.read().then(({ done, value }) => {
          if (done) {
            if (onComplete) {
              onComplete();
            }
            resolve();
            return;
          }

          // 解码数据
          buffer += decoder.decode(value, { stream: true });
          
          // 处理 SSE 格式数据
          const lines = buffer.split('\n');
          buffer = lines.pop() || ''; // 保留不完整行

          for (const line of lines) {
            if (line.startsWith('data: ')) {
              try {
                const data = JSON.parse(line.substring(6));
                
                if (data.error) {
                  if (onError) {
                    onError(new Error(data.error));
                  }
                  reject(new Error(data.error));
                  return;
                }

                if (data.done) {
                  if (onComplete) {
                    onComplete(data.usage);
                  }
                  resolve(data);
                  return;
                }

                if (data.content && onChunk) {
                  onChunk(data.content);
                }
              } catch (e) {
                console.error('解析数据失败:', e);
              }
            }
          }

          // 继续读取
          readStream();
        }).catch(error => {
          if (onError) {
            onError(error);
          }
          reject(error);
        });
      }

      readStream();
    })
    .catch(error => {
      if (onError) {
        onError(error);
      }
      reject(error);
    });
  });
}

作业4:Vue 组件实现流式显示

ChatComponent.vue:

<template>
  <div class="chat-container">
    <div class="messages" ref="messagesContainer">
      <div 
        v-for="(msg, index) in messages" 
        :key="index"
        :class="['message', msg.type]">
        <div class="message-content">
          {{ msg.content }}
          <span v-if="msg.streaming" class="cursor">|</span>
        </div>
        <div v-if="msg.usage" class="message-usage">
          Token: {{ msg.usage.total_tokens }}
        </div>
      </div>
    </div>
    
    <div class="input-area">
      <textarea
        v-model="inputMessage"
        @keydown.enter.exact.prevent="sendMessage"
        @keydown.shift.enter.exact="handleShiftEnter"
        placeholder="输入消息(Enter发送,Shift+Enter换行)..."
        :disabled="loading"
        rows="3" />
      <button 
        @click="sendMessage"
        :disabled="loading || !inputMessage.trim()">
        {{ loading ? '发送中...' : '发送' }}
      </button>
    </div>
  </div>
</template>

<script>
import { streamChatWithAI } from '@/utils/api';

export default {
  data() {
    return {
      messages: [],
      inputMessage: '',
      loading: false,
      conversationHistory: [],
      currentStreamingMessage: null
    };
  },
  methods: {
    async sendMessage() {
      if (!this.inputMessage.trim() || this.loading) return;

      const userMessage = this.inputMessage.trim();
      this.inputMessage = '';
      
      // 添加用户消息
      this.messages.push({
        type: 'user',
        content: userMessage,
        timestamp: Date.now()
      });

      // 更新对话历史
      this.conversationHistory.push({
        role: 'user',
        content: userMessage
      });

      // 创建AI消息占位
      const aiMessageIndex = this.messages.length;
      this.messages.push({
        type: 'ai',
        content: '',
        streaming: true,
        timestamp: Date.now()
      });

      this.loading = true;
      this.currentStreamingMessage = this.messages[aiMessageIndex];

      try {
        await streamChatWithAI(
          userMessage,
          this.conversationHistory,
          {
            onChunk: (content) => {
              // 实时更新AI消息内容
              this.$set(this.messages[aiMessageIndex], 'content', 
                this.messages[aiMessageIndex].content + content
              );
              
              // 自动滚动到底部
              this.$nextTick(() => {
                this.scrollToBottom();
              });
            },
            onComplete: (usage) => {
              // 完成流式输出
              this.$set(this.messages[aiMessageIndex], 'streaming', false);
              this.$set(this.messages[aiMessageIndex], 'usage', usage);
              
              // 更新对话历史
              this.conversationHistory.push({
                role: 'assistant',
                content: this.messages[aiMessageIndex].content
              });

              this.loading = false;
              this.currentStreamingMessage = null;
            },
            onError: (error) => {
              this.messages[aiMessageIndex].content = 
                error.message || '请求失败,请稍后重试';
              this.messages[aiMessageIndex].streaming = false;
              this.loading = false;
              this.currentStreamingMessage = null;
            }
          }
        );
      } catch (error) {
        this.messages[aiMessageIndex].content = 
          error.message || '请求失败,请稍后重试';
        this.messages[aiMessageIndex].streaming = false;
        this.loading = false;
        this.currentStreamingMessage = null;
      }
    },
    
    handleShiftEnter() {
      // Shift+Enter 换行,不做处理
    },
    
    scrollToBottom() {
      const container = this.$refs.messagesContainer;
      if (container) {
        container.scrollTop = container.scrollHeight;
      }
    }
  }
};
</script>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
}

.message {
  margin-bottom: 20px;
  padding: 12px;
  border-radius: 8px;
}

.message.user {
  background: #e3f2fd;
  margin-left: 20%;
}

.message.ai {
  background: #f5f5f5;
  margin-right: 20%;
}

.message-content {
  white-space: pre-wrap;
  word-break: break-word;
}

.cursor {
  animation: blink 1s infinite;
}

@keyframes blink {
  0%, 50% { opacity: 1; }
  51%, 100% { opacity: 0; }
}

.message-usage {
  font-size: 12px;
  color: #999;
  margin-top: 8px;
}

.input-area {
  padding: 20px;
  border-top: 1px solid #eee;
  display: flex;
  gap: 10px;
}

.input-area textarea {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  resize: none;
}

.input-area button {
  padding: 10px 20px;
  background: #1976d2;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.input-area button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

作业5:优化用户体验

添加加载动画:

<div v-if="loading && !currentStreamingMessage" class="loading">
  <span class="dot"></span>
  <span class="dot"></span>
  <span class="dot"></span>
  <span>AI正在思考...</span>
</div>

添加错误重试:

async sendMessage(retryCount = 0) {
  try {
    // ... 发送逻辑
  } catch (error) {
    if (retryCount < 3) {
      // 自动重试
      setTimeout(() => {
        this.sendMessage(retryCount + 1);
      }, 1000 * (retryCount + 1));
    } else {
      // 显示错误
      this.showError('请求失败,请稍后重试');
    }
  }
}

⚠️ 遇到的问题

问题1:SSE 连接断开

错误信息: EventSource connection closed

解决方案:

// 使用 fetch + ReadableStream 更稳定
// 添加心跳检测
setInterval(() => {
  res.write(': heartbeat\n\n');
}, 30000);

问题2:数据乱码

问题: 中文显示乱码

解决方案:

// 确保使用 UTF-8 编码
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');

// 前端正确解码
const decoder = new TextDecoder('utf-8');

问题3:流式响应中断

问题: 网络波动导致中断

解决方案:

// 添加错误处理和重连机制
eventSource.onerror = (error) => {
  console.error('SSE错误:', error);
  // 实现重连逻辑
};

问题4:内存泄漏

问题: 长时间运行内存占用增加

解决方案:

// 限制对话历史长度
if (this.conversationHistory.length > 20) {
  this.conversationHistory = this.conversationHistory.slice(-20);
}

// 清理定时器
beforeDestroy() {
  if (this.streamTimer) {
    clearInterval(this.streamTimer);
  }
}

📊 学习总结

今日收获

  1. ✅ 理解流式响应原理
  2. ✅ 掌握 SSE 实现方式
  3. ✅ 实现 OpenAI Stream API
  4. ✅ 前端实时显示 AI 回复
  5. ✅ 优化用户体验

关键知识点

  • 流式响应提升体验,即时反馈,类似 ChatGPT
  • SSE 适合单向推送,比 WebSocket 更简单
  • 逐字显示更自然,用户感觉更流畅
  • 错误处理很重要,网络波动需要重连机制
  • 性能优化,限制历史长度,避免内存泄漏

技术对比

普通响应:等待时间长,用户体验差
流式响应:即时反馈,体验流畅 ✅

前端开发者的优势

  • ✅ 熟悉异步编程(Promise/async-await)
  • ✅ 理解事件流(Event Stream)
  • ✅ 擅长 UI 交互优化
  • ✅ 熟悉状态管理

🔧 代码优化建议

1. 添加流式控制

// 可以暂停/恢复流式输出
let isPaused = false;

onChunk: (content) => {
  if (!isPaused) {
    // 更新内容
  }
}

2. 添加性能监控

const startTime = Date.now();

onComplete: (usage) => {
  const duration = Date.now() - startTime;
  console.log(`响应时间: ${duration}ms`);
  console.log(`Token使用: ${usage.total_tokens}`);
}

3. 添加缓存机制

// 缓存常见问题的回答
const cache = new Map();

if (cache.has(message)) {
  // 直接返回缓存
} else {
  // 调用API并缓存
}

📅 明日计划

明天将学习:

期待明天的学习! 🚀


📚 参考资源


💻 代码仓库

项目已更新:

  • ✅ 实现流式响应接口
  • ✅ 前端 SSE 接收
  • ✅ 实时显示 AI 回复
  • ✅ 用户体验优化

GitHub 提交: Day 04 - 流式响应实现


💭 写在最后

今天完成了流式响应的实现,让 AI 对话体验更加流畅自然。虽然技术实现有一定复杂度,但效果非常值得!明天将整合所有功能,打造一个完整的聊天机器人项目。

继续加油! 💪


✅ 快速检查清单

完成这些,第四天就达标了!


标签: #AI学习 #SSE #流式响应 #用户体验 #学习笔记

posted @ 2025-12-16 14:32  XiaoZhengTou  阅读(7)  评论(0)    收藏  举报