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를 더 고도화할 일이 있을 때 다시 글 작성하러 와야겠다. (_ _ )