JetBrains AI
Supercharge your tools with AI-powered features inside many JetBrains products
Kotlin으로 AI 에이전트 구축하기 – 1부: 최소 구성의 코딩 에이전트
에이전트를 만드는 일은 조금은 독특한 일입니다. 단순히 무언가를 수행하는 코드를 작성하는 것이 아닙니다. LLM에게 작업 수행 능력을 부여하는 코드를 작성하고, 무엇을 할지는 LLM이 결정합니다.
이러한 관점의 전환에는 적응이 필요합니다. 사용자는 에이전트에게 파일을 읽을 수 있는 능력을 부여하고, 어떤 파일을 언제 읽을지는 에이전트가 결정합니다. 에이전트가 메인 파일부터 시작할 것이라고 예상할 수 있겠지만 에이전트는 패턴을 이해하기 위해 먼저 세 개의 테스트 파일을 읽습니다. 사용자는 그렇게 하라고 지시하지 않아도 에이전트는 그렇게 합니다.
그렇다면 에이전트에게 어떤 능력을 부여해야 할까요? 능력이 너무 많으면 잘못된 선택을 합니다. 그렇다고 능력이 부족하면 작업을 수행할 수 없습니다. 그 사이 균형을 찾는다는 것은 시도해 보고, 무엇이 실패하는지 관찰하며, 조정해 나가는 것을 의미합니다.
이 블로그 시리즈에서는 세 가지 기본 도구로 시작해 실제 코딩 에이전트를 함께 만들어 보고, 시리즈를 진행하며 완성도 높은 에이전트로 발전시키면서 AI 에이전트를 살펴봅니다. 이 과정에서 에이전트의 실제 동작 방식, 그 동작을 관찰하고 디버그하는 방법, 중요한 아키텍처 결정에 대해 배우고, 마지막에는 이 에이전트뿐만 아니라 나만의 에이전트를 구축하는 방법도 배웁니다. 모두 Kotlin으로 진행됩니다.
먼저 간단히 코드베이스를 탐색하고 목표한 변경을 수행할 수 있는 에이전트를 만들어보겠습니다. 사용 도구는 JetBrains의 오픈 소스 프레임워크인 Koog입니다. Koog는 프롬프트 전송, 도구 호출 분석 및 실행을 담당하며, 완료될 때까지 이를 반복하는 실행 루프를 처리합니다. 실행 루프가 복잡해질 수는 있겠지만 기본적인 형태부터 시작해 에이전트가 실제로 어떤 역량을 가져야 하는지에 차츰 초점을 맞춰 보겠습니다.
단계적 에이전트 구축
무언가를 만들기 앞서, 실제로 무엇을 원하는지부터 생각해 볼까요. 무엇을 할 수 있는 에이전트면 좋을까요?
버그를 수정하면 좋을까요? 기능을 작성해 보게 할까요? 기존 코드를 리팩터링시킬까요?
우리의 목표는 이 모든 기능을 구현하는 겁니다. 이 말은 곧 코드베이스를 탐색하고 목표한 변경을 수행할 수 있는 에이전트가 필요하다는 뜻입니다.
이를 위해 에이전트는 프로젝트에 어떤 파일이 있는지 볼 수 있어야 하고, 그 파일들을 읽고, 수정하거나 새로운 파일을 생성할 수 있어야 합니다. 이 세 가지 역량을 세 가지 도구를 통해 부여해 보겠습니다.
이 세 가지 도구는 이미 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!") }"
)
같은 도구이지만 세 가지 서로 다른 작업을 수행합니다.
실패로부터 배우기
도구는 정상적으로 작동하지만 실제 에이전트로 테스트하니 문제가 있었습니다. 여러 차례 편집을 거치면서 파일이 달라진 겁니다. 에이전트가 추가 편집을 시도했지만, 찾고 있던 텍스트는 더 이상 없습니다. 이전 편집으로 변경되었기 때문입니다.
이 도구는 ‘수정 실패’를 반환하고 있었습니다. 저희는 에이전트가 파일을 다시 읽고 다시 시도할 것이라고 판단했습니다. 하지만 실제로는 어떤 일이 벌어졌을까요?
에이전트는 ‘편집 실패’를 보고, ‘이 파일에 뭔가 문제가 있다. 아니면 나에게 권한이 없을 수도? 그냥 변경 사항을 담은 새 파일을 만들어야겠다!’라고 생각했습니다. 그 결과, 갑자기 여기저기에 새 파일이 생겨났습니다. 원래 편집되었어야 할 코드는 새 파일에 들어가 버렸습니다.
해결 방법은 간단했습니다. 에이전트에게 실제로 무엇이 잘못되었는지를 알려주기만 하면 됐습니다. 오류 메시지를 “대체하려는 원본 텍스트를 파일 콘텐츠에서 찾을 수 없어. 마지막으로 읽은 후 원본이 변경되었는지 확인하려면 파일을 다시 읽어봐.”로 변경했습니다. 그랬더니 해결되었습니다. 에이전트는 ‘아, 텍스트를 찾지 못한 거구나. 다시 읽고 재시도해야겠다.’라고 학습했던 겁니다.
조각 맞추기
이제 세 가지 도구가 마련되었습니다. 이제 이 도구를 GPT-5-Codex에 연결해야 합니다.
프레임워크 없이 처음부터 이를 구축한다면, 실행 루프를 작성해야 합니다. GPT-5-Codex에 프롬프트를 보내고, 응답을 분석하고, 도구를 호출하려는지 확인한 뒤, 해당 도구를 실행하고, 결과를 다시 보내고, 이를 반복하는 방식입니다. 또한 API 요청 처리, 오류 상황, 토큰 제한, 그리고 도구 호출 프로토콜까지 모두 직접 처리해야 합니다. 상당한 설정과 노력이 필요하죠.
여기서는 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줄짜리 파일을 읽다가 토큰 한도에 도달해 컨텍스트를 잃은 뒤, 같은 파일을 반복해서 다시 읽은 적이 있습니다. 다행히 maxIterations = 200 설정 덕분에 API 예산을 소진하기 전에 이를 중단시킬 수 있었습니다. 200번이 지나면 에이전트는 종료됩니다. 각 도구 호출은 호출 1번, 응답 1번으로 총 2번을 소모하므로 최대 100회까지 호출할 수 있습니다.
때로는 에이전트가 한동안 실행되면서, 작동 중인지 아니면 멈춘 상태인지 헷갈리기도 합니다. 에이전트가 무엇을 하고 있는지 확인할 수 있도록 가시성을 추가하고자 합니다. handleEvents 블록은 Koog 내 진행 상황을 확인하는 방법입니다.
{
handleEvents {
onToolCallStarting { ctx -> println("Tool '${ctx.tool.name}' called with args:" +
" ${ctx.toolArgs.toString().take(100)}")
}
}
}
Koog는 onToolCallStarting, onToolCallFinished, onAgentFinished 등과 같은 다양한 이벤트를 제공하여 살펴볼 수 있도록 합니다. 여기서는 각 도구가 실행되기 직전에 실행되는 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()을 호출합니다.
이때부터 에이전트가 작업을 이어받습니다. 에이전트는 프로젝트를 탐색하고 파일을 읽고 변경을 수행하며 완료될 때까지 반복합니다. 작업이 끝나면 결과를 출력하세요.
실제로 다음과 같이 나옵니다.

작동 여부 확인
에이전트를 실행한 뒤, 저희는 이 세 가지 도구가 실제로 무엇을 해낼 수 있는지 확인하고자 했습니다. 그래서 GitHub 이슈에서 가져온 500개의 코딩 작업으로 구성된 벤치마크인 SWE-bench Verified에서 이를 테스트했습니다.
약 50%를 완료했는데, 50줄 짜리 코드 치고는 나쁘지 않습니다.
하지만 에이전트는 어떤 것도 검증하지 않습니다. 컴파일링할 수도 없고 테스트를 실행할 수도 없습니다. 코드를 읽고 그에 따라 변경을 수행하지만, 그 변경으로 실제로 해결되는지 알 방법이 없습니다.
이 글에서는 도구를 만드는 방법을 보여주지 않고 세 가지 도구를 사용했습니다. 도구의 기능은 알지만, 그 작동 방식이나 도구를 직접 만드는 방법은 모르실 겁니다.
그 내용은 이 시리즈의 다음 글에서 저의 동료인 Bruno가 소개해 드릴 것입니다. Bruno는 에이전트가 명령어를 실행하고 출력 결과를 확인하며 오류 발생에서 학습할 수 있도록 셸 실행 도구를 처음부터 구축합니다. 또한 Koog 도구의 작동 방식도 단계별로 설명합니다. 50줄짜리 코드는 특정 문제에 맞는 도구로 에이전트를 확장할 수 있을 때에만 의미 있기 때문입니다.
코드는 GitHub에 공개되어 있습니다. 직접 사용해 보시고 소감을 공유해 주세요.
게시물 원문 작성자