[Kotlin] JetBrains公式AIエージェント"Koog"の0.4.0出たから触る
はじめに
スマートラウンドで野生のAIエンジニアをやっている福本です🐈
普段はLangChainやMastraを使ったAIワークフロー/エージェントの新規開発や運用をやっています。
JetBrainsが公式で提供するAIエージェント開発フレームワークKoogが発表され、それに関する以下の記事を書きました📝
この記事を書いたときのバージョンは記事に記載の通り0.3.0だったのですが、8/28に0.4.0がリリースされました。 時が経つのがはやい(爺)。
前回の記事を書いた際に個人的に色々調べたのもあり、0.4.0も少し触ってみよう!ということで今回も感想メモを書いておきます。
0.4.0の主なリリース内容
0.4.0のリリース内容は先ほどの公式リリース記事がわかりやすいです。他にはKoogのリポジトリのリリース内容を見ておくのも良さそうです👇️
見るのがめんどくさい人向けにまとめたものが以下です。参考になれば!
使用する新機能
リリースされた新しい機能のうち、今回は以下の機能を使いました 🔧
- Ktor Integration
- Native Structured Output
- GPT-5 Model
- KMP iOS Target
- リトライ強化(production-grade retries)
- (後述, 修正された)PDF直接読み込み
また、以下の機能は使っていませんが、試してみた結果があるのでそちらも合わせて共有します。
- DeepSeek Model
実装
やること/テーマ
基本的には前回作成したレシピPDFの解析を改善していきます。それとは別に、新規に以下のコードを書いていきます📝
- WebアプリでPDFを渡して、それがレシピかどうかを判定して返す
- iOSアプリでチャットをする
利用バージョン
今回利用したライブラリとそのバージョンを記載しておきます👇️
- Kotlin: 2.2.0
- Java: 21
- Koog: 0.4.1
- リリース記事の通りiOS Targetが0.4.1実装なのでこちらを利用
- Ktor: 3.0.2
- XCode: 16.4
サンプルコードは以下のRepositoryにあります👇️
概要
それぞれの処理の概要を以下に示します👇️いずれも、記事で一覧して見やすいように、意図的に1ファイルに処理をまとめています。
レシピ解析
以下のようなワークフローが実行される想定です(前回から変わらず)
上記のワークフローをKoogで定義し、それを実行するKotlinコードになります。gradleコマンドからmain()
が実行されるイメージです。
(Ktor)レシピ判定
作成したKtorのApplication.kt
が以下となります。Application.configureRouting()
で処理がルーティングされます。
(KMP)iOSチャットアプリ
iOS targetsを含んだbuild.gradle.kts
が以下となります。
また、以下でKoogを用いてLLMと通信しています。
詳細
ここでは、具体的に使用した新機能のコードと所感を書いていきます!
ModelをGPT-5Miniに
以下のように素直に model
にOpenAIModels.Chat.GPT5Mini
を選べばOKです 💪
val agentConfig = AIAgentConfig(
prompt = prompt("recipe-extraction") {
system("""
あなたは料理レシピの専門家です。
提供された文書からレシピ情報を正確に抽出してください。
抽出する情報:
- レシピ名(料理の名前)
- 材料リスト(材料名、数量、単位)
注意事項:
- 数量は数値として正確に抽出してください
- 単位は日本語で記載してください(グラム、個、本、カップ、大さじ、小さじなど)
- 文書に記載されている情報のみを抽出してください
""".trimIndent())
},
model = OpenAIModels.Chat.GPT5Mini,
maxAgentIterations = 10
)
ちなみに、OpenAIModels.kt
で利用可能なモデル一覧が機能やコストとともに書かれています👇️使うモデルを選ぶ際はこちらも見てみると良さそうです。
PDFファイルを直接LLMに送信 + Native Structured Output
これはメインのリリースではなく、既存の機能の改善となります 👍 前回の記事でも書いた通り、0.3.0まではPDFファイルを直接LLMに送信できませんでした。
これがしっかり改善されていて、以下のように prompt
にattachment
を指定すればOKになりました。やったーーーーーーーー
// 3. ノード定義:レシピ判定(KoogのStructured Output使用)
val validateRecipePdf by node<PdfUrl, ValidatedPdfContent>("validate-recipe-pdf") { pdfUrl ->
println("\n📋 PDFの内容を判定中: ${pdfUrl.url}")
try {
val pdfBytes = pdfService.downloadPdf(pdfUrl.url)
// Koogの構造化出力でPDF判定(Strategy内でrequestLLMStructuredを使用)
val validationStructure = JsonStructuredData.createJsonStructure<PdfValidationResult>(
examples = PdfValidationResult.getExampleValidations()
)
val validation = try {
val result = llm.writeSession {
updatePrompt {
user(
content = "添付されたPDFファイルから、料理のレシピに関する情報が含まれているかを判定してください。",
attachments = listOf(
Attachment.File(
content = AttachmentContent.Binary.Bytes(pdfBytes),
format = "pdf",
mimeType = "application/pdf",
fileName = "recipe.pdf"
)
)
)
}
requestLLMStructured(
config = StructuredOutputConfig(
default = StructuredOutput.Manual(validationStructure),
fixingParser = StructureFixingParser(
fixingModel = OpenAIModels.Chat.GPT5Mini,
retries = 3
)
)
)
}
result.getOrThrow().structure
} catch (e: Exception) {
println("❌ Strategy内PDF判定でエラーが発生: ${e.message}")
PdfValidationResult(true, "PDF判定に失敗したため、処理を続行します")
}
println("🔍 判定結果: ${if (validation.isRecipe) "✅ レシピPDF" else "❌ レシピ以外"}")
println("📝 理由: ${validation.reason}")
storage.set(validationKey, validation)
ValidatedPdfContent(pdfUrl.url, pdfBytes, validation.isRecipe)
} catch (e: Exception) {
println("❌ PDF内容判定でエラーが発生: ${e.message}")
throw e
}
}
ちなみに、Structured Outputを利用するためにrequestLLMStructuredメソッドを使っており、これで新機能リリースに書かれたNative Structured Outputが使えます(Non-Nativeとの比較はできてませんが...)。
Ktor Integration
次はみんなお待ちかね(?)、KoogとKtorを連携する部分です。公式ドキュメントは以下👇️
まず、Application.ktで以下のようにApplication.configureKoog()
でKoogのLLM,AIAgentの設定を行います 🔧
fun Application.configureKoog() {
println("⚙️ Koogプラグインを設定中...")
val dotenv = dotenv {
directory = "../../"
ignoreIfMalformed = true
ignoreIfMissing = false
}
val apiKey = dotenv["OPENAI_API_KEY"]
?: throw IllegalStateException("OPENAI_API_KEY環境変数が設定されていません")
println("🔑 OpenAI APIキーを読み込み完了")
install(Koog) {
llm {
openAI(apiKey = apiKey)
}
agentConfig {
maxAgentIterations = 10
prompt {
system("あなたは料理レシピの専門家です。PDFドキュメントの内容を分析し、レシピが含まれているかを正確に判定してください。")
}
}
}
println("✅ Koogプラグイン設定完了")
}
Ktorの設定ができたら、実行したいワークフロー(KoogではStrategyと呼びます)を作ります。作り方は通常のワークフローと同じで、strategy<Inputの型, Outputの型>
を返すようにします。
private fun pdfValidationReActStrategy() = strategy<String, PdfValidationResult>("pdf-validation-react") {
val pdfService = PdfService()
// PDF解析ノード:PDFをダウンロードして内容を抽出し解析
val downloadAndAnalyzePdf by node<String, PdfValidationResult>("download-analyze-pdf") { pdfUrl ->
println("📄 PDFをダウンロードしてテキスト抽出中: $pdfUrl")
try {
// PDFをダウンロード
val pdfBytes = pdfService.downloadPdf(pdfUrl)
println("✅ PDFダウンロード完了: ${pdfBytes.size} bytes")
// PDFからテキストを抽出
val extractedText = pdfService.extractTextFromPdf(pdfBytes)
println("✅ テキスト抽出完了: ${extractedText.length} 文字")
// PdfValidationResultの構造化出力設定
val validationStructure = JsonStructuredData.createJsonStructure<PdfValidationResult>(
examples = PdfValidationResult.getExampleValidations()
)
// llm.writeSessionでStructured Outputを使用してPDF内容を解析
val validation = llm.writeSession {
updatePrompt {
system("""
あなたは料理レシピの専門家です。
提供されたPDFの実際のテキスト内容を分析し、料理レシピが含まれているかを正確に判定してください。
判定基準:
- 材料リスト(例:小麦粉、砂糖、卵など)が含まれている
- 調理手順(例:混ぜる、焼く、煮るなど)が含まれている
- 料理名が明記されている
- 分量や調理時間の記載がある
以下の構造で判定結果を回答してください:
- isRecipe: true/false (レシピかどうか)
- reason: 判定理由(具体的にどの要素が見つかったか、または見つからなかったかを説明)
""".trimIndent())
user("以下のPDFテキスト内容を分析してください:\n\n${extractedText.take(3000)}")
}
requestLLMStructured(
config = StructuredOutputConfig(
default = StructuredOutput.Manual(validationStructure),
fixingParser = StructureFixingParser(
fixingModel = OpenAIModels.Chat.GPT5Mini,
retries = 3
)
)
)
}
validation.getOrThrow().structure
} catch (e: Exception) {
println("❌ PDF解析でエラーが発生: ${e.message}")
PdfValidationResult(false, "PDF解析に失敗しました: ${e.message}")
}
}
// ワークフローエッジ定義
edge(nodeStart forwardTo downloadAndAnalyzePdf)
edge(downloadAndAnalyzePdf forwardTo nodeFinish)
}
そして、作成したstrategy
を aiAgentに渡し、実行結果を call.respond()にツッコめばOK👌
これで、ワークフローの結果がKtorのバックエンドからレスポンスとして返ってきます。便利ィーーーー
fun Application.configureRouting() {
routing {
get("/") {
call.respond(FreeMarkerContent("index.ftl", mapOf("title" to "Koog PDF Recipe Analyzer")))
}
post("/analyze") {
try {
val request = call.receive<AnalyzeRequest>()
println("📄 PDF解析リクエストを受信: ${request.pdfUrl}")
println("🤖 Koog Structured Outputで解析開始...")
// KoogのaiAgent関数でカスタムストラテジーを使用してPDF解析を実行
val validationResult = aiAgent(
strategy = pdfValidationReActStrategy(),
model = OpenAIModels.Chat.GPT5Mini,
input = request.pdfUrl
)
println("🧠 構造化された解析結果: $validationResult")
println("✅ 解析完了 - レシピ判定: ${validationResult.isRecipe}, 理由: ${validationResult.reason}")
// aiAgentの結果を直接レスポンスとして送信
call.respond(HttpStatusCode.OK, validationResult)
} catch (e: Exception) {
println("❌ PDF解析エラー: ${e.message}")
e.printStackTrace()
call.respond(HttpStatusCode.BadRequest, ErrorResponse("PDF解析に失敗しました: ${e.message}"))
}
}
}
}
KMP iOS target & production-grade retries
KMP(Kotlin Multiplatform)によるiOSアプリで、Koogを使ってみましょう。次の通りbuild.gradle.kts
でKoogやKMP,iOS target関連のコードを追加しておきます。
plugins {
kotlin("multiplatform") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
}
kotlin {
// iOS targets
iosX64()
iosArm64()
iosSimulatorArm64()
// iOS framework configuration
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "KoogChat"
isStatic = true
}
}
// JVM target for testing
jvm()
sourceSets {
commonMain {
dependencies {
// Koog dependencies for KMP
implementation("ai.koog:prompt-executor-openai-client:0.4.1")
implementation("ai.koog:prompt-llm:0.4.1")
implementation("ai.koog:prompt-model:0.4.1")
// Kotlinx serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
// Ktor client for HTTP requests
implementation("io.ktor:ktor-client-core:3.0.2")
implementation("io.ktor:ktor-client-content-negotiation:3.0.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.2")
}
}
iosMain {
dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.2")
}
}
jvmMain {
dependencies {
implementation("io.ktor:ktor-client-cio:3.0.2")
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
}
}
}
}
// iOS Framework configuration
kotlin.targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
binaries.all {
if (this is org.jetbrains.kotlin.gradle.plugin.mpp.Framework) {
baseName = "KoogChat"
isStatic = true
}
}
}
KoogでLLMのリクエストを実行してる箇所のコードは以下です。これまでの実装と異なり、 Strategyを使わずに素直な単発でのリクエスト送信を行う方法でLLMと通信しています 🤖
package chat
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.executor.clients.openai.OpenAIModels
import ai.koog.prompt.executor.clients.retry.RetryingLLMClient
import ai.koog.prompt.executor.clients.retry.RetryConfig
import ai.koog.prompt.dsl.prompt
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class ChatClient(private val apiKey: String) {
private val baseClient = OpenAILLMClient(apiKey = apiKey)
private val client = RetryingLLMClient(
delegate = baseClient,
config = RetryConfig.PRODUCTION
)
suspend fun sendMessage(
message: String,
chatHistory: List<ChatMessage> = emptyList()
): String {
return try {
val response = client.execute(
prompt = buildPrompt(message, chatHistory),
model = OpenAIModels.Chat.GPT4o
)
response.firstOrNull()?.content ?: "応答を取得できませんでした"
} catch (e: Exception) {
"エラーが発生しました: ${e.message}"
}
}
fun sendMessageStream(
message: String,
chatHistory: List<ChatMessage> = emptyList()
): Flow<String> = flow {
try {
val response = client.executeStreaming(
prompt = buildPrompt(message, chatHistory),
model = OpenAIModels.Chat.GPT4o
)
response.collect { chunk ->
chunk?.let { emit(it) }
}
} catch (e: Exception) {
emit("エラーが発生しました: ${e.message}")
}
}
private fun buildPrompt(message: String, chatHistory: List<ChatMessage>) = prompt("chat") {
system("""
あなたは親切で知識豊富なAIアシスタントです。
ユーザーの質問に対して、正確で役立つ回答を提供してください。
日本語で自然な会話を心がけてください。
""".trimIndent())
// チャット履歴を追加
chatHistory.takeLast(10).forEach { msg ->
if (msg.isUser) {
user(msg.content)
} else {
assistant(msg.content)
}
}
// 現在のメッセージを追加
user(message)
}
}
ちなみに、以下の箇所で0.4.0で新しく実装された production-gradeの設定がされたRetryingLLMClientを使っています👇️サンプルで実装するレベルの機能ではあまり恩恵が受けられませんが、後述に示す通りちゃんと通信されております。
private val baseClient = OpenAILLMClient(apiKey = apiKey)
private val client = RetryingLLMClient(
delegate = baseClient,
config = RetryConfig.PRODUCTION
)
DeepSeek
0.4.0のリリース記事にもある通り、DeepSeekのサポートを開始したと記載されていましたが、自分の環境で実行すると次のようなエラーが出てしまいました。
Field 'audioTokens' is required for type with serial name 'ai.koog.prompt.executor.clients.openai.models.PromptTokensDetails', but it was missing at path: $.usage.promptTokensDetails
以下のissueで試したコードや詳細をまとめてみましたので、実装の詳細が気になる方はご覧ください↓ちなみに、これを解消するPRがMergeされているので、次回のリリースではDeepSeekが問題なく使えそうな予感がします👌
実行結果
レシピ解析
前回同様、キーマカレーのPDFのURLを指定して実行した結果が以下。問題なくレシピの解析が完了していることがわかります🍛
$ ./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://kyushucgc.co.jp/recipe_pdf/202112/recipe05.pdf
🔍 判定結果: ✅ レシピPDF
📝 理由: PDFの1ページに「基本のキーマカレー」という料理名、材料(分量)、作り方の手順、調理方法の説明、料理写真が含まれているため料理レシピと判断しました
📄 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のStructured Outputを使ってRecipeEntityを抽出中...
⏱️ チャンクの内容:
1. ー〉…………………小さじ1
ギャバン15gクミン〈パウダー〉………………………小さじ1
ギャバン15gコリアンダー〈パウダー〉………………小さじ1
A
基本のキーマカレー
タクコで作る!
...
2. ❶玉ねぎはみじん切り、トマトはざく切り、
にんにくとしょうがはみじん切りにする。
❷フライパンにサラダ油を入れ、①のにんにく、しょうが、
玉ねぎを中~強火でこげ茶色になるまで炒める。(約10分...
🛠️ === sumMinutesツールが呼び出されました! ===
🕐 ツール実行結果: 10.0 + 10.0 + 2.0 + 2.0 + 2.0 = 分
✅ AIAgent Structured Output RecipeEntity抽出完了
抽出結果: RecipeEntity(name=基本のキーマカレー, ingredients=[Ingredient(name=牛豚ひき肉(または豚肉、鶏もも肉、ラム肉などのひき肉), unit=グラム, quantity=500.0), Ingredient(name=玉ねぎ, unit=個, quantity=1.0), Ingredient(name=トマト(またはカットトマト缶200g), unit=個, quantity=1.0), Ingredient(name=にんにく, unit=片, quantity=1.0), Ingredient(name=しょうが, unit=かけ, quantity=1.0), Ingredient(name=サラダ油, unit=大さじ, quantity=1.0), Ingredient(name=プレーンヨーグルト(無糖), unit=大さじ, quantity=4.0), Ingredient(name=塩, unit=小さじ, quantity=1.0), Ingredient(name=ギャバン15gクミン〈パウダー〉, unit=小さじ, quantity=1.0), Ingredient(name=ギャバン15gコリアンダー〈パウダー〉, unit=小さじ, quantity=1.0)])
✅ === ワークフロー完了! ===
📊 実行結果:
📋 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),
)
)
⌚️ 調理時間: 26.0分
🎉 === KoogのAIStrategy Graphテスト完了! ===
BUILD SUCCESSFUL in 1m 32s
3 actionable tasks: 2 executed, 1 up-to-date
Configuration cache entry reused.
(Ktor)レシピ判定
http://localhost:8080/ でWebの画面にアクセスし、キーマカレーのURLを入力して解析した結果の画面が以下です。無事にレシピ判定され、判定結果のJSONを受け取り画面に表示されていることがわかります 🎉
また、実行したコマンドとログは以下です。
$ ./gradlew web
Reusing configuration cache.
> Task :app:webRun
🚀 Koog PDF Recipe Analyzer サーバーを起動中...
📡 サーバーURL: http://localhost:8080
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プラグインを設定中...
🔑 OpenAI APIキーを読み込み完了
✅ Koogプラグイン設定完了
📄 PDF解析リクエストを受信: https://kyushucgc.co.jp/recipe_pdf/202112/recipe05.pdf
🤖 Koog Structured Outputで解析開始...
📄 PDFをダウンロードしてテキスト抽出中: https://kyushucgc.co.jp/recipe_pdf/202112/recipe05.pdf
✅ PDFダウンロード完了: 2442428 bytes
PDFからテキストを抽出中...
抽出されたテキスト長: 597文字
✅ テキスト抽出完了: 597 文字
🧠 構造化された解析結果: PdfValidationResult(isRecipe=true, reason=料理名(基本のキーマカレー)が明記されており、材料リスト(牛豚ひき肉、玉ねぎ、トマト、にんにく、しょうが、ヨーグルト、各種スパイスなど)とそれぞれの分量(500g、中1個、大1個、大さじ等)が記載されている。調理手順(みじん切り、炒める、弱火で加えるなど)や調理時間の目安(約10分、約2分)が具体的に示されているため、料理レシピと判断できる。)
✅ 解析完了 - レシピ判定: true, 理由: 料理名(基本のキーマカレー)が明記されており、材料リスト(牛豚ひき肉、玉ねぎ、トマト、にんにく、しょうが、ヨーグルト、各種スパイスなど)とそれぞれの分量(500g、中1個、大1個、大さじ等)が記載されている。調理手順(みじん切り、炒める、弱火で加えるなど)や調理時間の目安(約10分、約2分)が具体的に示されているため、料理レシピと判断できる。
<=========----> 71% EXECUTING [1m 46s]
(KMP)チャット
KMPのiOSアプリをREADMEの手順で実行すると、iOS Simulatorで以下のようなアプリが起動します。OpenAIのLLMとチャットができることがわかります。
感想
諸々触ってみた感想を以下に置いておきます!今後もKoogウォッチしていきます 👁️
- この段階でのKMP iOS TargetサポートやKtorへの組み込みをしている点に、昨今のKotlinのマルチプラットフォーム志向を強く感じた
- たまーにバグのようなものはあるが、クリティカルではない&きっちり直されているので問題なさそう
- やっぱ厳密な型システムでAIエージェントできるのありがたすぎる

株式会社スマートラウンドは『スタートアップの可能性を最大限に発揮できる世界を作る』というミッションを掲げています。スタートアップと投資家の実務を効率化するデータ作成・共有プラットフォーム『smartround』を開発・提供しています。 採用ページはこちら-> jobs.smartround.com/
Discussion