Python 那边的 agent 框架已经换过好几轮了,LangChain、LlamaIndex,再到 Google 自己出的 ADK。Kotlin 这边一直没什么动静——不是没人想做,是缺一个官方托底的东西,大家不太敢往生产里放。

Google 的 adk-kotlin 1.0 在 9 月 7 号 GA,1.1.0 是 9 月 15 号,Ktor 3.6.0 的构件 9 月 16 号上的 Maven Central。我拿这三个搭了个能用的东西:一个能自己查证「某个 KMP 库到底支不支持 iOS」的 agent,服务端和客户端放在同一个 KMP 工程里。

先交代一件事。写这篇文章的过程中我踩了个坑,照着源码写的请求体在真服务器上直接返回 400,而且不带任何错误信息。折腾了小半个钟头才定位到,是版本问题。这个放在后面单独讲,因为它比教程本身更值得记住。

这个 agent 解决什么问题

KMP 开发者最常问的一个问题是:「这个库支持 iOS 吗?」

README 经常不给答案,或者给的是过期的答案。真正权威的地方是库发布到 Maven Central 上的 Gradle module metadata——那个 .module 文件是构建过程生成的,里面列的就是这次编译真实产出的平台变体。

所以工具做的事很直接:把 .module 拉下来,解析出所有变体名,数一数里面有没有 iOS 相关的。

@Tool
fun checkLibraryPlatforms(
  @Param("Maven group id, e.g. com.google.adk") group: String,
  @Param("Maven artifact id, e.g. google-adk-kotlin-core") artifact: String,
  @Param("Version to check, e.g. 1.1.0") version: String,
): String {
  val url = "https://repo1.maven.org/maven2/${group.replace('.', '/')}/" +
    "$artifact/$version/$artifact-$version.module"
  // ... 拉取、解析、统计 iOS 变体
}

@Tool 和 @Param 是 ADK 的注解,后面会说它为什么重要。

结构:为什么是两个模块

adk-kotlin-agent-quickstart/
├── shared/          KMP 模块
│   └── src/
│       ├── commonMain/   Ktor 客户端 + 协议 DTO(jvm / iOS 共用)
│       ├── jvmMain/      CLI 入口 + CIO 引擎
│       └── iosMain/      Darwin 引擎
└── agent-server/    纯 JVM 模块:ADK agent + HTTP 服务

看起来可以塞进一个模块,但塞不进去。google-adk-kotlin-webserver 内部依赖的是 Ktor 2.3.13,而客户端要用 Ktor 3.6.0。放一起的话 Gradle 按「最高版本获胜」把服务端也拉到 Ktor 3,而 ADK 的代码是按 Ktor 2 的 API 编译的,运行时会直接炸。

这不是猜的。跑起来之后看类路径,服务端确实是这一串:

ktor-server-netty-jvm-2.3.13.jar
ktor-server-core-jvm-2.3.13.jar
ktor-client-core-jvm-2.3.13.jar
...

客户端那边是 3.6.0。物理隔开,各自安好。

版本锁定

KSP 必须和 Kotlin 严格配对,这是最容易埋雷的地方。我直接照抄了 google/adk-kotlin 仓库自己构建用的组合:

[versions]
kotlin = "2.3.21"
ksp = "2.3.9"          # 和 kotlin 一起升,别单独动
adk = "1.1.0"
ktor = "3.6.0"

服务端依赖三个:

dependencies {
  implementation(libs.adk.core)        // agents / models / tools / sessions / runners
  ksp(libs.adk.processor)              // 编译期生成工具 schema
  implementation(libs.adk.webserver)   // 把 agent 用 HTTP 暴露出去
}

工具是怎么变成 schema 的

ADK for Kotlin 和 Python 版最大的差别在这儿。Python 靠运行时反射,Kotlin 这边是 KSP 编译期生成。

KmpSupportAgent 里的这一行是关键:

tools = KmpLibraryService().generatedTools()

generatedTools() 这个函数在整个工程里我一行都没写过。编译完之后,它出现在:

agent-server/build/generated/ksp/main/kotlin/com/example/adk/server/
├── CheckLibraryPlatformsTool.kt
├── ListAvailableVersionsTool.kt
└── KmpLibraryService_GeneratedTools.kt

后者的内容是:

public fun KmpLibraryService.generatedTools(): List<FunctionTool> = listOf( ... )

好处是运行时不需要反射,R8/混淆、Native 镜像、启动时间这几个方向上都干净。代价是工具签名改了必须重新编译,动态注册工具这种玩法就别想了。

agent 本体

object KmpSupportAgent {
  @JvmField
  val rootAgent = LlmAgent(
    name = "kmp_library_platform_agent",
    model = Gemini(name = "gemini-3.1-flash-lite"),
    instruction = Instruction("""
      You are an assistant for Kotlin Multiplatform developers.
      When the user asks whether a library supports a platform, never answer
      from memory. Call `checkLibraryPlatforms` and read the result.
      ...
    """.trimIndent()),
    tools = KmpLibraryService().generatedTools(),
  )
}

顺带说下 @JvmField。官方示例里每个 agent 的 rootAgent 都带着它,我一开始也照抄了,还以为它是必需的。其实它只是让 Kotlin object 的属性变成真正的静态字段(而不是 INSTANCE.getRootAgent()),方便 Java 调用方访问。我试着去掉它重新编译运行,服务端照样起来、/list-apps 照样返回——因为 AdkServerConfig.inMemory(agent) 是直接拿 agent 对象,全程不走反射。抄没问题,但别以为少了它就启动不了。

instruction 里我特意写清楚了一件事:platform.type 是个聚合标记,Kotlin/Native 的目标统一报 native,具体是哪个 Apple 目标只出现在变体名里。不写这句,模型容易看着 native 这个词就自己下结论,而它并不能从 native 推出到底有没有 iOS。

起服务

fun main() {
  val config = AdkServerConfig.inMemory(KmpSupportAgent.rootAgent)
  AdkDevServer(config).start(wait = true)
}

AdkDevServer 会顺带挂上开发界面。路由长这样:

方法路径用途
GET/list-apps有哪些 agent,也就是 appName 能填什么
GET/version服务端版本
POST/apps/{appName}/users/{userId}/sessions建会话,返回会话 id
POST/run跑一轮,一次性返回完整事件列表
POST/run_sse跑一轮,SSE 流式返回
GET/dev-ui开发界面

实测三个能直接验:

$ curl -s localhost:8080/list-apps
["kmp_library_platform_agent"]

$ curl -s localhost:8080/version
{"version":"1.1.0","language":"kotlin","language_version":"21.0.10"}

$ curl -s -X POST -H "Content-Type: application/json" -d '{}' \
    localhost:8080/apps/kmp_library_platform_agent/users/demo-user/sessions
{"id":"b86f7324-81db-49df-8007-1700077ebdb4","appName":"kmp_library_platform_agent",...}

/run_sse 用的是标准 SSE,每个事件一行 data: <json>,后面跟一个空行。服务端的实现是手写的,因为 Ktor 2.x 没有 SSE 插件。源码里的注释挺有意思:

Ktor 2.x has no SSE plugin, so stream manually. Use async respondBytesWriter, not respondTextWriter, whose blocking bridge parks via a coroutines internal gone in 1.11.0.

Ktor 2.x 没有 SSE 插件,所以手动流式输出。要用异步的 respondBytesWriter,不能用 respondTextWriter——它的阻塞桥接依赖了一个在 kotlinx-coroutines 1.11.0 里已经被删掉的内部实现。

也就是说这一句注释本身在提醒你:升级 coroutines 版本可能把这个 API 弄坏。

客户端:写在 commonMain 里

真正跨平台的部分在这里。请求构造和 SSE 解析全部写在 commonMain,不碰平台 API:

fun run(prompt: String, sessionId: String? = null): Flow<Event> = channelFlow {
  val sid = sessionId ?: createSession()
  httpClient.preparePost("$baseUrl/run_sse") {
    contentType(ContentType.Application.Json)
    setBody(RunRequest(appName, userId, sid, Content.text("user", prompt), streaming = true))
  }.execute { response ->
    val channel = response.bodyAsChannel()
    while (true) {
      val line = channel.readLine() ?: break
      if (!line.startsWith("data:")) continue
      val payload = line.removePrefix("data:").trim()
      if (payload.isEmpty()) continue
      val event = json.decodeFromString<Event>(payload)
      send(event)
      if (event.turnComplete) break
    }
  }
}

只有引擎是分平台的,用一个 expect/actual 收口:

// commonMain
expect fun createPlatformHttpClient(json: Json): HttpClient

// jvmMain
actual fun createPlatformHttpClient(json: Json) = HttpClient(CIO) { ... }

// iosMain
actual fun createPlatformHttpClient(json: Json) = HttpClient(Darwin) { ... }

循环退出靠两个字段:partial 标记这是一个还没说完的流式分片,turnComplete 标记这一轮结束。没有 turnComplete 就得靠连接关闭来判断,那会拖到超时。

那个 400

现在讲开头说的坑。

我照着自己的理解写请求体,用了 app_name / user_id / new_message 这套命名——毕竟 Python 版 ADK 就是这样,而且我确实在仓库源码里看到 ApiModels.kt 给每个多词属性都挂了一个别名:

@JsonNames("app_name") val appName: String,
@JsonNames("user_id") val userId: String,

请求发出去:

HTTP/1.1 400 Bad Request
Content-Length: 0

没有 body,没有堆栈,服务端日志里连一行 ERROR 都没有。试着换成 camelCase 再发一次,返回变成 500——这说明反序列化通过了,失败发生在更后面(模型调用)。也就是说:

  • camelCase → 解析成功,一路走到 Gemini SDK
  • snake_case → 根本没进到业务逻辑

@JsonNames 在 kotlinx-serialization 里是「备用名」,理论上两种拼写都应该认。我甚至单独写了个最小复现,用同样的注解和同样的 Json 配置,snake_case 解析是成功的。所以问题不在 kotlinx。

问题在版本。我去翻了 tag:

v1.1.0 的 ApiModels.kt:
  data class AgentRunRequest(
    val appName: String,
    val userId: String,
    ...
  )
  → 一个 @JsonNames 都没有

那两个别名提交 94dcdd13(feat: accept snake_case and camelCase in web server request bodies)的日期是 2026-09-15,进的是 main,在 v1.1.0 tag 之后。仓库里还有个测试文件写着「服务端读 snake_case」——它描述的是 main 的行为,不是你能下载到的 1.1.0 的行为。

拿真实的 AgentRunRequest 和真实的 adkJson 解码 snake_case,报错很明确:

kotlinx.serialization.MissingFieldException: Fields [appName, userId] are required
for type with serial name '...AgentRunRequest', but they were missing

所以规则是:对 1.1.0,一律用 camelCase。 snake_case 要等下一个版本。

这件事的教训不是「要看 tag」,而是「读 main 的源码不等于读你依赖的那个版本」。我如果没真的把请求发出去试一遍,这篇文章里的示例代码就是照抄一个未发布行为、一跑就 400 的代码。

真要说自查手法,/version 这个端点很有用——服务端会自报版本,然后去对照那个 tag 的源码,比对着 main 猜要靠谱。

工具跑出来的样子

这一段不需要模型,工具是纯 Kotlin,可以直接跑:

./gradlew :agent-server:runTools

先拿 ADK 自己开刀:

com.google.adk:google-adk-kotlin-core:1.1.0
platform.type values: androidJvm, common, jvm
variants mentioning iOS: 0
Verdict: no iOS target. This library cannot be consumed from an
iosArm64 / iosSimulatorArm64 source set.

再拿 Ktor 对照:

io.ktor:ktor-client-core:3.6.0
platform.type values: common, js, jvm, native, wasm
variants mentioning iOS: 9
  - iosArm64ApiElements-published
  - iosSimulatorArm64ApiElements-published
  - iosX64ApiElements-published
  ...(共 9 个,含 Api / Metadata / Sources 三类)
Verdict: iOS targets are published, so Apple targets can depend on it.

同一个函数、同一套判断逻辑,两个库的结论正好相反。这个反差下面单独说。

版本查询和错误路径也顺带看一眼:

com.google.adk:google-adk-kotlin-core — latest=1.1.0
recent: 0.5.0, 0.6.0, 0.7.0, 0.8.0, 0.9.0, 1.0.0, 1.0.1, 1.1.0

Could not fetch metadata for com.google.adk:does-not-exist:9.9.9 (HTTP 404).
The coordinates or version are probably wrong.

错误路径返回的是一句人话,不是抛异常。工具返回给模型的是字符串,异常直接把这一轮对话打断,模型连「这个库不存在」都说不出来。

跨端这件事

shared 这块是真跨平台。我实际编译过:

$ ./gradlew :shared:compileKotlinIosSimulatorArm64 :shared:compileKotlinIosArm64
BUILD SUCCESSFUL in 33s

同一份 commonMain 代码,JVM 和 iOS 都过。

而 agent-server 永远只能在 JVM(或者 Android)上。这不是配置问题——google-adk-kotlin-core 发布到 Maven Central 的 Gradle 变体只有 common / androidJvm / jvm 三个,google-genai-kotlin 也一样。没有 iOS 变体,也没有 Native 变体。

所以如果你想在 iOS 上跑 agent,路只有两条:把它放在服务端,iOS 只做客户端(就像这个工程的结构);或者等上游补 Native 支持。我为此在 ADK 仓库开了个 issue,编号 #504,目前还没回复。

顺带一说,这个「agent 只在服务端」的切分其实多数场景更合理——模型密钥不会进客户端包,工具执行和权限检查也都在你能控住的地方。

怎么跑

export GOOGLE_API_KEY=...
./run-stack.sh

这个脚本干四件事:把服务端装出来并起在 8080、轮询 /list-apps 等它真就绪、跑客户端、跑完收掉服务端并把客户端的退出码原样传出来。

(第一版这个脚本是漏的:它借 Gradle 生成的启动脚本起服务,而那个脚本把 java 当子进程跑,kill 只杀掉外壳,服务端会变成孤儿一直占着 8080。改成直接起 java,$! 才是服务端本体。)

也可以拆开:

./gradlew :agent-server:run     # 终端 1
./gradlew :shared:runClient     # 终端 2

最后交代清楚我验证到哪一步了,别让你照着跑的时候产生落差。

跑通的:整个工程编译通过(含 KSP 代码生成);服务端启动;/list-apps、/version、建会话都返回 200;客户端发出的 /run_sse 请求被服务端接受,并进入 com.google.genai.kotlin 的模型调用层。

没跑通的:一次真实的模型对话。这需要你自己的 Gemini API key,我这边没有。所以文章开头那个「查 iOS 支持情况」的完整对话输出,我没有贴——它得接上有效 key 才能看到,我不想贴一段没跑过的输出。

代码在 https://github.com/trewvip-arch/adk-kotlin-agent-quickstart

Apache-2.0。有问题直接在仓库里开 issue。