想象你走进一家新开的“机构图书馆特快代写服务”柜台:你报出一个问题,前台接待员(前端代码)先填一张查书工单,从书库里搬来一摞相关资料;后台的研究员(大模型)拿到资料当场写起报告,但不是一次性打印完,而是像传真机一样一段一段往外吐(流式生成);每吐出一页,前台就誊写成排版漂亮的版面(Markdown 渲染),在关键句子上标好 \[1\]\[2\] 角标(引用来源);你随时可以喊“停”(中断请求),也可以连着追问好几个问题(竞态处理)。 这套“图书馆特快代写”流水线,就是前端 RAG 架构。过去大家以为 RAG 只是后端的事:检索文档、拼 Prompt、调用大模型,前端随便放个文本框和输出区就行。但真正决定用户体验好坏的全在前端:首字出现快不快、文字是否逐字蹦出、Markdown 有没有排版、引用能不能点、中途停止是否真的停得下来。这篇文章就用“图书馆代写服务”这个比喻,带你从前端视角把 检索 API 调用 → 流式生成展示 → Markdown 实时渲染 → 引用来源展示 → 错误中断处理 → 性能优化 → 全链路体验设计这条流水线一次装好。

岗位关联

学会前端 RAG 全链路设计,你就能把“能用的 AI 页面”升级成“人人都爱用”的产品,这是 AI 应用前端工程师最值钱的核心竞争力。

目录
  1. 可视化演示
  2. 检索API调用
  3. 流式生成展示
  4. Markdown渲染
  5. 引用来源展示
  6. 错误中断处理
  7. 性能优化实践
  8. 全链路体验设计
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

本节配有交互动效(进入视口后自动加载,约 3KB)。

前端RAG全链路架构 Q: 什么是RAG?引用来源:[1]RAG论文 [2]向量数据库 [3]Prompt工程Q: 什么是RAG?Q: 什么是RAG?引用来源:[1]RAG论文 [2]向量数据库 [3]Prompt工程Q: 什么是RAG? Q: 什么是RAG?引用来源:[1]RAG论文 [2]向量数据库 [3]Prompt工程Q: 什么是RAG?Q: 什么是RAG?引用来源:[1]RAG论文 [2]向量数据库 [3]Prompt工程Q: 什么是RAG?

检索API调用

你走到图书馆代写柜台,问:“帮我找找前端 RAG 怎么做流式渲染?”前台不会自己冲进书架乱翻,而是先填一张查书工单:查什么主题、要几本书、限定哪个分类。这张工单放到前端,就是检索 API 的请求参数。工单写得好不好,直接决定研究员有没有好资料可用。

前端通常用 fetch 向后端检索接口发 POST 请求,请求体里包含三个核心参数:query(问题文本)、top_k(返回文档条数)、filters(限定某个知识库或文档集)。后端返回命中文档片段以及每条文档的召回相关度分数,前端再把这些片段塞给大模型做生成。参数设计的第一步就是把“用户的一句话”规范成后端约定的工单结构。

除了参数,体验问题大多出在两张工单同时到达:用户第 3 秒问“A“,第 5 秒追问”B“,第 6 秒 A 的检索结果先返回,把 B 的结果顶掉了。这就是竞态。解法是给每次请求一个自增序号,响应回来时只认最新序号——好比顾客手里只认最大的单号。同时还得做超时重试:连接 8 秒没响应,自动重试一次,让”取书失败”尽量少发生在用户面前。

检索相关度背后其实是一个相似度打分公式,前端通常用它来排序展示命中文档:

回到柜台场景:工单里“要几本书、限定哪个分类”对应 top_k 和 filter;而排序算法帮前台决定“这本放最上面”。前端把检索工单封装好,后面整条流水线才有了可靠的原料。

s(q,d)=q⋅d∥q∥⋅∥d∥\displaystyle s(q,d) = \frac{\mathbf{q} \cdot \mathbf{d}}{\|\mathbf{q}\| \cdot \|\mathbf{d}\|}
这个公式说的是:把用户问题和文档分别编码成向量,算它们夹角的余弦值,数值越接近 1 表示“越沾边”。
  1. 查询向量用户的提问被编码成一串数字,就像把“要什么主题的书”写进工单。
    q=[q1,q2,…,qn]\mathbf{q} = [q_1, q_2, \dots, q_n]
  2. 文档向量每份资料也会被编码成同样长度的数列,表示它“属于哪个主题区域”。
    d=[d1,d2,…,dn]\mathbf{d} = [d_1, d_2, \dots, d_n]
  3. 点积算相似分子把两组数字逐位相乘再累加,得到方向一致性。
    q⋅d=∑i=1nqi×di\mathbf{q} \cdot \mathbf{d} = \sum_{i=1}^{n} q_i \times d_i
  4. 归一化成余弦分母把向量长度差异抹平,让分数稳定在 0 到 1 之间,方便前端排序。
    s∈[0,1]s \in [0, 1]

流式生成展示

研究员不是把整份报告打印完才递给你,而是写到一段就“唰”地吐出一页,前台一边接一边往你们面前的公告板上贴——这种逐字蹦出的效果,就是流式生成的魅力,也是 AI 问答界面和普通文本框最大的区别。

要让文字逐段到达前端,常用的协议是 SSE(Server-Sent Events),一条由服务端单向推送的通道。前端先通过 fetch 拿到响应体 ReadableStream,再用 reader.read() 循环读取二进制分块,解码成文本。每次拿到一小段,就把这段“增量”追加到显示缓冲区的末尾。

增量追加看起来简单,但有个坑:如果每个 token 直接拼 HTML,界面会闪成 PPT;正确做法是先累积文本缓冲区,再交给渲染层统一处理。加上 requestAnimationFrame 把同一帧内的多个分块合并刷新,公告板才能“一秒贴好几次但不显得忙乱”。

流式累积的过程可以写成:

公告板上的内容就是这样从空白变成完整报告的。首 token 时间决定了用户觉得“快不快”,而后续的增量渲染决定了“顺不顺”——这两件事,前端都牢牢握在手里。

Tk=Tk−1⊕Δk\displaystyle T_k = T_{k-1} \oplus \Delta_k
公式的意思是:当前展示的文本 = 上一次的完整文本 + 新到达的一小段。这样前端永远只维护一个不断增长的缓冲区。
  1. 初始状态还没收到任何内容时,展示区是空字符串。
    T0=∅T_0 = \emptyset
  2. 增量分块网络每次到达的一小段文本记为 Δk,如同打印机吐出的单页。
    Δk=第 k 批到达的 token 串\Delta_k = \text{第 } k \text{ 批到达的 token 串}
  3. 追加合并新状态等于旧状态拼接上增量,这是打字机效果的核心。
    Tk=Tk−1⊕Δk\displaystyle T_k = T_{k-1} \oplus \Delta_k
  4. 收敛完整当 k 到达末尾 K,最终文本就是完整回答。
    TK=A\displaystyle T_K = \mathcal{A}

Markdown渲染

打印机吐出来的纸上全是记号:# 代表大标题、- 代表列表、`` 代表代码块、|` 代表表格。前台不能把这些记号原样贴出去,得先誊写成正式版面——这套誊写过程就是 Markdown 渲染。

前端常用的解析库有 markdown-it、marked 等,它们把 Markdown 源文本转成 HTML 标签;代码块交给 highlight.js 做语法高亮,数学公式交给 KaTeX 排版。难点在于流式场景:文本是不完整的,可能刚收到半个 ``` 或半个表格,渲染器直接解析会把界面闪成乱码。

业界常用做法是“渲染当前完整缓冲”:把未闭合的代码块临时补一个收尾符,或者先按普通文本展示,等语法块闭合后再升级为高亮代码块。另一个关键点是 XSS 防护——AI 生成内容或知识库里可能夹带恶意 HTML,渲染结果必须经过 sanitize(清洗)才能挂到页面上,否则等于把后门敞给用户。

从 Markdown 到 HTML 可以形式化为:

前台誊写时最怕“字没写完就急着排版”——所以前端要等一小段缓冲再渲染,同时对不完整的语法块“打圆场”。排版这件事,急不得,但也慢不得。

H=ϕ(M)\displaystyle \mathcal{H} = \phi(\mathcal{M})
这表示:最终界面 HTML(H)是解析器(φ)作用于 Markdown 源文本(M)的产物。
  1. 源文本AI 返回的 Markdown 是带格式记号的手稿。
    M=Markdown 源文本\mathcal{M} = \text{Markdown 源文本}
  2. 转换函数解析器按语法把记号翻译成 HTML 标签的嵌套树。
    ϕ:语法记号→HTML 标签树\phi: \text{语法记号} \to \text{HTML 标签树}
  3. 增量渲染流式场景下,把当前完整缓冲交给 φ,得到可挂载的界面片段。
    Hk=ϕ(Mk)\displaystyle \mathcal{H}_k = \phi(\mathcal{M}_k)
  4. 组合最终页多段结果依次合并,形成完整的聊天问答 DOM。
    H=H1⊕H2⊕⋯⊕HK\displaystyle \mathcal{H} = \mathcal{H}_1 \oplus \mathcal{H}_2 \oplus \dots \oplus \mathcal{H}_K

引用来源展示

特快代写服务的报告和普通回答最大的区别,就是每一句关键结论后面都跟着一个 \[1\]\[2\] 小角标,像报纸上“详见第三版”的小字。顾客点一下角标,就能跳到底部的参考文献架,看到书名、页码和摘要——这就是 RAG 最值钱的地方:有出处,才可信。

后端在生成时会把检索到的文档按顺序编号,在文本里插入 [n] 角标。前端拿到后要做的第一件事是解析角标:要么在流式追加时用正则在文本里匹配 [数字],要么后端直接给结构化字段。解析出来的角标需要构建一张“序号 → 来源条目”的映射表,才能支撑后面的点击。

映射表做好后,角标渲染成可点击的小按钮,点击就调 scrollIntoView({ behavior: 'smooth' }) 平滑滚动到对应的来源卡片;反过来,点击来源卡片也能高亮正文里引用它的所有角标——形成正文 ⇄ 来源双向联动。这样用户从“看到结论”到“核实出处”只需要一次点击。

序号与来源的映射可以写成一个函数:

顾客点角标、跳书架的体验,全靠这张映射表兜着。没有引用来源的 RAG 回答就像没有参考文献的论文,用户不敢信;有了双向联动,信任感立刻建立起来。

f(i)=sources[i]\displaystyle f(i) = \text{sources}[i]
意思是:给定角标序号 i,就能查表得到第 i 条来源的标题、链接和摘要。
  1. 标注号生成时模型在关键句后输出 [1]、[2] 等角标。
    i∈{1,2,…,n}i \in \{1, 2, \dots, n\}
  2. 映射数组后端返回的 citations 是按序号排好的来源列表。
    sources[i]=(titlei, urli, snippeti)\text{sources}[i] = ( \text{title}_i, \ \text{url}_i, \ \text{snippet}_i )
  3. 跳转定位点击角标后,前端用元素坐标把视图滚到对应来源卡片。
    pos=scrollIntoView(targeti)\displaystyle \text{pos} = \text{scrollIntoView}( \text{target}_i )
  4. 条目绑定前端把 [i] 渲染成可点击角标按钮,绑定映射表中对应条目。
    btni→sources[i]\displaystyle \text{btn}_i \to \text{sources}[i]

错误中断处理

顾客看到一半突然改主意:“别写了,我换一个问题。”前台必须立刻给研究室挥旗子,让打印机停下。这个“挥旗子”动作,在前端就是 AbortController——一个能对 fetch 发出中断信号的对象。

调用 controller.abort() 后,fetch 会抛出 AbortError,配合 try/catch/finally 就能在取消时清理界面状态:关掉打字机动画、丢弃还没处理完的残余分块、把按钮恢复成可发送状态。如果只做“前端不再显示新内容”而不传中断信号,后端还在白算白打,浪费算力,所以中断一定要从请求层真正切断。

除了主动中断,还要处理被动故障:网络闪断、服务端 5xx、连接超时。常见做法是设计一个状态机:idle → loading → streaming → done / error / aborted,每个状态对应一套 UI 文案和按钮;比如 15 秒没收到首个 token 就显示“生成超时,请重试”。

整个中断-恢复逻辑可以用一个状态转移描述:

前台接待员最重要的职业素养是“永远知道现在卡在哪一步、下一步怎么走”。状态机就是这种素养的代码化:无论用户喊停、断网还是超时,界面都不会变成一个死胡同。

state={aborted,signal.aborted=truerunning,otherwise\displaystyle \text{state} = \begin{cases} \text{aborted}, & \text{signal.aborted} = \text{true} \\ \text{running}, & \text{otherwise} \end{cases}
意思是:只要中断信号被触发,请求状态就从“进行中”切到“已中止”,界面随之做出反应。
  1. 状态变量用一个布尔信号标记这次请求是否已中断,相当于流程里挂一块“停止”牌。
    signal.aborted∈{true,false}\text{signal.aborted} \in \{\text{true}, \text{false}\}
  2. 触发中断用户点击停止,控制器发出中断事件,fetch 立刻抛 AbortError。
    controller.abort()  ⇒  fetch 拒绝\text{controller.abort}() \;\Rightarrow\; \text{fetch 拒绝}
  3. 状态转移UI 根据信号切到 aborted,清理打字机计时器并丢弃残余分块。
    state:streaming→aborted\text{state}: \text{streaming} \to \text{aborted}
  4. 兜底恢复异常路径也统一回到可重试的初始状态,保证界面永远有出口。
    state→idle  (可重试)\text{state} \to \text{idle} \;\text{(可重试)}

性能优化实践

公告板贴纸条,如果每来一个字就贴一次,顾客眼花,前台手酸。聪明的前台会“攒一攒”:每隔一小段时间才贴一次,一次贴一小撮字。这个“攒一攒”就是渲染节流(throttle)——固定时间窗口内最多渲染一次,把窗口内到达的多个 chunk 合并成一次 DOM 更新。

另一个常见手法是防抖(debounce):用户敲完字停住 300ms 后才发起检索请求,避免每敲一个字母就触发一张查书工单。两者的区别在于:节流是“按时放行”,防抖是“等安静了再放行”;节流适合高频文本刷新,防抖适合输入联想。

长聊天记录还可以用虚拟滚动只渲染可见区域;相同问题加缓存,命中缓存直接秒回上次结果;Markdown 解析结果以内容哈希为 key 缓存,重复文本直接复用 DOM,不再重新解析。在 React 里还能用 useDeferredValue 把低优先级渲染延后,保证输入框永远不卡。

节流的数学表达很简洁:

柜台贴纸条讲究“节奏感”,前端渲染也讲节奏。节流防抖不是炫技,而是让浏览器在“流畅”和“实时”之间找到那个刚刚好的平衡点。

y(t)={x(t),t−tlast≥τ∅,t−tlast<τ\displaystyle y(t) = \begin{cases} x(t), & t - t_{\text{last}} \ge \tau \\ \emptyset, & t - t_{\text{last}} < \tau \end{cases}
意思是:只有距离上次渲染超过时间窗 τ 的更新才被放行,其余 chunk 被合并等待,渲染频率因此被封顶。
  1. 时间差记录上次刷新的时刻,每次拿到新 chunk 先算时间差。
    Δt=t−tlast\Delta t = t - t_{\text{last}}
  2. 阈值判断只有超过时间窗 τ 才放行这次更新,否则把 chunk 丢进合并队列。
    如果 Δt≥τ, 放行; 否则合并等待\text{如果 } \Delta t \ge \tau, \ \text{放行; 否则合并等待}
  3. 更新时间戳放行后把当前时刻记为最新时刻,下批 chunk 以它为基准重新计时。
    tlast←tt_{\text{last}} \leftarrow t
  4. 效果渲染频率被封顶,主线程不再被高频文本刷新拖垮。
    fpsrender≤1/τ\text{fps}_{\text{render}} \le 1 / \tau

全链路体验设计

把前面所有柜台服务拼成一条传送带:顾客下单(查询)→ 自动查书(检索)→ 研究员写作(生成)→ 前台排版(渲染)→ 贴角标(引用)→ 顾客随时喊停(中断)。单看每一步都不难,难的是把它们装进一个可维护的生命周期里。

工程上通常把整条流水线封装成一个组合式 Hook 或服务:输入查询后自动执行“检索 → 流式生成 → 渲染 → 引用 → 可中断”的完整流程,对外暴露 data、loading、error、stop 等状态,界面组件只负责展示。这样业务代码不用关心细节,任何一步都能独立替换。

体验层面的加分项还包括:骨架屏(等待首 token 时显示占位行,缓解焦虑)、思考过程折叠展示(reasoning/thinking 内容可展开查看)、ErrorBoundary(渲染崩溃时兜底降级,而不是白屏)、以及端到端指标监控(首 token 时间、渲染字数/秒)。这套组合拳就是“前端 RAG 一体化方案”。

整条流水线可以用函数组合表达:

传送带最重要的不是某一段跑得快,而是节拍一致、衔接不丢。检索、生成、渲染、引用、中断、优化——当前端把这几件零件拧成一条流水线,用户感受到的就不再是一个个按钮,而是一种“要什么有什么”的顺滑体验。

O=(C∘R∘G∘S)(Q)\displaystyle \mathcal{O} = (C \circ R \circ G \circ S)(Q)
意思是:用户问题 Q 依次经过检索 S、生成 G、渲染 R、交互 C 四道工序,得到最终界面输出 O。
  1. 查询输入用户在输入框中的问题 Q 是整个流水线的起点。
    Q=用户问题Q = \text{用户问题}
  2. 检索映射S(Q) 从知识库里选出最相关的 Top-k 文档集。
    S(Q)={d1,d2,…,dk}S(Q) = \{d_1, d_2, \dots, d_k\}
  3. 生成流水G 把文档集传给大模型,产出标记着引用的 Markdown 数据流。
    G(⋅)=token 流G(\cdot) = \text{token 流}
  4. 渲染与交互组合函数从右到左执行,R 渲染 Markdown 为界面,C 处理角标点击、停止生成等交互。
    (R∘G)(Q)=R(G(Q))\displaystyle (R \circ G)(Q) = R(G(Q))

AI 应用场景

智能客服工单助手

阶段:检索API调用 + 引用来源展示

解决的问题:传统客服需要人工翻工单库才能回答用户问题,回复慢且没有依据。接入前端 RAG 后,用户提问先走检索接口从历史工单库取回相似案例,再流式生成回答,末尾以工单号作为引用角标,让用户自己也能点击核实。

具体案例:电商大促期间用户问“我的订单发票怎么开?”前端调用检索接口从知识库取回 3 条流程文档,流式展示回答,末尾 [1][2] 角标分别指向“发票申请流程”和“常见驳回原因”两条工单页,点击即可查看原文。

企业Wiki问答助手

阶段:Markdown渲染 + 引用来源展示

解决的问题:企业文档散落在 Wiki 各个角落,新员工很难精准搜索。RAG 助手把 Wiki 文章切片后做向量检索,AI 回答用 Markdown 渲染成带标题、列表、表格的规范界面,引用直接跳转到 Wiki 原文段落,核实时不再需要二次搜索。

具体案例:新同事问“项目发布前要过哪些检查?”回答渲染出一份带复选框的发布检查清单,引用角标直达 Wiki 中“发布检查表”章节,点入即为原文,信任感立刻建立。

AI代码生成面板

阶段:流式生成展示 + 性能优化实践

解决的问题:IDE 插件里生成代码需要逐行“打字机”输出,同时不能阻塞编辑器的输入。前端用流式读取加节流渲染,把代码块交给语法高亮器着色,底部展示引用的开源实现文件路径,点击即可跳转查看原文。

具体案例:开发者在插件面板里输入“写一个防抖函数”,界面逐行打印代码、关键字高亮,并附上引用的开源实现片段路径;点击引用卡片后,编辑器自动定位到对应文件的行号。

Python 演示

python60 行
import time DOCS = [    "RAG 的前端负责把用户问题发给后端检索接口.",    "流式生成采用 SSE 协议, 前端逐块读取 token.",    "Markdown 实时渲染需要先解析再增量挂载 DOM.",    "引用来源展示通过 [序号] 角标关联来源列表.",]  def simple_retrieve(query, docs, top_k=2):    """模拟检索接口: 按字符命中数给文档打分并返回序号."""    scores = []    for idx, doc in enumerate(docs):        hit = sum(1 for ch in query if ch in doc)        scores.append((hit, idx))    scores.sort(key=lambda x: x[0], reverse=True)    return [scores[i][1] for i in range(min(top_k, len(scores)))]  def stream_generate(query, docs, hit_idx):    """模拟大模型流式生成: 每次 yield 一小段文本."""    for j in hit_idx:        yield f"[{j + 1}] 相关资料: {docs[j]}\n"        time.sleep(0.05)    yield "\n---\n"    yield "> 结论: 前端 RAG 由检索, 流式生成, 渲染, 引用四段构成.\n"    yield "[1] 检索接口封装\n[2] SSE 读取\n"  def render_markdown(text):    """模拟 Markdown 渲染: 把文本转成简易 HTML 结构."""    html = []    for line in text.splitlines():        line = line.strip()        if not line:            continue        if line.startswith("---"):            html.append("<hr>")        elif line.startswith("> "):            html.append(f"<blockquote>{line[2:]}</blockquote>")        elif line.startswith("["):            html.append(f'<span class="cite">{line}</span>')        else:            html.append(f"<p>{line}</p>")    return "\n".join(html)  def main():    query = "前端RAG怎么做流式渲染"    idxs = simple_retrieve(query, DOCS, top_k=2)    buffer = ""    for chunk in stream_generate(query, DOCS, idxs):        buffer += chunk        print(render_markdown(buffer))        print("-" * 30)  if __name__ == "__main__":    main()
目的:用纯 Python 模拟前端 RAG 的“检索打分 → 流式产出 → 增量渲染”闭环,帮你理解每一步的数据流。
  1. def simple_retrieve(query, docs, top_k=2):定义“查书工单”函数:按字符命中率给文档打分。
  2. scores.sort(key=lambda x: x[0], reverse=True)把命中文档按相关度从高到低排序,模拟检索接口的返回顺序。
  3. def stream_generate(query, docs, hit_idx):模拟研究员“边写边吐稿”,每次 yield 一小段文本而不是一次性返回全文。
  4. time.sleep(0.05)人为制造网络分块时延,模拟真实 SSE 推送的“打字机节奏”。
  5. def render_markdown(text):前台排版员:把 Markdown 记号(---、>、[n])翻译成 HTML 片段。
  6. html.append(f'<span class="cite">{line}</span>')对 [1] 这类引用行做特殊包裹,对应前端“角标单独渲染”的逻辑。
  7. buffer += chunk增量追加缓冲区,对应公式 T_k = T_{k-1} + Δ_k。
  8. for chunk in stream_generate(query, DOCS, idxs):主循环:像 reader.read() 一样逐块消费生成流。
  9. print(render_markdown(buffer))每收到一块就重新渲染当前完整缓冲,对应前端的“全量渲染当前文本”。

常见误区

误区“后端把检索、生成都做好了,前端不就是接个接口把字符串显示出来吗?我直接等完整回答回来再 `innerHTML` 一塞,功能就完成了。”

正解前端是 RAG 体验的主战场。打字机流式输出、首 token 延迟、Markdown 排版、引用点击、中途停止、竞态防覆盖,全部由前端决定。就像图书馆代写服务里,书库和研究员再好,顾客看到的是前台怎么贴公告板、怎么标角标——服务体验的感知全在柜台这一侧。

为何常见:因为大模型 API 的示例大多只展示“发个请求拿完整结果”,很多人没接触过流式接口,就以为前端只是“显示字符串”。

误区“我每收到一个 chunk 就 `innerHTML += chunk`,文字立刻上屏,多简单!就是有时候界面会闪,还会报奇怪的错。”

正解每块直接拼 HTML 会引发大量 DOM 重排,还会把 AI 返回的 <script> 或 <img onerror> 当成真 HTML 执行,造成 XSS 漏洞。正确做法是:先累积纯文本缓冲区,再交给 Markdown 渲染器整体处理,渲染结果经过 sanitize 后才挂载,频率用节流控制。

为何常见:innerHTML 是“改界面最直接”的 API,初学者很容易被它的便捷误导,忽略了安全和性能代价。

误区“流式渲染又要拼 Buffer 又要处理半截 Markdown,太麻烦。我等后端全生成完,一次性显示完整答案,又能少写代码又不乱。”

正解等完整回答意味着用户盯着空白屏幕好几秒。流式输出的核心价值就是“首 token 秒回”,让用户立刻感觉系统在思考。前端可以用 requestAnimationFrame 合并分块来压缩渲染成本,骨架屏再兜底展示等待态,体验比“憋大招”好太多。

为何常见:一次性渲染在开发时最容易调试,人们本能地选择先跑通再优化,但 RAG 场景里“等待感”本身就是最大的体验杀手。

误区“我点了停止生成,界面不动了,这不就停了吗?后端还在跑有什么关系?反正我看不到。”

正解前端停止显示不代表请求结束。必须用 AbortController.abort() 真正中断 fetch,让网络连接关闭;有条件的话还要通知后端取消本次生成任务,否则算力持续消耗、费用持续累加。就像顾客喊停时,前台如果不给研究员挥旗,研究员还在埋头写,纸就白费了。

为何常见:“看不见就等于停止了”是直觉思维,但网络请求和后端推理是独立运行的两套系统,前端 UI 只是其中一端。

误区“反正渲染库够快,我每次拿到新 chunk 就把整个 markdown 字符串重新 parse 一遍再塞进 DOM,效果应该也还行吧?”

正解大模型回答动辄几千字,每个 chunk 全量重渲染会持续占用主线程,导致输入框卡顿、滚动掉帧。正确姿势是节流合并渲染帧,配合增量 diff 只更新尾部差异,代码块高亮也只在闭合后才做。渲染节奏跟不上流式节奏时,再快的库也会变成卡顿源。

为何常见:在小文本 demo 里全量重渲染确实看不出问题,但真实 RAG 回答长度和 chunk 频率都会放大性能问题。

面试题

Q1. 前端RAG中,调用后端检索接口最恰当的方式是?

单选题 · easy

  • A.fetch以POST请求传递参数← 正确答案
  • B.WebSocket双向长连接传输
  • C.img标签拼接GET参数触发
  • D.script标签执行JSONP回调

答案:A。检索接口通常用 POST + JSON body 传递 query、top_k、filter,语义清晰且能承载长文本。WebSocket 更适合全双工实时通信,检索是“请求-响应”模式,不必常驻连接;img/script 标签都是 hack 方案,不适用于带业务语义的 API 调用。

Q2. SSE协议相比WebSocket的最大特点是?

单选题 · easy

  • A.服务端单向推送流式文本← 正确答案
  • B.客户端服务端全双工通信
  • C.高效传输二进制大数据块
  • D.需要轮询才能获取新消息

答案:A。SSE 是服务端单向推送的通道,像打印机只负责“往柜台吐纸”,天然适合大模型流式输出。WebSocket 是双向的,二进制效率更高;D 说的是轮询,不是 SSE;C 描述的是 WebSocket 或二进制帧协议的特性。

Q3. 前端避免“旧回答覆盖新回答”的竞态方案有哪些?

多选题 · mid

  • A.请求序号自增并只渲染最新← 正确答案
  • B.每次新提问先abort旧请求← 正确答案
  • C.用队列顺序展示所有响应
  • D.新请求发送前锁住输入框

答案:AB。自增序号只认最新、新请求发起时 abort 旧请求,是两种主流竞态治理方案。C 排队展示会让旧响应依然出现,治标不治本;D 锁输入框只能防止连续输入,无法阻止已发出的旧请求返回。竞态的关键是“让旧响应失效”,而不是“让用户别操作”。

Q4. 打字机效果卡顿的首要原因是?

单选题 · mid

  • A.等全部内容返回才渲染界面← 正确答案
  • B.字体加载过慢导致首屏慢
  • C.后端每个token之间间隔久
  • D.用户网络带宽不足传输慢

答案:A。打字机体验的“假”往往不是网速或字体,而是前端等完整响应才一次性渲染——用户盯着空白页干等。RAG 流式的本质就是让首 token 尽快出现,边收边渲染。B/C/D 属于外部因素,不是“卡顿”的直接元凶。

Q5. Markdown实时渲染必须处理哪些问题?

多选题 · hard

  • A.对HTML输出执行XSS过滤← 正确答案
  • B.截断的分块加临时闭合符← 正确答案
  • C.渲染节流并缓存解析结果← 正确答案
  • D.每次都全量重建整个页面

答案:ABC。AI 生成内容可能夹带恶意脚本,必须 sanitize;流式中 Markdown 语法块常处于未闭合状态,要临时闭合兜底;高频分块需要节流和缓存。D 的“全量重建”恰恰是要避免的性能陷阱,正确做法是增量更新和局部 diff。

Q6. 点击引用角标跳转到来源,内部使用哪个API?

单选题 · mid

  • A.scrollIntoView配合平滑滚动← 正确答案
  • B.innerHTML直接替换整个内容
  • C.localStorage记录阅读位置点
  • D.WebWorker执行后台计算任务

答案:A。scrollIntoView({ behavior: 'smooth' }) 能把目标来源卡片平滑滚动进视口,是角标跳转的标准实现。B 是渲染内容的手段不是滚动定位;C 是持久化存储,与跳转无关;D 用于后台计算,不解决页面内定位问题。

Q7. 连续提问时,下面哪种方案能避免旧请求覆盖新内容?

单选题 · hard

  • A.每次请求带自增序号按时效渲染← 正确答案
  • B.把旧响应暂存用户手动选择看
  • C.用定时器把旧输出均匀延迟展示
  • D.关闭输入框等全部请求都返回

答案:A。自增序号按时效渲染是竞态处理的经典方案:响应回来时只认最新序号,旧响应直接丢弃。B 把选择负担丢给用户;C 用定时器打散时序会制造更大混乱;D 只是限制用户操作,已发出的旧请求依然可能覆盖新内容。

Q8. 前端RAG完整链路中,下列哪些属于前端职责?

多选题 · mid

  • A.组装检索请求并发起调用← 正确答案
  • B.逐块读取生成流维护缓冲← 正确答案
  • C.将Markdown渲染成可交互HTML← 正确答案
  • D.训练大模型参数并部署GPU服

答案:ABC。检索请求组装、流式读取与缓冲、Markdown 渲染都是前端 RAG 的核心职责。D 是后端/算法工程师的工作。前端负责的是“拿到检索结果之后展示、生成流读取、界面交互”这一整段体验链路。

Q9. 等待AI首个token期间展示占位界面,被称为?

单选题 · easy

  • A.骨架屏占位降低等待焦虑← 正确答案
  • B.虚拟滚动按需渲染长列表
  • C.IntersectionObserver懒加载
  • D.ServiceWorker离线缓存策略

答案:A。骨架屏(Skeleton Screen)是在内容加载前展示的灰色占位框,用“马上就有内容”的视觉暗示缓解等待焦虑。B、C、D 分别是列表优化、懒加载和离线缓存技术,与“首 token 等待占位”不是一个概念。

Q10. 关于前端中断RAG生成,下列说法正确的是?

多选题 · extreme

  • A.调用abort后fetch抛AbortError← 正确答案
  • B.前端还需清理计时器与状态机← 正确答案
  • C.残余网络分块需要过滤丢弃← 正确答案
  • D.服务端自动感知并停止计费

答案:ABC。abort() 会让 fetch promise 以 AbortError 拒绝,前端在 catch 中要清理计时器、状态机,并丢弃停止后残余到达的分块。D 是常见误解:前端中断连接后,服务端不会自动停止大模型推理,需要后端配合取消机制才能真正停止计费。