想象你在一家现炒现上的餐厅点了一桌菜。大厨(大模型)在厨房里一道一道地炒,每炒好一道就放到出菜口。传菜员(前端)绝不能等所有菜齐了再一次性端上桌——那样顾客早饿坏了。正确做法是:菜一出锅就立刻端到顾客面前,让顾客边吃边等。这就是“流式渲染”:大模型一个字一个字地“炒”出来,前端一个字一个字地“端”上去。如果传菜员跑得慢、托盘太沉、中途摔跤,顾客体验就会大打折扣。本文将围绕这位“传菜员”的四项核心技能展开:选对传输通道(SSE/WebSocket)、练好打字机手速(渲染节奏)、学会回应顾客喊停(中断处理)、以及摔跤后的爬起(断线重连),帮你把 AI 对话体验做得丝滑又可靠。

岗位关联

学完这篇,你将掌握 LLM 对话类应用前端流式输出的完整方案:从传输选型、打字机效果到渲染优化与断线恢复,核心思路可直接落入生产项目,让用户体验从“转圈等待”升级为“丝滑逐字”。

目录
  1. 可视化演示
  2. 传输方案选型
  3. SSE协议解析
  4. 打字机效果
  5. 渲染性能优化
  6. 中断处理
  7. 断线重连
  8. 工程实践
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示LLM应用中的前端流式渲染实的核心机制,通过基础/核心/应用/扩展四种模式展示关键概念与交互流程。

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

LLM应用中的前端流式渲染实:SSE与 SSE与WebSocket流式 打字机效果的字符级渲染原理 核心机制可视化 LLM应用中的前端流式渲染实整体架构与关键组件

传输方案选型

传菜员上岗的第一件事,是和厨房约定“怎么喊菜”。两种方案摆在面前:一条单向传送带(SSE),或一部双向对讲机(WebSocket)。传送带只能把菜从厨房送出来,但安装便宜、稳定,途中掉了还能自动补货;对讲机能让你随时喊“第 3 桌加杯水”,但两边都得有人一直值守。刚开业的餐厅,没必要先配一套对讲系统。

在 LLM 应用里,“传菜”就是模型生成的内容流。SSE(Server-Sent Events)是基于 HTTP 的单向流式协议,浏览器原生 EventSource 即可订阅,服务端每生成一段就推一段;WebSocket 则是基于 TCP 的全双工通道,需要自己处理握手、心跳和重连。选型关键看两个维度:消息是否主要是“服务端 → 客户端”,以及是否需要频繁双向交互。

纯粹的问答与生成场景——用户发一句、模型吐一串——SSE 足够且代码量约少一半;但像 AI 编程助手那样,用户在生成过程中会不断发新指令、或需要服务端推送多种事件(补全、报错、警告),WebSocket 更合适。早期可以只做 SSE,留好接口层,后续确有双向需求再升级。

所以回复越长的“大菜”,传送带相对“跑楼催菜”的优势越明显。绝大多数对话场景先选 SSE 这条传送带,是最省心的起点,等真有双向需求再上对讲机。

ΔT=T轮询−T流式≈(n−2)×τ\Delta T = T_{\text{轮询}} - T_{\text{流式}} \approx (n - 2) \times \tau
回复越长,流式相对“轮询取整包”的时延节省越明显。n 是数据块数,τ 是单次网络往返耗时。
  1. 轮询总耗时客户端每轮询一次要花一个网络往返 τ,轮询 n 次才拿到整份回复,总耗时是 n 倍的 τ。
    T轮询=n×τT_{\text{轮询}} = n \times \tau
  2. 流式总耗时流式只需建立连接时的一次往返,数据随后持续到达,量级约为 2 次往返。
    T流式≈2τT_{\text{流式}} \approx 2\tau
  3. 节省量两者相减,省掉了 n-2 次多余往返;n 越大,也就是回复越长,收益越高。
    ΔT=(n−2)×τ\Delta T = (n - 2) \times \tau

SSE协议解析

传送带并不是把菜随手一扔,每个托盘都贴了标签:data: 是菜名,event: 是菜品分类,id: 是出菜序号。托盘和托盘之间必须留一个空位(空行),传菜员才知道“这道菜到此为止”。标签写对了,后厨和前台才不至于鸡同鸭讲。

SSE 的报文就是一系列“标签 + 值”的文本行,MIME 类型是 text/event-stream。浏览器端用 new EventSource(url) 订阅,服务端每写一段 data:,前端就触发一次 message 事件。常用字段有四类:data 是载荷,通常放 JSON 字符串;event 是自定义事件名,配合 addEventListener 使用;id 是消息序号,重连时浏览器会自动把它放进 Last-Event-ID 请求头回传给服务端;retry 则告诉浏览器断线后隔多少毫秒重连。

多行 data 会被拼接成一个值后再触发事件;真正区分“一帧结束”的是紧跟其后的空行。这个设计让 SSE 同时解决了三件事:增量传输、事件分类、断点续传的前提准备。

有了出菜序号,传菜员摔跤后才知道该从第几道接着上;retry 字段则是告诉传送带“掉线后隔多久重启”。SSE 是浏览器把重连、续传都内置好的“傻瓜传送带”,前端开发者要做的只是贴好托盘标签。

SSE帧=event: e⏟事件名+data: J⏟载荷+id: i⏟序号+∅⏟空行\text{SSE帧} = \underbrace{\text{event: }e}_{\text{事件名}} + \underbrace{\text{data: }J}_{\text{载荷}} + \underbrace{\text{id: }i}_{\text{序号}} + \underbrace{\varnothing}_{\text{空行}}
一帧 SSE 数据 = 若干“标签 + 值”的行 + 一个空行终止符。id 字段是断线续传的基石。
  1. 事件名event: e 声明该帧触发的自定义事件名,缺省时浏览器派发默认的 message 事件。
    event: e\text{event: e}
  2. 载荷data: J 是实际内容,通常是 JSON 字符串;连续多行 data 会被浏览器拼接为一个值。
    data: J\text{data: J}
  3. 序号id: i 让浏览器记住最后一条的序号,断线重连时自动通过 Last-Event-ID 请求头回传。
    id: i\text{id: i}
  4. 帧结束空行是帧终止符,浏览器据此把前面的行组装成一整帧并触发事件。
    ∅:空行\varnothing : \text{空行}

打字机效果

菜不能等整桌齐了一次端,字也不能一口气全上屏——要有“打字”的节奏感。但传菜员真正的功夫不是跑得快,而是节奏管理:顾客眼前永远有东西在变化,而不是突然哗啦一下多出一大片。打字机效果的本质,是“展示进度”刻意落后于“接收进度”。

为什么刻意落后?两个原因:其一,逐字出现给用户“正在生成”的实感,缓解等待焦虑;其二,如果网络一下子涌来 200 个字,直接整段上屏会让视野跳动、用户视线失焦,而匀速推进让人眼有追踪目标。实现上维护两个计数器:已接收字符数 n(t) 和已展示字符数 f(t)。收到新 chunk 只更新缓冲,展示由渲染循环按固定速度推进。

这样拆开之后,“网络多快”和“显示多快”就解耦了:网络慢,展示等网络;网络快,展示按匀速走。打字机效果本质上是一个平滑滤波器,把网络的抖动变成屏幕上的稳定节拍。

一句话总结:服务端负责“菜做多快”,前端负责“桌上看起来多顺”。打字机效果是给用户视觉安装的平滑滤波器,它不改变内容,只改变内容出现的节奏。

display(t)=text[0:f(t)],f(t)=min⁡(n(t), v×t−t0Δt)\text{display}(t) = \text{text}\big[0 : f(t)\big],\quad f(t) = \min\Big(n(t),\, v \times \frac{t - t_0}{\Delta t}\Big)
当前展示的文本 = 从原文开头截取 f(t) 个字符;f(t) 取“已收到字数”和“匀速推进字数”中较小的那个。
  1. 展示区间display(t) 从完整文本开头截取 f(t) 个字符,实现“追加”而非每次重建整个文本。
    display(t)=text[0:f(t)]\text{display}(t) = \text{text}[0 : f(t)]
  2. 接收上限n(t) 是已收到的字符总数,展示进度绝不能超过接收进度。
    f(t)≤n(t)f(t) \le n(t)
  3. 匀速推进v 是每单位时间显示的字符数,让每个时间片前进固定量,形成均匀打字节奏。
    f(t)=v×t−t0Δtf(t) = v \times \frac{t - t_0}{\Delta t}
  4. 取最小值min 表示“网络慢则等网络,网络快则按匀速走”,两个约束同时生效。
    f(t)=min⁡(n(t), 匀速值)f(t) = \min\big(n(t),\, \text{匀速值}\big)

渲染性能优化

传菜员端着托盘,一次能带五六碟小菜;如果每碟都单独跑一趟,过道就会堵死。浏览器也一样——每写一次 DOM,都可能触发一次布局(layout)和一次重绘(paint)。把多个 token 攒成一帧再提交,就是把六碟小菜码到一个托盘上。

优化的总原则是“提交次数不超过帧数”。用 requestAnimationFrame 作为统一渲染闸门:无论网络一帧里推来多少块数据,渲染循环最多调度一次;该帧开头把攒下的缓冲一次性写入文本节点,而不是每来一块写一次。这样把几十次微小的 DOM 操作合并成一次,布局引擎只工作一遍。

三个实用技巧:第一,按行渲染——已经完整的一行固化进真实 Markdown 节点,末尾的“半截话”放进轻量悬浮节点,下一帧直接替换,避免对已渲染内容反复解析;第二,解析异步化——Markdown 解析放到 requestIdleCallback 或 Web Worker 里,防止阻塞主线程导致滚动卡顿;第三,内容超长时用分段容器或虚拟滚动,限制回流范围只发生在可见区域。

“能端一托盘就别跑单趟”——前端渲染的金律是:把每一帧都当成一次出菜窗口,窗口内合并所有要上的菜,一次端出去。这样既保证丝滑,又给手机省电。

帧预算=100060≈16.7ms,tJS+t布局+t绘制≤帧预算\text{帧预算} = \frac{1000}{60} \approx 16.7\text{ms},\quad t_{\text{JS}} + t_{\text{布局}} + t_{\text{绘制}} \le \text{帧预算}
60fps 下每帧只有约 16.7ms 的预算,JS 写入、布局、绘制三者之和必须塞进这个预算,否则就掉帧。
  1. 帧预算屏幕每秒刷新 60 次,每一帧留给前端工作的时间约 16.7ms,这是硬性上限。
    帧预算=100060≈16.7ms\text{帧预算} = \frac{1000}{60} \approx 16.7\text{ms}
  2. 渲染开销JS 写入、浏览器布局、绘制三部分耗时之和必须小于帧预算,任何一项超支都会造成卡顿。
    tJS+t布局+t绘制≤帧预算t_{\text{JS}} + t_{\text{布局}} + t_{\text{绘制}} \le \text{帧预算}
  3. 批量摊薄把 N 个字符按批量大小 B 提交,提交次数约为 N/B,批量越大单帧开销越小。
    提交次数≈NB\text{提交次数} \approx \frac{N}{B}

中断处理

顾客吃到一半摆了摆手:“够了,这些够了,别再炒了!”传菜员要做的不是把桌上菜收走,而是立刻通过对讲机喊停后厨——不然大厨还在那儿呼哧呼哧炒,炒好的菜全倒进垃圾桶。前端的中断处理也一样:停的是“生成”,不是“已经上桌的内容”。

前端用 AbortController 取消进行中的 fetch 或 EventSource 请求。调用 controller.abort() 后连接断开,底层请求被标记为中止,服务端感知后取消正在进行的模型推理,token 不再产生——这不仅是省流量,更是省昂贵的 GPU 算力。如果只是“前端不再显示了”但连接还挂着,服务端会把剩余 token 全部生成完再丢弃,成本白白流失。

完整的中断处理要覆盖四个场景:用户点击“停止生成”按钮、组件卸载(离开对话页)、超时无数据(兜底自动中断)、以及流正常结束后清理监听器。中断后应保留已渲染内容,把状态从“流式中”置为“已中断”,并清理定时器与事件监听,防止内存泄漏。

喊停是服务能力的一部分:一喊即停、菜不浪费、桌上的菜原样保留,才是成熟的传菜流程。在 LLM 应用里,“能优雅停止”和“能流畅输出”同样影响用户体验,后者决定口碑,前者决定成本。

S={空闲,连接中,流式中,已中断,已完成}S = \{\text{空闲}, \text{连接中}, \text{流式中}, \text{已中断}, \text{已完成}\}
流式中→abort()已中断\text{流式中} \xrightarrow{\text{abort()}} \text{已中断}
一次流式请求的生命周期可以用五个状态描述;用户喊停对应的就是“流式中 → 已中断”这次转移。
  1. 状态集合五个状态覆盖一次流式请求从发起、连接、接收、中断到完成的完整生命周期。
    S={空闲,连接中,流式中,已中断,已完成}S = \{\text{空闲}, \text{连接中}, \text{流式中}, \text{已中断}, \text{已完成}\}
  2. 中断触发用户点击停止或组件卸载时调用 abort(),驱动状态从流式中转移到已中断。
    流式中→abort()已中断\text{流式中} \xrightarrow{\text{abort()}} \text{已中断}
  3. 中断后处理清理定时器与监听器,保留已渲染内容,停留在已中断状态等待用户下一条指令。
    已中断→清理→停留\text{已中断} \rightarrow \text{清理} \rightarrow \text{停留}

断线重连

传菜员在过道里摔了一跤,托盘洒了。他爬起来不是回后厨重做一桌,而是先问:“刚才上到哪道菜了?”厨房的台账上记着编号——这就是 lastEventId。另外,重连不能像无头苍蝇一样每秒冲一次,每失败一次就多等一会儿,给厨房留出喘息空间。

使用原生 EventSource 时,断线重连的大部分逻辑浏览器已经内置:断线后自动重连,并把最后一次收到的消息 id 放进 Last-Event-ID 请求头;服务端读到这个头,从该序号之后继续推送即可。而 WebSocket 没有这些内置能力,需要自己实现三件事:心跳检测、指数退避、消息补偿。

心跳:客户端每隔一段时间发一个 ping,超过阈值没收到 pong 就判定连接已死,主动发起重连。指数退避:第 n 次重试的等待时间为 t₀×2ⁿ 毫秒,并设一个上限(如 30 秒),避免服务端恢复瞬间被重试请求打爆。消息补偿:为每条消息分配递增 id,重连后对比缺口,向服务端请求补发这几条。

好的重连策略像老练的传菜员:摔了一跤不慌,看台账、等两秒再出发、越失败越沉得住气。用户只看到偶尔闪现的“网络抖动中…”,下一秒内容又自然接上,仿佛什么都没发生过。

tretry(n)=min⁡(t0×2n, tmax⁡)t_{\text{retry}}(n) = \min(t_0 \times 2^{n},\, t_{\max})
续传点=max⁡{已成功渲染的消息 id}\text{续传点} = \max\{\text{已成功渲染的消息 id}\}
重试等待时间按 1s、2s、4s、8s 翻倍增长并封顶;重连后从“已渲染的最大 id”之后续传,不重不漏。
  1. 退避序列第 n 次重试等待 t₀×2ⁿ,例如初始 1s,之后 2s、4s、8s 指数增长。
    tretry(n)=t0×2nt_{\text{retry}}(n) = t_0 \times 2^{n}
  2. 封顶上限min 与 t_max 保证等待时间不会无限膨胀,避免用户在极端网络下等太久。
    min⁡(t0×2n, tmax⁡)\min\big(t_0 \times 2^{n},\, t_{\max}\big)
  3. 续传定位重连后从已成功渲染的最大 id 之后继续接收,防止重复渲染和内容缺口。
    续传点=max⁡{已成功渲染的 id}\text{续传点} = \max\{\text{已成功渲染的 id}\}

工程实践

一家餐厅要长久经营,光有反应快的传菜员不够,还要有台账、质检和应急预案。前端流式渲染上线前,要定义好“多快算快、多卡算卡”,把体验变成可量化的指标,再配上监控和降级方案。

三个核心指标:TTFB(首字节时间)、TTFT(首字上屏时间)、卡顿率(长时间低于 50fps 的帧占比)。LLM 应用还会额外关注断线重连次数——重连过多说明传输链路不稳定。把这些指标按业务权重聚合成一个“体验分”,作为每次迭代的北极星:改版前后对比体验分,就知道优化是前进还是倒退。

工程落地还要注意四件事:其一,流式 Markdown 渲染前必须做内容消毒(sanitize),LLM 输出可能包含恶意 HTML,直接 innerHTML 会引入 XSS;其二,超大回复(上万字)用按段懒渲染或虚拟滚动,防止内存膨胀;其三,高并发对话场景前端要做重试退避,后端要做限流排队;其四,上线前压测不同长度回复的 TTFT 分布并设置告警阈值,超过阈值立即通知值班。

台账立起来了,餐厅才知道哪个环节慢、哪个环节容易摔;体验分就是 LLM 应用的“顾客满意度调查表”,让每一轮优化都有据可依,而不是靠感觉拍脑袋。

TTFT=T首个字符上屏−T请求发出\text{TTFT} = T_{\text{首个字符上屏}} - T_{\text{请求发出}}
体验分=w1×TTFT+w2×卡顿率+w3×断线率\text{体验分} = w_1 \times \text{TTFT} + w_2 \times \text{卡顿率} + w_3 \times \text{断线率}
TTFT 是流式体验的“起跑线”指标;体验分把首字速度、卡顿、断线三项加权合成一个数字,方便持续追踪。
  1. TTFT 定义从用户点击发送到第一个字符出现在屏幕上的时间差,是流式体验最重要的起步指标。
    TTFT=T首字−T发出\text{TTFT} = T_{\text{首字}} - T_{\text{发出}}
  2. 卡顿率统计渲染帧中低于 50fps 的占比,衡量长文本持续滚动时是否流畅。
    卡顿率=掉帧数总帧数\text{卡顿率} = \frac{\text{掉帧数}}{\text{总帧数}}
  3. 体验分合成三个指标按权重 w 加权,追求首屏体验就调大 w₁,追求稳定则调大 w₃。
    体验分=w1×TTFT+w2×卡顿率+w3×断线率\text{体验分} = w_1 \times \text{TTFT} + w_2 \times \text{卡顿率} + w_3 \times \text{断线率}

AI 应用场景

智能客服对话窗口

阶段:前端交互层 / 流式展示

解决的问题:传统客服是“等完整回答后一次性返回”,用户平均要盯着空白 3-5 秒,极易误以为系统卡死、反复重发。改用 SSE 流式后,首字几百毫秒内上屏,配合打字机效果与“停止生成”按钮,用户感知响应速度提升一个量级。

具体案例:某银行的 LLM 客服系统,用户发起“帮我看看理财产品赎回规则”,服务端模型流式生成回复,前端通过 EventSource 接收、逐字渲染;用户随时点击“停止生成”打断回答并追加追问,前端用 AbortController 取消当前请求后发起新请求,全程无页面刷新。

AI 编程助手

阶段:双向交互 / 代码流式补全

解决的问题:代码补全需要“边生成边给建议,用户随时打断并继续输入”,单向 SSE 难以满足这种高频双向交互。WebSocket 全双工通道支持用户在生成过程中不断发送新的编辑事件,服务端也能推送补全、报错、警告等多种消息类型。

具体案例:Copilot 式 IDE 插件中,用户输入半行代码,服务端通过 WebSocket 推送代码增量,前端按行流式渲染并做语法高亮;用户按 Esc 中断补全后继续输入,新请求复用同一连接,无重新握手延迟。

长文档写作助手

阶段:长任务 / 断线续传

解决的问题:生成 2000 字以上长文时网络抖动几乎不可避免,重连后若从头生成,用户会看到内容跳动且算力白白浪费。利用 lastEventId 续传机制,重连后只补收缺失片段,把“断线重来”变成“断点续写”。

具体案例:某内容平台的 AI 写作助手在生成万字长文时,前端每 30 秒记录一次“已渲染最大 id”;断线后自动重连并携带 Last-Event-ID,服务端从该序号后续推。若连续重连失败超过 5 次,界面出现“从断点继续生成”按钮,点击后从上次断点恢复。

Python 演示

python57 行
import jsonimport time  class MockLLM:    """模拟大模型逐字生成回复"""     def __init__(self, text, delay=0.02):        self._text = text        self._delay = delay        self._count = 0     def stream(self):        for ch in self._text:            time.sleep(self._delay)            self._count += 1            yield ch     @property    def count(self):        return self._count  def sse_frame(event, data, msg_id):    """把一段数据包装成 SSE 文本帧"""    payload = json.dumps(data, ensure_ascii=False)    return f"id: {msg_id}\nevent: {event}\ndata: {payload}\n\n"  def batch_tokens(source, size=6):    """攒够 size 个字符再交给渲染层,模拟前端批量提交"""    buf = []    for token in source.stream():        buf.append(token)        if len(buf) >= size:            yield "".join(buf)            buf = []    if buf:        yield "".join(buf)  def main():    answer = "你好,我是AI助手,这是我用流式方式写下的回答。虽然篇幅不长,但确实一字一句生成的。"    llm = MockLLM(answer, delay=0.02)    rendered = 0    for i, chunk in enumerate(batch_tokens(llm)):        print(sse_frame("message", {"delta": chunk}, i + 1), end="")        print(f"[渲染] 收到批次 {i + 1}: {chunk}", flush=True)        rendered += len(chunk)        if rendered >= 20:            print("\n[用户] 停止生成!")            break    print(f"[完成] LLM 生成 {llm.count} 字,前端渲染 {rendered} 字")  if __name__ == "__main__":    main()
目的:完整模拟一次 LLM 流式对话的链路——大模型逐字吐词、包装成 SSE 帧、前端攒批渲染、用户中途喊停。
  1. class MockLLM模拟大模型:stream() 是生成器,每 yield 一个字,delay 控制生成速度
  2. def sse_frame(event, data, msg_id)把数据包装成 id/event/data + 空行 的标准 SSE 帧
  3. def batch_tokens(source, size=6)攒够 6 个字才交出去,对应正文讲的“一帧一托盘”批量提交
  4. print(sse_frame(...), end="")打印完整的 SSE 帧文本,空行就是帧结束标记
  5. if rendered >= 20: break模拟用户点击“停止生成”,中断后 LLM 的生成计数随之停住

常见误区

误区SSE 是旧技术,WebSocket 才高级。反正都要做流式,直接上 WebSocket,以后要双向也不用重构,一步到位。

正解SSE 和 WebSocket 不是上位替代关系。SSE 基于 HTTP,浏览器原生支持自动重连和事件分发,实现量可能只有 WebSocket 的一半;WebSocket 的全双工能力在大部分 LLM 对话场景根本用不上,却要自己补心跳、重连、断线补偿。选型标准应是“消息是否主要是服务端到客户端”,是则 SSE 更合适。

为何常见:很多人把“双向”默认当成“更强”,忽略了 SSE 在单向流式场景下的极简优势,也高估了自己对双向消息的真实需求。

误区最简单的打字机就是 setInterval,每秒追加 20 个字符,效果一模一样,还不用学什么 requestAnimationFrame。

正解setInterval 与屏幕刷新不同步——显示器 60Hz 刷新,而 setInterval 的固定间隔可能落在任意相位,导致跳字、重复帧;后台标签页还会继续消耗 CPU。rAF 按浏览器渲染帧调度、页面不可见自动暂停,配合缓冲批量提交,才能真正做到“一帧一次渲染”。

为何常见:setInterval 实现直观、几行代码就能跑,问题要在低端设备和长文本场景下才暴露;不少入门教程也只教了 setInterval 的最简写法。

误区反正 Markdown 解析需要完整文本,收完再 render 一次不就行了?中间先显示纯文本也无所谓。

正解如果等收完再渲染,用户会在几秒甚至十几秒里只看到纯文本或空白,首屏体验直接崩坏;而且最终一次性解析长文本会阻塞主线程一两百毫秒,用户滚动时尤其明显。正确做法是按行增量渲染:已完整行转成静态 DOM 节点,末尾半行用轻量容器,解析放到空闲回调里做。

为何常见:早期实现常把“解析完整”当成前置条件,忽略了 LLM 回复是持续产生的增量数据;加上“先能跑再优化”的心态,问题被带到线上才暴露。

误区用户点停止,我直接关掉 EventSource 或 WebSocket 不就行了?服务端自己会停的,不用专门发什么取消消息。

正解前端断开连接后,HTTP/WS 层可能需要几秒才能感知;模型推理会继续跑,产生大量无用的 token,既费钱又占 GPU。正确做法是用 AbortController 传递 abort 信号,让底层请求真正被中止,服务端收到后取消推理;WebSocket 场景则需要发送一条“取消生成”的协议消息。

为何常见:单向断开在浏览器层面“看起来成功了”,但“连接断掉”和“服务端停止计算”是两回事,这个成本差异要看到服务端账单才深刻。

面试题

Q1. 关于 SSE 与 WebSocket 的对比,下列哪项说法正确?

单选题 · easy

  • A.SSE 支持双向通信,能向服务端主动发送请求
  • B.WebSocket 基于 HTTP 协议,原生支持自动断线重连
  • C.SSE 是服务端到客户端的单向文本流,浏览器可自动重连← 正确答案
  • D.WebSocket 只能用于服务端向客户端单向推送消息

答案:C。SSE(Server-Sent Events)本质是 HTTP 上的单向文本流,浏览器 EventSource 内置自动重连;WebSocket 是全双工通道,但重连、心跳都需自己实现。A 说反了方向,B 说反了协议基础,D 说反了 WebSocket 的双向能力。

Q2. 使用原生 EventSource 接收 SSE 时,收到没有 event 字段的数据帧,默认触发哪个事件?

单选题 · easy

  • A.ondata
  • B.onmessage← 正确答案
  • C.onpush
  • D.onstream

答案:B。EventSource 默认把不带 event 字段的帧派发为 message 事件,对应 onmessage;自定义事件需用 addEventListener 监听。这是协议约定,不是 ondata/onpush 等自定义命名。

Q3. 用户点击“停止生成”按钮后,前端最合理的处理方式是?

单选题 · easy

  • A.直接关闭页面并清空所有已渲染内容
  • B.调用 AbortController.abort() 取消请求并保留已展示内容← 正确答案
  • C.断开 WebSocket,同时清空浏览器控制台日志
  • D.停止打字机动画,但继续接收剩余数据包

答案:B。abort() 会中断底层请求,让服务端感知并取消推理,同时已渲染的内容保留在界面上。A 把已写好的回答也删了;C 清控制台没有意义;D 只停动画不断连接,服务端仍在烧算力。

Q4. 关于 SSE 断线重连后的续传,正确做法是?

单选题 · mid

  • A.重连后从头请求,保证内容顺序一致
  • B.服务端按 Last-Event-ID 续传后续内容← 正确答案
  • C.重连前把已渲染内容存进 localStorage
  • D.SSE 自带无损重传,无需记录任何序号

答案:B。EventSource 断线重连时自动携带 Last-Event-ID 请求头,服务端据此从该序号之后继续推送。A 从头开始浪费前面已生成的 token;C 存 localStorage 解决不了服务端续传问题;D 的自带重连不保证无损,需要 id 字段配合。

Q5. 打字机效果中,推荐用 requestAnimationFrame 而非 setInterval 的原因有哪些?

多选题 · mid

  • A.标签页不可见时 rAF 自动降频省资源← 正确答案
  • B.rAF 与屏幕刷新同步,避免掉帧← 正确答案
  • C.rAF 能保证回调间隔严格 16.6 毫秒
  • D.setInterval 在后台仍持续消耗 CPU← 正确答案

答案:ABD。rAF 按浏览器渲染帧调度,页面不可见时自动暂停,与屏幕刷新同步所以不掉帧;setInterval 的固定间隔在后台标签页依然运行、持续耗电。C 错在 rAF 并不保证固定 16.6ms,刷新率更高或帧任务较重时间隔会变化。

Q6. 流式渲染中,把多个 token 攒成一帧再提交 DOM 的主要目的是?

单选题 · mid

  • A.减少 DOM 提交,降低布局重绘开销← 正确答案
  • B.让文本间隔一致,看起来更整齐
  • C.避免阻塞服务端生成新的文本
  • D.让网络请求能更快到达服务端

答案:A。每写一次 DOM 都可能触发布局与重绘,攒批后每帧只提交一次,把渲染成本摊进 60fps 的帧节奏里。B 误解了视觉节奏的来源;C、D 与前端 DOM 操作无关——网络数据早已收到,瓶颈在渲染层。

Q7. 流式 Markdown 渲染,下列哪种方案最合理?

单选题 · hard

  • A.收到完整文本后一次性调用解析器渲染
  • B.每帧对全部缓冲重新解析并整体替换节点
  • C.按行切分,完整行静态渲染,尾部用轻量容器← 正确答案
  • D.用 innerHTML 直接插入原始文本保证速度

答案:C。C 是业界推荐的增量渲染:完整行转成静态 DOM,尾部半行用轻量节点快速替换,解析开销不随累计文本膨胀。A 首屏延迟长;B 每次全量解析,文本越长越卡;D 有 XSS 风险,整段 innerHTML 还会频繁打断浏览器渲染流水线。

Q8. 自研 WebSocket 流式通道时,需要自己实现哪些 SSE 内置的能力?

多选题 · hard

  • A.心跳检测连接是否存活← 正确答案
  • B.指数退避避免重试风暴← 正确答案
  • C.按序号续传未达内容← 正确答案
  • D.断线时直接刷新页面

答案:ABC。WebSocket 只提供全双工通道,自动重连、按序续传、心跳存活检测都要自己实现;SSE 则把这些内置了。D 刷新页面是暴力手段,不仅丢上下文,还会让用户重复等待,不是工程方案。

Q9. 以下哪些手段能降低 LLM 应用的“首字上屏耗时”(TTFT)?

多选题 · mid

  • A.服务端边生成边推送,不等待整段回复← 正确答案
  • B.前端收到首个 chunk 立即提交上屏← 正确答案
  • C.浏览器取消同源限制直接访问内网服务
  • D.首字采用投机生成与快速返包策略← 正确答案

答案:ABD。TTFT 由“服务端何时产出首字”和“前端何时渲染首字”共同决定。A 开启流式是前提;B 让首字尽快上屏;D 通过投机解码让首字更快产出。C 与 TTFT 无关,而且取消同源限制是严重的安全错误。

Q10. 关于 SSE 帧格式,下列哪项描述正确?

单选题 · extreme

  • A.多个 data 行会拼接为一个 data 值,空行表示帧结束← 正确答案
  • B.每个 SSE 帧必须包含 event 字段才能被浏览器识别
  • C.data 字段只能包含纯文本,不能携带 JSON 字符串
  • D.id 字段用于标识事件类型,由浏览器自动生成序号

答案:A。按规范,同一帧内多个 data 行会被拼接为单个 data 值,两条记录之间用空行分隔,空行即帧结束符。B 的 event 字段可省略;C 的 data 完全可以是 JSON 文本;D 的 id 由服务端定义语义,浏览器只是原样记住并在重连时回传。