fix(wiki): bound LLM retry + http read timeout (RFC-012 M1 follow-up)

This commit is contained in:
matevip 2026-04-14 16:05:37 +08:00
parent b9ed8219ba
commit a369e8055d
4 changed files with 64 additions and 8 deletions

View File

@ -33,7 +33,9 @@ import org.springframework.http.HttpHeaders;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
@ -805,7 +807,8 @@ public class AgentGraphBuilder {
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
MultiValueMap<String, String> headers = buildOpenAiHeaders(kwargs);
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
// Spring AI OpenAiApi 构造函数会先 set User-Agent "spring-ai" addAll 我们的 headers
@ -923,7 +926,8 @@ public class AgentGraphBuilder {
throw new MateClawException("err.agent.anthropic_key_invalid", "Anthropic API Key 未配置或无效: " + provider.getProviderId());
}
String baseUrl = provider.getBaseUrl();
RestClient.Builder restClientBuilder = restClientBuilderProvider.getIfAvailable(RestClient::builder);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
restClientBuilderProvider.getIfAvailable(RestClient::builder));
WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder);
AnthropicApi.Builder builder = AnthropicApi.builder()
@ -1275,6 +1279,19 @@ public class AgentGraphBuilder {
return headers;
}
/**
* RFC-012 M1 LLM 调用走的 RestClient 显式配置超时避免 socket 永久挂起等待
* <p>
* connectTimeout=10s任何 LLM 提供方都不该超过这个建立连接时间
* readTimeout=180s覆盖 nginx 60s 网关超时 + 留足真实长响应余量超时后由上层 retry 接管
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory();
rf.setConnectTimeout(Duration.ofSeconds(10));
rf.setReadTimeout(Duration.ofSeconds(180));
return builder.requestFactory(rf);
}
/**
* generateKwargs.headers 中提取需要强制覆盖的 headers
* 用于通过 RestClient/WebClient 拦截器绕过 Spring AI OpenAiApi 的默认 User-Agent

View File

@ -57,4 +57,28 @@ public class WikiProperties {
/** 扫描时跳过大于此大小的文件(字节),默认 50MB */
private long maxScanFileSize = 50 * 1024 * 1024;
/**
* Wiki LLM 重试最大尝试次数含首次
* <p>
* RFC-012 M1旧实现无最大次数遇到 nginx 504 这种"反复瞬时"错误会永远重试
* 设为 5 后单 chunk 最多走 5 配合 llmMaxTotalDurationMs 共同保证有界停止
*/
private int llmMaxAttempts = 5;
/**
* Wiki LLM 重试总耗时上限毫秒从首次调用开始计时
* <p>
* RFC-012 M1 chunk LLM 调用 + 重试的硬封顶超过即放弃让该 chunk 进入 failed 计数
* 默认 4 分钟
*/
private long llmMaxTotalDurationMs = 240_000;
/**
* 是否启用两阶段消化路由 逐页 merge
* <p>
* RFC-012 M2true 时单 chunk LLM 输出量大幅缩减避免 nginx 60s 网关超时
* 默认 false 保持向后兼容M2 实现完成后切到 true
*/
private boolean useTwoPhaseDigest = false;
}

View File

@ -454,16 +454,22 @@ public class WikiProcessingService {
* 立即终止模型不可用401/403 认证失败模型不存在quota 用尽非法 API key
* InterruptedException优雅关停
* <p>
* 使用指数退避1s 2s 4s ... 封顶 60s无最大尝试次数
* 使用指数退避1s 2s 4s ... 封顶 60s
* <p>
* RFC-012 M1加入 maxAttempts maxTotalDurationMs 双重上限避免 nginx 504 这种
* 反复瞬时错误把单 chunk 卡到永远buildChatModel 提到循环外所有重试复用同一实例
*/
private String callLlmWithResilientRetry(Prompt prompt, String ctx) {
long backoffMs = 1000;
final long maxBackoffMs = 60_000;
final int maxAttempts = Math.max(1, properties.getLlmMaxAttempts());
final long maxTotalDurationMs = Math.max(1_000L, properties.getLlmMaxTotalDurationMs());
final long startNanos = System.nanoTime();
final ChatModel chatModel = buildChatModel();
int attempt = 0;
while (true) {
attempt++;
try {
ChatModel chatModel = buildChatModel();
ChatResponse response = chatModel.call(prompt);
if (response == null || response.getResult() == null
|| response.getResult().getOutput() == null
@ -484,10 +490,17 @@ public class WikiProcessingService {
ctx, attempt, t.getMessage());
throw new RuntimeException("LLM unavailable: " + t.getMessage(), t);
}
log.warn("[Wiki] LLM transient failure for {} attempt={}, retrying in {}ms: {}",
ctx, attempt, backoffMs, t.getMessage());
long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000L;
if (attempt >= maxAttempts || elapsedMs >= maxTotalDurationMs) {
log.error("[Wiki] LLM exhausted for {} after {} attempts in {}ms (limits: maxAttempts={}, maxTotalDurationMs={}): {}",
ctx, attempt, elapsedMs, maxAttempts, maxTotalDurationMs, t.getMessage());
throw new RuntimeException("LLM exhausted: " + t.getMessage(), t);
}
long sleepMs = Math.min(backoffMs, Math.max(0L, maxTotalDurationMs - elapsedMs));
log.warn("[Wiki] LLM transient failure for {} attempt={}/{} elapsed={}ms, retrying in {}ms: {}",
ctx, attempt, maxAttempts, elapsedMs, sleepMs, t.getMessage());
try {
Thread.sleep(backoffMs);
Thread.sleep(sleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("LLM retry interrupted for " + ctx, ie);

View File

@ -59,8 +59,10 @@ spring:
jdbc:
initialize-schema: embedded
# Spring AI Retry 配置429/503/529 归为可重试TransientAiException启用指数退避
# RFC-012 M1max-attempts 从 5 调到 2避免与 wiki 层 callLlmWithResilientRetry 的 5×N 嵌套放大;
# 真正的重试主控权交给业务层wiki / agentSpring AI 仅负责"一次性瞬时抖动"的快速重试
retry:
max-attempts: 5
max-attempts: 2
on-http-codes: 429, 503, 529
backoff:
initial-interval: 3000