自建 LLM 网关,很多团队的初版就是一个透明转发器:上游回什么帧,下游就收什么帧。跑起来才发现,同样叫"流式",不同上游的帧形态差异巨大——有的把一段回答切成 182 个碎片帧,每帧只有两三个字符;有的几 KB 一大块,一帧顶半段话。透传之后,下游客户端渲染忽快忽慢,甚至一字一行地"抽搐"。这篇文章换个角度:碎的问题不该由客户端修补,重建上游 SSE 流本来就是代理层的职责。
现象:透传的流,形状不可控
直接透传上游 SSE 时,通常会同时遇到两类上游:
- 碎片型:一个四百字的回答被拆成 182 帧,平均每帧两三个字符,帧间隔还不均匀。客户端每收一帧渲染一次,看起来像逐字蹦出的打字机;
- 大块型:相反,几 KB 一个数据块,客户端先是半秒没动静,然后整段话一次性刷出。
同一个网关、同一套前端,接的上游不同,体验就截然不同。问题不在客户端也不在上游——每家上游都有自己的切片策略,透传等于把这些差异原样交给下游。
更麻烦的是帧形态还会随负载漂移:同一个上游,高峰期碎片更多,低峰期块更大;推理参数一变(长输出、高温度),切片节奏也变。客户端按某一种形态调优过一遍,换个时段又对不上。网关站在中间,理应由它来整形,而不是让下游去适应所有上游的脾气。
设计决策:不透传,重建
网关的核心决策只有一条:上游原始帧不直接下发,而是作为输入材料,经重建层组装成规范 chunk 再输出。重建层负责三件"统一":
带来的收益是结构性的:下游协议只定义一次,接入任何上游、上游改协议,都不必动下游一行代码。网关从"搬运工"升级成协议的定义者。
这个决策还有一个隐含前提:重建层必须是流式的。它不能等上游全部收完再整体下发——那样就成了伪流式,首字延迟直接劣化到整段生成时长。正确的姿势是边收边攒边发:上游帧到达即入缓冲,窗口条件满足就立刻冲刷,网关内不出现"整段停留"。换句话说,重建改变的是帧的粒度,不改变流的本质。
微窗聚合: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 客服多少钱:报价拆解、微信机器人自动回复:像真人一样接会话。



