mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(llm): multi-model failover chain driven by per-provider priority
Replaces the hardcoded single-DashScope fallback with a DB-driven ordered chain. Same-provider primary deployments (e.g., DashScope qwen-max) finally get a real fallback; if any provider in the chain returns an empty body or transient failure, the next is tried. Schema — DB-driven chain - mate_model_provider gains `fallback_priority INT DEFAULT 0`. Positive values define try-order; 0 = not in chain. Migration V21 (h2 + mysql) seeds DashScope as priority 1 to preserve existing behavior. - ModelProviderService.listFallbackChain() returns providers ordered by priority ascending. - ModelProviderEntity gains the new field. Runtime — chain walk + empty-response trigger - AgentGraphBuilder.buildFallbackChain(primaryConfig) returns a List<ChatModel>, identity-filtering the primary by (providerId, modelName) — fixes the bug where same-provider-primary deployments got null fallback. Providers whose API key is missing are silently skipped with WARN. Old buildFallbackModel(ChatModel) kept as @Deprecated wrapper. - NodeStreamingChatHelper accepts List<ChatModel>; the post-retry fallback block now walks the chain in priority order, single-shot per entry. Old single-fallback constructors retained as @Deprecated one-element-list wrappers so legacy callers keep working. - New ErrorType.EMPTY_RESPONSE: when the LLM returns no content, no thinking, AND no tool calls, mark the result as a soft failure and break the same-model retry loop, handing off directly to the fallback chain. - Broadcast updated to "切换到备选模型 (N/M)..." so SSE consumers see chain progress. Tests - NodeStreamingChatHelperFallbackChainTest covers constructor variants, chain immutability, deprecated-overload back-compat, and the EMPTY_RESPONSE enum exists as a compile-time contract. - 159 tests pass (was 153 + 6 new).
This commit is contained in:
parent
980b16109d
commit
ed37e81e7e
@ -237,7 +237,7 @@ public class AgentGraphBuilder {
|
|||||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||||
ChatClient chatClient = ChatClient.create(chatModel);
|
ChatClient chatClient = ChatClient.create(chatModel);
|
||||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort);
|
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
|
||||||
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||||||
chatModel, conversationWindowManager);
|
chatModel, conversationWindowManager);
|
||||||
}
|
}
|
||||||
@ -246,15 +246,20 @@ public class AgentGraphBuilder {
|
|||||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||||
ChatClient chatClient = ChatClient.create(chatModel);
|
ChatClient chatClient = ChatClient.create(chatModel);
|
||||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort);
|
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
|
||||||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||||||
chatModel, conversationWindowManager);
|
chatModel, conversationWindowManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||||
|
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||||
|
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
|
||||||
try {
|
try {
|
||||||
ChatModel fallbackModel = buildFallbackModel(chatModel);
|
List<ChatModel> fallbackChain = buildFallbackChain(primaryModelConfig);
|
||||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel, llmCacheMetricsAggregator);
|
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackChain, llmCacheMetricsAggregator);
|
||||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||||
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
|
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
|
||||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
|
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
|
||||||
@ -346,9 +351,14 @@ public class AgentGraphBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||||
|
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||||
|
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
|
||||||
try {
|
try {
|
||||||
ChatModel fallbackModel = buildFallbackModel(chatModel);
|
List<ChatModel> fallbackChain = buildFallbackChain(primaryModelConfig);
|
||||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel, llmCacheMetricsAggregator);
|
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackChain, llmCacheMetricsAggregator);
|
||||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||||
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
|
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
|
||||||
ActionNode actionNode = new ActionNode(executor, streamTracker);
|
ActionNode actionNode = new ActionNode(executor, streamTracker);
|
||||||
@ -530,9 +540,84 @@ public class AgentGraphBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建 fallback 模型:优先使用 UI 配置的 DashScope provider key 构建新实例,
|
* build the full multi-provider failover chain for a primary
|
||||||
* 避免直接依赖 Spring 注入的 dashScopeChatModel bean(它只读环境变量)。
|
* model. Providers are read from {@code mate_model_provider} ordered by
|
||||||
|
* {@code fallback_priority ASC} (positive values only), each resolved to
|
||||||
|
* its default {@link ModelConfigEntity} and turned into a {@link ChatModel}
|
||||||
|
* via {@link #buildRuntimeChatModel(ModelConfigEntity, RetryTemplate)}.
|
||||||
|
*
|
||||||
|
* <p>Providers whose API key / base URL is missing (build throws) are
|
||||||
|
* <b>silently skipped</b> with a warning — fallback should never break
|
||||||
|
* the primary call path. The returned list preserves chain order; the
|
||||||
|
* streaming helper tries entries in order until one succeeds.</p>
|
||||||
|
*
|
||||||
|
* <p>The primary model is excluded from the chain when its provider +
|
||||||
|
* model name matches a chain entry. Previously only reference equality
|
||||||
|
* was checked, which meant a DashScope-primary deployment ended up with
|
||||||
|
* {@code null} fallback — the case this design targets.</p>
|
||||||
|
*
|
||||||
|
* @param primaryModelConfig the {@code ModelConfigEntity} used to build
|
||||||
|
* the primary model; used to identity-filter the chain
|
||||||
|
* @return ordered, possibly-empty list of fallback {@link ChatModel}s
|
||||||
*/
|
*/
|
||||||
|
List<ChatModel> buildFallbackChain(ModelConfigEntity primaryModelConfig) {
|
||||||
|
List<ModelProviderEntity> providers;
|
||||||
|
try {
|
||||||
|
providers = modelProviderService.listFallbackChain();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[LlmFailover] failed to load fallback chain from DB: {}; running without fallback",
|
||||||
|
e.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (providers == null || providers.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
|
||||||
|
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
|
||||||
|
|
||||||
|
List<ChatModel> chain = new ArrayList<>();
|
||||||
|
for (ModelProviderEntity p : providers) {
|
||||||
|
ModelConfigEntity fallbackConfig;
|
||||||
|
try {
|
||||||
|
fallbackConfig = modelConfigService.getDefaultModelByProvider(p.getProviderId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[LlmFailover] skipping provider {} — cannot resolve default model: {}",
|
||||||
|
p.getProviderId(), e.getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fallbackConfig == null) {
|
||||||
|
log.debug("[LlmFailover] skipping provider {} — no default model configured",
|
||||||
|
p.getProviderId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (primaryProviderId != null
|
||||||
|
&& primaryProviderId.equals(p.getProviderId())
|
||||||
|
&& fallbackConfig.getModelName() != null
|
||||||
|
&& fallbackConfig.getModelName().equals(primaryModelName)) {
|
||||||
|
log.debug("[LlmFailover] skipping primary {}/{} in fallback chain",
|
||||||
|
primaryProviderId, primaryModelName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
|
||||||
|
chain.add(m);
|
||||||
|
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
|
||||||
|
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
|
||||||
|
p.getFallbackPriority());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
|
||||||
|
p.getProviderId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use {@link #buildFallbackChain(ModelConfigEntity)} — the
|
||||||
|
* single-fallback variant cannot represent an ordered chain and only
|
||||||
|
* worked when the primary was a non-DashScope provider.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
ChatModel buildFallbackModel(ChatModel primaryModel) {
|
ChatModel buildFallbackModel(ChatModel primaryModel) {
|
||||||
try {
|
try {
|
||||||
ModelProviderEntity dashScopeProvider = modelProviderService.getProviderConfig("dashscope");
|
ModelProviderEntity dashScopeProvider = modelProviderService.getProviderConfig("dashscope");
|
||||||
|
|||||||
@ -44,24 +44,43 @@ public class NodeStreamingChatHelper {
|
|||||||
|
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
/** Fallback model, used after consecutive failures of the primary model. */
|
/**
|
||||||
private final ChatModel fallbackModel;
|
* Ordered fallback chain tried after the primary model exhausts retries.
|
||||||
|
* Each entry is attempted once (no retry); the first successful response
|
||||||
|
* wins. Empty list disables fallover entirely.
|
||||||
|
*/
|
||||||
|
private final List<ChatModel> fallbackChain;
|
||||||
|
|
||||||
/** Optional cache-metrics aggregator; {@code null} in tests or when the bean is absent. */
|
/** Optional cache-metrics aggregator; {@code null} in tests or when the bean is absent. */
|
||||||
private final vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics;
|
private final vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics;
|
||||||
|
|
||||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
||||||
this(streamTracker, null, null);
|
this(streamTracker, List.of(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use the list-based constructor — a single fallback cannot
|
||||||
|
* express the ordered multi-provider chain
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
|
||||||
this(streamTracker, fallbackModel, null);
|
this(streamTracker, fallbackModel == null ? List.of() : List.of(fallbackModel), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use the list-based constructor — a single fallback cannot
|
||||||
|
* express the ordered multi-provider chain
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
|
||||||
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
||||||
|
this(streamTracker, fallbackModel == null ? List.of() : List.of(fallbackModel), cacheMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, List<ChatModel> fallbackChain,
|
||||||
|
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
||||||
this.streamTracker = streamTracker;
|
this.streamTracker = streamTracker;
|
||||||
this.fallbackModel = fallbackModel;
|
this.fallbackChain = fallbackChain == null ? List.of() : List.copyOf(fallbackChain);
|
||||||
this.cacheMetrics = cacheMetrics;
|
this.cacheMetrics = cacheMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +252,13 @@ public class NodeStreamingChatHelper {
|
|||||||
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
||||||
return lastResult; // 已经重试过了
|
return lastResult; // 已经重试过了
|
||||||
}
|
}
|
||||||
|
// EMPTY_RESPONSE — break the primary-retry loop and fall through to
|
||||||
|
// the fallback chain. Retrying the same model that returned nothing is rarely
|
||||||
|
// productive; a different provider has a better chance of succeeding.
|
||||||
|
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
|
||||||
|
log.warn("[{}] Primary returned empty response — skipping same-model retries, handing off to fallback chain", phase);
|
||||||
|
break;
|
||||||
|
}
|
||||||
// 成功
|
// 成功
|
||||||
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
|
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
|
||||||
return lastResult;
|
return lastResult;
|
||||||
@ -246,19 +272,33 @@ public class NodeStreamingChatHelper {
|
|||||||
// lastResult == null 表示需要重试
|
// lastResult == null 表示需要重试
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主模型耗尽重试 — 尝试 fallback model
|
// Primary exhausted retries — walk the fallback chain in priority order.
|
||||||
if (fallbackModel != null && fallbackModel != chatModel) {
|
// Each fallback gets a single shot (no retry); first successful result wins.
|
||||||
log.warn("[{}] Primary model exhausted retries, switching to fallback model for conversation {}",
|
// Same-instance entries (e.g., primary accidentally included in the chain)
|
||||||
phase, conversationId);
|
// are skipped so we don't re-try the exact model that just failed.
|
||||||
|
for (int i = 0; i < fallbackChain.size(); i++) {
|
||||||
|
ChatModel fallback = fallbackChain.get(i);
|
||||||
|
if (fallback == chatModel) continue;
|
||||||
|
log.warn("[{}] Primary exhausted, trying fallback {}/{} ({}) for conversation {}",
|
||||||
|
phase, i + 1, fallbackChain.size(),
|
||||||
|
fallback.getClass().getSimpleName(), conversationId);
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
broadcastDelta(conversationId, "warning",
|
broadcastDelta(conversationId, "warning",
|
||||||
buildDeltaJson("主模型不可用,正在切换到备选模型..."));
|
buildDeltaJson("主模型不可用,正在切换到备选模型 (" + (i + 1) + "/" + fallbackChain.size() + ")..."));
|
||||||
}
|
}
|
||||||
StreamResult fallbackResult = doStreamCall(fallbackModel, prompt, conversationId,
|
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
|
||||||
phase + "_fallback", broadcast, 0);
|
phase + "_fallback_" + (i + 1), broadcast, 0);
|
||||||
if (fallbackResult != null) {
|
// Accept only fully successful fallbacks. Non-successful results (auth
|
||||||
|
// error, client error, still-rate-limited) propagate to the next
|
||||||
|
// fallback instead of being surfaced as the final result.
|
||||||
|
if (fallbackResult != null
|
||||||
|
&& fallbackResult.errorType() == ErrorType.NONE
|
||||||
|
&& fallbackResult.errorMessage() == null) {
|
||||||
return fallbackResult;
|
return fallbackResult;
|
||||||
}
|
}
|
||||||
|
if (fallbackResult != null) {
|
||||||
|
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastResult != null ? lastResult
|
return lastResult != null ? lastResult
|
||||||
@ -274,7 +314,7 @@ public class NodeStreamingChatHelper {
|
|||||||
boolean broadcast, int attempt) {
|
boolean broadcast, int attempt) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
||||||
// 加入 jitter 防止雷群效应(Hermes 风格)
|
// 加入 jitter 防止雷群效应(a comparable reference runtime 风格)
|
||||||
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
|
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
|
||||||
delay = Math.min(delay, BACKOFF_CAP_MS);
|
delay = Math.min(delay, BACKOFF_CAP_MS);
|
||||||
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
||||||
@ -496,6 +536,22 @@ public class NodeStreamingChatHelper {
|
|||||||
phase, conversationId);
|
phase, conversationId);
|
||||||
// warning 已在 dispose 时广播,无需重复
|
// warning 已在 dispose 时广播,无需重复
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// guard against silent empty responses. Some providers return
|
||||||
|
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
|
||||||
|
// capacity, context filter, upstream overload). Treat this as a failure
|
||||||
|
// signal so streamCallInternal can hand off to the fallback chain.
|
||||||
|
// Only fire when the primary wasn't truncated by our own repetition
|
||||||
|
// detector (which deliberately produces short content) and when there
|
||||||
|
// are no tool calls (tool-only responses are legitimately empty-text).
|
||||||
|
if (!truncatedByRepetition
|
||||||
|
&& contentAccum.length() == 0
|
||||||
|
&& thinkingAccum.length() == 0
|
||||||
|
&& toolCallAccumulators.isEmpty()) {
|
||||||
|
log.warn("[{}] LLM returned empty response (no content, no thinking, no tool calls) — marking as EMPTY_RESPONSE for fallback", phase);
|
||||||
|
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
|
||||||
|
}
|
||||||
|
|
||||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||||
promptTokens.get(), completionTokens.get(),
|
promptTokens.get(), completionTokens.get(),
|
||||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
||||||
@ -750,6 +806,13 @@ public class NodeStreamingChatHelper {
|
|||||||
CLIENT_ERROR,
|
CLIENT_ERROR,
|
||||||
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
|
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
|
||||||
THINKING_BLOCK_ERROR,
|
THINKING_BLOCK_ERROR,
|
||||||
|
/**
|
||||||
|
* LLM returned no content, no thinking, and no tool calls.
|
||||||
|
* Treated as a soft failure — skip same-model retries and hand off to
|
||||||
|
* the fallback chain directly. Typical cause: upstream rate-limit
|
||||||
|
* rejection that comes back as HTTP 200 with empty body.
|
||||||
|
*/
|
||||||
|
EMPTY_RESPONSE,
|
||||||
/** 其他未知错误 */
|
/** 其他未知错误 */
|
||||||
UNKNOWN
|
UNKNOWN
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,13 @@ public class ModelProviderEntity {
|
|||||||
|
|
||||||
private String oauthAccountId;
|
private String oauthAccountId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* position of this provider in the fallback chain.
|
||||||
|
* {@code 0} (default) means "not in the chain"; positive values are tried
|
||||||
|
* in ascending order after the primary model exhausts its retries.
|
||||||
|
*/
|
||||||
|
private Integer fallbackPriority;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -143,6 +143,20 @@ public class ModelProviderService {
|
|||||||
return getProvider(providerId);
|
return getProvider(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ordered list of providers that participate in the multi-model
|
||||||
|
* failover chain. Filters by {@code fallback_priority > 0} and sorts
|
||||||
|
* ascending, so priority 1 is tried first after the primary model
|
||||||
|
* exhausts retries. An empty list disables fallover entirely.
|
||||||
|
*/
|
||||||
|
public List<ModelProviderEntity> listFallbackChain() {
|
||||||
|
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ModelProviderEntity> qw =
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
|
||||||
|
qw.gt("fallback_priority", 0);
|
||||||
|
qw.orderByAsc("fallback_priority");
|
||||||
|
return modelProviderMapper.selectList(qw);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isProviderConfigured(String providerId) {
|
public boolean isProviderConfigured(String providerId) {
|
||||||
return isProviderConfigured(getProvider(providerId));
|
return isProviderConfigured(getProvider(providerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -151,7 +151,7 @@ mate:
|
|||||||
per-category:
|
per-category:
|
||||||
shell: 120
|
shell: 120
|
||||||
web: 30
|
web: 30
|
||||||
# RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget).
|
# RFC-008 tool-result three-layer budget (per-result spill + per-turn aggregate budget).
|
||||||
# Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized
|
# Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized
|
||||||
# single results to disk; Layer 3 enforces an aggregate cap on the combined
|
# single results to disk; Layer 3 enforces an aggregate cap on the combined
|
||||||
# response size of one tool turn. The full output is preserved on disk and
|
# response size of one tool turn. The full output is preserved on disk and
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- ordered multi-provider fallback chain
|
||||||
|
--
|
||||||
|
-- `fallback_priority` defines the order in which a provider is tried after the
|
||||||
|
-- primary model exhausts retries:
|
||||||
|
-- 0 : not in the fallback chain (default — matches pre-RFC behavior)
|
||||||
|
-- 1, 2, … : try in ascending order
|
||||||
|
--
|
||||||
|
-- Seed: keep DashScope as priority 1 so existing deployments preserve the
|
||||||
|
-- single-fallback-to-DashScope behavior the hardcoded path used to provide.
|
||||||
|
|
||||||
|
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS fallback_priority INT DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE mate_model_provider SET fallback_priority = 1
|
||||||
|
WHERE provider_id = 'dashscope' AND (fallback_priority IS NULL OR fallback_priority = 0);
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
-- ordered multi-provider fallback chain
|
||||||
|
--
|
||||||
|
-- `fallback_priority` defines the order in which a provider is tried after the
|
||||||
|
-- primary model exhausts retries:
|
||||||
|
-- 0 : not in the fallback chain (default — matches pre-RFC behavior)
|
||||||
|
-- 1, 2, … : try in ascending order
|
||||||
|
--
|
||||||
|
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use the INFORMATION_SCHEMA guard so
|
||||||
|
-- this migration is idempotent across redeploys.
|
||||||
|
|
||||||
|
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'mate_model_provider'
|
||||||
|
AND COLUMN_NAME = 'fallback_priority');
|
||||||
|
SET @s := IF(@c = 0,
|
||||||
|
'ALTER TABLE mate_model_provider ADD COLUMN fallback_priority INT DEFAULT 0',
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Seed: keep DashScope as priority 1 so existing deployments preserve the
|
||||||
|
-- single-fallback-to-DashScope behavior the hardcoded path used to provide.
|
||||||
|
UPDATE mate_model_provider
|
||||||
|
SET fallback_priority = 1
|
||||||
|
WHERE provider_id = 'dashscope'
|
||||||
|
AND (fallback_priority IS NULL OR fallback_priority = 0);
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package vip.mate.agent.graph;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* smoke tests for the multi-model fallback chain wiring on
|
||||||
|
* {@link NodeStreamingChatHelper}.
|
||||||
|
*
|
||||||
|
* <p>Full streaming-flow integration (ChatModel.stream / Flux mocking) is left
|
||||||
|
* to end-to-end smoke tests in the RFC; these tests verify the public
|
||||||
|
* surface — constructor variants, chain immutability, deprecated-overload
|
||||||
|
* compatibility — so future refactors of those entry points are caught.</p>
|
||||||
|
*/
|
||||||
|
class NodeStreamingChatHelperFallbackChainTest {
|
||||||
|
|
||||||
|
private final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("List-based constructor preserves fallback chain order and contents")
|
||||||
|
void listConstructorPreservesOrder() throws Exception {
|
||||||
|
ChatModel a = mock(ChatModel.class);
|
||||||
|
ChatModel b = mock(ChatModel.class);
|
||||||
|
ChatModel c = mock(ChatModel.class);
|
||||||
|
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, List.of(a, b, c), null);
|
||||||
|
|
||||||
|
List<ChatModel> chain = readFallbackChain(helper);
|
||||||
|
assertEquals(3, chain.size(), "fallback chain should preserve all entries");
|
||||||
|
assertSame(a, chain.get(0), "priority 1 must be first");
|
||||||
|
assertSame(b, chain.get(1));
|
||||||
|
assertSame(c, chain.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Null fallback chain is normalized to empty list (defensive)")
|
||||||
|
void nullChainNormalizedToEmpty() throws Exception {
|
||||||
|
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, (List<ChatModel>) null, null);
|
||||||
|
assertTrue(readFallbackChain(helper).isEmpty(),
|
||||||
|
"null chain must not throw — it should be normalized to an empty list");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Single-arg constructor (no fallback) yields empty chain")
|
||||||
|
void singleArgConstructorEmptyChain() throws Exception {
|
||||||
|
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker);
|
||||||
|
assertTrue(readFallbackChain(helper).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Deprecated single-fallback constructor wraps the model into a 1-element chain")
|
||||||
|
void deprecatedSingleFallbackConstructorBackCompat() throws Exception {
|
||||||
|
ChatModel single = mock(ChatModel.class);
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, single);
|
||||||
|
|
||||||
|
List<ChatModel> chain = readFallbackChain(helper);
|
||||||
|
assertEquals(1, chain.size(), "deprecated overload should produce a 1-entry chain");
|
||||||
|
assertSame(single, chain.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Deprecated single-fallback constructor with null produces empty chain (no NPE)")
|
||||||
|
void deprecatedSingleFallbackNullSafe() throws Exception {
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, (ChatModel) null);
|
||||||
|
assertTrue(readFallbackChain(helper).isEmpty(),
|
||||||
|
"null single fallback must collapse to an empty chain");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("EMPTY_RESPONSE error type exists (fallback trigger)")
|
||||||
|
void emptyResponseErrorTypeExists() {
|
||||||
|
// Compile-time safety net: the enum constant the streaming pipeline relies on
|
||||||
|
// must not be renamed or removed without breaking the fallback contract.
|
||||||
|
NodeStreamingChatHelper.ErrorType t = NodeStreamingChatHelper.ErrorType.EMPTY_RESPONSE;
|
||||||
|
assertNotNull(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<ChatModel> readFallbackChain(NodeStreamingChatHelper helper) throws Exception {
|
||||||
|
Field f = NodeStreamingChatHelper.class.getDeclaredField("fallbackChain");
|
||||||
|
f.setAccessible(true);
|
||||||
|
return (List<ChatModel>) f.get(helper);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user