Logo
Published on

第一次學 Kotlin Koog AI 就上手 Day 09:讓 AI 更強大:開發能與外部世界互動的工具

在 Day 5 的學習中,我們掌握了 Koog 工具系統的基礎知識,學會了建立像 AddTool 這樣的本地工具。這些工具使用 suspend 關鍵字,能夠處理輸入並返回結果,非常適合數學計算、字串處理等本地運算

但在現實世界中,AI Agent 經常需要與外部服務互動,例如

  • 查詢天氣 API 獲取即時天氣資訊
  • 呼叫物流 API 查詢包裹狀態
  • 連接金融 API 取得匯率資訊
  • 使用地圖 API 規劃路線

這些操作需要透過網路呼叫外部服務,涉及網路延遲和可能的連線問題。今天我們將學習如何建立能夠與真實外部 API 互動的工具

準備 OpenWeather API

在開始建立天氣查詢工具之前,我們需要設定真實的天氣 API。我們將使用 OpenWeather 的 One Call API 3.0 來獲取實際的天氣資料

申請 OpenWeather API 金鑰

  • 前往 OpenWeather 註冊帳號
  • 申請 One Call API 3.0(免費版提供每日 1,000 次呼叫)- 需要先升級帳號才可以使用相關的 API,我是測試完就把帳號降級
  • 取得你的 API 金鑰

設定 API 金鑰管理

就如同 Day 02 的 OpenAI API 金鑰管理方式,我們為 OpenWeather API 也建立統一的管理機制

/**
 * API 金鑰管理器 - 統一管理各種 API 金鑰
 */
object ApiKeyManager {
    val openAIApiKey: String? = System.getenv("OPENAI_API_KEY")
    val openWeatherApiKey: String? = System.getenv("OPENWEATHER_API_KEY")

    init {
        if (openAIApiKey.isNullOrBlank()) {
            println("⚠️ 警告:未找到 OPENAI_API_KEY 環境變數")
        }
        if (openWeatherApiKey.isNullOrBlank()) {
            println("⚠️ 警告:未找到 OPENWEATHER_API_KEY 環境變數")
        }
    }
}

在這裡多加上了,啟動時檢查金鑰的相關 log

在 IntelliJ IDEA 的環境變數設定中新增

  • OPENWEATHER_API_KEY=你的OpenWeather API金鑰

新增 HTTP 客戶端依賴

為了進行 HTTP 請求,我們使用 OkHttp 函式庫

build.gradle.kts 中新增依賴

dependencies {
    // Koog 相關依賴
    implementation("ai.koog:koog-agents:0.3.0")

    // HTTP 客戶端 - 用於 API 呼叫
    implementation("com.squareup.okhttp3:okhttp:4.12.0")

    // JSON 序列化 - 用於 API 回應解析
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
}

動手建立天氣查詢工具

現在讓我們建立一個真正能與 OpenWeather API 互動的 WeatherTool,學習外部 API 整合工具的開發步驟

定義 Args 資料類別

首先,我們定義工具需要的參數結構

// API 回應資料模型
@Serializable
data class GeocodingResponse(
    val name: String,
    val lat: Double,
    val lon: Double,
    val country: String
)

@Serializable
data class WeatherResponse(
    val lat: Double,
    val lon: Double,
    val current: CurrentWeather
)

@Serializable
data class CurrentWeather(
    val temp: Double,
    val humidity: Int,
    val weather: List<WeatherDescription>
)

@Serializable
data class WeatherDescription(
    val id: Int,
    val main: String,
    val description: String
)

object WeatherTool : SimpleTool<WeatherTool.Args>() {

    @Serializable
    data class Args(
        val city: String,           // 必填:城市名稱
        val country: String = "TW"  // 選填:國家代碼,預設台灣
    ) : ToolArgs

    override val argsSerializer = Args.serializer()


    // 其它程式碼 ...
}

設定工具描述資訊

告訴 AI 這個工具的用途和參數說明

override val descriptor = ToolDescriptor(
    name = "get_weather",
    description = "查詢指定城市的天氣資訊",
    requiredParameters = listOf(
        ToolParameterDescriptor(
            name = "city",
            description = "城市名稱(支援中文或英文)",
            type = ToolParameterType.String
        )
    ),
    optionalParameters = listOf(
        ToolParameterDescriptor(
            name = "country",
            description = "國家代碼,預設為 TW(台灣)",
            type = ToolParameterType.String
        )
    )
)

實作執行邏輯與超時處理

override suspend fun doExecute(args: Args): String {
    return try {
        // 設定 5 秒超時,避免無限等待
        withTimeout(5000) {
            fetchWeatherData(args.city, args.country)
        }
    } catch (e: kotlinx.coroutines.TimeoutCancellationException) {
        "⏱️ 天氣查詢超時,請稍後再試"
    } catch (e: Exception) {
        "❌ 無法獲取 ${args.city} 的天氣資訊:${e.message}"
    }
}

實作真實 API 呼叫

現在實作真正的 OpenWeather API 呼叫,讓工具能夠獲取實際的天氣資料

private suspend fun fetchWeatherData(city: String, country: String): String {
    try {
        // 使用 Geocoding API 獲取城市座標
        val coordinates = getCoordinates(city, country)

        // 使用座標呼叫 One Call API 獲取天氣
        return callWeatherApi(coordinates.lat, coordinates.lon, city)

    } catch (e: Exception) {
        throw Exception("無法獲取 $city 的天氣資訊:${e.message}")
    }
}


/**
 * 使用 Geocoding API 將城市名稱轉換為座標
 */
private suspend fun getCoordinates(city: String, country: String): GeocodingResponse {
    val client = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS)
        .readTimeout(10, TimeUnit.SECONDS)
        .build()

    val encodedCity = URLEncoder.encode(city, "UTF-8")
    val url = "https://api.openweathermap.org/geo/1.0/direct?q=$encodedCity,$country&limit=1&appid=${ApiKeyManager.openWeatherApiKey}"

    val request = Request.Builder()
        .url(url)
        .build()

    return withContext(Dispatchers.IO) {
        client.newCall(request).execute().use { response ->
            if (response.isSuccessful) {
                val body = response.body?.string() ?: throw Exception("Empty response")
                parseCoordinates(body, city)
            } else {
                throw Exception("Geocoding API 呼叫失敗,狀態碼:${response.code}")
            }
        }
    }
}

/**
 * 解析 Geocoding API 回應獲取座標
 */
private fun parseCoordinates(response: String, city: String): GeocodingResponse {
    val json = Json { ignoreUnknownKeys = true }
    val locations = json.decodeFromString<List<GeocodingResponse>>(response)

    if (locations.isNotEmpty()) {
        return locations[0]
    } else {
        throw Exception("找不到城市 $city 的地理位置")
    }
}

/**
 * 呼叫 OpenWeather One Call API 獲取天氣資料
 */
private suspend fun callWeatherApi(lat: Double, lon: Double, cityName: String): String {
    val client = OkHttpClient.Builder()
        .connectTimeout(5, TimeUnit.SECONDS)
        .readTimeout(10, TimeUnit.SECONDS)
        .build()

    val url = "https://api.openweathermap.org/data/3.0/onecall?lat=$lat&lon=$lon&appid=${ApiKeyManager.openWeatherApiKey}&units=metric&lang=zh_tw"

    val request = Request.Builder()
        .url(url)
        .build()

    return withContext(Dispatchers.IO) {
        client.newCall(request).execute().use { response ->
            if (response.isSuccessful) {
                val body = response.body?.string() ?: throw Exception("Empty response")
                parseWeatherData(body, cityName)
            } else {
                throw Exception("天氣 API 呼叫失敗,狀態碼:${response.code}")
            }
        }
    }
}

/**
 * 解析天氣 API 回應
 */
private fun parseWeatherData(response: String, cityName: String): String {
    try {
        val json = Json { ignoreUnknownKeys = true }
        val weather = json.decodeFromString<WeatherResponse>(response)

        val temperature = weather.current.temp.toInt()
        val humidity = weather.current.humidity
        val description = weather.current.weather.firstOrNull()?.description ?: "無資料"

        return """
            🌤️ $cityName 天氣
            🌡️ 溫度:${temperature}°C
            ☁️ $description
            💧 濕度:${humidity}%
            即時天氣資料來自 OpenWeather
        """.trimIndent()

    } catch (e: Exception) {
        throw Exception("解析天氣資料失敗:${e.message}")
    }
}

關鍵技術要點解析

超時處理

withTimeout(5000) {
    fetchWeatherData(args.city, args.country)
}
  • withTimeout 設定操作的最大執行時間
  • 如果超時會拋出 TimeoutCancellationException
  • 防止因網路問題導致的無限等待

使用 OkHttp 進行 API 整合

val client = OkHttpClient.Builder()
    .connectTimeout(5, TimeUnit.SECONDS)
    .readTimeout(10, TimeUnit.SECONDS)
    .build()

val request = Request.Builder()
    .url(url)
    .build()

withContext(Dispatchers.IO) {
    client.newCall(request).execute().use { response ->
        if (response.isSuccessful) {
            val body = response.body?.string() ?: throw Exception("Empty response")
            // 處理回應
        }
    }
}

OkHttp 的主要優勢

  • 高效能連線池:自動重複使用連線,減少網路延遲
  • 自動重試機制:內建智能重試和重定向處理
  • 安全性:內建 HTTPS 支援和憑證管理
  • 簡潔 API:比 HttpURLConnection 更易使用和理解
  • resource 管理:使用 .use {} 自動關閉資源,避免記憶體洩漏

兩階段 API 呼叫

我們的天氣工具使用了兩個 OpenWeather API

  • Geocoding API:將城市名稱轉換為經緯度座標
  • One Call API:使用座標獲取詳細天氣資料

這種設計讓工具能支援任何城市名稱,包括中文城市名稱

結構化 JSON 解析

@Serializable
data class WeatherResponse(
    val lat: Double,
    val lon: Double,
    val current: CurrentWeather
)

val json = Json { ignoreUnknownKeys = true }
val weather = json.decodeFromString<WeatherResponse>(response)
val temperature = weather.current.temp.toInt()

kotlinx-serialization 的主要優勢

  • 類型安全:編譯時期檢查 JSON 結構,避免執行時錯誤
  • 結構化資料:使用資料類別清楚定義 API 回應結構
  • 自動轉換:JSON 字串自動轉換為 Kotlin 物件
  • 容錯處理ignoreUnknownKeys = true 忽略不需要的欄位
  • 效能優越:相比正則表達式更高效且可靠

建立天氣查詢 AI Agent

現在讓我們建立一個簡單的 AI Agent 來測試我們的 WeatherTool

class WeatherAgent {

    private val toolRegistry = ToolRegistry {
        tool(WeatherTool)  // 我們的天氣查詢工具
    }

    private val agent = AIAgent(
        executor = simpleOpenAIExecutor(ApiKeyManager.openAIApiKey!!),
        systemPrompt = """
            你是一個友善的天氣助手:

            當使用者詢問天氣時:
            - 使用 get_weather 工具查詢指定城市的天氣
            - 提供清楚、有用的天氣資訊
            - 可以給出貼心的建議(如是否需要帶傘、外套等)

            請用正體中文回應,保持友善的語調。
        """.trimIndent(),
        llmModel = OpenAIModels.CostOptimized.GPT4_1Mini,
        toolRegistry = toolRegistry,
        temperature = 0.7
    )

    suspend fun queryWeather(query: String): String {
        return try {
            agent.run(query)
        } catch (e: Exception) {
            "抱歉,無法查詢天氣資訊:${e.message}"
        }
    }
}

實際使用範例

讓我們建立一個簡單的範例來測試天氣查詢功能

suspend fun main() {
    println("外部 API 天氣查詢工具展示")
    println("=".repeat(40))

    val weatherAgent = WeatherAgent()

    println("\n測試天氣查詢功能")
    println("-".repeat(40))

    val query = "台中市今天天氣如何?"
    val response = weatherAgent.queryWeather(query)
    println("使用者:$query")
    println("天氣助手:$response")

    println("\n測試完成!")
}

執行 AI 回應內容

外部 API 天氣查詢工具展示
========================================

測試天氣查詢功能(使用 OpenWeather API----------------------------------------
使用者:台中市今天天氣如何?
天氣助手:根據最新的天氣資訊,台中目前的天氣狀況如下

🌤️ 台中 天氣
🌡️ 溫度:23°C
☁️ 多雲
💧 濕度:78%
即時天氣資料來自 OpenWeather

天氣算是舒適的,有點多雲,溫度適中。如果要外出建議可以帶件薄外套,以防早晚溫差。有什麼其他需要幫忙的嗎?

測試完成!

注意:執行結果會根據查詢時間和實際天氣狀況而有所不同,以上展示的是真實 API 回應的格式

總結

今天我們成功從 Day 5 的本地工具進化到真正的外部 API 整合

對比項目Day 5 AddToolDay 9 WeatherTool (更新版)
執行方式本地計算,立即返回外部 API 呼叫,網路請求
關鍵技術基本的 doExecutesuspend + OkHttp + kotlinx-serialization
錯誤處理簡單 try-catch多層次錯誤處理 + 網路超時處理
適用場景數學計算、字串處理外部服務整合、實時資料獲取
實用性基礎學習用途生產環境可用的真實工具
資料來源本地計算OpenWeather API 真實天氣資料

掌握了外部 API 整合工具開發後,下一篇文章我們將探討 Koog 框架的多模態內容處理能力,學習如何讓 AI Agent 處理圖像、音訊等多媒體內容,進一步擴展 AI Agent 的能力邊界


支持創作

如果這篇文章對您有幫助,歡迎透過 贊助連結 支持我持續創作優質內容。您的支持是我前進的動力!


圖片來源:AI 產生