news 2026/9/29 20:16:14

KMP 全栈开发实战:用 Compose Multiplatform 从 Android 到 AI Agent 的配置骨架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
KMP 全栈开发实战:用 Compose Multiplatform 从 Android 到 AI Agent 的配置骨架

1. 从 Android 单端到 KMP 全栈:AI Agent 接入的真实痛点

如果你现在手上有一个 Compose Multiplatform 项目,Android 端 UI 已经跑起来了,想再往前一步接入 AI Agent 能力,大概率会卡在同一个地方:Key 放哪、请求从哪个模块发、Android 和 Desktop 要不要各写一套网络层。我见过太多项目在androidMain里直接OkHttpClient硬编码一个 API Key,等到要加 iOS 或 Desktop 目标时,整段逻辑只能复制粘贴,改一处漏三处。

Kotlin Multiplatform 的价值在这里才真正体现出来。Compose Multiplatform 负责共享 UI,而 AI Agent 的调用链路——Prompt 组装、消息历史、流式解析、错误重试——全部是纯 Kotlin 逻辑,天然属于commonMain。你只需要在共享模块里维护一套 Agent 客户端,Android、Desktop、iOS 各自只负责把结果渲染出来。

这篇要交付的是一套可以直接复制的配置骨架:settings.json和config.toml两个文件怎么放、统一 Key 通道怎么设计、Gradle 依赖怎么声明,最后用一个端到端的连通性验证动作确认整条链路是通的。目标不是讲概念,而是让你从零搭出一个能跑起来的跨端智能应用雏形。适合已经写过 Compose、但对 KMP 模块划分和 AI 服务接入还不太熟的同学。

2. TaoToken 前置:统一 Key 通道与项目结构

在动手写代码之前,先把 Key 通道这件事定下来。跨端项目最忌讳的就是每个平台各自读一份配置,Android 读BuildConfig、Desktop 读环境变量、iOS 读 plist,最后没人说得清线上到底用的哪个 Key。我的做法是:所有平台统一走一个AiConfig数据类,由共享模块在启动时注入,Key 本身从本地配置文件或环境变量读取,绝不进版本库。

TaoToken 在这里扮演的是统一模型接入层的角色。它提供 OpenAI 兼容的接口形态,意味着你在commonMain里写的请求代码不需要为不同模型厂商改结构,换模型只改baseUrl和model字段。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,直接作为baseUrl使用。

推荐的 KMP 项目结构如下,重点是shared模块内部的分层:

MyKmpAgent/ ├── shared/ │ ├── src/ │ │ ├── commonMain/kotlin/ │ │ │ ├── ai/ │ │ │ │ ├── agent/AgentClient.kt │ │ │ │ ├── llm/LlmProvider.kt │ │ │ │ └── prompt/PromptBuilder.kt │ │ │ ├── network/HttpEngineFactory.kt │ │ │ ├── config/AiConfig.kt │ │ │ └── domain/Message.kt │ │ ├── androidMain/kotlin/ │ │ ├── desktopMain/kotlin/ │ │ └── iosMain/kotlin/ │ └── build.gradle.kts ├── androidApp/ ├── desktopApp/ └── settings.gradle.kts

ai包只放纯逻辑,不依赖任何平台 API;network包负责创建 HTTP 引擎,这里用 Ktor 的HttpClient,引擎实现按平台 expect/actual 分发;config包持有AiConfig。Android 端和 Desktop 端各自在入口处构造AiConfig并传给共享的AgentClient。

Key 的读取策略建议这样:本地开发时放在项目根目录的local.properties(Android 侧)或环境变量(Desktop 侧),共享模块只接收字符串,不关心来源。这样既避免了 Key 硬编码,也让 CI 环境可以统一注入。

3. 可复制配置:settings.json 与 config.toml 骨架

这一节给出两个配置文件的完整骨架。settings.json用于声明模型与 Agent 行为参数,config.toml用于声明构建与运行期的基础设施参数。两者都放在项目根目录,由共享模块的配置加载器读取。

先看settings.json:

{ "ai": { "provider": "taotoken", "baseUrl": "https://taotoken.net/api", "model": "claude-3-5-sonnet", "temperature": 0.7, "maxTokens": 2048, "timeoutSeconds": 60 }, "agent": { "systemPrompt": "你是一个跨端智能助手,回答简洁准确。", "maxHistoryRounds": 10, "enableStream": true }, "keyChannel": { "source": "env", "envName": "TAOTOKEN_API_KEY", "fallbackFile": "local.properties" } }

字段说明:baseUrl固定为 TaoToken 的 API 基址,不带路径后缀;model按你实际开通的模型填写;keyChannel.source支持env和file两种,env优先读环境变量,读不到再回退到fallbackFile。enableStream控制是否走流式返回,Compose 端做打字机效果时需要打开。

再看config.toml,这个文件主要给 Gradle 和运行期读取:

[build] kotlinVersion = "2.0.21" composeVersion = "1.7.0" ktorVersion = "3.0.0" [targets] android = true desktop = true ios = false [network] connectTimeoutMs = 15000 requestTimeoutMs = 60000 retryCount = 2 [logging] level = "info" logRequestBody = false

logRequestBody默认关闭,避免把用户输入打到日志里。retryCount设为 2,配合 Ktor 的HttpRequestRetry插件使用。

接下来是共享模块的 Gradle 依赖声明,shared/build.gradle.kts关键片段:

kotlin { androidTarget() jvm("desktop") // iosX64(); iosArm64(); iosSimulatorArm64() sourceSets { val commonMain by getting { dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation("io.ktor:ktor-client-core:3.0.0") implementation("io.ktor:ktor-client-content-negotiation:3.0.0") implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } } val androidMain by getting { dependencies { implementation("io.ktor:ktor-client-okhttp:3.0.0") } } val desktopMain by getting { dependencies { implementation("io.ktor:ktor-client-cio:3.0.0") } } } }

注意commonMain里只声明ktor-client-core,具体引擎在平台源集里补。这样 Android 用 OkHttp、Desktop 用 CIO,共享代码完全无感。

配置加载器写在commonMain,用kotlinx.serialization解析settings.json:

@Serializable data class AiSettings( val ai: AiSection, val agent: AgentSection, val keyChannel: KeyChannelSection ) @Serializable data class AiSection( val provider: String, val baseUrl: String, val model: String, val temperature: Double = 0.7, val maxTokens: Int = 2048, val timeoutSeconds: Int = 60 )

AiConfig的构造逻辑:先读环境变量TAOTOKEN_API_KEY,为空则读local.properties里的同名键,再为空就抛异常并在 UI 层提示用户配置。这一步是整个 Key 通道的核心,务必只在一处实现。

4. 端到端连通性验证:一次请求跑通全链路

配置就位后,写一个最小的AgentClient来验证链路。先定义统一接口,方便后续换模型:

interface LlmProvider { suspend fun chat(messages: List<Message>): String suspend fun chatStream(messages: List<Message>): Flow<String> }

Message是共享的领域模型:

@Serializable data class Message( val role: String, val content: String )

AgentClient的实现,走 OpenAI 兼容的/v1/chat/completions路径:

class AgentClient( private val config: AiConfig, private val httpClient: HttpClient ) : LlmProvider { override suspend fun chat(messages: List<Message>): String { val response = httpClient.post("${config.baseUrl}/v1/chat/completions") { header(HttpHeaders.Authorization, "Bearer ${config.apiKey}") header(HttpHeaders.ContentType, ContentType.Application.Json) setBody( ChatRequest( model = config.model, messages = messages, temperature = config.temperature, maxTokens = config.maxTokens ) ) } val body = response.body<ChatResponse>() return body.choices.first().message.content } }

请求体与响应体的序列化类:

@Serializable data class ChatRequest( val model: String, val messages: List<Message>, val temperature: Double, @SerialName("max_tokens") val maxTokens: Int ) @Serializable data class ChatResponse( val choices: List<Choice> ) @Serializable data class Choice( val message: Message )

HTTP 引擎的 expect/actual 分发:

// commonMain expect fun createHttpEngine(): HttpClientEngineFactory<*> // androidMain actual fun createHttpEngine() = OkHttp // desktopMain actual fun createHttpEngine() = CIO

在commonMain里组装客户端:

fun buildAgentClient(config: AiConfig): AgentClient { val client = HttpClient(createHttpEngine()) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } install(HttpTimeout) { requestTimeoutMillis = config.timeoutSeconds * 1000L } } return AgentClient(config, client) }

验证动作:在 Android 的MainActivity或 Desktop 的main里调用一次chat,传入一条用户消息,打印返回内容。如果控制台输出模型回复,说明 Key 通道、网络层、序列化、共享模块全部打通。这一步跑通之前不要急着写 UI,否则出问题很难定位是配置还是渲染。

Compose 端调用示例:

@Composable fun AgentScreen(client: AgentClient) { var reply by remember { mutableStateOf("") } LaunchedEffect(Unit) { reply = client.chat(listOf(Message("user", "你好,做个自我介绍"))) } Text(text = reply) }

Android 和 Desktop 共用这个AgentScreen,差异只在client的构造来源。

5. 本篇常见错排查

401 或 403 返回:九成是 Key 没读到。先确认环境变量名和settings.json里的envName完全一致,大小写敏感。Android 侧如果用了local.properties,注意该文件默认在.gitignore里,CI 上需要单独注入。另外检查Authorization头是不是Bearer加空格再加 Key,少空格会直接 401。

序列化报错Unknown key:模型返回的 JSON 字段比你的ChatResponse多。在Json配置里加ignoreUnknownKeys = true,上面代码已经带了,如果你自己写的没加,补上即可。

Desktop 端连不上但 Android 正常:多半是引擎选错。Desktop 用 CIO,Android 用 OkHttp,如果createHttpEngine的 actual 实现写反了,会出现平台特有的连接异常。检查desktopMain和androidMain的 actual 函数是否对应。

流式返回卡住不输出:enableStream打开后,Ktor 需要用preparePost加bodyAsChannel逐行读,不能直接用body<ChatResponse>()。流式解析要按data:前缀切分,遇到[DONE]结束。这块建议单独封装一个chatStream实现,不要和同步chat混在一起。

Gradle 同步失败提示找不到 compose 插件:settings.gradle.kts里的pluginManagement仓库需要包含google()和mavenCentral(),Compose Multiplatform 的插件坐标是org.jetbrains.compose,版本和config.toml里的composeVersion保持一致。

Key 泄露风险:任何时候不要把 Key 写进settings.json提交到仓库。settings.json只放envName和fallbackFile,真实 Key 走环境变量或本地文件。如果已经提交过,立刻在控制台轮换 Key。

6. 下一步:把骨架跑成真正的 Agent

到这里,你已经有了一个能跨 Android 和 Desktop 发请求的共享 Agent 客户端。接下来要做的不是继续堆 UI,而是把 Agent 的几块核心能力补进commonMain:Prompt 模板管理、多轮历史裁剪、工具调用协议解析。这些逻辑和平台无关,放在共享模块里维护成本最低。

如果你要长期在这个项目上做编码和 Agent 工作流,建议把 Key 管理和额度规划放到 Coding Plan 里统一处理,入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,先把 API Key 建好,再对照接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 确认请求格式和模型名。想先验证模型返回效果,可以直接在模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 里试几条 Prompt,确认没问题再写进代码。

骨架跑通只是起点。真正让 KMP 项目在 AI 时代站住脚的,是共享模块里那套与平台无关的 Agent 引擎——它今天服务 Android 和 Desktop,明天加 iOS 和 Web 时,你只需要补一个 UI 层。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 20:15:26

VSCode 同步设置及扩展插件:用 TaoToken 统一多设备配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 20:14:59

Tampermonkey油猴脚本安装与使用教程:从零开始玩转浏览器扩展

折腾浏览器这么多年&#xff0c;我一直觉得浏览器扩展是提升上网效率最直接的方式&#xff0c;而在所有扩展里&#xff0c;油猴&#xff08;Tampermonkey&#xff09;绝对算得上是一把“万能钥匙”。第一次接触它时&#xff0c;我还以为它只是某个网页小插件&#xff0c;后来才…

作者头像 李华
网站建设 2026/9/29 20:13:26

用 Skill 把死锁与 CAP 讲成寓言:TaoToken 配置骨架与验证动作

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 20:12:56

用 LLM 当评审员:四条纪律,和一次「它把不存在的证据编得很专业」的事故

流水线里有一类活&#xff0c;确定性代码判不了&#xff1a;这段讲解讲得对不对、这个说法有没有依据、这份内容是不是偷偷降低了难度。 于是很自然想到让另一个模型来判。这一步是整条流水线里最诱人的——便宜、能规模化、还能给出看起来很专业的分数。 它也是最容易自欺的一…

作者头像 李华
网站建设 2026/9/29 20:11:34

init preallocate_vmalloc_pages

preallocate_vmalloc_pages 是 x86-64 内核在启动阶段用于预分配 vmalloc 区域页表页的初始化函数&#xff0c;位于 arch/x86/mm/init_64.c。它的核心目的是避免运行时昂贵的页表同步操作。核心问题&#xff1a;为什么需要预分配&#xff1f;在 x86-64 上&#xff0c;vmalloc 区…

作者头像 李华