REACT · Vol.VII · LESSON 45 · AI 时代的前端进阶
流式 UI 与 LLM 响应
为什么 LLM 必须流式
上一篇第 44 篇结尾留了个钩子:streaming SSR 和 LLM 响应是同一件事——服务端不断产生,前端不断接收。先把「为什么必须流式」钉死。LLM 推理是自回归的:一个 token 接一个 token 往外吐,后一个依赖前一个,没法提前算完。500 token 的回答,按每 token 30~80ms 算,要 15~40 秒。
非流式的姿势是:请求挂着,全部生成完一次性返回 JSON——用户盯着白屏 30 秒,AI 产品的死穴。流式则是首个 token 一两秒就到(≈ prefill 时间),之后边生成边显示:
| 维度 | 非流式 | 流式 |
|---|---|---|
| 首字延迟 | = 全部生成时间(15~40s) | 1~2s(prefill 完就出) |
| 感知等待 | 全程空白,像挂了 | 打字机持续反馈,「AI 在思考」 |
| 中途取消 | 只能干等或刷新 | 随时 abort,已生成的保留 |
| 总耗时 | 15~40s | 几乎相同——流式不省时间,省的是「感觉」 |
最后一行是本站的灵魂:流式不改变总时长,改变的是可感知的等待与可控性。
非流式像把整个 ResultSet 读成 List 再返回;流式像 JDBC 流式游标(fetchSize)逐行推送。你早懂的道理——大结果集没人全量读——放到 LLM 上同理:「结果集」是 token,「消费方」是 UI。
SSE:单向就别上双工
承载「服务端单方面唠叨」的协议叫 SSE(Server-Sent Events):一条普通的 HTTP 长连接,响应头是 Content-Type: text/event-stream,报文就是一行行 data: 开头的文本,事件之间用空行分隔。先看生肉:
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"delta":{"content":"你"}}
data: {"delta":{"content":"好"}}
data: [DONE]
三行规则:data: 是载荷行;空行表示一个事件结束;[DONE] 是 OpenAI 兼容接口的约定终止哨兵——不是协议的一部分,是各家 LLM 接口的行规。协议还有 event:、id:、retry: 字段,做 AI 聊天用不上。
和 WebSocket 怎么选?一张表:
| 方案 | 方向 | 底层 | 断线重连 | 适用场景 |
|---|---|---|---|---|
| SSE | 服务端 → 客户端,单向 | 普通 HTTP 响应 | 简单(协议自带约定) | LLM 回复、进度推送、通知 |
| WebSocket | 双向 | 独立协议(升级握手) | 自己实现心跳与重连 | 协同编辑、双向聊天室、游戏 |
| 轮询 | 客户端拉 | 普通 HTTP | 无 | 低频更新、兼容性兜底 |
决策句:聊天场景里「用户发消息」是普通 POST,「AI 回复」才是流——只有响应需要流,单向足够就别上双工。OpenAI、Anthropic 的官方接口清一色 SSE,不是偶然。
SSE 在 Spring 里对应 SseEmitter 或 Flux<ServerSentEvent>——一个「迟迟不结束的 HTTP 响应」,反复 flush。WebSocket 则是绕开 HTTP 的独立双工通道:能力更强,但连接管理、代理穿透、鉴权全要自己扛。单向场景上双工,纯属给自己加运维。
后端:把 token 灌进 SSE
轮到你的主场。最常见架构是 Java 服务做 LLM 代理:收到请求 → 调 OpenAI 兼容接口(stream: true)→ 逐 chunk 转发。先写服务端:
@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String prompt) {
SseEmitter emitter = new SseEmitter(60_000L);
llmClient.stream(prompt) // Flux<String>:逐 token 的冷流
.doOnNext(token -> {
try {
emitter.send(token); // 每到一段,立即 flush 给浏览器
} catch (IOException e) {
emitter.completeWithError(e); // 客户端断开 / 写失败
}
})
.doOnComplete(emitter::complete) // 对应 [DONE] 之后关闭响应
.subscribe();
return emitter; // Controller 方法已返回,HTTP 响应还开着——这就是流式
}
上游给你的 chunk 长这样(字段精简过,真实报文还有 id、model):
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
三个读点:增量藏在 choices[0].delta.content——注意不是 message,那是非流式的全量字段;delta 变空对象、finish_reason 变 "stop",正文结束;data: [DONE] 流终止。前端的全部工作就一件:剥壳,拼纯文本。
服务端 doOnNext = 前端 reader.read() 循环,doOnComplete = done 信号,completeWithError = catch。整套你在 Reactor 里全写过,只是消费端换成了浏览器。前端不用担心背压——拼字符串永远快过 LLM 生成 token。
前端读流三件套与 useChatStream
先回答一个常见疑问:浏览器不是有现成的 EventSource 吗?——它只支持 GET、不能带 body 和自定义请求头(鉴权没地方放),所以标准姿势是 fetch POST + 手动读流。三件套:
response.body.getReader():拿 ReadableStream 的读取器;reader.read()循环:每次返回{ done, value },value 是一段 Uint8Array 字节;TextDecoder:字节转字符串,必须传{ stream: true }——一个汉字 3 字节,可能被切开在两个 chunk 里,不开流式解码就会出现乱码。
还有个坑:chunk 边界 ≠ 事件边界——一个 chunk 可能装着半个事件,也可能装着两个半。所以要自管 buffer,按行切、残留半行留给下一轮。全部装进一个自定义 Hook(第 13 篇的手艺):
import { useCallback, useRef, useState } from "react";
type Status = "idle" | "loading" | "streaming" | "done" | "error";
export function useChatStream() {
const [status, setStatus] = useState<Status>("idle");
const [text, setText] = useState("");
const abortRef = useRef<AbortController | null>(null); // 第 12 篇:ref 存跨渲染的实例
const stop = useCallback(() => abortRef.current?.abort(), []);
const start = useCallback(async (prompt: string) => {
abortRef.current?.abort(); // 上一轮没结束?先掐掉(第 34 篇的竞态处理)
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
setText("");
try {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
signal: controller.signal, // 取消的绳子拴在这
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
setStatus("streaming");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = ""; // chunk 边界 ≠ 事件边界,攒着按行切
while (true) {
const { done, value } = await reader.read(); // 等下一段字节
if (done) break;
buffer += decoder.decode(value, { stream: true }); // 多字节字符跨 chunk 不乱码
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // 最后半行可能没到全,留到下一轮
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") continue; // 终止哨兵,done 分支兜底
const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
if (delta) setText((prev) => prev + delta); // 函数式更新,防闭包旧值(第 10 篇)
}
}
setStatus("done");
} catch (e) {
if ((e as Error).name === "AbortError") setStatus("done"); // 主动取消不算失败
else setStatus("error");
}
}, []);
return { status, text, start, stop };
}
几处设计意图:五个状态各驱动一种 UI(idle 输入框、loading 思考中、streaming 打字机、done 定稿、error 重试);abortRef 而非 state 存 controller——它不参与渲染;setText((prev) => prev + delta) 是函数式更新,避免闭包旧 text(第 10 篇的坑在这里会真的出现)。生产上再给 JSON.parse 单条 try/catch、加多轮消息列表,第 46 篇展开。
打字机、自动滚动与取消生成
Hook 已把流拼成 text,渲染层只做三件事:打字机光标、自动滚动、停止按钮:
"use client";
import { useEffect, useRef } from "react";
import { useChatStream } from "./useChatStream";
export default function ChatPanel() {
const { status, text, start, stop } = useChatStream();
const boxRef = useRef<HTMLDivElement>(null); // 第 12 篇:DOM 引用,不触发重渲染
useEffect(() => {
// text 每变一次,把滚动条压到底部——流式场景的刚需
boxRef.current?.scrollTo({ top: boxRef.current.scrollHeight });
}, [text]);
const busy = status === "loading" || status === "streaming";
return (
<div>
<div ref={boxRef} className="chat-box">
{text}
{busy && <span className="cursor">▌</span>} // 光标闪烁交给 CSS 动画
</div>
{busy
? <button onClick={stop}>停止生成</button>
: <button onClick={() => start("用一句话解释 RSC")}>提问</button>}
</div>
);
}
体验细节:text 一变就把 scrollTop 压到底,用户永远看着最新字(用 ref 拿 DOM,不经过 state,第 12 篇);停止按钮调 stop() → abort → fetch 抛 AbortError → 状态落回 done,已生成的文本原样保留——「说到一半停下」远好于整段丢弃。光标闪烁是 CSS 的事,给 .cursor 配个 keyframes 即可。
补一句框架级的流划清边界:React 19 的 use() 能在组件里直接消费 Promise,RSC 的 <Suspense> streaming 让服务端把 UI 分块流下来(第 44 篇)——那是框架替你读流,流的是「UI 块」;本篇的 fetch + reader 是你自己读流,流的是「数据」。做 AI 聊天,后者绕不开。
- LLM 逐 token 生成:流式不缩短总耗时,但把 30 秒空白变成 1 秒出首字加持续反馈,还支持中途取消。
- 单向推送选 SSE:HTTP 长连接 +
data:行 + 空行分事件;只有双工需求才上 WebSocket。 - OpenAI 兼容流的增量在
choices[0].delta.content,[DONE]是终止哨兵。 - 前端读流三件套:fetch POST +
body.getReader()的 read() 循环 +TextDecoder(stream: true)。 - chunk 边界 ≠ 事件边界:自管 buffer 按
\n切行防撕裂;AbortController 管取消,取消不是错误。
本章回顾
把 LLM 的分词响应接到界面上,全链路是:后端一条「不结束的 HTTP 响应」(SseEmitter / Flux),中间隔一条 SSE(data: 行、空行分事件、[DONE] 收尾),前端一个「不结束的读取循环」(read + decode + 切行拼接),渲染层配上光标、自动滚动和停止键。整套东西你在 Java 里全见过——就是 Reactive 流,从服务端一路搬到浏览器。管道已经通了,下一篇第 46 篇把它装进真实产品:多轮对话的上下文管理、消息列表的状态设计、错误重试与成本控制。
Comments · 评论