首页 / React 学习笔记 / 45

REACT · Vol.VII · LESSON 45 · AI 时代的前端进阶

流式 UI 与 LLM 响应

实战核心#流式#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几乎相同——流式不省时间,省的是「感觉」

最后一行是本站的灵魂:流式不改变总时长,改变的是可感知的等待与可控性

非流式 白屏等待 · 一个 token 都看不到 27s 全量返回 流式 1.2s 首字就到 已到达的内容持续追加 边生成边显示 · 随时可以取消 0s 10s 20s 30s
图 1同一道题两种体验:总时长一样,流式把 30 秒空白换成 1 秒出首字加持续反馈。
后端类比:全量 List 还是流式游标

非流式像把整个 ResultSet 读成 List 再返回;流式像 JDBC 流式游标(fetchSize)逐行推送。你早懂的道理——大结果集没人全量读——放到 LLM 上同理:「结果集」是 token,「消费方」是 UI。

SSE:单向就别上双工

承载「服务端单方面唠叨」的协议叫 SSE(Server-Sent Events):一条普通的 HTTP 长连接,响应头是 Content-Type: text/event-stream,报文就是一行行 data: 开头的文本,事件之间用空行分隔。先看生肉:

SSE 原始报文 · 浏览器里收到的就是这些字节
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,不是偶然。

后端类比:ResponseBodyEmitter 一直不 close

SSE 在 Spring 里对应 SseEmitter 或 Flux<ServerSentEvent>——一个「迟迟不结束的 HTTP 响应」,反复 flush。WebSocket 则是绕开 HTTP 的独立双工通道:能力更强,但连接管理、代理穿透、鉴权全要自己扛。单向场景上双工,纯属给自己加运维。

后端:把 token 灌进 SSE

轮到你的主场。最常见架构是 Java 服务做 LLM 代理:收到请求 → 调 OpenAI 兼容接口(stream: true)→ 逐 chunk 转发。先写服务端:

ChatController.java · SseEmitter:一个「迟迟不结束」的 HTTP 响应
@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):

chunk 结构 · OpenAI 兼容 stream:true 的增量报文
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] 流终止。前端的全部工作就一件:剥壳,拼纯文本。

后端类比:Flux 与 reader 是同构的 Reactive 流

服务端 doOnNext = 前端 reader.read() 循环,doOnComplete = done 信号,completeWithError = catch。整套你在 Reactor 里全写过,只是消费端换成了浏览器。前端不用担心背压——拼字符串永远快过 LLM 生成 token。

前端读流三件套与 useChatStream

先回答一个常见疑问:浏览器不是有现成的 EventSource 吗?——它只支持 GET、不能带 body 和自定义请求头(鉴权没地方放),所以标准姿势是 fetch POST + 手动读流。三件套:

  1. response.body.getReader():拿 ReadableStream 的读取器;
  2. reader.read() 循环:每次返回 { done, value },value 是一段 Uint8Array 字节;
  3. TextDecoder:字节转字符串,必须传 { stream: true }——一个汉字 3 字节,可能被切开在两个 chunk 里,不开流式解码就会出现乱码。

还有个坑:chunk 边界 ≠ 事件边界——一个 chunk 可能装着半个事件,也可能装着两个半。所以要自管 buffer,按行切、残留半行留给下一轮。全部装进一个自定义 Hook(第 13 篇的手艺):

useChatStream.ts · 状态机:idle → loading → streaming → done / error
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 篇展开。

reader.read() 的 while 循环,就是你在 Reactor 里写的 subscribe + onNext;done: true 就是 onComplete。fetch 的 ReadableStream 是个「冷」流——你不 read,它不塞数据给你,天然背压。

打字机、自动滚动与取消生成

Hook 已把流拼成 text,渲染层只做三件事:打字机光标、自动滚动、停止按钮:

ChatPanel.tsx · 渲染层:光标、自动滚动、停止按钮
"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 · 评论