🔧

Koog実践編 - AIエージェントフレームワークで外部ツールを実装してみた

に公開

はじめに

前回の記事「Koog入門 - KotlinでAIエージェントを実装してみた」では、Koogの基本的な使い方と単一実行エージェントの実装について解説しました。今回は第2回として、AIエージェントにツール(外部機能)を追加する方法にフォーカスします。

KoogのツールシステムはAIエージェントに「手足」を与えるようなもので、天気情報の取得やニュース検索、データベース操作など、様々な外部機能を呼び出せるようになります。

なぜツールが必要なのか?

LLM(Large Language Model)は優れた対話能力を持っていますが、単体では以下のような制限があります。

  • リアルタイムな情報にアクセスできない
  • 外部APIやデータベースと連携できない
  • 計算や変換処理など、正確な処理が苦手

これらの制限を解決するのが「ツール」です。

Koogの3つのツール実装アプローチ

Koogでは、用途に応じて3つの異なるツール実装方法を提供しています。

1. ビルトインツール(Built-in Tools)

Koogがあらかじめ提供している、エージェントとユーザーの対話管理用ツールです。

  • SayToUser - エージェントからユーザーへメッセージを送信
  • AskUser - ユーザーに質問して入力を待つ
  • ExitTool - 会話を終了してセッションを終了

これらは基本的な対話機能を提供し、カスタムツールと組み合わせて使用します。

2. アノテーションベースツール(Annotation-based Tools)

既存の関数を簡単にツール化できる、宣言的なアプローチです。

基本的な実装手順

  1. ToolSetインターフェースを実装
  2. メソッドに@Toolアノテーションを付与
  3. @LLMDescriptionで説明を追加

主要なアノテーション

アノテーション 用途 使用例
@Tool メソッドをツールとして公開 @Tool fun myFunction()
@Tool(customName) カスタム名でツールを公開 @Tool("get_weather")
@LLMDescription LLMへの説明文を付与 @LLMDescription("天気を取得")

ベストプラクティス

  • 明確な説明文 - ツール、パラメータ、戻り値の目的と動作を明確に説明
  • 全パラメータに説明 - すべてのパラメータに@LLMDescriptionを付与
  • 一貫した命名規則 - 直感的で統一された命名規則を使用
  • 関連ツールのグループ化 - 同じToolSetに関連ツールをまとめ、クラスレベルの説明を提供
  • 情報量の多い戻り値 - 操作結果が明確にわかる戻り値を返す
  • 優雅なエラー処理 - エラーハンドリングを含め、情報量の多いエラーメッセージを返す
  • デフォルト値の文書化 - パラメータのデフォルト値は説明文で明記
  • タスクの焦点化 - 各ツールは特定の明確なタスクに集中させる

適用例 - データベース操作群、外部API連携群、既存サービスクラスのツール化など

3. クラスベースツール(Class-based Tools)

より細かい制御が必要な場合に使用する、プログラマティックなアプローチです。

SimpleTool

テキストベースの結果を返すツール向けの簡潔な実装方法です。

必須コンポーネント

コンポーネント 説明
型パラメータ <Args> - 引数型を定義
argsSerializer 引数のシリアライザー
descriptor ツールのメタデータ定義(名前、説明、パラメータ)
doExecute() ツールのロジック実装(String型を返す)

適用例 - UUID生成、日時変換、簡単な計算処理など

Tool

複雑な結果型が必要な場合の完全カスタマイズ可能な実装方法です。

必須コンポーネント

コンポーネント 説明
型パラメータ <Args, Result> - 引数と結果型を定義
argsSerializer 引数のシリアライザー
descriptor ツールのメタデータ定義(名前、説明、パラメータ)
execute() ツールのロジック実装(Result型を返す)
encodeResultToString() 結果を文字列に変換するメソッド

適用例 - ファイル処理、画像変換、複雑なデータ変換など

今回は、主にアノテーションベースツールとクラスベースツールの実装方法を実際のコードで解説していきます。

実装例1: アノテーションベースツール(天気情報ツール)

最初に、アノテーションベースのToolSetアプローチを使って、天気情報を取得するツールを実装してみましょう。

注意: このツールを実際に動かすには、OpenWeatherMap APIキーが必要です。
OpenWeatherMapで無料アカウントを作成してAPIキーを取得し、環境変数に設定してください。

WeatherTools.kt

@Component
@LLMDescription("天気情報を取得するツール群")  // クラスレベルの説明を提供
class WeatherTools(
    private val apiConfig: ApiConfig,
    private val httpClient: HttpClientService
) : ToolSet {  // ToolSetインターフェースを実装して関連ツールをグループ化

    @Tool  // メソッドをツールとして公開
    @LLMDescription("指定された都市の現在の天気情報を取得します")  // ツールの用途を明確に説明
    suspend fun getWeather(
        @LLMDescription("天気を取得したい都市名(日本語または英語)") city: String  // パラメータに説明を付与
    ): String {
        return try {
            val weather = fetchWeather(city)
            formatWeatherResponse(weather)
        } catch (e: Exception) {
            // ユーザーフレンドリーなエラーメッセージを返す
            "天気情報の取得に失敗しました: ${e.message}"
        }
    }
    
    private suspend fun fetchWeather(city: String): WeatherApiResponse {
        val url = "${apiConfig.openweatherBaseUrl}/weather?" +
                "q=$city" +
                "&appid=${apiConfig.openweatherApiKey}" +
                "&units=metric" +
                "&lang=ja"
        
        return httpClient.get(url, emptyMap(), WeatherApiResponse::class.java)
    }
    
    private fun formatWeatherResponse(weather: WeatherApiResponse): String {
        val tempCelsius = weather.main.temp.roundToInt()
        val feelsLike = weather.main.feelsLike.roundToInt()
        val weatherDesc = weather.weather.firstOrNull()?.description ?: "不明"
        val humidity = weather.main.humidity
        val windSpeed = weather.wind?.speed ?: 0.0
        
        // 構造化された見やすい形式で結果を返す
        return buildString {
            appendLine("【${weather.cityName}の現在の天気】")
            appendLine("天気: $weatherDesc")
            appendLine("気温: ${tempCelsius}°C(体感温度: ${feelsLike}°C)")
            appendLine("湿度: $humidity%")
            appendLine("風速: ${windSpeed}m/s")
            
            if (weather.clouds != null) {
                appendLine("雲量: ${weather.clouds.all}%")
            }
        }
    }
}

ポイント

  • ToolSetインターフェースを実装して関連する天気ツールをグループ化
  • @Tool@LLMDescriptionアノテーションでツールとパラメータを明確に定義
  • エラー処理により、API呼び出し失敗時もユーザーフレンドリーなメッセージを返す

実装例2: クラスベースツール - SimpleTool(UUID生成ツール)

次に、クラスベースツールのSimpleToolを使って、UUID生成ツールを実装してみましょう。

UUIDGeneratorTool.kt

object UUIDGeneratorTool : SimpleTool<UUIDGeneratorTool.Args>() {
    // 引数型の定義
    @Serializable
    data class Args(
        val count: Int = 1,
        val format: String = "standard"
    ) : ToolArgs

    // シリアライザーの定義
    override val argsSerializer = kotlinx.serialization.serializer<Args>()

    // ツールのメタデータ定義
    override val descriptor = ToolDescriptor(
        name = "uuid_generator",
        description = "UUID(Universally Unique Identifier)を生成します",
        optionalParameters = listOf(
            ToolParameterDescriptor(
                name = "count",
                description = "生成するUUIDの個数(1-10、デフォルト: 1)",  // デフォルト値と有効範囲を明記
                type = ToolParameterType.Integer
            ),
            ToolParameterDescriptor(
                name = "format",
                description = "UUIDのフォーマット(standard, compact, uppercase)",  // 有効な値を明記
                type = ToolParameterType.String
            )
        )
    )

    // ツールのロジック実装(String型を返す)
    override suspend fun doExecute(args: Args): String {
        return try {
            // 入力値の検証
            val count = args.count.coerceIn(1, 10)

            // UUID生成という単一タスクに特化
            val uuids = List(count) {
                val uuid = UUID.randomUUID().toString()
                formatUUID(uuid, args.format)
            }

            // 構造化された見やすい結果を返す
            buildString {
                appendLine("【UUID生成結果】")
                appendLine("生成数: $count")
                appendLine("フォーマット: ${args.format}")
                appendLine()
                uuids.forEachIndexed { index, uuid ->
                    appendLine("${index + 1}. $uuid")
                }
            }.trim()
        } catch (e: Exception) {
            // エラー時も情報量の多いメッセージを返す
            "UUID生成エラー: ${e.message}"
        }
    }

    private fun formatUUID(uuid: String, format: String): String {
        return when (format.lowercase()) {
            "compact" -> uuid.replace("-", "")
            "uppercase" -> uuid.uppercase()
            else -> uuid  // 不明な形式はデフォルト(standard)を使用
        }
    }
}

ポイント

  • SimpleToolを継承してテキストベースの結果を返すツールを実装
  • descriptorでツールメタデータ、doExecute()でロジックを定義
  • 入力値の検証により不正な値を防ぎ、構造化された結果を返す

実装例3: クラスベースツール - Tool(Base64エンコーダー)

最後に、より高度なカスタマイズが必要な場合のToolクラスの直接継承を見てみましょう。

Base64EncoderTool.kt

/**
 * Tool抽象クラスを直接継承したクラスベースのツール実装例
 * より柔軟な実装が必要な場合に使用
 */
object Base64EncoderTool : Tool<Base64EncoderTool.Args, Base64EncoderTool.Result>() {

    // 引数型の定義
    @Serializable
    data class Args(
        val text: String,
        val operation: String = "encode",
        val urlSafe: Boolean = false
    ) : ToolArgs

    // カスタム結果型の定義
    @Serializable
    data class Result(
        val originalText: String,
        val processedText: String,
        val operation: String,
        val urlSafe: Boolean,
        val originalLength: Int,
        val processedLength: Int
    ) : ToolResult {
        // 結果の表示形式を完全に制御
        override fun toStringDefault(): String {
            return """
                【Base64処理結果】
                操作: $operation
                URLセーフ: ${if (urlSafe) "はい" else "いいえ"}

                元のテキスト(${originalLength}文字):
                $originalText

                処理後のテキスト(${processedLength}文字):
                $processedText
            """.trimIndent()
        }
    }

    // シリアライザーの定義
    override val argsSerializer = kotlinx.serialization.serializer<Args>()

    // ツールのメタデータ定義
    override val descriptor = ToolDescriptor(
        name = "base64_encoder",
        description = "テキストをBase64形式でエンコード/デコードします。URLセーフなエンコーディングもサポートしています。",
        // 必須パラメータとオプションパラメータを明確に分離
        requiredParameters = listOf(
            ToolParameterDescriptor(
                name = "text",
                description = "エンコード/デコードするテキスト",
                type = ToolParameterType.String
            )
        ),
        optionalParameters = listOf(
            ToolParameterDescriptor(
                name = "operation",
                description = "実行する操作(encode または decode)",  // 有効な値を明記
                type = ToolParameterType.String
            ),
            ToolParameterDescriptor(
                name = "urlSafe",
                description = "URLセーフなBase64を使用するか(trueまたはfalse)",  // 型と有効な値を明記
                type = ToolParameterType.Boolean
            )
        )
    )

    // ツールのロジック実装(Result型を返す)
    override suspend fun execute(args: Args): Result {
        val encoder = if (args.urlSafe) {
            Base64.getUrlEncoder()
        } else {
            Base64.getEncoder()
        }

        val decoder = if (args.urlSafe) {
            Base64.getUrlDecoder()
        } else {
            Base64.getDecoder()
        }

        return try {
            // 不正な操作タイプをチェック
            val processedText = when (args.operation.lowercase()) {
                "encode" -> {
                    encoder.encodeToString(args.text.toByteArray())
                }
                "decode" -> {
                    String(decoder.decode(args.text))
                }
                else -> throw IllegalArgumentException("操作は 'encode' または 'decode' である必要があります")
            }

            // 複数のフィールドで詳細情報を提供
            Result(
                originalText = args.text,
                processedText = processedText,
                operation = args.operation,
                urlSafe = args.urlSafe,
                originalLength = args.text.length,
                processedLength = processedText.length
            )
        } catch (e: IllegalArgumentException) {
            throw Exception("Base64処理エラー: ${e.message}")
        }
    }

    // 結果を文字列に変換
    override fun encodeResultToString(result: Result): String {
        return result.toStringDefault()
    }
}

ポイント

  • Tool<Args, Result>を継承してカスタム結果型を持つツールを実装
  • 必須パラメータとオプションパラメータを明確に分離
  • Result型により元のテキストや文字数などのメタ情報も含めた詳細な結果を提供

エージェントへのツール統合

ここまで作成した個別のツールは、それ単体では動作しません。KoogのAIエージェントに統合することで、LLMがツールを認識し、必要に応じて自動的に呼び出せるようになります。

ToolRegistryを使用することで、複数のツールを一元管理し、エージェントが利用可能なツールのカタログを作成します。

ToolAgent.kt

@Component
class ToolAgent(
    private val config: Phase2Config,
    private val weatherTools: WeatherTools,
    private val newsTools: NewsTools,
    @param:Value("\${api.google-api-key}")
    private val googleApiKey: String,
) {
    // ToolRegistryを作成してツールを登録
    private val toolRegistry = ToolRegistry {
        // ビルトインツール
        tool(AskUser)
        tool(SayToUser)

        // カスタムツール(反射ベースのツールセット)
        tools(weatherTools.asTools())
        tools(newsTools.asTools())
        
        // SimpleToolベースのツール
        tool(UUIDGeneratorTool)
        
        // Toolクラスベースのツール
        tool(Base64EncoderTool)
    }

    // ToolRegistryを使用してAIAgentを作成
    private fun createAgent() = AIAgent(
        llmModel = config.llmModel,
        executor = simpleGoogleAIExecutor(googleApiKey),
        systemPrompt = config.systemPrompt,
        temperature = config.temperature,
        toolRegistry = toolRegistry,
        maxIterations = 10
    )

    suspend fun process(userMessage: String): String {
        return try {
            val agent = createAgent()
            val response = agent.run(userMessage)
            response
        } catch (e: Exception) {
            "エラーが発生しました: ${e.message}"
        }
    }
}

REST APIでの公開

作成したツール統合エージェントを実際に使用するために、REST APIとして公開します。これにより、Webアプリケーションやモバイルアプリ、他のサービスからHTTPリクエストを通じてエージェントと対話できるようになります。

Spring Bootのコントローラーを使用することで、簡単にエンドポイントを作成できます。

ToolController.kt

@RestController
@RequestMapping("/api/phase2/tools")
class ToolController(
    private val toolAgent: ToolAgent,
) {
    @PostMapping("/chat")
    fun chat(@RequestBody request: ToolRequest): ResponseEntity<ToolResponse> = runBlocking {
        return@runBlocking try {
            val response = toolAgent.process(request.message)
            ResponseEntity.ok(ToolResponse(response = response))
        } catch (e: Exception) {
            ResponseEntity.internalServerError()
                .body(ToolResponse(response = "エラーが発生しました: ${e.message}"))
        }
    }
}

使用例

実装したツールを実際に試してみましょう。エージェントは自然言語の要求を理解し、適切なツールを自動的に選択して実行します。

天気情報の取得

curl -X POST http://localhost:8080/api/phase2/tools/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "東京都の天気を教えて"}'

レスポンス

{
  "response": "東京都の現在の天気は薄い雲、気温は30°C(体感温度32°C)、湿度は55%、風速は6.5m/s、雲量は15%です。\n"
}

エージェントは「天気を教えて」という要求から、WeatherToolsのgetWeatherメソッドを呼び出すことを判断し、OpenWeatherMap APIから情報を取得して整形して返しています。

UUID生成

curl -X POST http://localhost:8080/api/phase2/tools/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "UUIDを3つ生成して、大文字形式で"}'

レスポンス

{
  "response": "大文字形式でUUIDを3つ生成しました。結果は以下の通りです。\n\n1. 92837379-7CE8-4254-9D8D-21A9BF855161\n2. F6789A55-CA3A-4E15-B09E-18834D2EC758\n3. AE9E6BC8-D242-469E-8028-A7E81D6DEB73"
}

「3つ」「大文字形式」という条件を理解し、UUIDGeneratorToolに適切なパラメータ(count=3、format=uppercase)を渡しています。

Base64エンコード

curl -X POST http://localhost:8080/api/phase2/tools/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "「Hello, Koog!」をBase64エンコードして"}'

レスポンス

{
  "response": "Hello, Koog!をBase64エンコードした結果は、SGVsbG8sIEtvb2ch です。\n"
}

複数ツールの連携

エージェントは複数のツールを組み合わせて使用することも可能です。

curl -X POST http://localhost:8080/api/phase2/tools/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "東京都の天気を調べて、その結果を文字列としてBase64エンコードして"}'

レスポンス

{
  "response": "東京都の天気に関する情報をBase64エンコードした結果は以下の通りです。\n\n5p2x5Lqs6YO944Gu5aSp5rCX44Gv5bCP6Zuo44CB5rCX5rip44GvMjjCsEPjgIHkvZPmhJ/muKnluqbjga8zMsKwQ+OAgea5v+W6pjc0JeOAgemiqOmAnzkuMjhtL3PjgIHpm7Lph48xMDAl44Gn44GZ44CC\n"
}

このように、エージェントは

  1. まずWeatherToolsで天気情報を取得(「東京都の天気は小雨、気温は28°C...」)
  2. その結果をBase64EncoderToolでエンコード
  3. エンコードされた文字列(日本語がBase64形式に変換されたもの)をユーザーに返す

という複数ステップの処理を自動的に実行します。

どのアプローチを選ぶべきか?

それぞれのアプローチには適した使用場面があります。

アプローチ 使用場面 特徴 適用例
ビルトインツール エージェントとユーザーの対話 ・Koogが提供済み
・設定不要
・基本的な対話機能
SayToUser、AskUser、ExitTool
アノテーションベース
(ToolSet)
関連ツールをグループ化したい ・宣言的な実装
@Toolアノテーションのみ
・既存メソッドを活用
天気ツール群、DB操作群、外部API連携
クラスベース
(SimpleTool)
単一ツールを簡潔に実装したい ・String型の結果を返す
・実装が簡潔
doExecute()メソッドのみ
UUID生成、日時変換、文字列処理
クラスベース
(Tool)
複雑な結果型が必要 ・カスタム結果型を定義可能
execute()で完全制御
・結果の表示形式もカスタマイズ
ファイル処理、画像変換、複雑なデータ変換

まとめ

今回は、Koogの3つのツール実装アプローチについて解説しました。

  • ビルトインツール - 基本的な対話機能
  • アノテーションベースツール - 既存クラスを簡単にツール化
  • クラスベースツール - 高度なカスタマイズが可能

それぞれの特徴を理解し、用途に応じて使い分けることで、効率的にAIエージェントを拡張できます。ツールを追加することで、LLMの制限を超えて、実用的なアプリケーションを構築できるようになります。

サンプルコード

今回の実装コードは以下のリポジトリで公開しています。

参考資料

Discussion