1. 为什么我选择用 Ktor 来搭一个本地优先的 AI Agent1.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 协程、Flowagent-llm大模型适配层本地/远程统一接口Ktor Client、SSE 解析agent-tools工具注册与调用反射、JSON Schemaagent-memory短期对话 长期向量记忆SQLite、嵌入式向量库agent-server对外 HTTP/WebSocket 服务Ktor Serveragent-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 \ --compresszip-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注意--compresszip-6在 JDK 21 之后改成了--compresszip-6老版本是--compress2参数名变了别照抄旧教程。这套流程踩过的坑不少。比如jdeps有时候会漏掉反射加载的模块导致运行时抛ClassNotFoundException。我的经验是先跑一遍完整 JDK用-verbose:class观察实际加载了哪些类再反推需要哪些模块。宁可多带一两个模块也别漏。3. 核心细节LLM 适配层与工具调用怎么落地3.1 统一 LLM 接口的设计本地模型和远程模型的 API 差异很大。本地 Ollama 走的是/api/chat远程的走的是 OpenAI 兼容格式还有些走自己的私有协议。如果每个都写一套调用逻辑代码会乱成一团。我的做法是定义一个统一的接口interface LlmProvider { suspend fun chat( messages: ListChatMessage, tools: ListToolSpec emptyList(), stream: Boolean false ): FlowChatChunk 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]) returnreadUTF8LineLoop buffer.append(data) try { val chunk json.decodeFromStringChatChunk(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.withTypeKotlinCompile { compilerOptions { javaParameters.set(true) } }3.4 记忆层的双轨设计记忆分两块短期记忆是当前对话的上下文长期记忆是跨会话的知识沉淀。短期记忆简单就是一个有上限的消息列表超过 token 预算就做摘要压缩。我的做法是保留最近 N 轮完整对话更早的用一个小模型压缩成一段摘要。这样既控制了上下文长度又不丢失关键信息。长期记忆用的是向量检索。对话结束后把关键信息抽出来做 embedding存进本地向量库。下次对话时先做相似度检索把相关的历史片段召回作为上下文。向量库我选的是嵌入式方案数据存在本地 SQLite 文件里不依赖任何外部服务。注意embedding 模型的选择很关键。本地跑的话小模型比如 100MB 级别的效果够用但召回率一般大模型效果好但吃资源。我的折中是默认用小模型用户可以在配置里换成远程 embedding 服务。4. 实操过程从零跑通一个本地 Agent4.1 环境准备与依赖配置先把环境说清楚。我用的技术栈版本如下JDK 21LTSjlink 支持完善Kotlin 2.0Ktor 3.0Gradle 8.5Gradle 依赖核心就这几个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.receiveChatRequest() 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然后实现 OllamaProviderclass OllamaProvider( private val baseUrl: String http://localhost:11434 ) : LlmProvider { private val client HttpClient(CIO) override suspend fun chat( messages: ListChatMessage, tools: ListToolSpec, stream: Boolean ): FlowChatChunk 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.registerExec(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.registerExec(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, --compresszip-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 完整打印出来包括工具调用的参数和返回。很多时候问题不在模型而在你喂给它的上下文。看到完整的对话流问题往往一眼就能定位。这个习惯帮我省了无数个小时。