大模型流式 chunk 会因「网络波动」乱序吗?——TCP 的顺序保证,与「拼接 / 交错 / 截断」三件真身
用 Node 搭 Agent 的人,几乎都问过同一句话:
大模型把 token 一帧帧吐回来,路上要过抖动、丢包、代理、网关——chunk 会不会乱序?我是不是得在客户端按时间戳重排一遍?
先给结论,三条:
- 一条流内部不会乱序。 这不是"概率很低",而是传输层的投递语义:IP 层确实会丢包、也可能乱序到达,但重排和去重由 TCP 承担,交给应用的永远是有序字节流。所以客户端既没必要重排,也没有重排的依据(你的 chunk 上并没有一个"网络序号"可排)。
- 你真正会踩的坑是另外三件事:分帧(chunk 边界不等于字符/帧边界,读错就烂码、丢帧)、交错(一个 Agent 同时跑多路流,合流看起来"跳")、截断(中途断流,拿到一段残文却以为写完了)。这三件里没有一件是"乱序"。
- 唯一真能"乱序"的地方,是写出顺序本身——而那时该修的是写出方,不是给客户端加一个重排器(它会忠实地把错误的顺序搬给你,也不会替你纠正)。
下面把规范逐字原文、7 组本机实测、生产清单都摆出来。全文的实测脚本随仓库留档在 source/sse-assembly-lab/(harness.mjs + harness.log),零依赖、不需要 API key,可复现。
0. 一张表先对齐:你担心的 vs 真身
| 你可能担心的现象 | 真身 | 本实验编号 |
|---|---|---|
| chunk 在网络上跑一圈乱序了 | 传输层保证不换序;乱序只能来自「写出顺序」 | 2 / 4 / 7 |
一次 read 拿到半条 JSON,解析失败 | 真问题,但它叫「分帧」,不叫乱序 | 3 |
| 汉字被切成两半,渲染出乱码方块 | 真问题,属多字节字符被 chunk 拦腰截断 | 3 |
| 一个 Agent 的多个流混在一起 | 那是「交错」;每一路各自有序,按 lane 归档即可 | 6 |
| 断线后拼出一段残文,还以为成功了 | 这才是线上最常见的真故障:截断(不是乱序) | 5 |
一句话概括:「乱序」是伪问题,「分帧 + 截断」是真问题。
1. 为什么传输层不会乱序——逐字规范
先把最硬的一条摆出来。TCP 的标准(RFC 9293,2022 年,取代 RFC 793)在 §2.2 第一句就是:
【官方原文】"TCP provides a reliable, in-order, byte-stream service to applications." —— RFC 9293 §2.2 Key TCP Concepts(抓取日期 2026-09-22,
curl取.txt版,HTTP 200)
紧跟的一句交代了"靠什么做到":应用字节流被切成 TCP 段、每段装进一个 IP 数据报发出去。IP 层是不可靠的——会丢、会重复、可能乱序到达;但 TCP 用序列号把这些在交付给应用之前整理好。所以你 fetch 出来的 response.body,是一条有序、无洞、无重复的字节流。
那 HTTP/2 多路复用呢?一条连接上跑多个流、帧互相穿插,会不会把某一条流搞乱?RFC 9113 §5 写明:
【官方原文】"A single HTTP/2 connection can contain multiple concurrently open streams, with either endpoint interleaving frames from multiple streams." —— RFC 9113 §5 Streams and Multiplexing(抓取日期 2026-09-22,
curl,HTTP 200)
关键是紧接着的这一条:
【官方原文】"The order in which frames are sent is significant. Recipients process frames in the order they are received. In particular, the order of HEADERS and DATA frames is semantically significant." —— RFC 9113 §5
拆开读:不同流之间可以交错(这正是多路复用的意义),但同一条流内部的帧顺序被规范定义为"有语义的",接收方按收到顺序处理。你的 SSE 响应独占一条流,所以它内部照样有序。(HTTP/3 把承载换成 QUIC,道理相同:单条流有序,多流之间互不阻塞——这一条本文未实测,见 §7 局限。)
【本人推断】正因如此,客户端"重排"这件事在协议层是没有立足点的——chunk 上没有"网络序号"这种字段可供你排序,你能拿到的"序号"只有服务端写在载荷里的(
id:、content_block.index、tool_calls[].index)。它们标识的是"这一份属于哪个块/哪条并行通道",不是"它是第几个网络包"。
2. 实测:抖动和停顿下,拼接结果逐字节相同
光说规范不够,我造了一个可注入延迟的中间代理(TCP 层透明转发),跑同一段 40 帧的流:
| 读取路径 | 网络块数 | 块间隔(最大 / 均值) | 收到顺序 | 拼接文本 |
|---|---|---|---|---|
| 直连 | 41 | 约 2ms | 1…40 一致 | 期望文本 |
| 经「抖动」代理(每块随机延迟 0–25ms) | 41 | 24ms / 11ms | 1…40 一致 | 与直连逐字节相同 |
| 经「停顿」代理(第 10 块卡住 400ms) | 41 | 406ms | 1…40 一致 | 与直连逐字节相同 |
「停顿」那一路是故意模仿真实丢包的观感:TCP 丢了一个段,接收端会缓冲住后面的段、不交付给应用,直到丢失段重传成功——表现出来就是先卡一下、然后一口气补齐。它的确是"抖动",但它不是换序。
【本人实测】三条路径的拼接结果逐字节相同(实验 7:长度 40/40/40,两两相等)。所以"网络波动影响拼接"这句话,准确的说法是:波动只改变"到达时刻",不改变"按序拼接的结果"。
反过来说,如果你的 UI 出现"卡一下"然后一大段字蹦出来,那不是乱序、不是拼接 bug,而是"流不流"的经典问题(代理缓冲、gzip 缓冲)——这一条我在 Agent 流式传输选型 里实测过:开了 gzip 之后 50 次数据变成 1 次,缓冲代理把首块延迟拉平到末块。
3. 那"乱序"到底从哪来?只有三种,且都不是网络
实验 4 直接把这个可能性做出来了:让服务端自己按错序写(1,3,2,5,4…):
服务端按 …1,3,2,5,4… 的错序写出
客户端收到顺序 = 1,3,2,4,6,5,7,9,8,10, … → 与写出顺序逐位一致,未被"自动纠正"
到达顺序是否单调递增:否
后果:拼接文本被写坏 → 文本正确?否
传输层做的恰恰是"忠实照搬":你写什么顺序,对面就读到什么顺序——它不会替你打乱,也不会替你纠正。于是"乱序"的三种真实来源就清楚了:
① 写出顺序本身错了。 上面这一例。谁写的、谁修:检查写出侧是否串行(await 一个接一个),有没有把"并发完成顺序"当成了"发送顺序"。如果是多来源汇聚(比如多个 worker 往同一条流里写),就必须在载荷里带 seq / 序号,让消费端能检出乱序,而不是默默拼错。
② 多路流被合流到一起(实验 6)。 一个 Agent 同时跑两个子任务、或两个并行工具调用,两路流各自的 chunk 到达是交错的:
两路合流后前 16 条到达次序:A1 B1 A2 B2 A3 B3 A4 B4 A5 B5 A6 B6 A7 B7 A8 B8
看起来"跳来跳去",但每一路各自严格递增:是
这是交错,不是乱序。处置办法是把每路打上 lane id(会话 id / runId / content_block.index / tool_calls[].index)归档到各自的累积器,而不要用一个全局字符串去接所有流——那才是真会拼错的做法。
③ 中间件自己写错了。 我写这个代理时就踩了:up.on('data', async (chunk) => { await sleep(random); sock.write(chunk) }) —— 每个块各自 await,延迟小的先写出去,顺序立刻被我自己搞乱了。改成 promise 串行链才恢复有序。(所以:任何"转发/代理/聚合"组件,都必须保证同一条流内串行写出。)
**【本人推断】**把这三条合起来看,结论有点反直觉:你担心"网络乱序",但网络是这条链路上唯一一个保证不乱序的环节。 会乱序的,全是代码。
4. 真正的"拼接问题":chunk 边界 ≠ 字符/帧边界
这才是 99% 的人在"拼接"上真正踩的坑。SSE 是文本行协议,而 fetch 给你的 chunk 是TCP 层的字节块——两者边界毫不相干。一个字节块可能:
- 只装得下半个汉字(UTF-8 里一个汉字 3 字节);
- 只装得下半行
data:; - 或者一口气装下两三个完整事件。
实验 3 让服务端把 2036 字节按 11 字节硬切(其中 6 个切口落在多字节字符中间),然后对比两种读法:
✗ 错误读法——每个网络块单独解码、单独解析(很多 demo 就是这么写的):
const s = Buffer.from(value).toString('utf8'); // 坑 1:块内可能只是半个汉字
for (const line of s.split('\n')) { // 坑 2:块内可能只是半行
if (!line.startsWith('data:')) continue;
events.push(JSON.parse(line.slice(5))); // 坑 3:半条 JSON 直接抛异常
}
✗ 逐块 toString + JSON.parse:收到 186 个网络块 → 解出 0/40 帧
替换字符(U+FFFD,即被切断的汉字)出现 15 次,JSON 解析失败 18 次
40 帧,一帧都没解出来。 注意这里的失败不是乱序——是"边界没对齐"。你就算给这些 chunk 排序,也依然一帧都拼不出来。
✓ 正确读法——攒字节、按帧切、再解析:
// 关键 1:TextDecoder 的 stream:true —— 让它自己攒住"半个多字节字符"
// 关键 2:只按 '\n\n'(空行)切帧 —— 攒够一整帧才解析
const reader = res.body.getReader();
const dec = new TextDecoder('utf-8');
let buf = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const raw = buf.slice(0, i);
buf = buf.slice(i + 2); // 残帧留在 buf 里,等下一轮数据
const ev = parseEvent(raw);
if (ev) handle(ev);
}
}
✓ 攒字节 + 按 \n\n 切帧:收到 40/40 帧,拼接文本 == 期望:是
帧内的字段解析也要照规范来(WHATWG HTML §9.2.5,抓取日期 2026-09-22):
【官方原文】"If the field name is "data" Append the field value to the data buffer, then append a single U+000A LINE FEED (LF) character to the data buffer."
即 data: 可以出现多行,多行要用 \n 拼成一条;: 开头的行是注释(心跳 : ping 就走这里),必须跳过。顺带一提,规范还规定事件流恒为 UTF-8、且不允许指定别的编码——所以解码统一走 UTF-8 是对的,TextDecoder(..., {stream:true}) 就是它的正确用法。(Node 里另一种等价写法是 res.setEncoding('utf8'),让 stream 层替你处理跨块的半个字符。)
5. 真正的线上故障:截断(不是乱序)
如果说"乱序"是伪问题,那截断就是那个天天发生、却最容易被忽略的真故障。实验 5:服务端写完第 24 帧、第 25 帧只写了 24 字节,然后强断 socket:
期望 40 帧 + [DONE];实际收到 24 帧
读到终帧 [DONE]:否 底层是否抛错:是(terminated)
结尾残留半帧 24 字节 → 按规范必须丢弃,不 dispatch
判定"没流完"的两个信号:① 没等到 [DONE]/message_stop 终帧:是 ② 结尾残留非空:是
三件事值得注意:
① 已收到的 24 帧是有效的,别扔。 流式的好处就是"边收边用",断在中间不代表前面白干。
② 那 24 字节的尾巴必须丢弃——这是规范规定的,不是我的选择。 WHATWG 在 §9.2.5 里逐字写着:
【官方原文】"Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an event, before the final empty line, the incomplete event is not dispatched.)"
所以正确读法天然不会被那半帧骗到(没有空行就不成帧);反过来说,如果你自己手搓解析、看到什么都往 JSON.parse 里塞,就会在这里得到一个"看起来像坏帧"的异常——然后又误以为是"乱序导致的"。
③ "没流完"必须有信号,不能靠"文本看起来结束了"来判断。 两个可靠信号:
- 没有终帧:OpenAI 兼容端点以
data: [DONE]收尾,Anthropic 以message_stop收尾(本站已在 llm-format 流式篇 用真实抓包核对过这两家的收尾事件)。收到终帧才算完成; - 底层抛错 / 结尾残留非空:本次实测中 undici 直接抛了
terminated。要在读循环外面try/catch,把它归类为"截断"而不是"程序 bug"。
5.1 断流之后怎么办:两条路,别混
路 A:重发整条请求(大多数情况)。 各家流式 API 普遍不提供"从第 N 个 token 续写"(这一条本文未对各家文档逐一实测,见 §7)。所以重试 = 重新生成。这里有个致命细节:
重试得到的不是"同一串字节的续集",而是另一次生成。 把你已经渲染的半截文本 + 重试结果拼起来,是错的(模型是自回归采样,两次输出不会在断点处接得上)。要么整体替换,要么把已渲染部分明确标注为"未完成/已作废"。
路 B:自建续传(你自己给浏览器供 SSE 时)。 规范给了现成的机制:
【官方原文】"The
Last-Event-IDHTTP request header reports an EventSource object's last event ID string to the server when the user agent is to reestablish the connection." —— WHATWG HTML §9.2.4
配一条 §9.2.5 的 retry 字段设定重连间隔,服务端保留一段可重放缓冲即可。MCP 的 Streamable HTTP 传输也是这条路(本站 MCP 系列 记过)。注意:这是"你自己当服务端"时的能力;上游模型 API 是否支持,是另一回事。
另外,重试要配幂等键,避免"重试两次、扣两次费、触发两次工具"。但要说清楚边界:
**【本人推断】**幂等键解决的是"同一请求别重复执行",不解决"重试要接着写"。流式生成的不确定性意味着"重试必然换一串文本",这两件事不能混为一谈——具体的键名与语义请以各家当期文档为准(本文档抓取受限,见 §7)。
6. 生产 checklist(流式消费侧)
□ 解码:TextDecoder(..., {stream:true}) 或 setEncoding('utf8');绝不逐块 toString
□ 分帧:按 '\n\n' 切帧;跨块残帧进缓冲区;':' 开头的注释(心跳)跳过;多行 data 用 '\n' 拼
□ 终帧:OpenAI 兼容看 data: [DONE];Anthropic 看 message_stop —— 收到才算完成
□ 截断:没有终帧 = 未完成;把底层异常归类为"截断",别当程序 bug 吞掉
□ 心跳:长思考期服务端发注释帧保活(否则网关空闲超时掐断 → 截断,不是乱序)
□ 多路:每路打 lane id 归档,别用一个全局字符串接所有流
□ 重试:退避 + jitter + 幂等键;明确"重试=重新生成",不要把半截文本续上去
□ 自建续传:id + Last-Event-ID + retry 三件套(你自己当服务端时)
□ 上限:给累积文本设长度/超时上限,防单条流无限增长
□ 观测:单独统计"首帧延迟"与"是否收到终帧"——后者能直接暴露截断率
☐ 别做:给 chunk 按时间戳/长度"重排"——没有依据,只会引入新 bug
7. 本文的局限
- 所有实测在本机回环、单进程、Node 24.19.0 下完成,没有真实网络、没有 nginx/CDN/网关;"抖动/停顿代理"是行为等价的最小复现,不是 nginx 本身;
- 实验 4 的"乱序"是故意让服务端写错序造出来的,它证明的是"顺序由写出决定",不是"网络会乱序"的证据;把它当成网络乱序的证据是误读;
- 实验 3 的切口数(11 字节硬切、6 个落在多字节字符中间)依赖具体文本与切法,换内容会变;它证明的是机制,不是一个固定的故障率;
- 未实测:HTTP/2 / HTTP/3 下"单流内有序"(本实验只跑 HTTP/1.1 回环,该结论引自规范原文);两家厂商流式接口的续传/幂等实际行为;
- 官方原文与本人实测是两类陈述,正文中已分别标注。RFC 与 WHATWG 的逐字引文、URL、抓取日期与抓取方式留档在
source/sse-assembly-lab/official-sources.md;其中 Anthropic / OpenAI 的文档页在本机抓不到正文(客户端渲染 + 区域限制 / 403),故本文不对这两家的流式细节做"官方原文"式引用,涉及它们的事件名一律以本站真实抓包为准。
关联
- 帧格式与传输选型的地基:SSE 是什么、Agent 流式传输选型(后者讲清了"压缩/代理会杀死流式",正是"卡顿"的真凶)
- 两家流式协议的真实抓包与打字机脚本:LLM 流式(SSE)与打字机
- 上游协议:MCP 系列(
Last-Event-ID续传的工业用法)
自测
- 为什么说"客户端给 chunk 重排"在协议层没有立足点?你的 chunk 上有"网络序号"吗?
- HTTP/2 一条连接上多流交错,会不会把某一条流搞乱?规范依据是哪一句?
- "一次
read拿到半条 JSON"属于哪类问题?为什么给它排序也解决不了? TextDecoder为什么要stream: true?不写会看到什么现象?- 流被中途掐断,有哪三个信号能判定"没流完"?那半帧该怎么处理,规范怎么说?
- 断流后重试,为什么不能把已渲染的半截文本和重试结果拼起来?
Last-Event-ID是谁提供的能力?你自己当服务端时怎么用?- 一个 Agent 同时跑两路流,日志"跳来跳去"算乱序吗?正确处置是什么?
参考
- TCP 的顺序语义("reliable, in-order, byte-stream"):RFC 9293 §2.2
- HTTP/2 多路复用与单流帧顺序("order of HEADERS and DATA frames is semantically significant"):RFC 9113 §5
- SSE 事件流格式、残帧丢弃、
Last-Event-ID、retry:WHATWG HTML Standard §9.2 Server-sent events EventSource的重连与关闭语义:MDN EventSource- MCP Streamable HTTP(
Last-Event-ID续传的工业实现):modelcontextprotocol.io - 实测脚本与逐字来源留档:
source/sse-assembly-lab/(harness.mjs、harness.log、README.md、official-sources.md)
