JetBrains AI
Supercharge your tools with AI-powered features inside many JetBrains products
使用 Kotlin 构建 AI 智能体 – 第 2 部分:深入探讨工具
在上一篇文章中,我们了解了如何构建具有列表、读取、写入和编辑能力的基本编码智能体。今天,我们将深入探讨如何通过在 Koog 框架内创建附加工具来扩展智能体的能力。我们将以构建 ExecuteShellCommandTool 为例,教会智能体运行代码,并形成实际工程所依赖的反馈回路:运行代码、观察失败,并根据实际输出改进代码。
尽管 LLM 通常擅长避免语法错误,但在处理集成问题时仍存在困难。例如,它们有可能会调用不存在的方法、遗漏导入,或仅部分实现接口。编译和运行代码的传统方式会立即暴露这些问题。但有了少量的额外提示,我们可以促使 LLM 运行小规模测试来验证此类行为。
那么,我们如何构建这类工具呢? 我们先从基础知识讲起。
Koog 工具采用的具体结构是什么?
首先,我们要继承抽象类 ai.koog.agents.core.tools.Tool,它告诉我们需要提供:
- 名称:在我们团队中,我们喜欢遵循
snake_casing约定,即使用双下划线包围名称,但这只是个人偏好。 - 描述:此字段作为 LLM 的主要文档,解释了工具的作用和调用原因。
Args类:此类描述了调用工具时 LLM 需要提供或可以提供的形参。Result类:此类定义了将格式化为消息供 LLM 读取的数据。它可以包含textForLLM()函数,该函数用于将数据格式化为字符串,供 LLM 读取。引入Result类的主要目的是为开发者提供便利,以便更轻松地记录日志或在 UI 中呈现工具结果。智能体本身只需要格式化后的字符串。Execute()方法:此方法接收 Args 的实例,并返回Result的实例。它定义了 LLM 调用工具时执行的逻辑。
这些组件构成了每个 Koog 工具的基础,无论是构建数据库连接器、API 客户端,还是我们今天将看到的 shell 命令执行器。我们通过构建 ExecuteShellCommandTool 来具体了解这些原则的实际应用。
关于安全的简要说明
在深入探讨 ExecuteShellCommandTool 的细节之前,我们需要解决几个关键的安全考量因素。
虽然 LLM 并不是有意制造问题的恶意行为者,但它们确实偶尔会犯下意想不到的错误,进而可能导致后续问题。如果我们将为其赋予命令行执行的权力,这些错误可能引发严重后果。
不过,可以通过几种方法缓解这种风险,包括在隔离环境中对命令执行进行沙盒处理,和/或限制你授予它们的权限。但这些方法实现起来可能相当复杂,我们现在的关注点是创建实际工具。
鉴于此,我们将提供两种最简单的风险缓解策略:
- 对每条命令进行命令执行确认。这是最安全的选择,只要我们花一点时间检查 LLM 想要执行的每一条命令即可,但这种方式确实严重限制了智能体的自主性。
- Brave 模式,在该模式下,会自动批准每条命令。虽然此模式会在未正确进行沙盒处理的情况下对你在使用的机器构成风险,但我们只会在自己的基准测试中使用此模式,这些基准测试在可销毁的隔离环境中运行,不会产生任何后果。
实现 ExecuteShellCommandTool
对于我们的 ExecuteShellCommandTool,这些组件如下:
- 名称:
__execute_shell_command__。 - 描述:类似于“执行 shell 命令并返回其输出”。
Args类:command、timeoutSeconds、workingDirectoryResult类:output、exitCode、command(看似令人意外,但便于在日志或 UI 中报告运行的命令)。Execute()方法:请求确认,然后执行命令(我们将在后文中具体探讨实现细节)。
Execute() 方法实现
现在,我们来看一下如何实现 execute() 方法。我们通过将核心逻辑委托给帮助程序方法来确保方法的简洁性,具体如下:
override suspend fun execute(args: Args): Result = when (
val confirmation = confirmationHandler.requestConfirmation(args)
) {
is ShellCommandConfirmation.Approved -> try {
val result = executor.execute(
args.command, args.workingDirectory, args.timeoutSeconds
)
Result(args.command, result.exitCode, result.output)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result(
args.command, null, "Failed to execute command: ${e.message}"
)
}
is ShellCommandConfirmation.Denied ->
Result(
args.command, null,
"Command execution denied with user response: ${confirmation.userResponse}"
)
}
流程很简单:请求用户确认是否运行命令,如果获得批准,则通过命令执行器执行命令,否则向 LLM 返回拒绝消息。
我们也可以通过此方式捕获异常并将错误消息转发给 LLM,使其能够调整策略或尝试替代方法。
ConfirmationHandler 配置
创建 ExecuteShellCommandTool 时,ConfirmationHandler 会变为可配置状态,从而支持多种实现。目前,我们提供两种实现:
PrintShellCommandConfirmationHandler:通过命令行提示用户。BraveModeConfirmationHandler:自动批准一切请求。
第二种实现会无条件批准所有请求,而第一种实现则有一些有趣的细节:
override suspend fun requestConfirmation(
args: ExecuteShellCommandTool.Args
): ShellCommandConfirmation {
println("Agent wants to execute: ${args.command}")
args.workingDirectory?.let { println("In: $it") }
println("Timeout: ${args.timeoutSeconds}s")
print("Confirm (y / n / reason-for-denying): ")
val userResponse = readln().lowercase()
return when (userResponse) {
"y", "yes" -> ShellCommandConfirmation.Approved
else -> ShellCommandConfirmation.Denied(userResponse)
}
}
请注意,虽然用户看到三个选项(批准、拒绝、拒绝并提供原因),但实现时会对两种拒绝类型做同等处理:两者都会向 LLM 返回消息,由 LLM 分别对每种类型进行解析和处理。

CommandExecutor 配置
与 ConfirmationHandler 类似,CommandExecutor 也是可配置的,但我们目前只提供 JVM 实现。理论上来讲,你可以为 Android、iOS、WebAssembly 和其他平台创建实现,但由于没有明确的需求,我们将暂缓此类工作。
如何处理超时?
无论使用哪一平台,CommandExecutor 超时处理这一方面都需要特别关注。我们目前的实现不允许智能体中断长时间运行的命令。
人类常基于“不耐烦启发式”行事,这可能会导致他们使用 Ctrl+C 取消执行。但这种行为假定存在多线程、智能体意识和潜在的不耐烦概念。不过,有一种更直观且更贴合此上下文的替代方案。
通过要求 LLM 指定最长执行时间,我们可以安全地中断超过此限值的命令。此超时值会显示给用户,如果请求的时长看似不合理或过长,用户可以拒绝执行。
我们应当尽可能多地保留输出,而不是简单地终止进程并返回通用超时消息。即使是不完整的结果,也可以帮助 LLM 提取有用信息,或者至少可以帮助 LLM 理解超时发生的位置。通过谨慎实现,我们可以实现这一目标:
val stdoutJob = launch {
process.inputStream.bufferedReader().useLines { lines ->
try {
lines.forEach { stdoutBuilder.appendLine(it) }
} catch (_: IOException) {
// Ignore IO exception if the stream is closed and silently stop stream collection
}
}
}
val isCompleted = withTimeoutOrNull(timeoutSeconds * 1000L) {
process.onExit().await()
} != null
if (!isCompleted) {
process.destroyForcibly()
}
stdoutJob.join()
在智能体层面有哪些改动?
在智能体层面,改动微乎其微,大概只有十几行代码。如代码差异所示,这些更新主要涉及到系统提示的扩展。

我们本可以通过添加工具来进一步减少改动,但我们反而引入了两处对智能体的额外修改(这两处改动也非常小)。
A) BRAVE_MODE 切换开关
此切换开关的实现相对来说比较简单;仅会检查 BRAVE_MODE 环境变量。我们甚至为 Brave 模式 ConfirmationHandler 使用了 lambda 函数,以表明自行实现可以非常简单,但也可以使用前面提到的 BraveModeConfirmationHandler。
fun createExecuteShellCommandToolFromEnv(): ExecuteShellCommandTool {
return if (System.getenv("BRAVE_MODE")?.lowercase() == "true") {
ExecuteShellCommandTool(JvmShellCommandExecutor()) {
_ -> ShellCommandConfirmation.Approved
}
} else {
ExecuteShellCommandTool(
JvmShellCommandExecutor(),
PrintShellCommandConfirmationHandler(),
)
}
}
B) 增强了提示中的“完成定义”
为了确保 LLM 有效利用这种新能力执行代码,我们使用“完成定义”扩展了提示,此定义会强烈建议编写和运行测试:
""" ... // Previous prompt from step 01 Production-ready means verified to work—your changes must be proven correct and not introduce regressions. You have shell access to execute commands and run tests. Use this to work with executable feedback instead of assumptions. Establish what correct behavior looks like through tests, then iterate your implementation until tests pass. Validate that existing functionality remains intact. Production-ready means proven through green tests—that's your definition of done. """
大功告成。我们已构建完整的 ExecuteShellCommandTool 组件,并通过最少量的改动将其集成到我们的智能体中。但实际问题是:它真的会提高性能吗?
基准测试结果
对照 SWE-bench Verified 集合运行两个版本的测试确认了执行能力具有明显优势:智能体表现从 249/500 (50%) 提高到 279/500 (56%) 的成功样本。虽然排行榜分数达到 70% 左右,但我们的结果表明,赋予智能体运行和验证代码的能力是正确的发展方向。不过,为了理解智能体仍存在的问题以及后续改进措施,我们需要更深入地观察其行为。这就是日志记录变得至关重要的地方。
结论:在 Koog 中构建工具
通过这篇文章,我们了解了 Koog 工具结构的运作方式:每个工具都需要名称、描述和 execute() 方法。我们通过 ConfirmationHandler 和 CommandExecutor 组件使 ExecuteShellCommandTool 变为可配置状态,展示了如何将复杂逻辑委托给可替换实现。
这些相同的模式适用于你可能希望构建的任何工具:数据库连接器、API 客户端、文件处理器或自定义集成。框架为 LLM 通信提供结构;你提供具体能力。
在下一篇文章中,我们将探讨如何为我们的智能体添加日志记录和跟踪功能,让我们能够获取理解并改进其行为所需的信息。了解你的智能体如何做出决策对于迭代至关重要,而围绕可观测性构建适当工具与为智能体本身提供工具同样重要。
本博文英文原作者:



