kotlin koog AI Agent

2025. 11. 25. 12:08·Backend/kotlin

1. Introduction

 

📌 Background

koog 이 자식.

Jetbrain KotlinConf 2025 영상에서 처음 보고 완전히 반해서 만져보고는 있는데, 역시 beta 버전에는 손을 대면 안 됐던 걸까.

버전 관리의 중요성에 대해 여실히 느끼는 중이다.

 

이번 포스팅은 정말 간단하게 돌아가는 정도의 AI Agent를 만들고, docker로 MCP 띄워서 tool로 제공하는 것까지만 할 것이다.

왜냐하면, 나도 이제 막 그 정도만 하고 왔기 때문.

 

버전은 다음과 같다.

  • kotlin 2.2.20
  • koog-agents 0.5.2
  • kotlinx-coroutines 1.10.2

나도 koog-agents 최신 버전이 0.5.3이라는 거 알고 있다.

그런데 그거 사용하면 자꾸 `SerializationException: No tag in stack for requested element` 터지면서 죽어버리길래, 스트레스 받아서 다운그레이드 해버렸다.

 


2. AIAgent

 

📌 Simple AIAgent

기본적인 사용법은 공식 문서에서 확인이 가능하다.

뒤에서 말하겠지만 사실 이건 거짓말이다.

이렇게 만든 AIAgent는 이제 제대로 굴러가지 않는다;;

 

나중에 다시 언급하도록 하고, OpenAI를 LLM으로 사용한다고 하면 다음과 같이 쓰면 된다.

val agent = AIAgent( 
    executor = simpleOpenAIExecutor(System.getenv("YOUR_API_KEY")),
    llmModel = OpenAIModels.Chat.GPT4o
)

이 외에도 temperature, systemPrompt 같은 것들 설정이 가능하긴 하나, 필수 값은 아니다.

 

실행은 그냥 run() 호출해서 prompt 던져주면 된다.

 

⚠️ run() 함수는 suspend 함수이므로, 반드시 코루틴 안에서 실행되어야 한다.
agent.run("hello?")
     .also { println("Result $it") }

 

진짜 이게 끝이다.

 

그런데 다시 말하지만 이건 제대로 굴러가지 않는다.

코틀린 페이지랑 jetbrain 페이지 이슈에서 확인 가능하듯이, agent.run() 두 번째 호출부터는 "Agent was already started"라는 메시지와 함께 IllegalStateException 에러가 터진다.

그 이유가 병렬 사용할 때 문제가 생겨서 명시적으로 일회용으로 만들었기 때문이라는데, 덕분에 이제 AIAgentService를 사용해야 한다. (주석에 좀 써놓으라고)

 

그런데 바로 여기로 넘어가면 난이도가 갑자기 급상승하니까 자잘한 것 몇 가지를 더 알아보고 가자.

 

📌 Custom AI Executor

지난 번에 Junction ASIA 2025에서 겁도 없이 koog 써보겠다가, Solar Pro는 기본 설정값이 상수로 제공되지 않아서 포기했었다.

그런데 지금 하고 있는 것도 대외비 문서로 사내에서 쓸 AI Agent를 만드는 거라, 사내에서 제공하는 인프라를 써야하는 문제가 발생했다.

 

하지만 만약 정말 디테일한 세부 조정이 필요 없고, 기존 OpenAI 요청 스펙과 동일한데 주소 정도만 다르다면 매우 쉽게 해결할 수 있다.

 

public fun simpleOpenAIExecutor(
    apiToken: String
): SingleLLMPromptExecutor = SingleLLMPromptExecutor(OpenAILLMClient(apiToken))

일단 저 simpleOpenAIExecutor를 열어보면 단순하기 그지없다.

여기서 무엇을 건드리면 좋을까 둘러보면, SingleLLMPromptExecutor는 다른 회사 제품도 다 동일하게 쓰고 있는 것들이라 냅둬도 좋다는 걸 금방 알 수 있다.

 

우리가 건드려야 할 건 OpenAILLMClient다.

...

@OptIn(ExperimentalAtomicApi::class)
public open class OpenAILLMClient(
    apiKey: String,
    private val settings: OpenAIClientSettings = OpenAIClientSettings(),
    baseClient: HttpClient = HttpClient(),
    clock: Clock = Clock.System,
) : AbstractOpenAILLMClient<OpenAIChatCompletionResponse, OpenAIChatCompletionStreamResponse>(
    apiKey,
    settings,
    baseClient,
    clock,
    staticLogger
),

...

처음에는 AbstractOpenAILLMClient 구현하는 CustomAILLMClient를 만들까 싶었는데, 이건 구현 비용이 급격하게 올라간다.

어차피 우리가 원하는 건 동작을 모두 바꾸는 게 아니라, Client의 요청 경로를 살짝 틀어주는 것 뿐이니 OpenAIClientSettings를 확인해보면 된다.

 

public class OpenAIClientSettings(
    baseUrl: String = "https://api.openai.com",
    timeoutConfig: ConnectionTimeoutConfig = ConnectionTimeoutConfig(),
    chatCompletionsPath: String = "v1/chat/completions",
    public val responsesAPIPath: String = "v1/responses",
    public val embeddingsPath: String = "v1/embeddings",
    public val moderationsPath: String = "v1/moderations",
) : OpenAIBasedSettings(baseUrl, chatCompletionsPath, timeoutConfig)

그럼 바로 원하던 게 나온다.

이 값만 수정해서 OpenAILLMClient를 생성해주면 끝난다.

 

import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.executor.llms.SingleLLMPromptExecutor

fun simpleCustomAIExecutor(
    apiKey: String
): SingleLLMPromptExecutor {
    val customSettings = OpenAIClientSettings(
        baseUrl = "<AI GW path>",
        chatCompletionsPath = "<필요하면 나머지도 설정 ㄱㄱ>"
    )
    
    val customClient = OpenAILLMClient(
        apiKey = apiKey,
        settings = customSettings
    )
    
    return SingleLLMPromptExecutor(llmClient = customClient)
}

끝났으면 처음에 AIAgent에 넘겨주던 함수를 바꿔치면 된다.

 

val agent = AIAgent( 
    executor = simpleCustomAIExecutor(System.getenv("YOUR_API_KEY")), // 변경
    llmModel = OpenAIModels.Chat.GPT4o
)

llmModel도 비슷한 방식으로 바꾸면 되는데, 이건 진짜 쉽다.

OpenAIModels 클래스 열어보면, 그냥 LLModelDefinitions 마커 인터페이스 구현하고 내부에 수많은 object가 선언해놓았을 뿐이다.

 

필요한 스펙에 따라 똑같이 작성하면 된다.

 

📌 MCP Process with docker

일반 function tools 넘기는 것보다, MCP Tools 넘기는 게 더 어렵다.

그런데 난 지금 function tools 넘길 일이 없다보니 중간 과정 모두 스킵해버린 터라, 오히려 저건 어떻게 하는지 잘 모르겠다.

 

여튼, AIAgent 파라미터를 다시보면 toolRegistory 라는 것이 있다.

여기에 MCP tool 담아주면 된다.

 

내 경우엔 atlassian mcp를 연결해주고 싶었다.

 

val process = ProcessBuilder(
    "docker", "run", "--rm", "-i",
    "-e", "CONFLUENCE_URL",
    "-e", "CONFLUENCE_PERSONAL_TOKEN",
    "-e", "JIRA_URL",
    "-e", "JIRA_PERSONAL_TOKEN",
    "--name", "mcp-atlassian-agent",
    "<atlassian-mcp-image>"
).apply {
    environment().put("CONFLUENCE_URL", config.confluenceUrl)
    environment().put("CONFLUENCE_PERSONAL_TOKEN", config.confluencePersonalToken)
    environment().put("JIRA_URL", config.jiraUrl)
    environment().put("JIRA_PERSONAL_TOKEN", config.jiraPersonalToken)
}.start()

이 다음에 process.isAlive로 컨테이너가 올바르게 띄워졌는지 확인할 수는 있지만, 컨테이너의 활성화가 곧 애플리케이션의 활성화를 의미하지는 않으므로 주의가 필요하다.

 

이제 이렇게 생성한 process로 Registry를 생성해주자.

 

toolRegistry = McpToolRegistryProvider.fromTransport(
    transport = McpToolRegistryProvider.defaultStdioTransport(process)
).also {
    logger.info { "✅ MCP 초기화 완료" }
    logger.info { "✅ ${it.tools.size}개의 도구 로드 완료" }
}

짜잔.

이렇게 하고, 아까 전 agent의 toolRegistry 속성으로 넘겨주면 알아서 적용이 된다.

 


3. AIAgentService

 

📌 Take a deep breath

이제 위에서 이야기한 것처럼, AIAgent를 AIAgentService로 바꾸는 작업을 진행할 것이다.

그런데 내가 kotlin이 익숙칠 않아서 그런가, 난이도가 급상승해서 혼자 골머리 좀 앓았다.

그냥 하나하나 파라미터 주석 다 읽어가면서 될 때까지 시도했더니, 갑자기 돌아가게 된 터라 오류가 있을 수도 있다.

그런 것들은 추후 정정할 예정.

 

그런데 여기서 문제는 AIAgentService의 정적 팩토리다.

 

AIAgentService를 만들기 위해서는 GraphAIAgent 혹은 FunctionalAIAgent 둘 중 하나여야 하는데, 상속 관계를 살펴보면 GraphAIAgent,  → StatefulSingleUseAIAgent → AIAgent로 되어 있다.

나는 GraphAIAgent를 사용하기로 결정했는데, 그냥 이게 더 쉬워보였다.

 

문제는 저 GraphAIAgent 필수 파라미터가 너무 많다.

 

참고로 난 아직 kotlin에 미숙한 상태니, 대충 본인이 나보다 잘 작성한다면 부디 그렇게 하시길..

 

📌 AIAgentConfig
val aiAgentConfig = AIAgentConfig(
    prompt = Prompt.build(id = "<프롬프트 고유 식별값>") {
        system("<대충 넣고 싶은 시스템 프롬프트 있으면 작성>")
    },
    model = OpenAIModels.Chat.GPT4o,
    maxAgentIterations = 10 // 너무 작으면 터지니까, 적당히 임계치 정하고 테스트 ㄱㄱ
)

여긴 그다지 어려운 부분은 없다.

다만 maxAgentIerations를 처음에 3으로 잡았더니, strategy 반복 횟수가 툭하면 초과해서 "maxAgentIterations 반복 횟수 늘려보는 것 좀 고려해봐라"라는 에러 문구가 나와서 늘려줬다.

 

저거 말고도 missingToolsConversation라는 게 있는데, 만약 tool 사용하라고 지시했는데 없으면 어떻게 할 지 결정하는 설정을 추가해줄 수 있다.

optional 필드이고, 난 필요하지 않아서 추가하지 않았다.

 

📌 strategy

AIAgentGraphStrategy를 사용해도 되고, builder를 사용해도 된다.

하지만 조금 폼나게 사용해보고 싶었다.

정확히는 KotlinConf에서 봤던 그 코드를 직접 사용해보고 싶었다. (ㅋㅋㅋㅋㅋㅋㅋㅋ)

 

ai.koog.agents.core.dsl.builder.AIAgentGraphStrategyBuilder.kt에는 AIAgentGraphStrategyBuilder 클래스 외에도 strategy inline 함수를 제공하는데, 이걸로 진짜 폼나게 전략을 만들 수 있다.

 

GraphStaregy이므로, 말 그대로 graph를 직접 그려서 원하는 알고리즘을 구상해보아야 한다.

각 네모 박스는 node, 간선은 edge다.

 

node는 ai.koog.agents.core.dsl.extension에 사용할 수 있는 것들이 길게 나열되어 있다.

위 graph를 전략으로 구성한다고 치면, 다음과 같이 전략을 생성할 수 있다.

 

import ai.koog.agents.core.dsl.builder.forwardTo
import ai.koog.agents.core.dsl.builder.strategy
import ai.koog.agents.core.dsl.extension.*

val agentStrategy = strategy(name = "<전략 이름>") {
    val nodeCallLLM by nodeLLMRequest()
    val nodeExecutionTool by nodeExecuteTool()
    val nodeSendToolResult by nodeLLMSendToolResult()

    edge(nodeStart forwardTo nodeCallLLM)

    // LLM이 tool 없이 응답하면 직접 종료
    edge(
        (nodeCallLLM forwardTo nodeFinish)
                onAssistantMessage { true }
    )

    // LLM이 tool 요청하면 실행
    edge(
        (nodeCallLLM forwardTo nodeExecutionTool)
                onToolCall { true }
    )

    // tool 결과를 LLM으로 다시 전송
    edge(nodeExecutionTool forwardTo nodeSendToolResult)

    // tool 결과를 수신한 후 tool 호출 처리
    edge(
        (nodeSendToolResult forwardTo nodeExecutionTool)
                onToolCall { true }
    )

    // LLM이 tool 결과를 처리한 후 종료
    edge(
        (nodeSendToolResult forwardTo nodeFinish)
                onAssistantMessage { true }
    )
}

하, 베타 버전 koog 쓰면서 화가 많이 나긴 하는데, 이거 보고 있으니 너무 행복해진다.

 

📌 GraphAIAgent

지금까지 만든 것들을 모두 종합해서 AIAgent를 대체할 agent를 만들어주면 된다.

 

val agent = GraphAIAgent(
    inputType = typeOf<String>(),
    outputType = typeOf<String>(),
    promptExecutor = simpleCustomAIExcutor(config.customApiKey),
    agentConfig = aiAgentConfig,
    strategy = agentStrategy,
    toolRegistry = toolRegistry
}

다소 이해가 안 가는 점은 GraphAIAgent<Input, Output> 제네릭으로 정의했으면서, KType의 inputType과 outputType을 필수로 적어주어야 한다는 부분.

이건 내가 kotlin이랑 아직 어색한 사이라 잘못 사용하고 있는 것일 수도 있다.

 

📌 Log

strategy에서 정의한 node 이동 간 로그를 추적하고 싶다면, GraphAIAgent의 installFeatures 필드를 사용하면 된다.

 

val agent = GraphAIAgent(
    ...,
    installFeatures = {
        install(EventHandler) {
            onAgentStarting { eventContext: AgentStartingContext ->
                logger.info { "Starting strategy : ${eventContext.context.strategyName}" }
            }
            onNodeExecutionCompleted { eventContext: NodeExecutionCompletedContext ->
                logger.info { "We have passed the node ${eventContext.node.name} and we ${eventContext.context.agentInput}" }
            }
            onAgentCompleted { eventContext: AgentCompletedContext ->
                logger.info { "Result: ${eventContext.result}" }
            }
        }
    }
)

내가 추가한 로그는 Agent 시작과 종료, 그리고 각 node의 실행 후 로그를 남기도록 설정했다.

 

이것 외에도, ai.koog.agents.features.eventHandler.feature.EventHandlerConfig.kt에 뭐가 많으니 입맛대로 추가하면 된다.

 

📌 AIAgentService

축하한다.

드디어 모든 것이 끝났다.

위에서 만든 agent를 AIAgentService의 정적 팩토리 메서드에 집어넣어주면 된다.

 

agentService = AIAgentService.fromAgent<String, String>(agent)

이제 저 agentService를 필요한 서비스에 주입하고, 다음과 같이 호출하면 된다.

 

agentService.createAgentAndRun(agentInput = "<감격의 눈물 좔좔>")

 


4. Conclusion

 

📌 Next Step

이제 막 스타트 라인에 섰을 뿐이다.

RAG도 보완해야 하고, flow도 개선해야 하고, 종종 진행하다가 혼자 멈춰버리는 경우도 있어서 재시도와 실패 로직도 처리를 해주어야 한다.

 

할 건 많지만, 오랜만에 해보고 싶었던 걸 직접 해보니까 역시 기분이 좋다.

한동안 KMP랑 좀 더 싸우다가, koog를 더 고도화할 일이 있을 때 다시 글 작성하러 와야겠다. (_ _ )

저작자표시 비영리 (새창열림)
'Backend/kotlin' 카테고리의 다른 글
  • koog, KMP 사용하다가 java.lang.NoClassDefFounError: kotlinx/datetime/Clock$System 이슈 해결 방법
나죽못고나강뿐
나죽못고나강뿐
싱클레어, 대부분의 사람들이 가는 길은 쉽고, 우리가 가는 길은 어려워요. 우리 함께 이 길을 가봅시다.
  • 나죽못고나강뿐
    코드를 찢다
    나죽못고나강뿐
  • 전체
    오늘
    어제
    • 분류 전체보기 (483)
      • Computer Science (60)
        • Git & Github (4)
        • Network (17)
        • Computer Structure & OS (13)
        • Software Engineering (5)
        • Database (9)
        • Security (5)
        • Concept (7)
      • Frontend (22)
        • React (14)
        • Android (4)
        • iOS (4)
      • Backend (85)
        • Spring Boot & JPA (53)
        • Django REST Framework (14)
        • MySQL (10)
        • Nginx (1)
        • FastAPI (4)
        • kotlin (2)
        • OpenSearch (1)
      • DevOps (24)
        • Docker & Kubernetes (11)
        • Naver Cloud Platform (1)
        • AWS (2)
        • Linux (6)
        • Jenkins (0)
        • GoCD (3)
      • Coding Test (112)
        • Solution (104)
        • Algorithm (7)
        • Data structure (0)
      • Reference (139)
        • Effective-Java (90)
        • Pragmatic Programmer (0)
        • CleanCode (11)
        • Clean Architecture (5)
        • Test-Driven Development (4)
        • Relational Data Modeling No.. (0)
        • Microservice Architecture (2)
        • 알고리즘 문제 해결 전략 (9)
        • Modern Java in Action (0)
        • Spring in Action (0)
        • DDD start (0)
        • Design Pattern (6)
        • 대규모 시스템 설계 (7)
        • JVM 밑바닥까지 파헤치기 (4)
        • The Pragmatic Programmer (1)
      • Service Planning (2)
      • Side Project (5)
      • AI (1)
      • MATLAB & Math Concept & Pro.. (2)
      • Review (24)
      • Interview (4)
      • IT News (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃
  • 공지사항

    • 요새 하고 있는 것
    • 한동안 포스팅은 어려울 것 같습니다. 🥲
    • N Tech Service 풀스택 신입 개발자가 되었습니다⋯
    • 취업 전 계획 재조정
    • 취업 전까지 공부 계획
  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
나죽못고나강뿐
kotlin koog AI Agent
상단으로

티스토리툴바