diff --git a/README.md b/README.md index 95964d3..7a267f8 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,8 @@ historyMessageLimit: 20 logPrompt: false # 达到需要合并转发消息的阈值 messageMergeThreshold: 150 -# 最大循环次数,至少2次 -retryMax: 5 +# 单次对话正常调用模型的最大循环轮数,至少2轮;失败重试不占用此轮数 +retryMax: 10 # 关键字呼叫,支持正则表达式 callKeyword: '[小筱][林淋月玥]' # 是否显示工具调用消息,默认是 @@ -457,7 +457,8 @@ fallbackCooldownMinutes: 5 ### 工作机制 - 主接入点为列表首位,备用接入点按配置顺序排在其后。 -- **单次对话内**:某接入点流式调用失败时,经短暂指数退避后切换到下一个接入点重试,而非反复重试同一个故障点。 +- **单轮调用内**:配置备用接入点时,每个接入点最多尝试一次,全部失败后立即结束;未配置备用接入点时,主接入点失败后只重试一次。每次重试前都会进行带抖动的指数退避。 +- **正常对话循环**:只有成功完成的模型调用才计入 `retryMax`,失败尝试不消耗正常循环轮数;默认最多 10 轮。 - **跨对话冷却**:失败的接入点进入冷却期(`fallbackCooldownMinutes` 分钟),冷却期内会被排到重试队尾。这样主接入点 key 到期后,后续消息会直接走健康的备用接入点,不必每条都先卡一次超时。调用成功或冷却到期后自动恢复。 - 仅在**LLM 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。 - 备用接入点切换只作用于聊天模型;推理模型和视觉模型仍使用各自接入点,其中视觉模型自身的失败重试也使用统一退避。 diff --git a/src/main/kotlin/config/PluginConfig.kt b/src/main/kotlin/config/PluginConfig.kt index f2793c3..84255b2 100644 --- a/src/main/kotlin/config/PluginConfig.kt +++ b/src/main/kotlin/config/PluginConfig.kt @@ -259,8 +259,8 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("达到需要合并转发消息的阈值") val messageMergeThreshold by value(150) - @ValueDescription("最大循环次数,至少2次") - val retryMax: Int by value(5) + @ValueDescription("单次对话正常调用模型的最大循环轮数,至少2轮;失败重试不占用此轮数") + val retryMax: Int by value(10) @ValueDescription("关键字呼叫,支持正则表达式") val callKeyword by value("[小筱][林淋月玥]") diff --git a/src/main/kotlin/conversation/ConversationEngine.kt b/src/main/kotlin/conversation/ConversationEngine.kt index a0e6ac6..1646e4c 100644 --- a/src/main/kotlin/conversation/ConversationEngine.kt +++ b/src/main/kotlin/conversation/ConversationEngine.kt @@ -50,8 +50,6 @@ import top.jie65535.mirai.tools.WebSearch import top.jie65535.mirai.util.RetryBackoff import java.time.OffsetDateTime import java.time.format.DateTimeFormatter -import kotlin.math.max -import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -129,11 +127,12 @@ internal object ConversationEngine { if (endpoints.isEmpty()) error("OpenAI Token 未设置,无法开始") var endpointIndex = 0 var done: Boolean - var retry = max(PluginConfig.retryMax, 3) + val maxRounds = PluginConfig.retryMax.coerceAtLeast(2) + var completedRounds = 0 val retryBackoff = RetryBackoff.fromConfig() var consecutiveFailures = 0 do { - val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)] + val endpoint = endpoints[endpointIndex] var streamingOk = false try { val startedAt = OffsetDateTime.now().toEpochSecond().toInt() @@ -197,6 +196,7 @@ internal object ConversationEngine { reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContent?.toString() else null, ) recordUsage(event, lastTokenUsage, lastCacheUsage) + completedRounds++ if (responseToolCalls.size > toolCallTasks.size) { val finalToolResult = responseToolCalls.last().toResultMessage(event) @@ -208,7 +208,9 @@ internal object ConversationEngine { } if (!done) { - history += ChatMessage.User(buildContinuationPrompt(retry, startedAt, event)) + history += ChatMessage.User( + buildContinuationPrompt(maxRounds - completedRounds, startedAt, event) + ) } else { if (PluginConfig.enableContextCache) { ConversationContext.saveCache( @@ -223,31 +225,40 @@ internal object ConversationEngine { } } catch (cause: Exception) { if (cause is CancellationException) throw cause - val failureMessage = if (!streamingOk) { - LargeLanguageModels.reportFailure(endpoint) - if (endpointIndex < endpoints.lastIndex) { - endpointIndex++ - "接入点[${endpoint.label}]调用失败,将切换备用接入点[${endpoints[endpointIndex].label}]" - } else { - "接入点[${endpoint.label}]调用失败,无更多备用接入点" - } - } else { - "调用llm后处理时发生异常" - } - if (retry <= 1) { - JChatGPT.logger.warning("$failureMessage,已无剩余尝试", cause) + if (streamingOk) { + JChatGPT.logger.warning("调用llm后处理时发生异常,不再重试模型请求", cause) throw cause } + LargeLanguageModels.reportFailure(endpoint) consecutiveFailures++ + val nextEndpointIndex = nextChatEndpointIndex( + endpointCount = endpoints.size, + currentIndex = endpointIndex, + failureCount = consecutiveFailures, + ) + if (nextEndpointIndex == null) { + JChatGPT.logger.warning( + "接入点[${endpoint.label}]调用失败,已无剩余接入点或重试次数", + cause, + ) + throw cause + } + val nextEndpoint = endpoints[nextEndpointIndex] val retryDelayMillis = retryBackoff.delayMillis(consecutiveFailures) + val retryMessage = if (nextEndpointIndex == endpointIndex) { + "接入点[${endpoint.label}]调用失败,将重试一次" + } else { + "接入点[${endpoint.label}]调用失败,将切换备用接入点[${nextEndpoint.label}]" + } JChatGPT.logger.warning( - "$failureMessage,将在 ${retryDelayMillis}ms 后重试", + "$retryMessage,将在 ${retryDelayMillis}ms 后重试", cause, ) + endpointIndex = nextEndpointIndex if (retryDelayMillis > 0) delay(retryDelayMillis) done = false } - } while (!done && 0 < --retry) + } while (!done && completedRounds < maxRounds) } catch (cause: CancellationException) { throw cause } catch (cause: Throwable) { @@ -323,9 +334,9 @@ internal object ConversationEngine { return truncated } - private fun buildContinuationPrompt(retry: Int, startedAt: Int, event: MessageEvent): String = buildString { + private fun buildContinuationPrompt(remainingRounds: Int, startedAt: Int, event: MessageEvent): String = buildString { appendLine("## 系统提示") - append("本次运行最多还剩").append(retry - 1).appendLine("轮。") + append("本次运行最多还剩").append(remainingRounds).appendLine("轮。") appendLine("如果要多次发言,可以一次性调用多次发言工具。") appendLine("如果没有什么要做的,可以提前结束。") appendLine("当前时间:${dateTimeFormatter.format(OffsetDateTime.now())}") @@ -359,3 +370,14 @@ internal object ConversationEngine { else content.take(maxLength) + "\n\n[系统提示:因内容过长,部分内容已被省略]" } } + +internal fun nextChatEndpointIndex(endpointCount: Int, currentIndex: Int, failureCount: Int): Int? { + require(endpointCount > 0) { "endpointCount must be positive" } + require(currentIndex in 0 until endpointCount) { "currentIndex must reference an endpoint" } + require(failureCount > 0) { "failureCount must be positive" } + return when { + endpointCount == 1 && failureCount == 1 -> currentIndex + currentIndex < endpointCount - 1 -> currentIndex + 1 + else -> null + } +} diff --git a/src/test/kotlin/conversation/ConversationRetryPolicyTest.kt b/src/test/kotlin/conversation/ConversationRetryPolicyTest.kt new file mode 100644 index 0000000..af0eb63 --- /dev/null +++ b/src/test/kotlin/conversation/ConversationRetryPolicyTest.kt @@ -0,0 +1,38 @@ +package top.jie65535.mirai.conversation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ConversationRetryPolicyTest { + @Test + fun singleEndpointIsRetriedOnce() { + assertEquals(0, nextChatEndpointIndex(endpointCount = 1, currentIndex = 0, failureCount = 1)) + assertEquals(null, nextChatEndpointIndex(endpointCount = 1, currentIndex = 0, failureCount = 2)) + } + + @Test + fun multipleEndpointsAreEachAttemptedOnce() { + assertEquals(1, nextChatEndpointIndex(endpointCount = 3, currentIndex = 0, failureCount = 1)) + assertEquals(2, nextChatEndpointIndex(endpointCount = 3, currentIndex = 1, failureCount = 2)) + assertEquals(null, nextChatEndpointIndex(endpointCount = 3, currentIndex = 2, failureCount = 3)) + } + + @Test + fun successfulFallbackRemainsTheStartingEndpointForLaterRounds() { + assertEquals(2, nextChatEndpointIndex(endpointCount = 3, currentIndex = 1, failureCount = 1)) + } + + @Test + fun rejectsInvalidEndpointState() { + assertFailsWith { + nextChatEndpointIndex(endpointCount = 0, currentIndex = 0, failureCount = 1) + } + assertFailsWith { + nextChatEndpointIndex(endpointCount = 2, currentIndex = 2, failureCount = 1) + } + assertFailsWith { + nextChatEndpointIndex(endpointCount = 2, currentIndex = 0, failureCount = 0) + } + } +}