)
1. 为什么要在 KMP 后端用 Ktor 搓 MCP Agent 服务如果你写过 Android又想把 AI Agent 能力塞进自己的后端Ktor MCP KMP 这套组合是目前最顺的一条路。Ktor 是 JetBrains 出的纯 Kotlin 异步 Web 框架基于协程做非阻塞 I/O几个线程就能扛住大量并发连接MCPModel Context Protocol是一套让模型按标准协议调用外部工具的约定工具清单和调用结果通过 SSE 流式透出KMPKotlin Multiplatform则让你把请求体、工具契约、状态机这些领域模型写在 commonMain 里Android、iOS、Web 前端和 Ktor 后端共用同一份序列化定义彻底消除两边字段对不上的老问题。这套东西能做什么简单说你可以在一个 Ktor 服务里同时提供两件事一是普通的/chat接口接收用户问题、调用大模型、返回答案二是/mcp/sse工具端点把本地能力查时间、算数、查数据库以 MCP 标准暴露出去让模型自己决定什么时候调哪个工具。适合谁适合已经会 Kotlin、想从 Android 往全栈智能体方向走的开发者也适合手里有 KMP 项目、想加一层 AI 网关的团队。我试过把模型调用直接写死在业务代码里结果换模型、换 Key、加工具都要改一堆文件。后来改成 Ktor 网关 MCP 工具层 TaoToken 统一 Key 接入模型通道和业务逻辑彻底解耦换模型只改一个环境变量。下面从零开始把每一步都写成可复制的代码。2. TaoToken 统一 Key 接入前置准备在写 Ktor 路由之前先把模型通道打通。TaoToken 提供统一的 API 入口你不需要在代码里硬编码各家厂商的地址和密钥只要拿到一个 Key配好 Base URLKtor 里用标准 OpenAI 兼容格式发请求就行。第一步打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册账号。注册流程很常规邮箱加密码收个验证码就完事。第二步进控制台创建 API Key。地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 在 API Keys 页面点新建复制那串sk-开头的密钥。注意这个 Key 只显示一次先存到安全的地方。第三步确认 Base URL。TaoToken 的 API 入口是https://taotoken.net/api注意这个地址不带任何查询参数代码里直接用它拼/v1/chat/completions就是完整的对话接口。如果你用的是 Anthropic 风格的 Claude Code 接入Base URL 同样用这个路径换成对应的 messages 端点即可。第四步把 Key 写进环境变量别写进代码。Ktor 服务启动时用System.getenv(TAOTOKEN_API_KEY)读取本地开发可以在 IDE 的运行配置里加生产环境用容器注入。这样代码提交到仓库也不会泄露密钥。这里有个容易踩的坑有人把 Base URL 写成带 UTM 参数的推广链接结果请求 404。记住推广链接是给人点的API 调用只用https://taotoken.net/api这个干净地址。另外模型 ID 要填对比如gpt-4o、claude-3-5-sonnet这类具体支持哪些可以在模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里试一下能正常回复就说明通道没问题。前置准备做完你手里应该有三样东西一个sk-开头的 Key、Base URLhttps://taotoken.net/api、一个确认可用的模型 ID。接下来写 Ktor 代码。3. 可复制的 Ktor 路由与 MCP 工具注册配置这一节是全文核心所有代码都可以直接复制进项目。先建一个 KMP 项目模块结构建议这样分shared放 commonMain 的领域模型server放 Ktor 网关和 MCP 工具层。先看shared/src/commonMain/kotlin/model/Models.kt定义跨端复用的请求和工具契约package model import kotlinx.serialization.Serializable Serializable data class ChatRequest( val sessionId: String, val question: String ) Serializable data class ChatResponse( val answer: String, val toolCalls: ListString emptyList() ) Serializable data class ToolCall( val name: String, val arguments: MapString, String emptyMap() ) Serializable data class ToolResult( val name: String, val output: String )这几个类写在 commonMainAndroid 端和 Ktor 端共用序列化不会出现字段名不一致的问题。接着看server/build.gradle.kts的依赖关键是 Ktor 服务端、内容协商、以及 HTTP 客户端用来调 TaoTokenplugins { kotlin(jvm) kotlin(plugin.serialization) id(io.ktor.plugin) version 3.5.0 } dependencies { implementation(io.ktor:ktor-server-core:3.5.0) implementation(io.ktor:ktor-server-netty:3.5.0) implementation(io.ktor:ktor-server-content-negotiation:3.5.0) implementation(io.ktor:ktor-serialization-kotlinx-json:3.5.0) implementation(io.ktor:ktor-client-core:3.5.0) implementation(io.ktor:ktor-client-cio:3.5.0) implementation(io.ktor:ktor-client-content-negotiation:3.5.0) implementation(ch.qos.logback:logback-classic:1.5.6) }现在写主服务server/src/main/kotlin/Application.kt。先配好 TaoToken 的客户端和环境变量读取package com.example.agent import io.ktor.client.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json val taoTokenBaseUrl https://taotoken.net/api val taoTokenApiKey System.getenv(TAOTOKEN_API_KEY) ?: error(TAOTOKEN_API_KEY 未设置) val modelId System.getenv(TAOTOKEN_MODEL) ?: gpt-4o val httpClient HttpClient(CIO) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys true }) } }注意error(...)那行Key 没配就直接启动失败比运行到一半报 401 更容易定位。接下来是 MCP 工具注册。工具用强类型函数声明编译期就能生成 JSON Schema。先定义工具接口和两个示例工具package com.example.agent import model.ToolCall import model.ToolResult import java.time.LocalDateTime import java.time.format.DateTimeFormatter interface McpTool { val name: String val description: String val parameters: MapString, String suspend fun execute(call: ToolCall): ToolResult } class TimeTool : McpTool { override val name get_current_time override val description 获取服务器当前时间 override val parameters emptyMapString, String() override suspend fun execute(call: ToolCall): ToolResult { val now LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)) return ToolResult(name, now) } } class CalcTool : McpTool { override val name calculate override val description 计算两个数的加减乘除 override val parameters mapOf( a to number, b to number, op to string: add|sub|mul|div ) override suspend fun execute(call: ToolCall): ToolResult { val a call.arguments[a]?.toDoubleOrNull() ?: 0.0 val b call.arguments[b]?.toDoubleOrNull() ?: 0.0 val op call.arguments[op] ?: add val result when (op) { add - a b sub - a - b mul - a * b div - if (b 0.0) Double.NaN else a / b else - Double.NaN } return ToolResult(name, result.toString()) } } val toolRegistry: MapString, McpTool listOf( TimeTool(), CalcTool() ).associateBy { it.name }工具注册表用associateBy建索引调用时按名字查O(1) 命中。然后是 Agent 引擎负责把用户问题、工具清单一起发给 TaoToken解析模型返回的工具调用意图package com.example.agent import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* import kotlinx.serialization.Serializable import kotlinx.serialization.json.* Serializable data class OpenAiMessage(val role: String, val content: String) Serializable data class OpenAiRequest( val model: String, val messages: ListOpenAiMessage, val tools: ListJsonObject? null ) suspend fun callTaoToken(question: String): String { val toolsJson toolRegistry.values.map { tool - buildJsonObject { put(type, function) putJsonObject(function) { put(name, tool.name) put(description, tool.description) putJsonObject(parameters) { put(type, object) putJsonObject(properties) { tool.parameters.forEach { (k, v) - putJsonObject(k) { put(type, v) } } } } } } } val response httpClient.post($taoTokenBaseUrl/v1/chat/completions) { header(HttpHeaders.Authorization, Bearer $taoTokenApiKey) contentType(ContentType.Application.Json) setBody( OpenAiRequest( model modelId, messages listOf(OpenAiMessage(user, question)), tools toolsJson ) ) } val body response.bodyJsonObject() return body[choices]?.jsonArray?.firstOrNull() ?.jsonObject?.get(message) ?.jsonObject?.get(content) ?.jsonPrimitive?.content ?: 模型未返回内容 }这段代码把工具清单转成 OpenAI 兼容的tools数组TaoToken 会把它透传给底层模型。模型如果决定调工具会在返回里带tool_calls字段你可以按需解析后执行本地工具再把结果作为新一轮消息发回去。最后是 Ktor 路由把/chat和/mcp/sse两个端点接上package com.example.agent import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.response.* import io.ktor.server.request.* import io.ktor.server.routing.* import io.ktor.serialization.kotlinx.json.* import model.ChatRequest import model.ChatResponse import model.ToolCall fun main() { embeddedServer(Netty, port 8080) { install(ContentNegotiation) { json() } routing { post(/chat) { val req call.receiveChatRequest() val answer callTaoToken(req.question) call.respond(ChatResponse(answer answer)) } get(/mcp/sse) { call.response.header(Content-Type, text/event-stream) val channel call.response.channel() val schema toolRegistry.values.joinToString(,) { {name:${it.name},description:${it.description}} } channel.write(data: [$schema]\n\n.toByteArray()) channel.flush() } post(/mcp/sse/post) { val toolCall call.receiveToolCall() val tool toolRegistry[toolCall.name] ?: returnpost call.respond( mapOf(error to unknown tool) ) val result tool.execute(toolCall) call.respond(mapOf(result to result.output)) } } }.start(wait true) }到这里Ktor 路由、MCP 工具注册、TaoToken 调用三块都齐了。整个服务不到 150 行跑在 JVM 上KMP 的 shared 模块还能被 Android 端直接引用。4. 本地启动与验证请求的完整动作代码写完先确认环境变量配好。在项目根目录建一个.env文件别提交到 git内容如下TAOTOKEN_API_KEYsk-你的密钥 TAOTOKEN_MODELgpt-4o如果你用 IntelliJ IDEA在 Run Configuration 的 Environment variables 里填这两项也行。Gradle 启动命令可以这样写export TAOTOKEN_API_KEYsk-你的密钥 export TAOTOKEN_MODELgpt-4o ./gradlew :server:run看到控制台输出Responding at http://0.0.0.0:8080就说明服务起来了。第一个验证动作测/chat接口。开一个终端用 curl 发请求curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -d {sessionId:test-001,question:你好请用一句话介绍你自己}预期返回类似{answer:你好我是一个基于 Ktor 和 MCP 协议构建的 AI Agent 服务。,toolCalls:[]}如果返回里answer有内容说明 TaoToken 通道打通了模型正常回复。第二个验证动作测 MCP 工具清单端点。用 curl 访问 SSE 路由curl -N http://localhost:8080/mcp/sse预期看到一行data: [{name:get_current_time,...},{name:calculate,...}]然后连接保持打开。-N参数是禁用 curl 缓冲让你实时看到 SSE 推送。第三个验证动作直接调工具执行端点模拟模型发起工具调用curl -X POST http://localhost:8080/mcp/sse/post \ -H Content-Type: application/json \ -d {name:calculate,arguments:{a:12,b:8,op:mul}}预期返回{result:96.0}再测时间工具curl -X POST http://localhost:8080/mcp/sse/post \ -H Content-Type: application/json \ -d {name:get_current_time,arguments:{}}预期返回当前服务器时间格式类似2026-01-15 14:32:07。三个动作都通过说明整条链路是通的Ktor 接收请求、TaoToken 提供模型能力、MCP 工具层正常注册和执行。这时候你可以把/chat的 question 换成「现在几点了」观察模型是否会主动触发get_current_time工具调用。如果模型返回了tool_calls字段你在 Agent 引擎里解析后执行工具、再把结果回传就完成了完整的 Agent 闭环。5. 本篇常见报错与排查对照接入过程中最容易撞的几个报错我按真实错误信息整理成对照表遇到直接查。401 Unauthorized。返回体通常是{error:{message:Invalid API key}}。原因就两个Key 没读到或者 Key 写错了。先在终端echo $TAOTOKEN_API_KEY确认环境变量有值再检查代码里System.getenv的变量名和实际导出的名字是否一致。注意 Key 前后不要有空格复制的时候容易带上换行。local proxy failed / connection refused。这个报错说明 Ktor 的 HTTP 客户端连不上taotoken.net。先确认 Base URL 写的是https://taotoken.net/api没有多余路径。再检查本机网络能不能正常访问外网用curl -I https://taotoken.net/api看返回码。如果公司网络有出口限制换一个网络环境再试。reading choices 时抛序列化异常。典型信息是Unexpected JSON token或Field choices is required。这通常是因为模型返回了错误结构比如限流时返回的是{error:...}而不是正常的choices数组。解决办法是在解析前先判断body[error]是否存在存在就打印出来。另外确认Json { ignoreUnknownKeys true }已经配上避免模型新增字段导致解析失败。OAuth / token 过期类报错。如果你用的是 Claude Code 或 Codex 这类需要 OAuth 的客户端接入报错信息里会出现OAuth token expired或auth.json invalid。这时候检查三件套是否齐全Base URL 填https://taotoken.net/apiKey 填sk-开头的密钥Model ID 填你确认可用的模型名。Codex 的auth.json里字段名要和官方一致OPENAI_API_KEY和OPENAI_BASE_URL两个键都要有。CC Switch 或 Cline MCP 配置里同样要写全这三项缺一个都会报鉴权失败。MCP SSE 连接建立后立刻断开。检查call.response.header(Content-Type, text/event-stream)是否在写数据之前设置。另外 Ktor 的 Netty 引擎默认有请求超时SSE 长连接需要在embeddedServer配置里调大requestTimeout或者用install(RequestTimeout)单独给 SSE 路由放行。工具调用返回 unknown tool。说明toolRegistry里没有这个名字。检查ToolCall.name和McpTool.name是否完全一致大小写敏感。注册表是用associateBy { it.name }建的名字对不上就查不到。排查顺序建议从外到内先 curl 测 TaoToken 通道通不通再测 Ktor 服务起没起最后测工具注册对不对。每层单独验证比一上来就调整个链路容易定位。6. 把模型通道和业务逻辑彻底解耦整套跑下来最值得说的一点是模型通道和业务逻辑必须解耦。我见过太多项目把 API Key、Base URL、模型名硬编码在业务代码里换一个模型要改十几个文件加一个工具要动核心逻辑。用 Ktor 做网关、MCP 做工具契约、TaoToken 做统一 Key 接入这三层各管各的换模型只改环境变量加工具只加一个类。如果你打算长期做编码类 Agent或者要把这套服务部署到多端建议把 Coding Plan 也了解一下地址是 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有面向长期编码场景的通道配置说明。API Key 管理和接入文档分别在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 和 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 遇到鉴权或路径问题先翻文档。最后留一个实用技巧在callTaoToken里加一行日志把每次请求的模型 ID 和耗时打出来。跑一段时间你就能看出哪个模型响应快、哪个工具调用频繁后面做成本优化和工具裁剪就有数据支撑了。这套服务现在跑在 8080 端口你可以直接把它塞进现有的 KMP 项目Android 端复用 shared 模块的ChatRequest前后端字段永远对得上。