#RAG#Next.js

RAG 应用的流式 UI 实现

RAG 不是"一次返回"——检索和生成都该流式,让用户边等边看。本文讲清 RAG 流式 UI 的实现:检索阶段流式(先显示"找到的文档")、生成阶段流式(打字机正文)、引用渲染(答案出完同步亮引用)、React 的增量渲染不卡,以及"检索中→生成中"的状态切换。

本文要点

  • 主题聚焦:RAG、Next.js
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2025年11月29日·更新于 2026年7月23日

RAG 应用的流式 UI 实现

做过 RAG 应用的同学都知道,这玩意儿的延迟结构是"两段叠加":先检索(向量召回 + rerank + 可能还多路融合),再生成(大模型逐 token 吐字)。这两段各自都不便宜,叠在一起就更不便宜。

典型链路是 800ms2s 检索 + 315s 生成。如果你的 UI 是"点一下、转圈、砰一下出全部答案",用户在这 8 秒里盯着 loading,中间没有任何信息密度——这是体验灾难。

我的观点很直接:RAG 不是一次请求一次返回,它是两段信息流的拼接。检索阶段有"我查到了哪些资料"这个高价值信号,生成阶段有"答案正在形成"这个渐进信号。任何一段你不流式出去,都是在把用户当傻子晾着。这篇文章就把这两段流式、以及围绕它们的 UI 工程,一次讲透。

一、为什么 RAG 必须流式:两段延迟,两种信号

先建立体感。一个 RAG 请求的时间线长这样:

text
[0ms] 用户提问
   │  ⬇ 检索阶段(用户什么都看不到)
[1200ms] 检索完成,拿到 5 个 chunk
   │  ⬇ 生成阶段(如果全量返回,用户还是什么都看不到)
[9000ms] 大模型吐完最后一个 token,一次性渲染

如果做成一次性返回,用户在前 9 秒的每一个瞬间,屏幕上都只有一个 loading 转圈。这个 9 秒里有两个本该暴露给用户的信号被你吃掉了:

  1. 第 1.2 秒:检索结果已经出来了。用户这时候最想知道的是"你到底拿哪些资料在回答我"。如果召回了无关文档,用户可以立刻中断、换个问法重来,而不是等 8 秒后看到个跑题的答案再骂街。
  2. 第 1.2~9 秒:大模型在逐 token 生成。打字机效果不仅是为了"好看",它让用户提前判断答案方向——读到一半发现跑题,直接停掉,省 token 省时间。

所以流式不是为了炫技,是为了把这两段信号在它们产生的瞬间交给用户。

二、两段流式:检索阶段 + 生成阶段

检索阶段流式:先告诉用户"我在查这些资料"

检索完成的第一时间,把召回的文档列表(标题、片段摘要、来源链接)推给前端。这有两个价值:一是方向确认,二是建立信任。用户看到答案来源,才会信这个答案。

在协议上,检索阶段我用一个独立的事件类型,和生成阶段的 delta 分开:

text
event: sources
data: {"items":[{"id":"d1","title":"Next.js 路由文档","snippet":"...","url":"..."}]}

event: delta
data: {"text":"根据文档,"}

event: citation
data: {"marker":1,"sourceId":"d1"}

event: done
data: {}

把 sources 和 delta 分开,是因为它们对应 UI 上完全不同的两块区域:来源列表(顶部/侧栏)和答案正文(主区)。混在一起前端要靠字段判断分流,分开则是一个 event 驱动一个 state 切片,干净。

生成阶段流式:打字机正文

这就是经典 SSE 逐 token 推送,前端拼接。但 RAG 的生成阶段比纯 Chat 多一个东西:引用标记。模型在生成时会吐出 [1][2] 这样的标记,你得把它们和来源列表关联起来。这个放到第四节细讲。

三、UI 状态机:检索中 → 已召回 → 生成中 → 完成

这是整个流式 UI 的骨架。强烈建议用一个明确的有限状态机,而不是一个 boolean isLoading。 原因很简单:RAG 有四个语义不同的阶段,isLoading 表达不了"已经查到资料、正在生成答案"这个中间态,你的 UI 会到处写 isLoading && hasSources && !answer 这种难维护的条件。

tsx
type Source = { id: string; title: string; snippet: string; url?: string };

type RagState =
  | { phase: "idle" }
  | { phase: "searching"; query: string }
  | { phase: "retrieved"; query: string; sources: Source[] }
  | { phase: "generating"; query: string; sources: Source[]; text: string }
  | { phase: "done"; query: string; sources: Source[]; text: string }
  | { phase: "error"; query: string; message: string }
  | { phase: "aborted"; query: string };

注意几个设计决策:

  • searching 和 retrieved 我拆成了两个状态。searching 是"还在查",UI 显示骨架屏;retrieved 是"查到了、正准备生成",这时候来源已经能渲染了。中间这个极短的窗口,是给用户"看到了资料"的反馈点。
  • generating 里直接带 text,而不是单独维护一个 answer 状态。状态和它驱动的数据绑在一起,减少不一致。
  • 有 error 和 aborted 两个终态。aborted 是用户主动中断(显示"已停止"),error 是链路挂了(显示重试)。别混。

驱动这个状态机的是一个 SSE 消费 hook:

tsx
function useRagStream() {
  const [state, setState] = useState<RagState>({ phase: "idle" });
  const ctrlRef = useRef<AbortController | null>(null);

  const ask = useCallback(async (query: string) => {
    ctrlRef.current?.abort();
    const ctrl = new AbortController();
    ctrlRef.current = ctrl;
    setState({ phase: "searching", query });

    try {
      const res = await fetch("/api/rag", {
        method: "POST",
        body: JSON.stringify({ query }),
        signal: ctrl.signal,
        headers: { Accept: "text/event-stream" },
      });
      // 用 SSE 的 body 读流,这里用简化的 reader
      for await (const ev of readSse(res.body!)) {
        if (ctrl.signal.aborted) break;
        setState((prev) => reduce(prev, ev));  // 纯函数 reducer
      }
    } catch (e) {
      if ((e as Error).name === "AbortError") {
        setState((prev) => ({ ...prev, phase: "aborted" }));
      } else {
        setState({ phase: "error", query, message: "检索或生成失败" });
      }
    }
  }, []);

  const stop = useCallback(() => ctrlRef.current?.abort(), []);
  return { state, ask, stop };
}

reduce(prev, ev) 是一个纯函数,把 SSE 事件映射到状态迁移:sources 事件 → retrieved,第一个 delta → generating,done → done。把迁移逻辑放在一个纯函数里,好测试、好回放。

UI 层就变成纯粹的"根据 phase 渲染",非常清爽:

tsx
switch (state.phase) {
  case "searching":  return <SourceSkeleton />;
  case "retrieved":
  case "generating": return <><SourceList sources={state.sources} /><Typewriter text={state.text} /></>;
  case "done":       return <><SourceList sources={state.sources} /><Answer text={state.text} /></>;
}

四、引用渲染:答案和引用对齐

这是 RAG 流式 UI 最容易翻车的地方。要求看起来简单:答案正文里出现 [1][2],页面上要么有可点的角标,要么底部有对应的来源卡片。但流式场景下有三个坑:

  1. token 切割:模型吐 [1] 可能被拆成 [、1、] 三个 token,你不能假设一个 delta 里就有一个完整标记。
  2. 关联时机:来源卡片什么时候"亮起来"?我的建议是——不要在 token 一出现就亮,会闪得乱七八糟。等答案出完(done),统一解析全文里的 [n],一次性把命中的来源卡片高亮。
  3. 索引对齐:[1] 必须对应来源列表的第 1 个,顺序不能错。

解析用正则,流式期间边拼边算命中集合:

tsx
const CITE = /\[(\d+)\]/g;

function useCitations(text: string, sources: Source[]) {
  return useMemo(() => {
    const hits = new Set<number>();
    for (const m of text.matchAll(CITE)) hits.add(Number(m[1]));
    return sources.map((s, i) => ({ ...s, cited: hits.has(i + 1) }));
  }, [text, sources]);
}

正文渲染把 [1] 替换成可点的角标(<sup>)。关键是渲染时机分两段:流式中,角标先灰色显示(占位、不可点);done 之后,把命中的来源卡片高亮、角标变可点、点击滚动到对应卡片。这样视觉上就是"答案出完,引用同步亮起",干净。

tsx
function renderAnswer(text: string, done: boolean) {
  const parts = text.split(CITE);  // 配合全局正则需注意,实际用 split+capture
  return parts.map((p, i) =>
    i % 2 === 0 ? p : <sup key={i} aria-disabled={!done}>{`[${p}]`}</sup>
  );
}

五、React 增量渲染:流式 markdown 不卡

生成阶段最常见的性能问题:每来一个 token,就把整个 markdown 字符串塞进 react-markdown 全量 reparse + re-render。几百 token 之后开始掉帧,答案越长越卡。

几个实务处理:

  1. 节流渲染,不要一个 token 一刷。SSE 可能 20ms 推一次,但渲染合并到 50~80ms 一次(用 requestAnimationFrame 或一个简单的 buffer)。人眼根本看不出 30ms 的差别,但 React 重排开销省了一大半。
tsx
function useThrottledText(stream: string, ms = 60) {
  const [text, setText] = useState("");
  const buf = useRef(stream);
  buf.current = stream;
  useEffect(() => {
    const t = setInterval(() => setText(buf.current), ms);
    return () => clearInterval(t);
  }, [ms]);
  return text;
}
  1. markdown 解析缓存。流式过程中答案末尾一直在变,但前面已经稳定的部分不需要反复 parse。可以用 memo + 按"已稳定段 / 活跃段"分段渲染。一个讨巧做法:每输出一段(遇到换行或句号)就把这段冻结成一个 memoized 节点,后续 token 只 render 最后一段。

  2. startTransition 包低优先更新。流式文本更新用 startTransition(() => setText(...)),让 React 在紧张时能中断这次渲染、优先响应点击/滚动等交互,避免"答案在刷、我想点停止却点不动"。

  3. markdown 限流。某些特性(比如 KaTeX 公式、代码高亮)代价高,流式期间关掉,done 之后再全量启用一次。流式期只需要"可读",不需要"完美"。

六、工程:SSE 协议、中断、错误

中断是 RAG 流式的标配,不是可选。生成一个答案要花真金白银的 token,用户读到一半发现跑题,必须能停。前端实现很简单:AbortController,上面 hook 里的 stop() 就是。但后端要配合——收到客户端断开后,必须把上游 LLM 的流也关掉,否则 token 照烧。

ts
// Next.js Route Handler 简化示意
export async function POST(req: Request) {
  const ctrl = new AbortController();
  req.signal.addEventListener("abort", () => ctrl.abort());

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      const send = (ev: string, data: unknown) =>
        controller.enqueue(encoder.encode(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`));

      try {
        const sources = await retrieve(body.query);      // 检索
        send("sources", { items: sources });
        for await (const chunk of llm.stream({ ... }, { signal: ctrl.signal })) {
          send("delta", { text: chunk.text });           // 逐 token
        }
        send("done", {});
      } catch (e) {
        send("error", { message: "上游生成中断" });
      } finally {
        controller.close();
      }
    },
  });
  return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
}

几个容易漏的点:

  • 错误中途恢复:已经在 generating 阶段挂了,不要把已渲染的答案清掉。保留已生成的文本,在末尾标个"生成中断"的提示,给个"继续/重试"按钮。用户最烦的就是"打了一大半,网一抖,全没了"。
  • 超时兜底:SSE 连接长时间没 delta(比如 LLM 卡住),前端要有 watchdog,30s 无新 token 主动 abort 并提示。
  • 协议版本:SSE 事件名(event:)比裸 data: 好用得多。前端用 EventSource 解析或自己读流都行,但事件类型化能让 reducer 写成 switch,可读性上一个档次。

七、体验细节:骨架屏、进度提示、滚动跟随

这些是"做好了没人夸、做差了被骂"的细节,但 RAG 这种长流程下特别重要:

  • 检索阶段骨架屏:别用转圈,用和来源列表同构的骨架(几个标题 + 几行文字的占位)。同构骨架让"出现真内容"时没有布局抖动,体感更顺。
  • 进度提示:searching 显示"正在检索 12 篇文档…",retrieved 显示"已找到 5 条相关来源,正在生成答案…"。具体到数字,比"loading…"可信十倍。
  • 滚动跟随:生成阶段答案在长出来,容器要自动滚到底。但有个经典坑——用户手动往上滚的时候,不要抢滚动控制权。判断 scrollHeight - scrollTop - clientHeight < 阈值 才自动跟随,否则尊重用户当前阅读位置。
tsx
useEffect(() => {
  const el = ref.current;
  if (!el) return;
  const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
  if (atBottom) el.scrollTop = el.scrollHeight;  // 只在贴近底部时跟随
}, [text]);

小结

  • RAG 是两段流,不是一个请求:检索阶段和生成阶段各自有该暴露的信号,别把它们捂在一个 loading 后面。
  • 用状态机不用 boolean:searching / retrieved / generating / done 四态区分清楚,UI 就是 phase 的纯函数。
  • 引用分两段亮:流式期角标占位、done 后统一解析 [n] 并高亮来源卡片,避免闪烁。
  • 增量渲染要节流:token 合并刷新、稳定段冻结、startTransition 降优先级,答案再长也不卡。
  • 中断和错误是标配:能停(AbortController + 后端级联 abort)、断了不清空已生成内容。

RAG 的体验,赢在"用户每一秒都能看到进展",输在"8 秒空白后砰一下"。流式不是装饰,是 RAG 应用的底层交互范式。

Related

相关推荐