☰
LLM网关流式输出“一字一行”根治记:SSE帧重建、finish_reason空串与120ms微批窗口
2026/9/26 4:11:07 网站建设 项目流程

引子:一个看起来像网络问题的协议问题

流式输出是大模型应用体验的底线,却是整条链路里最容易被"看起来能跑"的透传实现埋雷的地方。这些问题是作者在做一款本地部署的微信自动回复工具、为它自建 LLM 网关时踩到的。本文完整复盘其中最典型的一次:客户端输出区"一字一行"地蹦字。从网关两侧抓帧、定位到上游每帧都带 finish_reason 空串这个结构性根源,到下决心在网关输出层彻底放弃透传、按 SSE 规范自行重建数据帧,再用 120ms 微批窗口把同一个回答从 182 帧压到 6 帧,以及顺带根治的思考段刷屏、断线重连与背压问题,全过程和关键代码都在这篇文章里。

一、现象:像打字机坏了的输出区

先说症状。客户端接某个 LLM 服务时,输出区不是预期里那种平滑的逐段输出,而是一字一行地蹦:每个字符独占一帧、独立刷新一次,前一帧刚把一个字画上去,下一帧就在下面另起一块再画一个字,整个回答被拆成几百个独立的小块,一行一行往下跳。更要命的是输入框跟着抖动——每来一帧页面布局就重排一次,正在打字的人能明显感觉到输入框在上下弹跳,体验极差,基本不可用。

第一反应是怀疑网络。流式卡顿、跳字,直觉上就是弱网、丢包、代理抖动这一类问题。我们换了网络环境、换了代理、换了时间段反复重试,现象纹丝不动,而且有个关键细节:不是丢帧,也不是乱序。每一个字符都完整地到达了,只是到达的方式出了问题——每个字符单独成帧,每帧单独触发一次渲染。

第二个怀疑对象是客户端渲染层:是不是前端没有把增量文本合并进同一个文本节点,每帧都新建了一个块级元素?但把同一个客户端指到另一个模型服务上,同样的代码、同样的渲染逻辑,输出立刻恢复平滑。客户端没变,变的是上游服务的帧形状。

顺手做了两个最小复现实验。第一,绕过网关让客户端直连这个上游服务,现象原样复现;第二,用一个只做原样转发的最小代理脚本串在中间,现象依旧。两个实验把嫌疑范围收缩到极小——问题跟着上游的帧形状走,谁原样转发谁出事,与具体网络路径无关。这个最小复现后来也成了回归验证的基准:网关每次改动之后,跑一遍同一段问答,数一遍客户端实际收到的帧数,帧数稳定在预期之内才放行。

到这里问题正式定性:这不是网络抖动,也不是渲染缺陷,而是协议层的问题——网关、上游、客户端三方之间,有一方对 SSE 帧的理解和其他两方不一致。接下来就是抓出原始帧,让数据自己说话。

二、抓帧:在网关两侧各装一个探头

定位这类问题的唯一正解,是把原始字节流抓下来看,而不是隔着日志猜。我们在网关的入口和出口各挂了一个探针:入口记录上游发来的每一个 SSE 帧,出口记录网关发给客户端的每一帧,都带上毫秒级时间戳、字节长度和序号,落到本地文件里,事后可以逐帧对齐。

探针实现本身很简单,核心是在转发管道上串一个透写函数,边转发边留底:

// 伪代码:在转发管道上串一个 tee,把原始帧留底functiontee(stream:Readable,label:string,sink:fs.WriteStream){letseq=0;stream.on("data",(chunk:Buffer)=>{sink.write(JSON.stringify({seq:seq++,t:Date.now(),side:label,bytes:chunk.length,raw:chunk.toString("utf8"),})+"\n");});}

抓了三次完整问答之后,把两份帧日志按序号对齐,问题一目了然。

第一层发现:网关出口的帧和网关入口的帧逐字节一致。也就是说,网关在这条链路上做的是纯粹透传——上游发什么形状的帧,客户端就原样收到什么形状的帧,网关没有做任何加工。

第二层发现更有意思:上游发来的每一帧都长这个样子。

data: {"choices":[{"delta":{"content":"你"},"finish_reason":""}]} data: {"choices":[{"delta":{"content":"好"},"finish_reason":""}]} data: {"choices":[{"delta":{"content":","},"finish_reason":""}]}

三个特征。其一,上游按最小粒度推流,一帧基本只携带一个字符。其二,每一帧都带 finish_reason 字段,而且值是空串——从头到尾没有一帧带过 stop 这样的正常结束标记,直到流被服务端直接关闭。其三,帧与帧之间完全没有 event 或 id 这类字段,只有裸的 data 行。

拿着这份帧样本回头再看客户端的渲染逻辑,结构性根源就浮出水面了。

三、根因:空串 finish_reason 撞上客户端的兼容分支

我们的客户端为了同时对接多家模型服务,渲染层对"一帧算什么"做过一套三态判断:

finish_reason 为 null 或字段缺省 → 增量帧:把 delta 文本拼进当前消息 finish_reason 为具体值(stop 等) → 终止帧:收尾当前消息 finish_reason 为空串 → 语义无法判定,走兜底:按独立完整消息重绘

这套判断里最致命的就是第三条兜底。它最早是当年适配另一家服务时留下的——那家服务用空串表示"这个字段无意义",我们把空串当成"语义不明"处理,为了不让消息卡死,兜底策略定成了"当作独立消息展示"。这条规则安静地躺了很长时间,直到撞上现在这个上游:它每一帧都带空串 finish_reason。于是每一帧都进了第三条分支,每一帧都被当成一条独立完整消息重绘。上游又一帧一个字,客户端就一字一行地蹦——每一"行",就是一条被误判出来的"独立消息"。

这里必须强调一个判断:这不是客户端的孤立缺陷,而是网关透传埋下的结构性问题。原因有两层。

第一层,SSE 帧的形状是上游决定的,而上游帧形状根本不可控。同一个"流式输出",不同模型、不同供应商的实现差异大得惊人:finish_reason 的语义各家不一致,stop、length、tool_calls、null、空串、字段缺省混着用;有的按 token 粒度推流,有的按句子,有的一次推一整段;delta 增量和完整 message 两种载荷形状并存;多候选、工具调用帧的包装方式也各不相同。客户端位于链路末端,被迫为每一种上游形状准备一个兼容分支,分支越堆越多,分支之间还会互相踩——这次就是为 A 服务写的兜底踩中了 B 服务的帧形状。

第二层,网关是整条链路里唯一一个既看得见上游、又看得见下游的位置,是唯一有能力把上游帧形状挡住、不让它泄漏给客户端的组件。但它选择了透传,等于主动放弃了这个隔离职责,把上游的任意形状原样暴露给客户端。透传省事,写的时候一行转发代码就完事;但所有上游差异都会穿透网关直达客户端,上游任何一次变更都会变成客户端的线上事故。这次的一字一行,就是透传路线欠下的债集中兑现。

顺带把账算清楚:这个问题里没有一行代码是"写错的"。上游按自己的约定发空串,没有错;客户端的兜底分支按当年的适配约定写的,也没有错;网关透传在只有单一上游的年代甚至是最优解。错的是系统演化之后,三方之间始终没有一份明确的帧契约,每个组件都在拿自己的历史假设去解释对方的数据。协议问题最终都要回到契约上解决,这也是下一节先补规范课的原因。

四、补课:SSE 规范到底规定了什么

要重建帧,先把规范吃透。SSE(Server-Sent Events)的帧格式本质上是一套基于行的纯文本协议,规则不多,但每一条都有工程含义。一个典型的帧长这样:

event: delta id: 17 retry: 3000 data: {"text":"第一行"} data: {"text":"第二行"}

逐个字段说。

data 是载荷字段,帧里真正的内容。一个帧里可以出现多个 data 行,客户端会把它们用换行符拼接成一条完整消息。这是规范里最容易踩坑的一条——很多人以为一行 data 就是一条消息,遇到上游把长载荷拆成多个 data 行的实现就解析错了。

event 是事件类型,缺省为 message。服务端可以用它区分增量帧、终止帧、错误帧,客户端按事件名注册处理器。我们重建层后来大量依赖它来承载边界语义。

id 是帧序号。客户端每收到一帧就把它记下来,断线重连时通过 Last-Event-ID 请求头把最后收到的序号带回服务端,服务端据此重放缺帧。这是 SSE 自带的断点续传机制,重建层的幂等设计就挂在它上面。

retry 是重连间隔建议,单位毫秒,告诉客户端断线之后等多久再重连比较合适。

帧与帧之间以一个空行分隔,这是唯一的帧边界标记。行结束符兼容回车换行、换行、裸回车三种。以冒号开头的行是注释,客户端必须忽略——这个看似无用的语法,后来成了我们心跳保活的标准载体,第七节会讲到。

这套字段乍看简单,但对网关的意义重大:它们是仅有的几个由服务端向客户端传递传输语义的通道。透传模式下,上游发的 id 是上游的序号,上游发的 retry 是上游的建议;断线重连时客户端拿着上游的序号来找网关对账,网关根本对不上——这就是透传架构下断线续传几乎不可能做对的原因。帧重建之后,id 与 retry 的语义才第一次真正收回到网关手里。

解析端要处理的最麻烦的事是 chunk 边界:底层分包不保证一帧完整地落在一个网络分片里,一个分片里也可能挤着好几帧。所以解析器必须是有状态的,攒够一个完整帧才吐出去:

// 最小可用的 SSE 帧解析器:处理 chunk 边界、CRLF、多行 dataclassSseParser{privatebuf="";feed(text:string):SseEvent[]{this.buf+=text;constevents:SseEvent[]=[];// 帧以空行分隔,兼容 CRLF 与 LFconstparts=this.buf.split(/\r?\n\r?\n/);this.buf=parts.pop()??"";// 残帧留下次拼接for(constrawofparts){constdataLines:string[]=[];constev:SseEvent={event:"message",data:""};for(constlineofraw.split(/\r?\n/)){if(line.startsWith(":"))continue;// 注释行(心跳)忽略consti=line.indexOf(":");constfield=i<0?line:line.slice(0,i);letvalue=i<0?"":line.slice(i+1);if(value.startsWith(" "))value=value.slice(1);if(field==="data")dataLines.push(value);elseif(field==="event")ev.event=value;elseif(field==="id")ev.id=value;elseif(field==="retry")ev.retry=Number(value);}ev.data=dataLines.join("\n");// 多行 data 用换行拼接events.push(ev);}returnevents;}}

这个解析器后来成了重建层的入口组件。注意它对上游不再做任何"猜测"——空串也好,缺省也好,原样解析出来,语义判断交给上一层归一化。

五、方案:输出层彻底不透传,按规范自行重建

修复方案的决策其实很快,因为方向之争只有一条:要不要继续在透传路线上打补丁。

打补丁的思路是继续透传,然后在客户端把那条空串兜底分支改掉。但改掉之后呢?下一个上游把 finish_reason 缺省怎么办?把结束标记放进别的字段里怎么办?透传路线上的每一帧补丁,本质都是在客户端为上游差异继续堆条件分支,这条路我们已经看到了尽头。

所以我们换了一条路线,也是网关本该走的路线:网关输出层彻底不透传上游帧,按 SSE 规范自行重建数据帧。上游发来什么形状、什么语义的帧,都终结在网关里;客户端从此只认识一种帧——网关定义的标准帧。

整条流水线分成四步:

上游 chunk → [解析成逻辑帧] → [归一化为内部事件] → [120ms 微批缓冲合并] → [组装标准 SSE 帧下发]

第一步解析,用上面的 SseParser 把字节流切成逻辑帧。第二步归一化,把各家上游的帧形状翻译成统一的内部事件,这是吸收所有上游差异的唯一位置:

typeFrame=|{type:"delta";text:string}// 正文增量|{type:"reasoning";text:string}// 思考增量|{type:"boundary";kind:"reasoning_end"|"answer_end"};// 阶段边界functionnormalize(ev:SseEvent):Frame[]{constchunk=JSON.parse(ev.data);constout:Frame[]=[];constc=chunk.choices?.[0]??{};constreasoning=c.delta?.reasoning_content??c.delta?.reasoning;if(typeofreasoning==="string")out.push({type:"reasoning",text:reasoning});if(typeofc.delta?.content==="string")out.push({type:"delta",text:c.delta.content});// 关键:空串与缺省在此处统一抹平,语义只有"结束"与"未结束"两种constfinish=c.finish_reason;if(typeoffinish==="string"&&finish.length>0){out.push({type:"boundary",kind:"answer_end"});}returnout;}

归一化层是整套方案的灵魂:上游 finish_reason 的空串、缺省、null、具体值,所有形态在这里被翻译成干净的内部语义,客户端再也看不到任何一种上游方言。

第三步缓冲合并,也就是 120ms 微批窗口,下一节展开。第四步组装下发,用规范字段重新组帧:

functionemitFrame(res:ServerResponse,id:number,payload:object){res.write(`event: delta\nid:${id}\ndata:${JSON.stringify(payload)}\n\n`);}

从这一刻起,客户端收到的每一帧都是网关亲自组装的:有事件名、有单调递增的 id、有唯一的 data 行,结束语义只有明确的 boundary 帧才携带。上游是什么形状,客户端永远不必知道。

六、120ms 微批窗口:把 182 帧并成 6 帧

重建层解决的是"帧语义对不对",微批窗口解决的是"帧数量多不多"。上游一帧一个字符,就算语义全对,一个回答几百帧打过去,客户端的渲染压力和页面刷新抖动依然存在。所以缓冲合并这一步的目标很明确:把碎片帧攒起来,合并成大帧再下发。

窗口大小的选择是个折中。窗口太大,攒的帧多、合并率高,但端上延迟感知明显,流式输出会变成一顿一顿的推送;窗口太小,合并率上不去,攒了等于白攒。最后定在 120ms,依据有两条:一是阅读场景下 120ms 的刷新粒度约等于每秒八帧,肉眼已经是平滑滚动;二是 120ms 足够把上游一大串单字符帧合并成有意义的文本段。我们也试过两端的极端值:窗口压得更小,合并率掉得厉害,帧数下不来,抖动残留;窗口放大到半秒以上,输出肉眼可见地一顿一顿,体感像卡顿。120ms 是合并率与延迟体感之间的平衡点,这个参数后来做成了配置项,但 120ms 始终是默认值。实现很直接:

classMicroBatcher{privatebuf:Frame[]=[];privatetimer:NodeJS.Timeout|null=null;constructor(privatewindowMs=120,// 微批窗口privatemaxBytes=32*1024,// 缓冲上限(示例值),防雪崩privateflush:(frames:Frame[])=>void,){}push(f:Frame){this.buf.push(f);if(this.timer===null){// 一个窗口期内只排一个定时器this.timer=setTimeout(()=>this.drain(),this.windowMs);}}privatedrain(){this.timer=null;constout=this.buf;this.buf=[];this.flush(mergeAdjacentDeltas(out));}}functionmergeAdjacentDeltas(frames:Frame[]):Frame[]{constout:Frame[]=[];for(constfofframes){constlast=out[out.length-1];if(f.type==="delta"&&last?.type==="delta"){last.text+=f.text;// 核心合并:N 个单字符帧并成 1 个文本帧}else{out.push(f);}}returnout;}

两个工程细节值得一提。其一,boundary 帧是强制截止点:窗口攒批期间若来了 answer_end 或 reasoning_end,必须立刻把攒着的内容全部 flush,再单独下发 boundary 帧,绝不能把边界和正文合并进同一帧,否则客户端的阶段切换会错序。其二,缓冲必须有上限:上游推流速度远超下游消费速度时,微批缓冲会无限膨胀,上限和背压的关系在第九节展开。

实测数据是这套方案最硬的背书:同一段回答,改造前客户端收到 182 帧,改造后收到 6 帧,帧数压到原来的三十分之一左右。一字一行的现象从结构上消灭了——因为客户端那边已经不存在"一帧一条独立消息"的可能,每一帧都是网关标准语义里的一个完整段落增量,渲染层把它拼进同一个文本节点,输出恢复成平滑的逐段滚动,输入框也不抖了。

七、姊妹问题:思考段刷屏与 boundary 模式

正文的问题解决之后,第二个问题浮出水面:思考段的刷屏。不少模型在正式回答之前会先输出一段 reasoning 内容,这部分如果沿用正文的处理方式边到边刷,客户端就会在整个思考阶段不停地刷新刷屏,几秒钟里滚动条狂奔,用户什么都看不清。

第一版迭代我们犯了想当然的错误:既然边到边刷太碎,那就攒着,每秒 flush 一次总行了吧?实测立刻翻车——攒 60 帧、每 1 秒往下推一次,客户端每秒整段重绘一遍,还是刷屏,只是从高频抖动变成了低频抖动,甚至因为每次重绘的内容量更大,视觉上更糟糕。这次踩坑给了我们一个重要认知:刷屏的根源不是刷新频率,而是渲染单位不稳定——只要每次刷新都改变已有内容的呈现范围,频率高是抖,频率低是跳,都是刷屏。

第二版于是换了思路,定下 boundary 模式:思考内容完全不做边到边输出,整个思考阶段网关一帧 reasoning 都不往下发,直到思考阶段结束的边界帧到来,才把完整思考段一次性整段 flush。思考进行中,用户看到的是"模型正在思考"的稳定状态提示,不再有任何内容闪变;思考结束,完整推理过程一次性落定;之后进入正文的 120ms 微批节奏。

但 boundary 模式立刻带来一个新问题:思考阶段可能长达十几秒甚至更久,这期间网关到客户端的连接上一帧都不发,链路中间的任何一层——反向代理、负载均衡、浏览器——都可能因为空闲超时把连接掐断。解法是 SSE 规范里的注释行语法:以冒号开头的行客户端必须忽略,服务端拿它当心跳帧,既保活又零副作用:

// boundary 模式:思考阶段不发内容帧,只发心跳保活onFrame(f:Frame){if(f.type==="reasoning"){this.stage="thinking";return;// 攒住,绝不边到边刷}if(f.type==="boundary"&&f.kind==="reasoning_end"){this.flushReasoningOnce();// 思考结束,整段一次性 flushthis.stage="answering";return;}this.batcher.push(f);// 正文进入 120ms 微批}// 心跳:注释行,客户端按规范忽略,但链路中间层不会超时constheartbeat=setInterval(()=>res.write(": ping\n\n"),10_000);res.on("close",()=>clearInterval(heartbeat));

boundary 模式上线后,思考段刷屏彻底消失,心跳帧也让长思考不再掉线。回头看,第一版的"每秒一批"和第二版的 boundary,差的不只是参数,而是对"什么时候该让用户看到内容"这个语义的建模:内容应该在语义完整的边界呈现,而不是在时间刻度上呈现。

八、重建层的幂等与断线重连

帧是自己组的,序号就是自己发的,这让断线重连第一次变得可控。设计分三块。

第一块,序号与去重。网关给每个下发的帧分配单调递增的 id,客户端记录最后已应用的 id。渲染层应用一帧之前先核对序号:小于等于已应用序号的帧直接丢弃。这保证了重放帧即使被重复送达,也不会重复追加文本——同一帧应用一次和应用多次,结果一致,这就是幂等。

第二块,重放缓冲。网关为每个会话维护一个有界的重放缓冲,保存最近下发的帧。客户端断线后按 SSE 规范带上 Last-Event-ID 请求头重连,网关从缓冲里取出序号大于该值的帧依序重放,然后无缝续上新的增量。客户端既不丢字,也不重复。

第三块,缓冲覆盖不到的兜底。重放缓冲是有界的,断线太久、缺帧已经滚出缓冲时,续传无法完成,网关会明确返回一个"需要重新生成"的错误帧,而不是静默续一个残缺的流。宁可让客户端显式重来,也不能给用户看一段缺了中间几个字的回答——流式输出的完整性必须可验证,id 的连续性就是验证手段。

GET /chat/stream (断线重连) Last-Event-ID: 41 → 网关重放 id 42、43、44…然后继续实时帧

这一整套做完之后,移动网络下切换基站、锁屏再回前台这类常见的断线场景,从"回答缺字"变成了"无感续传"。

九、背压与缓冲上限

微批窗口引入了缓冲,有缓冲就必须回答背压问题:下游消费不动了,上游还在狂推,怎么办?

Node 的 HTTP 层自带背压信号:write 返回 false 表示内核写缓冲已超过高水位,此时继续写入只会把数据堆在进程内存里。重建层把这个信号接到了上游控制上:

constok=res.write(payload);if(!ok){upstream.pause();// 下游写不动,先暂停拉上游res.once("drain",()=>upstream.resume());}

在暂停与恢复之上还有一道保险:网关侧的待发缓冲设了硬上限。大模型偶尔会因为异常生成超长输出,或者下游直接僵死,此时单纯暂停上游不够,必须止损——待发字节超过上限就中止本次生成,给客户端发一个明确的错误帧,释放会话。原则是宁快败不慢卡:一个不死不活的连接挂着,占着会话额度和内存,比干脆利落地失败糟糕得多。

微批缓冲本身也被纳入同一个上限体系:窗口期内攒下的帧字节数计入待发缓冲,超限时微批立即排空,不再等窗口到期。这些上限互相配合,保证无论上游多快、下游多慢,网关的内存占用都是有界的。

背压还有一个容易被忽略的联动项:超时。暂停上游之后,上游连接的空闲计时可能触发对端的读超时,反而把上游连接掐断。所以暂停策略要和上游的读超时配置放在一起调整,让"慢消费者"这种场景始终停留在网关自己的缓冲与止损逻辑里,而不是被上下游两边的超时各自为政地处理。网络栈里每一层都有超时,谁先触发决定了故障呈现的形态,这些参数必须放在同一张表里统一审。

十、教训沉淀

这次修复横跨抓帧、协议、架构和协作流程,最后沉淀下来四条教训,按重要程度排。

第一条,也是最重要的:先定协议再实现。正确的顺序是先写一份网关到客户端的帧协议契约——帧类型、字段、序号规则、边界语义、重连语义——让客户端只依赖这份契约,让网关独占"上游方言到契约"的翻译权。我们最初跳过了这一步,直接用透传把三方焊死在一起,后来为此付出了抓帧排查、客户端兼容分支堆积以及这次重构的全部代价。尤其不要在透传路线上逐帧打补丁:每打一个补丁,客户端就多一个上游专属分支,分支之间还会互相踩,这次空串兜底误伤就是现成的例子。

第二条,上游帧形状不可信,也不必可信。finish_reason 的空串、缺省、具体值混用只是众多方言里的一种,载荷形状、推流粒度、事件包装方式各家都不同。把差异全部吸收在归一化层,让它成为唯一需要理解上游的地方,其余所有组件只面对一种干净的内部语义。

第三条,多会话并行改同一个文件会互相覆盖。排查修复期间,我们有两个工作会话同时开着,各自改网关的配置和代码,结果一方的修改被另一方毫不知情地覆盖,一度出现"明明改好了怎么又坏了"的灵异现象,白白多耗了半天排查时间。事后复盘把这条写进了规范:配置与代码要用同一份真相源,变更必须走版本化提交,任何时刻不允许两个会话并行裸改同一个文件。故障处理的时间有一半不是花在技术难题上,而是花在自己制造的混乱上,这条教训的性价比反而最高。

第四条,体验问题要往协议层追,不要停在渲染层。一字一行看起来是前端问题,思考段刷屏看起来是刷新频率问题,最终答案都在帧协议里。客户端渲染只是协议语义的镜子,镜子里花了,要去镜子照的东西上找原因。

这套帧重建层后来成了我们工具里所有模型接入的公共底座。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询