想象你在一家以慢炖出名的餐厅点了一锅佛跳墙,菜单上写着“制作约 20 分钟”。传统上菜方式是把二十多种配料全部炖好、装罐,再一次性端到你面前——这 20 分钟里,你只能盯着空桌子咽口水,连菜到底做没做都不知道。大语言模型的回答也是一样:模型要逐个“想”出成千上万个 token,如果等全部生成完再返回,用户就只能对着白屏干等 5 到 10 秒。流式输出的思路,就是让后厨改变上菜方式:总厨一边炖肉,一边先端上一碟开胃小菜,紧接着又一盅汤——从第一秒起,你就有东西可吃、有反馈可看。这份“边做边端”的能力,在技术上由两个关键部件支撑:分块传输编码(把响应体切成一段段标好长度的小盒子送出去)和 Server-Sent Events(一个驻守在餐桌旁、菜出锅就立刻端来的专属服务生)。本文就带你从“点菜”到“上菜”完整走一遍:为什么需要流式、HTTP 怎么配合、分块怎么标长度、前端如何把收到的小块拼成完整回答,以及客人中途喊停时该怎么办。

岗位关联

学会流式输出与分块传输,你就能亲手实现 ChatGPT 同款打字机效果:让用户在模型没答完时先看到内容,显著降低等待焦虑、提升产品留存,这是 LLM 应用工程师的必会基本功。

目录
  1. 可视化演示
  2. 流式输出价值
  3. HTTP传输基础
  4. 分块传输编码
  5. SSE协议原理
  6. 流式API设计
  7. 前端增量渲染
  8. 中断异常处理
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示LLM流式输出中的分块传输的核心机制,通过总览/传输/转换/输出四种模式展示关键概念与交互流程。

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

LLM流式输出中的分块传输:HTTP HTTP分块传输编码原理与Tr LLM推理输出到分块传输的映射 核心机制可视化 LLM流式输出中的分块传输整体架构与关键组件

流式输出价值

假设你在一家以慢炖出名的餐厅点了一锅佛跳墙,菜单上写着“制作约 20 分钟”。传统上菜方式是把二十多种配料全部炖好、装罐,再端上来——等待期间你完全不知道后厨进度,只能干瞪眼。大模型回答的“后厨”同样需要时间:模型逐个预测下一个 token,几百字可能要算 5 到 10 秒。如果不做任何处理,用户看到的就是一整块白屏,焦虑感拉满。

流式输出的思路由此而来:模型每生成一小段,服务端就立刻把它推送出去,用户从第一秒起就看到内容。专业上,从发出请求到收到第一段内容的时间叫首字延迟(TTFT,Time To First Token),它是流式体验最关键的指标——只要首字在几百毫秒内出现,用户就会觉得“系统活着”,哪怕整段回答还没生成完。

打字机效果不只是好看,它实打实地缩短了用户的主观等待。假设答案被切成 n 块,第 i 块在 t_i 时刻到达,那么用户“平均要等多久才能收到一块内容”可以写成:

这正是总厨的智慧:先端上一碟开胃菜,再送一盅汤,客人从第一秒就开始“用餐”,而不是第 20 分钟才看到整桌菜。在 LLM 应用里,流式输出不是锦上添花,而是决定用户愿不愿意等下去的体验底线。

平均等待=1n∑i=1nti≈T总2 (均匀流式),一次性返回=T总\displaystyle \text{平均等待} = \frac{1}{n}\sum_{i=1}^{n} t_i \approx \frac{T_{\text{总}}}{2}\,(\text{均匀流式}), \quad \text{一次性返回} = T_{\text{总}}
  1. n 个分块表示整个回答被切成 n 段,对应厨师做的 n 道菜。
    n=回答被切成的块数n = \text{回答被切成的块数}
  2. 第 i 块到达时刻$t_i$ 是从请求发出到第 i 块内容到达客户端的时间。
    ti=第 i 块到达的时刻t_i = \text{第 } i \text{ 块到达的时刻}
  3. 平均等待把所有分块到达时刻取平均,代表用户平均要等多久才能看到一块内容。
    1n∑i=1nti\frac{1}{n}\sum_{i=1}^{n} t_i
  4. 流式与一次性对比均匀流式时平均等待约为 $T_{\text{总}}/2$,一次性返回则每块都要等满 $T_{\text{总}}$。
    T总2 (流式)vsT总 (一次性)\frac{T_{\text{总}}}{2}\,(\text{流式}) \quad \text{vs} \quad T_{\text{总}}\,(\text{一次性})

HTTP传输基础

但“边做边端”的前提,是搞清楚传统上菜方式为什么不适用。HTTP 协议就是我们和餐厅之间的大货车:一次请求对应一次响应,响应由三部分组成——状态行(货车单据,说明本次运输是否成功)、响应头(货单明细,比如总重量)、响应体(货厢里装的菜)。大模型的回答,最终也是装在这辆“HTTP 大货车”里送到浏览器面前的。

传统响应有个硬性要求:响应头里必须带 Content-Length 字段,它告诉浏览器“这箱货总共有多少字节”。可问题是,大模型还没把回答生成完,根本算不出总长度。于是只能等全部生成完、称好重、再一口气发出去——这正是“20 分钟干等”的根源。更麻烦的是,响应体必须按 Content-Length 精确对齐字节数,多一字节少一字节都会被判定为错误。

打个比方:Content-Length 就像货单上的总重量,必须等所有菜都装车才能过磅。只要想“边做边送”,这个大货车思路就走不通——前菜先走、硬菜后走,总重量根本没法提前知道。HTTP 响应的整体结构可以写成:

好消息是,HTTP/1.1 早就为我们准备了另一套方案——不用大货车拉全桌,改用传送带逐盒送菜。这就是下一节的主人公:分块传输编码。

HTTP响应=HTTP/1.1 200 OK⏟状态行+Content-Length:L⏟响应头+CRLF+Body⏟响应体\displaystyle \text{HTTP响应} = \underbrace{\text{HTTP/1.1 200 OK}}_{\text{状态行}} + \underbrace{\text{Content-Length}: L}_{\text{响应头}} + \text{CRLF} + \underbrace{\text{Body}}_{\text{响应体}}
  1. 状态行响应的第一行,说明协议版本与状态码,比如 HTTP/1.1 200 OK 表示“请求成功”。
    HTTP/1.1 200 OK\text{HTTP/1.1 200 OK}
  2. 响应头 Content-Length$L$ 是响应体的总字节数,客户端靠它判断正文到哪儿结束。
    Content-Length:L\text{Content-Length}: L
  3. 空行分隔一个 CRLF 空行把头部和正文隔开,是 HTTP 报文的固定分界。
    CRLF\text{CRLF}
  4. 响应体真正的回答内容字节流,传统模式下一次性整体到达。
    Body\text{Body}

分块传输编码

分块传输编码(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 就是那位服务生。

Chunk=HexSize⏟十六进制长度+CRLF+Data⏟块数据+CRLF;终止块:0+CRLF+CRLF\displaystyle \text{Chunk} = \underbrace{\text{HexSize}}_{\text{十六进制长度}} + \text{CRLF} + \underbrace{\text{Data}}_{\text{块数据}} + \text{CRLF};\qquad \text{终止块}: 0 + \text{CRLF} + \text{CRLF}
  1. 十六进制长度用十六进制数表示本块数据的字节数,例如 6 表示 6 字节。
    HexSize=hex(len(Data))\text{HexSize} = \text{hex}(\text{len}(\text{Data}))
  2. CRLF 分隔长度与数据之间必须用 CRLF 隔开,防止粘连。
    CRLF\text{CRLF}
  3. 块数据实际内容的字节流,接收方按长度精确切出。
    Data\text{Data}
  4. 终止块一个零长度块加 CRLF 再加 CRLF,标记整个响应体结束,相当于“菜上完了”。
    0+CRLF+CRLF0 + \text{CRLF} + \text{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:内容⏟每行一个字段+空行⏟消息边界;断线续传:id:编号⇒Last-Event-ID\displaystyle \text{事件} = \underbrace{\text{data}: \text{内容}}_{\text{每行一个字段}} + \underbrace{\text{空行}}_{\text{消息边界}};\quad \text{断线续传}: \text{id}:\text{编号} \Rightarrow \text{Last-Event-ID}
  1. data 字段事件的主要内容,一行一条;连续多行 data 会被合并为同一条消息。
    data:增量内容\text{data}: \text{增量内容}
  2. 空行定界空行表示一条消息结束,相当于“这道菜上完了”。
    空行\text{空行}
  3. id 与续传给事件编号,重连时通过 Last-Event-ID 告诉服务端上次的进度。
    id:编号⇒Last-Event-ID\text{id}: \text{编号} \Rightarrow \text{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[0].delta.content;结束信号:finish_reason=stop\displaystyle \text{增量内容} = \text{choices}[0].\text{delta}.\text{content};\qquad \text{结束信号}: \text{finish\_reason} = \text{stop}
  1. choices 数组兼容接口统一用 choices 包装生成结果,流式时每一行里只有一个元素。
    choices[0]\text{choices}[0]
  2. delta 对象本次的增量内容,具体文本在 delta.content 里,逐行拼接得到全文。
    delta.content\text{delta}.\text{content}
  3. finish_reason最后一行给出结束原因,stop 代表正常结束,length 代表触顶截断。
    finish_reason=stop\text{finish\_reason} = \text{stop}

前端增量渲染

菜到了桌边,客人得马上开吃——这就是前端逐字渲染。现代浏览器里,我们用 fetch 配合 Response.body 的 ReadableStream 来读流:response.body.getReader() 返回一个阅读器,每次调用 reader.read(),就能从网络上拿到一块 Uint8Array 二进制数据。整个过程就像从传送带上一盒一盒接菜。

这里有个容易踩的坑:一个中文字符在 UTF-8 编码下占 3 个字节,网络分块可能恰好把它劈成两半——这一块末尾是半个“你”字,下一块开头是另外半个。所以不能直接拿字节转字符串,必须用 TextDecoder 统一解码,它会在内部缓存半截字符,等下一块到了再拼完整。把解码出的片段不断追加到累计文本,再写进页面,打字机效果就出现了。

渲染还有个性能要点:每个 chunk 到达都立刻改写 DOM,会引发大量重排(reflow),反而卡顿。经验做法是用 requestAnimationFrame 把同一帧内到达的所有增量合并,每帧只渲染一次;写文本时用 textContent 而不是 innerHTML,既安全又省去 HTML 解析。整个流程的公式化表达:

客人吃到嘴里的每一口都是新鲜的,但也不必每一粒米都吧唧一下——合并渲染就是“攒一小口再咽”,既及时又不噎着。不过,总有客人吃到一半喊“后面的菜不要了”,这就是最后的工程兜底:中断与异常处理。

累计=decoder.decode(chunk);文本=文本旧+累计;DOM=文本 (每帧一次)\displaystyle \text{累计} = \text{decoder}.\text{decode}(\text{chunk});\quad \text{文本} = \text{文本}_{\text{旧}} + \text{累计};\quad \text{DOM} = \text{文本}\,(\text{每帧一次})
  1. 解码TextDecoder 把二进制块还原成文本,内部缓存被切碎的多字节字符。
    decoder.decode(chunk)\text{decoder}.\text{decode}(\text{chunk})
  2. 累积把新解码出的片段追加到已有文本末尾,而不是替换全文。
    文本=文本旧+累计\text{文本} = \text{文本}_{\text{旧}} + \text{累计}
  3. 渲染用 rAF 合并一帧内所有增量,每帧只写一次 DOM。
    DOM=文本 (每帧一次)\text{DOM} = \text{文本}\,(\text{每帧一次})

中断异常处理

总有客人吃到一半说“后面的菜不要了”。前端中断流式输出靠 AbortController:调用 abort() 后,fetch 的连接会被断开,正在进行的 reader.read() 会抛错;在组件卸载、页面跳转时必须记得调用,否则连接和回调会一直悬着,造成内存泄漏。这就像客人一摆手,服务生立刻停下上菜的动作。

但客人摆手只是“不再端菜”,后厨可能还在闷头炒菜。服务端必须能感知中断:当客户端断开连接,服务端往 socket 写数据时会抛出 BrokenPipeError / ConnectionResetError 之类的异常;生成循环捕获到异常后应立即 break 并清理资源,否则模型还在后台继续生成,白白浪费 GPU 算力和 token 费用。

还有一类“天灾”:网络抖动导致断线,或服务端内部出错。SSE 的自动重连能救一部分场,但重连后要不要续传、从哪一段续传,需要业务方设计好;同时要设计兜底状态——finish_reason 区分正常结束和截断,前端设置超时与错误提示,别让页面永远转圈。整个中断链路和兜底状态可以这样表示:

一个成熟的服务生既要会上菜,也要在客人摆手的一瞬间通知后厨熄火,还要在汤撒了的时候主动换一盅重上。流式工程里的“职业素养”就是这套:中断要快、断线要连、异常要说人话。

abort()⇒连接断开⇒服务端写异常⇒停止生成;finish_reason∈{stop,length}\displaystyle \text{abort}() \Rightarrow \text{连接断开} \Rightarrow \text{服务端写异常} \Rightarrow \text{停止生成};\quad \text{finish\_reason} \in \{\text{stop}, \text{length}\}
  1. 主动中断AbortController.abort() 让 fetch 抛错并断开连接,读取循环随之终止。
    abort()⇒断开\text{abort}() \Rightarrow \text{断开}
  2. 服务端感知客户端断开后,服务端写流抛出异常,生成循环捕获后跳出。
    write()⇒异常⇒停止生成\text{write()} \Rightarrow \text{异常} \Rightarrow \text{停止生成}
  3. 状态兜底用 finish_reason 区分正常结束与截断,前端据此展示提示或重试按钮。
    finish_reason∈{stop,length}\text{finish\_reason} \in \{\text{stop}, \text{length}\}

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 演示

python74 行
# -*- 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()
目的:用纯标准库实现一个流式 SSE 服务端 + 逐块读取的客户端,直观演示“服务端边生成边写 chunk、客户端边收到边累积”的完整链路。
  1. protocol_version = "HTTP/1.1"声明使用 HTTP/1.1,这是分块传输编码的前提。
  2. def send_chunk(self, data)核心函数:把每段数据按“十六进制长度 + CRLF + 数据 + CRLF”打包。
  3. size = "%x" % len(data)十六进制格式化字节长度,正是 chunk 盒子上的标签。
  4. self.send_header("Transfer-Encoding", "chunked")告诉客户端响应体是分块流,没有固定长度。
  5. for piece in token_stream(ANSWER)模拟大模型边生成边吐词,每次只产出 3 个字符。
  6. event = b"data: " + piece + b"\n\n"拼出 SSE 消息:data 字段 + 空行结尾。
  7. for raw in resp:客户端像读普通文件一样逐行迭代,urllib 自动解分块。
  8. 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 响应中,用哪个响应头表示响应体总长度?

单选题 · easy

  • A.Cache-Control
  • B.Content-Length← 正确答案
  • C.Transfer-Encoding
  • D.Content-Encoding

答案:B。一次性响应就像大货车,必须预先称好总重,Content-Length 的 L 就是响应体总字节数。Transfer-Encoding 恰恰相反,用于分块传输时替代定长;Cache-Control 管缓存,Content-Encoding 管压缩,都与长度无关。

Q2. 分块传输编码中,终止块的内容是什么?

单选题 · mid

  • A.两个连续空换行
  • B.发送 END 标志
  • C.零长度加换行← 正确答案
  • D.断开 TCP 连接

答案:C。分块传输的最后一块必须是零长度块,写法是 0\r\n\r\n,表示“菜上完了”。两个空行、END 标志、断开 TCP 都不是标准终止方式,照做会被接收方判定为传输异常。

Q3. SSE 规定服务端响应的 MIME 类型是什么?

单选题 · easy

  • 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 消息中,字段名与字段值之间的分隔符是什么?

单选题 · mid

  • A.冒号加一个空格← 正确答案
  • B.等号加一个空格
  • C.右箭头符号
  • D.竖线加空格

答案:A。SSE 每个字段写成“字段名:值”,有点像传菜小票上的“菜名:数量”。等号、箭头、竖线都不是 SSE 语法,用错了解析器会直接忽略该行。

Q5. OpenAI 流式接口中 choices[0].delta.content 的含义是什么?

单选题 · mid

  • A.回答的完整文本内容
  • B.相对上一段的增量内容← 正确答案
  • C.当前已累计的 token 数
  • D.模型内部隐藏状态信息

答案:B。delta 是“增量”,即相对上一行新产生的文本;把所有 delta.content 拼起来才是全文,类似把每道刚出锅的菜拼成一整桌宴席。完整文本、token 数、隐藏状态都不是 delta 的语义。

Q6. 前端用 fetch 读取流式响应时,逐块取数据用的是哪个 API?

单选题 · mid

  • A.读取流的 getReader 方法← 正确答案
  • B.响应上面的 next 方法
  • C.读取流的 reader 对象
  • D.响应上面的 read 方法

答案:A。response.body 是 ReadableStream,调用 getReader() 拿到阅读器后,每次 reader.read() 取一块 Uint8Array,就像从传送带上逐盒接菜。next/read 并不是 ReadableStream 的标准取块方法。

Q7. 关于分块传输编码的说法,正确的是哪一项?

单选题 · hard

  • A.与 Content-Length 同用
  • B.每块前缀十六进制长度← 正确答案
  • C.只能用于 SSE 场景
  • D.长度必须十进制标注

答案:B。chunked 的每个分块前缀是十六进制长度,且与 Content-Length 互斥(二选一),它不只服务于 SSE,任何长度未知的响应体都能用。十六进制是协议规定,写成十进制会解析失败。

Q8. 要在页面上实现打字机效果,前端需要做哪些事?

多选题 · mid

  • A.用 fetch 流式读取接口← 正确答案
  • B.用 TextDecoder 解码← 正确答案
  • C.更新前先合并累积文本← 正确答案
  • D.等响应全部返回再渲染

答案:ABC。打字机效果四步走:fetch 流式读 → TextDecoder 解码(防止中文被劈成两半)→ 累积拼接 → 合并渲染。D 是典型的“假打字机”——等全部返回再渲染,用户等待时间一点没减少。

Q9. 下列哪些情形需要主动中断流式输出?

多选题 · hard

  • A.用户点击停止生成← 正确答案
  • B.页面组件被卸载← 正确答案
  • C.请求超时准备重试← 正确答案
  • D.收到完成标记也要中断

答案:ABC。用户喊停、组件卸载、超时重试都属于“客人摆手”,需要 AbortController 中断并让服务端停火,否则白白浪费资源。D 中收到完成标记时流已自然结束,不存在进行中的状态需要中断。

Q10. 相比一次性返回,SSE 流式输出的优势有哪些?

多选题 · easy

  • A.首字到达时间更早← 正确答案
  • B.无需缓存完整响应← 正确答案
  • C.支持客户端反推数据
  • D.断线后自动重连← 正确答案

答案:ABD。SSE 让首字先到、客户端边收边渲染、内存无需缓存全文,且浏览器内置自动重连。但 SSE 是服务端单向推送,不支持客户端反向推数据——想“对着服务生喊话”得用 WebSocket。