conversation: separate retry and loop budgets

This commit is contained in:
2026-08-05 12:41:13 +08:00
parent d140ba4fad
commit 1aa6939893
4 changed files with 88 additions and 27 deletions
+4 -3
View File
@@ -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 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。
- 备用接入点切换只作用于聊天模型;推理模型和视觉模型仍使用各自接入点,其中视觉模型自身的失败重试也使用统一退避。
+2 -2
View File
@@ -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("[小筱][林淋月玥]")
@@ -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 retryDelayMillis = retryBackoff.delayMillis(consecutiveFailures)
val nextEndpointIndex = nextChatEndpointIndex(
endpointCount = endpoints.size,
currentIndex = endpointIndex,
failureCount = consecutiveFailures,
)
if (nextEndpointIndex == null) {
JChatGPT.logger.warning(
"$failureMessage,将在 ${retryDelayMillis}ms 后重试",
"接入点[${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(
"$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
}
}
@@ -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<IllegalArgumentException> {
nextChatEndpointIndex(endpointCount = 0, currentIndex = 0, failureCount = 1)
}
assertFailsWith<IllegalArgumentException> {
nextChatEndpointIndex(endpointCount = 2, currentIndex = 2, failureCount = 1)
}
assertFailsWith<IllegalArgumentException> {
nextChatEndpointIndex(endpointCount = 2, currentIndex = 0, failureCount = 0)
}
}
}