☰
Ktor 构建本地优先 AI Agent:离线推理与工具调用实战
2026/11/2 3:00:18 网站建设 项目流程

1. 为什么我选择用 Ktor 来搭一个本地优先的 AI Agent

1.1 从一次断网经历说起

去年冬天我在高铁上想改一段 Agent 的调度逻辑,结果发现手头那个基于云端推理服务的原型彻底趴窝了——没网,什么都跑不起来。那一刻我意识到一个问题:AI Agent 这东西,如果连本地都跑不通,那它本质上只是个远程 API 的壳子。于是我开始琢磨,能不能用一套纯 JVM 的技术栈,把 Agent 的核心循环、工具调用、记忆管理全部塞进本地进程里,网络只作为可选的增强项而不是必需项。

这就是这个项目的起点。标题里说的"本地优先",不是营销词,而是一条硬约束:默认离线可用,联网只是加分项。而选 Ktor 作为底层框架,是因为它同时满足了我三个诉求——轻量、协程原生、能同时当服务端和客户端用。Kotlin 生态里能把这三点凑齐的,Ktor 几乎是唯一解。

1.2 本地优先到底意味着什么

先把概念说清楚。所谓本地优先的 Agent,指的是这么几件事:

  • 推理可以走本地模型,比如通过 Ollama 或者 llama.cpp 的 JVM 绑定跑量化模型,不依赖任何远程接口;
  • 工具调用在本地完成,文件读写、命令执行、SQLite 查询这些都在本机进程内闭环;
  • 记忆持久化在本地,用嵌入式数据库存对话历史和向量索引,而不是往云上同步;
  • 网络是可选层,需要联网搜索或者调用远程大模型时才启用,断网时自动降级到本地能力。

这么设计的好处很直接:隐私不出机器、延迟可控、成本几乎为零、调试时不用等网络往返。坏处也很明显——本地模型能力有限、显存吃紧、并发上不去。所以整个项目的架构核心,就是在本地能力和远程能力之间做优雅的降级与切换。

1.3 Ktor 在这个架构里扮演什么角色

很多人对 Ktor 的印象还停留在"Kotlin 版的 Web 框架",其实它在 Agent 场景里能干的事远不止于此。我把它拆成了三个用途:

第一,作为 Agent 的 HTTP 服务层。Agent 需要一个对外的接口来接收任务、返回结果、暴露状态,Ktor Server 用几行代码就能起一个带协程支持的嵌入式服务,不需要 Tomcat 那种重容器。

第二,作为工具调用的客户端。Agent 要调用外部工具时,Ktor Client 的多引擎抽象特别好用——本地工具走 CIO 引擎,远程 API 走 OkHttp 引擎,切换只改一行配置。

第三,作为流式输出的通道。大模型的 token 是流式吐出来的,Ktor 的respondTextWriter配合 Kotlin Flow,能把 SSE 流式响应做得很干净,前端拿到的是逐字输出的效果。

提示:Ktor 3.x 之后对协程调度做了不少优化,如果你还在用 2.x,建议升级,流式场景下的表现差异挺明显。

2. 项目整体架构与核心模块拆解

2.1 一张图看懂模块划分

整个项目我分成了六个核心模块,每个模块职责单一,通过接口解耦:

模块名职责关键技术
agent-coreAgent 主循环、状态机、任务调度Kotlin 协程、Flow
agent-llm大模型适配层,本地/远程统一接口Ktor Client、SSE 解析
agent-tools工具注册与调用反射、JSON Schema
agent-memory短期对话 + 长期向量记忆SQLite、嵌入式向量库
agent-server对外 HTTP/WebSocket 服务Ktor Server
agent-cli命令行交互入口Kotlin CLI、JLine

这么拆的原因是:Agent 的复杂度不在单个功能,而在模块之间的编排。如果全塞一个模块里,改一处逻辑要重新理解整个文件,维护成本会爆炸。分开之后,每个模块可以独立测试,LLM 层换模型不影响工具层,记忆层换存储不影响主循环。

2.2 Agent 主循环的设计取舍

Agent 的核心就是一个循环:接收输入 → 思考 → 决定调用工具 → 执行工具 → 观察结果 → 继续思考 → 直到给出最终答案。听起来简单,但实现时有几个坑。

第一个坑是循环终止条件。如果模型一直决定调用工具而不给最终答案,就会死循环。我的做法是设一个最大迭代次数(默认 10 轮),超过就强制要求模型输出总结。这个数字不是拍脑袋定的,实测下来大部分任务 3 到 5 轮就能收敛,10 轮是给复杂任务留的余量。

第二个坑是工具调用的错误处理。工具执行失败时,不能直接抛异常终止循环,而应该把错误信息作为观察结果喂回给模型,让它自己决定是重试、换工具还是放弃。这一点很关键,因为模型往往能根据错误信息自我修正。

第三个坑是状态的可中断性。用户可能中途想取消任务,所以整个循环必须支持协程取消。Ktor 的协程上下文天然支持这个,只要在每个挂起点检查isActive就行。

2.3 为什么用 jlink 和 jdeps 来打包

项目做到后期,我发现一个尴尬的问题:用户想跑这个 Agent,得先装 JDK,还得配环境变量。对于非开发者来说这是巨大的门槛。于是我用 jlink 做了一套自定义运行时。

jlink 的原理是:JDK 本身是模块化的,你可以用jdeps分析出你的应用实际依赖哪些 JDK 模块,然后只把这些模块打包进一个精简的运行时。我的项目实测下来,完整 JDK 大概 300MB,用 jlink 裁剪后只有 60MB 左右,而且用户机器上不需要预装任何 Java 环境。

具体流程是这样的:

# 第一步:用 jdeps 分析依赖 jdeps --multi-release 21 --ignore-missing-deps \ --print-module-deps \ --class-path "build/libs/agent-all.jar" \ build/libs/agent-all.jar # 输出类似:java.base,java.sql,java.logging,java.naming,jdk.crypto.ec # 第二步:用 jlink 生成运行时 jlink --add-modules java.base,java.sql,java.logging,java.naming,jdk.crypto.ec \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=zip-6 \ --output build/custom-jre # 第三步:用 jpackage 打成安装包 jpackage --type app-image \ --name LocalAgent \ --input build/libs \ --main-jar agent-all.jar \ --main-class com.example.agent.MainKt \ --runtime-image build/custom-jre

注意:--compress=zip-6在 JDK 21 之后改成了--compress=zip-6,老版本是--compress=2,参数名变了,别照抄旧教程。

这套流程踩过的坑不少。比如jdeps有时候会漏掉反射加载的模块,导致运行时抛ClassNotFoundException。我的经验是:先跑一遍完整 JDK,用-verbose:class观察实际加载了哪些类,再反推需要哪些模块。宁可多带一两个模块,也别漏。

3. 核心细节:LLM 适配层与工具调用怎么落地

3.1 统一 LLM 接口的设计

本地模型和远程模型的 API 差异很大。本地 Ollama 走的是/api/chat,远程的走的是 OpenAI 兼容格式,还有些走自己的私有协议。如果每个都写一套调用逻辑,代码会乱成一团。

我的做法是定义一个统一的接口:

interface LlmProvider { suspend fun chat( messages: List<ChatMessage>, tools: List<ToolSpec> = emptyList(), stream: Boolean = false ): Flow<ChatChunk> val supportsToolCalling: Boolean val supportsStreaming: Boolean }

然后每个后端实现这个接口。Ollama 一个实现,OpenAI 兼容的一个实现,本地 llama.cpp 绑定的一个实现。上层主循环只依赖这个接口,完全不关心底层是谁。

这里有个细节值得说:supportsToolCalling这个属性很重要。不是所有本地模型都支持函数调用,有些小模型压根没这个能力。如果模型不支持,主循环就得降级到"用提示词让模型输出 JSON 格式的工具调用意图,然后自己解析"的模式。这个降级逻辑我封装在了一个ToolCallingStrategy里,根据 provider 的能力自动选择。

3.2 流式响应的解析

流式响应是体验的关键。用户等 10 秒看到完整答案,和逐字看到答案冒出来,感受完全不同。但流式解析有个麻烦:SSE 的 chunk 边界不保证和 JSON 对象边界对齐,可能一个 JSON 被切成两半。

我的处理方式是维护一个缓冲区,每次收到数据就追加进去,然后尝试按行分割解析,解析失败的部分留在缓冲区等下一批数据。核心逻辑大概是这样:

var buffer = StringBuilder() client.preparePost("/v1/chat/completions") { setBody(request) }.execute { response -> response.bodyAsChannel().readUTF8LineLoop { line -> if (line.startsWith("data: ")) { val data = line.removePrefix("data: ").trim() if (data == "[DONE]") return@readUTF8LineLoop buffer.append(data) try { val chunk = json.decodeFromString<ChatChunk>(buffer.toString()) emit(chunk) buffer = StringBuilder() } catch (e: SerializationException) { // 数据不完整,等下一批 } } } }

提示:Ktor 的readUTF8LineLoop是挂起函数,配合 Flow 的flow { }构建器用起来很自然,比手动管理 ByteReadChannel 省心。

3.3 工具注册与 JSON Schema 生成

工具调用的核心是让模型知道"有哪些工具可用、每个工具要什么参数"。主流做法是给模型传一份 JSON Schema 描述。手写 Schema 太累,我用反射自动生成。

定义一个注解:

@Target(AnnotationTarget.FUNCTION) annotation class AgentTool( val name: String, val description: String ) @Target(AnnotationTarget.VALUE_PARAMETER) annotation class ToolParam(val description: String)

然后写一个工具类:

class FileTools { @AgentTool("read_file", "读取指定路径的文件内容") fun readFile( @ToolParam("文件路径") path: String ): String = File(path).readText() }

启动时扫描所有带@AgentTool的方法,用反射提取参数类型和描述,生成 JSON Schema。这样加新工具只需要写一个方法加注解,不用改任何注册代码。

这里有个坑:Kotlin 的反射拿参数名需要开启-java-parameters编译选项,否则参数名会变成arg0、arg1。在 Gradle 里加这么一行:

tasks.withType<KotlinCompile> { compilerOptions { javaParameters.set(true) } }

3.4 记忆层的双轨设计

记忆分两块:短期记忆是当前对话的上下文,长期记忆是跨会话的知识沉淀。

短期记忆简单,就是一个有上限的消息列表,超过 token 预算就做摘要压缩。我的做法是保留最近 N 轮完整对话,更早的用一个小模型压缩成一段摘要。这样既控制了上下文长度,又不丢失关键信息。

长期记忆用的是向量检索。对话结束后,把关键信息抽出来做 embedding,存进本地向量库。下次对话时先做相似度检索,把相关的历史片段召回作为上下文。向量库我选的是嵌入式方案,数据存在本地 SQLite 文件里,不依赖任何外部服务。

注意:embedding 模型的选择很关键。本地跑的话,小模型(比如 100MB 级别的)效果够用但召回率一般,大模型效果好但吃资源。我的折中是:默认用小模型,用户可以在配置里换成远程 embedding 服务。

4. 实操过程:从零跑通一个本地 Agent

4.1 环境准备与依赖配置

先把环境说清楚。我用的技术栈版本如下:

  • JDK 21(LTS,jlink 支持完善)
  • Kotlin 2.0+
  • Ktor 3.0+
  • Gradle 8.5+

Gradle 依赖核心就这几个:

dependencies { // Ktor 服务端 implementation("io.ktor:ktor-server-core:3.0.0") implementation("io.ktor:ktor-server-netty:3.0.0") implementation("io.ktor:ktor-server-content-negotiation:3.0.0") implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0") // Ktor 客户端 implementation("io.ktor:ktor-client-core:3.0.0") implementation("io.ktor:ktor-client-cio:3.0.0") implementation("io.ktor:ktor-client-okhttp:3.0.0") // 协程 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") // 序列化 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") // 本地数据库 implementation("org.xerial:sqlite-jdbc:3.46.0.0") // 反射 implementation(kotlin("reflect")) }

提示:Ktor 3.0 的包名和 2.x 有变化,ktor-server-netty的引擎配置方式也调整了,迁移时注意看官方 changelog。

4.2 启动一个最小的 Agent 服务

先跑通最小闭环。一个 Ktor 服务,一个/chat接口,接收用户消息,调用 LLM,返回结果:

fun main() { embeddedServer(Netty, port = 8080) { install(ContentNegotiation) { json() } routing { post("/chat") { val request = call.receive<ChatRequest>() val agent = AgentEngine(llmProvider = OllamaProvider()) val result = agent.run(request.message) call.respond(ChatResponse(result)) } } }.start(wait = true) }

AgentEngine 的核心循环:

class AgentEngine(private val llmProvider: LlmProvider) { suspend fun run(input: String): String { val messages = mutableListOf(ChatMessage(role = "user", content = input)) var iterations = 0 while (iterations < MAX_ITERATIONS) { val response = llmProvider.chat(messages, tools = toolRegistry.specs()) .last() // 非流式场景取最后一个 chunk if (response.toolCalls.isEmpty()) { return response.content } messages.add(response.toMessage()) for (call in response.toolCalls) { val result = toolRegistry.invoke(call.name, call.arguments) messages.add(ChatMessage(role = "tool", content = result)) } iterations++ } return "任务超过最大迭代次数,已终止" } }

这段代码跑通,你就有了一个能调用工具的 Agent 骨架。接下来就是往里填工具、填记忆、填流式。

4.3 接入本地模型

本地模型我用 Ollama 做演示,因为它安装简单、API 清晰。装好 Ollama 后拉一个支持工具调用的模型:

ollama pull qwen2.5:7b

然后实现 OllamaProvider:

class OllamaProvider( private val baseUrl: String = "http://localhost:11434" ) : LlmProvider { private val client = HttpClient(CIO) override suspend fun chat( messages: List<ChatMessage>, tools: List<ToolSpec>, stream: Boolean ): Flow<ChatChunk> = flow { val response = client.post("$baseUrl/api/chat") { contentType(ContentType.Application.Json) setBody(OllamaRequest(messages, tools, stream)) } // 解析响应,emit chunk } override val supportsToolCalling = true override val supportsStreaming = true }

实测下来,7B 级别的模型在工具调用上已经够用,但复杂推理会力不从心。如果你的机器显存够,上 14B 或者 32B 效果会好很多。显存不够的话,用 4bit 量化版本,7B 大概占 5GB 左右。

4.4 用 jlink 打包成独立可执行文件

前面讲了 jlink 的原理,这里给完整的 Gradle 任务配置:

tasks.register<Exec>("analyzeDeps") { commandLine("jdeps", "--multi-release", "21", "--ignore-missing-deps", "--print-module-deps", "--class-path", "build/libs/agent-all.jar", "build/libs/agent-all.jar") } tasks.register<Exec>("buildRuntime") { dependsOn("analyzeDeps") commandLine("jlink", "--add-modules", "java.base,java.sql,java.logging,java.naming,jdk.crypto.ec", "--strip-debug", "--no-man-pages", "--no-header-files", "--compress=zip-6", "--output", "build/custom-jre") }

打包完成后,build/custom-jre/bin/java就是你的精简运行时。用户拿到整个目录,双击启动脚本就能跑,不需要装任何东西。

注意:Windows 上 jlink 生成的运行时目录结构和 Linux 不同,启动脚本要分别写。我是在 Gradle 里根据OperatingSystem.current()判断,生成对应的.bat或.sh。

5. 常见问题与排查技巧实录

5.1 工具调用不生效的几种情况

这是踩坑最多的地方。模型明明支持工具调用,但就是不调用,或者调用了但参数解析失败。我整理了一张排查表:

现象可能原因排查方法
模型从不调用工具工具描述太模糊检查 description 是否说清了"什么时候用"
调用但参数为空Schema 生成有问题打印实际发送的 JSON Schema
参数类型不匹配反射拿到的类型不对检查-java-parameters是否开启
调用后循环不继续观察结果没喂回模型检查 messages 是否正确追加
一直重复调用同一工具终止条件缺失加最大迭代次数限制

我的经验是:工具描述要写得像给新人看的说明书。不要写"读取文件",要写"读取指定路径的文本文件内容,适用于需要查看文件内容的场景,参数 path 是文件的绝对路径"。描述越具体,模型调用越准确。

5.2 流式输出卡顿或断流

流式场景常见的问题是:前端收到一半就没数据了,或者数据来得不均匀。原因通常有三个:

第一,缓冲区没刷新。Ktor 的respondTextWriter默认有缓冲,需要手动flush()。我的做法是每收到一个 chunk 就 flush 一次。

第二,协程调度器不对。流式输出是 IO 密集型,如果跑在Dispatchers.Default上会和其他计算任务抢线程。应该用Dispatchers.IO。

第三,客户端超时设置太短。大模型首 token 可能要等好几秒,如果客户端超时设成 3 秒就会断。建议首 token 超时设 30 秒,后续 chunk 间隔超时设 10 秒。

5.3 jlink 打包后的运行时缺类

这个坑我踩了两次。第一次是java.sql模块没加,SQLite 连接直接抛异常。第二次是jdk.crypto.ec没加,HTTPS 请求握手失败。

根本原因是jdeps的静态分析看不透反射和动态加载。我的解决办法是:打包后一定要在干净环境里跑一遍完整测试,包括数据库操作、HTTPS 请求、文件读写。发现问题就补模块,别嫌麻烦。

还有一个技巧:用--add-modules ALL-MODULE-PATH先打一个全量包,跑通后再逐步裁剪。这样能快速定位到底缺哪个模块。

5.4 本地模型并发上不去

标题热词里有个"ai agent 怎么扛并发",这确实是痛点。本地模型推理是串行的,一个请求占满 GPU,第二个请求就得排队。

我的处理策略是分级降级:

  • 并发低时,走本地模型,延迟低但吞吐有限;
  • 并发高时,自动切换到远程模型,本地只做轻量任务;
  • 极端情况下,对请求做队列化,返回"排队中"状态,前端轮询。

这个切换逻辑封装在一个LlmRouter里,根据当前队列深度和响应延迟动态决策。实测下来,单机本地模型大概能扛 2 到 3 路并发,再多就得靠远程兜底。

提示:如果你的场景对并发要求高,建议从一开始就把 LLM 层设计成可插拔的,别把本地模型写死。后期加远程支持会省很多事。

5.5 记忆检索召回不准

向量检索召回不准,通常是 embedding 模型和查询语义不匹配。我的调优经验是:

  • 查询改写:用户的问题往往很短,直接拿去检索效果差。先用小模型把问题扩写成一段完整描述,再检索。
  • 混合检索:纯向量检索对关键词不敏感,加上 BM25 关键词检索做融合,召回率能提升不少。
  • 重排序:召回 top 20,再用一个小的重排序模型精排取 top 5,精度提升明显。

这三招组合下来,我的测试集上召回准确率从 60% 左右提到了 85% 以上。当然代价是每次检索多花几百毫秒,看你的场景能不能接受。

6. 一些关于本地优先 Agent 的个人体会

做这个项目最大的感受是:本地优先不是技术炫技,而是一种产品哲学。当你把"必须联网"这个假设去掉之后,很多设计会变得不一样。你会开始认真考虑模型体积、内存占用、冷启动时间这些在云端根本不用管的问题。这些约束反过来会逼你把架构做得更干净。

Ktor 在这个过程里帮了大忙。它的协程模型让流式处理和并发控制写起来很自然,多引擎的客户端抽象让本地和远程的切换几乎零成本。如果你也在做类似的东西,我的建议是:先把本地闭环跑通,再考虑联网增强。反过来做的话,你会被网络问题牵着鼻子走,最后做出来的还是个 API 壳子。

jlink 和 jdeps 这套打包方案,一开始觉得麻烦,但当你把 60MB 的运行时发给用户、对方双击就能跑的时候,那种顺畅感是值得的。尤其是给非技术用户用的时候,少一步环境配置,就少一半的求助消息。

最后分享一个小技巧:调试 Agent 的时候,把每一轮的 messages 完整打印出来,包括工具调用的参数和返回。很多时候问题不在模型,而在你喂给它的上下文。看到完整的对话流,问题往往一眼就能定位。这个习惯帮我省了无数个小时。

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

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

立即咨询