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 M2:true 时单 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 M1:max-attempts 从 5 调到 2,避免与 wiki 层 callLlmWithResilientRetry 的 5×N 嵌套放大;
# 真正的重试主控权交给业务层(wiki / agent),Spring AI 仅负责"一次性瞬时抖动"的快速重试
retry:
max-attempts: 5
max-attempts: 2
on-http-codes: 429, 503, 529
backoff:
initial-interval: 3000