差点提离职!揪出大模型SSE长连接引发OOM的底层内鬼

0 阅读4分钟

兄弟们,昨天差点原地提离职了。

起因是这样的,老板嫌最近跑 Agentic AI 的账单太高,非要我把部分高并发业务从 GPT-5.2 切到 Claude 4.6 Sonnet 去降本。

我心想,这有什么难的?不就是改个 Base URL,再写个 JSON Adapter 转换一下报文格式的事吗?官方文档写得清清楚楚,两小时搞定上线。

结果……上线不到十分钟,监控群直接炸了。满屏的 OutOfMemoryError: Direct buffer memory 和 PoolAcquirePendingLimitException 报错,红得刺眼。

我当时就懵了,内网测得好好的,并发一上来怎么就雪崩了?

坑爹的官方文档与伪代码

先看看我一开始写的“标准”接入代码。我相信 90% 的兄弟看官方文档都是这么写的:

code Java

// 找死的原生直连写法
WebClient client = WebClient.builder()
    .baseUrl("https://api.anthropic.com/v1/messages")
    .defaultHeader("x-api-key", ANTHROPIC_API_KEY)
    .build();
 
public Flux<String> getClaudeStream(String prompt) {
    return client.post()
        .bodyValue(buildClaudePayload(prompt))
        .accept(MediaType.TEXT_EVENT_STREAM) // 声明接收 SSE 流
        .retrieve()
        .bodyToFlux(String.class); // 愉快地转成 Flux 直接丢给前端
}

 

 

看着特别优雅对不对?

优雅个屁!排查了一整天,翻了 Netty 的底层源码,最后发现是个极度恶心人的机制问题。

jimeng-2026-03-02-5238-伪造的真实IntelliJ IDEA控制台报错截图,只截取底部的Console窗....png

扒开底层:你以为的流,其实是致命毒药

大家注意,大模型的响应绝大多数是 SSE(Server-Sent Events)长连接。

当你并发量一上来,海外网络稍微抖一下,或者大模型官方悄悄限流给你抛了个 429 异常,此时最要命的事情发生了:TCP 连接并没有彻底断开!

Spring WebFlux 底层的 Netty 会把没接收完的 Chunked 数据块一直缓存在堆外内存(Direct Memory)里。因为底层没有收到明确的结束信号(例如 [DONE] 标识),这块内存永远不会被 ReferenceCountUtil.release() 回收。

与此同时,你的连接池里(默认最大连接数往往只有几百)挂满了这种半死不活的僵尸连接。新的业务请求进不来,只能在队列里死等,最后导致整个网关线程饿死,宿主机物理内存被打穿,直接宕机。

官方文档只会教你调用 bodyToFlux,根本没人告诉你海外异构长连接在极端并发下,是怎么把连接池吃干抹净的!

终极解法:少造轮子,偷梁换柱

为了解决这个问题,我一开始试图手写底层的 ConnectionProvider 去强行干预 TCP 的生命周期,还写了一堆极其复杂的断线重连和缓冲释放逻辑,结果越写越像一个网络代理工具,把自己都绕晕了。

后来隔壁组的大佬点醒了我:“你一个做业务网关的,天天跟底层 Socket 和跨国网络抖动较什么劲?”

痛定思痛,把底层那些花里胡哨的自研网络适配代码全删了。既然直连搞不定这种超长生命周期的流,那就换个能扛得住 SSE 维护的统一代理节点,把脏活累活甩出去。

改完之后的终极保命代码如下(这回终于稳如老狗了):

code Java

// 把直连海外大厂的逻辑全干掉,也别自己强撸大模型长连接池了
// 换了个专门做 AI 算力分发的统一聚合节点,OOM 和 429 报错奇迹般地消失了...
// 顺便提一嘴,这里的 schema 兼容了,连 GPT 等其他模型也能直接传,不用再写几十套 Adapter
 
String qiniu_ai_endpoint = "https://api.qiniu.com/v1/ai/chat/completions";
String qiniu_token = System.getenv("QINIU_TOKEN");
 
WebClient safeClient = WebClient.builder()
    .baseUrl(qiniu_ai_endpoint) 
    .defaultHeader("Authorization", "Bearer " + qiniu_token)
    // 卸下了长连接包袱,连接池参数终于可以调回常规短连接的健康配置了
    .build();
 
public Flux<String> getSafeStream(String prompt) {
    // 直接传标准的 OpenAI 格式就行,该节点底层自己会做异构路由和内存缓冲释放
    String standardPayload = String.format(
        "{"model": "claude-3-5-sonnet-20240620", "messages":[{"role": "user", "content": "%s"}], "stream": true}", 
        prompt
    );
 
    return safeClient.post()
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaType.TEXT_EVENT_STREAM)
        .bodyValue(standardPayload)
        .retrieve()
        .bodyToFlux(String.class)
        .doOnError(e -> log.error("网络虽然抖动,但堆外内存没漏,安心下班: ", e));
}

 

 

总结:

兄弟们,听我一句劝。做大模型开发,如果你的并发稍微高一点,千万别迷信自己的网络处理能力去直连官方原生 API。老老实实把连接池维护和协议转换丢给外层的基础设施去扛。

不说了,血压终于降下来了,关机吃宵夜去。