國內直連
api.openai.com又慢又不穩,兩個報錯會先後冒出來。SocketTimeoutException: Read timed out是讀超時,調超時或走流式能解決;Connection reset by peer是連接被中間設備悄悄 RST,加大超時沒用,得換代理節點或加重試。最省心的是base-url指到穩定中轉,再顯式加大讀寫超時。
Issue
國內服務器用 Spring AI 調 OpenAI,經常在兩個地方栽跟頭:請求要麼卡住拋讀超時,要麼中途斷掉報連接重置。兩者的現象都像「網絡不好」,實際根因和修法完全不同。
最容易踩的時間點是:剛接入能跑通(短文本、快網絡),一上國內生產環境或換成長生成,就間歇性報錯。OpenAiApi 預設只傳 api-key 和 base-url,其餘全走預設,超時和代理都沒顯式配,問題就從這裡冒出來。
Environment
- 網絡:國內服務器,直連或經本地 Clash / v2ray 透明代理訪問
api.openai.com,RTT(往返時延)偏高。 - 構建:Spring Boot 3.x + Spring AI(OpenAI 後端),JDK 17 / 21。
- 版本參考:issue #5594 環境為 Spring Boot 3.5.10、spring-ai-openai 1.1.2、Java 21、模型 gpt-5。
- 超時預設值:2.0.0-M5 起底層換成官方 openai-java SDK,
spring.ai.openai.timeout預設 60 秒;更早版本沒有這個屬性。
StackTrace
讀超時,走 JDK HttpURLConnection 這條鏈路時拋,報錯原文:
org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://api.openai.com/v1/chat/completions": Read timed out
完整堆疊,最內層是 JDK Socket 拋出的 SocketTimeoutException:
java.net.SocketTimeoutException: Read timed out
at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:283)
at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:309)
at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:350)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:1091)
at org.springframework.http.client.SimpleStreamingClientHttpRequest.executeInternal(SimpleStreamingClientHttpRequest.java:137)
at org.springframework.web.client.RestClient$DefaultRequestBodyUriSpec.exchangeInternal(RestClient.java:400)
at com.example.demo.chat.OpenAiService.ask(OpenAiService.java:28)
at com.example.demo.chat.ChatController.ask(ChatController.java:22)
連接被重置,走 Reactor Netty(WebClient)這條鏈路時拋,報錯原文:
org.springframework.web.reactive.function.client.WebClientRequestException: Connection reset
完整堆疊,最內層是 Netty 拿到的 SocketException: Connection reset:
reactor.core.Exceptions$ReactiveException: java.net.SocketException: Connection reset
at reactor.netty.channel.FluxReceive.onInboundError(FluxReceive.java:298)
at reactor.netty.channel.ChannelOperations.onInboundError(ChannelOperations.java:443)
at reactor.netty.http.client.HttpClientOperations.onInboundError(HttpClientOperations.java:588)
at reactor.netty.channel.ChannelOperationsHandler.exceptionCaught(ChannelOperationsHandler.java:158)
Caused by: java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:394)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:426)
Windows 上 JDK 會把後一個報成「遠程主機強迫關閉了一個現有的連接」,本質是同一個東西。用 curl 直連時它顯示為 Recv failure: Connection reset by peer。
Reproduce
最小依賴(當前版本官方 artifact 名,老版本為 spring-ai-openai-spring-boot-starter):
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
不配代理、不調超時,最容易觸發:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: https://api.openai.com/v1
一個最簡調用:
@GetMapping("/ask")
String ask(@RequestParam String q) {
return chatClient.prompt().user(q).call().content();
}
這段程式碼本身不報錯,報錯取決於網絡環境。要在本地確定性復現,先用 curl 打到網絡層:
curl -v --max-time 15 https://api.openai.com/v1/models \
-H "Authorization: Bearer $OPENAI_API_KEY" 2>&1 | head -80
看到 * Connection reset by peer 或 curl: (56) Recv failure,就是鏈路被 RST;看到正常 HTTP 返回(哪怕是 401),說明網絡通、問題在超時設定。
Root Cause
SocketTimeoutException:讀超時被掐
SocketTimeoutException 是 JDK HttpURLConnection(SimpleClientHttpRequestFactory)拋的,不是 Netty。看到它,基本能判斷走的是 JDK 客戶端。
這條鏈路上有個容易誤會的地方:readTimeout 不是「整個請求的總時長」,而是「兩次數據到達之間的最大間隔」。所以即使總時長沒超,只要服務端中間沉默太久——比如推理模型先想一陣才吐字——讀超時照樣觸發。
國內再加一層:代理 RTT 高,長文本生成又動不動幾十秒,一次請求很容易頂穿預設的 60 秒超時。
Connection reset:鏈路被 RST
這個和超時無關,加大超時沒用。連接能建立、數據也傳得動,但中途被一個 RST 包掐斷,客戶端就讀到 Connection reset。國內網絡下常見三個來源:
- TCP RST 注入:中間設備對流量發 RST 包強制斷連,還伴生 DNS 汙染把域名解析到錯誤 IP。
- 本地代理抖動:Clash、v2ray 節點不穩,或中轉站主動斷連。
- 上游主動關閉:空閒連接、超長連接被負載均衡或 CDN 回收。
判斷的關鍵是看有沒有 HTTP 狀態碼。真正的 429 會帶 JSON body 和 Retry-After 頭;Connection reset、讀超時這類網絡層失敗,通常拿不到任何 HTTP 響應。
The Fix
第一步:curl 定位是哪一層
先別急著改程式碼,用上面的 curl 直連一次。見 reset 就換代理,見超時或掛起就調超時,兩步別混著修。
第二步:配好代理或換中轉
最省心的做法是 base-url 指到穩定中轉(one-api、new-api,或自建 Cloudflare Worker):
spring:
ai:
openai:
base-url: https://your-relay.example.com/v1
api-key: ${OPENAI_API_KEY}
只想走本地透明代理(Clash 預設 7890 端口),用 SimpleClientHttpRequestFactory.setProxy:
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 7890)));
或者用 JVM 參數,JDK 的 HttpURLConnection 會讀取:
java -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=7890 -jar app.jar
第三步:顯式調大超時
新版直接配屬性:
spring:
ai:
openai:
timeout: 120s
max-retries: 3
老版本沒有 timeout 屬性,就用自定義 RestClient.Builder,這條路所有版本都通吃:
@Bean
OpenAiApi openAiApi(@Value("${spring.ai.openai.api-key}") String apiKey,
@Value("${spring.ai.openai.base-url}") String baseUrl) {
RestClient.Builder restClientBuilder = RestClient.builder()
.requestFactory(ClientHttpRequestFactoryBuilder.simple()
.connectTimeout(Duration.ofSeconds(10))
.readTimeout(Duration.ofSeconds(120))
.build());
return OpenAiApi.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.restClientBuilder(restClientBuilder)
.build();
}
第四步:長生成走流式 + 重試
讀超時和長生成是死對頭。用 .stream() 代替 .call(),token 持續往外吐,兩次數據之間不會沉默太久,讀超時自然不容易觸發。
Connection reset 大多是偶發抖動,配 max-retries 加退避就能扛住大部分。但別對超時無限重試,長生成重一遍更慢,用流式更實在。
References(參考來源)
- Spring AI OpenAI Chat — 官方文檔spring-docs
- OpenAI request fails after 5 minutes despite increased timeouts — spring-ai issue #5594github
- [BUG] WebClientRequestException: Connection reset 偶發 — spring-ai-alibaba issue #3942github
- How to Fix AI API Failures That Look Like Rate Limits but Are Actually Network Issues — dev.todev.to
- Spring AI 實戰:解決 Netty 超時問題 — 技術棧blog
