别再手写 fetch 了!用 Next.js 14 + SSE 打造媲美 ChatGPT 的生产级 AI 流式聊天前端(全套代码+避坑实战)

别再手写 fetch 了!用 Next.js 14 + SSE 打造媲美 ChatGPT 的生产级 AI 流式聊天前端(全套代码+避坑实战)

在接入大模型 API 时,很多前端同学的第一反应往往是:

"流式交互嘛,不就是后端推 text/event-stream,前端调一个 fetch,拿到 response.body.getReader() 然后 while(true) 循环追加字符串吗?能有多难?"

然而,一旦将这种"Demo 级代码"推向正式生产环境,各种令人抓狂的诡异 Bug 便接踵而至:

  1. 中文诡异乱码:偶发性出现黑色菱形问号 ``,同一个词有的字正常,有的字乱码;
  2. 打字机疯狂抖动:网络稍有抖动,十几甚至几十个 Token 瞬间堆积涌入,DOM 产生剧烈重排(Layout Shift),页面像打桩机一样上下乱晃;
  3. Markdown / LaTeX 渲染雪崩 :大模型刚吐出一个反引号 ``` 或数学公式符号 $,页面因为 AST 解析失败直接白屏报错,或者代码高亮反复闪烁;
  4. 流式假死:上线到 Nginx 或生产网关后,前端根本看不到逐字输出,转圈等了十几秒,最后砰的一下全量文本一次性蹦出来!

为什么官方 ChatGPT 或 Claude 的打字体验如丝般顺滑,而我们自己写的聊天窗口却显得廉价且脆弱?流式交互绝非简单的"字符串拼接",而是一套涵盖网络协议分帧、多字节流解码、双缓冲插值动效以及 AST 容错渲染的综合工程架构!

本文将手把手带你基于 Next.js 14(App Router)+ TypeScript,构建一套达到工业级标准的 AI 流式聊天前端中枢,不仅提供可直接运行的完整代码,更会深入剖析每一个底层陷阱与防御方案。


一、生产环境四大"流式死穴"根因拆解

在写代码之前,我们必须先从计算机底层原理搞清楚:为什么你的简易实现会频频暴雷?

flowchart TD A[大模型后端 Token 流] --> B[Nginx / 代理网络层] B -->|陷阱4: proxy_buffering 积压| C[浏览器 TCP 接收缓冲区] C -->|陷阱1: TCP 分包多字节截断| D[TextDecoder 边界重组] D -->|陷阱2: 突发涌入/打字机卡顿| E[双缓冲平滑插值队列] E -->|陷阱3: 未闭合语法 AST 闪烁| F[虚拟占位 Markdown/LaTeX 渲染] F --> G[最终极客体验 UI]

1.1 陷阱一:UTF-8 多字节边界截断(为什么汉字会变乱码?)

在 UTF-8 编码体系中:

  • 标准 ASCII 字符(英文字母、数字)占用 1 字节;
  • 常用中文汉字通常占用 3 字节 (如"我"的 UTF-8 编码为 E6 88 91);
  • 部分 Emoji 和特殊符号占用 4 字节。

TCP 是面向字节流的协议,并不知晓应用层的字符边界。当网络拥塞发生分包,或者服务端输出切块时,一个 3 字节的汉字极有可能被硬生生劈成两半:前 1 字节落在 Chunk A,后 2 字节落在 Chunk B。

如果你在前端简单粗暴地调用:

typescript 复制代码
// ❌ 灾难写法:每次 chunk 独立解码
const chunkText = new TextDecoder().decode(value);

当 Chunk A 仅包含 0xE6 时,TextDecoder 会认为这是一个不合法的孤立序列,自动将其替换为 Unicode 替换字符 \uFFFD(即黑色菱形问号 ``)!哪怕紧接着 Chunk B 传来了 0x88 0x91,前一个字节也早已永久损毁,中文彻底乱码。

1.2 陷阱二:Token 突发涌入与打字机卡顿(Stuttering & Bursting)

大模型的推理并不是机械均匀地每 30ms 吐一个字,而是:

  • Prefill 首字阶段需要等待数十到数百毫秒;
  • Decode 阶段可能受网络抖动影响,数个 TCP 数据包被打包合并送达(TCP Nagle 算法或反向代理缓冲区刷新),导致前端在 1 帧内瞬间收到 30 个 Token; *紧接着网络可能又出现短暂微停顿。

如果前端"来一个字符就 setState 一次",会导致 React 在极短时间内连续触发大量的 Re-render 与 DOM 重排。更致命的是,前一秒字符像机关枪一样狂飙,下一秒突然卡死,视觉体验极为粗糙。必须引入双缓冲平滑衰减插值算法。

1.3 陷阱三:流式 AST 语法树不完整导致的重绘闪烁

富文本交互中,大模型频繁输出 Markdown 格式、代码块与 LaTeX 数学公式。 在流式传输过程中,语法标记必定是逐步到达的:

  • 刚输出了 typescript`,代码块闭合标签 还没输出;
  • 刚输出了 $\frac{1}{2,后半截 }$ 还没到达;
  • 刚输出了表格首行 | Header |,分隔行还没建立。

许多流行的 Markdown / KaTeX 解析库(如 react-markdown、KaTeX)在遇到未闭合标签时,会抛出解析异常,或者将半截公式降级为难看的纯文本代码。当后续 Token 到达闭合标签的一瞬间,整个 DOM 节点发生突变,公式从纯文本瞬间变成渲染树,产生严重的视觉闪烁(Flicker)!

1.4 陷阱四:反向代理的"静默死刑"(Buffer 积压)

这是导致无数全栈工程师通宵加班的经典元凶。 默认情况下,无论是 Nginx 、Cloudflare 还是某些内网 API 网关,为了提高吞吐量,都默认开启了响应缓冲(proxy_buffering on;)。网关会把后端返回的数据积攒到 4KB 或 8KB,才会一次性推给客户端浏览器。

结果就是:后端大模型明明在流式逐字输出,前端却转圈干等 10 秒,直到网关缓冲区满了或者整个请求响应结束,前端才"瞬发"收到全部内容,流式体验名存实亡!


二、架构设计:生产级 AI 流式交互状态机

为了从工程层面彻底解决上述所有缺陷,我们构建了一套解耦的流式前端架构:

stateDiagram-v2 [*] --> Idle: 初始化 Idle --> Connecting: 用户发送 Prompt Connecting --> Streaming: HTTP 200 / 收到首字节 Connecting --> Error: 连接超时 / 503 异常 state Streaming { [*] --> BufferIngest: 原始 Uint8Array 分块 BufferIngest --> StreamDecoding: TextDecoder(stream=true) StreamDecoding --> SSEFrameParse: 解析 data: JSON SSEFrameParse --> QueuePush: 推入平滑打字队列 QueuePush --> RAFInterpolation: requestAnimationFrame 均匀消费 } Streaming --> Completed: 收到 [DONE] / 正常结束 Streaming --> Aborted: 用户主动点击「停止生成」 Streaming --> Reconnecting: 异常网络断流 (带上次 offset) Completed --> Idle Aborted --> Idle Reconnecting --> Streaming: 重连成功,接续流 Reconnecting --> Error: 重试次数超限 Error --> Idle: 用户点击重试

这套架构核心包含三个关键层:

  1. 传输解析层(Transport Layer) :负责跨环境 fetch 调度、stream: true 跨包多字节解码、SSE 帧提取与断网指数退避重连;
  2. 动效缓冲层(Motion Buffer Layer) :基于 requestAnimationFrame 的自适应速率平滑打字机队列,将不规则的突发 Token 转化为贴合人类阅读节律的丝滑视觉流动;
  3. 容错呈现层(Resilient Render Layer):利用虚拟标记闭合与 AST 状态缓存,消除未完成代码块与数学公式的解析报错与闪烁。

三、底层实现:流式 UTF-8 解码与双缓冲平滑打字队列

3.1 跨 Chunk 乱码防御:原生流式解码器

在现代浏览器中,TextDecoder 原生支持流式保留上下文状态。关键在于创建单个解码器实例 ,并在每次调用 decode 时传入 { stream: true }:

typescript 复制代码
// lib/stream/decoder.ts
export class ResilientStreamDecoder {
  private decoder: TextDecoder;

  constructor(encoding: string = 'utf-8') {
    // 实例化一个持久的 TextDecoder 实例
    this.decoder = new TextDecoder(encoding, { fatal: false });
  }

  /**
   * 解码流式数据块
   * @param chunk 接收到的 Uint8Array
   * @param isFinal 是否为流的末尾
   */
  public decode(chunk: Uint8Array, isFinal: boolean = false): string {
    // 关键配置:stream: !isFinal 告知解码器保持内部未完成的多字节 buffer!
    return this.decoder.decode(chunk, { stream: !isFinal });
  }

  /**
   * 重置解码器状态
   */
  public reset(): void {
    this.decoder = new TextDecoder('utf-8', { fatal: false });
  }
}

原理解析 :当汉字的前两个字节到达时,开启 { stream: true } 的 TextDecoder 不会输出任何字符,而是将这两个悬挂字节保存在底层 internal buffer 中;等到后续 Chunk 带来最后一个字节,它会立即完成拼装并正确吐出汉字,乱码问题自此物理终结。

3.2 告别卡顿与跳字:双缓冲自适应速率打字机

如果直接把后端推送的字符直接赋值给 React state,界面的文本输出速度完全由网络波动决定,极度突兀。 我们设计一个基于 requestAnimationFrame 的自适应打字队列:

sequenceDiagram participant Network as 网络接收线程 participant Queue as 字符待播队列 (Queue) participant RAF as requestAnimationFrame (60/120Hz) participant UI as React State (渲染文本) Network->>Queue: 突发推入 25 个 Token loop 每渲染帧 (约 16.6ms) RAF->>Queue: 探测待播字数 (length = 25) Note over RAF: 自适应算力:队列积压大 -> 提速<br/>队列积压小 -> 匀速微动 RAF->>UI: 每次吐出 2~4 个字符 (平滑过渡) end

下面是核心算法实现:

typescript 复制代码
// lib/stream/smooth-typing-queue.ts
export interface SmoothTypingOptions {
  minSpeed?: number;    // 最低打字速度 (字符/秒)
  maxSpeed?: number;    // 最高打字速度 (字符/秒)
  onRender: (renderedText: string) => void;
  onComplete?: () => void;
}

export class SmoothTypingQueue {
  private targetText: string = '';
  private currentText: string = '';
  private isRunning: boolean = false;
  private isStreamDone: boolean = false;
  private rafId: number | null = null;
  private lastFrameTime: number = 0;
  
  private minSpeed: number;
  private maxSpeed: number;
  private onRender: (renderedText: string) => void;
  private onComplete?: () => void;

  constructor(options: SmoothTypingOptions) {
    this.minSpeed = options.minSpeed || 25; // 默认每秒最少 25 字符
    this.maxSpeed = options.maxSpeed || 200; // 默认每秒最多 200 字符
    this.onRender = options.onRender;
    this.onComplete = options.onComplete;
  }

  /**
   * 追加新到达的文本
   */
  public push(newSegment: string): void {
    this.targetText += newSegment;
    if (!this.isRunning) {
      this.start();
    }
  }

  /**
   * 标记服务端推流已结束
   */
  public markDone(): void {
    this.isStreamDone = true;
    if (!this.isRunning && this.currentText.length >= this.targetText.length) {
      this.onComplete?.();
    }
  }

  /**
   * 立即强刷全部内容(例如用户直接点击了跳过)
   */
  public flush(): void {
    this.stop();
    this.currentText = this.targetText;
    this.onRender(this.currentText);
    this.onComplete?.();
  }

  private start(): void {
    this.isRunning = true;
    this.lastFrameTime = performance.now();
    this.rafId = requestAnimationFrame(this.tick.bind(this));
  }

  private stop(): void {
    this.isRunning = false;
    if (this.rafId !== null) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }
  }

  private tick(now: number): void {
    const deltaSec = Math.min((now - this.lastFrameTime) / 1000, 0.1); // 防页面后台切回时的极端大 delta
    this.lastFrameTime = now;

    const remainingChars = this.targetText.length - this.currentText.length;

    if (remainingChars <= 0) {
      if (this.isStreamDone) {
        this.stop();
        this.onComplete?.();
        return;
      }
      // 队列空了但流未断,休眠等待下个 chunk
      this.rafId = requestAnimationFrame(this.tick.bind(this));
      return;
    }

    // 核心算法:根据积压量动态计算步进速度 (Dynamic Acceleration)
    // 积压越多,步进越快;积压逼近 0,保持丝滑匀速
    const dynamicRate = Math.min(
      this.maxSpeed,
      Math.max(this.minSpeed, remainingChars * 10)
    );

    const stepChars = Math.max(1, Math.floor(dynamicRate * deltaSec));
    const nextLength = Math.min(this.targetText.length, this.currentText.length + stepChars);

    this.currentText = this.targetText.slice(0, nextLength);
    this.onRender(this.currentText);

    this.rafId = requestAnimationFrame(this.tick.bind(this));
  }

  public destroy(): void {
    this.stop();
    this.targetText = '';
    this.currentText = '';
  }
}

四、全套生产级实战:Next.js 14 自定义 Hook 与完整组件

4.1 工业级 useChatStream Hook 实现

为了让业务开发无需感知底层字节流细节,我们将流式管理、中断、重试、错误自愈全量封装进自定义 Hook。

创建 hooks/useChatStream.ts:

typescript 复制代码
import { useState, useRef, useCallback, useEffect } from 'react';
import { ResilientStreamDecoder } from '../lib/stream/decoder';
import { SmoothTypingQueue } from '../lib/stream/smooth-typing-queue';

export interface Message {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  createdAt: number;
}

export interface UseChatStreamOptions {
  apiEndpoint?: string;
  onFinish?: (finalMessage: Message) => void;
  onError?: (error: Error) => void;
}

export function useChatStream(options: UseChatStreamOptions = {}) {
  const {
    apiEndpoint = '/api/chat',
    onFinish,
    onError
  } = options;

  const [messages, setMessages] = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [streamingContent, setStreamingContent] = useState<string>('');

  const abortControllerRef = useRef<AbortController | null>(null);
  const typingQueueRef = useRef<SmoothTypingQueue | null>(null);
  const decoderRef = useRef<ResilientStreamDecoder>(new ResilientStreamDecoder());

  // 组件卸载时释放资源
  useEffect(() => {
    return () => {
      if (abortControllerRef.current) abortControllerRef.current.abort();
      if (typingQueueRef.current) typingQueueRef.current.destroy();
    };
  }, []);

  /**
   * 主动中断生成
   */
  const stopGeneration = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      abortControllerRef.current = null;
    }
    if (typingQueueRef.current) {
      typingQueueRef.current.flush();
    }
    setIsLoading(false);
  }, []);

  /**
   * 发送聊天请求并处理流式返回
   */
  const sendMessage = useCallback(async (userPrompt: string) => {
    if (!userPrompt.trim() || isLoading) return;

    const userMessage: Message = {
      id: `msg_${Date.now()}_u`,
      role: 'user',
      content: userPrompt,
      createdAt: Date.now()
    };

    const nextMessages = [...messages, userMessage];
    setMessages(nextMessages);
    setIsLoading(true);
    setStreamingContent('');

    // 创建全新中止器与解码器
    const abortController = new AbortController();
    abortControllerRef.current = abortController;
    const decoder = new ResilientStreamDecoder();
    decoderRef.current = decoder;

    // 当前 assistant 消息 ID
    const assistantMsgId = `msg_${Date.now()}_a`;
    let accumulatedText = '';

    // 初始化平滑打字队列
    const typingQueue = new SmoothTypingQueue({
      minSpeed: 30,
      maxSpeed: 220,
      onRender: (smoothText) => {
        setStreamingContent(smoothText);
      },
      onComplete: () => {
        const completedMessage: Message = {
          id: assistantMsgId,
          role: 'assistant',
          content: accumulatedText,
          createdAt: Date.now()
        };
        setMessages(prev => [...prev, completedMessage]);
        setStreamingContent('');
        setIsLoading(false);
        onFinish?.(completedMessage);
      }
    });
    typingQueueRef.current = typingQueue;

    try {
      const response = await fetch(apiEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream'
        },
        body: JSON.stringify({ messages: nextMessages }),
        signal: abortController.signal
      });

      if (!response.ok || !response.body) {
        throw new Error(`HTTP 错误 [${response.status}]: ${response.statusText}`);
      }

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

      while (true) {
        const { done, value } = await reader.read();
        
        if (done) {
          // 刷出解码器剩余字节
          const finalStr = decoder.decode(new Uint8Array(), true);
          if (finalStr) buffer += finalStr;
          typingQueue.markDone();
          break;
        }

        // 增量流式安全解码
        const chunkText = decoder.decode(value, false);
        buffer += chunkText;

        // 处理 SSE 帧协议行 (以 \n 切割)
        const lines = buffer.split('\n');
        // 最后一行可能未闭合,保留到下一个 tick
        buffer = lines.pop() || '';

        for (const line of lines) {
          const trimmed = line.trim();
          if (!trimmed || trimmed.startsWith(':')) continue; // 忽略心跳与空行

          if (trimmed.startsWith('data:')) {
            const dataStr = trimmed.slice(5).trim();
            if (dataStr === '[DONE]') {
              continue;
            }
            try {
              const payload = JSON.parse(dataStr);
              // 兼容 OpenAI / Anthropic / 自建中转格式
              const delta = payload.choices?.[0]?.delta?.content 
                || payload.content 
                || payload.text 
                || '';
              
              if (delta) {
                accumulatedText += delta;
                typingQueue.push(delta);
              }
            } catch {
              // 容错:如果后端推送的并非 JSON,而是纯文本流
              accumulatedText += dataStr;
              typingQueue.push(dataStr);
            }
          }
        }
      }

    } catch (err: any) {
      if (err.name === 'AbortError') {
        // 用户主动停止,属于预期行为
        return;
      }
      console.error('AI Stream Error:', err);
      onError?.(err);
      setIsLoading(false);
      // 失败时回退已渲染部分到对话流,防止白屏
      if (accumulatedText) {
        setMessages(prev => [...prev, {
          id: assistantMsgId,
          role: 'assistant',
          content: accumulatedText + '\n\n*(连接中断)*',
          createdAt: Date.now()
        }]);
      }
    }
  }, [apiEndpoint, isLoading, messages, onFinish, onError]);

  return {
    messages,
    isLoading,
    streamingContent,
    sendMessage,
    stopGeneration
  };
}

4.2 容错 Markdown 与代码公式渲染组件

针对流式未闭合 Markdown 标签(如 ``` 或 $),我们需要在渲染前做一层自愈修补(Auto-healing Patch)。

创建 components/StreamingMarkdown.tsx:

tsx 复制代码
import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github-dark.css';

interface StreamingMarkdownProps {
  content: string;
  isStreaming?: boolean;
}

/**
 * 智能补全未闭合的代码块与公式标记,杜绝 AST 解析崩溃与闪烁
 */
function autoHealMarkdown(raw: string, isStreaming: boolean): string {
  if (!isStreaming) return raw;

  let text = raw;

  // 1. 检查代码块反引号数量 (```)
  const backtickMatches = text.match(/```/g);
  const backtickCount = backtickMatches ? backtickMatches.length : 0;
  // 如果反引号数量为奇数,说明正处于流式代码块中,自动补齐闭合符
  if (backtickCount % 2 !== 0) {
    text += '\n\n```';
  }

  // 2. 检查内联行内公式 ($) 是否成对
  const singleDollarMatches = text.match(/(?<!\\)\$/g);
  const singleDollarCount = singleDollarMatches ? singleDollarMatches.length : 0;
  if (singleDollarCount % 2 !== 0) {
    text += '$';
  }

  return text;
}

export const StreamingMarkdown: React.FC<StreamingMarkdownProps> = ({ content, isStreaming = false }) => {
  // 利用 useMemo 避免在父组件无更新时反复解析 AST
  const healedContent = useMemo(() => {
    return autoHealMarkdown(content, isStreaming);
  }, [content, isStreaming]);

  return (
    <div className="prose prose-slate dark:prose-invert max-w-none break-words leading-relaxed text-sm md:text-base">
      <ReactMarkdown
        remarkPlugins={[remarkGfm, remarkMath]}
        rehypePlugins={[rehypeKatex, rehypeHighlight]}
        components={{
          // 自定义代码块外观并增加复制按钮支持
          code({ node, inline, className, children, ...props }: any) {
            const match = /language-(\w+)/.exec(className || '');
            return !inline && match ? (
              <div className="relative group my-3 rounded-lg overflow-hidden border border-slate-700 bg-slate-900">
                <div className="flex items-center justify-between px-3 py-1.5 bg-slate-800 text-xs text-slate-400 font-mono">
                  <span>{match[1]}</span>
                  <button
                    onClick={() => navigator.clipboard.writeText(String(children))}
                    className="hover:text-white transition-colors"
                  >
                    复制
                  </button>
                </div>
                <pre className="p-4 overflow-x-auto text-xs md:text-sm font-mono leading-normal">
                  <code className={className} {...props}>
                    {children}
                  </code>
                </pre>
              </div>
            ) : (
              <code className="px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono text-sm text-pink-600 dark:text-pink-400" {...props}>
                {children}
              </code>
            );
          }
        }}
      >
        {healedContent}
      </ReactMarkdown>
      {/* 流式打字闪烁光标 */}
      {isStreaming && (
        <span className="inline-block w-2 h-4 ml-1 bg-blue-500 animate-pulse align-middle" />
      )}
    </div>
  );
};

4.3 完整前端聊天容器视图

在 app/chat/page.tsx 中将它们装配起来:

tsx 复制代码
'use client';

import React, { useState, useRef, useEffect } from 'react';
import { useChatStream } from '../../hooks/useChatStream';
import { StreamingMarkdown } from '../../components/StreamingMarkdown';

export default function ChatPage() {
  const [input, setInput] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const {
    messages,
    isLoading,
    streamingContent,
    sendMessage,
    stopGeneration
  } = useChatStream({
    apiEndpoint: '/api/chat',
    onError: (err) => alert(`生成失败: ${err.message}`)
  });

  // 自动滚动到底部 (平滑跟随)
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, streamingContent]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading) return;
    sendMessage(input);
    setInput('');
  };

  return (
    <div className="flex flex-col h-screen max-w-4xl mx-auto bg-white dark:bg-slate-950 shadow-xl border-x border-slate-200 dark:border-slate-800">
      {/* 顶部标题栏 */}
      <header className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <div className="w-3 h-3 rounded-full bg-emerald-500 animate-ping" />
          <h1 className="font-semibold text-lg text-slate-800 dark:text-white">Next.js 14 AI Stream Pro</h1>
        </div>
        <span className="text-xs text-slate-500 bg-slate-100 dark:bg-slate-800 px-2.5 py-1 rounded-full">
          HTTP/2 · SSE · Smooth Engine
        </span>
      </header>

      {/* 消息历史滚动区 */}
      <div className="flex-1 overflow-y-auto p-6 space-y-6">
        {messages.map((m) => (
          <div
            key={m.id}
            className={`flex gap-3 ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
          >
            {m.role !== 'user' && (
              <div className="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-white text-xs font-bold shrink-0">
                AI
              </div>
            )}
            <div
              className={`p-4 rounded-2xl max-w-[85%] ${
                m.role === 'user'
                  ? 'bg-blue-600 text-white rounded-br-none'
                  : 'bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-bl-none'
              }`}
            >
              {m.role === 'user' ? (
                <p className="whitespace-pre-wrap">{m.content}</p>
              ) : (
                <StreamingMarkdown content={m.content} isStreaming={false} />
              )}
            </div>
          </div>
        ))}

        {/* 正在流式生成的临时消息卡片 */}
        {isLoading && streamingContent && (
          <div className="flex gap-3 justify-start">
            <div className="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-white text-xs font-bold shrink-0">
              AI
            </div>
            <div className="p-4 rounded-2xl max-w-[85%] bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-bl-none">
              <StreamingMarkdown content={streamingContent} isStreaming={true} />
            </div>
          </div>
        )}

        <div ref={messagesEndRef} />
      </div>

      {/* 底部输入框与操作栏 */}
      <footer className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 backdrop-blur">
        <form onSubmit={handleSubmit} className="flex gap-2">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder={isLoading ? 'AI 正在全力思考生成中...' : '输入问题,Shift+Enter 换行...'}
            disabled={isLoading}
            className="flex-1 px-4 py-3 rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-950 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 text-sm md:text-base"
          />
          {isLoading ? (
            <button
              type="button"
              onClick={stopGeneration}
              className="px-5 py-3 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-medium text-sm transition-colors shadow-sm"
            >
              停止生成
            </button>
          ) : (
            <button
              type="submit"
              disabled={!input.trim()}
              className="px-6 py-3 rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium text-sm transition-colors shadow-sm"
            >
              发送
            </button>
          )}
        </form>
      </footer>
    </div>
  );
}

五、后端配套:Next.js 14 App Router API Route

前端准备完毕后,后端必须严格遵循 SSE 响应规范输出流。

创建 app/api/chat/route.ts:

typescript 复制代码
import { NextRequest } from 'next/server';

export const runtime = 'edge'; // 采用 Edge Runtime 降低首字延迟与连接占用

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  // 创建一个 TransformStream 用于流式响应
  const encoder = new TextEncoder();
  const stream = new TransformStream();
  const writer = stream.writable.getWriter();

  (async () => {
    try {
      // 对接你的实际大模型提供方 (OpenAI, DeepSeek, 硅基流动等)
      const upstreamRes = await fetch('https://api.deepseek.com/v1/chat/completions', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          model: 'deepseek-chat',
          messages,
          stream: true
        })
      });

      if (!upstreamRes.body) throw new Error('上游无响应体');

      const reader = upstreamRes.body.getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        // 原样透传上游的 SSE 帧
        await writer.write(value);
      }
    } catch (err: any) {
      const errorFrame = `data: ${JSON.stringify({ error: err.message })}\n\n`;
      await writer.write(encoder.encode(errorFrame));
    } finally {
      await writer.close();
    }
  })();

  // 必须严格返回 text/event-stream 与防缓冲请求头!
  return new Response(stream.readable, {
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no' // 生产避坑铁律:强制关闭 Nginx 反代缓冲!
    }
  });
}

六、生产上线与运维避坑红线

在将流式聊天服务推向生产环境前,必须仔细核对以下清单:

6.1 Nginx 反代必配金标准

如果你的应用部署在标准容器或虚拟机后方,Nginx 配置文件必须显式包含以下参数:

nginx 复制代码
location /api/chat {
    proxy_pass http://nextjs_upstream;
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    # 核心避坑:关闭任何形式的代理缓冲!
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;

    # 延长超时,防止模型长推理被网关 60s 掐断
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
}

6.2 虚拟滚动与 DOM 爆炸防御

当用户与 AI 进行多达数十轮的长对话时,整个页面的 Markdown AST 节点数量会突破上万个,导致输入框打字掉帧。 生产解决方案:

  • 历史已完成消息仅渲染静态只读 HTML 或精简 Markdown,停止维护活跃 AST;
  • 对超过 20 条消息的会话引入 @tanstack/react-virtual 进行可视区域虚拟化挂载;
  • 当离开当前会话标签页时,及时调用 SmoothTypingQueue.destroy() 释放 requestAnimationFrame 句柄,避免后台空耗 CPU。

七、总结与互动

回顾全文,打造一个媲美官方 ChatGPT 的工业级流式前端,绝非一个简单的 fetch 调用,而是需要层层设防:

  1. 多字节安全 :使用持久化 TextDecoder(stream: true) 彻底消灭中文乱码菱形符;
  2. 动效自适应 :用 requestAnimationFrame + 双缓冲动态衰减打字队列抹平网络突发涌入;
  3. 语法容错渲染:在 AST 解析前预判并闭合未完成代码块与 LaTeX,避免白屏闪烁;
  4. 全链路透传 :在代码与 Nginx 层全面配置 X-Accel-Buffering: no,瓦解流式死刑。

💬 极客互动思考题

随着以 DeepSeek-R1 与 OpenAI o1 为代表的"长思考链推理模型"兴起,模型在输出最终答案前会先吐出长达几千字的 <think> 思考过程。 你会如何在现有的 useChatStream 架构中,实现对 <think> 标签的流式状态分离,做到思考过程可折叠、耗时实时跳秒、且折叠展开丝滑过渡?

欢迎在评论区分享你的设计思路与实战见解!

相关推荐
鸿蒙开发1 小时前
扫描 PDF 看得见却搜不到?用 Tesseract.js + PDF.js 生成可搜索 PDF
前端·react.js
鬼手点金4 小时前
ReAct 循环
linux·服务器·前端·javascript·react.js·前端框架
misty youth5 小时前
前端知识速查
前端·react.js·web
薛一半9 小时前
Redux实战:组件状态管理全解析
开发语言·前端·javascript·react.js
鸿蒙开发1 天前
PDF 盖黑框不等于脱敏:用 PDF.js + Canvas + pdf-lib 实现永久遮盖
前端·react.js
Java后端的Ai之路1 天前
15_ReactJS_Flux_简介
前端·react.js·前端框架
weixin_446260851 天前
ReAct‑Evolve:基于轨迹反馈的ReAct智能体自主提示演化系统
前端·react.js·前端框架
阿Q的AI笔记1 天前
从ReAct到GRPO、从OpenHands到SWE-agent:Agent完整知识体系全拆解
前端·react.js·前端框架