源码解读:LLM 流式输出的前端实现
"流式输出是 AI 应用的体感生死线——用户盯着一秒一个字冒出来 vs 等十秒一次性蹦出来,体验天差地别。本文从协议到渲染讲透前端流式:SSE/ReadableStream 怎么解析、React 怎么增量渲染不卡、AI SDK 的 useChat 怎么简化,以及中断/错误的处理。"
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
AI 应用有个反直觉的体感规律:1 秒首字 + 流式输出 5 秒,比"等 3 秒一次性返回"体验好得多——哪怕前者总时间更长。原因:流式让用户看到"在动",消除等待焦虑;首字时间比总时间更决定体感。
所以流式不是可选项,是 AI 应用的标配。这篇从协议到渲染讲透前端流式怎么做。
一、流式的底层:Server-Sent Events
LLM 流式的事实标准是 SSE(Server-Sent Events)——服务端单向、持续推送数据给浏览器。它本质是一个长连接的 HTTP 响应,Content-Type: text/event-stream,按 data: ...\n\n 分块。
后端(Next.js Route Handler / Server Action)调模型时用流式接口,把 token 边生成边写进响应:
// app/api/chat/route.ts
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
messages,
});
return result.toDataStreamResponse(); // 自动转成 SSE 流
}
toDataStreamResponse() 把模型的流式 token 包成 AI SDK 的 data stream 协议(SSE 之上,带类型标记),前端用配套解析。
二、前端:手写一个流式接收
不用任何 SDK,先看裸实现,理解原理:
async function streamChat(input: string, onToken: (t: string) => void) {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: input }] }),
});
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// SSE 按 \n\n 分块
const parts = buffer.split("\n\n");
buffer = parts.pop()!; // 最后一段可能不完整,留到下次
for (const part of parts) {
const line = part.split("\n").find(l => l.startsWith("data: "));
if (!line) continue;
const data = line.slice(6);
if (data === "[DONE]") return;
onToken(JSON.parse(data).text); // 解析出 token,回调
}
}
}
核心是 ReadableStream + TextDecoderStream:边读边解码、按 SSE 分块边界切、解析每块的 data:、回调增量 token。
三、React 渲染:增量但不卡
收到 token 就 setState 追加,听起来简单,但有坑:
function Chat() {
const [text, setText] = useState("");
return <div>{text}</div>;
}
// streamChat(input, t => setText(prev => prev + t))
问题:长输出(几千 token)下,每个 token 一次 setState + 重新渲染整个 markdown,会卡。优化:
1. 批量更新。 别每个 token 一次 setState,攒一个小 buffer(如每 30ms 或每 5 个 token)刷一次。减少渲染次数。
2. markdown 渲染别在主线程重算。 流式过程中每帧重新 parse 完整 markdown 会卡。要么流式期间用纯文本预览、结束后再渲染 markdown;要么用流式友好的 markdown 库(markdown-to-jsx 之类,或 AI SDK 的内置)。
3. 用 useRef + requestAnimationFrame。 把增量 token 存 ref,用 rAF 合并刷到 state,避免高频 setState。
四、AI SDK 的 useChat:把上面全简化
实际上,你不用手写上面这些。AI SDK 的 useChat 把"调接口 + 流式接收 + 状态管理 + 渲染优化"全包了:
"use client";
import { useChat } from "ai/react";
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<>
{messages.map(m => <div key={m.id}>{m.content}</div>)}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</>
);
}
useChat 内部:
- 管
messages状态(用户消息、助手消息)。 - 流式接收助手回复,增量更新最后一条
messages。 - 处理 loading、error、abort。
99% 的场景,直接用 useChat 就够。手写那套是帮你理解它在干什么、出问题时能调。
五、中断与错误(容易漏的)
流式比一次性返回多了几个状态要处理:
1. 用户主动中断。 长输出用户不想等了,要能"停止生成"。useChat 提供 stop(),内部用 AbortController 取消 fetch。手写时记得把 AbortController.signal 传给 fetch,中断时主动 close reader。
2. 连接中断。 网络断了,reader 会抛错。catch + 给用户"重试"按钮,别让界面卡在"加载中"。
3. 流中途出错。 模型流式生成中途报错(限流、内容审核),SSE 会推一个 error 事件。解析时要识别 error,显示"生成中断"+ 保留已生成内容。
4. 空流。 偶尔模型一个 token 都没生成就结束,别让界面留个空消息。
六、几个体验细节
- 首字时间最关键。优化后端让首 token 尽快出(prompt caching、不要在生成前做重计算)。首字 < 1s 是黄金线。
- 打字机速度可选。有些产品故意限制输出速度模拟打字机(视觉更舒服),但别慢于真实生成——纯等没意义。
- 滚动跟随。流式输出时自动滚到底(但要尊重用户主动上滚——用户往上翻看时别强制拉回底部)。
- 可复制。流式过程中允许复制已生成部分,别等到结束。
小结
流式输出是 AI 应用的体感生死线。技术上它是一套组合:
- 协议:SSE(服务端单向流)。
- 后端:模型流式接口 → 转成 SSE 响应(AI SDK 的
toDataStreamResponse)。 - 前端:
ReadableStream解析增量(手写理解原理 / 用useChat直接搞定)。 - 渲染:批量更新 + 流式友好的 markdown,别每 token 重渲染卡顿。
- 健壮性:中断(AbortController)、断线重试、中途错误、空流。
记住一条:首字时间比总时间更决定体感。把首字优化到 < 1s,配上流式渲染,你的 AI 应用体感立刻从"等"变成"在动"——这一项的体验提升,往往比模型升级一档还明显。
Related