别再手写 fetch 了!用 Next.js 14 + SSE 打造媲美 ChatGPT 的生产级 AI 流式聊天前端(全套代码+避坑实战)
在接入大模型 API 时,很多前端同学的第一反应往往是:
"流式交互嘛,不就是后端推
text/event-stream,前端调一个fetch,拿到response.body.getReader()然后while(true)循环追加字符串吗?能有多难?"
然而,一旦将这种"Demo 级代码"推向正式生产环境,各种令人抓狂的诡异 Bug 便接踵而至:
- 中文诡异乱码:偶发性出现黑色菱形问号 ``,同一个词有的字正常,有的字乱码;
- 打字机疯狂抖动:网络稍有抖动,十几甚至几十个 Token 瞬间堆积涌入,DOM 产生剧烈重排(Layout Shift),页面像打桩机一样上下乱晃;
- Markdown / LaTeX 渲染雪崩 :大模型刚吐出一个反引号 ``` 或数学公式符号
$,页面因为 AST 解析失败直接白屏报错,或者代码高亮反复闪烁; - 流式假死:上线到 Nginx 或生产网关后,前端根本看不到逐字输出,转圈等了十几秒,最后砰的一下全量文本一次性蹦出来!
为什么官方 ChatGPT 或 Claude 的打字体验如丝般顺滑,而我们自己写的聊天窗口却显得廉价且脆弱?流式交互绝非简单的"字符串拼接",而是一套涵盖网络协议分帧、多字节流解码、双缓冲插值动效以及 AST 容错渲染的综合工程架构!
本文将手把手带你基于 Next.js 14(App Router)+ TypeScript,构建一套达到工业级标准的 AI 流式聊天前端中枢,不仅提供可直接运行的完整代码,更会深入剖析每一个底层陷阱与防御方案。
一、生产环境四大"流式死穴"根因拆解
在写代码之前,我们必须先从计算机底层原理搞清楚:为什么你的简易实现会频频暴雷?
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 流式交互状态机
为了从工程层面彻底解决上述所有缺陷,我们构建了一套解耦的流式前端架构:
这套架构核心包含三个关键层:
- 传输解析层(Transport Layer) :负责跨环境
fetch调度、stream: true跨包多字节解码、SSE 帧提取与断网指数退避重连; - 动效缓冲层(Motion Buffer Layer) :基于
requestAnimationFrame的自适应速率平滑打字机队列,将不规则的突发 Token 转化为贴合人类阅读节律的丝滑视觉流动; - 容错呈现层(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 的自适应打字队列:
下面是核心算法实现:
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 调用,而是需要层层设防:
- 多字节安全 :使用持久化
TextDecoder(stream: true)彻底消灭中文乱码菱形符; - 动效自适应 :用
requestAnimationFrame+ 双缓冲动态衰减打字队列抹平网络突发涌入; - 语法容错渲染:在 AST 解析前预判并闭合未完成代码块与 LaTeX,避免白屏闪烁;
- 全链路透传 :在代码与 Nginx 层全面配置
X-Accel-Buffering: no,瓦解流式死刑。
💬 极客互动思考题
随着以 DeepSeek-R1 与 OpenAI o1 为代表的"长思考链推理模型"兴起,模型在输出最终答案前会先吐出长达几千字的
<think>思考过程。 你会如何在现有的useChatStream架构中,实现对<think>标签的流式状态分离,做到思考过程可折叠、耗时实时跳秒、且折叠展开丝滑过渡?
欢迎在评论区分享你的设计思路与实战见解!