Ai logo

JetBrains AI

Supercharge your tools with AI-powered features inside many JetBrains products

Tutorials

使用 Kotlin 构建 AI 智能体 – 第 1 部分:极简编码智能体 

Read this post in other languages:

构建智能体很奇妙。你不是在编写执行任务的代码, 而是在编写让 LLM 有能力执行任务的代码,LLM 决定该做什么。

这种转变需要一些时间来适应。你赋予智能体读取文件的能力,智能体决定要读取的文件和读取时间。你可能预计智能体会最先读取主文件, 但智能体却先读取三个测试文件,以了解相关模式。你没有告诉智能体这样做, 但它就是这样做了。

那么,你应该赋予智能体哪些能力呢? 赋予智能体的能力过多,它将无法做出正确选择。赋予智能体的能力过少,它将无法完成任务。找到平衡点意味着不断尝试,观察哪里出了问题,然后再进行调整。

在本系列博客中,我们将共同构建一个真实的编码智能体,以探索 AI 智能体。我们将从三个基础工具入手,并通过整个系列博客逐步将智能体转化成一个功能完备的智能体。你将学习智能体的实际运作方式、智能体行为的观察和调试方法,以及至关重要的架构决策。最后,你不仅将理解此智能体,还能掌握自行构建智能体的方法。这一切都是通过 Kotlin 实现的。

首先,我们将构建一个可以浏览代码库并进行针对性更改的智能体。我们将使用 JetBrains 推出的开源框架 Koog,它将负责处理执行循环:发送提示、解析工具调用并执行,并一直重复此过程,直至任务完成。执行循环可能会很复杂,但我们将从基本的循环入手,将关注点放在更有趣的问题上:你的智能体究竟应该具备哪些能力?

逐步构建智能体

在动手编写任何代码之前,我们应该先明确自己的需求。智能体… 到底可以做什么?

修正 bug? 编写功能? 重构原有代码?

我们希望它能够完成所有这些任务。但这到底意味着什么? 这意味着我们需要一个能够浏览代码库并进行针对性更改的智能体。

它需要能够查看项目中有哪些文件、读取这些文件、编辑文件或创建新文件 – 我们将通过三个工具赋予智能体这三种能力。

以下三种工具已构建并捆绑到 Koog 中。我们将了解每个工具的运作方式,然后将它们全部连接到 GPT-5-Codex。这样,我们便能构建出一个能够浏览代码库、读取文件并进行更改的功能完备的智能体。

为智能体赋予查看的能力

我们先添加这个工具,让智能体具备查看文件的能力:

tool(ListDirectoryTool(JVMFileSystemProvider.ReadOnly))

现在,智能体可以查看存在哪些文件。但问题是:如果你只是将一堆文件路径丢给智能体,其实帮助不大。智能体需要理解结构。

为了让智能体更轻松地理解结构,工具会智能地对输出进行格式化。JVM 项目采用深层软件包结构, 典型路径可能是 src/main/kotlin/com/example/service/impl/UserServiceImpl.kt。如果工具显示每个中间目录,输出就会充满大量不含实用信息的单一子目录。

因此,找到一长串的单一目录时,工具会将它们收起:

/project/src/main/kotlin/Main.kt (<0.1 KiB, 20 lines)

如果同一层级中有多个文件,工具会以树形结构显示这些文件:

/project/src/main/kotlin/
  Main.kt (<0.1 KiB, 20 lines)
  Utils.kt (<0.1 KiB, 10 lines)

请注意,还会显示文件大小和行数。为什么要显示这些信息呢?

如果不显示,你将遇到我们曾面临的问题。  我们最初构建这个工具时,并没有包含文件大小。智能体会浏览目录并查看文件名,但对文件大小一无所知。因此,需要理解某个文件时,它就会直接读取整个文件。

后来,我们遇到了一个真实案例:智能体试图读取一个长度超过 7,700 行的 Python 测试文件。单单那一个文件就填满了 LLM 的上下文窗口。当智能体试图继续工作时,它已经忘记之前读取过的其他文件、自己本应执行的任务以及需要做出的更改。单单那一个庞大的文件就将其他所有信息挤出了上下文窗口。

这就是目录列表中要包含行数和文件大小的原因。现在,当智能体浏览代码库时,它可以在尝试读取 test_dataset.py 之前看到该文件包含 7,700 行。智能体可以决定只读取特定部分,而不读取整个文件,或者在文件与任务无关的情况下完全跳过该文件。

读取代码

接下来,智能体需要读取文件:

tool(ReadFileTool(JVMFileSystemProvider.ReadOnly))

看到 JVMFileSystemProvider.ReadOnly 了吗?这是因为此工具不会直接调用 java.nio.file API。它是基于 Koog 的 FileSystemProvider 接口构建的。

为什么? 因此,你只需编写一次工具,便可在任何位置使用它。ReadFileTool 只需调用提供程序的方法, 无需关心该提供程序是从本地磁盘、通过 SSH 传输的文件还是云存储空间读取文件。如果将该工具传递给其他提供程序,它会按相同的方式运行。如果你要构建自己的文件工具,可以为存储后端实现 FileSystemProvider,并将其传递给 Koog 的文件工具,这些工具就会正常运行。

我们在这里使用的是 JVMFileSystemProvider.ReadOnly。为什么它是 ReadOnly 权限? 通过在提供程序层面分离读写权限,你可以编写具有不同权限级别的智能体。需要一个只浏览和分析代码的智能体? 为它提供使用 ReadOnly 提供程序的工具,你就可以确定它不会修改任何内容。对于此智能体,我们后续将添加修改功能。EditFileTool 将改用 JVMFileSystemProvider.ReadWrite。明确分离权限意味着你可以精确控制每个智能体可以执行的操作和不可执行的操作。

工具还支持行范围:

readFile("Main.kt", startLine = 45, endLine = 72)

记得 ListDirectoryTool 中的那些行数吗? 这就是设置它的原因。当智能体在目录列表中看到包含数千行数据的文件时,它就知道需要采取更具策略性的方式来读取文件。它可能会分块读取文件,以免多次重复读取,或者提取特定部分。这种做法能帮助避免因读取像 7,700 行 Python 测试文件示例那样的大文件而导致上下文耗尽。

修改代码

现在,我们将赋予智能体实际更改内容的能力:

tool(EditFileTool(JVMFileSystemProvider.ReadWrite))

EditFileTool 执行查找并替换操作。向其提供文件路径、要查找的文本和替换内容。就是这么简单。

但有趣的是:这三个形参可以执行不同的任务,具体取决于你传递的内容。

想要替换文本? 指定原始文本和替换文本:

edit_file(
    path = "Main.kt", 
    original = "Hello World!", 
    replacement = "Hello Koog!"
)

想要删除一些内容? 只需将替换部分设为空白即可:

edit_file(
    path = "Main.kt", 
    original = "Hello World!", 
    replacement = ""
)

想要创建全新的文件? 将原始文本留空即可:

edit_file(
    path = "Main.kt", 
    original = "", 
    replacement = "fun main() { println(\"Hello World!\") }"
)

同一个工具。三种不同的操作。

从失败中学习

工具正常运行,但我们在用真实智能体进行测试时发现了一些问题。经过多次编辑后,文件已发生变化。智能体试图再进行一处编辑,但它要查找的文本已不存在。之前的编辑已更改了文本。

工具将返回“Edit failed”(编辑失败)。我们原以为智能体会重新读取文件并再试一次。猜猜实际发生了什么?

智能体看到“Edit failed”(编辑失败),并认为“这个文件有问题。可能是我没有权限? 那我就创建一个包含所有更改的新文件!” 突然之间,到处都是新文件。本应被编辑的代码最后却出现在新文件中。 

这个问题修正起来很简单。我们只需要告诉智能体实际上出了什么问题。我们将错误消息更改为“在文件内容中未找到要替换的原始文本。考虑重新读取文件,以检查自上次读取以来原始文本是否更改”。这种方法奏效了。智能体们学到:“哦,没有找到文本,那我应该重新读取文件并再试一次。”

将各部分整合起来

我们有三个工具。现在,我们需要将这些工具连接到 GPT-5-Codex。

如果你从头开始构建,而不使用框架,则需要编写一个执行循环:向 GPT-5-Codex 发送提示,解析响应,检查它是否要调用工具,执行该工具,发回结果,重复执行此过程。你需要处理 API 请求、错误案例、token 限制以及工具调用协议。这一过程牵涉到大量的设置工作和精力。

在这里,Koog 的 AIAgent 会处理该循环。你为它提供 LLM、工具和指令:

val executor = simpleOpenAIExecutor(System.getenv("OPENAI_API_KEY"))
val agent = AIAgent(
    promptExecutor = executor,
    llmModel = OpenAIModels.Chat.GPT5Codex,

这样便可将智能体连接到 GPT-5-Codex。promptExecutor 是 Koog 处理 LLM 通信的方式。你为它提供 API 密钥和模型即可,它将搞定剩余的工作。

现在,为它提供你的工具的相关信息:

    toolRegistry = ToolRegistry {
        tool(ListDirectoryTool(JVMFileSystemProvider.ReadOnly))
        tool(ReadFileTool(JVMFileSystemProvider.ReadOnly))
        tool(EditFileTool(JVMFileSystemProvider.ReadWrite))
    },

ToolRegistry 是你列出工具的位置。Koog 会提取每个工具,将其转换为 GPT-5-Codex 要求的 JSON 格式,并在 GPT-5-Codex 请求时处理调用流程。

为它提供指令:

    systemPrompt = """
        You are a highly skilled programmer tasked with updating the provided codebase according to the given task.
        Your goal is to deliver production-ready code changes that integrate seamlessly with the existing codebase 
        and solve given task.
    """.trimIndent(),

这是系统提示,用于告知智能体其具体任务。注意到“updating the provided codebase”了吗? 正是它让智能体实际修改文件,而非仅仅说明应当更改的内容。

告诉它如何运行:

    strategy = singleRunStrategy(),
    maxIterations = 100

采取的策略是智能体循环。singleRunStrategy() 是 Koog 的内置基础版本。智能体会持续调用工具,直至其确定任务已完成。在后续文章中,我们将探讨如何编写复杂的自定义策略,但此策略适用于大多数任务。

但如果调用无限循环,该怎么办? 我们曾经遇到过一种情况,智能体读取 7,000 行的文件,触发了 token 限制,丢失了上下文,然后又重新读取同一个文件,如此反复。幸好设置了 maxIterations = 200,最后在耗尽 API 预算前停止了循环。执行 200 步后,智能体退出。每次工具调用消耗 2 步(一步用于调用,一步用于响应),因此最多进行 100 次调用。

有时智能体的运行时间会比较长,让你不禁想知道它是在正常工作还是卡住了。我们想要为智能体增加一些可见性,以了解其运行情况。在 Koog 中,可以通过 handleEvents 块观察智能体运行情况:

{
    handleEvents {
        onToolCallStarting { ctx -> println("Tool '${ctx.tool.name}' called with args:" +
                " ${ctx.toolArgs.toString().take(100)}")
        }
    }
}

Koog 提供了不同的可挂接事件,例如 onToolCallStartingonToolCallFinishedonAgentFinished其他事件。我们在这里使用的是 onToolCallStarting,它会在每个工具执行前运行,这样,我们便能记录即将发生的事件。 

但是,你将需要调试记录的内容。我们第一次调试时打印了全部内容。EditFileTool 滚动了几百行。随后,我们尝试只打印工具名称。EditFileTool 运行了三次,但我们不知道它在编辑哪些内容。现在,我们会打印 100 个字符。你可以跟踪事件进展,且不必查看过多的文本内容。

运行我们的智能体

智能体已构建。现在,我们需要通过一种方法实际应用智能体:

suspend fun main(args: Array) {
    if (args.size < 2) {
        println("Error: Please provide the project absolute path and a task as arguments")
        println("Usage:  ")
        return
    }

    val (path, task) = args
    val input = "Project absolute path: $pathnn## Taskn$task"
    try {
        val result = agent.run(input)
        println(result)
    } finally {
        executor.close()
    }
}

智能体需要知道两件事:项目所在位置和项目任务。借助此代码块,我们可以从命令行实参获取这两项信息。如果这两项信息缺失,我们会显示错误。否则,我们会将这些信息合并到一个输入字符串中,并调用 agent.run()

此后,智能体会接管任务。智能体会浏览项目、读取文件并进行更改,它会循环执行此过程,直至任务完成。在智能体完成任务后,我们打印结果。

以下是实际运作示例:

智能体是否可以完成任务?

让智能体运行后,我们想看看这三个工具究竟能完成什么任务。因此,我们基于 SWE-bench Verified 对智能体进行了测试,SWE-bench Verified 是一个包含 GitHub 问题中 500 个编码任务的基准。 

它完成了其中约 50% 的任务,对于 50 行的代码来说,这个结果不错。

但智能体从不进行任何验证。不能编译。不能运行测试。它会读取代码并根据看到的内容进行更改,但无法通过任何方式确认这些更改是否实际生效。

我们已使用三个工具,却没有展示如何构建工具。你知道这些工具的用途,却不知道它们的运作方式,也不知道如何创建自己的工具。

我的同事 Bruno 将在本系列的下一篇文章中向大家展示相关内容。他将从头开始构建 shell 执行工具,以便智能体可以运行命令、查看输出,并从失败中学习。他还会深入介绍 Koog 工具的运作方式,因为只有当你可以针对特定问题使用工具对智能体进行扩展时,这 50 行代码才能真正发挥作用。

代码位于 GitHub 上。欢迎尝试并与我们分享你的体验。

本博文英文原作者:

Fatimazahra El Akkary

Fatimazahra El Akkary

Machine Learning Engineer

image description

Discover more