想象你在一家以慢炖出名的餐厅点了一锅佛跳墙,菜单上写着“制作约 20 分钟”。传统上菜方式是把二十多种配料全部炖好、装罐,再一次性端到你面前——这 20 分钟里,你只能盯着空桌子咽口水,连菜到底做没做都不知道。大语言模型的回答也是一样:模型要逐个“想”出成千上万个 token,如果等全部生成完再返回,用户就只能对着白屏干等 5 到 10 秒。流式输出的思路,就是让后厨改变上菜方式:总厨一边炖肉,一边先端上一碟开胃小菜,紧接着又一盅汤——从第一秒起,你就有东西可吃、有反馈可看。这份“边做边端”的能力,在技术上由两个关键部件支撑:分块传输编码(把响应体切成一段段标好长度的小盒子送出去)和 Server-Sent Events(一个驻守在餐桌旁、菜出锅就立刻端来的专属服务生)。本文就带你从“点菜”到“上菜”完整走一遍:为什么需要流式、HTTP 怎么配合、分块怎么标长度、前端如何把收到的小块拼成完整回答,以及客人中途喊停时该怎么办。
学会流式输出与分块传输,你就能亲手实现 ChatGPT 同款打字机效果:让用户在模型没答完时先看到内容,显著降低等待焦虑、提升产品留存,这是 LLM 应用工程师的必会基本功。
可视化演示
动态演示LLM流式输出中的分块传输的核心机制,通过总览/传输/转换/输出四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
LLM流式输出中的分块传输:HTTP HTTP分块传输编码原理与Tr LLM推理输出到分块传输的映射 核心机制可视化 LLM流式输出中的分块传输整体架构与关键组件
流式输出价值
假设你在一家以慢炖出名的餐厅点了一锅佛跳墙,菜单上写着“制作约 20 分钟”。传统上菜方式是把二十多种配料全部炖好、装罐,再端上来——等待期间你完全不知道后厨进度,只能干瞪眼。大模型回答的“后厨”同样需要时间:模型逐个预测下一个 token,几百字可能要算 5 到 10 秒。如果不做任何处理,用户看到的就是一整块白屏,焦虑感拉满。
流式输出的思路由此而来:模型每生成一小段,服务端就立刻把它推送出去,用户从第一秒起就看到内容。专业上,从发出请求到收到第一段内容的时间叫首字延迟(TTFT,Time To First Token),它是流式体验最关键的指标——只要首字在几百毫秒内出现,用户就会觉得“系统活着”,哪怕整段回答还没生成完。
打字机效果不只是好看,它实打实地缩短了用户的主观等待。假设答案被切成 n 块,第 i 块在 t_i 时刻到达,那么用户“平均要等多久才能收到一块内容”可以写成:
这正是总厨的智慧:先端上一碟开胃菜,再送一盅汤,客人从第一秒就开始“用餐”,而不是第 20 分钟才看到整桌菜。在 LLM 应用里,流式输出不是锦上添花,而是决定用户愿不愿意等下去的体验底线。
- n 个分块表示整个回答被切成 n 段,对应厨师做的 n 道菜。
- 第 i 块到达时刻$t_i$ 是从请求发出到第 i 块内容到达客户端的时间。
- 平均等待把所有分块到达时刻取平均,代表用户平均要等多久才能看到一块内容。
- 流式与一次性对比均匀流式时平均等待约为 $T_{\text{总}}/2$,一次性返回则每块都要等满 $T_{\text{总}}$。
HTTP传输基础
但“边做边端”的前提,是搞清楚传统上菜方式为什么不适用。HTTP 协议就是我们和餐厅之间的大货车:一次请求对应一次响应,响应由三部分组成——状态行(货车单据,说明本次运输是否成功)、响应头(货单明细,比如总重量)、响应体(货厢里装的菜)。大模型的回答,最终也是装在这辆“HTTP 大货车”里送到浏览器面前的。
传统响应有个硬性要求:响应头里必须带 Content-Length 字段,它告诉浏览器“这箱货总共有多少字节”。可问题是,大模型还没把回答生成完,根本算不出总长度。于是只能等全部生成完、称好重、再一口气发出去——这正是“20 分钟干等”的根源。更麻烦的是,响应体必须按 Content-Length 精确对齐字节数,多一字节少一字节都会被判定为错误。
打个比方:Content-Length 就像货单上的总重量,必须等所有菜都装车才能过磅。只要想“边做边送”,这个大货车思路就走不通——前菜先走、硬菜后走,总重量根本没法提前知道。HTTP 响应的整体结构可以写成:
好消息是,HTTP/1.1 早就为我们准备了另一套方案——不用大货车拉全桌,改用传送带逐盒送菜。这就是下一节的主人公:分块传输编码。
- 状态行响应的第一行,说明协议版本与状态码,比如 HTTP/1.1 200 OK 表示“请求成功”。
- 响应头 Content-Length$L$ 是响应体的总字节数,客户端靠它判断正文到哪儿结束。
- 空行分隔一个 CRLF 空行把头部和正文隔开,是 HTTP 报文的固定分界。
- 响应体真正的回答内容字节流,传统模式下一次性整体到达。
分块传输编码
分块传输编码(Transfer-Encoding: chunked)是 HTTP/1.1 的标准特性。它改变了装货方式:不再预先知道总重量,而是每做好一道菜就装进一个保温盒,盒子上用十六进制写清“这一盒里有多少字节”,然后通过传送带送出。请求方收到的是一连串“标了重量的盒子”,而不是一箱不知道边界的散装货物。
在响应头里,用 Transfer-Encoding: chunked 替代 Content-Length——两者互斥,只能二选一。此后响应体是一串“盒子”:每个盒子 = 十六进制长度 + CRLF + 数据 + CRLF。最后用一个长度为 0 的盒子(0\r\n\r\n)告诉浏览器“菜全上完了”。
为什么用十六进制?因为长度值可能很大,十六进制更紧凑,而且 HTTP 的文本协议天然适合这种描述方式。比如某块数据是“你好”(UTF-8 编码下恰好 6 个字节),盒子标签就写 6。客户端解析时先读十六进制长度,再按长度精确读取数据块,循环往复直到遇见终止块。一块的格式如下:
于是厨师不再需要等整桌菜做完:炖好一锅先装一盒,炒完一盘再装一盒,传送带永远不会空着。这也是大模型接口能“吐一个字传一个字”的底层基础——但有了传送带,还得有人负责端菜,接下来登场的 SSE 就是那位服务生。
- 十六进制长度用十六进制数表示本块数据的字节数,例如
6表示 6 字节。 - CRLF 分隔长度与数据之间必须用 CRLF 隔开,防止粘连。
- 块数据实际内容的字节流,接收方按长度精确切出。
- 终止块一个零长度块加 CRLF 再加 CRLF,标记整个响应体结束,相当于“菜上完了”。
SSE协议原理
有了传送带和保温盒,还需要一位专属服务生负责“第一时间端菜”,这就是 Server-Sent Events(SSE)。SSE 是建立在 HTTP 之上的轻量推送方案:连接建立后,服务端可以持续向客户端推送事件,连接始终保持打开,直到服务端明确结束。它不像 WebSocket 那样“双向对讲”,而是“服务生单向往桌上端菜”——对 LLM 逐字输出这种“只需要往下发”的场景刚刚好。
浏览器里接入 SSE 极其简单:用 new EventSource(url) 一行代码建立连接。关键在于服务端要把响应头设置成 Content-Type: text/event-stream,浏览器看到这个 MIME 类型后,就知道这不是普通响应,而是一条“永不结束”的事件流,并内置了断线自动重连的能力——服务生走神了,浏览器会主动把他叫回来继续上菜。
SSE 的消息格式非常简单:每行一个“字段:值”,消息之间用空行分隔。最常用的是 data 字段;连续多行 data 会被合并成一条消息;还可以用 id 字段给事件编号,断线重连时浏览器自动带上 Last-Event-ID,服务端就能从上次上到的那道菜接着上。一条典型事件可以写成:
SSE 就是那位专门守在你桌旁的服务生:他一道道端菜,你不需要反复呼叫;他走神了(断线),浏览器也会把他叫回来接着上菜。理解了“上菜服务生”,我们再去看后厨和传菜口之间的小票约定——流式 API 该怎么设计。
- data 字段事件的主要内容,一行一条;连续多行 data 会被合并为同一条消息。
- 空行定界空行表示一条消息结束,相当于“这道菜上完了”。
- id 与续传给事件编号,重连时通过 Last-Event-ID 告诉服务端上次的进度。
流式API设计
大餐要顺利上桌,厨房和传菜口之间必须有清晰的“小票”约定——这就是流式 API 的设计。以业界标准的 OpenAI 兼容接口为例,流式模式下服务端返回的不再是一整段 JSON,而是一行接一行的 JSONL(JSON Lines,每行一个独立的 JSON 对象),每一行代表一小步生成结果。这就像传菜口每出一道菜,就打印一张小票递给服务生。
小票上的关键字段是 choices[0].delta.content:delta 是“增量”的意思,表示相对上一行新产生的这一小段文本,而不是累计全文。把所有行的 delta 拼起来,才是完整回答。接口还在第一行用 role 标记身份(user/assistant),最后一行用 finish_reason 标记结束原因:stop 表示模型正常说完,length 表示达到长度上限被截断。前端要做的,就是逐行累积 delta,直到看见结束信号。
这份契约带来两个实打实的好处:第一,客户端不必缓存超大响应,内存友好;第二,delta 语义让下游能精确控制“哪些是新内容”,逐字动效正是靠它实现的。整个交互可以浓缩成:
就像小票上写清楚“哪一桌、第几道、是否最后一道”,服务生才不会送错;流式 API 里的结构化字段,就是后端与前端之间的“传菜小票”。菜到了桌边,客人(前端)该怎么优雅地开吃?我们进入前端增量渲染。
- choices 数组兼容接口统一用 choices 包装生成结果,流式时每一行里只有一个元素。
- delta 对象本次的增量内容,具体文本在
delta.content里,逐行拼接得到全文。 - finish_reason最后一行给出结束原因,stop 代表正常结束,length 代表触顶截断。
前端增量渲染
菜到了桌边,客人得马上开吃——这就是前端逐字渲染。现代浏览器里,我们用 fetch 配合 Response.body 的 ReadableStream 来读流:response.body.getReader() 返回一个阅读器,每次调用 reader.read(),就能从网络上拿到一块 Uint8Array 二进制数据。整个过程就像从传送带上一盒一盒接菜。
这里有个容易踩的坑:一个中文字符在 UTF-8 编码下占 3 个字节,网络分块可能恰好把它劈成两半——这一块末尾是半个“你”字,下一块开头是另外半个。所以不能直接拿字节转字符串,必须用 TextDecoder 统一解码,它会在内部缓存半截字符,等下一块到了再拼完整。把解码出的片段不断追加到累计文本,再写进页面,打字机效果就出现了。
渲染还有个性能要点:每个 chunk 到达都立刻改写 DOM,会引发大量重排(reflow),反而卡顿。经验做法是用 requestAnimationFrame 把同一帧内到达的所有增量合并,每帧只渲染一次;写文本时用 textContent 而不是 innerHTML,既安全又省去 HTML 解析。整个流程的公式化表达:
客人吃到嘴里的每一口都是新鲜的,但也不必每一粒米都吧唧一下——合并渲染就是“攒一小口再咽”,既及时又不噎着。不过,总有客人吃到一半喊“后面的菜不要了”,这就是最后的工程兜底:中断与异常处理。
- 解码TextDecoder 把二进制块还原成文本,内部缓存被切碎的多字节字符。
- 累积把新解码出的片段追加到已有文本末尾,而不是替换全文。
- 渲染用 rAF 合并一帧内所有增量,每帧只写一次 DOM。
中断异常处理
总有客人吃到一半说“后面的菜不要了”。前端中断流式输出靠 AbortController:调用 abort() 后,fetch 的连接会被断开,正在进行的 reader.read() 会抛错;在组件卸载、页面跳转时必须记得调用,否则连接和回调会一直悬着,造成内存泄漏。这就像客人一摆手,服务生立刻停下上菜的动作。
但客人摆手只是“不再端菜”,后厨可能还在闷头炒菜。服务端必须能感知中断:当客户端断开连接,服务端往 socket 写数据时会抛出 BrokenPipeError / ConnectionResetError 之类的异常;生成循环捕获到异常后应立即 break 并清理资源,否则模型还在后台继续生成,白白浪费 GPU 算力和 token 费用。
还有一类“天灾”:网络抖动导致断线,或服务端内部出错。SSE 的自动重连能救一部分场,但重连后要不要续传、从哪一段续传,需要业务方设计好;同时要设计兜底状态——finish_reason 区分正常结束和截断,前端设置超时与错误提示,别让页面永远转圈。整个中断链路和兜底状态可以这样表示:
一个成熟的服务生既要会上菜,也要在客人摆手的一瞬间通知后厨熄火,还要在汤撒了的时候主动换一盅重上。流式工程里的“职业素养”就是这套:中断要快、断线要连、异常要说人话。
- 主动中断AbortController.abort() 让 fetch 抛错并断开连接,读取循环随之终止。
- 服务端感知客户端断开后,服务端写流抛出异常,生成循环捕获后跳出。
- 状态兜底用 finish_reason 区分正常结束与截断,前端据此展示提示或重试按钮。
AI 应用场景
智能客服对话窗口
阶段:应用交互层 / 流式渲染
解决的问题:传统做法等模型生成完整回答再一次性返回,Chat 类产品要白屏等 5~8 秒,客户以为服务挂了,放弃率很高。改用 SSE + 前端逐字渲染后,首字 300ms 内出现,后续内容边到边渲染,感知速度提升数倍。
具体案例:某电商智能客服接入大模型后,用户提问“我的订单什么时候到”,页面立刻出现逐字滚动的回答,回答末尾常驻“停止回答”按钮;用户点击后前端用 AbortController 中断请求,服务端同步停止计费。
长文档生成工具
阶段:后端流式接口设计
解决的问题:生成 3000 字报告需要 30~60 秒,若一次性返回,用户只能盯着进度圈,中途出错也无法挽救。后端把接口设计为流式:每生成一个自然段就 yield 一段 JSONL(含 delta 与段落序号),前端按段落追加并随时保存草稿。
具体案例:某“周报生成助手”把用户本周记录作为输入,模型边写边吐;用户看到第一段就能判断方向对不对,不满意立刻点击停止,省下几十秒的 token 开销。
大模型 API 网关
阶段:平台基础设施 / 流量转发
解决的问题:企业接入多家大模型供应商时,网关需要把上游流式响应透传给下游服务;上游断了要能重连兜底,下游取消时要能向上游发 cancel 以节省费用。
具体案例:某公司 API 网关统一封装 OpenAI 兼容流式接口,在控制台实时展示每个会话的 delta 累计量与 finish_reason,并按“首字延迟 P95 < 50ms”的 SLA 做监控告警。
Python 演示
# -*- coding: utf-8 -*-"""演示 LLM 流式输出中的分块传输:服务端把答案切成小块(SSE 事件)逐个写出,客户端逐块接收并即时追加打印, 形成打字机效果。"""import http.serverimport threadingimport timeimport urllib.request ANSWER = "你好,我是AI助手。今天我们来学习流式输出与分块传输。" def token_stream(text, step=3): """把答案逐块切出, 模拟大模型流式生成。""" for i in range(0, len(text), step): time.sleep(0.1) yield text[i:i + step].encode("utf-8") class StreamHandler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def send_chunk(self, data): """按分块传输编码写出: 十六进制长度 + CRLF + 数据 + CRLF。""" if not data: self.wfile.write(b"0\r\n\r\n") return size = "%x" % len(data) self.wfile.write(size.encode("ascii") + b"\r\n") self.wfile.write(data + b"\r\n") def do_GET(self): if self.path != "/chat": self.send_error(404) return self.send_response(200) self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Transfer-Encoding", "chunked") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "close") self.end_headers() for piece in token_stream(ANSWER): event = b"data: " + piece + b"\n\n" try: self.send_chunk(event) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): break try: self.send_chunk(b"") self.wfile.flush() except (BrokenPipeError, ConnectionResetError): pass def log_message(self, *args): pass def main(): server = http.server.ThreadingHTTPServer(("127.0.0.1", 8765), StreamHandler) threading.Thread(target=server.serve_forever, daemon=True).start() print("流式服务已启动: http://127.0.0.1:8765/chat") url = "http://127.0.0.1:8765/chat" with urllib.request.urlopen(url) as resp: print("响应头 Content-Type:", resp.headers.get("Content-Type")) print("响应头 Transfer-Encoding:", resp.headers.get("Transfer-Encoding")) buf = "" for raw in resp: line = raw.decode("utf-8").strip() if line.startswith("data:"): buf += line[6:] print("已收到:", buf) print("最终回答:", buf) server.shutdown() if __name__ == "__main__": main()
protocol_version = "HTTP/1.1"声明使用 HTTP/1.1,这是分块传输编码的前提。def send_chunk(self, data)核心函数:把每段数据按“十六进制长度 + CRLF + 数据 + CRLF”打包。size = "%x" % len(data)十六进制格式化字节长度,正是 chunk 盒子上的标签。self.send_header("Transfer-Encoding", "chunked")告诉客户端响应体是分块流,没有固定长度。for piece in token_stream(ANSWER)模拟大模型边生成边吐词,每次只产出 3 个字符。event = b"data: " + piece + b"\n\n"拼出 SSE 消息:data 字段 + 空行结尾。for raw in resp:客户端像读普通文件一样逐行迭代,urllib 自动解分块。buf += line[6:]只追加新来的增量,形成打字机效果。
常见误区
误区流式输出不就是长连接吗?那 WebSocket 更强大,还能双向通信,为什么非要用 SSE?
正解SSE 是服务端单向推送,基于普通 HTTP,浏览器 EventSource 一行接入且自带自动重连;WebSocket 是全双工协议,但要握手升级、自己处理重连和心跳。SSE 是专职上菜的服务生,WebSocket 是既能上菜又能听点歌的对讲机——LLM 逐字输出这种“只用往下发”的场景,前者简单可靠得多。
为何常见:两者都能“保持连接持续收数据”,初学者容易只看到共同点,忽略了方向性与复杂度的巨大差异。
误区看到十六进制长度和 CRLF 就觉得很高深,担心要手写一整套底层协议才能用。
正解分块传输是 HTTP/1.1 官方标准,绝大多数 Web 框架(Flask、Express、Spring 等)和浏览器、urllib 都内建支持。后端只需返回一个生成器/流对象,框架自动完成 chunk 封装;客户端读取时也透明解块,开发者甚至感觉不到它的存在。
为何常见:教程常展示原始字节格式,让人误以为这是需要手写的协议,其实那是协议内部机制,工程上框架全包了。
误区反正前端能做打字机动画,等接口全部返回后把字符串一个个吐出来,效果不是一样吗?
正解那是“假打字机”——用户整体等待时间没变,流式意义完全丧失。真正的流式是网络层面边生成边传输,前端拿到多少渲染多少;尤其长回答场景,首字延迟与总延迟都能显著下降。
为何常见:前端动画效果好,容易让人误以为“效果一样”,忽略了底层传输时机才是关键。
误区流式渲染嘛,收到一块就 textContent 一次,更新越快用户越爽。
正解高频 DOM 写入会触发大量重排(reflow),反而卡顿丢帧。常用做法是把一帧内到达的多个 chunk 累积起来,用 requestAnimationFrame 合并渲染一次;用 textContent 而非 innerHTML 也更安全高效。
为何常见:直觉上“及时”等于“立刻更新”,但浏览器渲染是按帧合并的,过度写入只是在浪费主线程。
误区前端一调 abort(),连接断了,服务端肯定马上停止生成,不用操心。
正解前端断开只是让“菜端不上桌”,后厨可能还在闷头做菜。服务端生成循环要向 socket 写数据才会感知断开(触发异常),若写不进去或数据积压,模型还会继续算完,浪费算力与费用。需要在服务端捕获写异常并显式 break,同时设计超时与 cancel 处理。
为何常见:客户端视角“断开即结束”很自然,但服务端进程感知不到连接状态,只有写数据失败才知道。
面试题
Q1. 传统 HTTP 响应中,用哪个响应头表示响应体总长度?
- A.Cache-Control
- B.Content-Length← 正确答案
- C.Transfer-Encoding
- D.Content-Encoding
答案:B。一次性响应就像大货车,必须预先称好总重,Content-Length 的 L 就是响应体总字节数。Transfer-Encoding 恰恰相反,用于分块传输时替代定长;Cache-Control 管缓存,Content-Encoding 管压缩,都与长度无关。
Q2. 分块传输编码中,终止块的内容是什么?
- A.两个连续空换行
- B.发送 END 标志
- C.零长度加换行← 正确答案
- D.断开 TCP 连接
答案:C。分块传输的最后一块必须是零长度块,写法是 0\r\n\r\n,表示“菜上完了”。两个空行、END 标志、断开 TCP 都不是标准终止方式,照做会被接收方判定为传输异常。
Q3. SSE 规定服务端响应的 MIME 类型是什么?
- A.text/html
- B.application/json
- C.text/event-stream← 正确答案
- D.text/plain
答案:C。浏览器看到 text/event-stream 才会把响应当作事件流处理,并开启自动重连。text/html 是网页,application/json 是数据接口,text/plain 是纯文本,都不会触发 SSE 行为。
Q4. SSE 消息中,字段名与字段值之间的分隔符是什么?
- A.冒号加一个空格← 正确答案
- B.等号加一个空格
- C.右箭头符号
- D.竖线加空格
答案:A。SSE 每个字段写成“字段名:值”,有点像传菜小票上的“菜名:数量”。等号、箭头、竖线都不是 SSE 语法,用错了解析器会直接忽略该行。
Q5. OpenAI 流式接口中 choices[0].delta.content 的含义是什么?
- A.回答的完整文本内容
- B.相对上一段的增量内容← 正确答案
- C.当前已累计的 token 数
- D.模型内部隐藏状态信息
答案:B。delta 是“增量”,即相对上一行新产生的文本;把所有 delta.content 拼起来才是全文,类似把每道刚出锅的菜拼成一整桌宴席。完整文本、token 数、隐藏状态都不是 delta 的语义。
Q6. 前端用 fetch 读取流式响应时,逐块取数据用的是哪个 API?
- A.读取流的 getReader 方法← 正确答案
- B.响应上面的 next 方法
- C.读取流的 reader 对象
- D.响应上面的 read 方法
答案:A。response.body 是 ReadableStream,调用 getReader() 拿到阅读器后,每次 reader.read() 取一块 Uint8Array,就像从传送带上逐盒接菜。next/read 并不是 ReadableStream 的标准取块方法。
Q7. 关于分块传输编码的说法,正确的是哪一项?
- A.与 Content-Length 同用
- B.每块前缀十六进制长度← 正确答案
- C.只能用于 SSE 场景
- D.长度必须十进制标注
答案:B。chunked 的每个分块前缀是十六进制长度,且与 Content-Length 互斥(二选一),它不只服务于 SSE,任何长度未知的响应体都能用。十六进制是协议规定,写成十进制会解析失败。
Q8. 要在页面上实现打字机效果,前端需要做哪些事?
- A.用 fetch 流式读取接口← 正确答案
- B.用 TextDecoder 解码← 正确答案
- C.更新前先合并累积文本← 正确答案
- D.等响应全部返回再渲染
答案:ABC。打字机效果四步走:fetch 流式读 → TextDecoder 解码(防止中文被劈成两半)→ 累积拼接 → 合并渲染。D 是典型的“假打字机”——等全部返回再渲染,用户等待时间一点没减少。
Q9. 下列哪些情形需要主动中断流式输出?
- A.用户点击停止生成← 正确答案
- B.页面组件被卸载← 正确答案
- C.请求超时准备重试← 正确答案
- D.收到完成标记也要中断
答案:ABC。用户喊停、组件卸载、超时重试都属于“客人摆手”,需要 AbortController 中断并让服务端停火,否则白白浪费资源。D 中收到完成标记时流已自然结束,不存在进行中的状态需要中断。
Q10. 相比一次性返回,SSE 流式输出的优势有哪些?
- A.首字到达时间更早← 正确答案
- B.无需缓存完整响应← 正确答案
- C.支持客户端反推数据
- D.断线后自动重连← 正确答案
答案:ABD。SSE 让首字先到、客户端边收边渲染、内存无需缓存全文,且浏览器内置自动重连。但 SSE 是服务端单向推送,不支持客户端反向推数据——想“对着服务生喊话”得用 WebSocket。