From 72892336bcb1ef9720ec4c069c782c1b24808f04 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Fri, 22 May 2026 14:03:42 +0800 Subject: [PATCH] Add first-chunk timeout --- src/main/kotlin/LargeLanguageModels.kt | 4 +++ src/main/kotlin/ModelService.kt | 48 ++++++++++++++++++-------- src/main/kotlin/PluginConfig.kt | 5 ++- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/LargeLanguageModels.kt b/src/main/kotlin/LargeLanguageModels.kt index 55e6d31..34a7682 100644 --- a/src/main/kotlin/LargeLanguageModels.kt +++ b/src/main/kotlin/LargeLanguageModels.kt @@ -44,6 +44,7 @@ object LargeLanguageModels { fun reload() { val timeout = PluginConfig.timeout.milliseconds + val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds // 初始化聊天模型 if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) { @@ -51,6 +52,7 @@ object LargeLanguageModels { baseUrl = PluginConfig.openAiApi, token = PluginConfig.openAiToken, timeout = timeout, + firstChunkTimeout = firstChunkTimeout, extraBody = parseExtraBody(PluginConfig.chatModelExtraBody) ) } @@ -61,6 +63,7 @@ object LargeLanguageModels { baseUrl = PluginConfig.reasoningModelApi, token = PluginConfig.reasoningModelToken, timeout = timeout, + firstChunkTimeout = firstChunkTimeout, extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody) ) } @@ -71,6 +74,7 @@ object LargeLanguageModels { baseUrl = PluginConfig.visualModelApi, token = PluginConfig.visualModelToken, timeout = timeout, + firstChunkTimeout = firstChunkTimeout, extraBody = parseExtraBody(PluginConfig.visualModelExtraBody) ) } diff --git a/src/main/kotlin/ModelService.kt b/src/main/kotlin/ModelService.kt index 2508faf..e07aec1 100644 --- a/src/main/kotlin/ModelService.kt +++ b/src/main/kotlin/ModelService.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.* import kotlin.time.Duration @@ -21,15 +22,16 @@ class ModelService( val baseUrl: String, val token: String, val timeout: Duration, + val firstChunkTimeout: Duration, val extraBody: JsonObject? = null ) { val httpClient: HttpClient by lazy { HttpClient(OkHttp) { install(HttpTimeout) { - val millis = timeout.inWholeMilliseconds - requestTimeoutMillis = millis - connectTimeoutMillis = millis - socketTimeoutMillis = millis + // 总请求/socket 超时保持长值,允许慢速流式输出;连接握手则用短超时。 + requestTimeoutMillis = timeout.inWholeMilliseconds + socketTimeoutMillis = timeout.inWholeMilliseconds + connectTimeoutMillis = firstChunkTimeout.inWholeMilliseconds } defaultRequest { url(baseUrl) @@ -66,17 +68,35 @@ class ModelService( }.let { response -> val channel: ByteReadChannel = response.body() try { - while (currentCoroutineContext().isActive && !channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: continue - when { - line.startsWith("data: [DONE]") -> break - line.startsWith("data: ") -> { - val chunk = json.decodeFromString( - line.removePrefix("data: ") - ) - emit(chunk) + // 首块 data: 必须在 firstChunkTimeout 内到达,否则抛 TimeoutCancellationException + // 走 JChatGPT 的重试流程;之后的流式读取不再有应用层超时,由 socketTimeoutMillis 兜底。 + val firstDataLine: String? = withTimeout(firstChunkTimeout) { + var found: String? = null + while (currentCoroutineContext().isActive && !channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: continue + if (line.startsWith("data: ")) { + found = line + break + } + // 心跳/空行/注释行,不计为首块,继续等 + } + found + } + + if (firstDataLine != null) { + if (!firstDataLine.startsWith("data: [DONE]")) { + emit(json.decodeFromString(firstDataLine.removePrefix("data: "))) + + while (currentCoroutineContext().isActive && !channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: continue + when { + line.startsWith("data: [DONE]") -> break + line.startsWith("data: ") -> { + emit(json.decodeFromString(line.removePrefix("data: "))) + } + else -> continue + } } - else -> continue } } } finally { diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/PluginConfig.kt index b1408a4..26fab94 100644 --- a/src/main/kotlin/PluginConfig.kt +++ b/src/main/kotlin/PluginConfig.kt @@ -77,9 +77,12 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。") val temperaturePermission: Int by value(50) - @ValueDescription("等待响应超时时间,单位毫秒,默认60秒") + @ValueDescription("等待响应超时时间(整个请求的总超时与socket读超时),单位毫秒,默认60秒") val timeout: Long by value(60000L) + @ValueDescription("首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试") + val firstChunkTimeout: Long by value(10000L) + @Deprecated("使用外部文件而不是在配置文件内保存提示词") @ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改") var prompt: String by value("你是一个乐于助人的助手")