mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
Add chat endpoint failover for resilience against unstable LLM APIs
Configure a list of fallback chat endpoints (chatFallbacks); each blank field inherits the primary, so you can swap just the API key, just the model, or the whole vendor. On LLM streaming failure the retry loop advances to the next endpoint, and a failed endpoint enters a cooldown (fallbackCooldownMinutes) so a dead primary is skipped instead of wasting a timeout on every message. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -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 调用:
|
||||
|
||||
@@ -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<ToolCall.Function>()
|
||||
@@ -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<ChatMessage>,
|
||||
endpoint: LargeLanguageModels.ChatEndpoint,
|
||||
hasTools: Boolean = true,
|
||||
onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null
|
||||
): Flow<ChatCompletionChunk> {
|
||||
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 {
|
||||
|
||||
@@ -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<ChatEndpoint> = 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<String, Long>()
|
||||
|
||||
/** 上报某接入点调用失败,使其进入冷却。 */
|
||||
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<ChatEndpoint> {
|
||||
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<ChatEndpoint>()
|
||||
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()) {
|
||||
|
||||
@@ -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<ChatFallbackEndpoint> by value()
|
||||
|
||||
@ValueDescription("备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。设为0禁用,默认5分钟")
|
||||
val fallbackCooldownMinutes: Long by value(5L)
|
||||
|
||||
@ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}")
|
||||
val reasoningModelExtraBody: String by value("")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user