fix(llm): bound shared provider concurrency and clarify cooldown failures (#676)

This commit is contained in:
matevip 2026-11-02 01:25:43 -05:00
parent 07613e30ca
commit 29056897d2
6 changed files with 273 additions and 68 deletions

View File

@ -216,27 +216,6 @@ public class NodeStreamingChatHelper {
return m == null ? List.of() : List.of(new vip.mate.llm.failover.FallbackEntry("__legacy__", m));
}
/**
* Record a single primary-model outcome to the health tracker. No-op when
* either the tracker bean isn't wired or the primary's providerId is
* unknown (e.g., tests, legacy callers built without the full constructor).
*/
private void recordPrimary(boolean success) {
if (healthTracker == null || primaryProviderId == null) return;
if (success) healthTracker.recordSuccess(primaryProviderId);
else healthTracker.recordFailure(primaryProviderId);
}
/**
* Record a primary failure carrying a provider-stated retry window so the
* health tracker can start a cooldown of exactly that length. No-op under
* the same conditions as {@link #recordPrimary}.
*/
private void recordPrimaryFailure(long cooldownOverrideMs) {
if (healthTracker == null || primaryProviderId == null) return;
healthTracker.recordFailure(primaryProviderId, cooldownOverrideMs);
}
/**
* Map an {@link ErrorType} to the matching pool
* {@link vip.mate.llm.failover.AvailableProviderPool.RemovalSource} for
@ -886,8 +865,8 @@ public class NodeStreamingChatHelper {
phase, primaryProviderId, reason);
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("主模型暂时不可用(" + (primaryOutOfPool ? "已下线" : "冷却中")
+ "),直接尝试备选模型..."));
"主模型暂时不可用(" + (primaryOutOfPool ? "已下线" : "冷却中")
+ "),将尝试可用备选模型;如无可用模型请稍后重试。");
}
}
@ -923,14 +902,12 @@ public class NodeStreamingChatHelper {
}
llmCallCount++;
if (attempt > 0) retryCount++;
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, publicationMode, attempt, true, retryType, retryHint);
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, publicationMode, attempt, true, retryType, retryHint, primaryProviderId);
if (lastResult == null || !lastResult.stopped()) throwIfCancelled(conversationId);
if (lastResult != null) {
ErrorType errType = lastResult.errorType();
// Success — reaffirm health / pool membership and return.
if (lastResult.errorMessage() == null || errType == ErrorType.NONE) {
recordPrimary(true);
addToPool(primaryProviderId);
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return lastResult;
}
@ -973,22 +950,9 @@ public class NodeStreamingChatHelper {
// surface to the caller instead of burning the chain.
return lastResult;
}
if (errType.countsHealth()) {
// A rate-limit response carrying an explicit retry window
// becomes a health-cooldown override: later turns skip the
// provider until the stated instant instead of re-probing
// it every ~5 minutes and re-collecting the same 429.
Long hintMs = retryHint.get();
if (errType == ErrorType.RATE_LIMIT && hintMs != null && hintMs > 0) {
recordPrimaryFailure(hintMs);
} else {
recordPrimary(false);
}
healthRecorded = true;
}
if (errType.evictsProvider()) {
removeFromPool(primaryProviderId, errType, lastResult.errorMessage());
}
// Outcome / pool state is published under the admission permit,
// before queued requests can pass their cooldown check.
healthRecorded = errType.countsHealth();
log.warn("[{}] Primary failed (type={}) — handing off to fallback chain", phase, errType);
break;
}
@ -1001,7 +965,7 @@ public class NodeStreamingChatHelper {
// errors (MODEL_NOT_FOUND et al.) never dent provider health.
if (!primarySkipped && !healthRecorded && lastResult != null
&& isProviderLevelFailure(lastResult.errorType())) {
recordPrimary(false);
if (healthTracker != null) healthTracker.recordFailure(primaryProviderId);
}
// Primary exhausted retries — walk the fallback chain in priority order.
@ -1034,13 +998,13 @@ public class NodeStreamingChatHelper {
fallback.getClass().getSimpleName(), conversationId);
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("主模型不可用,正在切换到备选模型 (" + (i + 1) + "/" + fallbackChain.size() + ")..."));
"主模型不可用,正在切换到备选模型 (" + (i + 1) + "/" + fallbackChain.size() + ")...");
}
failoverCount++;
llmCallCount++;
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
phase + "_fallback_" + (i + 1), publicationMode, 0, false,
new AtomicReference<>(), new AtomicReference<>(0L));
new AtomicReference<>(), new AtomicReference<>(0L), entry.providerId());
if (fallbackResult == null || !fallbackResult.stopped()) throwIfCancelled(conversationId);
if (fallbackResult != null && fallbackResult.isPayloadTooLarge()) return fallbackResult;
// Accept only fully successful fallbacks. Non-successful results (auth
@ -1049,30 +1013,20 @@ public class NodeStreamingChatHelper {
if (fallbackResult != null
&& fallbackResult.errorType() == ErrorType.NONE
&& fallbackResult.errorMessage() == null) {
if (healthTracker != null) healthTracker.recordSuccess(entry.providerId());
addToPool(entry.providerId());
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
return fallbackResult;
}
// Only provider-level failures count toward the cooldown tracker. A
// null result is a retryable soft failure; a MODEL_NOT_FOUND result is
// model-scoped and must not penalise an otherwise-healthy provider.
if (healthTracker != null
&& (fallbackResult == null || isProviderLevelFailure(fallbackResult.errorType()))) {
healthTracker.recordFailure(entry.providerId());
}
if (fallbackResult != null) {
// HARD errors (auth / billing) evict the provider from the pool so
// later walks skip it outright. SOFT and model-scoped errors keep it
// in-pool — absorbed by the tracker's cooldown or simply retried.
removeFromPool(entry.providerId(), fallbackResult.errorType(), fallbackResult.errorMessage());
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
}
}
logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount);
if (lastResult == null && primaryInCooldown && !primaryOutOfPool)
return buildErrorResultWithType("供应商冷却中,且没有可用备选模型,请稍后重试", conversationId,
phase, ErrorType.PROVIDER_BUSY, publicationMode);
return lastResult != null ? lastResult
: buildErrorResult("LLM 调用失败,已达最大重试次数", conversationId, phase, publicationMode);
: buildErrorResult("当前没有可用模型,请检查供应商状态或稍后重试", conversationId, phase, publicationMode);
}
/** D-6: log a structured performance summary for the LLM call phase. */
@ -1095,7 +1049,7 @@ public class NodeStreamingChatHelper {
String conversationId, String phase,
PublicationMode publicationMode, int attempt, boolean primaryCall,
AtomicReference<ErrorType> retryTypeRef,
AtomicReference<Long> retryHintRef) {
AtomicReference<Long> retryHintRef, String providerId) {
// Collapse every SystemMessage in the prompt into a single SystemMessage
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
// server, certain strict vLLM / SGLang deployments) reject 400
@ -1143,8 +1097,43 @@ public class NodeStreamingChatHelper {
}
}
try {
return doStreamCallInner(chatModel, outbound, conversationId, phase, publicationMode, attempt, primaryCall, retryTypeRef, retryHintRef);
try (var permit = healthTracker == null ? null : healthTracker.acquireRequest(providerId,
() -> streamTracker.isStopRequested(conversationId) || Thread.currentThread().isInterrupted())) {
throwIfCancelled(conversationId);
if (healthTracker != null && permit == null)
return buildErrorResultWithType("供应商请求排队超时,请稍后重试或降低并发", conversationId,
phase, ErrorType.PROVIDER_BUSY, publicationMode);
// Another request may have triggered cooldown while this one queued.
if (!inPool(providerId))
return buildErrorResultWithType("供应商已下线,请检查配置或切换模型", conversationId,
phase, ErrorType.PROVIDER_BUSY, publicationMode);
if (healthTracker != null && healthTracker.isInCooldown(providerId))
return buildErrorResultWithType("供应商冷却中,请稍后重试", conversationId,
phase, ErrorType.PROVIDER_BUSY, publicationMode);
StreamResult result = doStreamCallInner(chatModel, outbound, conversationId, phase,
publicationMode, attempt, primaryCall, retryTypeRef, retryHintRef);
// Publish completion state BEFORE releasing capacity. Otherwise a queued
// request can slip between permit release and a newly established cooldown.
if (result != null) {
ErrorType type = result.errorType();
if (result.errorMessage() == null || type == ErrorType.NONE) {
if (healthTracker != null) healthTracker.recordSuccess(providerId);
addToPool(providerId);
} else {
boolean emptyRetryPending = primaryCall && type == ErrorType.EMPTY_RESPONSE
&& attempt < type.retryBudget();
if (healthTracker != null && type.countsHealth() && !emptyRetryPending) {
Long hint = retryHintRef.get();
healthTracker.recordFailure(providerId,
type == ErrorType.RATE_LIMIT && hint != null ? hint : 0);
}
removeFromPool(providerId, type, result.errorMessage());
}
}
return result;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw cancellation(interrupted);
} finally {
// Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) {
@ -1213,7 +1202,7 @@ public class NodeStreamingChatHelper {
if (broadcast && !emptyResponse) {
String cause = overloaded ? "模型服务繁忙" : "请求频率受限";
broadcastDelta(conversationId, "warning",
buildDeltaJson("⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
"⏱️ " + cause + ",等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)...");
}
// Poll stop flag every 100ms so user Stop is honored mid-backoff.
long remaining = delay;
@ -1538,7 +1527,7 @@ public class NodeStreamingChatHelper {
subscription.dispose();
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("模型在思考阶段停留过久,已自动截断"));
"模型在思考阶段停留过久,已自动截断");
}
// dispose 后 latch 可能不会 countDown,直接跳出
break;
@ -1553,7 +1542,7 @@ public class NodeStreamingChatHelper {
subscription.dispose();
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("检测到回答内容反复重复,已自动截断"));
"检测到回答内容反复重复,已自动截断");
}
break;
}
@ -1606,7 +1595,7 @@ public class NodeStreamingChatHelper {
phase, contentAccum.length(), toolCallAccumulators.size(), error.getMessage());
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("LLM 响应中断,使用已生成的部分内容继续"));
"LLM 响应中断,使用已生成的部分内容继续");
}
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
@ -2033,7 +2022,7 @@ public class NodeStreamingChatHelper {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (publicationMode != PublicationMode.SILENT && streamTracker != null && conversationId != null) {
broadcastDelta(conversationId, "warning",
buildDeltaJson(errorMsg));
errorMsg);
}
AssistantMessage errorMessage = new AssistantMessage("[错误] " + errorMsg);
return new StreamResult("[错误] " + errorMsg, "", errorMessage,
@ -2046,7 +2035,7 @@ public class NodeStreamingChatHelper {
log.error("[{}] Building typed error result for conversation {}: {} (type={})",
phase, conversationId, errorMsg, errorType);
if (publicationMode != PublicationMode.SILENT && streamTracker != null && conversationId != null) {
broadcastDelta(conversationId, "warning", buildDeltaJson(errorMsg));
broadcastDelta(conversationId, "warning", errorMsg);
// 广播结构化 error 事件,供前端展示错误卡片
String errorJson = buildErrorEventJson(errorMsg, conversationId, errorType);
streamTracker.broadcast(conversationId, "error", errorJson);
@ -2236,6 +2225,8 @@ public class NodeStreamingChatHelper {
* staying on a rate-limited provider wastes time — then fail over.
*/
RATE_LIMIT (MAX_RETRIES_RATE_LIMIT, true, false, true),
/** Local admission/cooldown; no provider request failed and health must not be penalized. */
PROVIDER_BUSY (0, true, false, false),
/**
* The provider's serving capacity is saturated (HTTP 529,
* "engine_overloaded", "model is overloaded"). The caller's key is

View File

@ -29,6 +29,17 @@ public class ProviderHealthProperties {
/** Cooldown window in milliseconds. */
private long cooldownMs = 300_000L;
/** Shared per-provider limit for agent LLM streams, including fallbacks. */
private int maxConcurrentRequests = 1;
/** Maximum admission wait; queued callers remain cancellable. */
private long admissionTimeoutMs = 120_000L;
public int getMaxConcurrentRequests() { return maxConcurrentRequests; }
public void setMaxConcurrentRequests(int value) { maxConcurrentRequests = Math.max(1, value); }
public long getAdmissionTimeoutMs() { return admissionTimeoutMs; }
public void setAdmissionTimeoutMs(long value) { admissionTimeoutMs = Math.max(1, value); }
/**
* TTL after which a provider HARD-removed for BILLING is lazily readmitted
* to the available pool. Users top up balances and aggregator quotas

View File

@ -39,6 +39,39 @@ public class ProviderHealthTracker {
private final ProviderHealthProperties props;
private final ConcurrentHashMap<String, AtomicLong> consecutiveFailures = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> cooldownUntilMs = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, java.util.concurrent.Semaphore> requestSlots = new ConcurrentHashMap<>();
/** Acquire across helper instances; hold until the entire stream terminates, not just headers. */
public RequestPermit acquireRequest(String providerId, java.util.function.BooleanSupplier cancelled)
throws InterruptedException {
if (providerId == null || providerId.isBlank()) return new RequestPermit(null);
var slots = requestSlots.computeIfAbsent(providerId,
ignored -> new java.util.concurrent.Semaphore(props.getMaxConcurrentRequests(), true));
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(props.getAdmissionTimeoutMs());
while (true) {
if (cancelled.getAsBoolean()) throw new java.util.concurrent.CancellationException("Queued LLM call cancelled");
long remaining = deadline - System.nanoTime();
if (remaining <= 0) return null;
if (slots.tryAcquire(Math.min(remaining, java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(100)),
java.util.concurrent.TimeUnit.NANOSECONDS)) {
var permit = new RequestPermit(slots);
if (cancelled.getAsBoolean()) {
permit.close();
throw new java.util.concurrent.CancellationException("Queued LLM call cancelled");
}
return permit;
}
}
}
public static final class RequestPermit implements AutoCloseable {
private final java.util.concurrent.Semaphore slots;
private final java.util.concurrent.atomic.AtomicBoolean closed = new java.util.concurrent.atomic.AtomicBoolean();
private RequestPermit(java.util.concurrent.Semaphore slots) { this.slots = slots; }
@Override public void close() {
if (slots != null && closed.compareAndSet(false, true)) slots.release();
}
}
public ProviderHealthTracker(ProviderHealthProperties props) {
this.props = props;

View File

@ -404,6 +404,9 @@ mateclaw:
enabled: true
failure-threshold: 3
cooldown-ms: 300000
# Shared by agent conversations and fallback models of the same provider.
max-concurrent-requests: ${MATECLAW_LLM_MAX_CONCURRENT_REQUESTS:1}
admission-timeout-ms: ${MATECLAW_LLM_ADMISSION_TIMEOUT_MS:120000}
# Multi-agent delegation (DelegateAgentTool).
delegation:
# Wall-clock budget for one delegateParallel batch (shared across all

View File

@ -0,0 +1,121 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.*;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.failover.*;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class NodeStreamingChatHelperAdmissionTest {
@Test
void cooldownIsPublishedBeforeNextQueuedRequestCanStart() throws Exception {
var recording = new CountDownLatch(1);
var release = new CountDownLatch(1);
var props = new ProviderHealthProperties();
props.setFailureThreshold(1);
var health = new ProviderHealthTracker(props) {
@Override public void recordFailure(String id, long hint) {
recording.countDown();
try { assertTrue(release.await(3, TimeUnit.SECONDS)); }
catch (InterruptedException e) { throw new AssertionError(e); }
super.recordFailure(id, hint);
}
};
var tracker = mock(ChatStreamTracker.class);
var bad = mock(ChatModel.class);
when(bad.stream(any(Prompt.class))).thenReturn(Flux.error(new RuntimeException("401 Unauthorized")));
var unexpectedCall = new CountDownLatch(1);
var next = mock(ChatModel.class);
when(next.stream(any(Prompt.class))).thenAnswer(ignored -> {
unexpectedCall.countDown();
return Flux.just(new ChatResponse(List.of(new Generation(new AssistantMessage("unexpected")))));
});
try (var workers = Executors.newFixedThreadPool(2)) {
var first = workers.submit(() -> new NodeStreamingChatHelper(tracker, List.of(), null, health, "same")
.streamCall(bad, new Prompt("hi"), "first", "reasoning"));
assertTrue(recording.await(1, TimeUnit.SECONDS));
var second = workers.submit(() -> new NodeStreamingChatHelper(tracker, List.of(), null, health, "same")
.streamCall(next, new Prompt("hi"), "next", "reasoning"));
try { assertFalse(unexpectedCall.await(150, TimeUnit.MILLISECONDS)); }
finally { release.countDown(); }
first.get(3, TimeUnit.SECONDS);
assertEquals(NodeStreamingChatHelper.ErrorType.PROVIDER_BUSY,
second.get(3, TimeUnit.SECONDS).errorType());
} finally { release.countDown(); }
verify(next, never()).stream(any(Prompt.class));
}
@Test
void separateHelpersShareProviderAdmissionForWholeStream() throws Exception {
exerciseSharedAdmission(false);
}
@Test
void fallbackStreamsUseTheSameSharedAdmission() throws Exception {
exerciseSharedAdmission(true);
}
private void exerciseSharedAdmission(boolean fallback) throws Exception {
var tracker = mock(ChatStreamTracker.class);
var health = new ProviderHealthTracker(new ProviderHealthProperties());
var active = new AtomicInteger();
var peak = new AtomicInteger();
var model = mock(ChatModel.class);
var response = new ChatResponse(List.of(new Generation(new AssistantMessage("ok"))));
when(model.stream(any(Prompt.class))).thenAnswer(ignored -> Flux.defer(() -> {
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
return Flux.just(response).delayElements(Duration.ofMillis(250))
.doOnComplete(active::decrementAndGet);
}));
var start = new CountDownLatch(1);
try (var workers = Executors.newFixedThreadPool(2)) {
var futures = new java.util.ArrayList<Future<?>>();
for (int i = 0; i < 2; i++) {
String id = "admission-" + i;
var primary = fallback ? mock(ChatModel.class) : model;
if (fallback) when(primary.stream(any(Prompt.class)))
.thenReturn(Flux.error(new RuntimeException("401 Unauthorized")));
var helper = new NodeStreamingChatHelper(tracker,
fallback ? List.of(new FallbackEntry("shared", model)) : List.of(),
null, health, fallback ? "primary-" + i : "shared");
futures.add(workers.submit(() -> {
start.await();
assertEquals("ok", helper.streamCall(primary, new Prompt("hello"), id, "reasoning").text());
return null;
}));
}
start.countDown();
for (var future : futures) future.get(5, TimeUnit.SECONDS);
}
assertEquals(1, peak.get(), "Same-provider streams must not overlap across helpers");
}
@Test
void cooldownWithoutFallbackIsTypedAndWarningHasOneJsonEnvelope() throws Exception {
var tracker = mock(ChatStreamTracker.class);
var health = new ProviderHealthTracker(new ProviderHealthProperties());
health.recordFailure("limited", 60_000);
var model = mock(ChatModel.class);
var helper = new NodeStreamingChatHelper(tracker, List.of(), null, health, "limited");
var result = helper.streamCall(model, new Prompt("hello"), "cooldown", "plan_summary");
assertEquals(NodeStreamingChatHelper.ErrorType.PROVIDER_BUSY, result.errorType());
assertFalse(result.errorMessage().contains("最大重试"), "No request was attempted");
verify(model, never()).stream(any(Prompt.class));
var payloads = org.mockito.ArgumentCaptor.forClass(String.class);
verify(tracker, atLeastOnce()).broadcast(eq("cooldown"), eq("warning"), payloads.capture());
var json = new com.fasterxml.jackson.databind.ObjectMapper();
for (String payload : payloads.getAllValues())
assertFalse(json.readTree(payload).path("delta").asText().startsWith("{"));
}
}

View File

@ -0,0 +1,46 @@
package vip.mate.llm.failover;
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
class ProviderRequestAdmissionTest {
@Test
void differentProvidersProceedAndQueueTimeoutDoesNotDamageHealth() throws Exception {
var props = new ProviderHealthProperties();
props.setAdmissionTimeoutMs(20);
var tracker = new ProviderHealthTracker(props);
try (var first = tracker.acquireRequest("a", () -> false);
var independent = tracker.acquireRequest("b", () -> false)) {
assertNotNull(first);
assertNotNull(independent);
assertNull(tracker.acquireRequest("a", () -> false));
assertTrue(tracker.snapshot().isEmpty());
}
try (var reused = tracker.acquireRequest("a", () -> false)) {
assertNotNull(reused, "Closing the first stream must release its permit");
}
}
@Test
void queuedCancellationDoesNotStealOrLeakCapacity() throws Exception {
var tracker = new ProviderHealthTracker(new ProviderHealthProperties());
var cancel = new AtomicBoolean();
try (var workers = Executors.newSingleThreadExecutor();
var first = tracker.acquireRequest("a", () -> false)) {
var entered = new CountDownLatch(1);
var future = workers.submit(() -> {
entered.countDown();
return tracker.acquireRequest("a", cancel::get);
});
assertTrue(entered.await(1, TimeUnit.SECONDS));
cancel.set(true);
var failure = assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS));
assertInstanceOf(CancellationException.class, failure.getCause());
}
try (var reused = tracker.acquireRequest("a", () -> false)) {
assertNotNull(reused);
}
}
}