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