欢迎光临
我们一直在努力

基于 SSE 的打字机效果前端平滑优化:后端的 Chunk 切割与缓冲区冲刷控制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

封面信息图

上周四下午,前端负责人小林端着他的马克杯杀到了我的工位。

“鸭梨哥,你们后端吐给前端的流式接口,体验太稀烂了。”小林把笔记本往我面前一推,“你看看这打字机效果,前两秒没动静,突然‘噗’的一下喷出来四五百个字,接着又卡死一秒,再喷两百个字。产品经理非说是我前端 CSS 动画写得太卡,我这插值动画帧率拉满了都救不回来!”

我凑过去看了一下 Network 面板里的 EventStream,果然,数据块不是一个 Token 一个 Token 顺滑流出来的,而是一坨一坨几十甚至上百字节整块落下来的。

做大模型应用落地,大家第一直觉都觉得“打字机效果”是前端的责任——不就是拿到 SSE(Server-Sent Events)的增量数据,往 DOM 里追加字符串么?或者前端起个 requestAnimationFrame 做匀速缓动队列?

但只要你在线上跑过高并发的生产系统,就会发现:前端的平滑度,百分之八十取决于后端的 Chunk 切割与缓冲区冲刷(Flush)策略。如果后端的传输链路上有一处缓冲区在偷偷“憋大招”,前端就算把算力烧干,做出来的打字机效果也像个结巴在演讲。

今天就把我们在流式输出平滑度治理上踩过的坑,以及最终沉淀下来的后端切割与冲刷方案完整梳理一遍。


为什么你的 SSE 数据总在“憋大招”?

要治好流式吐字不平滑的毛病,首先得把从大模型推理引擎到用户浏览器之间的这根“水管”彻底理清楚。

数据从大模型生成到最终在前端页面渲染,中间至少穿过了四道可能产生缓冲的关卡:

  • LLM 推理服务层:底层 vLLM、TGI 或者商用 API 的 Token 生成速率本身是有波动的,有时首字延迟(TTFT)长,后续生成快,有时又由于显存调度出现瞬时抖动。
  • 业务网关与代理层(Nginx / Envoy / Ingress):这是最经典的“背锅位”。反向代理默认开启了反向代理缓冲区(Proxy Buffering)。代理层认为“一次性把大块数据打包发给下游”能减少 TCP 报文交互,提升吞吐率,于是把本该实时透传的 SSE 片段强行攒成 4KB 甚至 8KB 的大包。
  • Java 应用容器缓冲区(Netty / Tomcat):无论是 Spring WebMVC 的 SseEmitter 还是 WebFlux 的响应式流,Servlet 容器或 Netty 的 Socket 缓冲区如果未配置及时 flush,或者操作系统层面的 Nagle 算法把小包合并,数据就会在内网网卡队列里排队。
  • 客户端读取缓冲:前端的 fetch 或 EventSource,如果网络库底层没有及时触发 ReadableStreamDefaultReader.read() 回调,也会导致视觉卡顿。
  • 其中最致命、排查概率最高的,就是中间代理缓冲和Java 服务端的冲刷时机。


    避坑第一关:拆掉反向代理的阻水坝

    很多后端写完接口,在本地 Postman 调测或者 curl 本机端口,发现 Token 一个个流得很欢快;一上测试环境或者预发环境,立马变成“便秘式输出”。

    原因无他,就是 Nginx 搞的鬼。

    Nginx 默认会对 HTTP 响应开启缓冲(proxy_buffering on)。对于普通的 JSON 接口,这是优化性能的神器;但对于 SSE,这就是灭顶之灾。它会死死守着 4KB 缓冲区,不塞满绝对不给下游转发。

    解决方式非常直接,后端的响应头里必须强制带上:

    Content-Type: text/event-stream; charset=UTF-8
    Cache-Control: no-cache
    Connection: keep-alive
    X-Accel-Buffering: no

    重点是 X-Accel-Buffering: no。这个私有头是告诉 Nginx:“哥们,这是流式长连接,别给我开缓冲区,拿到一个字节就赶紧往后送”。

    同时,如果你们集群使用的是 Envoy,需要确保流式路由配置里关闭了响应缓冲,并开启了流式透传。如果运维统一在 Nginx 配置层面拦截,也应确保配置了:

    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;


    核心权衡:逐 Token 冲刷 vs 智能 Chunk 切割

    代理层的水闸拉开后,问题就转移到了 Java 服务端本身。

    很多同学做 SSE,最粗暴的写法是:底层模型回调每吐出一个 Token,后端就调一次 emitter.send(),然后强行调用 response.flushBuffer()。

    我们早期也是这么干的,但在压测阶段遇到了灾难性的后果。

    1. 逐 Token 冲刷的性能悬崖

    假设模型吐字速度是 80 Tokens/s,当并发达到 2000 时,服务端每秒需要处理 16 万次小报文封包、系统调用(Syscall)以及 SSL 加密开销。

    在 Wireshark 抓包下,你会看到成千上万个只有两三个字节 payload 的 TCP 数据包(比如一个汉字占 3 字节,加上 SSE 格式 data: 测\\n\\n 也不到 15 字节)。这不仅导致网卡软中断飙高,CPU 大量浪费在内核态与用户态的上下文切换上,甚至会导致 TCP 连接窗口被极小碎片占满,引发网络丢包。

    2. 前端为什么需要平滑的 Chunk?

    前端打字机的核心体感,不在于“每个汉字到来的物理时间绝对均匀”,而在于字符抵达的节奏符合人类阅读速度。

    正常人类中文阅读速度在每秒 510 个字。如果后端能以稳定的 4060 毫秒为时间窗口,或者以 2~5 个汉字为一个语义片段进行微小聚合后冲刷,既能将服务端的网络包数量降低 60%~70%,又能让前端的渲染队列始终有水流动,不至于断流。

    因此,后端的最佳架构绝不是纯粹的“来一个推一个”,而是引入自适应动态冲刷控制器(Adaptive Flush Controller)。


    工业级方案:自适应微聚合冲刷器

    我们在 Spring WebFlux(基于 Netty)和 WebMVC(基于 Tomcat)的流式管道中,引入了一个轻量级的微批缓冲器。它的逻辑非常清晰:

  • 时间窗口优先:当积攒时间超过阈值(如 50ms),无论缓冲区里有几个字符,必须立即冲刷,保证延迟下限。
  • 字符容量兜底:当积攒字符达到阈值(如 4~8 个字符),立即触发冲刷,防止生成速度极快时单次下发包过大。
  • 标点符号敏感:如果遇到逗号、句号、问号、换行符等自然断句标点,立即冲刷。在自然语言中,停顿在标点处是最符合人类认知习惯的,前端配合渲染会显得极其自然。
  • 下面是一个在生产环境中验证过的自适应 Chunk 聚合器实现核心逻辑:

    public class AdaptiveStreamFlusher {

    private final StringBuilder buffer = new StringBuilder();
    private final Consumer<String> downstreamConsumer;
    private final ScheduledExecutorService scheduler;
    private final int maxCharBatch;
    private final long maxWaitMillis;

    private ScheduledFuture<?> scheduledTask;
    private final Object lock = new Object();

    public AdaptiveStreamFlusher(Consumer<String> downstreamConsumer,
    ScheduledExecutorService scheduler,
    int maxCharBatch,
    long maxWaitMillis) {
    this.downstreamConsumer = downstreamConsumer;
    this.scheduler = scheduler;
    this.maxCharBatch = maxCharBatch;
    this.maxWaitMillis = maxWaitMillis;
    }

    public void onToken(String token) {
    synchronized (lock) {
    buffer.append(token);

    // 规则1:遇到标点符号,立即冲刷
    if (endsWithPunctuation(token)) {
    flushLocked();
    return;
    }

    // 规则2:达到字符批量阈值,立即冲刷
    if (buffer.length() >= maxCharBatch) {
    flushLocked();
    return;
    }

    // 规则3:开启定时冲刷兜底
    if (scheduledTask == null || scheduledTask.isDone()) {
    scheduledTask = scheduler.schedule(() -> {
    synchronized (lock) {
    flushLocked();
    }
    }, maxWaitMillis, TimeUnit.MILLISECONDS);
    }
    }
    }

    public void complete() {
    synchronized (lock) {
    flushLocked();
    }
    }

    private void flushLocked() {
    if (scheduledTask != null && !scheduledTask.isDone()) {
    scheduledTask.cancel(false);
    }
    if (buffer.length() > 0) {
    String payload = buffer.toString();
    buffer.setLength(0);
    downstreamConsumer.accept(payload);
    }
    }

    private boolean endsWithPunctuation(String text) {
    if (text == null || text.isEmpty()) {
    return false;
    }
    char lastChar = text.charAt(text.length() – 1);
    return lastChar == ',' || lastChar == '。' || lastChar == '!'
    || lastChar == '?' || lastChar == '\\n' || lastChar == ';'
    || lastChar == ',' || lastChar == '.' || lastChar == '!'
    || lastChar == '?' || lastChar == ';' || lastChar == ':';
    }
    }

    在 Spring Boot 3.x(WebFlux)下,结合 Reactor 的 bufferTimeout 也可以写出非常函数式的逻辑,但原生 Reactor 算子对标点断句的支持相对较弱,手写这类轻量级管道控制器反而更可控,内存分配更紧凑。


    传输层排障:TCP_NODELAY 与 Nagle 算法的幽灵

    代码上线后,小林反馈大部分场景下打字机顺滑了,但在某些长文本场景下,仍然偶发几百毫秒的顿挫。

    我们拉上运维做抓包分析,发现网卡上有大量的 TCP Acked Delayed,即接收端启用了延迟确认(Delayed ACK),而后端发送端在某些特殊大小的包上触发了 Nagle 算法。

    Nagle 算法的初衷是为了防止网络充斥小包,它的策略是:如果前一个发出去的小包还没有收到 ACK,当前又有新的小包要发,那就必须缓存在本地,直到收到 ACK 或者凑齐了一个 MSS(最大报文段长度)。

    当 Nagle 算法遇上 Delayed ACK,就是著名的 200ms 握手卡顿地狱。

    在 Java 层解决这个问题很简单,必须确保长连接的 Socket 开启了 TCP_NODELAY:

    • 如果使用 Netty(Spring WebFlux 或网关):检查 ChannelOption.TCP_NODELAY 是否为 true(Netty 默认已开启,但一定要防止业务配置覆盖)。
    • 如果使用 Tomcat / Jetty:检查 server.xml 或配置属性中连接器的 tcpNoDelay 是否为 true。

    server:
    tomcat:
    connection-timeout: 30000
    # 确保底层开启 TCP_NODELAY,禁用 Nagle

    当把 TCP_NODELAY、X-Accel-Buffering: no 以及后端的 50ms / 4字符自适应聚合三者配合起来时,网络抓包中的 TCP 报文时延图就会从极其陡峭的折线变成平缓的水平线。


    写在最后

    上周五傍晚,小林在飞书上给我发了个竖大拇指的表情包:“鸭梨哥,现在的打字机体验顺滑得像吃了德芙,产品经理没话说了。”

    技术做久了你就会发现,软件系统里根本没有什么完全独立的“前端责任”或“后端责任”。

    一个看似简单的打字机动画,背后贯穿着大模型推理时延、后端的字符流切割、TCP 层的缓冲协议、反向代理的缓存策略以及前端渲染队列的消费速率。

    后端研发如果只管把从模型拿到的字符串一股脑扔进 OutputStream 就不管了,那不叫架构设计,那叫管杀不管埋。把传输链路上的每一个缓冲阀门摸清楚、拧顺溜,才是高并发高可用系统应有的基本功。

    赞(0)
    未经允许不得转载:171主机测评 » 基于 SSE 的打字机效果前端平滑优化:后端的 Chunk 切割与缓冲区冲刷控制
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址