LLM网关流式输出“一字一行”根治记:SSE帧重建、finish_reason空串与120ms微批窗口
引子一个看起来像网络问题的协议问题流式输出是大模型应用体验的底线却是整条链路里最容易被看起来能跑的透传实现埋雷的地方。这些问题是作者在做一款本地部署的微信自动回复工具、为它自建 LLM 网关时踩到的。本文完整复盘其中最典型的一次客户端输出区一字一行地蹦字。从网关两侧抓帧、定位到上游每帧都带 finish_reason 空串这个结构性根源到下决心在网关输出层彻底放弃透传、按 SSE 规范自行重建数据帧再用 120ms 微批窗口把同一个回答从 182 帧压到 6 帧以及顺带根治的思考段刷屏、断线重连与背压问题全过程和关键代码都在这篇文章里。一、现象像打字机坏了的输出区先说症状。客户端接某个 LLM 服务时输出区不是预期里那种平滑的逐段输出而是一字一行地蹦每个字符独占一帧、独立刷新一次前一帧刚把一个字画上去下一帧就在下面另起一块再画一个字整个回答被拆成几百个独立的小块一行一行往下跳。更要命的是输入框跟着抖动——每来一帧页面布局就重排一次正在打字的人能明显感觉到输入框在上下弹跳体验极差基本不可用。第一反应是怀疑网络。流式卡顿、跳字直觉上就是弱网、丢包、代理抖动这一类问题。我们换了网络环境、换了代理、换了时间段反复重试现象纹丝不动而且有个关键细节不是丢帧也不是乱序。每一个字符都完整地到达了只是到达的方式出了问题——每个字符单独成帧每帧单独触发一次渲染。第二个怀疑对象是客户端渲染层是不是前端没有把增量文本合并进同一个文本节点每帧都新建了一个块级元素但把同一个客户端指到另一个模型服务上同样的代码、同样的渲染逻辑输出立刻恢复平滑。客户端没变变的是上游服务的帧形状。顺手做了两个最小复现实验。第一绕过网关让客户端直连这个上游服务现象原样复现第二用一个只做原样转发的最小代理脚本串在中间现象依旧。两个实验把嫌疑范围收缩到极小——问题跟着上游的帧形状走谁原样转发谁出事与具体网络路径无关。这个最小复现后来也成了回归验证的基准网关每次改动之后跑一遍同一段问答数一遍客户端实际收到的帧数帧数稳定在预期之内才放行。到这里问题正式定性这不是网络抖动也不是渲染缺陷而是协议层的问题——网关、上游、客户端三方之间有一方对 SSE 帧的理解和其他两方不一致。接下来就是抓出原始帧让数据自己说话。二、抓帧在网关两侧各装一个探头定位这类问题的唯一正解是把原始字节流抓下来看而不是隔着日志猜。我们在网关的入口和出口各挂了一个探针入口记录上游发来的每一个 SSE 帧出口记录网关发给客户端的每一帧都带上毫秒级时间戳、字节长度和序号落到本地文件里事后可以逐帧对齐。探针实现本身很简单核心是在转发管道上串一个透写函数边转发边留底// 伪代码在转发管道上串一个 tee把原始帧留底functiontee(stream:Readable,label:string,sink:fs.WriteStream){letseq0;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 规范到底规定了什么要重建帧先把规范吃透。SSEServer-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.buftext;constevents:SseEvent[][];// 帧以空行分隔兼容 CRLF 与 LFconstpartsthis.buf.split(/\r?\n\r?\n/);this.bufparts.pop()??;// 残帧留下次拼接for(constrawofparts){constdataLines:string[][];constev:SseEvent{event:message,data:};for(constlineofraw.split(/\r?\n/)){if(line.startsWith(:))continue;// 注释行心跳忽略constiline.indexOf(:);constfieldi0?line:line.slice(0,i);letvaluei0?:line.slice(i1);if(value.startsWith( ))valuevalue.slice(1);if(fielddata)dataLines.push(value);elseif(fieldevent)ev.eventvalue;elseif(fieldid)ev.idvalue;elseif(fieldretry)ev.retryNumber(value);}ev.datadataLines.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[]{constchunkJSON.parse(ev.data);constout:Frame[][];constcchunk.choices?.[0]??{};constreasoningc.delta?.reasoning_content??c.delta?.reasoning;if(typeofreasoningstring)out.push({type:reasoning,text:reasoning});if(typeofc.delta?.contentstring)out.push({type:delta,text:c.delta.content});// 关键空串与缺省在此处统一抹平语义只有结束与未结束两种constfinishc.finish_reason;if(typeoffinishstringfinish.length0){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|nullnull;constructor(privatewindowMs120,// 微批窗口privatemaxBytes32*1024,// 缓冲上限示例值防雪崩privateflush:(frames:Frame[])void,){}push(f:Frame){this.buf.push(f);if(this.timernull){// 一个窗口期内只排一个定时器this.timersetTimeout(()this.drain(),this.windowMs);}}privatedrain(){this.timernull;constoutthis.buf;this.buf[];this.flush(mergeAdjacentDeltas(out));}}functionmergeAdjacentDeltas(frames:Frame[]):Frame[]{constout:Frame[][];for(constfofframes){constlastout[out.length-1];if(f.typedeltalast?.typedelta){last.textf.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.typereasoning){this.stagethinking;return;// 攒住绝不边到边刷}if(f.typeboundaryf.kindreasoning_end){this.flushReasoningOnce();// 思考结束整段一次性 flushthis.stageanswering;return;}this.batcher.push(f);// 正文进入 120ms 微批}// 心跳注释行客户端按规范忽略但链路中间层不会超时constheartbeatsetInterval(()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 表示内核写缓冲已超过高水位此时继续写入只会把数据堆在进程内存里。重建层把这个信号接到了上游控制上constokres.write(payload);if(!ok){upstream.pause();// 下游写不动先暂停拉上游res.once(drain,()upstream.resume());}在暂停与恢复之上还有一道保险网关侧的待发缓冲设了硬上限。大模型偶尔会因为异常生成超长输出或者下游直接僵死此时单纯暂停上游不够必须止损——待发字节超过上限就中止本次生成给客户端发一个明确的错误帧释放会话。原则是宁快败不慢卡一个不死不活的连接挂着占着会话额度和内存比干脆利落地失败糟糕得多。微批缓冲本身也被纳入同一个上限体系窗口期内攒下的帧字节数计入待发缓冲超限时微批立即排空不再等窗口到期。这些上限互相配合保证无论上游多快、下游多慢网关的内存占用都是有界的。背压还有一个容易被忽略的联动项超时。暂停上游之后上游连接的空闲计时可能触发对端的读超时反而把上游连接掐断。所以暂停策略要和上游的读超时配置放在一起调整让慢消费者这种场景始终停留在网关自己的缓冲与止损逻辑里而不是被上下游两边的超时各自为政地处理。网络栈里每一层都有超时谁先触发决定了故障呈现的形态这些参数必须放在同一张表里统一审。十、教训沉淀这次修复横跨抓帧、协议、架构和协作流程最后沉淀下来四条教训按重要程度排。第一条也是最重要的先定协议再实现。正确的顺序是先写一份网关到客户端的帧协议契约——帧类型、字段、序号规则、边界语义、重连语义——让客户端只依赖这份契约让网关独占上游方言到契约的翻译权。我们最初跳过了这一步直接用透传把三方焊死在一起后来为此付出了抓帧排查、客户端兼容分支堆积以及这次重构的全部代价。尤其不要在透传路线上逐帧打补丁每打一个补丁客户端就多一个上游专属分支分支之间还会互相踩这次空串兜底误伤就是现成的例子。第二条上游帧形状不可信也不必可信。finish_reason 的空串、缺省、具体值混用只是众多方言里的一种载荷形状、推流粒度、事件包装方式各家都不同。把差异全部吸收在归一化层让它成为唯一需要理解上游的地方其余所有组件只面对一种干净的内部语义。第三条多会话并行改同一个文件会互相覆盖。排查修复期间我们有两个工作会话同时开着各自改网关的配置和代码结果一方的修改被另一方毫不知情地覆盖一度出现明明改好了怎么又坏了的灵异现象白白多耗了半天排查时间。事后复盘把这条写进了规范配置与代码要用同一份真相源变更必须走版本化提交任何时刻不允许两个会话并行裸改同一个文件。故障处理的时间有一半不是花在技术难题上而是花在自己制造的混乱上这条教训的性价比反而最高。第四条体验问题要往协议层追不要停在渲染层。一字一行看起来是前端问题思考段刷屏看起来是刷新频率问题最终答案都在帧协议里。客户端渲染只是协议语义的镜子镜子里花了要去镜子照的东西上找原因。这套帧重建层后来成了我们工具里所有模型接入的公共底座。