
自建 LLM 网关很多团队的初版就是一个透明转发器上游回什么帧下游就收什么帧。跑起来才发现同样叫流式不同上游的帧形态差异巨大——有的把一段回答切成 182 个碎片帧每帧只有两三个字符有的几 KB 一大块一帧顶半段话。透传之后下游客户端渲染忽快忽慢甚至一字一行地抽搐。这篇文章换个角度碎的问题不该由客户端修补重建上游 SSE 流本来就是代理层的职责。现象透传的流形状不可控直接透传上游 SSE 时通常会同时遇到两类上游碎片型一个四百字的回答被拆成 182 帧平均每帧两三个字符帧间隔还不均匀。客户端每收一帧渲染一次看起来像逐字蹦出的打字机大块型相反几 KB 一个数据块客户端先是半秒没动静然后整段话一次性刷出。同一个网关、同一套前端接的上游不同体验就截然不同。问题不在客户端也不在上游——每家上游都有自己的切片策略透传等于把这些差异原样交给下游。更麻烦的是帧形态还会随负载漂移同一个上游高峰期碎片更多低峰期块更大推理参数一变长输出、高温度切片节奏也变。客户端按某一种形态调优过一遍换个时段又对不上。网关站在中间理应由它来整形而不是让下游去适应所有上游的脾气。设计决策不透传重建网关的核心决策只有一条上游原始帧不直接下发而是作为输入材料经重建层组装成规范 chunk 再输出。重建层负责三件统一统一事件格式下游只看到一种事件结构——类型、增量文本、序号、可选的结束标记与用量与上游的具体封装无关统一心跳心跳由网关按自己的节奏发出上游心跳只在重建层被消化不外泄统一 finish_reason 语义上游五花八门的结束表达归一成同一组取值。带来的收益是结构性的下游协议只定义一次接入任何上游、上游改协议都不必动下游一行代码。网关从搬运工升级成协议的定义者。这个决策还有一个隐含前提重建层必须是流式的。它不能等上游全部收完再整体下发——那样就成了伪流式首字延迟直接劣化到整段生成时长。正确的姿势是边收边攒边发上游帧到达即入缓冲窗口条件满足就立刻冲刷网关内不出现整段停留。换句话说重建改变的是帧的粒度不改变流的本质。微窗聚合token 与时间双阈值重建的核心参数是窗口多久的上游帧攒成一个规范 chunk。窗口太碎等于变相透传窗口太大首字延迟明显。实践里用双阈值微窗任一条件满足即冲刷时间阈值如 120ms。它主导渲染节奏也把网关引入的额外延迟约束在百毫秒量级token 阈值如 24 token。防止内容密集到达时一个窗口里堆出过大的文本块。# 微窗聚合伪代码 buffer last_flush now() TIMER_MS, TOKEN_LIMIT 120, 24 on upstream_frame(frame): text parse_delta(frame) # 抽取增量文本屏蔽上游封装差异 if text : keepalive(frame) # 上游心跳/空帧重建层消化不下发 return buffer text if elapsed_since(last_flush) TIMER_MS or token_len(buffer) TOKEN_LIMIT: flush() flush(): if buffer : return emit(canonical_chunk(buffer)) # 统一事件封装后下发 buffer last_flush now() on upstream_done(status): # 结束原因归一化stop / length / aborted / error finalize(finish_reason normalize(status))182 帧的碎片流经过这个窗口通常会被压平成个位数 chunk下游看到的到达速率接近匀速。若对首字延迟敏感还有一个常见变体首 token 到达时立即冲刷首帧直通后续再进入微窗节奏。规范事件格式与 finish_reason 兜底下游统一收到的事件大致长这样event: chunk data: {seq: 7, delta: 今天天气, finished: false} event: ping data: {t: 1727400000} # 网关心跳固定节奏 event: done data: {seq: 12, delta: , finish_reason: stop}字段少而固定seq 保证有序、可重放delta 是增量文本finished 标记内容结束finish_reason 归一上游的结束语义。其中 finish_reason 的空串分支很容易踩坑不少上游在异常截断连接断开、超时、5xx时会把 finish_reason 发成空串。网关必须对空串做兜底判定不能当成正常结束stop透传否则下游会把半截回答存进会话历史也不宜直接丢弃前文内容毕竟是有效的。实践中把它标记为 aborted/error 类取值内容照常下发同时把上游状态码与已收字节数写入日志。下游拿到 aborted可以选择提示回答中断或触发重试而不是把残缺回答当成完整答案。判定空串时建议交叉验证两个信号一是传输层是否干净关闭TCP 正常 FIN 且 done 事件已出现二是已收内容是否在语义上戛然而止。传输层异常关闭几乎必然对应 aborted传输层正常但没有任何结束事件同样要按异常截断处理——连接好好地断了不等于回答正常地完了。两类信号都不满足才允许归为 stop宁可多判几次异常也不要让下游把残缺回答当完整答案存档。踩坑逐帧修补与心跳混淆两条代价不小的弯路。其一不要加逐帧修补逻辑。早期思路是透传加补丁每来一帧就判断一次这帧是否太碎碎了就地攒一攒。结果是每来一帧补一次丁——上游有多少种碎片形态就有多少条补丁路径分支越写越多依然修不完。碎片问题只能在上游缓冲之后一次性解决修补逻辑非但不解决问题还把复杂度塞进了转发热路径。其二心跳帧要区分网关心跳和上游心跳。上游的 ping 帧必须在重建层被吞掉否则两种节奏的心跳交替下发下游的心跳超时判断会被搅乱。网关心跳由网关自己的定时器驱动与上游是否发心跳解耦上游长时间既无内容也无心跳则由网关判定超时走 aborted 语义收尾。心跳间隔的取值也有讲究太密会占用连接带宽并在部分代理链路上被当作异常流量太疏则起不到保活作用。经验值是 15 到 30 秒一跳且要注意中间的 Nginx、CDN 是否配置了比它更长的读超时——网关心跳的意义正是让这些中间层不要因为上游推理慢而提前掐断连接。实测182 帧 → 6 帧选了一个碎片化严重的上游做对比原始流 182 帧经微窗重建后下发 6 帧帧间隔接近 120ms 的整数倍下游渲染平滑首字延迟增加约一个窗口体感上难以区分。一字一行的打字机抽搐从机制上消除——不是客户端又改了一版而是它此后收到的帧形状已经由网关保证一致。重建层还有一层隐性收益新增上游或上游改格式时只需在重建层加一个解析器下游零改动。参考文章流式网关的输出最终要落到对话业务里检验。延伸阅读两篇微信 AI 客服多少钱报价拆解、微信机器人自动回复像真人一样接会话。