feat(wiki): one-hop chat-LLM fallback when the primary provider is wedged

This commit is contained in:
matevip 2026-05-02 21:46:22 +08:00
parent ac654f3957
commit c60fc4cf83

View File

@ -1557,7 +1557,12 @@ public class WikiProcessingService {
final int maxAttempts = Math.max(1, properties.getLlmMaxAttempts());
final long maxTotalDurationMs = Math.max(1_000L, properties.getLlmMaxTotalDurationMs());
final long startNanos = System.nanoTime();
final ChatModel chatModel = buildChatModelFor(kbId, step);
ResolvedChatModel resolved = resolveChatModel(kbId, step);
ChatModel chatModel = resolved.chatModel;
Long currentModelId = resolved.modelId;
boolean alreadyFellBack = false;
int attempt = 0;
while (true) {
attempt++;
@ -1588,8 +1593,29 @@ public class WikiProcessingService {
}
String rootInfo = summarizeRoot(t);
if (isFatalModelError(t)) {
log.error("[Wiki] LLM unavailable (fatal) for {} after {} attempts (rootCause={}): {}",
ctx, attempt, rootInfo, t.getMessage());
// One-hop fallback: when the primary provider is wedged
// (auth / quota / 余额不足 / model-not-found), try the next
// configured chat model with a different provider before
// giving up. The hop is one-shot if the fallback also
// fatals, we surface the error rather than walking the
// whole chain to avoid pathological loops.
if (!alreadyFellBack) {
ResolvedChatModel next = pickFallbackChatModel(currentModelId);
if (next != null) {
log.warn("[Wiki] LLM fatal on primary model={} for {}; failing over to model={} (rootCause={}): {}",
currentModelId, ctx, next.modelId, rootInfo, t.getMessage());
chatModel = next.chatModel;
currentModelId = next.modelId;
alreadyFellBack = true;
// Reset attempt counter so the fallback gets a fresh budget; total time
// budget continues to count down.
attempt = 0;
backoffMs = 1000;
continue;
}
}
log.error("[Wiki] LLM unavailable (fatal) for {} after {} attempts on model={} (rootCause={}): {}",
ctx, attempt, currentModelId, rootInfo, t.getMessage());
throw new RuntimeException("LLM unavailable (rootCause=" + rootInfo + "): " + t.getMessage(), t);
}
long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000L;
@ -1613,6 +1639,60 @@ public class WikiProcessingService {
}
}
/** Pair of modelId + built ChatModel — null modelId means we used the system default. */
private record ResolvedChatModel(Long modelId, ChatModel chatModel) {}
private ResolvedChatModel resolveChatModel(Long kbId, vip.mate.wiki.job.WikiJobStep step) {
if (modelRoutingService != null && kbId != null && step != null) {
try {
Long modelId = modelRoutingService.selectModelId(kbId, "heavy_ingest", step);
ModelConfigEntity model = modelConfigService.getModel(modelId);
if (model != null) {
return new ResolvedChatModel(modelId,
agentGraphBuilder.buildRuntimeChatModel(model, WIKI_NO_RETRY));
}
} catch (Exception e) {
log.warn("[Wiki] Model routing failed for kbId={} step={}, falling back to default: {}",
kbId, step, e.getMessage());
}
}
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
Long id = defaultModel == null ? null : defaultModel.getId();
return new ResolvedChatModel(id, buildChatModel());
}
/**
* Pick the next enabled chat model whose provider differs from the failed
* model's provider, so we cycle to a fresh credential / billing account
* rather than retrying a wedged one. Returns null when no alternative
* exists caller surfaces the original failure.
*/
private ResolvedChatModel pickFallbackChatModel(Long failedModelId) {
try {
String failedProviderId = null;
if (failedModelId != null) {
ModelConfigEntity failed = modelConfigService.getModel(failedModelId);
if (failed != null) failedProviderId = failed.getProvider();
}
for (ModelConfigEntity candidate : modelConfigService.listEnabledModels()) {
if (candidate.getId() == null) continue;
if (candidate.getId().equals(failedModelId)) continue;
String mt = candidate.getModelType();
if (mt != null && !mt.isBlank() && !"chat".equalsIgnoreCase(mt)) continue;
if (failedProviderId != null && failedProviderId.equals(candidate.getProvider())) continue;
try {
ChatModel built = agentGraphBuilder.buildRuntimeChatModel(candidate, WIKI_NO_RETRY);
return new ResolvedChatModel(candidate.getId(), built);
} catch (Exception e) {
log.debug("[Wiki] fallback candidate model={} unbuildable: {}", candidate.getId(), e.getMessage());
}
}
} catch (Exception e) {
log.debug("[Wiki] fallback lookup failed: {}", e.getMessage());
}
return null;
}
/**
* 判断是否为"模型不可用"级别的致命错误不重试立即终止
* <p>
@ -1658,6 +1738,16 @@ public class WikiProcessingService {
|| m.contains("no default model") || m.contains("model configuration")) {
return true;
}
// 中文 provider 余额耗尽Zhipu 1113 / DashScope "Throttling" 中文返回 / 通用 "余额不足"
// 原本走 "transient retry" 5 × 1s 退避3 分钟才放弃归类为 fatal 后立即触发 fallback hop
// Chinese patterns checked against original msg (case-insensitive in Chinese is moot);
// codes / English snippets against the already-lowercased m.
if (msg.contains("余额不足") || msg.contains("请充值")
|| m.contains("\"code\":\"1113\"") || m.contains("\"code\":1113")
|| m.contains("accountbalancenotenough")
|| m.contains("balance not enough")) {
return true;
}
// Review Bug 3prompt 结构性错误重试也得同样结果立即终止
if (m.contains("context_length_exceeded")
|| m.contains("context length")