本地优先AI Agent实战:基于Ktor的架构设计与工具调用实现
写这篇稿子的时候,我的 GitHub 仓库刚好破了 1k star。很多人私信问我:为什么用 Ktor?本地优先到底是个什么思路?Agent 的工具调用是怎么实现的?我干脆把这些年从构思到开源的一整套经验整理出来,结合这个项目的真实代码形态,聊一聊我做了什么、踩过哪些坑、为什么这么设计。
这个项目是一个完全本地优先的开源 AI Agent,核心服务端是 Kotlin + Ktor。所谓“本地优先”,不是简单地把模型跑在本机,而是所有上下文、会话记录、工具配置、知识库切片都优先存在本地,与云端 API 解耦。它能做到离线启动、网络隔离环境下部署、私有知识库不上传,同时保留接入远程大模型的能力。适合想自己做私有化 AI 助手、研究 Agent 编排机制、或者想试试用 JVM 体系写 AI 基础设施的同学参考。
1. 为什么偏偏是 Ktor:技术选型的完整逻辑
1.1 从 Spring Boot 转过来的第一直觉
最开始我其实用 Spring Boot 写了一个原型版本。Spring Boot 生态成熟,配置全家桶一顿梭哈,几小时就能把 REST 接口跑起来。但原型能跑和长期维护是两回事,越写到后面越难受。
第一是内存占用。Spring Boot 随便起一个空工程,JVM 堆外加各种自动配置,内存轻松上 300MB。我这个 Agent 要跑在用户的笔记本上,甚至树莓派上,这种资源开销不可接受。
第二是启动速度。本地优先的产品,用户双击就能跑是最基本的体验要求。Spring Boot 冷启动 3~5 秒,再加上模型加载时间,体验非常难受。
第三是阻塞模型。Spring Boot 默认的 Tomcat + Servlet 模型虽然也能支持虚拟线程,但整个链路依然是传统的请求线程池模型。而 AI Agent 的核心交互是流式输出、长连接、多工具并行调用,本质上是 IO 密集 + 高并发连接,但每个请求持续时间又很长。传统的“一个请求占一个线程”在这种场景下会迅速耗尽线程池。
Ktor 则完全不同。它是 Kotlin 协程原生的服务器框架,Netty 引擎下每个连接的成本非常低,可以同时维持成千上万个 SSE 长连接而不需要为每个连接分配一个线程。我后来对比过,同样在 4C8G 的机器上,Ktor 版本的内存占用只有 Spring Boot 版本的三分之一,启动时间从 4 秒降到 800 毫秒。这个差距对桌面端本地服务来说是天壤之别。
1.2 Ktor 的协程模型和 AI Agent 天然匹配
AI Agent 不是简单的“收到请求 → 返回结果”,而是一个不断循环的推理过程:接收用户输入 → 调用模型 → 得到工具调用意图 → 执行工具 → 把结果回填给模型 → 再推理 → 再调用工具……每一步都是异步的,而且中间还夹杂着流式生成。
用阻塞模型写这个循环非常痛苦,要么用回调地狱,要么用响应式编程把每一层都包成 Mono/Flux,心智负担极重。Ktor 的挂起函数(suspend function)和 Kotlin 协程让这个循环可以直接写成同步风格的代码,编译器帮我们把异步细节封装掉。
我实际写了一段核心调度逻辑,大概是这样的:
suspend fun runAgent(userInput: String): AgentResponse { var messages = history.load() messages += Message.user(userInput) repeat(maxIterations) { val response = model.complete(messages) val toolCalls = response.toolCalls if (toolCalls.isEmpty()) { return AgentResponse(text = response.content, messages = messages) } messages += Message.assistant(response.content, toolCalls) val toolResults = toolCalls.map { call -> async { toolRegistry.execute(call) } }.awaitAll() messages += Message.toolResults(toolResults) } throw TooManyIterationsException() }这段代码看起来像是普通的同步循环,但每一条model.complete、toolRegistry.execute都是挂起函数,内部会释放线程。协程调度器在等待模型响应时可以让其他请求继续执行。写多了 Java 的人看到这个应该会非常感动:复杂异步逻辑的代码居然可以这么简洁、可读。
1.3 插件化设计与本地优先场景的契合
Ktor 的另一个核心设计是 Pipeline + 插件(或叫拦截器)。请求从进入开始,会依次经过各种插件:CORS、认证、压缩、日志、自定义拦截器。Agent 场景下我依赖了这几点:
- 身份认证可以按路由定制:本地模式完全不鉴权,局域网模式用 Token,云端部署时用 JWT。
- 全局错误处理插件,统一把模型超时、工具异常、上下文过长等错误映射为 SSE 错误帧。
- 请求日志插件可以记录每次 Agent 调用的 token 消耗和耗时,方便自托管用户观察运行情况。
这种插件机制天然适合做“一个内核适配多种部署模式”。本地优先不是只能本机跑,而是优先考虑本地运行,但当你需要多设备访问时,改个配置就能变成局域网服务,不用动业务代码。
1.4 开源协议与生态的考量
选择 Ktor 还有一个很现实的原因:它使用 Apache 2.0 许可证。我计划把这个项目开源,而且希望保持宽松协议,让企业用户能放心地改造成自己的内部工具,不用承担传染性协议的风险。Ktor 本身和 Kotlin 语言绑定紧密,意味着代码里能大量使用 Kotlin 的协程、数据类、密封类等特性,相比 Java 版本代码量能减少接近 40%。
当然,Ktor 也有它的短板:生态比 Spring 小,很多“开箱即用”的 starter 得自己写;文档中英文都偏简略,经常要看源码才能解决配置问题;社区案例较少,遇到奇怪问题搜索不到答案。但对于一个 AI Agent 项目来说,这些短板换来的高并发能力和低资源占用,我觉得非常划算。
2. 本地优先到底优先了什么:设计理念与运行机制
2.1 数据不出本的架构假设
很多 AI Agent 项目一上来就设计成“客户端 → 云端服务 → 大模型 API”的结构,会话记录、知识库、用户画像全在云端。但本地优先的设计逻辑完全不同:整个 Agent 服务作为一个本地进程运行,监听回环地址或局域网地址,所有数据默认写在本地磁盘。
这意味着需要重新审视每一层:
- 会话记录:本地 SQLite,不用远端数据库。
- 知识库:本地向量数据库(如 LanceDB / sqlite-vec),不上传文档。
- 配置文件:YAML / TOML 放在本地目录,用户可随时编辑。
- 模型调用:默认对接本地模型(如 Ollama、llama.cpp),若用户配置了云端 API,也只是把请求发出去,但传输内容经过用户自己的授权。
这个假设的价值在于:它是一个隐私安全的底线。用户对自己的数据拥有绝对控制权。企业内网部署时,不需要担心数据经过第三方中转。
2.2 离线可用与异常降级
我给自己定了一个硬性指标:完全没有外网的情况下,Agent 必须还能正常工作。这意味着所有功能都不能强依赖云端服务。
- 嵌入模型本地化:在 Ollama 里跑
nomic-embed-text或bge-m3,向量化不依赖远程 API。 - 大模型本地化:Ollama 加载 qwen2.5、gemma 一类的开放权重模型。速度可能慢一点,但逻辑推理和工具调用能力已经足够日常使用。
- 知识库离线拆解:纯本地实现 PDF/Markdown/Word 解析和文本切片。
离线时的体验差别只在于回答质量和速度,但功能链路是通的。当用户想用更好的模型时,在配置文件中加一个 OpenAI 兼容的 baseUrl 就能切换。这种降级设计是我最满意的地方:没有一个功能是“离开云端就废掉”的。
2.3 本地知识库的向量化方案
知识库是 Agent 的长期记忆来源。很多用户喜欢上传 PDF 然后问“这个文档里的结论是什么”,这背后就是知识库问答。
我选择的切片策略比较简单粗暴但稳定:
- 按章节标题切分(Markdown 的
#、PDF 中的大纲),保留上下文结构。 - 每个切片不超过 512 个 token,相邻切片之间重叠 64 个 token,避免切断语义。
- 用本地嵌入模型计算向量,存入向量表。
- 查询时取 TopK=5,然后把命中的切片作为 system prompt 的一部分回填给大模型。
性能上要特别留意:如果每次查询都实时计算用户问题的向量,会有一个几十到几百毫秒的延迟。我在服务启动时预加载了嵌入模型,并用缓存存储最近 1000 条问题的向量结果,实测命中率还不错。
2.4 会话状态与多设备同步
本地优先有一个天然劣势:换设备之后会话记录不在这台机器上。我不能像云端产品那样无感同步,但可以做到“可迁移”:所有的会话数据都保存在一个独立的data/目录下,用户把这个目录拷贝到另一台设备,启动后数据完全恢复。
另外,我留了一个实验性功能:支持将data/目录通过 WebDAV 或 Syncthing 同步到其他设备。这不算传统意义的“云同步”,但满足了用户“数据自己掌握”的诉求。内部实现是,每次会话结束后把 SQLite 变更事件广播出去,同步插件收到后增量推送。这种设计比中心化云同步简单得多,而且不会引入新的数据安全隐患。
3. Agent 的核心工作流:从意图解析到工具调用
3.1 多轮对话的状态管理
脚本小子都知道要把messages拼在一起发给模型。但真实场景下,上下文窗口有上限,不可能无限累积。我设计了一个滑动窗口 + 摘要压缩机制:
- 当 messages 的总 token 数超过阈值(默认 16000)时,把最早的对话发送给一个低成本模型做摘要。
- 摘要作为一条 system 消息注入到对话开头。
- 原始的细粒度消息存入本地 SQLite,随时可以调取完整记录。
这套机制实测下来能让长对话不崩,但要注意摘要模型不能太差。我试过用 3B 模型做摘要,结果把关键事实压缩错了,后面回答全部偏离。建议至少用 7B 以上的模型做摘要。
3.2 模型接入层:兼容 OpenAI 格式与本地模型
现在的大模型服务,基本都兼容 OpenAI 的/chat/completions接口格式。因此我把模型层抽象成了一个ModelProvider接口:
interface ModelProvider { suspend fun chat(messages: List<Message>, options: ChatOptions): ChatResponse suspend fun chatStream(messages: List<Message>, options: ChatOptions): Flow<ChatChunk> suspend fun embed(texts: List<String>): List<List<Float>> }结构上分三块:
OpenAIProvider:处理标准 OpenAI 格式,可以对接任意兼容服务。OllamaProvider:走 Ollama 的本地 HTTP 接口,处理与 OpenAI 格式不同的细节,比如/api/generate与/api/embed。MockProvider:测试时用,返回预设数据,方便跑集成测试。
这个抽象层是整个项目里最关键的部分。因为 AI 模型迭代太快,如果没有这一层,后面每换一个模型都要改业务代码。
3.3 工具注册与动态调用
Agent 和普通聊天的最大区别就是工具调用。我的实现思路是:
- 每个工具是一个带元数据描述的 class。
- 启动时扫描所有工具类,把名称、描述、JSON Schema 参数格式注册到工具表中。
- 调用模型时把工具表作为
tools参数传进去。 - 模型返回的响应里如果包含
tool_calls,就解析参数并执行。
工具的描述直接影响模型调用的准确率。我总结的教训是,描述要写清楚“这个工具做什么”“什么时候该用”“参数的单位和范围”。比如天气工具,不要写“查询天气”,而要写:
查询指定城市的当前天气预报。 当用户询问天气、温度、降雨、风力等情况时使用。 参数 city 为城市中文名,例如“北京”;unit 可选值为 celsius/fahrenheit。实测同样的模型,把工具描述写详细之后,调用准确率从 72% 提升到 93%。不要觉得这是废话,这是 Agent 之间拉开差距的地方。
3.4 流式输出的实现细节
用户问一句,等十秒全量结果再吐出来,体验非常差。我采用 SSE(Server-Sent Events)输出 AI 的 token。
Ktor 的事件 API 写流式输出很舒服:
route("/api/chat") { post { val request = call.receive<ChatRequest>() val flow = agent.streamChat(request) call.respondTextWriter(contentType = ContentType.Text.EventStream) { flow.collect { chunk -> write("data: ${Json.encodeToString(chunk)}\n\n") flush() } } } }注意几个坑:
- 一定要显式设置
ContentType.Text.EventStream,否则前端 EventSource 解析可能失败。 - 每条事件必须以
data:开头、空行结尾。 - 要定期发送心跳事件,保持连接不超时。Ktor 的 Netty 引擎默认空闲超时是 60 秒,模型慢的话会断连,要在 Netty 配置里调大。
流式输出还有一个前端交互问题:后端每吐一个 token 都会触发前端 DOM 更新,如果渲染的是长文本,很容易卡死。我在前端用了虚拟化渲染,只在每个 chunk 到达时更新文本节点的尾部,而不是重新渲染整个消息块,实测流畅度提升明显。
4. 代码骨架与关键实现片段
4.1 整体项目结构
开源项目最怕一上来就是一堆没有组织的代码。我的目录结构是这样组织的:
agent-kora/ ├── core/ # 核心领域模型:消息、会话、工具、Agent ├── providers/ # 模型接入层 ├── storage/ # SQLite、文件存储、向量索引 ├── tools/ # 内置工具:网络搜索、计算器、时间、数据库查询 ├── server/ # Ktor 路由、插件、SSE、静态资源 ├── cli/ # 命令行交互模式 ├── ui/ # 极简 Web 前端(Vue3 + Vite) └── build.gradle.kts这里有个容易忽略的地方:AI 项目的核心是领域模型,不是 HTTP 接口。把core单独拆出来,仓库里的各个模块相互依赖关系清晰,后续加新工具、换新模型不会动到路由层。我把 Gradle 配成多模块,核心模块不依赖 Ktor,这样单元测试可以纯 JVM 跑,不必启动服务器。
4.2 Agent 核心路由
路由层除了/api/chat,还提供了一套管理接口。摘出比较典型的几个:
fun Route.agentRoutes() { route("/api") { get("/conversations") // 会话列表 post("/conversations") // 创建会话 get("/conversations/{id}/messages") // 历史消息 post("/conversations/{id}/messages") // 发送消息 delete("/conversations/{id}") // 删除会话 post("/tools/{name}/invoke") // 手动触发工具,用于调试 } }管理接口和业务接口分开,方便写 API 文档,也让前端开发者职责清晰。其中手动触发工具这个接口非常有用——调试 Agent 时经常只需要验证某个工具返回的数据格式,而不是将整个 Agent 流程跑一遍。
4.3 工具执行的安全边界
工具调用是 Agent 能力最强大的地方,也是最危险的地方。我做的开源实现里保留了“沙箱”思想:
- 网络工具默认不允许访问内网 IP 段。
- 文件工具只允许操作配置中指定的工作目录。
- 执行 Shell 命令的工具默认关闭,用户需要在配置中显式开启并白名单允许的命令。
- 所有工具调用都记录审计日志,包含调用时间、参数、结果摘要。
在这个项目里,我不能吹嘘“绝对安全”,但我能保证的是:默认配置下 Agent 无法越权操作。很多本地 Agent 开源项目死于安全问题,就是因为一上来就允许模型调用任意 Shell 命令。这种做法方便,但也让你家成了半个公共门厅。
4.4 并发处理:如何扛住多用户同时追问
本地优先并不代表只有一个用户使用。我做了一个压力测试:同时开 20 个会话,每个会话都发送一个需要工具调用的问题(搜索 + 计算器 + 数据库查询),观察 Agent 整体表现。
结果发现两个瓶颈:
- 大模型推理是串行的,本地 GPU 资源有限,并发请求只能在模型服务排队。
- 工具调用和模型推理如果共享同一个协程调度器,工具执行里的阻塞 IO 会拖慢模型流的响应延迟。
优化方案是:
- 为模型推理单独设置一个线程池,限制最大并发数为 1 或 2(取决于模型服务的并发能力)。
- 为工具调用设置另一个线程池,并发数可以放宽到 8。
- 使用信号量控制总并发,超出排队。
val modelSemaphore = Semaphore(2) val toolSemaphore = Semaphore(8) suspend fun <T> withModelLimit(block: suspend () -> T): T = withContext(Dispatchers.IO) { modelSemaphore.withPermit { block() } }实测优化后,20 个会话的响应时间分布从“前面的全快、后面的全卡死”变成了“全部稳定在可接受延迟内”。并发控制不是无脑加线程,而是分清哪些操作是资源型瓶颈,哪些是 IO 等待。
5. 开源过程中的坑与实战经验
5.1 本地大模型与 Ktor 的偶发超时
我最开始用的是 Ollama 作为本地模型服务,Ktor 和 Ollama 之间通过 HTTP 通信。测试时发现一个问题:如果模型在生成过程中思考时间较长(比如需要先分析再回答),几十秒没有返回任何字节,Netty 客户端会报读超时。
排查了很久,最后定位是 Ktor 客户端的HttpTimeout配置只设置了requestTimeout,而没有设置connectTimeout和socketTimeout的合理增量。本地模型通常请求头很快就返回了,但内容字节流可能很慢。正确的配置是:
val client = HttpClient(CIO) { install(HttpTimeout) { connectTimeoutMillis = 10_000 requestTimeoutMillis = null // 不限制整个请求 socketTimeoutMillis = 300_000 // 单次 IO 读超时放宽到 5 分钟 } }本地模型 + 大上下文时,5 分钟都未必能整段读完。这个超时设置要根据模型推理速度动态调整,上线前一定要压测,不要照抄默认值。
5.2 SSE 推流时遇到中文乱码
本地优先的项目里,很多用户直接在 Windows 上跑。Windows 默认控制台编码是 GBK,而 Ktor 后台输出 UTF-8 文本之后,在控制台直接看日志会出现中文乱码。排查步骤是:
- 保证 HTTP 响应头
Content-Type: text/event-stream; charset=utf-8。 - 检查 Flow 中每个 chunk 的字符串编码是否正常。如果是从 ByteArray 解码的,要显式指定
Charsets.UTF_8。 - 不要在 write 时直接调
toString(),有些类型默认使用平台编码。
最后我把所有涉及字符串和字节流转换的地方都封装成String.toByteArray(Charsets.UTF_8)和ByteArray.decodeToString(),彻底避免平台默认编码的坑。
5.3 开源项目发布前的打包经验
Ktor 项目打包成可执行文件,我用的是 Gradle 的installDist或ShadowJar。这里有个经验:AI Agent 项目往往包含模型配置、工具依赖(比如 PDF 解析用的原生库),打包后体积动辄几百 MB。如果发到 GitHub Releases 上,建议分开两个包:
agent-server-lite.zip:只含 JVM 运行环境和服务端代码,不带任何模型权重,体积控制在 50MB 以内。agent-server-with-models.zip:额外包含本地嵌入模型和默认的推荐模型清单,但真正的大模型权重要用户自己用 Ollama pull,不放进发布包里。
这样做有两个好处:一是用户下载快,二是避免大模型权重许可证和软件本身混在一起引发的合规问题。发布页放一张架构图和一个快速开始的代码块,star 量提升非常明显——用户能一眼看懂项目是干嘛的。
5.4 社区协作中的三个心得
项目开源之后,陆续收到一些 issue 和 PR。我学到的三件事:
第一,文档要写“为什么”,不能只写“怎么做”。有贡献者提 PR 时,我要求他们在描述里说明设计动机,这能避免未来维护时被迫猜测当初的意图。
第二,Issue 模板必须要求用户填环境信息。Ktor 版本、Kotlin 版本、JDK 版本、操作系统,这些信息缺一个,bug 就很难复现。我已经被“JDK 8 vs JDK 17 的行为差异”坑过至少三次。
第三,不要过早设计通用接口。刚开始我想做一个“万能插件系统”,让所有工具都从插件加载。后来发现过度抽象导致每个工具的接入成本都变高了。现在我只保留了一个简单的 SPI,核心代码里只有 5 个内置工具,其他高级能力统统在 separate 仓库里做扩展。接口缩小之后,维护压力瞬间小了很多。
6. 后续还想做的方向
这个项目上线半年后,我收到了很多反馈,其中最常被提到的是:希望支持多 Agent 协作。也就是一个用户同时让“研究助理”和“代码助手”两个不同配置的 Agent 并行工作,然后汇总结果。我大致想清楚了一个方案:在 core 层引入 Agent 编排器,通过一个“黑板”模式共享上下文摘要。具体实现我还没做完,因为这里隐含着一个问题:多个 Agent 之间的通信成本会指数级上升,得依赖轻量级消息总线,而不是直接把所有历史消息互相丢。
还有一个方向是把本地优先的架构用到移动端。Ktor 本身支持 Native 目标,但 SQLite、Ollama 这类原生库在移动端的集成还比较繁琐。如果未来能做成一个 Kotlin Multiplatform 的 Agent SDK,那会很有意思。
我的实际体会是,本地优先的 AI Agent 并不是一个“小项目”,它对工程质量的要求和云端服务差不多,甚至更高,因为你没法依赖“不行就加机器”来解决性能问题。把所有中间环节都拉回到本地后,代码质量和资源效率变成了必须正面解决的问题。
如果你也想动手写一个,我的建议是:先把最核心的“消息循环 + 单个工具调用”跑通,再考虑知识库、插件化、多用户。功能可以慢慢加,但架构上一定要把模型层和工具层抽象好,这决定了你后续加班还是准点下班。
最后分享一个小测试技巧:我在仓库里放了一套test/fixtures/工具,包含几个固定的多轮对话和工具调用场景,每次改动 Agent 核心逻辑后,先跑一遍 fixture 对比输出,再跑真正的模型。有了这套回归测试,迭代起来会非常有安全感。项目地址就是 GitHub 上的那个开源仓库,欢迎提 issue 和 PR,有问必回。