mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-04 06:55:32 +08:00
fix(llm): bound shared provider concurrency and clarify cooldown failures (#676)
This commit is contained in:
parent
07613e30ca
commit
29056897d2
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("{"));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user