可写简历:大模型流式输出全链路排查方案,领先90%只知道调超时的人

0 阅读9分钟

可写简历:大模型流式输出全链路排查方案,领先90%只知道调超时的人

在这里插入图片描述 做过大模型流式输出的同学,大概率踩过这个坑:本地测试好好的,一上生产就经常断流、卡顿,用户打了一半字突然停了,体验极差。

然后你去查怎么解决,90%的文章只告诉你一句话:"把超时时间调长一点。"

你调了,发现该断还是断。为什么?因为流式输出断流根本不是单点问题,是从大模型客户端→后端服务→Nginx→浏览器,整条链路4个节点都可能出问题,只调一个地方的超时,当然没用。

今天给你一套全链路排查方案,每个节点的坑和解决方案都讲透,Java项目直接照着排查,改完流式输出稳定不卡。


一、先搞清楚:断流和卡顿是两回事

很多人把"断流"和"卡顿"混为一谈,但原因完全不同:

  • 断流:连接直接断开,输出停在一半,前端需要重新发起请求。原因是某个节点超时断开了连接。
  • 卡顿:连接没断,但数据一卡一卡的,半天出一个字。原因是某个节点开了缓冲,数据攒一批才发。

排查的时候先搞清楚是哪种,再对症下药。下面按链路顺序,逐个节点讲。


二、节点1:大模型客户端——超时和缓冲的双重坑

第一个坑在大模型客户端,很多人对接通义千问、OpenAI的流式接口时,HTTP客户端配置不对。

坑1:读超时太短 大模型流式输出,第一个Token可能要等2-5秒(模型推理时间),之后每个Token间隔几十毫秒。如果HTTP客户端的读超时设成了默认的10秒,遇到模型稍微慢一点,或者输出内容长一点,直接就超时断连了。

正确配置

import okhttp3.OkHttpClient;
import java.time.Duration;

// 大模型流式客户端配置
OkHttpClient client = new OkHttpClient.Builder()
        .connectTimeout(Duration.ofSeconds(10))   // 连接超时10秒
        .readTimeout(Duration.ofMinutes(5))        // 读超时5分钟!流式必须设长
        .writeTimeout(Duration.ofSeconds(30))
        .build();

关键:流式输出的读超时必须按最长输出时间算,一份长回答可能要输出2-3分钟,读超时至少设5分钟。

坑2:客户端开了缓冲 有些HTTP客户端默认会缓冲响应体,攒够一定大小才返回。流式输出必须关闭缓冲,逐行读取SSE事件。

正确读取方式

import okhttp3.Response;
import okhttp3.ResponseBody;
import java.io.BufferedReader;
import java.io.InputStreamReader;

// 流式读取,逐行处理,不要用string()一次性读
try (Response response = client.newCall(request).execute()) {
    ResponseBody body = response.body();
    if (body == null) return;
    BufferedReader reader = new BufferedReader(new InputStreamReader(body.byteStream()));
    String line;
    while ((line = reader.readLine()) != null) {
        // 逐行处理SSE事件,实时推送给前端
        if (line.startsWith("data:")) {
            String data = line.substring(5).trim();
            // 推送到SseEmitter
            emitter.send(SseEmitter.event().data(data));
        }
    }
    emitter.complete();
}

坑:千万别用response.body().string(),这会把整个响应读完才返回,流式变成同步,前端直接卡死。


三、节点2:后端服务——Spring MVC的异步坑

第二个坑在SpringBoot后端,SSE流式输出的异步配置很容易踩坑。

坑1:异步请求超时太短 Spring MVC的异步请求(SseEmitter/WebAsyncTask)默认超时只有30秒,长回答直接超时。

正确配置

import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

// 创建SseEmitter时指定超时时间,单位毫秒
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时

或者全局配置:

spring:
  mvc:
    async:
      request-timeout: 300000  # 异步请求超时5分钟

坑2:和普通接口共用线程池 流式请求占用线程时间长(一个请求可能占线程几分钟),如果和普通接口共用Tomcat线程池,几个流式请求就能把线程占满,整个服务卡死。

解决:单独分配流式线程池

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

@Configuration
public class SseThreadPoolConfig {

    @Bean("sseExecutor")
    public ThreadPoolTaskExecutor sseExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("sse-stream-");
        // 拒绝策略:降级为非流式输出,而不是抛异常
        executor.setRejectedExecutionHandler((r, e) -> {
            // 降级处理:返回完整回答而不是流式
        });
        executor.initialize();
        return executor;
    }
}

坑3:异常没捕获,连接泄漏 流式输出过程中如果前端断开连接,后端还在继续往emitter里写数据,会抛异常。如果没捕获,线程不会释放,造成连接泄漏。

正确处理

SseEmitter emitter = new SseEmitter(300000L);

// 必须注册这三个回调,否则会泄漏资源
emitter.onCompletion(() -> {
    // 连接正常结束,释放资源
    log.info("SSE连接正常结束");
});
emitter.onTimeout(() -> {
    log.warn("SSE连接超时");
    emitter.complete();
});
emitter.onError(e -> {
    log.error("SSE连接异常", e);
    emitter.complete();
});

四、节点3:Nginx/网关——缓冲和超时的重灾区

第三个坑是最容易被忽略的,也是90%的人踩的坑——Nginx反向代理。

坑1:proxy_buffering 默认开启 Nginx默认会缓冲后端响应,攒够一个buffer才发给前端。这就是为什么你本地测试流式正常,一上Nginx就一卡一卡的——Nginx在攒数据。

必须关闭缓冲

location /api/chat/stream {
    proxy_pass http://backend;
    
    # 流式输出必须关闭代理缓冲!
    proxy_buffering off;
    proxy_cache off;
    
    # 关闭响应分块缓冲(部分版本需要)
    chunked_transfer_encoding on;
    
    # 超时设置(整条链路都要对应调长)
    proxy_connect_timeout 10s;
    proxy_read_timeout 300s;   # 读超时5分钟
    proxy_send_timeout 300s;
    
    # 关键:设置响应头,告诉浏览器不要缓冲
    add_header X-Accel-Buffering no;
    add_header Cache-Control no-cache;
}

最关键的两行:proxy_buffering offadd_header X-Accel-Buffering no。少了任何一行,Nginx都可能偷偷缓冲。

坑2:网关层超时 如果你用了Spring Cloud Gateway、Kong等API网关,它们的默认超时也很短,需要对应调长。

Spring Cloud Gateway配置:

spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 10000
        response-timeout: 300s  # 响应超时5分钟

五、节点4:浏览器/前端——重连和心跳

第四个坑在前端,连接断开后如果没有重连机制,用户只能刷新页面。

坑1:没有心跳保活 流式输出过程中,如果大模型推理时间较长(比如复杂问题要等5秒),这段时间没有数据传输。很多网关、防火墙、CDN会自动断开长时间没有数据的连接(通常60秒无数据就断)。

解决:心跳保活

// 后端:每隔10秒发一个心跳注释事件,保持连接活跃
SseEmitter emitter = new SseEmitter(300000L);

// 启动心跳定时任务
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor();
heartbeat.scheduleAtFixedRate(() -> {
    try {
        // SSE注释行,以:开头,浏览器会忽略,但能保持连接活跃
        emitter.send(SseEmitter.event().comment("heartbeat"));
    } catch (Exception e) {
        // 连接已断开,停止心跳
        heartbeat.shutdown();
    }
}, 10, 10, TimeUnit.SECONDS);

// 连接结束时停止心跳
emitter.onCompletion(() -> heartbeat.shutdown());
emitter.onTimeout(() -> heartbeat.shutdown());
emitter.onError(e -> heartbeat.shutdown());

原理:SSE协议中,以冒号:开头的行是注释,浏览器会忽略不处理,但数据确实传输了,能保持连接不被中间节点断开。

坑2:断线重连没有断点续传 前端EventSource自带断线重连,但重连后会从头开始请求,用户已经看到的内容会重复输出。

解决:断点续传

// 前端:记录已接收的偏移量,重连时带给后端
let lastOffset = 0;

const es = new EventSource('/api/chat/stream?sessionId=xxx&offset=' + lastOffset);

es.addEventListener('token', (e) => {
    answerDiv.innerHTML += e.data;
    lastOffset++;  // 每接收一个Token,偏移量+1
});

// EventSource自带重连,会自动带上offset参数
// 后端根据offset从断点处继续输出,而不是从头开始

后端对应处理:

@GetMapping("/api/chat/stream")
public SseEmitter stream(
        @RequestParam String sessionId,
        @RequestParam(defaultValue = "0") int offset) {
    SseEmitter emitter = new SseEmitter(300000L);
    
    // 从会话缓存中获取已生成的回答
    List<String> cachedTokens = sessionCache.get(sessionId);
    
    // 如果offset > 0,说明是断线重连,从断点继续
    if (offset > 0 && cachedTokens != null) {
        // 先补发offset之后的缓存内容
        for (int i = offset; i < cachedTokens.size(); i++) {
            emitter.send(SseEmitter.event().name("token").data(cachedTokens.get(i)));
        }
        // 如果已经生成完了,直接结束
        if (isGenerationComplete(sessionId)) {
            emitter.complete();
            return emitter;
        }
    }
    
    // 继续生成剩余内容...
    return emitter;
}

六、全链路检查清单(照着排查)

上线前对照这张清单逐项检查,少一项都可能断流:

节点检查项配置值
大模型客户端读超时≥5分钟
大模型客户端响应读取方式逐行流读取,禁止string()
后端服务SseEmitter超时≥5分钟
后端服务线程池隔离流式单独线程池
后端服务异常回调onCompletion/onTimeout/onError全注册
后端服务心跳保活每10秒发注释心跳
Nginx/网关proxy_bufferingoff(必须)
Nginx/网关X-Accel-Bufferingno(必须)
Nginx/网关proxy_read_timeout≥5分钟
前端断线重连EventSource自带+offset断点续传

📦 落地资源推荐

搭大模型测试环境需要稳定服务器,我平时调试用阿里云轻量应用服务器,2核4G跑模型推理+SpringBoot后端完全够用,新用户首年特惠性价比高并且帮忙申请到了9折券www.aliyun.com/daily-act/e…

🎤 面试话术模板

如果面试官问你"流式输出断流怎么排查",可以直接这么说:

流式断流要从全链路排查,不是单点问题。首先大模型客户端读超时要设到5分钟以上,用流读取不能一次性读string;然后后端SseEmitter超时对应调长,流式请求单独线程池隔离,注册异常回调防泄漏;最关键是Nginx层必须关闭proxy_buffering和设置X-Accel-Buffering: no,否则会缓冲导致卡顿;最后加心跳保活避免中间节点超时断开,前端做断线重连+断点续传。整条链路4个节点都检查到,流式输出就稳定了。

这段话讲完,面试官就知道你真的上线过流式功能,不是只会调接口。 关注图片上水印即Java-AI工程师,打出工具箱三字即可。

💬 本期互动

你对接流式输出的时候,踩过最头疼的坑是什么? A. Nginx缓冲问题 B. 超时自动断开 C. 线程泄漏 D. 前端重连重复输出

评论区报你的选项就行~有想刷的面试题,欢迎后台投稿。

📢 下周预告

下周开启全新的Java AI Agent 从 0 到 1 的生产实战系列,从零搭建 + 可写简历 + 生产级痛点。记得星标公众号别错过。

祝大家周末愉快!

本文属于「周五刷题营」合集