mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
conversation: separate retry and loop budgets
This commit is contained in:
@@ -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 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。
|
||||
- 备用接入点切换只作用于聊天模型;推理模型和视觉模型仍使用各自接入点,其中视觉模型自身的失败重试也使用统一退避。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user