想象一下,你在一家繁忙的物流分拣中心工作。当一辆满载包裹的卡车驶入时,你不需要等所有包裹卸完才开始处理——你可以把卡车上的包裹按传送带顺序一个一个地扔上传送带,每扔一个就贴一个标签,最后一个包裹贴上“结束”标记。收货方一边接收一边处理,不用干等。这就是 Chunked 分块传输的运作方式:服务器把数据切成一块一块地发送,每块自带长度标签,最后以“结束块”收尾。大模型应用里那种“一个字一个字蹦出来”的流式输出体验,底层正是靠这个机制撑着。当大模型第一个 token 生成完毕,服务器就能通过分块传输把它推送给浏览器,不需要等全文生成完。本文将由浅入深,从协议细节到 LLM 应用场景,带你完整掌握这条“数据传送带”。
理解分块传输编码,你就能真正看懂 LLM 流式输出在 HTTP 层的完整链路——这是 AI 应用工程师调试流式接口、优化首字延迟的必备底层知识。
可视化演示
展示HTTP Chunked分块传输编码的数据流程、与Content-Length模式对比、以及LLM流式推理中逐token输出的底层机制,理解流式通信核心原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Chunked分块传输数据流程 服务器将数据分成多个chunk逐块发送,每个chunk前有十六进制长度行+CRLF 最终以0长度chunk标记结束,客户端逐块接收渲染,无需预知总长度 Chunk = Hex(len) + CRLF + Data + CRLF 流式传输核心机制
数据分块
还是回到那个物流分拣中心。现在有一辆装载了几万件包裹的巨型卡车要卸货,但你不想等它完全停稳才开始工作——你想让传送带边卸边转。最简单的办法就是:从车厢里不断往外搬包裹,每搬一批就在传送带上做一个标记。服务器端的数据和这些包裹一样,大模型生成的文本还没完全成型,但已经生成的部分可以先发出去,这让“边生成边传输”成为可能。
这就是数据分块的核心思想:HTTP 服务器把响应体拆分成多个大小可自定义的“块”(chunk),每个块独立发送,客户端收到一个块就解析一个块。与传统的 Content-Length 方式不同,分块传输不需要预先知道整个响应的字节数——因为块的大小由服务器在生成过程中动态决定,可以实时调整。
从技术细节看,服务器通常将数据按固定大小(如 4KB)切块,也可以按业务逻辑切块。比如在 LLM 场景中,每当模型生成一个新的 token,就把这个 token 作为一块单独发出;也可以等累积了几个 token 再发出,权衡网络开销和实时性。这种灵活性使得分块传输特别适合数据持续产生、总量未知的场景——这正是 LLM 流式推理的典型特征。
传送带上的包裹一个接一个地往前送,服务器也是一块接一块地往外写数据。数据分块把“等待全部生成完成”变成“边生成边发送”,这是 LLM 流式体验的第一块基石。
- 数据总字节数需要传输的完整数据大小,例如大模型生成的完整回答文本。
- 块大小每个分块的最大字节数,比如常见的 4096 字节(4KB)。
- 向上取整如果数据不能整除,最后一块仍然要发送,所以取整到最近的整数。
块格式解析
包裹在传送带上不能随便堆,每个包裹上都要贴一张快递单——单号、目的地、尺寸信息。HTTP 的分块传输也一样,每个块在传送带上必须有明确的“标签”,让接收方知道这个包裹从哪开始、到哪结束、里面装了多少货。这个标签就是块头。
每个 chunk 的格式非常固定:先是块大小(用十六进制表示的字节数),紧跟一个 CRLF(回车换行),然后是块数据本身,最后再跟一个 CRLF 表示本块结束。格式如下:
例如,5\r\nhello\r\n 表示这个块包含 5 个字节的数据"hello"。服务器可以在块大小后面附加一些扩展字段(以分号分隔),不过这些扩展字段在实际应用中很少见,了解即可。
每个块在传送带上是“自描述”的——读一个长度,知道下一段读多少数据;读完数据,看到 CRLF,知道一个块结束了。这种精确的格式让接收方可以流式地解析数据,不需要缓存整个响应体。
- 十六进制长度用十六进制数字字符串表示数据部分的字节数,例如
5表示 5 字节。 - CRLF 分隔符
\r\n是 HTTP 协议中的标准行结束符,用于分隔长度头和数据。 - 数据体实际的负载内容,比如 LLM 生成的一段 token 文本。
- 结束 CRLF当前块的结束标记,为下一个块的开始做铺垫。
终止块与解码
传送带上的包裹终于越来越少,但收货方怎么知道“这一批货已经全部送完了”?你不可能派一个人去源头盯着看,更高效的办法是:最后一个包裹上贴一张红色的"END"标签,看到这个标签就知道不用再等后面的包裹了。HTTP 分块传输的终止块就是这个红色标签。
终止块是一个长度为 0 的块,格式为 0\r\n\r\n。服务器发送完所有数据块后,会发送这个特殊的块来表示传输结束。客户端在解析过程中,一旦读到长度字段为 0,就立即停止读取,不再等待后续数据。这种设计被称为“协议自描述”——数据流自己知道何时结束,不需要外部信息。
解码是编码的逆过程:客户端从响应头读出 Transfer-Encoding: chunked,就知道后续数据需要按块解析。它不断读取“长度行 + 数据体”,直到遇到长度为 0 的终止块。整个过程中,解出来的数据被依次拼接,最终还原完整的响应内容。
- 十六进制零
0表示这一块的长度是 0 字节,即没有数据体。 - 第一个 CRLF紧跟长度字段,终止“长度行”。
- 第二个 CRLF表示“数据体为空”后的块结束标记。
内容长度对比
物流中心还有另一种操作方式:发货前先清点好所有包裹的总数,写在运货单上——“本批共 1000 件”。收货方看到这个总数,就知道该等多少个包裹。这种方式在数据量已知且固定的场景下非常高效,HTTP 里对应的就是 Content-Length 响应头。但问题在于:如果数据还没生成完,你根本没法提前知道总数。让 LLM 先憋着把全文生成完再告诉你“总共 1000 字”?那用户早就等得不耐烦了。
Content-Length 和 Transfer-Encoding: chunked 是互斥的。如果响应中包含 Transfer-Encoding: chunked 头,Content-Length 头就会被忽略。为什么?因为服务器发送分块响应时,数据仍在持续生成,总字节数是未知的,自然无法填写 Content-Length。
在实际应用中的取舍很清晰:如果服务器能提前知道响应长度(比如返回一个静态文件),用 Content-Length 更高效;如果响应是动态生成的(比如 LLM 的流式输出、实时日志),就必须用 chunked。前者的效率来自“预知”,后者的灵活来自“边走边看”。
- 单个块大小每个数据块的实际字节数。
- 累加求和将所有块的大小加起来得到总长度。
- 动态 vs 静态Content-Length 要求 n 固定可预知,而分块传输中 n 是动态增长的。
流式输出原理
现在把镜头拉回大模型应用的真实场景。你打开一个 AI 聊天助手,输入问题,几毫秒后屏幕上开始一个字一个字地蹦出回答。这个“打字机效果”不是魔法,而是分块传输与 LLM 推理机制完美配合的结果。大模型的推理是自回归的——一个 token 一个 token 地生成,第一个 token 最快几毫秒就出来了。分块传输允许服务器在第一个 token 生成完毕的瞬间,立刻把它作为第一个 chunk 发出去。
用户感知到的“首字延迟”大幅降低。如果用 Content-Length 方式,用户必须等待模型生成完所有 token(可能几十秒甚至几分钟)才能看到任何内容。而用 chunked,响应在模型刚开始生成时就“打开闸门”,后续每个 token 生成后立即推送给客户端。浏览器或前端应用通过读取流来逐步渲染内容。
从产品体验角度,分块传输把长长的等待过程切碎,让用户感觉到“AI 在思考、在输出”,缓解了等待焦虑。这也是为什么几乎所有主流 LLM API(OpenAI、Anthropic、国产大模型厂商)的流式接口都基于 chunked 或 SSE 实现。
- 网络 RTT客户端与服务器之间的一个网络往返时间,通常几十到几百毫秒。
- 首 token 生成时间大模型生成第一个 token 所需的计算时间。
- 核心洞察总响应时间虽然没变,但感知延迟从“全文生成时间”缩短为“首个 token 时间”。
代理缓冲机制
数据在传送带上跑,中间可能经过多个“中转站”——HTTP 代理、CDN 边缘节点、负载均衡器。如果中转站的处理方式不合适,即使源服务器已经把数据分块发出来了,用户端依然可能感觉“卡顿”。问题出在缓冲(buffering):某些代理服务器会把收到的数据块攒起来,攒够一定量或等到连接关闭时才一次性转发给下游。
在 LLM 流式场景中,这种缓冲是致命的。假设代理攒 4KB 才转发,而模型每生成一个 token 大约 5 字节,那就意味着要等生成约 800 个 token(可能十几秒)客户端才能看到第一个字——流式体验完全被破坏。解决方法是:配置代理禁用缓冲,强制每个 chunk 到达后立即转发。Nginx 中有 proxy_buffering off 指令,就是为了应对这类实时通信需求。
分块传输与 SSE(Server-Sent Events)经常配合使用:SSE 基于 HTTP 长连接,服务端通过 chunked 不断推送事件流。理解代理缓冲机制,是调试“为什么我的流式接口在测试环境正常、上生产就变卡”这类问题的关键。
- 传输延迟数据在物理链路上传输所需的时间,通常与距离和带宽相关。
- 缓冲延迟代理节点攒数据的时间,这是可以优化消除的。
- 逐跳累加数据经过的每一个节点都会贡献延迟。
连接复用管理
最后一个环节是长连接管理。传统 HTTP/1.0 时代,每次请求都要新建 TCP 连接、握手、传输、断开,效率很低。HTTP/1.1 引入了 Keep-Alive 机制,让 TCP 连接在多个请求/响应之间保持复用。但这给分块传输提出了一个有趣的问题:既然连接是持久的、会继续为下一个请求服务,客户端怎么知道当前响应已经结束、可以解析下一个响应了?
答案还是靠终止块。虽然在 HTTP/1.1 中,Keep-Alive 连接无法靠“关闭连接”来标记响应结束,但 chunked 的 0\r\n\r\n 终止块充当了这个边界标记。客户端读到终止块,就知道当前响应体已经完整接收完毕,连接的下一个字节将是下一个响应的起始行。这种设计让分块传输和持久连接完美兼容。
在 LLM 应用中,同一个用户可能连续向 AI 助手发送多个请求,模型流式回答多次。得益于 Keep-Alive 和 chunked 的协同工作,这些连续请求可以复用同一条 TCP 连接,避免了频繁握手的开销。分块边界标记着响应的结束,连接本身则保留下来为下一次对话服务。
- 总请求数同一 TCP 连接上发送的 HTTP 请求总数。
- TCP 连接数实际建立的底层连接数量。
- 复用率一条连接承载的请求次数越多,性能越好。
AI 应用场景
流式对话接口开发
阶段:LLM 应用部署与接入
解决的问题:传统 API 需要等模型完整生成回答才返回,用户面对几十秒的空白等待,体验很差。基于 Chunked 传输的流式接口可以在每个 token 生成后立即推送,把“死等”变成“实时反馈”。
具体案例:某智能客服系统接入大模型,使用 SSE 协议 + Chunked 传输,用户在输入问题后平均 300ms 就能看到第一个字的回复,而此前完整等待时间为 3-5 秒。客户满意度提升 40%,“幻觉式焦虑”的负反馈大幅减少。开发团队通过 axios 的 onDownloadProgress 回调,配合 ReadableStream 解析分块数据,实现了平滑的打字机效果。
日志实时分析管道
阶段:数据接入与处理
解决的问题:AI 模型训练和推理时的日志量巨大,传统方式等日志写满一个文件再批量上传,无法实时监控模型状态。Chunked 传输让日志像流水一样持续不断地流入分析平台。
具体案例:一家 MLOps 公司的模型监控平台,使用 GitLab CI 构建日志采集 Agent,通过 Chunked 编码将推理日志实时推送到 ClickHouse 数据管道。当模型出现异常 token 时,运维工程师能在 1 秒内收到告警,而不是等日志轮转后再发现。
Python 演示
"""模拟 Chunked 传输编码的流式 LLM 客户端"""import socketimport time def parse_chunked_response(data: bytes) -> bytes: """解析 HTTP Chunked 编码的响应体""" body = b"" pos = 0 data_len = len(data) while pos < data_len: # 查找 CRLF 分隔符 crlf_idx = data.find(b"\r\n", pos) if crlf_idx == -1: break # 读取块大小(十六进制) size_str = data[pos:crlf_idx].decode("ascii").strip() if not size_str: break try: chunk_size = int(size_str, 16) except ValueError: break # 遇到终止块 if chunk_size == 0: break # 移动 pos 到数据起始处 data_start = crlf_idx + 2 data_end = data_start + chunk_size # 避免数据不完整 if data_end + 2 > data_len: break body += data[data_start:data_end] pos = data_end + 2 # 跳过数据末尾的 CRLF return body def mock_llm_stream_response() -> bytes: """模拟 LLM 服务器分块返回数据""" chunks = [ "Hello", ", ", "world", "! This is", " chunked", " encoding." ] response_line = "HTTP/1.1 200 OK\r\n" headers = "Transfer-Encoding: chunked\r\nContent-Type: text/plain\r\n\r\n" body = b"" for chunk in chunks: chunk_bytes = chunk.encode("utf-8") body += f"{len(chunk_bytes):x}\r\n".encode("ascii") body += chunk_bytes + b"\r\n" body += b"0\r\n\r\n" # 终止块 return (response_line + headers).encode("ascii") + body def main(): """主入口:演示分块数据的实时接收与解析""" print("=== 模拟 LLM 流式响应(Chunked 编码)===\n") # 模拟从网络接收数据 raw_data = mock_llm_stream_response() # 分离响应头和数据 header_end = raw_data.find(b"\r\n\r\n") headers = raw_data[:header_end].decode("utf-8") body_data = raw_data[header_end + 4:] print("--- 响应头 ---") print(headers) print("\n--- 原始分块数据 ---") print(repr(body_data)) print("\n--- 流式接收过程 ---") # 模拟逐块到达 pos = 0 while pos < len(body_data): # 每块之间间隔 0.1 秒,模拟网络传输 chunk_end = body_data.find(b"\r\n", pos + 1) if chunk_end == -1: break # 找到块的数据结束位置 size_str = body_data[pos:chunk_end].decode("ascii") try: size = int(size_str, 16) except ValueError: break data_start = chunk_end + 2 data_end = data_start + size if size == 0: print("收到终止块: 0") break part = body_data[data_start:data_end].decode("utf-8") print(f"收到块[{size_str}] -> {part!r}") time.sleep(0.1) pos = data_end + 2 print("\n--- 完整解析结果 ---") result = parse_chunked_response(body_data).decode("utf-8") print(f"解码后内容: {result!r}") assert result == "Hello, world! This is chunked encoding." if __name__ == "__main__": main()
body += f"{len(chunk_bytes):x}\r\n"将块大小转换为十六进制,后面接 CRLF,这是块头。body += chunk_bytes + b"\r\n"数据体后面接 CRLF,标记当前块结束。body += b"0\r\n\r\n"发送零长度终止块,告知客户端传输结束。pos = data_end + 2跳过每个块数据末尾的 CRLF,定位到下一个块的长度字段。raw_data.find(b"\r\n\r\n")利用双 CRLF 定位响应头与响应体的分界点。
常见误区
误区初学 HTTP 时,看到响应头里既有 `Transfer-Encoding: chunked` 又有 `Content-Length`,以为两个可以配合使用,一个表示总长一个表示分块。
正解这两个头是互斥的。RFC 7230 明确规定:如果响应中包含 Transfer-Encoding,Content-Length 必须被忽略。逻辑上也说不通——都分块了,怎么可能提前知道总长度呢?就像传送带上的包裹已经贴了“全程分批次”的标签,再贴一个“共 1000 件”的总数就自相矛盾了。
为何常见:很多教材和博客把两个头分开讲,从没说清互斥关系。实践中,开发者调试时看到响应头里两个头都出现,容易混淆。
误区以为 Chunked 编码就像 TCP 分段一样,每个块的大小必须固定(例如 4KB),不能变。
正解块大小完全由服务器动态决定。可以固定大小切分,也可以按语义切分(如每个 token 一块),甚至每毫秒生成多少发多少。协议只要求每块前标注长度,不限制块大小的一致性。就像快递员每次搬多少件包裹完全取决于自己的节奏,只需在包裹上贴好标签。
为何常见:分块的"chunk"容易让人联想到固定大小的网络包(如 MTU),实际上 HTTP chunk 的粒度是完全灵活的应用层概念。
误区初学者以为客户端必须等所有 chunk 都收到、拼成完整响应后,才能开始处理数据。
正解分块传输的最大价值恰恰是流式处理——收到一块就可以解析一块、渲染一块。浏览器在收到第一个 chunk 时就能开始渲染内容。就像微信聊天时,对方每发一条消息你就看到一条,不需要等对方把整个长消息说完才看。流式解析器按块处理,边收边用。
为何常见:习惯了“完整文件下载再打开”的思路,把 HTTP 响应体当作一个必须先完整接收的 blob,忽视了流式接口的存在。
误区以为只要服务器发了 chunked 响应,任何 HTTP 客户端库都能完美地流式处理、实时回调。
正解实际上很多 HTTP 客户端库默认会缓冲整个响应体,直到接收完毕才触发回调。比如 requests 库默认就不是流式的,必须用 stream=True 参数才能逐块获取数据。在 LLM 应用中,前端必须显式使用支持流式读取的 API(如浏览器原生的 fetch + ReadableStream,或 axios 的 onDownloadProgress),否则体验和普通请求没区别。
为何常见:把“协议层支持”和“应用层支持”混为一谈。协议层定义了分块格式,但应用层是否利用这个格式逐个解析,取决于具体库的封装。
误区以为 Chunked 编码是新技术,专门为 LLM 流式输出发明的。
正解分块传输是 HTTP/1.1 时代就有的标准协议特性(1997 年引入),比 LLM 早了 25 年。它最初是为了解决动态页面生成、实时日志推送等场景。如今它恰好成为 LLM 流式输出的底层支撑,但视频直播、证券行情推送、服务器推送等场景同样依赖它。技术常在,场景常新。
为何常见:很多 AI 开发者从 LLM 应用入手接触 HTTP 协议,容易把自己遇到的第一个场景当作技术的唯一用途。
面试题
Q1. Chunked 分块传输中,每个数据块的块长字段使用什么进制表示?
- A.十进制
- B.二进制
- C.十六进制← 正确答案
- D.八进制
答案:C。RFC 7230 规定,chunk 的大小使用十六进制表示。例如 5\r\nhello\r\n 中的 5 表示数据部分有 5 个字节。记住这块长字段的“十六进制”属性,是解析 Chunked 数据流的起点。
Q2. 下列哪个头字段与 Transfer-Encoding: chunked 互斥?
- A.Content-Type
- B.Content-Length← 正确答案
- C.Connection
- D.Accept-Encoding
答案:B。Content-Length 与 Transfer-Encoding: chunked 互斥。分块传输的数据总量动态未知,无法预先计算 Content-Length,因此 RFC 要求两者同时出现时忽略 Content-Length。
Q3. Chunked 传输中,终止块(terminal chunk)的格式是什么?
- A.
end\r\n\r\n - B.
0\r\n\r\n← 正确答案 - C.
\r\n0\r\n - D.
00\r\n
答案:B。终止块是一个长度为 0 的块,写作 0\r\n\r\n。第一个 CRLF 结束长度行,第二个 CRLF 表示空数据体的结束。客户端读到长度为 0 的块就知道传输完毕,这在某种意义上是对“最后一件包裹”的标记。
Q4. 关于 Chunked 分块传输中「块」的格式,下列说法正确的是?
- A.每个块由「块大小 + CRLF + 数据 + CRLF」组成← 正确答案
- B.块大小用十进制表示
- C.块大小可以附带扩展参数← 正确答案
- D.每个块的数据长度必须一致
答案:AC。每个 chunk 的格式是「十六进制长度 + CRLF + 数据 + CRLF」。块后面可以跟分号附加扩展参数。每个块的大小完全独立,不要求一致。十进制和“等长”都是常见误解——是否相等由服务器决定,不来自协议强制。
Q5. 在 LLM 流式输出场景中,使用分块传输的核心收益是什么?
- A.减少传输的总数据量
- B.降低首 token 的生成延迟
- C.降低用户感知的首字等待时间← 正确答案
- D.减少服务器的 GPU 计算量
答案:C。分块传输不会减少数据总量,也不影响模型生成 token 的速度。它让服务器在第一个 token 生成后立即推送给客户端,把感知等待从“全文生成时间”缩短为“首 token 生成时间”。
Q6. 关于 Chunked 编码与 HTTP/1.1 Keep-Alive 连接的关系,正确的说法是?
- A.Chunked 编码与 Keep-Alive 不兼容
- B.终止块标记了响应体的结束边界← 正确答案
- C.Keep-Alive 连接上可以连续传输多个分块响应← 正确答案
- D.分块编码只能用于短连接
答案:BC。Chunked 和 Keep-Alive 是配合使用的关系。终止块 0\r\n\r\n 恰好充当了响应边界标记,让客户端知道一个响应结束而连接可以继续。这保证了持久连接上多个流式响应可以串行传输。
Q7. 代理服务器在 LLM 流式传输中可能导致用户体验变差的原因是什么?
- A.对响应内容进行压缩
- B.缓冲了分块数据,攒够一定量才转发← 正确答案
- C.修改了响应头的 Content-Type
- D.添加了额外的 Cookie
答案:B。代理缓冲是分块流式的头号敌人。如果代理攒满 4KB 才转发,而模型每 token 只有几字节,客户端要等几百个 token 才能看到第一个字。禁用代理缓冲是流式传输落地时的关键调优手段。
Q8. 以下哪个响应头表示服务器使用了分块传输编码?
- A.
Content-Type: text/event-stream - B.
Transfer-Encoding: chunked← 正确答案 - C.
Content-Encoding: gzip - D.
Connection: keep-alive
答案:B。Transfer-Encoding: chunked 是分块传输的标识符。Content-Encoding 表示内容压缩方式,Connection: keep-alive 表示连接复用,只有 Transfer-Encoding 才是分块传输的开关。
Q9. 在 Python 的 requests 库中实现 LLM 流式输出接收,以下做法正确的是?
- A.直接使用
requests.get()会一次性缓冲整个响应← 正确答案 - B.设置
stream=True并遍历response.iter_lines()可以逐块获取← 正确答案 - C.必须使用 websocket 才能实现流式输出
- D.设置
stream=True后,响应体会自动变成流式迭代对象← 正确答案
答案:ABD。requests.get() 默认缓冲整个响应体,stream=True 可以避免这一行为,并使 iter_lines() 可以逐块迭代。流式输出并不需要 websocket,HTTP 分块传输 + 流式解析即可实现。
Q10. 终止块之后还追加了 \r\n,这一设计的目的是什么?
- A.增加传输的数据量
- B.与数据块的 CRLF 格式保持一致← 正确答案
- C.表明服务器准备关闭连接
- D.便于客户端区分不同的 TCP 报文段
答案:B。终止块 0\r\n\r\n 的完整写法是 0 + CRLF + CRLF。第一个 CRLF 表示“长度行为空”,第二个 CRLF 表示“数据体为空”,格式与普通数据块的「长度行 + 分隔符 + 数据 + 分隔符」保持一致,让解析器可以用同一套逻辑处理所有块。