🐥

JetBrains公式のKotlin製AIエージェントフレームワーク"Koog"やるぞ

に公開

はじめに

スマートラウンドで野生のAIエンジニアをやっている福本です🐈
普段はLangChainMastraを使ったAIワークフロー/エージェントの新規開発や運用をやっています。

最近、登記簿PDFファイルからデータを抽出して自動で資本政策を作る”AI資本政策”という機能を作ったりしました。

というわけでAIエージェントやワークフロー開発には色々と興味を持っているわけですが、先日のKotlinConf 2025でJetBrainsが公式で提供するAIエージェント開発のフレームワークKoogが発表されました👇️

https://blog.jetbrains.com/ai/2025/05/meet-koog-empowering-kotlin-developers-to-build-ai-agents/

所属するスマートラウンドがKotlinカンパニーであり、私自身がServer-Side Kotlinの開発者コミュニティの運営に携わっている関係から、「これはお前が触れって言ってるな!!!」と思い(※言ってない)触ってみたので、メモがてら感想を置いておこうと思います。

Koogとは?

Koogは、先述の通りJetBrains公式のKotlin製のAIエージェントフレームワークです。誰かに怒られそうなくらい雑な説明をすると、LangChainやMastraのKotlin版みたいなイメージでしょうか。

以下のような主要なLLMモデルに対応していて、好みのモデルを使ったAIエージェントやワークフローをKotlinで構築できます。

  • OpenAI
  • Anthropic
  • Gemini
  • OpenRouter
  • Ollama

より詳しい説明は、以下のJetBrainsの公式ドキュメントや、JetBrains公式代理店の方の記事が参考になるかと思います。

https://docs.koog.ai/

https://zenn.dev/nattosystem_jp/articles/d7dd6df74cf5b0

他にも、海外の方の記事でこういったものがありました👇️

https://medium.com/@cprasad362/beyond-buttons-why-kotlins-koog-is-about-to-redefine-your-android-apps-d6caa0019d83

Koogで簡易的なAIエージェントのワークフローを構築する

話はここまでにして、いきなりですがKoogを用いたAIエージェントのワークフローを構築してみましょう!サンプルとなるテーマを先に挙げて、それをKoogで実現する方針で進めようと思います。

やること/テーマ

今回のテーマとして、以下を行うAIエージェントを作ってみます🧑‍🍳

  1. PDFファイルをLLMに送信し、それが料理のレシピかどうかを判定
  2. 料理のレシピであれば、そのPDFファイルをチャンク化 -> Embeddingしベクトル化
  3. [4と並行]ベクトル化されたレシピから、料理名や材料などを構造化してデータを抽出する
  4. [3と並行]ベクトル化されたレシピから、料理に掛かる時間をKotlinのメソッドを使って計算する
  5. 3と4の結果を合成して、料理のデータを構造化した結果を返す

上記を実現するためには、AIエージェントに以下の機能が実装されている必要があります 🤔

  • MultiModal
  • RAG
  • Structured Output
  • (Parallel)Graph
  • Tools(=Function Calling, Tool Use)

※Koogを調査して理解を深めるために、抽出フローを分けたりEmbeddingするなど、意図的に色々な機能を使っています

ちなみに、今回サンプルとして利用するレシピは、Webでいい感じのレシピを求めていて見つけた以下のキーマカレー 🍛 。文字がコピペできる&URLでアクセス可能なレシピのPDFであれば、だいたいうまくいくと思います。

https://kyushucgc.co.jp/recipe_pdf/202112/recipe05.pdf

実装

上記のワークフローをKoogを使って実装した内容を以下で掲載しておきます。Koogに関わる主要な部分のみを記載しているので、周辺の処理が気になる方は後述のリポジトリのサンプルコードをご覧ください 👌

利用バージョンやモデル

今回Koogを使うにあたり、利用したライブラリとそのバージョン、およびLLMのモデルを記載しておきます👇️

サンプルコードは以下のRepositoryにあります👇️

https://github.com/chanrute/koog-sample/tree/0.3.0

メイン処理

Koogのワークフローを定義し、それを実行するKotlinコードになります。gradleコマンドからmain()が実行されるイメージです👇️

https://github.com/chanrute/koog-sample/blob/0.3.0/koog/app/src/main/kotlin/org/example/App.kt

※記事で一覧して見やすいように、意図的に1ファイルに処理をまとめています

詳細

上記のメイン処理および周辺データについて、少し解説します 🐦

LLMのAPI実行

以下は「1. PDFファイルをLLMに送信し、それが料理のレシピかどうかを判定」の処理ノードです。シンプルなLLM実行はsimpleOpenAIExecutorを使って呼び出します👇️JSONのレスポンスを取得する部分(Structured Output)については後述します。

// 3. ノード定義:レシピ判定(KoogのStructured Output使用)
val validateRecipePdf by node<PdfUrl, PdfUrl>("validate-recipe-pdf") { pdfUrl ->
    println("\n📋 PDFの内容を判定中: ${pdfUrl.url}")

    try {
        // NOTE:
        // 0.3.0時点ではKoogから直接PDFファイルを送信して処理できない。PDFファイルを添付する際に必要な` LLMCapability.Document`が付与されていない。
        // そのため、PDFを一度画像に変換してLLMに送信している。
        // ref: https://github.com/JetBrains/koog/blob/0.3.0/prompt/prompt-executor/prompt-executor-clients/prompt-executor-openai-client/src/commonMain/kotlin/ai/koog/prompt/executor/clients/openai/OpenAIModels.kt
        //
        // 以下のcommitから実装されているので、次のバージョンからはPDFファイルを添付して送信できるようになりそう。
        // ref: https://github.com/JetBrains/koog/blob/38a8424467038edf46cafc262286fa15689e3f09/prompt/prompt-executor/prompt-executor-clients/prompt-executor-openai-client/src/commonMain/kotlin/ai/koog/prompt/executor/clients/openai/OpenAIModels.kt
        val pdfBytes = pdfService.downloadPdf(pdfUrl.url)
        val imageBytes = pdfService.convertPdfToImage(pdfBytes)

        // Koogの構造化出力でPDF判定
        val validationStructure = JsonStructuredData.createJsonStructure<PdfValidationResult>(
            schemaFormat = JsonSchemaGenerator.SchemaFormat.JsonSchema,
            examples = PdfValidationResult.getExampleValidations(),
            schemaType = JsonStructuredData.JsonSchemaType.SIMPLE
        )

        // プロンプトに画像を含めて実行
        val promptWithImage = prompt("validation-with-image", LLMParams(temperature = 0.0)) {
            system("""
                あなたは料理レシピの専門家です。
                添付された画像を確認して、この文書が料理のレシピに関する内容かどうかを判断してください。
                
                判断基準:
                - 料理名、材料、作り方、調理時間などが含まれているか
                - 料理に関する情報が主な内容となっているか
               
                JSON以外のレスポンスを返却することは禁止されています。
                
                以下のJSON構造で正確に出力してください:
                `reason`は必ず日本語で理由を記載してください。
                
                ${validationStructure.schema}
            """.trimIndent())
            
            user {
                +"添付された画像から、料理のレシピに関する情報が含まれているかを判定してください。"
                
                attachments {
                    image(
                        Attachment.Image(
                            content = AttachmentContent.Binary.Bytes(imageBytes),
                            format = "png",
                            fileName = "pdf_page.png"
                        )
                    )
                }
            }
        }

        val executor = simpleOpenAIExecutor(getOpenAiApiKey())
        val response = executor.execute(promptWithImage, OpenAIModels.Chat.GPT4o, emptyList())
        val result = response.first().content
        
        println("🔍 LLMレスポンス: $result")

        val validation = try {
            validationStructure.parse(result)
        } catch (e: Exception) {
            println("❌ JSON解析エラー: ${e.message}")
            PdfValidationResult(true, "JSON解析に失敗したため、処理を続行します")
        }

        println("🔍 判定結果: ${if (validation.isRecipe) "✅ レシピPDF" else "❌ レシピ以外"}")
        println("📝 理由: ${validation.reason}")

        storage.set(validationKey, validation)
        pdfUrl
        
    } catch (e: Exception) {
        println("❌ PDF内容判定でエラーが発生: ${e.message}")
        throw e
    }
}

コメントに記載していますが、Koogの0.3.0時点では直接PDFファイルを送信できない点に注意してください ⚠️ 実行時に以下のようなエラーが出てしまいます。

Model gpt-4o does not support files

そのため、今回はPDFを画像ファイルに変換した後にLLMに送信しています。

RAG

PDFから抽出した文字チャンク(chunks)をOpenAIのClientを使ってEmbeddingしています⏫。本来はChroma DBなどのVector StoreにEmbeddingされたチャンクを格納する運用が多いと思いますが、今回は簡易的に変数で持って取り回しています。

suspend fun createOpenAIEmbeddings(chunks: List<String>): List<ChunkEmbedding> {
    println("🧠 KoogのLLMEmbedderを使ったOpenAI Embeddingを作成中...")
    return try {
        val embedder = createLLMEmbedder()
        val embeddings = chunks.map { chunk ->
            val embedding = embedder.embed(chunk) as Vector
            ChunkEmbedding(chunk, embedding)
        }
        println("✅ ${embeddings.size}個のKoog LLMEmbeddingを作成しました")
        embeddings
    } catch (e: Exception) {
        println("❌ Koog LLMEmbedder作成でエラーが発生: ${e.message}")
        emptyList()
    }
}

private suspend fun createLLMEmbedder(): LLMEmbedder {
    val openAiApiKey = getOpenAiApiKey()
    val client = OpenAILLMClient(openAiApiKey)
    val lModel = LLModel(
        LLMProvider.OpenAI,
        "text-embedding-ada-002",
        capabilities = listOf(LLMCapability.Embed)
    )
    return LLMEmbedder(client, lModel)
}

そして、以下の箇所でEmbeddingした内容からキーワードに対する類似度(embedder.diffがコサイン類似度を表す)で並べ、上位3つのチャンクを返しています。ここで得たチャンクを、後述のStructured OutputやToolsでの出力に活用します 💡

suspend fun findRelevantChunks(
    queryEmbedding: Vector,
    embeddings: List<ChunkEmbedding>,
    topK: Int = 3
): List<String> {
    val embedder = createLLMEmbedder()
    val similarities = embeddings.map { chunkEmbedding ->
        val diff = embedder.diff(queryEmbedding, chunkEmbedding.vectorEmbedding)
        chunkEmbedding.chunkText to diff.toDouble()
    }
    val topChunks = similarities.sortedBy { it.second }.take(topK).map { it.first }
    
    println("⏱️ チャンクの内容:")
    topChunks.forEachIndexed { index, chunk ->
        println("  ${index + 1}. ${chunk.take(100)}...")
    }
    return topChunks
}

suspend fun createQueryEmbedding(query: String): Vector? {
    return try {
        val embedder = createLLMEmbedder()
        println("  クエリのEmbedding生成中...")
        embedder.embed(query)
    } catch (e: Exception) {
        println("❌ クエリEmbedding作成でエラーが発生: ${e.message}")
        null
    }
}

Structured Output(Data)

以下のRecipeEntityデータをPDFのチャンクデータから抽出します👩‍🍳

公式ドキュメントは、このStructured Dataの章がかなり参考になります。結論、アノテーションを付与したdata classをLLMに渡せばOKです。

RecipeEntity.kt
package org.example

import ai.koog.agents.core.tools.annotations.LLMDescription
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable


/**
 * 材料を表すデータクラス
 *
 * @property name 材料名
 * @property unit 単位(例:グラム、カップ、個など)
 * @property quantity 数量
 */
@Serializable
@SerialName("Ingredient")
@LLMDescription("料理の材料")
data class Ingredient(
    @property:LLMDescription("材料名(例:玉ねぎ、にんじん、牛肉など)")
    val name: String,

    @property:LLMDescription("材料の単位(例:グラム、カップ、個、大さじ、小さじなど)")
    val unit: String,

    @property:LLMDescription("材料の数量")
    val quantity: Float
)

/**
 * 料理のレシピを表すデータクラス
 *
 * @property name レシピ名
 * @property ingredients 必要な材料のリスト
 */
@Serializable
@SerialName("RecipeEntity")
@LLMDescription("料理のレシピ情報")
data class RecipeEntity(
    @property:LLMDescription("料理のレシピ名(例:カレーライス、パスタボロネーゼなど)")
    val name: String,

    @property:LLMDescription("レシピに必要な材料のリスト")
    val ingredients: List<Ingredient>
) {
    companion object {
        /**
         * Koogの構造化出力用のサンプルレシピ例を提供する
         */
        fun getExampleRecipes(): List<RecipeEntity> {
            return listOf(
                RecipeEntity(
                    name = "カレーライス",
                    ingredients = listOf(
                        Ingredient("玉ねぎ", "個", 2.0f),
                        Ingredient("にんじん", "本", 1.0f),
                        Ingredient("牛肉", "グラム", 300.0f),
                        Ingredient("カレールー", "箱", 1.0f)
                    )
                ),
                RecipeEntity(
                    name = "パスタボロネーゼ",
                    ingredients = listOf(
                        Ingredient("パスタ", "グラム", 200.0f),
                        Ingredient("挽き肉", "グラム", 150.0f),
                        Ingredient("トマト缶", "缶", 1.0f),
                        Ingredient("オリーブオイル", "大さじ", 2.0f)
                    )
                )
            )
        }
    }
}

上記のdata classをJsonStructuredData.createJsonStructureにしてLLMのレスポンスをparse、もしくはpromptExecutor.executeStructuredに渡すことで、意図した構造のデータを抽出できます👇️

suspend fun generateAnswerWithAgent(relevantChunks: List<String>): RecipeEntity? {
    println("🤖 AIAgentとKoogのstructured outputを使ってRecipeEntityを抽出中...")
    
    return try {
        val apiKey = getOpenAiApiKey()
        val context = relevantChunks.joinToString("\n\n") { "【文書内容】\n$it" }
        val exampleRecipes = RecipeEntity.getExampleRecipes()

        // Koogの構造化出力設定
        val recipeStructure = JsonStructuredData.createJsonStructure<RecipeEntity>(
            schemaFormat = JsonSchemaGenerator.SchemaFormat.JsonSchema,
            examples = exampleRecipes,
            schemaType = JsonStructuredData.JsonSchemaType.SIMPLE
        )

        val agent = AIAgent(
            executor = simpleOpenAIExecutor(apiKey),
            systemPrompt = """
                あなたは料理レシピの専門家です。
                提供された文書からレシピ情報を正確に抽出してください。

                抽出する情報:
                - レシピ名(料理の名前)
                - 材料リスト(材料名、数量、単位)

                注意事項:
                - 数量は数値として正確に抽出してください
                - 単位は日本語で記載してください(グラム、個、本、カップ、大さじ、小さじなど)
                - 文書に記載されている情報のみを抽出してください
                - 以下のJSON構造で正確に出力してください:

                ${recipeStructure.schema}
            """.trimIndent(),
            llmModel = OpenAIModels.Chat.GPT4o
        )

        val result = agent.run("以下の文書からレシピ情報を抽出してください:\n\n$context")
        println("✅ AIAgent RecipeEntity抽出完了")
        println("抽出結果: $result")
        
        recipeStructure.parse(result)
    } catch (e: Exception) {
        println("❌ AIAgent RecipeEntity抽出でエラーが発生: ${e.message}")
        e.printStackTrace()
        null
    }
}

Tools

以下のCalculateToolをLLMに渡して、doExecute()の引数を返してもらいます🔧 Agentには、KoogのSimpleToolを継承したToolを渡す必要があります。公式ドキュメントはこちら

Tool.kt
package org.example

// Koog Tools API
import ai.koog.agents.core.tools.SimpleTool
import ai.koog.agents.core.tools.ToolDescriptor
import ai.koog.agents.core.tools.ToolParameterDescriptor
import ai.koog.agents.core.tools.ToolParameterType
import ai.koog.agents.core.tools.ToolArgs
import ai.koog.agents.core.tools.annotations.LLMDescription
import kotlinx.serialization.Serializable
import kotlinx.serialization.KSerializer

/**
 * 調理時間計算ツール
 */
@Serializable
@LLMDescription("調理時間の合計を計算するための引数")
data class SumMinutesArgs(
    @property:LLMDescription("調理時間の値の配列(分単位)")
    val minutes: List<Float>
) : ToolArgs

class CalculateTool : SimpleTool<SumMinutesArgs>() {
    companion object {
        const val NAME = "sumMinutes"
    }

    suspend fun sum(args: SumMinutesArgs): Float {
        println("\n🛠️ === sumMinutesツールが呼び出されました! ===")
        return args.minutes.sum()
    }

    // NOTE: SimpleToolを継承しているため必要
    override suspend fun doExecute(args: SumMinutesArgs): String {
        return sum(args).toString()
    }

    override val argsSerializer: KSerializer<SumMinutesArgs>
        get() = SumMinutesArgs.serializer()

    override val descriptor: ToolDescriptor
        get() = ToolDescriptor(
            name = NAME,
            description = "複数の調理時間の値を分単位で合計し、総調理時間を計算する",
            requiredParameters = listOf(
                ToolParameterDescriptor(
                    name = "minutes",
                    type = ToolParameterType.List(ToolParameterType.Float),
                    description = "調理時間の値の配列(分単位)"
                )
            ),
            optionalParameters = listOf()
        )
}

ToolをLLMに渡して実行する部分はこちら👇️

val extractCookingTime by node<RecipeSearchResult, ParallelExtractionData>("extract-cooking-time") { searchResult ->
    println("\n🔍 料理時間関連のチャンクを検索中...")

    val timeQuery = "時間 分 調理時間 作業時間 合計"
    val timeQueryEmbedding = createQueryEmbedding(timeQuery)
    val timeRelatedChunks = if (timeQueryEmbedding != null) {
        findRelevantChunks(timeQueryEmbedding, searchResult.allChunkEmbeddings, topK = 3)
    } else {
        emptyList()
    }

    // Koogのツール実行機能を使用
    val response = llm.writeSession {
        updatePrompt {
            system("""
                あなたは料理レシピの調理時間を分析する専門家です。
                提供された文書から料理にかかる時間(分)を抽出してください。
                必ずsumMinutesツールを使って合計時間を計算してください。

                注意事項:
                - 「約10分」のような表現は10として扱ってください
                - 「5〜10分」のような範囲は最大値(10)を使用してください
                - 時間が明記されていない場合は、一般的な調理時間を推定してください
            """.trimIndent())

            user("""
                以下の文書から調理時間を抽出し、sumMinutesツールを使って合計時間を計算してください:

                ${timeRelatedChunks.joinToString("\n\n") { "【文書内容】\n$it" }}

                必ずsumMinutesツールを呼び出して時間を合計してください。
            """.trimIndent())
        }
        requestLLM()
    }

    // ツール実行結果から調理時間を抽出
    val cookingMinutes = try {
        if (response is Message.Tool.Call && response.tool == "sumMinutes") {
            // SumMinutesArgsをKotlinxシリアライゼーションでデシリアライズ
            val sumArgs = Json.decodeFromString<SumMinutesArgs>(response.content)
            
            // CalculateToolを実際に実行して結果を取得
            val sumTool = CalculateTool()
            val toolResult = sumTool.sum(sumArgs)
            
            println("🕐 ツール実行結果: ${sumArgs.minutes.joinToString(" + ")} = 分")
            toolResult
        } else null
    } catch (e: Exception) {
        println("⚠️ 調理時間の取得中にエラー: ${e.message}")
        null
    }

    ParallelExtractionData(null, cookingMinutes)
}

ちなみに、ToolCallに成功すると、以下のようなCallクラスのレスポンスが返ってきます👇️

Call(id=call_xxxxxxxxxxxx, tool=sumMinutes, content={"minutes":[10,2,2]}, metaInfo=ResponseMetaInfo(timestamp=2025-08-DDTHH:mm:ss, totalTokensCount=XXX, inputTokensCount=XXX, outputTokensCount=XX, additionalInfo={}))

ワークフローグラフ (Agent Strategy)

ここまでは各機能を個別に紹介してきましたが、それらを呼び出す一連の流れをワークフローとして定義して呼び出しましょう。

KoogではAIエージェントによるワークフローグラフ(LangChainでいうとLangGraph, MastraでいうとWorkFlow)を、Agent Strategyと呼びます。

LangChainと同様、nodeやedgeを定義してワークフローを作っていくイメージとなります。先述のコードのうち、createRecipeExtractionStrategyメソッドでAgentStrategyを作っています(以下で再掲、長くなるので折りたたんでいます)。

createRecipeExtractionStrategy
// ==========================================
// Koog AI Strategy Graph - 複雑なワークフロー構築
// ==========================================

fun createRecipeExtractionStrategy() = strategy<PdfUrl, RecipeExtractionResult>("recipe-extraction") {
    // 1. ストレージキーの定義
    val recipeDataKey = createStorageKey<RecipeWorkflowData>("recipeData")
    val validationKey = createStorageKey<PdfValidationResult>("validation")

    // 2. 終了ノード:レシピでない場合の処理
    val notRecipeFinish by node<PdfUrl, RecipeExtractionResult>("not-recipe-finish") { _ ->
        println("❌ このPDFは料理のレシピではありません。処理を終了します。")
        RecipeExtractionResult(null, null)
    }

    // 3. ノード定義:レシピ判定(KoogのStructured Output使用)
    val validateRecipePdf by node<PdfUrl, PdfUrl>("validate-recipe-pdf") { pdfUrl ->
        println("\n📋 PDFの内容を判定中: ${pdfUrl.url}")

        try {
            // NOTE:
            // 0.3.0時点ではKoogから直接PDFファイルを送信して処理できない。PDFファイルを添付する際に必要な` LLMCapability.Document`が付与されていない。
            // そのため、PDFを一度画像に変換してLLMに送信している。
            // ref: https://github.com/JetBrains/koog/blob/0.3.0/prompt/prompt-executor/prompt-executor-clients/prompt-executor-openai-client/src/commonMain/kotlin/ai/koog/prompt/executor/clients/openai/OpenAIModels.kt
            //
            // 以下のcommitから実装されているので、次のバージョンからはPDFファイルを添付して送信できるようになりそう。
            // ref: https://github.com/JetBrains/koog/blob/38a8424467038edf46cafc262286fa15689e3f09/prompt/prompt-executor/prompt-executor-clients/prompt-executor-openai-client/src/commonMain/kotlin/ai/koog/prompt/executor/clients/openai/OpenAIModels.kt
            val pdfBytes = pdfService.downloadPdf(pdfUrl.url)
            val imageBytes = pdfService.convertPdfToImage(pdfBytes)

            // Koogの構造化出力でPDF判定
            val validationStructure = JsonStructuredData.createJsonStructure<PdfValidationResult>(
                schemaFormat = JsonSchemaGenerator.SchemaFormat.JsonSchema,
                examples = PdfValidationResult.getExampleValidations(),
                schemaType = JsonStructuredData.JsonSchemaType.SIMPLE
            )

            // プロンプトに画像を含めて実行
            val promptWithImage = prompt("validation-with-image", LLMParams(temperature = 0.0)) {
                system("""
                    あなたは料理レシピの専門家です。
                    添付された画像を確認して、この文書が料理のレシピに関する内容かどうかを判断してください。
                    
                    判断基準:
                    - 料理名、材料、作り方、調理時間などが含まれているか
                    - 料理に関する情報が主な内容となっているか
                   
                    JSON以外のレスポンスを返却することは禁止されています。
                    
                    以下のJSON構造で正確に出力してください:
                    `reason`は必ず日本語で理由を記載してください。
                    
                    ${validationStructure.schema}
                """.trimIndent())
                
                user {
                    +"添付された画像から、料理のレシピに関する情報が含まれているかを判定してください。"
                    
                    attachments {
                        image(
                            Attachment.Image(
                                content = AttachmentContent.Binary.Bytes(imageBytes),
                                format = "png",
                                fileName = "pdf_page.png"
                            )
                        )
                    }
                }
            }

            val executor = simpleOpenAIExecutor(getOpenAiApiKey())
            val response = executor.execute(promptWithImage, OpenAIModels.Chat.GPT4o, emptyList())
            val result = response.first().content
            
            println("🔍 LLMレスポンス: $result")

            val validation = try {
                validationStructure.parse(result)
            } catch (e: Exception) {
                println("❌ JSON解析エラー: ${e.message}")
                PdfValidationResult(true, "JSON解析に失敗したため、処理を続行します")
            }

            println("🔍 判定結果: ${if (validation.isRecipe) "✅ レシピPDF" else "❌ レシピ以外"}")
            println("📝 理由: ${validation.reason}")

            storage.set(validationKey, validation)
            pdfUrl
            
        } catch (e: Exception) {
            println("❌ PDF内容判定でエラーが発生: ${e.message}")
            throw e
        }
    }

    // 4. 基本的なノード群:データ処理パイプライン
    val downloadAndExtractPdf by node<PdfUrl, PdfContent>("download-extract-pdf") { pdfUrl ->
        println("\n📥 PDFダウンロード中: ${pdfUrl.url}")
        val pdfBytes = pdfService.downloadPdf(pdfUrl.url)
        val extractedText = pdfService.extractTextFromPdf(pdfBytes)
        PdfContent(pdfBytes, extractedText)
    }

    val splitIntoChunks by node<PdfContent, DocumentChunks>("split-chunks") { pdfContent ->
        val chunks = pdfService.splitTextIntoChunks(pdfContent.extractedText)
        DocumentChunks(pdfContent.extractedText, chunks)
    }

    val createEmbeddings by node<DocumentChunks, EmbeddedChunks>("create-embeddings") { documentChunks ->
        val embeddings = createOpenAIEmbeddings(documentChunks.textChunks)
        EmbeddedChunks(documentChunks.textChunks, embeddings)
    }


    // 5. ベクトル類似度検索ノード(Koog LLMEmbedder使用)
    val findRelevantChunks by node<EmbeddedChunks, RecipeSearchResult>("find-relevant-chunks") { embeddedChunks ->
        val recipeQuery = "レシピ名 材料 分量"
        println("🔍 KoogのLLMEmbedderを使ってクエリに関連するチャンクを検索中: '$recipeQuery'")
        val queryEmbedding = createQueryEmbedding(recipeQuery)
        val matchedChunks = if (queryEmbedding != null) {
            findRelevantChunks(queryEmbedding, embeddedChunks.chunkEmbeddings)
        } else {
            emptyList()
        }
        val result = RecipeSearchResult(recipeQuery, matchedChunks, embeddedChunks.chunkEmbeddings)
        storage.set(recipeDataKey, RecipeWorkflowData(result, null))
        result
    }


    // 6. 並列実行ノード群(Koogの並列処理)
    val extractRecipeEntity by node<RecipeSearchResult, ParallelExtractionData>("extract-recipe-entity") { searchResult ->
        val recipeEntity = generateAnswerWithAgent(searchResult.matchedChunks)
        ParallelExtractionData(recipeEntity, null)
    }

    val extractCookingTime by node<RecipeSearchResult, ParallelExtractionData>("extract-cooking-time") { searchResult ->
        println("\n🔍 料理時間関連のチャンクを検索中...")

        val timeQuery = "時間 分 調理時間 作業時間 合計"
        val timeQueryEmbedding = createQueryEmbedding(timeQuery)
        val timeRelatedChunks = if (timeQueryEmbedding != null) {
            findRelevantChunks(timeQueryEmbedding, searchResult.allChunkEmbeddings, topK = 3)
        } else {
            emptyList()
        }

        // Koogのツール実行機能を使用
        val response = llm.writeSession {
            updatePrompt {
                system("""
                    あなたは料理レシピの調理時間を分析する専門家です。
                    提供された文書から料理にかかる時間(分)を抽出してください。
                    必ずsumMinutesツールを使って合計時間を計算してください。

                    注意事項:
                    - 「約10分」のような表現は10として扱ってください
                    - 「5〜10分」のような範囲は最大値(10)を使用してください
                    - 時間が明記されていない場合は、一般的な調理時間を推定してください
                """.trimIndent())

                user("""
                    以下の文書から調理時間を抽出し、sumMinutesツールを使って合計時間を計算してください:

                    ${timeRelatedChunks.joinToString("\n\n") { "【文書内容】\n$it" }}

                    必ずsumMinutesツールを呼び出して時間を合計してください。
                """.trimIndent())
            }
            requestLLM()
        }

        // ツール実行結果から調理時間を抽出
        val cookingMinutes = try {
            if (response is Message.Tool.Call && response.tool == "sumMinutes") {
                // SumMinutesArgsをKotlinxシリアライゼーションでデシリアライズ
                val sumArgs = Json.decodeFromString<SumMinutesArgs>(response.content)
                
                // CalculateToolを実際に実行して結果を取得
                val sumTool = CalculateTool()
                val toolResult = sumTool.sum(sumArgs)
                
                println("🕐 ツール実行結果: ${sumArgs.minutes.joinToString(" + ")} = 分")
                toolResult
            } else null
        } catch (e: Exception) {
            println("⚠️ 調理時間の取得中にエラー: ${e.message}")
            null
        }

        ParallelExtractionData(null, cookingMinutes)
    }

    // 7. 並列実行コントローラー(Koogの並列処理機能)
    val extractInParallel by parallel(
        extractRecipeEntity, extractCookingTime
    ) {
        // foldによる並列結果の統合
        fold(ParallelExtractionData(null, null)) { acc, extractionData ->
            ParallelExtractionData(
                extractionData.extractedRecipe ?: acc.extractedRecipe,
                extractionData.totalCookingMinutes ?: acc.totalCookingMinutes
            )
        }
    }

    // 8. 最終結果作成ノード
    val createFinalResult by node<ParallelExtractionData, RecipeExtractionResult>("create-final-result") { extractionData ->
        RecipeExtractionResult(extractionData.extractedRecipe, extractionData.totalCookingMinutes)
    }

    // 9. ワークフローエッジ定義(Koogの条件分岐機能)
    edge(nodeStart forwardTo validateRecipePdf)

    // 条件分岐:レシピ判定による処理分岐
    edge(
        (validateRecipePdf forwardTo downloadAndExtractPdf)
            onCondition { _ ->
                val validation = storage.getValue(validationKey)
                validation.isRecipe
            }
    )
    edge(
        (validateRecipePdf forwardTo notRecipeFinish)
            onCondition { _ ->
                val validation = storage.getValue(validationKey)
                !validation.isRecipe
            }
    )

    // メインワークフローチェーン
    edge(downloadAndExtractPdf forwardTo splitIntoChunks)
    edge(splitIntoChunks forwardTo createEmbeddings)
    edge(createEmbeddings forwardTo findRelevantChunks)
    edge(findRelevantChunks forwardTo extractInParallel)
    edge(extractInParallel forwardTo createFinalResult)
    edge(createFinalResult forwardTo nodeFinish)
    edge(notRecipeFinish forwardTo nodeFinish)
}

構築したグラフをフローチャートにすると、以下の図のようなワークフローになっています🔀。並列や処理の分岐などがあり、それが実装されていることがわかります。

実行結果

サンプルのレシピURLに対して実行した結果、以下のよう出力されます👇️無事にレシピの情報が抽出されていることがわかります。

$ ./gradlew run
Starting a Gradle Daemon (subsequent builds will be faster)
Reusing configuration cache.

> Task :app:run
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.

🔄 === Koogワークフロー実行開始 ===

📋 PDFの内容を判定中: https://kyushucgc.co.jp/recipe_pdf/202112/recipe05.pdf
PDFを画像に変換中...
PDF画像変換完了: 1299328バイト
🔍 LLMレスポンス: {"isRecipe":true,"reason":"画像には料理名、材料、作り方が含まれており、料理のレシピに関する情報が主な内容となっています。"}
🔍 判定結果: ✅ レシピPDF
📝 理由: 画像には料理名、材料、作り方が含まれており、料理のレシピに関する情報が主な内容となっています。

📥 PDFダウンロード中: https://kyushucgc.co.jp/recipe_pdf/202112/recipe05.pdf
PDFからテキストを抽出中...
抽出されたテキスト長: 597文字
テキストをチャンクに分割中...
2個のチャンクに分割しました
🧠 KoogのLLMEmbedderを使ったOpenAI Embeddingを作成中...
✅ 2個のKoog LLMEmbeddingを作成しました
🔍 KoogのLLMEmbedderを使ってクエリに関連するチャンクを検索中: 'レシピ名 材料 分量'
  クエリのEmbedding生成中...
⏱️ チャンクの内容:
  1. ー〉…………………小さじ1
ギャバン15gクミン〈パウダー〉………………………小さじ1
ギャバン15gコリアンダー〈パウダー〉………………小さじ1
A
基本のキーマカレー
タクコで作る!
...
  2. ❶玉ねぎはみじん切り、トマトはざく切り、
にんにくとしょうがはみじん切りにする。
❷フライパンにサラダ油を入れ、①のにんにく、しょうが、
玉ねぎを中~強火でこげ茶色になるまで炒める。(約10分...

🔍 料理時間関連のチャンクを検索中...
  クエリのEmbedding生成中...
🤖 AIAgentとKoogのstructured outputを使ってRecipeEntityを抽出中...
⏱️ チャンクの内容:
  1. ー〉…………………小さじ1
ギャバン15gクミン〈パウダー〉………………………小さじ1
ギャバン15gコリアンダー〈パウダー〉………………小さじ1
A
基本のキーマカレー
タクコで作る!
...
  2. ❶玉ねぎはみじん切り、トマトはざく切り、
にんにくとしょうがはみじん切りにする。
❷フライパンにサラダ油を入れ、①のにんにく、しょうが、
玉ねぎを中~強火でこげ茶色になるまで炒める。(約10分...

🛠️ === sumMinutesツールが呼び出されました! ===
🕐 ツール実行結果: 10.0 + 2.0 + 2.0 = 分
✅ AIAgent RecipeEntity抽出完了
抽出結果: '''json
{
  "name": "基本のキーマカレー",
  "ingredients": [
    {
      "name": "牛豚ひき肉(または豚肉、鶏もも肉、ラム肉などのひき肉)",
      "quantity": 500,
      "unit": "グラム"
    },
    {
      "name": "玉ねぎ",
      "quantity": 1,
      "unit": "個"
    },
    {
      "name": "トマト(またはカットトマト缶200g)",
      "quantity": 1,
      "unit": "個"
    },
    {
      "name": "にんにく",
      "quantity": 1,
      "unit": "片"
    },
    {
      "name": "しょうが",
      "quantity": 1,
      "unit": "かけ"
    },
    {
      "name": "サラダ油",
      "quantity": 1,
      "unit": "大さじ"
    },
    {
      "name": "プレーンヨーグルト(無糖)",
      "quantity": 4,
      "unit": "大さじ"
    },
    {
      "name": "塩",
      "quantity": 1,
      "unit": "小さじ"
    },
    {
      "name": "ギャバン15gクミン〈パウダー〉",
      "quantity": 1,
      "unit": "小さじ"
    },
    {
      "name": "ギャバン15gコリアンダー〈パウダー〉",
      "quantity": 1,
      "unit": "小さじ"
    }
  ]
}
'''

✅ === ワークフロー完了! ===
📊 実行結果:

📋 RecipeEntityオブジェクト:
RecipeEntity(
  name = "基本のキーマカレー",
  ingredients = listOf(
    Ingredient("牛豚ひき肉(または豚肉、鶏もも肉、ラム肉などのひき肉)", "グラム", 500.0f),
    Ingredient("玉ねぎ", "個", 1.0f),
    Ingredient("トマト(またはカットトマト缶200g)", "個", 1.0f),
    Ingredient("にんにく", "片", 1.0f),
    Ingredient("しょうが", "かけ", 1.0f),
    Ingredient("サラダ油", "大さじ", 1.0f),
    Ingredient("プレーンヨーグルト(無糖)", "大さじ", 4.0f),
    Ingredient("塩", "小さじ", 1.0f),
    Ingredient("ギャバン15gクミン〈パウダー〉", "小さじ", 1.0f),
    Ingredient("ギャバン15gコリアンダー〈パウダー〉", "小さじ", 1.0f),
  )
)

⌚️ 調理時間: 14.0分


🎉 === KoogのAIStrategy Graphテスト完了! ===

BUILD SUCCESSFUL in 23s
2 actionable tasks: 2 executed
Configuration cache entry reused.

また、料理のレシピではないダミーのPDF(例)を指定すると、ちゃんと判定してワークフローの処理を終了してくれます。やったぜ。

$ ./gradlew run
Reusing configuration cache.

> Task :app:run
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.

🔄 === Koogワークフロー実行開始 ===

📋 PDFの内容を判定中: https://www.pref.aichi.jp/kenmin/shohiseikatsu/education/pdf/student_guide.pdf
PDFを画像に変換中...
PDF画像変換完了: 192547バイト
🔍 LLMレスポンス: '''json
{
  "isRecipe": false,
  "reason": "画像には料理のレシピに関する情報(料理名、材料、作り方、調理時間など)が含まれていません。ダミーテキストのみが記載されています。"
}
'''
🔍 判定結果: ❌ レシピ以外
📝 理由: 画像には料理のレシピに関する情報(料理名、材料、作り方、調理時間など)が含まれていません。ダミーテキストのみが記載されています。
❌ このPDFは料理のレシピではありません。処理を終了します。

✅ === ワークフロー完了! ===
📊 実行結果:
❌ RecipeEntityの抽出に失敗しました


🎉 === KoogのAIStrategy Graphテスト完了! ===

BUILD SUCCESSFUL in 8s
2 actionable tasks: 2 executed
Configuration cache entry reused.

(おまけ)他のフレームワークと比較

主要なAIエージェントフレームワークのライブラリである、LangChain4j(Kotlin)とLangChain(Python)とMastra(TypeScript)で、だいたい同じようなことをしたコードを参考までに置いておきます👇️ いずれも、サンプルコードの同じリポジトリ内にコードが置いてあるので、気になる方は合わせてご覧ください!

LangChain(Python)
LangChain4j(Kotlin)
Mastra(TypeScript)

感想

私がまだスタンスを取れるほどAIエージェントフレームワークに精通していない前提で、触ってみた雑多な感想を置いておきます👇️

  • 後発ではあるが、AIエージェント開発やワークフロー構築に必要そうな機能はおおよそ揃っていそう 🙆
    • まだ不足している機能はあり、0.3.0時点ではGrounding(Webで検索する機能)はなさそう, 先述の通りPDFファイル送信もなかった -> これからに期待!
  • (Kotlinの話になるが)型があり、問題があればコンパイルでチェックしてくれるのは🙆
    • 例えばPythonでLangChainやってると、mypyの型ヒント使っても実行時にすり抜けてしまったりするので...
  • (最近リリースされたライブラリなので)LLMのカットオフ以前の情報にKoogのノウハウ含まれておらず、AIでいい感じにしづらい
    • そのうちモデルが新しくなる & MCPのドキュメントが提供されれば解決しそう
    • MCPドキュメントはLangChainMastraにはあったりする
  • (Koogに限らず)グラフ作るのはやっぱり大変
    • nodeやedgeを作って登録しているときは、求めている型を意識しながら型パズルしてた
    • ワークフローがまだ定まってない時点でワークフロー作ると変更が大変そう
    • MastraのWorkflowはメソッドチェーンで直感的に作れて、個人的には良いと感じる
  • ドキュメントが割と充実していて、JetBrains公式が開発する安心感はある
    • Langchain4jもKotlinで使えるので競合となりそうだが、LangChain公式ではない点で差別化はできそう
    • 他方、developに取り込まれたcommitがCI落ちがち(2025/08/08時点)なのは少し気になった

まとめ

  • JetBrains公式のAIエージェントフレームワークが出たぞ
  • 基本的な機能はだいたい揃ってて使えそうだぞ
  • 厳密な型システムの上でAIエージェントやれるのはいいぞ
スマートラウンド テックブログ

Discussion