diff --git a/README.md b/README.md index 9fe1a42..8c182b0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模 ## 功能特性 - **多模型支持**:支持聊天模型、推理模型和视觉模型 +- **接入点容灾**:聊天模型可配置多个备用接入点,主接入点 key 到期 / 限流 / 服务不稳定时自动切换 - **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等 - **上下文记忆**:支持持久化记忆存储 - **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染 @@ -88,6 +89,15 @@ visualModelToken: '' visualModel: 'qwen-vl-plus' # 聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {"thinking": {"type": "disabled"}} chatModelExtraBody: '' +# 聊天模型备用接入点列表(容灾)。主接入点连续失败时按顺序切换;每项留空的字段会继承主接入点 +# 例如只换API KEY就只填token,只换模型就只填model,整体换服务商就都填 +chatFallbacks: [] +# - api: 'https://api.deepseek.com/v1/' +# token: 'sk-xxxx' +# model: 'deepseek-chat' +# extraBody: '' +# 备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。0为禁用 +fallbackCooldownMinutes: 5 # 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}} reasoningModelExtraBody: '' # 视觉模型额外请求体JSON,会合并到请求体中。 @@ -276,6 +286,37 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型 当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。 +## 接入点容灾 + +聊天模型支持配置多个**备用接入点**,当主接入点连续调用失败(key 到期、用量超限、服务不稳定、超时等)时自动切换,提升可用性。 + +### 配置方式 + +在 `chatFallbacks` 中按优先级顺序列出备用接入点。每项的任意字段**留空则继承主接入点**对应配置,因此三种容灾场景都覆盖: + +```yaml +chatFallbacks: + # 只换 API KEY(同服务商备用 key) + - token: 'sk-备用key' + # 只换模型(同接入点降级到更稳定/更便宜的模型) + - model: 'qwen-plus' + # 整体换一个服务商 + - api: 'https://api.deepseek.com/v1/' + token: 'sk-deepseek-xxxx' + model: 'deepseek-chat' + extraBody: '' +fallbackCooldownMinutes: 5 +``` + +### 工作机制 + +- 主接入点为列表首位,备用接入点按配置顺序排在其后。 +- **单次对话内**:某接入点流式调用失败时,立即切换到下一个接入点重试,而非反复重试同一个故障点。 +- **跨对话冷却**:失败的接入点进入冷却期(`fallbackCooldownMinutes` 分钟),冷却期内会被排到重试队尾。这样主接入点 key 到期后,后续消息会直接走健康的备用接入点,不必每条都先卡一次超时。调用成功或冷却到期后自动恢复。 +- 仅在**LLM 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。 +- 推理模型、视觉模型不受影响,仍各自独立配置。 +- `/jgpt reload` 会重建接入点列表并清空冷却状态。 + ## 工具系统 插件内置了丰富的工具供 AI 调用: diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index dccee40..d4c9445 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -42,6 +42,7 @@ import java.time.ZoneOffset import java.time.format.DateTimeFormatter import kotlin.collections.* import kotlin.math.max +import kotlin.math.min import kotlin.math.pow import kotlin.math.sign import kotlin.time.Duration.Companion.seconds @@ -683,14 +684,23 @@ object JChatGPT : KotlinPlugin( ) } + // 聊天接入点容灾:按健康度排序,主接入点故障冷却时备用接入点会自动排到前面 + val endpoints = LargeLanguageModels.orderedChatEndpoints() + if (endpoints.isEmpty()) throw NullPointerException("OpenAI Token 未设置,无法开始") + var endpointIndex = 0 + var done: Boolean // 至少循环3次 var retry = max(PluginConfig.retryMax, 3) do { + // 当前使用的接入点:失败重试时会前移到下一个备用接入点 + val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)] + // 标记本轮 LLM 流式调用是否成功完成,用于精确区分「LLM失败」与「后续工具失败」 + var streamingOk = false try { val startedAt = OffsetDateTime.now().toEpochSecond().toInt() var lastCacheUsage: ModelService.CacheUsage? = null - val responseFlow = chatCompletions(history) { lastCacheUsage = it } + val responseFlow = chatCompletions(history, endpoint) { lastCacheUsage = it } var responseMessageBuilder: StringBuilder? = null var reasoningContentBuilder: StringBuilder? = null val responseToolCalls = mutableListOf() @@ -770,6 +780,9 @@ object JChatGPT : KotlinPlugin( // 捕获token使用量 chunk.usage?.let { lastTokenUsage = it } } + // LLM 流式调用成功完成,上报接入点健康(清除冷却) + streamingOk = true + LargeLanguageModels.reportSuccess(endpoint) // 移除思考内容 val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim() @@ -856,11 +869,23 @@ object JChatGPT : KotlinPlugin( } } } catch (e: Exception) { + // 仅当 LLM 流式调用本身失败时才上报接入点故障并切换; + // 若流式已成功、异常来自后续工具执行,则保持当前接入点不变 + if (!streamingOk) { + LargeLanguageModels.reportFailure(endpoint) + if (endpointIndex < endpoints.lastIndex) { + endpointIndex++ + logger.warning("接入点[${endpoint.label}]调用失败,切换备用接入点[${endpoints[endpointIndex].label}]重试", e) + } else { + logger.warning("接入点[${endpoint.label}]调用失败,无更多备用接入点,重试中", e) + } + } else { + logger.warning("调用llm后处理时发生异常,重试中", e) + } if (retry <= 1) { throw e } else { done = false - logger.warning("调用llm时发生异常,重试中", e) // event.subject.sendMessage("出错了...正在重试...") } } @@ -1031,21 +1056,21 @@ object JChatGPT : KotlinPlugin( private fun chatCompletions( chatMessages: List, + endpoint: LargeLanguageModels.ChatEndpoint, hasTools: Boolean = true, onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null ): Flow { - val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始") val availableTools = if (hasTools) { myTools.filter { it.isEnabled }.map { it.tool } } else null val request = ChatCompletionRequest( - model = ModelId(PluginConfig.chatModel), - temperature = PluginConfig.chatTemperature, + model = ModelId(endpoint.model), + temperature = endpoint.temperature, messages = chatMessages, tools = availableTools, ) - logger.info("API Requesting... Model=${PluginConfig.chatModel}") - return llm.chatCompletions(request, onCacheUsage) + logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]") + return endpoint.service.chatCompletions(request, onCacheUsage) } private fun getNameCard(member: Member): String { diff --git a/src/main/kotlin/LargeLanguageModels.kt b/src/main/kotlin/LargeLanguageModels.kt index 34a7682..bae597a 100644 --- a/src/main/kotlin/LargeLanguageModels.kt +++ b/src/main/kotlin/LargeLanguageModels.kt @@ -14,9 +14,28 @@ object LargeLanguageModels { private set /** - * 聊天助手 + * 一个聊天接入点:封装了请求服务、模型名与温度。 + * 主接入点为列表第 0 项,其余为备用接入点,用于容灾切换。 */ - var chat: ModelService? = null + data class ChatEndpoint( + val service: ModelService, + val model: String, + val temperature: Double?, + /** 唯一标识,用于健康状态跟踪与日志 */ + val label: String, + ) + + /** + * 聊天接入点列表:index 0 为主接入点,其余按配置顺序为备用接入点。 + */ + var chatEndpoints: List = emptyList() + private set + + /** + * 主聊天接入点服务(向后兼容旧用法)。 + */ + val chat: ModelService? + get() = chatEndpoints.firstOrNull()?.service /** * 推理模型 @@ -28,6 +47,40 @@ object LargeLanguageModels { */ var visual: ModelService? = null + /** + * 接入点健康状态:记录各接入点的冷却截止时间戳(毫秒)。 + * 失败的接入点进入冷却,期间在 [orderedChatEndpoints] 中被排到队尾, + * 避免每条消息都先卡在故障接入点上白白等一次超时。 + */ + private val cooldownUntil = HashMap() + + /** 上报某接入点调用失败,使其进入冷却。 */ + fun reportFailure(endpoint: ChatEndpoint) { + val minutes = PluginConfig.fallbackCooldownMinutes + // 只有存在备用接入点时冷却才有意义;否则没有可切换的目标,标记冷却反而无益 + if (minutes > 0 && chatEndpoints.size > 1) { + cooldownUntil[endpoint.label] = System.currentTimeMillis() + minutes * 60_000L + } + } + + /** 上报某接入点调用成功,清除其冷却。 */ + fun reportSuccess(endpoint: ChatEndpoint) { + cooldownUntil.remove(endpoint.label) + } + + /** + * 返回按健康度排序的接入点:未冷却的保持配置原顺序在前,冷却中的排到后面 + * (冷却中再按剩余冷却时间升序,优先重试快恢复的)。排序稳定,主接入点健康时始终最先。 + */ + fun orderedChatEndpoints(): List { + if (chatEndpoints.size <= 1) return chatEndpoints + val now = System.currentTimeMillis() + return chatEndpoints.sortedBy { ep -> + val until = cooldownUntil[ep.label] ?: 0L + if (until > now) until else 0L + } + } + private val json = Json { isLenient = true ignoreUnknownKeys = true @@ -46,16 +99,50 @@ object LargeLanguageModels { val timeout = PluginConfig.timeout.milliseconds val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds - // 初始化聊天模型 + // 初始化聊天接入点(主 + 备用),并重置健康状态 + cooldownUntil.clear() + val endpoints = mutableListOf() if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) { - chat = ModelService( - baseUrl = PluginConfig.openAiApi, - token = PluginConfig.openAiToken, - timeout = timeout, - firstChunkTimeout = firstChunkTimeout, - extraBody = parseExtraBody(PluginConfig.chatModelExtraBody) + endpoints.add( + ChatEndpoint( + service = ModelService( + baseUrl = PluginConfig.openAiApi, + token = PluginConfig.openAiToken, + timeout = timeout, + firstChunkTimeout = firstChunkTimeout, + extraBody = parseExtraBody(PluginConfig.chatModelExtraBody) + ), + model = PluginConfig.chatModel, + temperature = PluginConfig.chatTemperature, + label = "primary", + ) ) + + // 备用接入点:留空字段继承主接入点配置 + PluginConfig.chatFallbacks.forEachIndexed { i, fb -> + val api = fb.api.ifBlank { PluginConfig.openAiApi } + val token = fb.token.ifBlank { PluginConfig.openAiToken } + val model = fb.model.ifBlank { PluginConfig.chatModel } + val extraBody = fb.extraBody.ifBlank { PluginConfig.chatModelExtraBody } + if (api.isNotBlank() && token.isNotBlank()) { + endpoints.add( + ChatEndpoint( + service = ModelService( + baseUrl = api, + token = token, + timeout = timeout, + firstChunkTimeout = firstChunkTimeout, + extraBody = parseExtraBody(extraBody) + ), + model = model, + temperature = PluginConfig.chatTemperature, + label = "fallback$i:$model", + ) + ) + } + } } + chatEndpoints = endpoints // 初始化推理模型 if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) { diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/PluginConfig.kt index 6a8a2e1..9841be1 100644 --- a/src/main/kotlin/PluginConfig.kt +++ b/src/main/kotlin/PluginConfig.kt @@ -1,9 +1,22 @@ package top.jie65535.mirai +import kotlinx.serialization.Serializable import net.mamoe.mirai.console.data.AutoSavePluginConfig import net.mamoe.mirai.console.data.ValueDescription import net.mamoe.mirai.console.data.value +/** + * 聊天模型备用接入点。用于主接入点(openAiApi/openAiToken/chatModel)连续失败时容灾切换。 + * 任一字段留空则继承主接入点对应配置,因此可只换 API KEY、只换模型、或整体换一个服务商。 + */ +@Serializable +data class ChatFallbackEndpoint( + val api: String = "", + val token: String = "", + val model: String = "", + val extraBody: String = "", +) + object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("主人QQ,AI可以通过工具向主人发起请求,会等待一段时间") val ownerId: Long by value() @@ -41,6 +54,12 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}") val chatModelExtraBody: String by value("") + @ValueDescription("聊天模型备用接入点列表(容灾)。主接入点连续失败时按顺序切换;每项留空的字段会继承主接入点,例如只换API KEY就只填token,只换模型就只填model") + val chatFallbacks: List by value() + + @ValueDescription("备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。设为0禁用,默认5分钟") + val fallbackCooldownMinutes: Long by value(5L) + @ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}") val reasoningModelExtraBody: String by value("")