mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
profile: optimize conversation analysis concurrency
This commit is contained in:
@@ -20,6 +20,7 @@ import top.jie65535.mirai.data.PluginData
|
||||
import top.jie65535.mirai.data.SkillStore
|
||||
import top.jie65535.mirai.data.TokenUsageStore
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.normalizeMaxConcurrentRequests
|
||||
import top.jie65535.mirai.profile.GroupProfileAnalysisReport
|
||||
import top.jie65535.mirai.profile.ProfileAnalysisReport
|
||||
import top.jie65535.mirai.profile.ProfileAutoMaintenance
|
||||
@@ -90,18 +91,22 @@ object PluginCommands : CompositeCommand(
|
||||
require(batches > 0) { "batches 必须是正数" }
|
||||
val analyzeAllGroups = groupIds.isBlank()
|
||||
val parsedGroupIds = if (analyzeAllGroups) {
|
||||
UserProfileAnalysisService.listHistoryGroupIds()
|
||||
UserProfileAnalysisService.listPendingHistoryGroupIds()
|
||||
} else {
|
||||
parseProfileGroupIds(groupIds)
|
||||
}
|
||||
if (parsedGroupIds.isEmpty()) {
|
||||
sendMessage("聊天记录中没有可分析的群消息。")
|
||||
sendMessage("没有待推进的群画像:历史库中无有效群消息,或所有群均已追平当前快照。")
|
||||
return
|
||||
}
|
||||
val runToken = UserProfileAnalysisService.newRunToken()
|
||||
sendMessage(
|
||||
"已启动 ${parsedGroupIds.size} 个群的画像分析,每群最多推进 $batches 个批次。" +
|
||||
if (analyzeAllGroups) " 全量模式不设并发上限。" else ""
|
||||
if (analyzeAllGroups) {
|
||||
" 画像请求并发上限 ${normalizeMaxConcurrentRequests(PluginConfig.profileMaxConcurrentRequests)}。"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
val completedGroups = AtomicInteger()
|
||||
val successfulGroups = AtomicInteger()
|
||||
|
||||
@@ -75,6 +75,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("画像分析模型额外请求体JSON。留空时继承聊天模型额外请求体")
|
||||
val profileModelExtraBody: String by value("")
|
||||
|
||||
@ValueDescription("画像模型同时执行的请求上限,取值1~512,默认128;应用层排队且不计入首块超时,仅影响画像模型")
|
||||
val profileMaxConcurrentRequests: Int by value(128)
|
||||
|
||||
@ValueDescription("画像分析使用的聊天记录SQLite路径。留空时使用插件自己的chat-history.sqlite;本地实验可填写历史库绝对路径")
|
||||
val profileHistoryDatabasePath: String by value("")
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ object LargeLanguageModels {
|
||||
timeout = maxOf(timeout, profileFirstChunk),
|
||||
firstChunkTimeout = profileFirstChunk,
|
||||
extraBody = parseExtraBody(extraBody),
|
||||
maxConcurrentRequests = PluginConfig.profileMaxConcurrentRequests,
|
||||
),
|
||||
model = model,
|
||||
temperature = PluginConfig.profileModelTemperature ?: PluginConfig.chatTemperature,
|
||||
|
||||
@@ -12,10 +12,14 @@ import io.ktor.http.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.*
|
||||
import okhttp3.Dispatcher as OkHttpDispatcher
|
||||
import kotlin.time.Duration
|
||||
|
||||
class ModelService(
|
||||
@@ -23,10 +27,21 @@ class ModelService(
|
||||
val token: String,
|
||||
val timeout: Duration,
|
||||
val firstChunkTimeout: Duration,
|
||||
val extraBody: JsonObject? = null
|
||||
val extraBody: JsonObject? = null,
|
||||
maxConcurrentRequests: Int? = null,
|
||||
) {
|
||||
private val maxConcurrentRequests = maxConcurrentRequests?.let(::normalizeMaxConcurrentRequests)
|
||||
private val requestSemaphore = this.maxConcurrentRequests?.let(::Semaphore)
|
||||
|
||||
val httpClient: HttpClient by lazy {
|
||||
HttpClient(OkHttp) {
|
||||
this@ModelService.maxConcurrentRequests?.let { concurrencyLimit ->
|
||||
engine {
|
||||
config {
|
||||
dispatcher(createRequestDispatcher(concurrencyLimit))
|
||||
}
|
||||
}
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
// 流式响应的「首 token」与「token 间隔」超时统一由应用层 withTimeout 管控(见 chatCompletions)。
|
||||
// 这里特意不设 requestTimeoutMillis:否则正常但耗时较长的流式输出会被 Ktor 在中途整体掐断。
|
||||
@@ -79,7 +94,7 @@ class ModelService(
|
||||
}
|
||||
val body = JsonObject(requestJson).toString()
|
||||
|
||||
return flow {
|
||||
val responseFlow: Flow<ChatCompletionChunk> = flow {
|
||||
// 关键:服务器繁忙时会拖住「响应头」,使 httpClient.post() 自身阻塞在等待响应的阶段,
|
||||
// 因此必须把 post() 连同首个 data 块的读取一起包进 withTimeout。
|
||||
// 否则首 token 超时永远不会触发(post() 还没返回,根本进不到读取循环),
|
||||
@@ -137,5 +152,27 @@ class ModelService(
|
||||
channel?.cancel()
|
||||
}
|
||||
}
|
||||
return responseFlow.withConcurrencyLimit(requestSemaphore)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MAX_MODEL_CONCURRENT_REQUESTS = 512
|
||||
|
||||
internal fun normalizeMaxConcurrentRequests(value: Int): Int =
|
||||
value.coerceIn(1, MAX_MODEL_CONCURRENT_REQUESTS)
|
||||
|
||||
internal fun createRequestDispatcher(maxConcurrentRequests: Int): OkHttpDispatcher =
|
||||
OkHttpDispatcher().apply {
|
||||
val concurrencyLimit = normalizeMaxConcurrentRequests(maxConcurrentRequests)
|
||||
maxRequests = concurrencyLimit
|
||||
maxRequestsPerHost = concurrencyLimit
|
||||
}
|
||||
|
||||
internal fun <T> Flow<T>.withConcurrencyLimit(semaphore: Semaphore?): Flow<T> {
|
||||
semaphore ?: return this
|
||||
return flow {
|
||||
semaphore.withPermit {
|
||||
this@withConcurrencyLimit.collect { value -> emit(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ import java.sql.ResultSet
|
||||
|
||||
class ProfileHistoryReader(private val databaseFile: File) {
|
||||
data class TimeBounds(val startTime: Int, val endTime: Int)
|
||||
data class GroupTimeBounds(val botId: Long, val startTime: Int, val endTime: Int)
|
||||
data class GroupTimeBounds(
|
||||
val botId: Long,
|
||||
val groupId: Long,
|
||||
val startTime: Int,
|
||||
val endTime: Int,
|
||||
)
|
||||
|
||||
private data class Episode(
|
||||
val index: Int,
|
||||
@@ -63,6 +68,7 @@ class ProfileHistoryReader(private val databaseFile: File) {
|
||||
if (!results.next()) return@use null
|
||||
GroupTimeBounds(
|
||||
botId = results.getLong("bot_id"),
|
||||
groupId = groupId,
|
||||
startTime = results.getInt("min_time"),
|
||||
endTime = results.getInt("max_time").safeNextSecond(),
|
||||
)
|
||||
@@ -70,20 +76,32 @@ class ProfileHistoryReader(private val databaseFile: File) {
|
||||
}
|
||||
}
|
||||
|
||||
fun listGroupIds(): List<Long> = openReadConnection().use { connection ->
|
||||
fun listGroupTimeBounds(): List<GroupTimeBounds> = openReadConnection().use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT target_id, MAX(time) AS last_message_time
|
||||
SELECT bot_id, target_id, MIN(time) AS min_time, MAX(time) AS max_time
|
||||
FROM message_record
|
||||
WHERE kind = ? AND recalled = 0 AND target_id > 0
|
||||
GROUP BY target_id
|
||||
ORDER BY last_message_time DESC, target_id ASC
|
||||
GROUP BY bot_id, target_id
|
||||
ORDER BY max_time DESC, target_id ASC, bot_id ASC
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setInt(1, MessageSourceKind.GROUP.ordinal)
|
||||
statement.executeQuery().use { results ->
|
||||
val seenGroupIds = hashSetOf<Long>()
|
||||
buildList {
|
||||
while (results.next()) add(results.getLong("target_id"))
|
||||
while (results.next()) {
|
||||
val groupId = results.getLong("target_id")
|
||||
if (!seenGroupIds.add(groupId)) continue
|
||||
add(
|
||||
GroupTimeBounds(
|
||||
botId = results.getLong("bot_id"),
|
||||
groupId = groupId,
|
||||
startTime = results.getInt("min_time"),
|
||||
endTime = results.getInt("max_time").safeNextSecond(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object UserProfileAnalysisService {
|
||||
private const val MAX_CONVERSATION_CONFLICT_RETRIES = 3
|
||||
|
||||
private val runningUsers = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val runningGroups = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val runningCompactions = ConcurrentHashMap.newKeySet<Long>()
|
||||
@@ -32,11 +34,19 @@ object UserProfileAnalysisService {
|
||||
return report
|
||||
}
|
||||
|
||||
suspend fun listHistoryGroupIds(): List<Long> {
|
||||
suspend fun listPendingHistoryGroupIds(): List<Long> {
|
||||
check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" }
|
||||
check(UserProfileStore.isAvailable) { "用户画像数据库不可用" }
|
||||
return withContext(Dispatchers.IO) {
|
||||
ProfileHistoryReader(resolveHistoryFile()).listGroupIds()
|
||||
val historyBounds = ProfileHistoryReader(resolveHistoryFile()).listGroupTimeBounds()
|
||||
val cursors = UserProfileStore.loadGroupCursors()
|
||||
.associateBy { cursor -> cursor.botId to cursor.groupId }
|
||||
historyBounds.asSequence()
|
||||
.filter { bounds ->
|
||||
isGroupAnalysisPending(bounds, cursors[bounds.botId to bounds.groupId])
|
||||
}
|
||||
.map(ProfileHistoryReader.GroupTimeBounds::groupId)
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,10 +284,6 @@ object UserProfileAnalysisService {
|
||||
val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveHistoryFile()) }
|
||||
val bounds = withContext(Dispatchers.IO) { reader.findGroupTimeBounds(groupId) }
|
||||
?: return emptyGroupReport(groupId)
|
||||
val endpoint = checkNotNull(LargeLanguageModels.profile) {
|
||||
"画像分析模型未配置,请设置 profileModelApi/profileModelToken,或配置可继承的聊天模型接入点"
|
||||
}
|
||||
val model: ConversationProfileModel = ProfileModelClient(endpoint)
|
||||
var cursor = withContext(Dispatchers.IO) {
|
||||
UserProfileStore.loadGroupCursor(bounds.botId, groupId)
|
||||
} ?: GroupProfileCursor(
|
||||
@@ -297,6 +303,12 @@ object UserProfileAnalysisService {
|
||||
var skippedOperations = 0
|
||||
var totalUsage = ProfileTokenUsage()
|
||||
var caughtUp = cursor.cursorTime >= cursor.snapshotEndTime
|
||||
val model: ConversationProfileModel by lazy {
|
||||
val endpoint = checkNotNull(LargeLanguageModels.profile) {
|
||||
"画像分析模型未配置,请设置 profileModelApi/profileModelToken,或配置可继承的聊天模型接入点"
|
||||
}
|
||||
ProfileModelClient(endpoint)
|
||||
}
|
||||
|
||||
while (processedBatches < maxBatches && !caughtUp && runGate.canContinue(runToken)) {
|
||||
val batch = withContext(Dispatchers.IO) {
|
||||
@@ -421,20 +433,16 @@ object UserProfileAnalysisService {
|
||||
.filterValues { it >= minAuthoredTextChars.coerceAtLeast(1) }
|
||||
.keys
|
||||
if (eligibleUserIds.isEmpty()) return null
|
||||
return userLocks.withUserLocks(eligibleUserIds) locked@{
|
||||
if (withContext(Dispatchers.IO) { UserProfileStore.isConversationProcessed(batch.inputHash) }) {
|
||||
return@locked null
|
||||
}
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
eligibleUserIds.associateWith { userId ->
|
||||
UserProfileStore.load(userId) ?: UserProfileSnapshot(
|
||||
userId = userId,
|
||||
cursorTime = 0,
|
||||
snapshotEndTime = 0,
|
||||
)
|
||||
}
|
||||
var profiles = userLocks.withUserLocks(eligibleUserIds) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (UserProfileStore.isConversationProcessed(batch.inputHash)) null
|
||||
else loadConversationProfiles(eligibleUserIds)
|
||||
}
|
||||
} ?: return null
|
||||
var conflictRetries = 0
|
||||
var totalUsage = ProfileTokenUsage()
|
||||
|
||||
while (true) {
|
||||
val (result, reductions) = analyzeConversationWithRetry(
|
||||
model = model,
|
||||
profiles = profiles,
|
||||
@@ -444,25 +452,72 @@ object UserProfileAnalysisService {
|
||||
summaryMaxLength = summaryMaxLength,
|
||||
onRetryFailure = onRetryFailure,
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
UserProfileStore.commitConversation(
|
||||
reductions = reductions.map { reduction -> reduction to batch.forUser(reduction.profile.userId) },
|
||||
usage = result.usage,
|
||||
)
|
||||
totalUsage += result.usage
|
||||
val commitOutcome = userLocks.withUserLocks(eligibleUserIds) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (UserProfileStore.isConversationProcessed(batch.inputHash)) {
|
||||
ConversationCommitOutcome.AlreadyProcessed
|
||||
} else {
|
||||
val latestProfiles = loadConversationProfiles(eligibleUserIds)
|
||||
if (hasProfileVersionConflict(profiles, latestProfiles)) {
|
||||
ConversationCommitOutcome.Conflict(latestProfiles)
|
||||
} else {
|
||||
UserProfileStore.commitConversation(
|
||||
reductions = reductions.map { reduction ->
|
||||
reduction to batch.forUser(reduction.profile.userId)
|
||||
},
|
||||
usage = totalUsage,
|
||||
)
|
||||
ConversationCommitOutcome.Committed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onCommittedOperations(
|
||||
"source=CONVERSATION bot=${batch.botId} group=${batch.groupId} " +
|
||||
"batch=[${batch.startTime},${batch.endTime})",
|
||||
reductions,
|
||||
)
|
||||
ConversationProfileAnalysisReport(
|
||||
analyzedUsers = eligibleUserIds.size,
|
||||
processedMessages = batch.messages.size,
|
||||
appliedOperations = reductions.sumOf { it.operations.size },
|
||||
skippedOperations = reductions.sumOf { it.skippedOperations.size },
|
||||
usage = result.usage,
|
||||
|
||||
when (commitOutcome) {
|
||||
ConversationCommitOutcome.AlreadyProcessed -> return null
|
||||
ConversationCommitOutcome.Committed -> {
|
||||
onCommittedOperations(
|
||||
"source=CONVERSATION bot=${batch.botId} group=${batch.groupId} " +
|
||||
"batch=[${batch.startTime},${batch.endTime})",
|
||||
reductions,
|
||||
)
|
||||
return ConversationProfileAnalysisReport(
|
||||
analyzedUsers = eligibleUserIds.size,
|
||||
processedMessages = batch.messages.size,
|
||||
appliedOperations = reductions.sumOf { it.operations.size },
|
||||
skippedOperations = reductions.sumOf { it.skippedOperations.size },
|
||||
usage = totalUsage,
|
||||
)
|
||||
}
|
||||
|
||||
is ConversationCommitOutcome.Conflict -> {
|
||||
conflictRetries++
|
||||
if (conflictRetries > MAX_CONVERSATION_CONFLICT_RETRIES) {
|
||||
throw IllegalStateException(
|
||||
"群 ${batch.groupId} 会话画像提交连续冲突 $conflictRetries 次,未提交结果"
|
||||
)
|
||||
}
|
||||
profiles = commitOutcome.latestProfiles
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadConversationProfiles(userIds: Set<Long>): Map<Long, UserProfileSnapshot> =
|
||||
userIds.associateWith { userId ->
|
||||
UserProfileStore.load(userId) ?: UserProfileSnapshot(
|
||||
userId = userId,
|
||||
cursorTime = 0,
|
||||
snapshotEndTime = 0,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasProfileVersionConflict(
|
||||
expectedProfiles: Map<Long, UserProfileSnapshot>,
|
||||
latestProfiles: Map<Long, UserProfileSnapshot>,
|
||||
): Boolean = expectedProfiles.any { (userId, expected) ->
|
||||
latestProfiles[userId]?.version != expected.version
|
||||
}
|
||||
|
||||
private suspend fun analyzeConversationWithRetry(
|
||||
@@ -698,4 +753,17 @@ object UserProfileAnalysisService {
|
||||
if (skipped.size > 8) ";其余 ${skipped.size - 8} 项已省略" else ""
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class ConversationCommitOutcome {
|
||||
object Committed : ConversationCommitOutcome()
|
||||
object AlreadyProcessed : ConversationCommitOutcome()
|
||||
data class Conflict(
|
||||
val latestProfiles: Map<Long, UserProfileSnapshot>,
|
||||
) : ConversationCommitOutcome()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isGroupAnalysisPending(
|
||||
bounds: ProfileHistoryReader.GroupTimeBounds,
|
||||
cursor: GroupProfileCursor?,
|
||||
): Boolean = cursor == null || cursor.cursorTime < maxOf(cursor.snapshotEndTime, bounds.endTime)
|
||||
|
||||
@@ -192,13 +192,26 @@ object UserProfileStore {
|
||||
statement.setLong(2, groupId)
|
||||
statement.executeQuery().use { results ->
|
||||
if (!results.next()) return@use null
|
||||
GroupProfileCursor(
|
||||
botId = results.getLong("bot_id"),
|
||||
groupId = results.getLong("group_id"),
|
||||
cursorTime = results.getInt("cursor_time"),
|
||||
snapshotEndTime = results.getInt("snapshot_end_time"),
|
||||
updatedAt = results.getLong("updated_at"),
|
||||
)
|
||||
results.toGroupProfileCursor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGroupCursors(): List<GroupProfileCursor> {
|
||||
check(initialized) { "用户画像数据库尚未初始化" }
|
||||
return openReadConnection().use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT bot_id, group_id, cursor_time, snapshot_end_time, updated_at
|
||||
FROM profile_group_cursor
|
||||
ORDER BY bot_id, group_id
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.executeQuery().use { results ->
|
||||
buildList {
|
||||
while (results.next()) add(results.toGroupProfileCursor())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,6 +615,14 @@ object UserProfileStore {
|
||||
lastConfirmedAt = getInt("last_confirmed_at"),
|
||||
)
|
||||
|
||||
private fun ResultSet.toGroupProfileCursor() = GroupProfileCursor(
|
||||
botId = getLong("bot_id"),
|
||||
groupId = getLong("group_id"),
|
||||
cursorTime = getInt("cursor_time"),
|
||||
snapshotEndTime = getInt("snapshot_end_time"),
|
||||
updatedAt = getLong("updated_at"),
|
||||
)
|
||||
|
||||
private fun ProfileCategory.toStorageValue(): String = name.lowercase()
|
||||
private fun ProfileConfidence.toStorageValue(): String = name.lowercase()
|
||||
private fun Int.safeNextSecond(): Int = if (this == Int.MAX_VALUE) this else this + 1
|
||||
|
||||
Reference in New Issue
Block a user