RAG 应用的流式 UI 实现
RAG 不是"一次返回"——检索和生成都该流式,让用户边等边看。本文讲清 RAG 流式 UI 的实现:检索阶段流式(先显示"找到的文档")、生成阶段流式(打字机正文)、引用渲染(答案出完同步亮引用)、React 的增量渲染不卡,以及"检索中→生成中"的状态切换。
本文要点
- 主题聚焦:RAG、Next.js
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
RAG 应用的流式 UI 实现
做过 RAG 应用的同学都知道,这玩意儿的延迟结构是"两段叠加":先检索(向量召回 + rerank + 可能还多路融合),再生成(大模型逐 token 吐字)。这两段各自都不便宜,叠在一起就更不便宜。
典型链路是 800ms2s 检索 + 315s 生成。如果你的 UI 是"点一下、转圈、砰一下出全部答案",用户在这 8 秒里盯着 loading,中间没有任何信息密度——这是体验灾难。
我的观点很直接:RAG 不是一次请求一次返回,它是两段信息流的拼接。检索阶段有"我查到了哪些资料"这个高价值信号,生成阶段有"答案正在形成"这个渐进信号。任何一段你不流式出去,都是在把用户当傻子晾着。这篇文章就把这两段流式、以及围绕它们的 UI 工程,一次讲透。
一、为什么 RAG 必须流式:两段延迟,两种信号
先建立体感。一个 RAG 请求的时间线长这样:
[0ms] 用户提问
│ ⬇ 检索阶段(用户什么都看不到)
[1200ms] 检索完成,拿到 5 个 chunk
│ ⬇ 生成阶段(如果全量返回,用户还是什么都看不到)
[9000ms] 大模型吐完最后一个 token,一次性渲染
如果做成一次性返回,用户在前 9 秒的每一个瞬间,屏幕上都只有一个 loading 转圈。这个 9 秒里有两个本该暴露给用户的信号被你吃掉了:
- 第 1.2 秒:检索结果已经出来了。用户这时候最想知道的是"你到底拿哪些资料在回答我"。如果召回了无关文档,用户可以立刻中断、换个问法重来,而不是等 8 秒后看到个跑题的答案再骂街。
- 第 1.2~9 秒:大模型在逐 token 生成。打字机效果不仅是为了"好看",它让用户提前判断答案方向——读到一半发现跑题,直接停掉,省 token 省时间。
所以流式不是为了炫技,是为了把这两段信号在它们产生的瞬间交给用户。
二、两段流式:检索阶段 + 生成阶段
检索阶段流式:先告诉用户"我在查这些资料"
检索完成的第一时间,把召回的文档列表(标题、片段摘要、来源链接)推给前端。这有两个价值:一是方向确认,二是建立信任。用户看到答案来源,才会信这个答案。
在协议上,检索阶段我用一个独立的事件类型,和生成阶段的 delta 分开:
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 这种难维护的条件。
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:
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 渲染",非常清爽:
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],页面上要么有可点的角标,要么底部有对应的来源卡片。但流式场景下有三个坑:
- token 切割:模型吐
[1]可能被拆成[、1、]三个 token,你不能假设一个 delta 里就有一个完整标记。 - 关联时机:来源卡片什么时候"亮起来"?我的建议是——不要在 token 一出现就亮,会闪得乱七八糟。等答案出完(
done),统一解析全文里的[n],一次性把命中的来源卡片高亮。 - 索引对齐:
[1]必须对应来源列表的第 1 个,顺序不能错。
解析用正则,流式期间边拼边算命中集合:
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 之后,把命中的来源卡片高亮、角标变可点、点击滚动到对应卡片。这样视觉上就是"答案出完,引用同步亮起",干净。
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 之后开始掉帧,答案越长越卡。
几个实务处理:
- 节流渲染,不要一个 token 一刷。SSE 可能 20ms 推一次,但渲染合并到 50~80ms 一次(用
requestAnimationFrame或一个简单的 buffer)。人眼根本看不出 30ms 的差别,但 React 重排开销省了一大半。
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;
}
-
markdown 解析缓存。流式过程中答案末尾一直在变,但前面已经稳定的部分不需要反复 parse。可以用
memo+ 按"已稳定段 / 活跃段"分段渲染。一个讨巧做法:每输出一段(遇到换行或句号)就把这段冻结成一个 memoized 节点,后续 token 只 render 最后一段。 -
startTransition包低优先更新。流式文本更新用startTransition(() => setText(...)),让 React 在紧张时能中断这次渲染、优先响应点击/滚动等交互,避免"答案在刷、我想点停止却点不动"。 -
markdown 限流。某些特性(比如 KaTeX 公式、代码高亮)代价高,流式期间关掉,
done之后再全量启用一次。流式期只需要"可读",不需要"完美"。
六、工程:SSE 协议、中断、错误
中断是 RAG 流式的标配,不是可选。生成一个答案要花真金白银的 token,用户读到一半发现跑题,必须能停。前端实现很简单:AbortController,上面 hook 里的 stop() 就是。但后端要配合——收到客户端断开后,必须把上游 LLM 的流也关掉,否则 token 照烧。
// 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 < 阈值才自动跟随,否则尊重用户当前阅读位置。
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