想象一下,你在一家繁忙的物流分拣中心工作。当一辆满载包裹的卡车驶入时,你不需要等所有包裹卸完才开始处理——你可以把卡车上的包裹按传送带顺序一个一个地扔上传送带,每扔一个就贴一个标签,最后一个包裹贴上“结束”标记。收货方一边接收一边处理,不用干等。这就是 Chunked 分块传输的运作方式:服务器把数据切成一块一块地发送,每块自带长度标签,最后以“结束块”收尾。大模型应用里那种“一个字一个字蹦出来”的流式输出体验,底层正是靠这个机制撑着。当大模型第一个 token 生成完毕,服务器就能通过分块传输把它推送给浏览器,不需要等全文生成完。本文将由浅入深,从协议细节到 LLM 应用场景,带你完整掌握这条“数据传送带”。

岗位关联

理解分块传输编码,你就能真正看懂 LLM 流式输出在 HTTP 层的完整链路——这是 AI 应用工程师调试流式接口、优化首字延迟的必备底层知识。

目录
  1. 可视化演示
  2. 数据分块
  3. 块格式解析
  4. 终止块与解码
  5. 内容长度对比
  6. 流式输出原理
  7. 代理缓冲机制
  8. 连接复用管理
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示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 流式体验的第一块基石。

Chunk 数量=⌈数据总字节数块大小⌉\text{Chunk 数量} = \left\lceil \frac{\text{数据总字节数}}{\text{块大小}} \right\rceil
这个公式表示:将数据按固定块大小切分,向上取整得到块数。如果总长 1000 字节、块大小 256 字节,就需要 ceil(1000/256) = 4 块。
  1. 数据总字节数需要传输的完整数据大小,例如大模型生成的完整回答文本。
    数据总字节数\text{数据总字节数}
  2. 块大小每个分块的最大字节数,比如常见的 4096 字节(4KB)。
    块大小=4096\text{块大小} = 4096
  3. 向上取整如果数据不能整除,最后一块仍然要发送,所以取整到最近的整数。
    ⌈数据总字节数块大小⌉=⌈1000256⌉=4\left\lceil \frac{\text{数据总字节数}}{\text{块大小}} \right\rceil = \left\lceil \frac{1000}{256} \right\rceil = 4

块格式解析

包裹在传送带上不能随便堆,每个包裹上都要贴一张快递单——单号、目的地、尺寸信息。HTTP 的分块传输也一样,每个块在传送带上必须有明确的“标签”,让接收方知道这个包裹从哪开始、到哪结束、里面装了多少货。这个标签就是块头。

每个 chunk 的格式非常固定:先是块大小(用十六进制表示的字节数),紧跟一个 CRLF(回车换行),然后是块数据本身,最后再跟一个 CRLF 表示本块结束。格式如下:

例如,5\r\nhello\r\n 表示这个块包含 5 个字节的数据"hello"。服务器可以在块大小后面附加一些扩展字段(以分号分隔),不过这些扩展字段在实际应用中很少见,了解即可。

每个块在传送带上是“自描述”的——读一个长度,知道下一段读多少数据;读完数据,看到 CRLF,知道一个块结束了。这种精确的格式让接收方可以流式地解析数据,不需要缓存整个响应体。

Chunk=HexLength+CRLF+Data+CRLF\text{Chunk} = \text{HexLength} + \text{CRLF} + \text{Data} + \text{CRLF}
这个格式表明:每个块由“长度头 + 分隔符 + 数据体 + 分隔符”四部分组成。接收方先读十六进制长度,就知道接下来要读多少字节的数据。
  1. 十六进制长度用十六进制数字字符串表示数据部分的字节数,例如 5 表示 5 字节。
    HexLength=516=510\text{HexLength} = 5_{16} = 5_{10}
  2. CRLF 分隔符\r\n 是 HTTP 协议中的标准行结束符,用于分隔长度头和数据。
    CRLF=0x0D 0x0A\text{CRLF} = \text{0x0D 0x0A}
  3. 数据体实际的负载内容,比如 LLM 生成的一段 token 文本。
    Data="hello"\text{Data} = \text{"hello"}
  4. 结束 CRLF当前块的结束标记,为下一个块的开始做铺垫。
    CRLF 再次出现\text{CRLF 再次出现}

终止块与解码

传送带上的包裹终于越来越少,但收货方怎么知道“这一批货已经全部送完了”?你不可能派一个人去源头盯着看,更高效的办法是:最后一个包裹上贴一张红色的"END"标签,看到这个标签就知道不用再等后面的包裹了。HTTP 分块传输的终止块就是这个红色标签。

终止块是一个长度为 0 的块,格式为 0\r\n\r\n。服务器发送完所有数据块后,会发送这个特殊的块来表示传输结束。客户端在解析过程中,一旦读到长度字段为 0,就立即停止读取,不再等待后续数据。这种设计被称为“协议自描述”——数据流自己知道何时结束,不需要外部信息。

解码是编码的逆过程:客户端从响应头读出 Transfer-Encoding: chunked,就知道后续数据需要按块解析。它不断读取“长度行 + 数据体”,直到遇到长度为 0 的终止块。整个过程中,解出来的数据被依次拼接,最终还原完整的响应内容。

终止块=016+CRLF+CRLF\text{终止块} = 0_{16} + \text{CRLF} + \text{CRLF}
终止块的核心特征:长度为零、没有数据体、双 CRLF 结束。客户端读到它就知道整个响应体已经传输完毕。
  1. 十六进制零0 表示这一块的长度是 0 字节,即没有数据体。
    长度字段=016\text{长度字段} = 0_{16}
  2. 第一个 CRLF紧跟长度字段,终止“长度行”。
    CRLF=0x0D 0x0A\text{CRLF} = \text{0x0D 0x0A}
  3. 第二个 CRLF表示“数据体为空”后的块结束标记。
    CRLF 再次出现\text{CRLF 再次出现}

内容长度对比

物流中心还有另一种操作方式:发货前先清点好所有包裹的总数,写在运货单上——“本批共 1000 件”。收货方看到这个总数,就知道该等多少个包裹。这种方式在数据量已知且固定的场景下非常高效,HTTP 里对应的就是 Content-Length 响应头。但问题在于:如果数据还没生成完,你根本没法提前知道总数。让 LLM 先憋着把全文生成完再告诉你“总共 1000 字”?那用户早就等得不耐烦了。

Content-Length 和 Transfer-Encoding: chunked 是互斥的。如果响应中包含 Transfer-Encoding: chunked 头,Content-Length 头就会被忽略。为什么?因为服务器发送分块响应时,数据仍在持续生成,总字节数是未知的,自然无法填写 Content-Length。

在实际应用中的取舍很清晰:如果服务器能提前知道响应长度(比如返回一个静态文件),用 Content-Length 更高效;如果响应是动态生成的(比如 LLM 的流式输出、实时日志),就必须用 chunked。前者的效率来自“预知”,后者的灵活来自“边走边看”。

Content-Length=∑i=1nchunk_sizei\text{Content-Length} = \sum_{i=1}^{n} \text{chunk\_size}_i
这个公式表示:若使用 Content-Length,其值必须等于所有块大小的总和。但分块传输时 n 是动态的、数据流是持续的,这个和无法预先计算,因此两者不能并存。
  1. 单个块大小每个数据块的实际字节数。
    chunk_sizei\text{chunk\_size}_i
  2. 累加求和将所有块的大小加起来得到总长度。
    ∑i=1nchunk_sizei\sum_{i=1}^{n} \text{chunk\_size}_i
  3. 动态 vs 静态Content-Length 要求 n 固定可预知,而分块传输中 n 是动态增长的。
    固定长度vs动态分块\text{固定长度} \quad \text{vs} \quad \text{动态分块}

流式输出原理

现在把镜头拉回大模型应用的真实场景。你打开一个 AI 聊天助手,输入问题,几毫秒后屏幕上开始一个字一个字地蹦出回答。这个“打字机效果”不是魔法,而是分块传输与 LLM 推理机制完美配合的结果。大模型的推理是自回归的——一个 token 一个 token 地生成,第一个 token 最快几毫秒就出来了。分块传输允许服务器在第一个 token 生成完毕的瞬间,立刻把它作为第一个 chunk 发出去。

用户感知到的“首字延迟”大幅降低。如果用 Content-Length 方式,用户必须等待模型生成完所有 token(可能几十秒甚至几分钟)才能看到任何内容。而用 chunked,响应在模型刚开始生成时就“打开闸门”,后续每个 token 生成后立即推送给客户端。浏览器或前端应用通过读取流来逐步渲染内容。

从产品体验角度,分块传输把长长的等待过程切碎,让用户感觉到“AI 在思考、在输出”,缓解了等待焦虑。这也是为什么几乎所有主流 LLM API(OpenAI、Anthropic、国产大模型厂商)的流式接口都基于 chunked 或 SSE 实现。

首字延迟=网络RTT+首 token 生成时间\text{首字延迟} = \text{网络RTT} + \text{首 token 生成时间}
这个公式表明:用户看到第一个字的等待时间,只包含网络往返延迟加上模型生成第一个 token 的时间,而不是整个响应的生成时间。这正是流式输出体验的核心优势。
  1. 网络 RTT客户端与服务器之间的一个网络往返时间,通常几十到几百毫秒。
    网络RTT=请求到达 + 响应返回\text{网络RTT} = \text{请求到达 + 响应返回}
  2. 首 token 生成时间大模型生成第一个 token 所需的计算时间。
    首 token 生成时间=f(模型大小,问题复杂度)\text{首 token 生成时间} = f(\text{模型大小}, \text{问题复杂度})
  3. 核心洞察总响应时间虽然没变,但感知延迟从“全文生成时间”缩短为“首个 token 时间”。
    感知延迟=min⁡(全文时间,首 token 时间)\text{感知延迟} = \min(\text{全文时间}, \text{首 token 时间})

代理缓冲机制

数据在传送带上跑,中间可能经过多个“中转站”——HTTP 代理、CDN 边缘节点、负载均衡器。如果中转站的处理方式不合适,即使源服务器已经把数据分块发出来了,用户端依然可能感觉“卡顿”。问题出在缓冲(buffering):某些代理服务器会把收到的数据块攒起来,攒够一定量或等到连接关闭时才一次性转发给下游。

在 LLM 流式场景中,这种缓冲是致命的。假设代理攒 4KB 才转发,而模型每生成一个 token 大约 5 字节,那就意味着要等生成约 800 个 token(可能十几秒)客户端才能看到第一个字——流式体验完全被破坏。解决方法是:配置代理禁用缓冲,强制每个 chunk 到达后立即转发。Nginx 中有 proxy_buffering off 指令,就是为了应对这类实时通信需求。

分块传输与 SSE(Server-Sent Events)经常配合使用:SSE 基于 HTTP 长连接,服务端通过 chunked 不断推送事件流。理解代理缓冲机制,是调试“为什么我的流式接口在测试环境正常、上生产就变卡”这类问题的关键。

端到端延迟=∑每跳(传输延迟+缓冲延迟)\text{端到端延迟} = \sum_{\text{每跳}}\left( \text{传输延迟} + \text{缓冲延迟} \right)
这个公式表示:数据从服务器到客户端的路上,每一跳都会增加延迟,其中缓冲延迟往往是块传输中被忽视的元凶。每多一个缓冲节点,用户感知的延迟就多一分。
  1. 传输延迟数据在物理链路上传输所需的时间,通常与距离和带宽相关。
    传输延迟=数据大小带宽\text{传输延迟} = \frac{\text{数据大小}}{\text{带宽}}
  2. 缓冲延迟代理节点攒数据的时间,这是可以优化消除的。
    缓冲延迟=代理攒满缓冲区的等待时间\text{缓冲延迟} = \text{代理攒满缓冲区的等待时间}
  3. 逐跳累加数据经过的每一个节点都会贡献延迟。
    ∑每跳(⋯ )\sum_{\text{每跳}}\left( \cdots \right)

连接复用管理

最后一个环节是长连接管理。传统 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 连接,避免了频繁握手的开销。分块边界标记着响应的结束,连接本身则保留下来为下一次对话服务。

Keep-Alive 复用率=总请求数TCP 连接数\text{Keep-Alive 复用率} = \frac{\text{总请求数}}{\text{TCP 连接数}}
这个公式衡量连接复用的效率。复用率越高,网络开销越低。分块传输通过终止块标记响应边界,使得多个流式响应可以在同一条 TCP 连接上安全地串行传输。
  1. 总请求数同一 TCP 连接上发送的 HTTP 请求总数。
    总请求数=N\text{总请求数} = N
  2. TCP 连接数实际建立的底层连接数量。
    TCP 连接数=1\text{TCP 连接数} = 1
  3. 复用率一条连接承载的请求次数越多,性能越好。
    N1=N(N≥1)\frac{N}{1} = N \quad (N \geq 1)

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

python119 行
"""模拟 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()
目的:演示 Chunked 分块传输的核心机制——服务端如何将数据切块、每个块的格式、以及客户端如何解析并重组数据。
  1. body += f"{len(chunk_bytes):x}\r\n"将块大小转换为十六进制,后面接 CRLF,这是块头。
  2. body += chunk_bytes + b"\r\n"数据体后面接 CRLF,标记当前块结束。
  3. body += b"0\r\n\r\n"发送零长度终止块,告知客户端传输结束。
  4. pos = data_end + 2跳过每个块数据末尾的 CRLF,定位到下一个块的长度字段。
  5. 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 分块传输中,每个数据块的块长字段使用什么进制表示?

单选题 · easy

  • A.十进制
  • B.二进制
  • C.十六进制← 正确答案
  • D.八进制

答案:C。RFC 7230 规定,chunk 的大小使用十六进制表示。例如 5\r\nhello\r\n 中的 5 表示数据部分有 5 个字节。记住这块长字段的“十六进制”属性,是解析 Chunked 数据流的起点。

Q2. 下列哪个头字段与 Transfer-Encoding: chunked 互斥?

单选题 · mid

  • 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)的格式是什么?

单选题 · easy

  • 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 分块传输中「块」的格式,下列说法正确的是?

多选题 · mid

  • A.每个块由「块大小 + CRLF + 数据 + CRLF」组成← 正确答案
  • B.块大小用十进制表示
  • C.块大小可以附带扩展参数← 正确答案
  • D.每个块的数据长度必须一致

答案:AC。每个 chunk 的格式是「十六进制长度 + CRLF + 数据 + CRLF」。块后面可以跟分号附加扩展参数。每个块的大小完全独立,不要求一致。十进制和“等长”都是常见误解——是否相等由服务器决定,不来自协议强制。

Q5. 在 LLM 流式输出场景中,使用分块传输的核心收益是什么?

单选题 · mid

  • A.减少传输的总数据量
  • B.降低首 token 的生成延迟
  • C.降低用户感知的首字等待时间← 正确答案
  • D.减少服务器的 GPU 计算量

答案:C。分块传输不会减少数据总量,也不影响模型生成 token 的速度。它让服务器在第一个 token 生成后立即推送给客户端,把感知等待从“全文生成时间”缩短为“首 token 生成时间”。

Q6. 关于 Chunked 编码与 HTTP/1.1 Keep-Alive 连接的关系,正确的说法是?

多选题 · hard

  • A.Chunked 编码与 Keep-Alive 不兼容
  • B.终止块标记了响应体的结束边界← 正确答案
  • C.Keep-Alive 连接上可以连续传输多个分块响应← 正确答案
  • D.分块编码只能用于短连接

答案:BC。Chunked 和 Keep-Alive 是配合使用的关系。终止块 0\r\n\r\n 恰好充当了响应边界标记,让客户端知道一个响应结束而连接可以继续。这保证了持久连接上多个流式响应可以串行传输。

Q7. 代理服务器在 LLM 流式传输中可能导致用户体验变差的原因是什么?

单选题 · mid

  • A.对响应内容进行压缩
  • B.缓冲了分块数据,攒够一定量才转发← 正确答案
  • C.修改了响应头的 Content-Type
  • D.添加了额外的 Cookie

答案:B。代理缓冲是分块流式的头号敌人。如果代理攒满 4KB 才转发,而模型每 token 只有几字节,客户端要等几百个 token 才能看到第一个字。禁用代理缓冲是流式传输落地时的关键调优手段。

Q8. 以下哪个响应头表示服务器使用了分块传输编码?

单选题 · easy

  • 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 流式输出接收,以下做法正确的是?

多选题 · hard

  • 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,这一设计的目的是什么?

单选题 · mid

  • A.增加传输的数据量
  • B.与数据块的 CRLF 格式保持一致← 正确答案
  • C.表明服务器准备关闭连接
  • D.便于客户端区分不同的 TCP 报文段

答案:B。终止块 0\r\n\r\n 的完整写法是 0 + CRLF + CRLF。第一个 CRLF 表示“长度行为空”,第二个 CRLF 表示“数据体为空”,格式与普通数据块的「长度行 + 分隔符 + 数据 + 分隔符」保持一致,让解析器可以用同一套逻辑处理所有块。