基于 Python + LangChain + React 的 AI 流式对话与历史存储实战

前言

在大语言模型(LLM)飞速发展的今天,将 AI 对话能力集成到自己的应用中已经成为非常普遍的需求。然而,一个生产级别的 AI 聊天应用不仅仅是调用 API 那么简单------流式输出 让用户体验更自然,对话历史持久化 确保数据不丢失,多会话管理让用户可以在不同话题间自由切换。

本文将基于一个完整的实战项目,带你从零搭建一个包含以下功能的 AI 聊天系统:

  • 后端:Python + Flask + LangChain,对接通义千问(Qwen)大模型,支持 SSE 流式输出
  • 前端:React + Zustand 状态管理,实现类似 DeepSeek 的对话界面
  • 数据库:MySQL 存储对话历史,支持多会话、重命名、置顶等管理功能

一、项目架构概览

复制代码
├── test-project/              # 后端(Python Flask)
│   ├── server.py              # 主服务,所有 API 端点
│   ├── chat_qwen.py           # 通义千问模型封装
│   ├── chat_history.py        # 对话历史数据库操作
│   ├── rag_service.py         # RAG 知识检索服务
│   ├── config.py              # 数据库配置
│   ├── init_chat_history.sql  # 建表 SQL
│   └── .env                   # 环境变量(API Key)
│
└── my-react-app/              # 前端(React)
    ├── src/view/
    │   ├── pages/chat/        # 聊天页面组件
    │   │   ├── Chat.jsx
    │   │   ├── Chat.module.scss
    │   │   └── components/
    │   │       ├── ChatSidebar.jsx     # 侧边栏(对话列表)
    │   │       ├── ChatMessages.jsx    # 消息气泡区域
    │   │       └── ChatInput.jsx       # 输入框
    │   ├── store/chatStore.js          # Zustand 状态管理
    │   └── services/chatService.js     # API 服务(SSE + REST)
    ├── package.json
    └── server.js               # Node.js 辅助服务

二、数据库设计

2.1 建表 SQL

我们需要两张表:chat_conversations(对话表)和 chat_messages(消息表)。

sql 复制代码
-- 聊天历史相关表
-- 在 pytosql 数据库中执行

CREATE TABLE IF NOT EXISTS chat_conversations (
    id VARCHAR(36) PRIMARY KEY COMMENT '会话UUID',
    title VARCHAR(200) DEFAULT '新对话' COMMENT '会话标题',
    use_rag TINYINT DEFAULT 0 COMMENT '是否使用RAG',
    pinned TINYINT DEFAULT 0 COMMENT '是否置顶',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS chat_messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    conversation_id VARCHAR(36) NOT NULL COMMENT '关联会话ID',
    role ENUM('user', 'assistant', 'system') NOT NULL COMMENT '消息角色',
    content TEXT NOT NULL COMMENT '消息内容',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE,
    INDEX idx_msg_conv (conversation_id)
);

2.2 表结构说明

字段 说明
chat_conversations.id UUID 格式的主键,与前端会话 ID 一一对应
chat_conversations.title 对话标题,取用户第一条消息的前 20 个字符自动生成
chat_conversations.pinned 置顶标记,置顶的对话排在列表最前面
chat_messages.role 消息角色:user(用户)、assistant(AI)、system(系统)
chat_messages.conversation_id 外键关联对话表,设置 ON DELETE CASCADE 实现级联删除

为什么用 UUID 而不是自增 ID? UUID 在分布式场景下更安全,且前端可以先生成 ID 再与后端同步,避免 ID 冲突。

三、后端实现

3.1 环境准备

复制代码
pip install flask flask-cors langchain-openai langchain-community pymysql python-dotenv faiss-cpu

3.2 数据库配置(config.py

python 复制代码
DB_CONFIG = {
    "host": "localhost",
    "port": 3306,
    "user": "root",
    "password": "your_password",
    "database": "pytosql",
    "charset": "utf8mb4",
}

3.3 对话历史管理模块(chat_history.py)

这个模块封装了所有与对话和消息相关的数据库操作:

python 复制代码
import uuid
import pymysql
from config import DB_CONFIG

def _get_conn():
    return pymysql.connect(**DB_CONFIG)

def create_conversation(use_rag=False):
    """创建新对话,返回对话 ID"""
    conv_id = str(uuid.uuid4())
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO chat_conversations (id, use_rag) VALUES (%s, %s)",
            (conv_id, 1 if use_rag else 0)
        )
        conn.commit()
        return conv_id
    finally:
        conn.close()

def list_conversations():
    """获取所有对话列表,置顶优先,再按更新时间倒序"""
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute(
            "SELECT id, title, use_rag, pinned, created_at, updated_at "
            "FROM chat_conversations ORDER BY pinned DESC, updated_at DESC"
        )
        rows = cursor.fetchall()
        return [
            {
                "id": row[0], "title": row[1], "useRag": bool(row[2]),
                "pinned": bool(row[3]),
                "createdAt": str(row[4]) if row[4] else "",
                "updatedAt": str(row[5]) if row[5] else "",
            }
            for row in rows
        ]
    finally:
        conn.close()

def get_messages(conv_id):
    """获取某个对话的所有消息"""
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute(
            "SELECT id, role, content, created_at "
            "FROM chat_messages WHERE conversation_id = %s ORDER BY id",
            (conv_id,)
        )
        rows = cursor.fetchall()
        return [
            {"id": row[0], "role": row[1], "content": row[2],
             "createdAt": str(row[3]) if row[3] else ""}
            for row in rows
        ]
    finally:
        conn.close()

def save_message(conv_id, role, content):
    """保存一条消息并更新对话时间"""
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute(
            "INSERT INTO chat_messages (conversation_id, role, content) "
            "VALUES (%s, %s, %s)", (conv_id, role, content)
        )
        cursor.execute(
            "UPDATE chat_conversations SET updated_at = NOW() WHERE id = %s",
            (conv_id,)
        )
        conn.commit()
    finally:
        conn.close()

def update_title(conv_id, title):
    """更新对话标题"""
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute(
            "UPDATE chat_conversations SET title = %s WHERE id = %s",
            (title, conv_id)
        )
        conn.commit()
    finally:
        conn.close()

def toggle_pin(conv_id):
    """切换置顶状态"""
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute(
            "UPDATE chat_conversations SET pinned = IF(pinned = 1, 0, 1) "
            "WHERE id = %s", (conv_id,)
        )
        conn.commit()
    finally:
        conn.close()

def delete_conversation(conv_id):
    """删除对话及其所有消息(级联删除)"""
    conn = _get_conn()
    try:
        cursor = conn.cursor()
        cursor.execute("DELETE FROM chat_conversations WHERE id = %s", (conv_id,))
        conn.commit()
    finally:
        conn.close()

def get_or_create_conversation(conv_id=None, use_rag=False):
    """获取已有对话或创建新对话"""
    if conv_id:
        conn = _get_conn()
        try:
            cursor = conn.cursor()
            cursor.execute(
                "SELECT id FROM chat_conversations WHERE id = %s", (conv_id,)
            )
            if cursor.fetchone():
                return conv_id
        finally:
            conn.close()
    return create_conversation(use_rag=use_rag)

3.4 Flask 主服务(server.py

3.4.1 基础配置与模型初始化
python 复制代码
import os
import uuid
import json
from dotenv import load_dotenv
from flask import Flask, request, jsonify, send_from_directory, Response, stream_with_context
from flask_cors import CORS
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from chat_history import (
    create_conversation, list_conversations, get_messages,
    save_message, update_title, delete_conversation, get_or_create_conversation,
    toggle_pin
)

load_dotenv()

app = Flask(__name__, static_folder="static")
CORS(app)  # 允许跨域请求

SYSTEM_PROMPT = "你是一个有用的AI助手,请用中文回答问题。"

def get_chat_model():
    """创建通义千问聊天模型"""
    return ChatOpenAI(
        model="qwen-plus",
        openai_api_key=os.getenv("DASHSCOPE_API_KEY"),
        openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
    )

关键点 :通义千问提供了 OpenAI 兼容的 API 接口,所以可以直接使用 LangChain 的 ChatOpenAI 类,只需修改 openai_api_base 指向阿里云的 DashScope 端点即可。

3.4.2 对话管理 API
python 复制代码
# 创建新对话
@app.route("/api/conversations", methods=["POST"])
def new_conversation():
    data = request.json or {}
    use_rag = data.get("use_rag", False)
    try:
        conv_id = create_conversation(use_rag=use_rag)
        return jsonify({"code": 200, "data": {"id": conv_id}})
    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)})

# 获取所有对话列表
@app.route("/api/conversations", methods=["GET"])
def get_conversations():
    try:
        return jsonify({"code": 200, "data": list_conversations()})
    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)})

# 获取某个对话的消息列表
@app.route("/api/conversations/<conv_id>/messages", methods=["GET"])
def get_conv_messages(conv_id):
    try:
        return jsonify({"code": 200, "data": get_messages(conv_id)})
    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)})

# 删除对话
@app.route("/api/conversations/<conv_id>", methods=["DELETE"])
def remove_conversation(conv_id):
    try:
        delete_conversation(conv_id)
        return jsonify({"code": 200, "msg": "删除成功"})
    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)})

# 重命名对话
@app.route("/api/conversations/<conv_id>/rename", methods=["PUT"])
def rename_conversation(conv_id):
    data = request.json
    title = data.get("title", "").strip()
    if not title:
        return jsonify({"code": 400, "msg": "标题不能为空"})
    try:
        update_title(conv_id, title)
        return jsonify({"code": 200, "msg": "重命名成功"})
    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)})

# 切换置顶
@app.route("/api/conversations/<conv_id>/pin", methods=["PUT"])
def pin_conversation(conv_id):
    try:
        toggle_pin(conv_id)
        return jsonify({"code": 200, "msg": "操作成功"})
    except Exception as e:
        return jsonify({"code": 500, "msg": str(e)})
3.4.3 SSE 流式聊天接口(核心)

这是整个后端最核心的部分,实现了 Server-Sent Events(SSE)流式输出:

python 复制代码
@app.route("/api/chat/stream", methods=["POST"])
def chat_stream():
    data = request.json
    user_message = data.get("message", "").strip()
    session_id = data.get("session_id")
    use_rag = data.get("use_rag", False)

    if not user_message:
        return jsonify({"error": "消息不能为空"}), 400

    def generate():
        full_reply = ""
        conv_id = None
        try:
            # 获取或创建对话
            conv_id = get_or_create_conversation(session_id, use_rag=use_rag)

            # 保存用户消息到数据库
            save_message(conv_id, "user", user_message)

            # 加载已有消息历史
            existing = get_messages(conv_id)

            # 第一轮对话:自动生成标题
            if len(existing) <= 1:
                title = user_message[:20] + ("..." if len(user_message) > 20 else "")
                update_title(conv_id, title)

            # 构建 LangChain 消息历史
            history = [SystemMessage(content=SYSTEM_PROMPT)]
            for msg in existing:
                if msg["role"] == "user":
                    history.append(HumanMessage(content=msg["content"]))
                elif msg["role"] == "assistant":
                    history.append(AIMessage(content=msg["content"]))
            history.append(HumanMessage(content=user_message))

            # 调用 LLM 流式生成
            llm = get_chat_model()
            for chunk in llm.stream(history):
                token = chunk.content
                if token:
                    full_reply += token
                    # 注意:必须 yield bytes 类型!
                    yield (
                        f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
                    ).encode("utf-8")

            # 保存 AI 回复到数据库
            if full_reply:
                save_message(conv_id, "assistant", full_reply)

            # 发送完成信号
            yield (
                f"data: {json.dumps({'done': True, 'session_id': conv_id}, ensure_ascii=False)}\n\n"
            ).encode("utf-8")

        except Exception as e:
            import traceback
            traceback.print_exc()
            yield (
                f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
            ).encode("utf-8")

    resp = Response(
        stream_with_context(generate()),
        mimetype="text/event-stream",
    )
    resp.headers["Cache-Control"] = "no-cache"
    resp.headers["X-Accel-Buffering"] = "no"
    resp.headers["Connection"] = "keep-alive"
    return resp
SSE 实现的几个关键踩坑点
问题 原因 解决方案
AssertionError: applications must write bytes Werkzeug 要求生成器 yield bytes 而非 str 所有 yield 后加 .encode("utf-8")
ERR_INCOMPLETE_CHUNKED_ENCODING 响应流被提前截断 数据库操作放在 generate() 内部,错误通过 SSE 传回前端
前端收不到数据 direct_passthroughstream_with_context 冲突 去掉 direct_passthrough,使用标准的 Response
3.4.4 启动服务
python 复制代码
if __name__ == "__main__":
    app.run(debug=True, port=5000, use_reloader=False)

注意use_reloader=False 很重要。Flask 的 reloader 会在独立进程中重启应用,导致 SSE 流式连接中断。

四、前端实现

4.1 安装依赖

复制代码
npm install antd @ant-design/x@^1.0.5 react-markdown zustand react-router-dom sass

注意@ant-design/x 要安装 v1.x 版本,v2.x 需要 antd v6。

4.2 状态管理(chatStore.js)

使用 Zustand 管理全局聊天状态,搭配 persist 中间件持久化关键配置:

javascript 复制代码
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useChatStore = create(
  persist(
    (set, get) => ({
      conversations: [],           // 对话列表
      currentConversationId: null,  // 当前对话 ID
      messages: [],                 // 当前对话的消息
      isStreaming: false,           // 是否正在流式输出
      streamingContent: '',         // 流式输出中的内容
      useRag: false,               // RAG 模式开关
      sidebarCollapsed: false,     // 侧边栏折叠状态

      // 切换对话
      selectConversation: (id) => set({
        currentConversationId: id,
        messages: [],
        streamingContent: '',
        isStreaming: false,
      }),

      // 流式输出控制
      startStreaming: () => set({ isStreaming: true, streamingContent: '' }),
      appendStreamContent: (token) => set((state) => ({
        streamingContent: state.streamingContent + token,
      })),
      finishStreaming: () => set((state) => ({
        isStreaming: false,
        streamingContent: '',
        messages: [...state.messages, {
          id: Date.now(),
          role: 'assistant',
          content: state.streamingContent,
          createdAt: new Date().toISOString(),
        }],
      })),
      cancelStreaming: () => set((state) => {
        const partial = state.streamingContent;
        return partial
          ? { isStreaming: false, streamingContent: '',
              messages: [...state.messages, { id: Date.now(), role: 'assistant',
                content: partial, createdAt: new Date().toISOString() }] }
          : { isStreaming: false, streamingContent: '' };
      }),

      toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
    }),
    {
      name: 'chat-storage',
      // 只持久化配置项,不持久化消息(消息从后端数据库加载)
      partialize: (state) => ({
        currentConversationId: state.currentConversationId,
        useRag: state.useRag,
      }),
    }
  )
);

4.3 API 服务层(chatService.js)

封装了所有与后端交互的逻辑,包括 SSE 流式请求:

javascript 复制代码
const BASE_URL = process.env.REACT_APP_CHAT_API_URL || 'http://localhost:5000';

export class ChatService {

  // REST 接口封装
  async getConversations() { /* GET /api/conversations */ }
  async getMessages(id) { /* GET /api/conversations/:id/messages */ }
  async createConversation(useRag = false) { /* POST /api/conversations */ }
  async deleteConversation(id) { /* DELETE /api/conversations/:id */ }
  async renameConversation(id, title) { /* PUT /api/conversations/:id/rename */ }
  async togglePin(id) { /* PUT /api/conversations/:id/pin */ }

  /**
   * SSE 流式聊天 ------ 核心方法
   * 使用 fetch + ReadableStream 手动解析 SSE 数据
   */
  streamChat(message, sessionId, useRag, callbacks) {
    const { onToken, onDone, onError } = callbacks;
    const controller = new AbortController();

    fetch(`${BASE_URL}/api/chat/stream`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message,
        session_id: sessionId || null,
        use_rag: useRag,
      }),
      signal: controller.signal,
    })
      .then(async (response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);

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

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          buffer += decoder.decode(value, { stream: true });

          // 按 SSE 双换行分割数据帧
          const parts = buffer.split('\n\n');
          buffer = parts.pop(); // 保留不完整的尾部数据

          for (const part of parts) {
            for (const line of part.split('\n')) {
              if (!line.startsWith('data: ')) continue;
              const raw = line.slice(6);

              try {
                const parsed = JSON.parse(raw);
                if (parsed.error) { onError(new Error(parsed.error)); return; }
                if (parsed.token) onToken(parsed.token);
                if (parsed.done) { onDone(parsed.session_id); return; }
              } catch { /* 忽略解析失败的行 */ }
            }
          }
        }
        onDone();
      })
      .catch((err) => {
        if (err.name !== 'AbortError') onError(err);
      });

    return controller; // 返回控制器,可用于中断请求
  }
}

export const chatService = new ChatService();

为什么不使用 EventSource EventSource 只支持 GET 请求,而我们的流式接口是 POST,所以使用 fetch + ReadableStream 手动解析 SSE 格式。这也是业界通用做法。

4.4 聊天页面组件

4.4.1 主页面(Chat.jsx)
javascript 复制代码
import React, { useEffect } from 'react';
import { useChatStore } from '../../store/chatStore';
import { chatService } from '../../services/chatService';
import ChatSidebar from './components/ChatSidebar';
import ChatMessages from './components/ChatMessages';
import ChatInput from './components/ChatInput';
import styles from './Chat.module.scss';

export default function Chat() {
  const { currentConversationId, sidebarCollapsed, setMessages, toggleSidebar } = useChatStore();

  // 切换对话时从数据库加载历史消息
  useEffect(() => {
    if (!currentConversationId) { setMessages([]); return; }
    chatService.getMessages(currentConversationId)
      .then((msgs) => setMessages(msgs.map((m) => ({
        id: m.id, role: m.role, content: m.content, createdAt: m.createdAt,
      }))))
      .catch(() => setMessages([]));
  }, [currentConversationId]);

  return (
    <div className={styles.chatPage}>
      {!sidebarCollapsed && <ChatSidebar />}
      {sidebarCollapsed && (
        <div className={styles.collapsedBar} onClick={toggleSidebar}>展开侧栏</div>
      )}
      <div className={styles.mainArea}>
        <ChatMessages />
        <ChatInput />
      </div>
    </div>
  );
}
4.4.2 侧边栏(ChatSidebar.jsx)

侧边栏包含对话列表、新建对话按钮、搜索弹窗、重命名/置顶/删除菜单:

javascript 复制代码
// 对话项组件 ------ 悬停显示操作菜单
function ConversationItem({ conv, isActive, onSelect, onRefresh }) {
  const [renaming, setRenaming] = useState(false);

  const menuItems = {
    items: [
      { key: 'rename', icon: <EditOutlined />, label: '重命名' },
      { key: 'pin', icon: conv.pinned ? <PushpinFilled /> : <PushpinOutlined />,
        label: conv.pinned ? '取消置顶' : '置顶' },
      { type: 'divider' },
      { key: 'delete', icon: <DeleteOutlined />, label: '删除', danger: true },
    ],
    onClick: ({ key, domEvent }) => {
      domEvent.stopPropagation();
      // 处理各操作...
    },
  };

  return (
    <div className={`${styles.conversationItem} ${isActive ? styles.active : ''}`}
         onClick={() => !renaming && onSelect(conv.id)}>
      {conv.pinned && <PushpinFilled style={{ color: '#1677ff', fontSize: 12 }} />}
      {renaming ? (
        <Input size="small" value={newTitle}
               onPressEnter={handleRename} onBlur={() => setRenaming(false)} autoFocus />
      ) : (
        <span className={styles.conversationTitle}>{conv.title}</span>
      )}
      <Dropdown menu={menuItems} trigger={['click']}>
        <EllipsisOutlined className={styles.ellipsisIcon}
                          onClick={(e) => e.stopPropagation()} />
      </Dropdown>
    </div>
  );
}

对话列表按时间自动分组(今天 / 昨天 / 7天内 / 30天内 / 更早),置顶对话始终在最前面。

4.4.3 消息展示(ChatMessages.jsx)
javascript 复制代码
export default function ChatMessages() {
  const { messages, isStreaming, streamingContent } = useChatStore();

  // 无消息时显示欢迎页
  if (messages.length === 0 && !isStreaming && !streamingContent) {
    return (
      <div className={styles.welcome}>
        <RobotOutlined className={styles.icon} />
        <h2>AI Chat 助手</h2>
        <p>点击「开启新对话」或直接输入消息开始聊天</p>
      </div>
    );
  }

  return (
    <div className={styles.messagesArea}>
      <div className={styles.messageList}>
        {/* 已完成的消息 */}
        {messages.map((msg) => (
          <div key={msg.id} className={`${styles.messageItem} ${styles[msg.role]}`}>
            <Avatar icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />} />
            <div className={`${styles.messageContent} ${styles[msg.role]}`}>
              {msg.role === 'assistant'
                ? <ReactMarkdown>{msg.content}</ReactMarkdown>
                : msg.content}
            </div>
          </div>
        ))}

        {/* 流式输出中的消息(带闪烁光标) */}
        {isStreaming && streamingContent && (
          <div className={`${styles.messageItem} ${styles.assistant}`}>
            <Avatar icon={<RobotOutlined />} />
            <div className={`${styles.messageContent} ${styles.assistant}`}>
              <ReactMarkdown>{streamingContent}</ReactMarkdown>
              <span className={styles.streamingCursor} />
            </div>
          </div>
        )}

        {/* 思考中状态 */}
        {isStreaming && !streamingContent && <div>思考中...</div>}
      </div>
    </div>
  );
}
4.4.4 输入框(ChatInput.jsx)
javascript 复制代码
export default function ChatInput() {
  const [inputValue, setInputValue] = useState('');
  const abortRef = useRef(null);

  const handleSend = useCallback(() => {
    const text = inputValue.trim();
    if (!text || isStreaming) return;

    // 更新 UI
    setInputValue('');
    addMessage({ id: Date.now(), role: 'user', content: text, createdAt: new Date().toISOString() });
    startStreaming();

    // 发起 SSE 流式请求
    abortRef.current = chatService.streamChat(
      text, currentConversationId, useRag,
      {
        onToken: (token) => appendStreamContent(token),
        onDone: (sessionId) => {
          finishStreaming();
          if (sessionId) setCurrentConversationId(sessionId);
          // 刷新侧边栏对话列表
          chatService.getConversations()
            .then(list => useChatStore.getState().setConversations(list));
        },
        onError: (err) => {
          message.error(err.message || '请求失败');
          cancelStreaming();
        },
      }
    );
  }, [inputValue, isStreaming, currentConversationId, useRag]);

  // Enter 发送,Shift+Enter 换行
  const handleKeyDown = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className={styles.inputArea}>
      <textarea value={inputValue} onChange={handleChange}
                onKeyDown={handleKeyDown}
                placeholder="输入消息,Enter 发送,Shift+Enter 换行..." />
      {isStreaming
        ? <Button danger icon={<StopOutlined />} onClick={handleStop} />
        : <Button type="primary" icon={<SendOutlined />}
                  onClick={handleSend} disabled={!inputValue.trim()} />
      }
    </div>
  );
}

4.5 路由配置

聊天页面使用独立路由(不嵌套在 Layout 内),因为它有自己全屏的侧边栏布局:

javascript 复制代码
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { path: '/', element: <Home /> },
      { path: '/count', element: <Count /> },
      // ... 其他页面
    ]
  },
  {
    path: '/chat',
    element: <Chat />    // 独立路由,全屏布局
  }
]);

五、完整数据流

整个系统的数据流如下:

复制代码
用户输入消息
    ↓
ChatInput.handleSend()
    ↓
chatService.streamChat()  ──── HTTP POST ────→  Flask /api/chat/stream
    ↓                                              ↓
onToken(token)         ←──── SSE data ──────  llm.stream(history) 逐 token 输出
    ↓                                              ↓
appendStreamContent()                        save_message() 保存到 MySQL
    ↓
ChatMessages 实时渲染(带闪烁光标)
    ↓
onDone() → finishStreaming()
    ↓
messages 数组更新,流式光标消失

六、启动与测试

6.1 初始化数据库

6.2 启动后端

复制代码
cd test-project
python server.py
# 聊天机器人服务启动: http://127.0.0.1:5000

6.3 启动前端

复制代码
cd my-react-app
npm start
# 访问 http://localhost:3000/chat

6.4 验证功能

  1. 输入消息并发送 → AI 流式逐 token 回答
  2. 点击「开启新对话」→ 创建空白对话
  3. 侧边栏按时间分组展示历史对话
  4. 悬停对话项 → 显示菜单(重命名 / 置顶 / 删除)
  5. 刷新页面 → 历史消息从数据库恢复

七、总结

本文实现了一个完整的 AI 流式聊天系统,涵盖了以下技术要点:

  • SSE 流式输出 :Flask + LangChain 的 llm.stream() 实现逐 token 推送,前端 fetch + ReadableStream 手动解析 SSE 数据帧
  • 对话持久化:MySQL 存储对话和消息,支持多会话管理、历史恢复
  • 前端状态管理 :Zustand 的 persist 中间件持久化用户配置,消息数据从后端按需加载
  • 对话管理:重命名、置顶、删除、时间分组,提供完整的会话管理体验

这个架构可以进一步扩展:添加用户认证、接入更多 LLM 模型、支持图片/文件上传、实现 RAG 知识库检索等。希望本文能为你的 AI 应用开发提供参考。

注意⚠:本文已分享完整流程及主要代码,完整代码可后台私信联系!!!

相关推荐
小沈跨境1 小时前
Temu 运营进阶之路 工具选型与凌风体系分析
大数据·人工智能·产品运营·跨境电商·temu
迁移科技1 小时前
案例丨AI+3D视觉,赋能制药行业拆垛及破包更精准高效
人工智能·科技·3d·自动化·视觉检测
NQBJT1 小时前
万字拆解 NeckFix:AI 脖子前倾检测的算法原理与工程实现
人工智能·算法
数智工坊1 小时前
【Inner Monologue论文阅读】: 首次将大语言模型嵌入机器人控制闭环,实现自我反思和动态行为调整
论文阅读·人工智能·算法·语言模型·机器人·无人机
AI帮小忙1 小时前
Debian/Ubuntu 系linux操作系统Kali Linux 2026 里安装 Hermes Agent
人工智能
乌恩大侠1 小时前
基站正在成为 AI 计算节点:NVIDIA Aerial 推动 RAN 架构重构
人工智能·重构·架构
钓了猫的鱼儿1 小时前
基于深度学习+AI的水下目标目标检测与预警系统(Python源码+数据集+UI可视化
人工智能·深度学习·智能手机
Ting-yu2 小时前
Spring AI Alibaba零基础速成(6) ---- 向量化
数据库·人工智能
YUDAMENGNIUBI2 小时前
day29_NLP概念与文本预处理
人工智能·自然语言处理