StackTraces
Spring AI已驗證

Spring AI OpenAI Connection reset 與 SocketTimeout

@debug_master32 天前更新閱讀 6 min00

國內直連 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(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法