fix(llm): apply read timeout to streaming chat WebClient (openai-compat + anthropic)

This commit is contained in:
matevip 2026-04-30 08:54:46 +08:00
parent 92a7508dac
commit 6390abdecc
2 changed files with 50 additions and 2 deletions

View File

@ -1066,7 +1066,8 @@ public class AgentGraphBuilder {
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder));
// Spring AI OpenAiApi 构造函数会先 set User-Agent "spring-ai" addAll 我们的 headers
// 导致自定义 User-Agent 被追加而非覆盖因此对需要伪装客户端身份的 provider kimi-code
@ -1431,6 +1432,31 @@ public class AgentGraphBuilder {
return builder.requestFactory(rf);
}
/**
* Apply equivalent timeouts to the WebClient that backs OpenAI-compatible
* STREAMING calls (chat completions with {@code stream:true}). The
* RestClient version above only protects synchronous HTTP without this,
* the streaming code path uses the default {@code WebClient} which has
* neither connect nor read timeout, so a stalled provider can hang the
* call forever (observed: a single volcengine-plan request held the agent
* thread for 9+ minutes with no error, until the user manually pressed
* Stop). That kept the failover chain idle because nothing threw.
* <p>
* Uses {@link JdkClientHttpConnector} with the same {@link HttpClient} we
* already use for the RestClient so the dependency surface stays clean
* (reactor-netty is not on this project's classpath Spring's webflux
* starter is excluded by design).
*/
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(Duration.ofSeconds(180));
return builder.clientConnector(connector);
}
/**
* generateKwargs.headers 中提取需要强制覆盖的 headers
* 用于通过 RestClient/WebClient 拦截器绕过 Spring AI OpenAiApi 的默认 User-Agent

View File

@ -86,7 +86,8 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
String baseUrl = provider.getBaseUrl();
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
webClientBuilderProvider.getIfAvailable(WebClient::builder));
AnthropicApi.Builder builder = AnthropicApi.builder()
.apiKey(apiKey.trim())
@ -200,4 +201,25 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
rf.setReadTimeout(Duration.ofSeconds(180));
return builder.requestFactory(rf);
}
/**
* Streaming counterpart of {@link #applyHttpTimeouts(RestClient.Builder)}.
* Without this, Spring AI's AnthropicApi would back its streaming chat
* call by a default WebClient with neither connect nor read timeout a
* stalled provider could hang the agent thread indefinitely while the
* failover chain idles (no exception = no signal).
* <p>
* Mirrors AgentGraphBuilder.applyHttpTimeoutsToWebClient: same JDK
* HttpClient + JdkClientHttpConnector path, so the dependency surface
* doesn't pull in reactor-netty (excluded by this project's pom).
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
connector.setReadTimeout(Duration.ofSeconds(180));
return builder.clientConnector(connector);
}
}