diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/retry/LlmRetryBackoff.java b/backend-java/src/main/java/com/nanri/aiimage/common/retry/LlmRetryBackoff.java new file mode 100644 index 00000000..5fc9da02 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/common/retry/LlmRetryBackoff.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.common.retry; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * LLM 重试等待策略:指数退避 + 抖动。 + * + *

背景(2026-09-13 生产实测):上游 ai.t8star.org 存在持续数十秒的劣化窗口, + * 窗口内所有请求都不应答;原来的 1.5s/3s 密集重试会整批落在窗口内,三连失败后 + * 行降级(外观识别异常)。退避拉到 2s/10s 并带 ±30% 抖动,可覆盖更长的窗口, + * 同时避免同批多行在同一时刻齐发重试形成尖峰。 + */ +public final class LlmRetryBackoff { + + private static final long[] BASE_DELAYS_MILLIS = {2_000L, 10_000L}; + private static final double JITTER_RATIO = 0.3d; + private static final long MIN_DELAY_MILLIS = 200L; + + private LlmRetryBackoff() { + } + + /** 第 attemptIndex 次失败后的等待毫秒数(attemptIndex 从 1 开始);超出档位取最后一档。 */ + public static long delayMillis(int attemptIndex) { + return delayMillis(attemptIndex, ThreadLocalRandom.current().nextDouble()); + } + + /** 固定抖动入口(测试用):random 取值 [0,1),0.5 表示无抖动。 */ + static long delayMillis(int attemptIndex, double random) { + int index = Math.min(Math.max(1, attemptIndex), BASE_DELAYS_MILLIS.length) - 1; + long base = BASE_DELAYS_MILLIS[index]; + double bounded = Math.min(Math.max(random, 0d), 1d); + double factor = 1d + (bounded * 2d - 1d) * JITTER_RATIO; + return Math.max(MIN_DELAY_MILLIS, Math.round(base * factor)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java index ead29a4c..cbd56877 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java @@ -22,6 +22,12 @@ public class AppearancePatentProperties { private int llmMaxTokens = 64000; private int llmConnectTimeoutMillis = 10000; private int llmReadTimeoutMillis = 180000; + /** + * 首次尝试读超时(毫秒):上游偶发单请求不应答(挂满超时才失败), + * 首查用更短预算快速失败并重试;生产实测成功调用 p99≈34s、超 60s 仅 0.02%, + * 故默认 60s 既覆盖正常慢响应又能把单次挂死代价从 90s 降到 60s。 + */ + private int llmFirstAttemptReadTimeoutMillis = 60000; private int llmBatchSize = 10; /** * 批内行级并发数,默认等于批量大小 diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java index 6b255123..be573e7c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java @@ -161,6 +161,11 @@ public class SimilarAsinProperties { private int llmMaxTokens = 64000; private int llmConnectTimeoutMillis = 10000; private int llmReadTimeoutMillis = 180000; + /** + * 首次尝试读超时(毫秒):上游偶发单请求不应答,首查用更短预算快速失败后重试; + * 生产实测成功调用 p99≈34s、超 60s 仅 0.02%,默认 60s 兼顾慢响应与快速失败。 + */ + private int llmFirstAttemptReadTimeoutMillis = 60000; private int llmRetryTimes = 3; /** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */ diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java index d1838be7..5aab26d1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.appearancepatent.client; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.retry.LlmRetryBackoff; import com.nanri.aiimage.config.AppearancePatentProperties; import com.nanri.aiimage.config.HttpClientPool; import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; @@ -103,6 +104,8 @@ public class AppearancePatentLlmClient { private final UserSecretUsageService userSecretUsageService; private volatile RestClient sharedRestClient; + /** 首次尝试专用客户端(更短读超时,快速失败);配置未生效时与 sharedRestClient 相同。 */ + private volatile RestClient firstAttemptRestClient; private volatile ExecutorService rowExecutor; /** @@ -192,15 +195,20 @@ public class AppearancePatentLlmClient { String appearanceStatus = ""; String appearanceReason = ""; boolean appearanceFailed = false; - try { - AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey); - appearanceStatus = judgement.status(); - appearanceReason = judgement.reason(); - } catch (Exception ex) { - appearanceFailed = true; - appearanceReason = firstNonBlank(ex.getMessage(), "外观识别失败"); - log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}", - row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex)); + if (titleFailed) { + // 标题识别失败时本行必走回退分支(外观结果会被丢弃),再发外观请求只会在劣化中的上游白等一轮重试 + log.warn("[appearance-patent] 标题识别失败,跳过外观识别(本行同样走回退)asin={}", row.getAsin()); + } else { + try { + AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey); + appearanceStatus = judgement.status(); + appearanceReason = judgement.reason(); + } catch (Exception ex) { + appearanceFailed = true; + appearanceReason = firstNonBlank(ex.getMessage(), "外观识别失败"); + log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}", + row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex)); + } } if (titleFailed || appearanceFailed) { String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA); @@ -274,15 +282,16 @@ public class AppearancePatentLlmClient { Exception lastFailure = null; for (int attempt = 1; attempt <= attempts; attempt++) { try { - return invokeChatOnce(model, system, userText, images, apiKey, responseFormat); + return invokeChatOnce(model, system, userText, images, apiKey, responseFormat, attempt); } catch (Exception ex) { lastFailure = ex; if (attempt >= attempts) { break; } - log.warn("[appearance-patent] llm retryable failure attempt={} model={} err={}", - attempt, model, failureMessage(ex)); - sleepBeforeRetry(attempt); + long retryDelayMillis = LlmRetryBackoff.delayMillis(attempt); + log.warn("[appearance-patent] llm retryable failure attempt={} model={} 下次重试等待={}ms err={}", + attempt, model, retryDelayMillis, failureMessage(ex)); + sleepQuietly(retryDelayMillis); } } throw lastFailure == null ? new IllegalStateException("LLM call failed") : lastFailure; @@ -293,13 +302,14 @@ public class AppearancePatentLlmClient { String userText, List images, String apiKey, - String responseFormat) { + String responseFormat, + int attempt) { recordSecretUsage(); Map body = buildChatBody(model, system, userText, images, responseFormat); - log.info("[appearance-patent] llm request model={} url={} body={}", - model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), + log.info("[appearance-patent] llm request attempt={} model={} url={} body={}", + attempt, model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), writeJson(maskChatBody(body))); - RestClient.RequestBodySpec request = restClient().post() + RestClient.RequestBodySpec request = restClient(attempt).post() .uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions")) .headers(headers -> { headers.setBearerAuth(stripBearer(apiKey)); @@ -748,24 +758,49 @@ public class AppearancePatentLlmClient { } } - private RestClient restClient() { + /** + * 按尝试次数取客户端:第 1 次用「首次尝试超时」(快速失败,单次挂死代价更小), + * 重试用完整读超时,避免把慢但成功的正常调用误杀(实测成功调用 p99≈34s)。 + */ + private RestClient restClient(int attempt) { + if (attempt == 1 && firstAttemptTimeoutMillis() < Math.max(1, properties.getLlmReadTimeoutMillis())) { + RestClient client = firstAttemptRestClient; + if (client != null) { + return client; + } + synchronized (this) { + if (firstAttemptRestClient == null) { + firstAttemptRestClient = buildRestClient(firstAttemptTimeoutMillis()); + } + return firstAttemptRestClient; + } + } RestClient client = sharedRestClient; if (client != null) { return client; } synchronized (this) { if (sharedRestClient == null) { - RestClient.Builder builder = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis())); - if (externalCallMetrics != null) { - builder.requestInterceptor(externalCallMetrics.interceptor("llm")); - } - sharedRestClient = builder.build(); + sharedRestClient = buildRestClient(properties.getLlmReadTimeoutMillis()); } return sharedRestClient; } } + private int firstAttemptTimeoutMillis() { + int configured = properties.getLlmFirstAttemptReadTimeoutMillis(); + return configured > 0 ? configured : Math.max(1, properties.getLlmReadTimeoutMillis()); + } + + private RestClient buildRestClient(int readTimeoutMillis) { + RestClient.Builder builder = RestClient.builder() + .requestFactory(HttpClientPool.requestFactory(readTimeoutMillis)); + if (externalCallMetrics != null) { + builder.requestInterceptor(externalCallMetrics.interceptor("llm")); + } + return builder.build(); + } + private ExecutorService rowExecutor() { ExecutorService executor = rowExecutor; if (executor != null) { @@ -844,10 +879,6 @@ public class AppearancePatentLlmClient { return normalized.substring(0, Math.max(0, maxLength - 3)) + "..."; } - private void sleepBeforeRetry(int attemptIndex) { - sleepQuietly(Math.max(1, attemptIndex) * 1500L); - } - /** 密钥调用计次:每次真实 HTTP 请求(含重试)计 1 次;无任务上下文(无人归属)时跳过。 */ private void recordSecretUsage() { SecretUsageContext.Context context = SecretUsageContext.current(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java index 4e359ff0..fae2521e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.similarasin.client; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.retry.LlmRetryBackoff; import com.nanri.aiimage.config.HttpClientPool; import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; @@ -43,6 +44,8 @@ public class SimilarAsinLlmClient { private final UserSecretUsageService userSecretUsageService; private volatile RestClient sharedRestClient; + /** 首次尝试专用客户端(更短读超时,快速失败);配置未生效时与 sharedRestClient 相同。 */ + private volatile RestClient firstAttemptRestClient; public SimilarAsinLlmClient(SimilarAsinProperties properties, ObjectMapper objectMapper, @@ -75,15 +78,16 @@ public class SimilarAsinLlmClient { Exception lastFailure = null; for (int attempt = 1; attempt <= attempts; attempt++) { try { - return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat); + return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat, attempt); } catch (Exception ex) { lastFailure = ex; if (attempt >= attempts) { break; } - log.warn("[similar-asin][llm] retryable failure attempt={} model={} err={}", - attempt, model, failureMessage(ex)); - sleepBeforeRetry(attempt); + long retryDelayMillis = LlmRetryBackoff.delayMillis(attempt); + log.warn("[similar-asin][llm] retryable failure attempt={} model={} 下次重试等待={}ms err={}", + attempt, model, retryDelayMillis, failureMessage(ex)); + sleepQuietly(retryDelayMillis); } } throw lastFailure == null @@ -110,13 +114,14 @@ public class SimilarAsinLlmClient { String userText, List images, String apiKey, - String responseFormat) { + String responseFormat, + int attempt) { recordSecretUsage(); Map body = buildChatBody(model, system, userText, images, responseFormat); - log.debug("[similar-asin][llm] request model={} url={} body={}", - model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), + log.debug("[similar-asin][llm] request attempt={} model={} url={} body={}", + attempt, model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), writeJson(maskChatBody(body))); - RestClient.RequestBodySpec request = restClient().post() + RestClient.RequestBodySpec request = restClient(attempt).post() .uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions")) .headers(headers -> { headers.setBearerAuth(stripBearer(apiKey)); @@ -310,27 +315,52 @@ public class SimilarAsinLlmClient { userSecretUsageService.record(context.userId(), UserSecretModule.SIMILAR_ASIN.key(), 1); } - private RestClient restClient() { + /** + * 按尝试次数取客户端:第 1 次用「首次尝试超时」(快速失败,单次挂死代价更小), + * 重试用完整读超时,避免把慢但成功的正常调用误杀(实测成功调用 p99≈34s)。 + */ + private RestClient restClient(int attempt) { + if (attempt == 1 && firstAttemptTimeoutMillis() < Math.max(1, properties.getLlmReadTimeoutMillis())) { + RestClient client = firstAttemptRestClient; + if (client != null) { + return client; + } + synchronized (this) { + if (firstAttemptRestClient == null) { + firstAttemptRestClient = buildRestClient(firstAttemptTimeoutMillis()); + } + return firstAttemptRestClient; + } + } RestClient client = sharedRestClient; if (client != null) { return client; } synchronized (this) { if (sharedRestClient == null) { - RestClient.Builder builder = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis())); - if (externalCallMetrics != null) { - builder.requestInterceptor(externalCallMetrics.interceptor("llm")); - } - sharedRestClient = builder.build(); + sharedRestClient = buildRestClient(properties.getLlmReadTimeoutMillis()); } return sharedRestClient; } } - private void sleepBeforeRetry(int attemptIndex) { + private int firstAttemptTimeoutMillis() { + int configured = properties.getLlmFirstAttemptReadTimeoutMillis(); + return configured > 0 ? configured : Math.max(1, properties.getLlmReadTimeoutMillis()); + } + + private RestClient buildRestClient(int readTimeoutMillis) { + RestClient.Builder builder = RestClient.builder() + .requestFactory(HttpClientPool.requestFactory(readTimeoutMillis)); + if (externalCallMetrics != null) { + builder.requestInterceptor(externalCallMetrics.interceptor("llm")); + } + return builder.build(); + } + + private void sleepQuietly(long delayMillis) { try { - Thread.sleep(Math.max(1, attemptIndex) * 1500L); + Thread.sleep(delayMillis); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); throw new IllegalStateException("LLM retry interrupted", interruptedException); diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 54fe2434..f89eaa83 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -251,6 +251,7 @@ aiimage: llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000} llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000} llm-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_READ_TIMEOUT_MILLIS:180000} + llm-first-attempt-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_FIRST_ATTEMPT_READ_TIMEOUT_MILLIS:60000} llm-batch-size: ${AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE:10} llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10} max-parse-rows: ${AIIMAGE_APPEARANCE_PATENT_MAX_PARSE_ROWS:50000} @@ -283,6 +284,7 @@ aiimage: llm-max-tokens: ${AIIMAGE_SIMILAR_ASIN_LLM_MAX_TOKENS:64000} llm-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_CONNECT_TIMEOUT_MILLIS:10000} llm-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_READ_TIMEOUT_MILLIS:180000} + llm-first-attempt-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_FIRST_ATTEMPT_READ_TIMEOUT_MILLIS:60000} llm-retry-times: ${AIIMAGE_SIMILAR_ASIN_LLM_RETRY_TIMES:3} llm-row-concurrency: ${AIIMAGE_SIMILAR_ASIN_LLM_ROW_CONCURRENCY:5} llm-image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:10} diff --git a/backend-java/src/test/java/com/nanri/aiimage/common/retry/LlmRetryBackoffTest.java b/backend-java/src/test/java/com/nanri/aiimage/common/retry/LlmRetryBackoffTest.java new file mode 100644 index 00000000..5fbadc18 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/common/retry/LlmRetryBackoffTest.java @@ -0,0 +1,47 @@ +package com.nanri.aiimage.common.retry; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 重试退避策略:档位、上下界与抖动范围(固定 random 消除随机性)。 */ +class LlmRetryBackoffTest { + + @Test + void firstRetryWaitsTwoSeconds() { + assertThat(LlmRetryBackoff.delayMillis(1, 0.5d)).isEqualTo(2_000L); + } + + @Test + void secondRetryWaitsTenSeconds() { + assertThat(LlmRetryBackoff.delayMillis(2, 0.5d)).isEqualTo(10_000L); + } + + @Test + void attemptsBeyondTableUseLastTier() { + assertThat(LlmRetryBackoff.delayMillis(3, 0.5d)).isEqualTo(10_000L); + assertThat(LlmRetryBackoff.delayMillis(9, 0.5d)).isEqualTo(10_000L); + } + + @Test + void jitterStaysWithinThirtyPercent() { + assertThat(LlmRetryBackoff.delayMillis(1, 0d)).isEqualTo(1_400L); + assertThat(LlmRetryBackoff.delayMillis(1, 1d)).isEqualTo(2_600L); + assertThat(LlmRetryBackoff.delayMillis(2, 0d)).isEqualTo(7_000L); + assertThat(LlmRetryBackoff.delayMillis(2, 1d)).isEqualTo(13_000L); + } + + @Test + void attemptIndexBelowOneIsClampedToFirstTier() { + assertThat(LlmRetryBackoff.delayMillis(0, 0.5d)).isEqualTo(2_000L); + assertThat(LlmRetryBackoff.delayMillis(-3, 0.5d)).isEqualTo(2_000L); + } + + @Test + void randomDelayAlwaysPositiveAndBounded() { + for (int i = 0; i < 200; i++) { + long delay = LlmRetryBackoff.delayMillis(1); + assertThat(delay).isBetween(1_400L, 2_600L); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java index 52a0f8de..bf131c9d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java @@ -31,6 +31,10 @@ class AppearancePatentLlmClientHttpTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final Map callCounts = new ConcurrentHashMap<>(); private final List capturedBodies = new ArrayList<>(); + /** 纯文本(标题提取)请求计数:用于验证重试次数与「跳过外观」行为。 */ + private final AtomicInteger textRequestCount = new AtomicInteger(); + private volatile boolean failTextRequests = false; + private volatile long firstTextRequestDelayMillis = 0L; private AppearancePatentLlmClient client; @@ -60,6 +64,16 @@ class AppearancePatentLlmClientHttpTest { private void handleChat(HttpExchange exchange) throws IOException { String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); capturedBodies.add(body); + if (!body.contains("\"type\":\"image_url\"")) { + int sequence = textRequestCount.incrementAndGet(); + if (sequence == 1 && firstTextRequestDelayMillis > 0L) { + sleep(firstTextRequestDelayMillis); + } + if (failTextRequests) { + respond(exchange, 502, "{\"error\":{\"message\":\"bad gateway\"}}"); + return; + } + } try { @SuppressWarnings("unchecked") Map request = objectMapper.readValue(body, Map.class); @@ -103,6 +117,32 @@ class AppearancePatentLlmClientHttpTest { } } + private void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + } + + /** 用自定义超时/重试参数构建客户端(setUp 里的 client 用属性默认值)。 */ + private AppearancePatentLlmClient buildClient(java.util.function.Consumer customizer) { + AppearancePatentProperties properties = new AppearancePatentProperties(); + properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort()); + customizer.accept(properties); + return new AppearancePatentLlmClient(properties, objectMapper, null, + new BrandCheckClient(new BrandCheckProperties(), null), mock(UserSecretUsageService.class)); + } + private boolean messagesBodyContains(HttpExchange exchange, String text) { return capturedBodies.stream().anyMatch(b -> b.contains(text)); } @@ -194,4 +234,43 @@ class AppearancePatentLlmClientHttpTest { assertThat(appearanceBody).contains("\"type\":\"json_object\""); assertThat(appearanceBody).contains("产品描述:普通数据线"); } + + /** 标题识别失败 → 本行必走回退(外观结果会被丢弃),外观请求不再发出:劣化窗口下少一轮无效重试。 */ + @Test + void titleFailureSkipsAppearanceCallAndKeepsFallback() { + failTextRequests = true; + AppearancePatentLlmClient singleAttemptClient = buildClient(p -> { + p.setLlmRetryTimes(1); + p.setLlmFirstAttemptReadTimeoutMillis(5_000); + p.setLlmReadTimeoutMillis(5_000); + }); + + List rows = singleAttemptClient.inspectRows( + List.of(row("7", "B007", "任意标题", "", "https://img.example.com/7.jpg")), null, "test-key"); + + assertThat(rows.get(0).getAppearanceRisk()).isEqualTo("外观识别异常"); + assertThat(capturedBodies).noneMatch(b -> b.contains("\"type\":\"image_url\"")); + assertThat(textRequestCount.get()).isEqualTo(1); + } + + /** 首查用更短超时快速失败、重试走完整超时并成功(上游偶发单请求不响应的兜底路径)。 */ + @Test + void firstAttemptTimeoutRetriesWithFullBudgetAndSucceeds() { + firstTextRequestDelayMillis = 1_200L; + AppearancePatentLlmClient twoAttemptClient = buildClient(p -> { + p.setLlmRetryTimes(2); + p.setLlmFirstAttemptReadTimeoutMillis(400); + p.setLlmReadTimeoutMillis(8_000); + }); + + long startedAt = System.nanoTime(); + List rows = twoAttemptClient.inspectRows( + List.of(row("8", "B008", "普通数据线", "", "https://img.example.com/8.jpg")), null, "test-key"); + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + + AppearancePatentResultRowDto result = rows.get(0); + assertThat(result.getAppearanceRisk()).isEqualTo("无侵权"); + assertThat(textRequestCount.get()).isEqualTo(2); + assertThat(elapsedMs).isGreaterThanOrEqualTo(400L); + } }