☰
Ktor + MCP 协议:从零搓一个跑在 KMP 后端的 AI Agent 服务(TaoToken 统一 Key 接入篇)
2026/10/7 7:09:49 网站建设 项目流程

1. 为什么要在 KMP 后端用 Ktor 搓 MCP Agent 服务

如果你写过 Android,又想把 AI Agent 能力塞进自己的后端,Ktor + MCP + KMP 这套组合是目前最顺的一条路。Ktor 是 JetBrains 出的纯 Kotlin 异步 Web 框架,基于协程做非阻塞 I/O,几个线程就能扛住大量并发连接;MCP(Model Context Protocol)是一套让模型按标准协议调用外部工具的约定,工具清单和调用结果通过 SSE 流式透出;KMP(Kotlin 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 URL,Ktor 里用标准 OpenAI 兼容格式发请求就行。

第一步,打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册账号。注册流程很常规,邮箱加密码,收个验证码就完事。

第二步,进控制台创建 API Key。地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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: List<String> = emptyList() ) @Serializable data class ToolCall( val name: String, val arguments: Map<String, String> = emptyMap() ) @Serializable data class ToolResult( val name: String, val output: String )

这几个类写在 commonMain,Android 端和 Ktor 端共用,序列化不会出现字段名不一致的问题。

接着看server/build.gradle.kts的依赖,关键是 Ktor 服务端、内容协商、以及 HTTP 客户端用来调 TaoToken:

plugins { 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: Map<String, String> suspend fun execute(call: ToolCall): ToolResult } class TimeTool : McpTool { override val name = "get_current_time" override val description = "获取服务器当前时间" override val parameters = emptyMap<String, 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: Map<String, 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: List<OpenAiMessage>, val tools: List<JsonObject>? = 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.body<JsonObject>() 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.receive<ChatRequest>() 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.receive<ToolCall>() val tool = toolRegistry[toolCall.name] ?: return@post 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_KEY=sk-你的密钥 TAOTOKEN_MODEL=gpt-4o

如果你用 IntelliJ IDEA,在 Run Configuration 的 Environment variables 里填这两项也行。Gradle 启动命令可以这样写:

export TAOTOKEN_API_KEY=sk-你的密钥 export TAOTOKEN_MODEL=gpt-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/api,Key 填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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面有面向长期编码场景的通道配置说明。API Key 管理和接入文档分别在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 和 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,遇到鉴权或路径问题先翻文档。

最后留一个实用技巧:在callTaoToken里加一行日志,把每次请求的模型 ID 和耗时打出来。跑一段时间你就能看出哪个模型响应快、哪个工具调用频繁,后面做成本优化和工具裁剪就有数据支撑了。这套服务现在跑在 8080 端口,你可以直接把它塞进现有的 KMP 项目,Android 端复用 shared 模块的ChatRequest,前后端字段永远对得上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询