想象你走进一家餐厅。普通餐厅的做法是:你点完菜,后厨把全部菜品做好,再由服务员一次性端上桌——你饿着肚子干等十来分钟,期间只能盯着墙上的钟发呆。而一家聪明的餐厅会安排一位“传菜员”:后厨每做好一道菜,他立刻端到你的桌上。你边吃边等,等待感几乎消失。 AI 对话的打字机效果,就是这位传菜员。大模型生成回答的过程本来是“一个词一个词往外蹦”的,传统接口却非要等它把整段话憋完才一次性返回,用户看到的只有漫长的空白转圈。而流式输出的做法是:模型每生成一小段,服务端立刻通过 SSE 通道推送给浏览器,浏览器再以打字机般的节奏逐字渲染出来。 本文要讲的,就是从“后厨”到“餐桌”的完整传菜链路:后端怎么把 token 一道菜一道菜端出来(流式接口)、选哪种传菜员(SSE 与 WebSocket)、前端的盘子怎么接(分词缓冲)、怎么摆盘(Markdown 流式解析)、以及铃铛与节奏(光标和音效)。读完你就能从零搭出一条 ChatGPT 风格的流式对话管线。
掌握打字机效果的全流程实现,你就能为任何 LLM 应用装上“秒回”的流式对话体验——这是 AI 产品从 Demo 走向可用的关键一环,也是全栈 AI 工程师的必备基本功。
目录
可视化演示
演示AI对话流式输出的打字机效果全流程:SSE流式传输、分词渲染策略、Markdown实时解析和光标动效,通过交互式动画展示从后端到前端的流式渲染机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
SSE流式传输 Server-Sent Events单向推送 text/event-stream持续连接 逐token实时推送到前端 SSE是AI流式输出的标准协议
流式输出的核心价值:先上菜再等齐
传菜员的价值到底是什么?不是让后厨变快,而是“重新分配等待”。客人其实不怕菜做得慢,怕的是干坐着、看不到任何进展。只要第一道菜上了桌,后面就算间隔稍长,客人也会觉得“这家店在认真给我做菜”。
流式输出做的事完全一样。传统 HTTP 接口在模型生成完整个回答后才返回,前端只能一直转圈。改用流式输出后,前端把“第一个字到达”的时间从几秒压缩到几百毫秒——这个指标就是首字时间(Time To First Token,简称 TTFT)。TTFT 越小,用户越觉得“它已经在回答了”。
为什么能做到?因为 LLM 的生成机制天然支持。Transformer 架构每次推理只预测下一个 token,生成 n 个字的回答需要 n 步推理。传统接口“憋完整段再吐”其实是把 n 步结果暂存在内存里;流式输出只是把这 n 步的结果随到随发,推理循环的每一步都直接写进响应流。
首字时间就是“第一道菜上桌的时间”。聪明的传菜员不等全桌齐,先端上一碟开胃菜——用户从“等待者”变成“品尝者”,体验的天壤之别就在这零点几秒里。
- T 请求发出用户点击“发送”按钮的瞬间,是整个计时链路的起点。
- T 第一个token送达前端通过 EventSource 或 fetch 流式读取收到第一个数据块的时刻。
- T 首字两者之差,也就是“第一道菜上桌的时间”。流式架构的目标就是把这个值压到几百毫秒以内。
传输协议选型:SSE 与 WebSocket 对决
传菜员分两种工种。一种是“单向传菜员”:只管把菜从后厨端到餐桌,路线固定、职责单一,后厨一按铃他就出发。另一种是“贴身管家”:全程守在客人身边,客人随时说话、他随时应答,还能随时跑后厨加菜。
对应到传输层,单向传菜员就是 SSE(Server-Sent Events,服务器推送事件),贴身管家就是 WebSocket。SSE 基于 HTTP,只有一条从服务端到客户端的单向通道,浏览器原生 EventSource 对象即可接收,断线还能自动重连。WebSocket 则需要先做一次升级握手,建立双向全双工的长连接。
对 AI 对话来说,用户发一次 prompt,之后主要是“只收不发”,SSE 的方向性恰好够用,还带来三个免费福利:基于 HTTP 可以复用鉴权、负载均衡、日志等中间件;配合 fetch 流式读取可以用 POST 携带任意请求体;断线重连由协议内置。WebSocket 的优势在于双向低延迟,适合协作白板、在线游戏这类多路高频互动场景。选型口诀:对话流式首选 SSE,双向互动才上 WebSocket。
单向传菜员虽然不能替客人传话,但正因为职责单一,他不需要额外的对讲系统(握手协议),迷路了还会自己按原路返回(自动重连)。对“一桌菜等着上”的 AI 对话,他就是最合适的人选。
- data 数据行以 data: 开头的一行文本,负载通常是 JSON 字符串,携带增量的内容。
- 空行两个连续的换行符,是事件的结束标志,浏览器据此知道一条消息完整到达。
- 整条消息数据行与空行拼接,构成一次完整的 SSE 事件,触发一次 onmessage 回调。
后端流式接口:后厨的传菜窗口
传菜通道再好,后厨没开窗口也是白搭。后厨的“传菜窗口”在后端就是生成器函数——一个用 yield 代替 return 的函数。
普通函数像“一桌菜全部做好端出”:return 只能返回一次、返回整个结果。生成器则是流水线:函数执行到 yield 就暂停并把当前值交出去,调用方处理完再继续跑下一步,直到全部产出耗尽。
在 FastAPI 中,把生成器对象交给 StreamingResponse,设置 media_type=“text/event-stream“,就得到标准的 SSE 接口。生成器内部循环调用模型推理(或调用大模型 SDK 的 stream 接口),每拿到一段增量就 yield 一次。这里有三个魔鬼细节:响应头要设置 Cache-Control: no-cache 防止浏览器缓存整个响应;如果部署在 Nginx 后面,还要设置 X-Accel-Buffering: no 关闭代理缓冲,否则代理层会把流”攒”到结束才放行,流式效果瞬间归零;客户端断开时,生成器的 finally 块负责关闭底层连接、释放显存等资源。
传菜窗口一旦打开,后厨和餐桌之间就建立了一条持续流淌的通道。但窗口的开合必须稳健:客人走了要落闸(finally 释放资源),中间有层隔断要提前打通(关闭代理缓冲)——这就是后端流式接口的全部秘密。
- generator一个含 yield 的生成器函数对象,每次被迭代就产出一段新的增量内容。
- StreamingResponseFastAPI 提供的流式响应类,负责逐块发送内容并保持连接不断开。
- media_type声明为 text/event-stream,前端收到响应后按 SSE 协议逐事件解析。
分词渲染策略:端菜不破相
传菜有一门基本功:刀工。豆腐不能端半块上去,肉丝不能拖一截在地上。前端接流式数据,最怕的也是“破相”——一个汉字被网络劈成两半。
为什么会被劈?网络分包是按字节切的,不关心字符语义。中文在 UTF-8 编码下每个字占 3 个字节,可能第一个数据包带走了前 2 个字节,第二个包才带来最后 1 个字节。如果前端拿到包就立刻渲染,页面上就会出现残缺的乱码字符。所以所有内容要先进入缓冲区(buffer),渲染前检查末尾字节是否构成完整字符。
完整的分词渲染策略是:接收层把 chunk 追加进 buffer;渲染层按“flush 条件”决定何时把 buffer 交给界面——比如攒够 3 个字符、遇到句号或换行等自然边界、或 50ms 定时器到期;中文场景下务必做整字边界检查(用 TextDecoder 的 stream: true 模式或手写 UTF-8 尾字节判断)。另外要控制渲染频率:不要每个字节都去碰 DOM,攒批渲染能大幅减少页面布局抖动,视觉上也更接近真人打字节奏。
刀工好了,客人才不会在菜里挑出半块豆腐。分词汇缓存与整字边界检查,就是前端打字机效果的“刀工”——看似不起眼,却决定了输出是否体面。
- buffer累积流式数据的内存队列,防止碎片化内容直接进入渲染层。
- 阈值攒到一定字符数再触发渲染,例如 3 个字符,避免一个字动一次。
- 末尾字符完整缓冲区末尾不是一个被截断的 UTF-8 序列,确保中文整字上屏。
流式格式解析:Markdown 实时摆盘
客人点的是一桌“摆盘定制”的菜:标题要有大小、代码要放在框里、列表要排整齐。传菜员不能把食材往桌上一倒了事——这就是 Markdown 流式解析的活。
传统渲染器(如 markdown-it)需要完整字符串才能工作。流式场景下内容一条一条到达,直接拿不完整文本渲染,会出现“标题标记还没闭合就闪成大号字”“代码块标记才写了一半就按普通段落显示”的乱象。解法是增量解析:维护一个解析状态,把已经解析过的部分缓存下来,新内容只触发局部更新,而不是全量重渲染。
轻量实现是维护几个状态变量:当前是否在代码块中、当前列表嵌套层级、当前标题层级。每收到一个 chunk,就按字符扫描并更新状态:碰到三反引号的行就切换代码块状态;代码块内的内容先以纯文本追加,直到遇到闭合的三反引号再一次性交给高亮渲染。这套状态机就是增量解析的“记忆”,它让 AI 输出的 Markdown 在“还没写完”时就已经摆好了大半的盘。
摆盘师傅心里始终记着“现在摆到哪个位置”:这块是围边(代码),先放着等齐了再摆;这块是主菜(正文),直接上桌。状态机就是这份“心里账”,有了它,流式 Markdown 才敢实时见人。
- state_n当前解析状态,例如“是否在代码块中”“列表嵌套几层”。
- chunk新到达的内容片段,是状态机唯一需要处理的新输入。
- f状态转移函数,扫描 chunk 更新状态,并产出这一小步的渲染增量。
光标音效实现:铃铛与节奏
客人边吃边等,怎么知道菜还没上完?有经验的餐厅会在桌上放一只小铃铛——铃铛在,菜就没完。打字机效果里的光标就是这只铃铛;它一停止闪烁,用户就知道“话说完了”。
光标实现思路很朴素:一条随内容长度移动的竖线,按固定周期亮灭。关键约束是“渲染节奏与闪烁节奏解耦”——文字渲染由数据到达驱动,光标闪烁由时间驱动,两者共用 requestAnimationFrame(rAF)回调,各自维护状态,互不拖累。
闪烁算法:记录当前时间 t,周期 p(如 530ms),亮灭状态 = 时间对周期取整后再对 2 取模,偶数亮、奇数灭。打字音效用 Web Audio API 合成:每次渲染新字符时触发一个极短的“嗒”声——创建振荡器,频率约 1800Hz,通过指数增益在 30ms 内快速衰减,避免刺耳。音效触发频率要与渲染节奏同步:攒批渲染时只在 flush 时响一声,而不是每个字节都响。
铃铛不紧不慢地响,客人才吃得安心。光标闪得稳、音效点得准,AI 的“思考中”就不再是一潭死水,而是一种有呼吸的陪伴感。
- t当前时间戳,由 rAF 回调每帧持续更新。
- p闪烁周期,决定光标的“呼吸感”,通常取 500ms 左右。
- mod 2对周期编号取奇偶,偶数为亮、奇数为灭,循环往复。
工程实践打通:有教养的等待
一道菜上桌不难,难的是整条链路经得起意外:后厨火灭了、传菜通道被堵了、客人中途改主意了。全流程打通要处理的就是这些“万一”。
四个工程要点缺一不可。其一,断线自动重连:SSE 的 EventSource 自带重连,但要控制节奏,用指数退避避免“断线风暴”。其二,断点续传:服务端给每条事件带编号,客户端保存最后处理的编号,重连时通过请求头告知服务端,从断点继续推送。其三,停止生成:用户点击“停止”后前端中断请求,服务端捕获取消异常,在 finally 中释放资源。其四,前后端联调验证:在真实代理链路上确认关闭缓冲的响应头已生效,观察首包到达时间是否在几百毫秒以内。
指数退避是控制重连节奏的标准策略:首次等 1 秒,失败后翻倍到 2 秒、4 秒、8 秒,封顶 30 秒。同时,生产环境要把 SSE 链路接入监控:记录“连接建立到首事件到达”的耗时、断线次数、重连成功率,一旦首事件耗时异常飙升,立刻告警。
客人随时可能离开,通道随时可能堵塞——有经验的餐厅靠的不是运气,而是“有教养的等待”:稍等再试、越等间隔越长、实在不行礼貌致歉。全流程打通的打字机效果,就是这套万无一失的传菜体系。
- d_0初始等待时间,通常取 1 秒。
- 2^(n-1)第 n 次重连的等待倍数,n 越大等待越长。
- d_max封顶上限,防止多次失败后等待时间过长,例如 30 秒。
AI 应用场景
智能客服对话界面
阶段:流式输出与前端渲染(应用工程层)
解决的问题:传统客服接口要等大模型生成完整答复才返回,用户盯着“正在输入”标识平均等待 6~8 秒,放弃率居高不下。使用 SSE 流式接口 + 前端打字机渲染后,首字时间压到 500ms 内,用户肉眼可见地“看到 AI 在打字”,等待焦虑大幅缓解,对话完成率显著提升。
具体案例:某 SaaS 客服平台将 LLM 回复接口从普通 POST 改为 FastAPI StreamingResponse 的 SSE 端点,前端用 EventSource 接收、按缓冲区逐字渲染。上线后,会话首字延迟从 6.8s 降至 0.4s,用户满意度评分提升 23%。
AI 代码助手实时补全
阶段:Markdown 流式解析与代码块渲染
解决的问题:代码补全场景中,模型先输出代码块起始标记再逐行补充代码,如果按普通文本实时渲染,代码会以正文样式一闪一闪地展示,用户无法阅读。采用“代码块延迟渲染”:检测到起始标记后进入代码块状态,内容按纯文本缓冲,闭合后再做语法高亮,一次成型。
具体案例:一款 IDE 插件接入流式补全时,对代码块采用状态机增量解析,光标跟随补全位置移动,配合 rAF 驱动的闪烁光标,形成“AI 正在给你敲代码”的沉浸体验。
实时语音转写字幕
阶段:分词汇缓存与打字音效
解决的问题:语音转写按词输出,同样面临中文字节边界问题——转写服务可能把“你好”拆成“你”和半个“好”。复用缓冲区加整字边界 flush 策略,字幕逐词上屏,配以轻量打字音效,观众能清晰感知“正在转写”的实时性。
具体案例:某会议系统将语音转写字幕从“整句渲染”改为“分词流式渲染”,延迟从约 2 秒降到 300ms,并用 Web Audio 合成 30ms 的短促嗒声作为字幕刷新提示音。
Python 演示
import jsonimport sysimport time ANSWER = "你好,流式输出就像传菜员,做好一道菜就端上一道,不用等全部做完." def llm_generate(): """模拟大模型逐token生成,每次产出2~3个字符的小块""" idx = 0 while idx < len(ANSWER): step = 2 if idx % 2 == 0 else 3 yield ANSWER[idx:idx + step] idx += step def sse_encode(gen): """把生成器包装成标准SSE事件流:data行加空行""" for delta in gen: payload = json.dumps({"delta": delta, "done": False}, ensure_ascii=False) yield "data: " + payload + "\n\n" done = json.dumps({"delta": "", "done": True}, ensure_ascii=False) yield "data: " + done + "\n\n" def typewriter_render(): """前端逐字渲染:流式内容先进缓冲区,显示层按打字节奏输出""" buffer = "" shown = 0 for event in sse_encode(llm_generate()): data = event[5:].strip() # 去掉data:前缀 payload = json.loads(data) if payload.get("done"): break buffer += payload["delta"] # 先入缓冲区 while shown < len(buffer): # 再逐字渲染 sys.stdout.write(buffer[shown]) sys.stdout.flush() shown += 1 time.sleep(0.03) # 打字机节奏 if __name__ == "__main__": typewriter_render() print()
def llm_generate():后厨的“炉子”:生成器函数,模拟大模型逐 token 产出内容。yield ANSWER[idx:idx + step]每执行到 yield 就交出一小块内容,相当于“做好一道菜端一道”。def sse_encode(gen):传菜员的托盘:把生成器包装成 SSE 协议格式。yield "data: " + payload + "\n\n"每条消息以“data 行 + 空行”结束,空行就是按铃。buffer += payload["delta"]前端先让内容进缓冲区,而不是直接渲染,防止字符被网络“腰斩”。while shown < len(buffer):缓冲区内容逐字吐出,形成打字机节奏。time.sleep(0.03)30ms 的字符间隔,模拟真人打字的韵律。
常见误区
误区“SSE 能做的 WebSocket 都能做,那把后端的接口全改成 WebSocket 不就行了?反正都是流式返回数据。”
正解两类协议方向性不同:SSE 是服务端到客户端的单向 HTTP 长连接,浏览器原生支持、自动重连;WebSocket 是双向全双工长连接,需要独立握手并自建心跳与重连。对“发一次请求、持续收内容”的 AI 对话,SSE 更轻量,还能复用 HTTP 的鉴权、代理和日志设施。强行上 WebSocket 只是把简单问题复杂化。
为何常见:教程常把两者并列介绍,初学者误以为区别只在“通道粗细”,忽略了方向性这一本质差异。
误区“后端吐一个字节我就渲染一个字节,这样最实时,为什么要多此一举搞缓冲区?”
正解网络分包不保证字符边界。一个汉字在 UTF-8 下占 3 字节,可能被拆到两个 chunk 里,直接渲染就会出现乱码。正确做法是:内容先进缓冲区,渲染前确认末尾字符完整,再按阈值或自然边界 flush。缓冲区不是延迟渲染,而是让渲染“正确且从容”。
为何常见:前端直觉是“数据到了就该显示”,容易忽略底层字节流与上层字符语义的粒度差异。
误区“流式 Markdown 没写完就渲染会闪跳,干脆等全部内容到齐再一次性渲染。”
正解等全部内容等于放弃流式体验。增量解析用状态机记住“当前解析到哪”,每来一个 chunk 只更新受影响的片段;代码块则在检测到起始标记后先按纯文本累积,等闭合标记再一次性高亮。实时与正确可以兼得。
为何常见:一次性渲染的思维惯性,没意识到“解析状态”可以被缓存并增量推进。
误区“打字机效果不就是 setInterval 每 30ms 往页面塞一个字符吗?简单又直接。”
正解setInterval 与浏览器绘制帧率不同步,容易丢帧、卡顿,而且在数据还没到时白白空转。标准做法是用 requestAnimationFrame 驱动渲染:每帧检查缓冲区是否有新内容,有则渲染,无则只更新光标。这样渲染节奏自然收敛到屏幕刷新率,既流畅又省电。
为何常见:setInterval 是入门动画教程里的“万金油”,很少有人意识到它与浏览器渲染管线是脱节的。
误区“后端用 StreamingResponse 返回了,前端直接收数据,中间不会有任何问题。”
正解真实链路里 Nginx 等反向代理默认会缓冲上游响应,把流“攒”到结束才放行。服务端必须设置 X-Accel-Buffering: no、Cache-Control: no-cache 等响应头,前端用 fetch + ReadableStream 逐块读取。开发环境通常没有代理层,验证流式效果没问题就以为万事大吉,到了生产才发现被“攒包”。
为何常见:本地开发没有代理层,没经过真实链路就上线,是流式输出最常见的翻车原因。
面试题
Q1. SSE 与 WebSocket 最核心的本质区别是什么?
- A.通道方向:SSE 单向而后者双向← 正确答案
- B.传输速度:后者必然快于前者
- C.底层协议:前者基于用户数据报
- D.浏览器兼容:后者无法原生使用
答案:A。SSE 是服务端到客户端的单向推送协议,WebSocket 是双向全双工连接,A 正确。B 的速度对比不绝对;C 两者都基于 TCP;D 浏览器原生支持 WebSocket。
Q2. 一条完整的 SSE 消息以什么作为结束标志?
- A.数据行以单个换行符结束
- B.消息以空行即双换行结束← 正确答案
- C.消息达到设定字节阈值结束
- D.客户端收到零字节数据结束
答案:B。SSE 协议规定每条消息以空行(两个连续换行符)结束,B 正确。单个换行只是行结束,与字节阈值和零字节无关。
Q3. 前端收到一个可能“腰斩”中文汉字的数据块,正确处理是?
- A.立即把半个字符渲染出去保实时
- B.舍弃末尾不完整字节渲染其余
- C.暂不渲染等后续字节凑齐再刷← 正确答案
- D.把缓冲区整体丢弃后重新接收
答案:C。中文在 UTF-8 下占 3 字节,网络分包可能把一个字拆到两个 chunk 里。正确做法是先进缓冲区,等整字字节凑齐再渲染。A 会产生乱码,B 会丢字,D 会丢内容。
Q4. requestAnimationFrame 相比 setInterval 驱动打字机动画的核心优势是?
- A.后台标签页中计时更加精确
- B.与浏览器绘制帧率保持同步← 正确答案
- C.可以替代所有定时器场景
- D.触发频率必然高于传统计时器
答案:B。rAF 在浏览器即将重绘时回调,与帧率天然同步,渲染更流畅。A 恰好相反,后台标签页 rAF 会暂停;C、D 都过于绝对。
Q5. 防止 Nginx 等代理层“囤积”流式响应,正确的工程方案是?
- A.设置关闭代理缓冲的响应头← 正确答案
- B.增大代理缓冲区的容量上限
- C.改用轮询替代持续流式连接
- D.关闭浏览器端的自动缓存
答案:A。设置 X-Accel-Buffering: no 可以明确要求代理关闭缓冲、逐块转发。B 治标不治本;C 失去流式意义;D 与代理层囤积无关。
Q6. 指数退避重连策略:初始 1 秒、上限 30 秒,第 4 次重连的等待时间是?
- A.4 秒后重试
- B.8 秒后重试← 正确答案
- C.16 秒后重试
- D.30 秒后重试
答案:B。delay = min(1 × 2^(n-1), 30),第 4 次即 n=4,1×8=8 秒,B 正确。4 秒是第 3 次,16 秒是第 5 次,30 秒是封顶值。
Q7. Markdown 流式输出遇到代码块起始标记后,更稳妥的做法是?
- A.立即高亮渲染已到代码内容
- B.等闭合标记后一次性渲染高亮← 正确答案
- C.将代码块内容从答案中滤除
- D.直接用拼接字符串替换渲染
答案:B。代码块内容未闭合时按普通文本渲染会造成大面积闪跳,实践上等闭合标记再一次性高亮渲染最稳妥。A 会产生错乱;C 丢失内容;D 存在安全与性能问题。
Q8. 提升打字机渲染性能的合理手段有哪些?
- A.用渲染帧调度合并每帧操作← 正确答案
- B.缓存已渲染节点进行复用← 正确答案
- C.每字节到达立即重绘全文
- D.控制刷新频率攒批渲染← 正确答案
答案:ABD。rAF 合并渲染、缓存 DOM 节点、攒批 flush 都是常见优化手段。C 每个字节都重绘全文会造成频繁布局抖动,是典型的反模式。
Q9. 后端流式接口在客户端中断时需要做哪些处理?
- A.用异常处理确保断开后清理资源← 正确答案
- B.客户端断开时主动清理连接← 正确答案
- C.重连时根据事件编号断点续传← 正确答案
- D.忽略异常让连接自然超时
答案:ABC。A、B、C 分别是资源释放、连接清理、断点续传的正确实践。D 忽略异常会让连接资源泄漏,严重影响服务稳定性。
Q10. 关于 SSE 流式传输的正确判断有哪些?
- A.纯对话流式输出场景应优选单向推送← 正确答案
- B.双向实时交互需求应选用双向长连接← 正确答案
- C.使用流式读取时无法携带请求正文
- D.跨域场景需要额外配置跨域放行头← 正确答案
答案:ABD。对话场景“只收不发”,SSE 单向推送足够且更轻量,A 正确;高频双向交互选 WebSocket,B 正确;SSE 跨域需要服务端配置 CORS 放行,D 正确。C 错误——使用 fetch + ReadableStream 流式读取时可以通过 POST 携带请求正文。