profile: rebase concurrent conversation updates

This commit is contained in:
2026-08-03 17:58:58 +08:00
parent 94f303ec72
commit 74bcf0b8d6
5 changed files with 198 additions and 83 deletions
+3 -2
View File
@@ -237,8 +237,9 @@ searchHistoryMaxRecords: 5000
插件会从画像历史库枚举所有含有效群消息的群,并在启动任务前排除游标已覆盖最新历史快照的群,只并发推进仍有 插件会从画像历史库枚举所有含有效群消息的群,并在启动任务前排除游标已覆盖最新历史快照的群,只并发推进仍有
历史待处理的群。画像模型通过应用层信号量按 `profileMaxConcurrentRequests` 限制同时执行的请求数,默认 128; 历史待处理的群。画像模型通过应用层信号量按 `profileMaxConcurrentRequests` 限制同时执行的请求数,默认 128;
等待信号量的时间不计入首块响应超时,OkHttp 使用相同上限兜底。群批次只在读取画像快照和提交结果时短暂 等待信号量的时间不计入首块响应超时,OkHttp 使用相同上限兜底。群批次只在读取画像快照和提交结果时短暂
持有用户锁,模型请求在锁外执行;提交前若发现画像已变化,会加载最新版并重新分析,避免共享群友把网络请求 持有用户锁,模型请求在锁外执行;提交前若发现画像已变化,会按稳定条目 ID 将同一响应的操作重放到最新版,
串行化。显式传入群号时仍只分析指定群。全量模式仅发送启动和最终汇总,逐群进度与结果写入日志,避免回执刷屏。 已不存在的操作目标会被跳过,不会重新请求模型。显式传入群号时仍只分析指定群。全量模式仅发送启动和最终汇总,
逐群进度与结果写入日志,避免回执刷屏。
下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的 下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的
长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。 长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。
@@ -1,6 +1,8 @@
package top.jie65535.mirai.profile package top.jie65535.mirai.profile
object ConversationProfileReducer { object ConversationProfileReducer {
private const val MISSING_ITEM_REFERENCE = "P2147483647"
fun reduce( fun reduce(
profiles: Map<Long, UserProfileSnapshot>, profiles: Map<Long, UserProfileSnapshot>,
batch: ConversationProfileBatch, batch: ConversationProfileBatch,
@@ -33,4 +35,55 @@ object ConversationProfileReducer {
) )
} }
} }
fun reduceRebased(
expectedProfiles: Map<Long, UserProfileSnapshot>,
latestProfiles: Map<Long, UserProfileSnapshot>,
batch: ConversationProfileBatch,
eligibleUserIds: Set<Long>,
response: ConversationProfileModelResponse,
model: String,
promptVersion: String,
summaryMaxLength: Int,
): List<ProfileReduction> {
val rebasedResponse = response.copy(
users = response.users.map { userResponse ->
val userId = batch.aliasToUserId[userResponse.userAlias]
val expected = userId?.let(expectedProfiles::get)
val latest = userId?.let(latestProfiles::get)
if (userId == null || userId !in eligibleUserIds || expected == null || latest == null) {
userResponse
} else {
userResponse.copy(
operations = userResponse.operations.map { operation ->
operation.rebaseItemReference(expected, latest)
}
)
}
}
)
return reduce(
profiles = latestProfiles,
batch = batch,
eligibleUserIds = eligibleUserIds,
response = rebasedResponse,
model = model,
promptVersion = promptVersion,
summaryMaxLength = summaryMaxLength,
)
}
private fun ProfileModelOperation.rebaseItemReference(
expected: UserProfileSnapshot,
latest: UserProfileSnapshot,
): ProfileModelOperation {
if (action == ProfileOperationAction.ADD) return this
val expectedItem = ProfileItemReferences.resolve(expected, itemRef)
?: return copy(itemRef = MISSING_ITEM_REFERENCE)
val latestReference = ProfileItemReferences.entries(latest)
.firstOrNull { entry -> entry.item.id == expectedItem.id }
?.reference
?: MISSING_ITEM_REFERENCE
return copy(itemRef = latestReference)
}
} }
@@ -14,8 +14,6 @@ import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
object UserProfileAnalysisService { object UserProfileAnalysisService {
private const val MAX_CONVERSATION_CONFLICT_RETRIES = 3
private val runningUsers = ConcurrentHashMap.newKeySet<Long>() private val runningUsers = ConcurrentHashMap.newKeySet<Long>()
private val runningGroups = ConcurrentHashMap.newKeySet<Long>() private val runningGroups = ConcurrentHashMap.newKeySet<Long>()
private val runningCompactions = ConcurrentHashMap.newKeySet<Long>() private val runningCompactions = ConcurrentHashMap.newKeySet<Long>()
@@ -433,75 +431,70 @@ object UserProfileAnalysisService {
.filterValues { it >= minAuthoredTextChars.coerceAtLeast(1) } .filterValues { it >= minAuthoredTextChars.coerceAtLeast(1) }
.keys .keys
if (eligibleUserIds.isEmpty()) return null if (eligibleUserIds.isEmpty()) return null
var profiles = userLocks.withUserLocks(eligibleUserIds) { val profiles = userLocks.withUserLocks(eligibleUserIds) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (UserProfileStore.isConversationProcessed(batch.inputHash)) null if (UserProfileStore.isConversationProcessed(batch.inputHash)) null
else loadConversationProfiles(eligibleUserIds) else loadConversationProfiles(eligibleUserIds)
} }
} ?: return null } ?: return null
var conflictRetries = 0 val (result, reductions) = analyzeConversationWithRetry(
var totalUsage = ProfileTokenUsage() model = model,
profiles = profiles,
while (true) { batch = batch,
val (result, reductions) = analyzeConversationWithRetry( eligibleUserIds = eligibleUserIds,
model = model, retryMax = retryMax,
profiles = profiles, summaryMaxLength = summaryMaxLength,
batch = batch, onRetryFailure = onRetryFailure,
eligibleUserIds = eligibleUserIds, )
retryMax = retryMax, val totalUsage = result.usage
summaryMaxLength = summaryMaxLength, val commitOutcome = userLocks.withUserLocks(eligibleUserIds) {
onRetryFailure = onRetryFailure, withContext(Dispatchers.IO) {
) if (UserProfileStore.isConversationProcessed(batch.inputHash)) {
totalUsage += result.usage ConversationCommitOutcome.AlreadyProcessed
val commitOutcome = userLocks.withUserLocks(eligibleUserIds) { } else {
withContext(Dispatchers.IO) { val latestProfiles = loadConversationProfiles(eligibleUserIds)
if (UserProfileStore.isConversationProcessed(batch.inputHash)) { val committedReductions = if (hasProfileVersionConflict(profiles, latestProfiles)) {
ConversationCommitOutcome.AlreadyProcessed ConversationProfileReducer.reduceRebased(
expectedProfiles = profiles,
latestProfiles = latestProfiles,
batch = batch,
eligibleUserIds = eligibleUserIds,
response = result.response,
model = model.modelName,
promptVersion = ProfilePromptStore.PROMPT_VERSION,
summaryMaxLength = summaryMaxLength.coerceAtLeast(100),
)
} else { } else {
val latestProfiles = loadConversationProfiles(eligibleUserIds) reductions
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
}
} }
} UserProfileStore.commitConversation(
} reductions = committedReductions.map { reduction ->
reduction to batch.forUser(reduction.profile.userId)
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, usage = totalUsage,
) )
} ConversationCommitOutcome.Committed(committedReductions)
is ConversationCommitOutcome.Conflict -> {
conflictRetries++
if (conflictRetries > MAX_CONVERSATION_CONFLICT_RETRIES) {
throw IllegalStateException(
"${batch.groupId} 会话画像提交连续冲突 $conflictRetries 次,未提交结果"
)
}
profiles = commitOutcome.latestProfiles
} }
} }
} }
val committedReductions = when (commitOutcome) {
ConversationCommitOutcome.AlreadyProcessed -> return null
is ConversationCommitOutcome.Committed -> commitOutcome.reductions
}
onCommittedOperations(
"source=CONVERSATION bot=${batch.botId} group=${batch.groupId} " +
"batch=[${batch.startTime},${batch.endTime})",
committedReductions,
)
return ConversationProfileAnalysisReport(
analyzedUsers = eligibleUserIds.size,
processedMessages = batch.messages.size,
appliedOperations = committedReductions.sumOf { it.operations.size },
skippedOperations = committedReductions.sumOf { it.skippedOperations.size },
usage = totalUsage,
)
} }
private fun loadConversationProfiles(userIds: Set<Long>): Map<Long, UserProfileSnapshot> = private fun loadConversationProfiles(userIds: Set<Long>): Map<Long, UserProfileSnapshot> =
@@ -755,11 +748,10 @@ object UserProfileAnalysisService {
} }
private sealed class ConversationCommitOutcome { private sealed class ConversationCommitOutcome {
object Committed : ConversationCommitOutcome() data class Committed(
object AlreadyProcessed : ConversationCommitOutcome() val reductions: List<ProfileReduction>,
data class Conflict(
val latestProfiles: Map<Long, UserProfileSnapshot>,
) : ConversationCommitOutcome() ) : ConversationCommitOutcome()
object AlreadyProcessed : ConversationCommitOutcome()
} }
} }
@@ -144,6 +144,78 @@ class ConversationProfileReducerTest {
assertFalse(prompt.contains(oldItem.id)) assertFalse(prompt.contains(oldItem.id))
} }
@Test
fun rebasesItemReferenceByStableIdWhenConcurrentUpdateChangesItemOrder() {
val target = item("target", "原始目标条目", 100)
val expected = emptyProfile(USER_A).copy(version = 1, items = listOf(target))
val inserted = item("inserted", "并发新增条目", 50)
val latest = expected.copy(version = 2, items = listOf(inserted, target))
val reduction = ConversationProfileReducer.reduceRebased(
expectedProfiles = mapOf(USER_A to expected, USER_B to emptyProfile(USER_B)),
latestProfiles = mapOf(USER_A to latest, USER_B to emptyProfile(USER_B)),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.UPDATE,
itemRef = "P1",
content = "更新后的目标条目",
evidenceRefs = listOf(1),
)
),
)
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
).single { it.profile.userId == USER_A }
assertEquals("并发新增条目", reduction.profile.items.single { it.id == "inserted" }.content)
assertEquals("更新后的目标条目", reduction.profile.items.single { it.id == "target" }.content)
assertEquals(1, reduction.operations.size)
}
@Test
fun skipsRebasedOperationWhenConcurrentUpdateRemovedTargetItem() {
val target = item("target", "稍后被删除的条目", 100)
val expected = emptyProfile(USER_A).copy(version = 1, items = listOf(target))
val latest = expected.copy(version = 2, items = emptyList())
val reduction = ConversationProfileReducer.reduceRebased(
expectedProfiles = mapOf(USER_A to expected, USER_B to emptyProfile(USER_B)),
latestProfiles = mapOf(USER_A to latest, USER_B to emptyProfile(USER_B)),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.DELETE,
itemRef = "P1",
evidenceRefs = listOf(1),
)
),
)
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
).single { it.profile.userId == USER_A }
assertTrue(reduction.operations.isEmpty())
assertTrue(reduction.skippedOperations.single().contains("不存在"))
assertTrue(reduction.profile.items.isEmpty())
}
@Test @Test
fun acceptsMoreThanFourOperationsForOneUser() { fun acceptsMoreThanFourOperationsForOneUser() {
val operations = (1..5).map { index -> val operations = (1..5).map { index ->
@@ -297,6 +369,15 @@ class ConversationProfileReducerTest {
snapshotEndTime = 0, snapshotEndTime = 0,
) )
private fun item(id: String, content: String, firstSeenAt: Int) = UserProfileItem(
id = id,
category = ProfileCategory.NOTABLE_FACT,
content = content,
confidence = ProfileConfidence.MEDIUM,
firstSeenAt = firstSeenAt,
lastConfirmedAt = firstSeenAt,
)
private fun message(ref: Int, fromId: Long, text: String) = ProfilePromptMessage( private fun message(ref: Int, fromId: Long, text: String) = ProfilePromptMessage(
record = ChatMessageRecord( record = ChatMessageRecord(
botId = BOT, botId = BOT,
@@ -149,12 +149,11 @@ class UserProfileAnalysisServiceTest {
} }
@Test @Test
fun sharedUserRequestsRunConcurrentlyAndConflictReloadsLatestProfile() = withProfileStore { fun sharedUserRequestsRunConcurrentlyAndConflictRebasesWithoutAnotherModelCall() = withProfileStore {
val firstEntered = CompletableDeferred<Unit>() val firstEntered = CompletableDeferred<Unit>()
val releaseFirst = CompletableDeferred<Unit>() val releaseFirst = CompletableDeferred<Unit>()
val secondFirstEntered = CompletableDeferred<Unit>() val secondFirstEntered = CompletableDeferred<Unit>()
val releaseSecondFirst = CompletableDeferred<Unit>() val releaseSecondFirst = CompletableDeferred<Unit>()
val secondRetried = CompletableDeferred<Unit>()
val secondCalls = AtomicInteger() val secondCalls = AtomicInteger()
val firstModel = InspectingConversationProfileModel { profiles -> val firstModel = InspectingConversationProfileModel { profiles ->
assertEquals(0, profiles.getValue(USER_A).version) assertEquals(0, profiles.getValue(USER_A).version)
@@ -163,20 +162,10 @@ class UserProfileAnalysisServiceTest {
result(responseFor("U1", "第一群归纳的信息", evidenceRef = 1)) result(responseFor("U1", "第一群归纳的信息", evidenceRef = 1))
} }
val secondModel = InspectingConversationProfileModel { profiles -> val secondModel = InspectingConversationProfileModel { profiles ->
when (secondCalls.incrementAndGet()) { secondCalls.incrementAndGet()
1 -> { assertEquals(0, profiles.getValue(USER_A).version)
assertEquals(0, profiles.getValue(USER_A).version) secondFirstEntered.complete(Unit)
secondFirstEntered.complete(Unit) releaseSecondFirst.await()
releaseSecondFirst.await()
}
2 -> {
assertEquals(1, profiles.getValue(USER_A).version)
secondRetried.complete(Unit)
}
else -> error("共享用户冲突只应触发一次重算")
}
result(responseFor("U1", "第二群归纳的信息", evidenceRef = 1)) result(responseFor("U1", "第二群归纳的信息", evidenceRef = 1))
} }
@@ -188,11 +177,10 @@ class UserProfileAnalysisServiceTest {
releaseFirst.complete(Unit) releaseFirst.complete(Unit)
assertNotNull(first.await()) assertNotNull(first.await())
releaseSecondFirst.complete(Unit) releaseSecondFirst.complete(Unit)
withTimeout(1_000) { secondRetried.await() }
assertNotNull(second.await()) assertNotNull(second.await())
} }
assertEquals(2, secondCalls.get()) assertEquals(1, secondCalls.get())
val profile = assertNotNull(UserProfileStore.load(USER_A)) val profile = assertNotNull(UserProfileStore.load(USER_A))
assertEquals(2, profile.version) assertEquals(2, profile.version)
assertEquals(2, profile.items.size) assertEquals(2, profile.items.size)