你在一家火锅店点餐。传统餐厅的做法是后厨把所有菜全部做好,再一次性端上桌——你盯着空桌子,从下单到第一口菜要忍受漫长的等待。而火锅店完全不同:毛肚先切好先上,虾滑随后跟上,你边涮边吃,第一盘菜30秒就到位,后面的菜还在后厨飞转。AI服务的实时交互,正经历着从“传统餐厅”到“火锅店”的转变。 支撑这个转变的核心技术,叫“分块传输编码”(Chunked Transfer Encoding)。它让HTTP响应不再是一整块“团餐”,而是一条“传菜流水线”:服务端每生成一小段数据,就立刻打包发出,客户端收到一段就用一段。大模型流式对话中你看到的一个个蹦出的文字、实时翻译里逐句浮现的译文,背后都是这条流水线在高速运转。 本文将带你从零理解分块传输编码,以及流式API设计、SSE/WebSocket配合、前端流式消费和工程实践。读完后,你将明白如何设计一套低延迟、体验丝滑的实时AI交互API。
一句话价值:掌握分块传输编码与流式API设计,是打造低延迟、高体验AI实时交互服务(如ChatGPT式流式对话)的必备技能,也是AI后端工程师与全栈开发者的核心加分项。
可视化演示
展示HTTP分块传输编码数据流、SSE与WebSocket双向通信对比、以及LLM Token级流式推送过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
分块传输流程 服务器将数据分割为chunk 每个chunk独立传输到客户端 无需预知Content-Length
流式响应原理
你参加公司团建,行政订了“团餐”——所有人到齐才能开饭,你饿了也得等。改用“火锅自助”后,食材一盘一盘上,你边吃边等,第一盘毛肚30秒就到位。HTTP协议的世界里也存在这两种模式。传统响应体像团餐:服务端必须知道Content-Length,把所有数据准备齐全,一次性发给客户端。而分块传输编码让服务端化身“火锅传菜员”,数据边生成边发送,客户端边接收边处理。
分块传输编码是HTTP/1.1协议(RFC 7230)定义的一种传输机制。当响应头中带有Transfer-Encoding: chunked时,响应体不再是一个整体,而是一个接一个的“分块”(chunk)。每个分块自带长度标记,服务端可以在不知道总长度的情况下开始发送,这正是AI模型流式输出的核心需求——模型生成第一个token时,谁也不知道最终会生成多少字。
分块的数据格式非常简洁:每个分块由“十六进制长度行 + 数据 + 回车换行”组成,最后以0\r\n\r\n作为终止块标识传输结束。客户端解析到0长度的分块,就知道数据全部到达。注意分块传输与Content-Length互斥——因为总长度未知,响应头中不会出现Content-Length。此外,分块编码也能用于流式压缩数据的传输,服务端可以先整体压缩再逐块发送。
回到火锅店的比喻:分块传输的每个分块就像一只小餐盘——盘子上贴着重量标签(size_hex),里面装着菜品(data),厨房做好一盘端一盘,最后上一盘写着“0“的空盘示意”菜上齐了”。你不需要等所有菜做好,第一盘毛肚下肚时,后面的虾滑还在锅里翻滚呢。
- size_hex每块数据的字节数以十六进制表示,如
4表示4字节、1f表示31字节,该行以\r\n结尾。 - data实际数据内容,紧随长度行之后,数据后跟
\r\n作为本块结尾。 - 终止块发送
0\r\n\r\n表示所有分块传输完毕,连接可复用或关闭。
流式API设计
快递公司设计分拣线时,最核心的问题是:攒够一整车再发,还是来一件发一件?前者成本低但慢,后者快但运力利用率低。AI服务设计流式API时,面临的正是同样的权衡:是等模型把完整答案生成了再返回,还是边生成边返回?
流式API设计的核心思路是“增量生成,边生成边返回”。以AI对话为例,模型每解码出一个token,服务端就写入响应流并立即flush(刷新到网络缓冲区),而不是等完整回答生成完毕。响应流在传输层通常依赖分块传输编码,在应用层则有三种主流形态:纯HTTP流式(如JSON Lines增量返回)、SSE(Server-Sent Events)、WebSocket。选型时主要看交互是单向推送还是双向对话。
衡量流式体验最关键的指标是TTFT(Time To First Token,首字延迟),即从客户端发出请求到收到第一个数据块的时间。设计时还要权衡分块大小:分块过小会导致网络包数量暴增、吞吐降低;分块过大会推高TTFT、体验变差。AI对话场景通常追求几字节到几十字节的小分块,让首字尽快闪现。
好的分拣线,第一件包裹永远最快出发。AI流式API同样应该让第一个token“抢跑”——用户看到第一个字的瞬间,就建立了“它在思考、它回应我了”的心理预期,后续内容即使有节奏波动也能被容忍。TTFT是整个流式体验的地基。
- T_first_token客户端收到服务端返回第一个数据块的时间点,是流式体验的“第一公里”。
- T_request_sent客户端发出HTTP请求的时间点,TTFT的起算基准。
- TTFT计算两者相减得到首字延迟。流式API设计的核心目标之一就是让TTFT尽可能小。
SSE协议配合
包裹发出后,物流系统给你发短信:“您的包裹已从杭州发出。”你只能接收通知,不能通过短信回复快递公司。SSE(Server-Sent Events)正是这种单向推送机制——服务端持续把事件推给客户端,客户端只负责接收和展示。
SSE是一种基于HTTP的应用层协议,响应Content-Type为text/event-stream。它天然兼容分块传输编码:服务端把每个事件作为一个数据块发送,浏览器通过EventSource API自动解析。相比WebSocket,SSE不需要复杂的握手和帧协议,还自带断线重连能力,正好契合AI流式对话“只读模型输出”的场景——用户通常只需要看AI说话,并不需要中途发指令。
SSE的消息格式非常简洁。一条消息由若干行以\n结尾的字段组成,以空行(\n\n)结束。常用字段包括:data(数据)、event(事件类型)、id(事件ID)、retry(重连间隔)。其中id配合Last-Event-ID请求头可以实现断点续传——客户端重连时带上最后收到的事件ID,服务端从该位置继续推送,对话不丢字。
物流短信通知虽然单向,但因为轻巧可靠,成为包裹追踪的标配。SSE之于AI流式对话,也是那个“轻巧可靠”的单向通道——不需要双向对话?用SSE准没错。
- event字段声明事件类型,前端
addEventListener('xxx')监听对应类型;省略时触发默认onmessage。 - data字段消息数据,一行一个data,多行会以换行符拼接为一条消息。
- id字段事件ID,浏览器重连时自动通过
Last-Event-ID头发给服务端,实现续传。 - 空行结束
\n\n表示一条事件的结束,浏览器收到后触发事件回调。
WebSocket对比
当快递员需要对你说“您家电梯在维修,我放丰巢了”,而你回复“好的放丰巢吧”时,单向短信不够用了。你需要一条专属客服专线,双方随时可以开口。WebSocket就是这个“专属专线”。
WebSocket是建立在TCP之上的全双工通信协议。客户端先发送HTTP Upgrade请求,服务端回复101 Switching Protocols后,连接升级为WebSocket通道,此后双方可以随时互发消息。与SSE的单向推送相比,WebSocket天然支持“用户打断AI生成”这类交互——客户端可以在AI滔滔不绝时发送一条“停一下”的消息。
WebSocket帧结构比SSE复杂。每个帧以一个或多个字节的头开始:FIN标记是否最后一帧,OPCODE标记帧类型(1文本、2二进制、8关闭),MASK标记客户端是否掩码,LEN记录负载长度。正是这些位域让WebSocket能高效承载双向、二进制、大消息等丰富场景,但也带来了比SSE更高的协议和运维复杂度。选择时遵循一个原则:服务端→客户端单向流用SSE,强双向交互(协同编辑、实时游戏、语音对讲)用WebSocket。
专属专线有专属专线的价格。WebSocket需要维护长连接状态、处理心跳和重连、配置反向代理的超时策略,运维成本明显高于HTTP流的SSE。在AI对话这个“服务端单向输出为主”的场景里,大多数团队最终选择SSE而不是WebSocket——就像你不会为每天一次的快递通知专门租一条专线。
- FIN1位,值为1表示当前帧是消息的最后一帧,消息可能被拆成多帧发送。
- OPCODE4位,帧类型标识,1为文本帧、2为二进制帧、8为关闭帧、9为ping。
- MASK1位,客户端发往服务端的帧必须置1并使用4字节掩码密钥,防止缓存投毒攻击。
- LEN负载长度,7位可表达0~125,更大时扩展为16位或64位。
前端流式消费
你是收件人,签收方式决定了你的体验。如果坚持“整车到齐再统一搬回家”,那和团购没区别;聪明的做法是“来一件拆一件”,让拆箱的快乐提前发生。前端消费流式API,关键就在这个“拆箱”姿势。
很多人以为用了fetch就自动有流式效果,实际上response.json()和response.text()都会等整个body到达后才解析,完全失去了流式意义。真正流式的姿势是:拿到response.body(一个ReadableStream),调用getReader()获得读取器,然后通过for await...of循环逐块读取数据。每读到一块,就立即解析并渲染到页面。
对于SSE流,可以更简单——原生EventSource对象自动解析text/event-stream,onmessage回调逐条处理事件。但EventSource不支持自定义请求头,需要携带认证信息时,得用fetch + ReadableStream手动解析。注意流式读取是“推”模式,消费者处理速度跟不上时会产生背压,可以通过reader.cancel()主动取消订阅,停止后续数据的到达。
正确的“拆箱姿势”让流式体验真正落地。当AI模型的每个token到达时,页面上的文字像打字机一样逐字浮现,用户感受到的不是“等待一个答案”,而是“观看一个思考过程”。这就是分块传输编码在前端的价值兑现时刻。
- chunk_iReadableStream读取到的第i个数据块,可能是几字节到几KB的文本片段。
- len(chunk_i)当前块的数据长度,逐块累加得到已接收总量。
- total完整响应的总长度,可从协议元数据或内容特征预估,用于计算进度百分比。
工程实践要点
快递物流中心里,每辆车有GPS心跳信号、分拣线有缓冲区和限流阀、派送失败有“过会儿再送”的智能重试。一个生产级的流式API服务,同样需要这三件套:心跳保活、背压控制、退避重试。
超时与心跳:流式长连接可能持续几十秒甚至几分钟,中间设备的空闲超时会悄悄断开连接。服务端应定期发送心跳包(SSE注释行": ping\n\n"或WebSocket的ping帧)维持连接活跃,客户端也应设置合理的超时——不是总超时,而是“相邻两个块之间的间隔超时”,超过即判定连接异常。背压控制:模型推理速度可能超过客户端消费速度,服务端需要缓冲区削峰填谷,防止内存被积压数据撑爆。
错误重试是流式服务的保命符。网络抖动导致连接中断时,客户端应自动重连。重试策略推荐指数退避(Exponential Backoff)+ 随机抖动(Jitter):每次重试的等待时间随尝试次数指数增长,并叠加一个随机量,避免大量客户端在同一时刻集体重连造成“重试风暴”(Thundering Herd)。
有了心跳、背压和退避重试,这条“快递分拣流水线”才能昼夜不息地运转。AI流式API的工程地基,就藏在这些看似琐碎的细节里——它们决定了你的服务在真实网络环境下,是丝滑还是翻车。
- base初始退避时间,如0.5秒,第一次重试等待约base时间。
- attempt当前重试次数,从0开始,每失败一次尝试次数加1。
- cap退避时间上限,如30秒,防止等待时间无限增长。
- jitter随机抖动值,取
[0, base)间的随机数,让重试时间错开。
AI 应用场景
大模型流式对话(ChatGPT类产品)
阶段:模型推理与输出阶段
解决的问题:传统方式必须等模型生成完整回答才返回,首字延迟高达数十秒,用户盯着空屏干等。AI通过SSE + 分块传输编码逐token推送,用户几乎立即看到“打字机效果”。
具体案例:某AI客服产品接入流式API后,用户等待感显著下降,会话完成率提升23%。后端用FastAPI的StreamingResponse输出SSE事件流,前端fetch + ReadableStream逐块渲染Markdown。
实时翻译与语音转写平台
阶段:数据接入与推理输出阶段
解决的问题:同声传译场景中,说话人说完一句话之前就需要开始翻译。流式API让翻译结果随语音输入逐句输出,而不是等整段语音识别完再一次性返回。
具体案例:某国际会议系统使用WebSocket双向流式通道:客户端持续上传音频分片,服务端流式返回识别 + 翻译结果,端到端延迟小于300ms。选择WebSocket是因为需要同时上传音频流和接收文本流,属于强双向交互。
Python 演示
"""演示分块传输编码:AI流式对话的编码与消费""" import time def chunk_encode(data, chunk_size=10): """服务端:将AI生成的文本编码为HTTP分块传输格式""" chunks = [] for i in range(0, len(data), chunk_size): chunk = data[i:i + chunk_size] size = format(len(chunk), 'x') chunks.append(f"{size}\r\n{chunk}\r\n") chunks.append("0\r\n\r\n") return "".join(chunks) def chunk_decode(stream): """客户端:解析分块传输格式,逐个产出数据块""" pos = 0 while pos < len(stream): line_end = stream.index("\r\n", pos) size = int(stream[pos:line_end], 16) pos = line_end + 2 if size == 0: break chunk = stream[pos:pos + size] yield chunk pos += size + 2 def main(): # 模拟AI模型逐token生成回答 answer = "分块传输编码让AI对话像打字机一样流畅。" print("服务端编码:") encoded = chunk_encode(answer, chunk_size=10) print(encoded) print("客户端流式消费:") received = "" for chunk in chunk_decode(encoded): received += chunk print(f" 收到{len(chunk):2d}字节: {chunk!r}") time.sleep(0.1) # 模拟逐字渲染耗时 print(f"完整响应: {received}") assert received == answer print("分块编码与解码校验通过!") if __name__ == "__main__": main()
size = format(len(chunk), 'x')将块长度转为十六进制字符串,符合HTTP分块传输规范chunks.append("0\r\n\r\n")追加终止块,标识所有数据发送完毕line_end = stream.index("\r\n", pos)从当前位置找到长度行的结束位置int(stream[pos:line_end], 16)把十六进制长度解析为整数yield chunk生成器逐块产出数据,模拟真实客户端流式消费
常见误区
误区很多初学者以为设置`Transfer-Encoding: chunked`就等于实现了SSE,觉得只要响应是分多次到达的就是SSE。
正解分块传输编码是HTTP传输层机制,SSE是应用层协议。SSE依赖分块传输或类似流式机制来推送事件,但分块传输的响应不一定是SSE——它可以是普通流式文本、JSON Lines等。类比来说,快递分拣线(分块)是基础设施,短信通知(SSE)是建立在分拣线之上的一种服务形态。
为何常见:两者都表现为“数据分多次到达”,初学者容易混淆底层传输机制与应用层协议的分层关系。
误区既然WebSocket能双向通信,那所有实时场景都应该用WebSocket,SSE是过时技术。
正解WebSocket是全双工,但握手复杂、需维护长连接状态;SSE是单向的,但轻量、自带断线重连、基于HTTP可直接使用CDN和代理。AI流式对话场景中服务端→客户端单向推送占绝对主导,SSE更简单可靠。专属专线(WebSocket)虽强但贵,短信通知(SSE)往往够用且便宜。
为何常见:看到“WebSocket更强大”就认为全面优于SSE,忽略了架构复杂度和场景匹配度。
误区认为HTTP响应必须一次性完整返回,否则客户端会认为响应出错。
正解只要正确使用Transfer-Encoding: chunked,客户端就能正确处理流式数据。分块编码用终止块0\r\n\r\n标识结束,而不是依赖连接关闭。就像火锅店不会等所有菜做好才开席,一盘盘上菜完全符合规则。
为何常见:对HTTP协议的理解停留在“请求-响应-关闭”的简单模型,不了解Keep-Alive和流式响应的协议细节。
误区写了fetch API就自动获得流式效果,`response.json()`会边接收边解析数据。
正解response.json()会等待整个body到达后才开始解析,完全不流式。要流式必须用response.body.getReader()配合ReadableStream逐块读取。类比来说,json()等整车快递到齐才拆箱,getReader()是来一件拆一件。
为何常见:fetch API封装得很好,初学者不知道json()内部会消费整个流,误以为Promise返回就是流式。
误区为了减少分块数量、降低传输开销,把每个分块设得特别大,认为这样效率最高。
正解分块大小的设计取决于业务。AI流式对话需要小分块(几字节到几十字节)来降低TTFT,让用户尽快看到首个token;大文件下载适合大分块减少开销。分块大小是“实时性 vs 吞吐量”的权衡。火锅要一片片涮才入味,一整坨肉丢进去反而砸锅。
为何常见:直觉上“少而大”效率高,忽略了实时交互场景对首字延迟的高度敏感。
面试题
Q1. 关于分块传输编码,以下哪项描述正确?
- A.分块传输依赖Content-Length指定总长
- B.分块传输以0\r\n\r\n作为终止标记← 正确答案
- C.分块传输须待全部数据就绪再发送
- D.分块传输编码仅支持文本类型数据
答案:B。分块传输编码不需要Content-Length(A错),它边生成边发送(C错),支持二进制数据(D错)。终止块0\r\n\r\n标识传输完毕(B对)。
Q2. 在AI流式对话中,TTFT(首字延迟)的含义是?
- A.从请求发起到收到完整响应的总时间
- B.从请求发起到收到第一个数据块的时间← 正确答案
- C.从服务端生成首个字再到客户端的耗时
- D.从服务端收到请求到生成首个字的时间
答案:B。TTFT指从客户端发出请求(T_request_sent)到收到第一个token/数据块(T_first_token)的时间差。C和D只是TTFT的一部分片段,A是总响应时间,都不完整。
Q3. SSE(Server-Sent Events)使用哪种MIME类型?
- A.application/json格式的数据
- B.text/event-stream的MIME类型← 正确答案
- C.text/plain的普通文本类型
- D.multipart/form-data的表单类型
答案:B。SSE的Content-Type必须是text/event-stream,浏览器识别该类型后才会解析事件流并触发onmessage等回调。其他三个MIME类型均不支持SSE的事件流语义。
Q4. WebSocket与SSE的关键区别是?
- A.WebSocket只能服务端发客户端收
- B.SSE支持全双工双向实时通信
- C.WebSocket支持双向全双工通信← 正确答案
- D.SSE基于UDP协议进行传输
答案:C。WebSocket是TCP之上的全双工协议,客户端和服务端可同时互发消息(C对)。SSE是单向的(仅服务端→客户端)且基于HTTP而非UDP,A和B和D均错误。
Q5. 前端实现流式消费需要哪些Web API?(多选)
- A.response.json()同步解析
- B.response.body.getReader()← 正确答案
- C.ReadableStream的for await← 正确答案
- D.response.text()完整接收
答案:BC。getReader()返回读取器,配合ReadableStream可以用for await...of逐块读取数据(B、C对)。response.json()和response.text()都会等整个body到达,不是流式API。
Q6. 分块传输编码中,分块大小用什么进制表示?
- A.使用十进制数字表示分块大小
- B.使用十六进制数字表示分块大小← 正确答案
- C.使用八进制数字表示分块大小
- D.使用二进制数字表示分块大小
答案:B。分块大小用十六进制表示,如4表示4字节、1f表示31字节,以\r\n结尾。这是HTTP/1.1 RFC 7230规定的格式。
Q7. 关于流式API的工程实践,以下哪些措施能提升体验?(多选)
- A.减小分块大小以降低首字延迟← 正确答案
- B.心跳包保活空闲连接防超时← 正确答案
- C.指数退避加随机抖动控制重试← 正确答案
- D.禁用TCP保活以节省资源
答案:ABC。小分块能降低TTFT(A对);心跳包防止NAT/代理超时断开连接(B对);指数退避+抖动避免重试风暴(C对)。禁用TCP保活反而会导致连接被中间设备回收(D错)。
Q8. gzip压缩与分块传输编码的关系是?
- A.压缩后必须使用分块传输编码
- B.压缩可以先于分块传输在服务端进行← 正确答案
- C.分块传输会破坏gzip压缩的数据
- D.压缩和分块传输是互斥的两种方式
答案:B。服务端可以先用gzip压缩整个响应体,再对压缩后的数据做分块传输。分块编码是传输层机制,不影响应用层压缩。A绝对化错误,C和D混淆了层次关系。
Q9. 客户端收到分块响应时,如何判断数据已全部到达?
- A.收到以0起始的终止块标记← 正确答案
- B.收到Chunked的end响应头
- C.连接空闲超过30秒判定完成
- D.数据长度达到Content-Length
答案:A。分块编码以0\r\n\r\n作为终止块,客户端解析到size为0的块即表示传输结束。分块编码没有Content-Length头(D错),B和C是臆造的判断方式。
Q10. 设计AI流式对话API时,以下哪些属于合理设计?(多选)
- A.用SSE推送每个增量token← 正确答案
- B.客户端用ReadableStream逐块渲染← 正确答案
- C.服务端攒满1KB才flush一次
- D.断线后基于Last-Event-ID续传← 正确答案
答案:ABD。SSE推送增量和ReadableStream逐块消费是流式对话的关键(A、B对);Last-Event-ID实现断点续传,防止断线丢字(D对)。C攒满1KB才flush会显著推高TTFT,对话场景应尽快flush(C错)。