Skip to content

Commit 40d780b

Browse files
committed
feat(agent): 优化 Agent 工具系统和会话管理
- 新增 ToolManagementTool 用于动态管理工具状态 - 支持工具注册和注销后缓存更新,避免重复构建工具列表 - 引入 CoroutineScope 管理后台任务生命周期,支持自动取消 - 优化 ConversationStore,添加 LRU 淘汰策略和单会话消息上限控制 - 调整 MessageBuffer 支持全局最大消息条数和 raw 字段截断 - 调整 OneBot 缓存策略,限制 messageSourceCache 大小,避免内存膨胀 - 改进 QdrantMemoryStore 的数据加载方式,提升性能并支持批量更新 - 完善 AgentConfig 支持工具状态与会话存储限制配置 - 引入 KSP 插件支持,重构示例工具声明方式,简化工具开发流程 - 优化文件索引服务,改用更高效的删除过滤接口 - 调整 ContextCompactor 中的消息列表复制方式,避免不必要的深拷贝关闭资源时安全取消相关协程和存储组件
1 parent c332caa commit 40d780b

136 files changed

Lines changed: 869 additions & 193 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
.gradle
2-
build/
2+
build
33
!gradle/wrapper/gradle-wrapper.jar
44
!**/src/main/**/build/
55
!**/src/test/**/build/
66
/other
77
/config
8+
src/main/vicky-ksp/build/
89

910
### IntelliJ IDEA ###
1011
.idea/modules.xml
@@ -47,4 +48,5 @@ bin/
4748
.mimocode/
4849

4950
### Mac OS ###
50-
.DS_Store
51+
.DS_Store
52+
/src/main/vicky-ksp/build/kotlin/compileKotlin/cacheable/caches-jvm/jvm/kotlin/class-attributes.tab

.idea/gradle.xml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build.gradle.kts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ plugins {
22
kotlin("jvm") version "2.3.20"
33
kotlin("plugin.serialization") version "2.3.20"
44
id("com.gradleup.shadow") version "9.0.0-beta12"
5+
id("com.google.devtools.ksp")
56
`maven-publish`
67
}
78

@@ -14,15 +15,20 @@ repositories {
1415

1516
dependencies {
1617
implementation("com.aallam.openai:openai-client:4.1.0")
18+
1719
implementation("io.ktor:ktor-client-okhttp:3.0.0")
1820
implementation("io.ktor:ktor-client-content-negotiation:3.0.0")
1921
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0")
22+
2023
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
2124
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
22-
implementation("ch.qos.logback:logback-classic:1.5.12")
25+
26+
implementation("ch.qos.logback:logback-classic:1.5.13")
2327

2428
implementation("top.mrxiaom.mirai:overflow-core:1.1.0")
2529

30+
ksp(project(":src:main:vicky-ksp"))
31+
2632
testImplementation(kotlin("test"))
2733
}
2834

settings.gradle.kts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
plugins {
2-
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
1+
pluginManagement {
2+
repositories {
3+
gradlePluginPortal()
4+
mavenCentral()
5+
google() // KSP 插件托管在 Google Maven,必须加
6+
}
7+
plugins {
8+
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
9+
id("com.google.devtools.ksp") version "2.3.9"
10+
}
311
}
4-
rootProject.name = "Vicky"
12+
13+
rootProject.name = "Vicky"
14+
15+
include("src:main:vicky-ksp")

src/main/kotlin/org/example/vicky/agent/Agent.kt

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import com.aallam.openai.api.chat.ToolType
1010
import com.aallam.openai.client.OpenAI
1111
import kotlinx.coroutines.CoroutineScope
1212
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.SupervisorJob
14+
import kotlinx.coroutines.cancel
1315
import kotlinx.coroutines.launch
1416
import kotlinx.serialization.json.Json
1517
import org.example.vicky.context.ContextBuilder
@@ -31,6 +33,8 @@ import org.example.vicky.tool.ToolContext
3133
import org.example.vicky.tool.ToolRegistry
3234
import org.example.vicky.tool.ToolResult
3335
import org.example.vicky.tool.builtin.BuiltinTools
36+
import org.example.vicky.tool.builtin.ToolManagementTool
37+
import org.example.vicky.config.ConfigManager
3438
import org.example.vicky.vector.QdrantVectorStore
3539
import com.aallam.openai.api.chat.Tool as OAITool
3640

@@ -42,10 +46,16 @@ abstract class Agent(
4246
protected val config: AgentConfig,
4347
private val openAi: OpenAI = OpenAiClientFactory.create(config),
4448
protected val contextBuilder: ContextBuilder = ContextBuilder(config.agentMd),
45-
protected val store: ConversationStore = ConversationStore(),
46-
) {
49+
protected val store: ConversationStore = ConversationStore(
50+
maxConversations = config.conversationStoreMaxConversations,
51+
maxMessages = config.conversationStoreMaxMessages,
52+
),
53+
) : AutoCloseable {
4754
val tools: ToolRegistry = ToolRegistry()
4855
private val compactor = ContextCompactor(config, openAi)
56+
private lateinit var toolManagementTool: ToolManagementTool
57+
private var cachedOaiTools: List<OAITool>? = null
58+
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
4959

5060
// 向量存储组件(Qdrant 不可用时自动禁用记忆功能)
5161
private val vectorStore: QdrantVectorStore? = run {
@@ -103,12 +113,18 @@ abstract class Agent(
103113
if (config.builtinTools) {
104114
val baseDir = java.io.File(System.getProperty("user.dir"))
105115
BuiltinTools.all(baseDir, memoryStore, fileIndexService, distillationScheduler).forEach { tools.register(it) }
116+
toolManagementTool = ToolManagementTool { persistToolStates() }
117+
tools.register(toolManagementTool)
118+
for ((toolName, enabled) in config.toolStates) {
119+
if (!enabled) tools.unregister(toolName)
120+
}
121+
cachedOaiTools = null
106122
}
107123

108124
// 启动时后台自动索引文件(不阻塞主线程)
109125
if (fileIndexService != null && config.fileIndexAutoIndexOnStart) {
110126
println("[Vicky] 开始后台索引文件...")
111-
CoroutineScope(Dispatchers.IO).launch {
127+
scope.launch(Dispatchers.IO) {
112128
try {
113129
val result = fileIndexService.indexAll { current, success, skipped ->
114130
print("\r[Vicky] 索引中: 已处理 $current 个文件,$success 个已索引,$skipped 个已跳过")
@@ -137,8 +153,24 @@ abstract class Agent(
137153
protected open val buffer: org.example.vicky.channel.onebot.MessageBuffer? = null
138154

139155
/** 运行时注册工具。 */
140-
fun registerTool(tool: Tool) = tools.register(tool)
141-
fun unregisterTool(name: String): Tool? = tools.unregister(name)
156+
fun registerTool(tool: Tool) {
157+
tools.register(tool)
158+
cachedOaiTools = null
159+
}
160+
161+
fun unregisterTool(name: String): Tool? {
162+
val result = tools.unregister(name)
163+
if (result != null) cachedOaiTools = null
164+
return result
165+
}
166+
167+
private fun persistToolStates() {
168+
val activeStates = tools.snapshot().filter { it.name != "manage_tools" }.map { it.name to true }
169+
val disabledStates = toolManagementTool.getDisabledToolNames().map { it to false }
170+
val allStates = (activeStates + disabledStates).toMap()
171+
val configData = ConfigManager.loadOrCreate().config
172+
ConfigManager.save(configData.copy(toolStates = allStates))
173+
}
142174

143175
/**
144176
* 入口:外部把收到的消息丢进来。
@@ -303,6 +335,8 @@ abstract class Agent(
303335
if (clearContextAfter) {
304336
store.clear(msg.conversationId)
305337
log("cleared context for '${msg.conversationId}' after reply")
338+
} else {
339+
store.trimIfNeeded(msg.conversationId)
306340
}
307341
}
308342
}
@@ -352,15 +386,26 @@ abstract class Agent(
352386
}
353387
}
354388

355-
private fun buildOpenAiTools(): List<OAITool> = tools.snapshot().map { t ->
356-
OAITool(
357-
type = ToolType.Function,
358-
function = FunctionTool(
359-
name = t.name,
360-
description = t.description,
361-
parameters = Parameters.fromJsonString(Json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), t.parameters)),
362-
),
363-
)
389+
private fun buildOpenAiTools(): List<OAITool> {
390+
cachedOaiTools?.let { return it }
391+
val result = tools.snapshot().map { t ->
392+
OAITool(
393+
type = ToolType.Function,
394+
function = FunctionTool(
395+
name = t.name,
396+
description = t.description,
397+
parameters = Parameters.fromJsonString(Json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), t.parameters)),
398+
),
399+
)
400+
}
401+
cachedOaiTools = result
402+
return result
403+
}
404+
405+
override fun close() {
406+
distillationScheduler?.stop()
407+
scope.cancel()
408+
vectorStore?.close()
364409
}
365410

366411
private companion object {

src/main/kotlin/org/example/vicky/agent/AgentConfig.kt

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,31 @@ import com.aallam.openai.api.model.ModelId
44

55
/**
66
* Agent 运行时配置。
7-
*
8-
* @property model OpenAI / 兼容服务的模型 id (例如 "gpt-4o-mini" 或 "deepseek-chat")。
9-
* @property apiKey API key (可空仅用于 mock 测试)。
10-
* @property baseUrl 兼容 OpenAI 协议的自定义 host,例如 "https://api.deepseek.com/v1/"。null = 官方。
11-
* @property maxSteps 单次 receive 最多走多少轮 LLM 推理,防死循环。
12-
* @property maxMemoryRounds 最多保留多少轮用户消息(1轮 = user + assistant),超过则截断旧消息。0 = 不限制。
13-
* @property maxContextLength 上下文总字符数上限,超过则触发 LLM 压缩生成摘要。0 = 不限制。
14-
* @property mode 模式 1 (SILENT) 或模式 2 (VERBOSE)。
15-
* @property temperature 透传给 chat completion。
16-
* @property agentMd 基础系统提示文本 (人设/指令),直接内联,不再读文件。
17-
* @property debug 打开后输出框架运行日志 (每轮推理 / 工具调用) 及底层 HTTP 日志。
18-
* @property think 打开后把 agent 每轮的中间思考文本 (调用工具前的 content) 打到日志。
19-
* @property embedding 语义模型配置;null = 未启用。内置 / 外置互斥,见 [EmbeddingConfig]。
7+
* @property model OpenAI / 兼容服务的模型 id,例如 `"gpt-4o-mini"`、`"deepseek-chat"`。
8+
* 直接作为 `ModelId` 透传给 chat completion 接口。
9+
* @property apiKey 调用 LLM 用的 API Key。仅在 mock 测试场景下可为空字符串。
10+
* @property baseUrl 兼容 OpenAI 协议的自定义服务地址,例如 `"https://api.deepseek.com/v1/"`。
11+
* 设为 `null` 表示走 OpenAI 官方端点。
12+
* @property maxSteps 单次 `receive` 内允许的最大 LLM 推理轮数,用于防止工具调用死循环。
13+
* 达到上限后框架会强制终止并返回当前结果。
14+
* @property maxMemoryRounds 短期上下文窗口中最多保留的对话轮数。
15+
* 此处 1 轮 = 1 条 user 消息 + 1 条 assistant 消息。
16+
* 超出后按时间序截断最早的整轮消息。
17+
* 设为 `0` 表示不限制条数(仍受 [maxContextLength] 约束)。
18+
* @property maxContextLength 上下文总字符数上限。超过该阈值时触发 LLM 摘要压缩,
19+
* 将旧消息合并为一段摘要后再继续对话。
20+
* 设为 `0` 表示不启用基于长度的摘要压缩。
21+
* @property mode 运行模式:[AgentMode.SILENT](模式 1,静默)或 [AgentMode.VERBOSE](模式 2,详尽输出)。
22+
* @property temperature 采样温度,透传给 chat completion。
23+
* 设为 `null` 表示使用服务端默认值;值越大输出越发散,越小越确定。
24+
* @property agentMd 基础系统提示文本(人设 / 指令),以字符串内联方式提供,不再从文件读取。
25+
* @property debug 是否开启框架运行日志(每轮推理、工具调用)及底层 HTTP 日志。
26+
* @property think 是否将 Agent 每轮的中间思考文本(调用工具前的 content)输出到日志。
27+
* 仅在 [debug] 或独立排查场景下有意义。
28+
* @property builtinTools 是否注册框架内置工具集。设为 `false` 时 Agent 仅依赖外部注入的工具。
29+
* @property embedding 语义向量模型配置,用于记忆与文件索引的向量化。
30+
* 设为 `null` 表示未启用语义能力,此时长期记忆与文件索引均不可用。
31+
* 内置与外置 Embedding 互斥,详见 [EmbeddingConfig]。
2032
*/
2133
data class AgentConfig(
2234
val model: ModelId,
@@ -31,12 +43,11 @@ data class AgentConfig(
3143
val debug: Boolean = false,
3244
val think: Boolean = false,
3345
val builtinTools: Boolean = true,
46+
val toolStates: Map<String, Boolean> = emptyMap(),
3447
val embedding: EmbeddingConfig? = null,
35-
// Qdrant 配置
3648
val qdrantHost: String? = null,
3749
val qdrantGrpcPort: Int = 6334,
3850
val qdrantHttpPort: Int = 6333,
39-
// 记忆配置
4051
val memoryEnabled: Boolean = false,
4152
val memoryTopK: Int = 5,
4253
val memoryTokenBudget: Int = 800,
@@ -46,18 +57,22 @@ data class AgentConfig(
4657
val memoryDistilledRetentionDays: Int = 7,
4758
val memoryCollection: String = "vicky_memories",
4859
val memoryRawCollection: String = "vicky_memories_raw",
49-
// 蒸馏配置
5060
val distillationEnabled: Boolean = true,
5161
val distillationSchedule: String = "0 2 * * *",
5262
val distillationMaxConversations: Int = 10,
5363
val distillationTemperature: Double = 0.1,
5464
val distillationMaxTokens: Int = 1000,
55-
// 文件索引配置
5665
val fileIndexEnabled: Boolean = false,
5766
val fileIndexCollection: String = "vicky_files",
5867
val fileIndexChunkSize: Int = 500,
5968
val fileIndexChunkOverlap: Int = 50,
6069
val fileIndexIgnorePatterns: List<String> = listOf(".git", ".gradle", "build", "node_modules"),
6170
val fileIndexPaths: List<String> = emptyList(),
6271
val fileIndexAutoIndexOnStart: Boolean = true,
63-
)
72+
// 会话存储限制
73+
val conversationStoreMaxConversations: Int = 500,
74+
val conversationStoreMaxMessages: Int = 200,
75+
// 消息缓冲区限制
76+
val messageBufferMaxGlobalEntries: Int = 10000,
77+
val messageBufferRawTruncate: Int = 500,
78+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package org.example.vicky.annotations
2+
3+
@Target(AnnotationTarget.CLASS)
4+
annotation class ToolGroup(val name: String)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.example.vicky.annotations
2+
3+
annotation class ToolParam(
4+
val description: String = "",
5+
val required: Boolean = true
6+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.example.vicky.annotations
2+
3+
annotation class VickyTool(
4+
val name: String,
5+
val description: String
6+
)

src/main/kotlin/org/example/vicky/channel/onebot/MessageBuffer.kt

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,35 +43,49 @@ data class RichMediaItem(
4343
*
4444
* - TTL 过期自动清理 (查询时惰性触发)。
4545
* - 单会话超过 [maxEntries] 条时丢弃最早的。
46+
* - 全局超过 [maxGlobalEntries] 条时按 FIFO 淘汰最旧会话。
4647
* - 支持 unread 游标:每次查询 unread 后自动推进。
4748
*
4849
* @property ttlMs 消息存活时间,默认 1 小时。
4950
* @property maxEntries 单会话最大消息条数,默认 500。
51+
* @property maxGlobalEntries 全局最大消息条数,默认 10000。
52+
* @property rawTruncate raw 字段截断长度,默认 500。
5053
*/
5154
class MessageBuffer(
5255
private val ttlMs: Long = 3_600_000L,
5356
private val maxEntries: Int = 500,
57+
private val maxGlobalEntries: Int = 10000,
58+
private val rawTruncate: Int = 500,
5459
) {
5560
private val buffers = ConcurrentHashMap<String, MutableList<BufferedMessage>>()
5661
private val cursors = ConcurrentHashMap<String, Long>()
5762

5863
/** 存入一条消息。 */
5964
fun store(conversationId: String, message: BufferedMessage) {
65+
val truncatedRaw = if (rawTruncate > 0 && message.raw.length > rawTruncate) {
66+
message.raw.take(rawTruncate)
67+
} else {
68+
message.raw
69+
}
70+
val truncated = if (truncatedRaw !== message.raw) message.copy(raw = truncatedRaw) else message
71+
6072
val list = buffers.getOrPut(conversationId) { mutableListOf() }
6173
synchronized(list) {
62-
list.add(message)
74+
// 存入时清理过期消息
75+
val cutoff = System.currentTimeMillis() - ttlMs
76+
list.removeAll { it.timestamp < cutoff }
77+
list.add(truncated)
6378
// 超过上限,丢弃最早的
6479
while (list.size > maxEntries) list.removeAt(0)
6580
}
81+
// 全局清理
82+
evictIfNeeded()
6683
}
6784

68-
/** 获取未过期的消息列表 (惰性清理 + 游标过滤)。 */
85+
/** 获取未过期的消息列表 (游标过滤)。 */
6986
private fun getValid(conversationId: String, since: Long = 0L): List<BufferedMessage> {
7087
val list = buffers[conversationId] ?: return emptyList()
71-
val cutoff = System.currentTimeMillis() - ttlMs
7288
synchronized(list) {
73-
// 惰性清理过期
74-
list.removeAll { it.timestamp < cutoff }
7589
return list.filter { it.timestamp > since }
7690
}
7791
}
@@ -108,4 +122,19 @@ class MessageBuffer(
108122
/** 指定会话当前缓冲的消息条数。 */
109123
fun size(conversationId: String): Int =
110124
buffers[conversationId]?.size ?: 0
125+
126+
private fun totalEntries(): Int = buffers.values.sumOf { it.size }
127+
128+
private fun evictIfNeeded() {
129+
if (totalEntries() <= maxGlobalEntries) return
130+
// 按最旧消息时间排序,淘汰最旧的会话
131+
val sorted = buffers.entries.sortedBy { entry ->
132+
entry.value.minOfOrNull { it.timestamp } ?: 0L
133+
}
134+
for (entry in sorted) {
135+
if (totalEntries() <= maxGlobalEntries) break
136+
buffers.remove(entry.key)
137+
cursors.remove(entry.key)
138+
}
139+
}
111140
}

0 commit comments

Comments
 (0)