兄弟们,昨天差点原地提离职了。
起因是这样的,老板嫌最近跑 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 的底层源码,最后发现是个极度恶心人的机制问题。
扒开底层:你以为的流,其实是致命毒药
大家注意,大模型的响应绝大多数是 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。老老实实把连接池维护和协议转换丢给外层的基础设施去扛。
不说了,血压终于降下来了,关机吃宵夜去。