用Ktor构建本地优先的AI Agent:架构设计与工程实践
老实说,我一开始没打算把“本地优先”和“AI Agent”放在一起做,直到自己跑了几轮云端API,被延迟、隐私和账单折磨之后,才决定用 Ktor 从零写一个开源版的本地 AI Agent。这个项目目前已经跑通核心流程,包含任务规划、工具调用、本地记忆和并发调度,代码完全开源,所有数据默认存在本地,不依赖任何云服务。
这篇文章我会把项目从设计到落地的全过程拆开讲,包括为什么选 Ktor、Agent 主循环怎么写、工具调度怎么做、并发扛不扛得住、哪些坑我踩过。如果你正准备用 Ktor 或 Kotlin 搭建本地优先的 AI Agent,或者只是想了解一个开源 Agent 项目的工程结构,这篇应该能帮你省不少时间。
1. 项目概述与核心设计思路
1.1 需求解析:为什么需要“本地优先”的 AI Agent
市面上大多数 AI Agent 产品都是云端 SaaS,任务数据、对话记录、工具调用日志全部在别人的服务器上。对个人用户来说,很多场景其实只需要一个能离线运行的“智能助理”,比如:
- 读取本机文件并做摘要;
- 自动整理本地知识库,回答基于私有文档的问题;
- 调用本地脚本、命令行工具完成重复性任务;
- 通过局域网对外提供 API,给其他设备或应用调用。
这些场景都有一个共同点:数据敏感、交互频繁、单次请求不重。如果每次都把数据抛到云端,不仅延迟高、费用不可控,还容易踩隐私红线。所以这个项目从一开始就定下原则:核心数据只管本机,所有模型推理优先走本地推理引擎,实在需要云端模型时也必须由用户显式授权,并且加密传输。
“本地优先”不是说完全不能联网,而是把离线可用作为默认状态。用户可以在局域网内通过 REST API 或 WebSocket 访问 Agent,也可以用命令行直接交互。它更像一个自托管的 AI 服务网关,而不是一个锁死的 App。
1.2 为什么用 Ktor 而不是 Spring Boot 或 Node.js
技术选型时我做了一个小对比,核心指标是启动速度、内存占用、并发模型、打包体积和生态适配。
| 指标 | Ktor | Spring Boot | Node.js(Express) |
|---|---|---|---|
| 启动速度 | 很快,毫秒级 | 较慢,秒级 | 较快 |
| 内存占用 | 低,基础服务几十 MB | 较高,常驻 200 MB+ | 中等 |
| 并发模型 | 协程,轻量 | 线程池/虚拟线程 | 事件循环 |
| 打包体积 | 可裁剪,native-image 可到几十 MB | 偏大,一般 100 MB+ | 运行时占用中等 |
| Kotlin 支持 | 原生 | 原生 | 无 |
选择 Ktor 最核心的理由是它和 Kotlin 协程深度绑定。AI Agent 天然是一个“大量 I/O 等待 + 少量 CPU 计算”的服务:要等模型推理、等工具执行、等外部 API 返回,如果每次等待都阻塞一个线程,并发一高就难受。Ktor 的协程可以把每次等待让出线程,单机能轻松挂起几万个并发请求,这一点和 Node.js 类似,但又有强类型和 JVM 生态的双重优势。
另一方面,Ktor 是一款轻量异步框架,没有 Spring 那种庞大的上下文和自动配置体系,非常适合做“小而精”的本地服务。它支持 JVM、Android、JS、Native,如果你未来想把 Agent 嵌入到手机或桌面端,同一套业务代码也可以复用。打包方面,Ktor 可以配合 GraalVM native-image 或 Jlink,不需要用户装 JRE,这对开源项目安装体验很重要。
1.3 开源框架下的模块划分
项目开源后,我把代码分成几个模块,避免“一个大目录塞所有东西”:
agent-core:Agent 循环、任务队列、上下文管理,不依赖具体框架;server-api:Ktor 路由、WebSocket、REST 接口,负责对外交互;tools-core:本地脚本、命令行、文件读写等内置工具,以及工具注册机制;plugins:外部工具扩展点,比如 HTTP 请求、数据库查询、定时任务;memory-store:本地向量库、SQLite 存储、会话历史归档。
用户拿到源码后,如果想自定义一个工具,只需要实现一个接口,再注册到 Agent 上下文里就行,不需要改核心代码。这个设计也是受开源社区常见做法影响,主项目保持精简,功能通过插件式扩展,方便做二次开发。
2. Agent 核心机制剖析与实现要点
2.1 Agent 主循环:从“对话机器人”到“能动手的 Agent”
很多人以为 Agent 只是套了一个大模型 API,加个 System Prompt 就能干任务。实际上一个真正能执行的 Agent,核心是那个不断循环的“Sense-Plan-Act-Observe”流程(感知—规划—行动—观察)。我实现的循环大致长这样:
- 接收用户任务;
- 把任务和当前上下文送给大模型,让模型决定下一步动作(可以调用工具、输出最终回答,或者继续等待);
- 如果模型决定调用工具,Agent 根据工具名查找注册表,执行工具并获得结果;
- 将工具结果作为新的观察信息追加到上下文;
- 重复上述过程,直到模型输出“完成”或达到最大迭代次数。
这个循环在 Ktor 里其实就是一个死循环加协程控制。我用delay和withTimeout控制单次任务总时长,避免 Agent 卡在某个工具上。每次模型请求之间会做一次上下文压缩,防止 token 无限膨胀。
关键点在于:Agent 的主循环最好做成可中断的。比如用户发来“帮我查一下明天的会议安排,然后把提醒发到手机”,这个任务可能涉及多个工具调用,中间如果某个工具失败,Agent 应该能基于错误信息自动调整策略,而不是直接崩溃。所以在实现时我用Result包装工具执行结果,把异常信息作为可读文本返回给模型,模型就能自然地从错误中恢复。
2.2 工具调用与智能调度
工具调用是 Agent 能“干活”的核心,也是整个项目里最容易出乱子的地方。我先定义了一个统一接口:
interface AgentTool { val name: String val description: String suspend fun execute(params: JsonObject): JsonElement }每个工具实现四个字段之外,还需要声明一个 JSON Schema 格式的参数说明,这样模型才能理解该传什么参数。这里的套路和 OpenAI Function Calling 差不多,但我要兼容本地模型,所以设计时尽量通用。
工具注册表使用一个线程安全的ConcurrentHashMap,在 Ktor 启动时扫描包名自动注册。为了避免模型来来回回调用同一个失败工具,我会在工具描述里加上错误信息示例,并在上下文中标注“已失败两次,不要重复调用”。
实际使用时,我最深的感受是:工具描述写得越详细,模型理解越准确。比如一个搜索本地文件夹的工具,描述如果只写“搜索文件”,模型就不知道内容是搜索文件名还是文件内容,也不知道支持什么通配符。后来我把描述扩充成“在当前用户目录下递归搜索文件名匹配的文件,支持 glob 模式,返回前 20 条结果”,准确率立刻上升。
2.3 记忆与上下文管理
本地优先的 Agent 必须有记忆。我选择了一个折中方案:
- 短期记忆:当前任务的对话上下文,存在内存里,任务结束时持久化;
- 长期记忆:把已经完成的任务摘要、关键结论写入 SQLite,并同时生成向量索引;
- 记忆召回:新任务进来时,先用本地嵌入模型把用户请求向量化,再从向量库里检索最相关的历史记录,注入上下文。
向量存储用的是 SQLite 的一个轻量扩展sqlite-vec,它比向量数据库轻得多,免去了单独维护一套服务的麻烦。但要注意,本地嵌入模型计算向量时如果文本很长,耗时可能会到几百毫秒,所以我对召回的文本做了预裁剪,每条记忆最长保留 500 字。
Kotlin 代码片段里,记忆召回的伪逻辑是这样的:
val queryVector = embeddingService.embed(userInput) val similarChunks = memoryStore.search( vector = queryVector, limit = 5, minScore = 0.4 ) val contextBlocks = similarChunks.map { it.asContextBlock() }召回不是越多越好,Top 5 基本够用,超过的话反而会干扰模型对当前任务的理解。
2.4 并发模型与多 Agent 实例调度
“AI Agent 怎么扛并发”是很多人在社区里讨论的热词,我自己的答案是:不要让一个 Agent 实例串行处理所有请求,而是为每个会话或每个任务创建独立的 Agent 实例,用 Ktor 的应用层协程来管理生命周期。
实现上,我使用CoroutineScope加SupervisorJob,每个会话创建一个独立的AgentSession,内部包含敞开的会话记录、工具调用上下文和协程状态。这样并发请求之间互不干扰,而且某个会话崩溃不会拖垮整个服务。
val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val session = AgentSession(sessionScope, config)但这里有一个容易忽略的问题:模型调用本身是阻塞的,如果直接写在协程里,多个任务同时调用本地模型,内存和显存可能直接爆掉。所以我给模型调用加了一个信号量,限制并发推理数为 2(机器性能不同可配置),其余请求排队。这是典型的“并发限制 + 背压”思路,虽然多路 Agent 同时跑,但底层推理不会被打垮。
3. 开源项目的工程化落地:从 Demo 到可维护的代码
3.1 项目结构与依赖选型
项目采用 Gradle Kotlin DSL 管理,源码结构大致如下:
agent/ server-api/ src/main/kotlin/... agent-core/ src/main/kotlin/... tools-core/ src/main/kotlin/... memory-store/ src/main/kotlin/...核心依赖:
io.ktor:ktor-server-core和ktor-server-netty:提供 HTTP/WebSocket 服务;io.ktor:ktor-client-cio:Agent 内部调用外部模型或工具时作为 HTTP 客户端;org.jetbrains.kotlinx:kotlinx-serialization-json:JSON 解析与工具参数序列化;com.zaxxer:HikariCP和org.xerial:sqlite-jdbc:SQLite 连接池与数据库操作;io.github.jeremylong:openai-kotlin-client:最初用于兼容 OpenAI API,后来发现本地模型更有价值,封装成统一模型接口。
依赖选型有几个坑:ktor-client-cio性能不错,但如果你要跟GraalVM native-image配合,建议用ktor-client-okhttp或ktor-client-java,CIO 在 native-image 下有一些反射问题。SQLite JDBC 打包成 native-image 时需要配置额外的反射规则,否则会报“Could not initialize class org.sqlite.JDBC”。
3.2 本地优先存储的设计与实现
本地优先的核心不是把数据库放在本机就完事,而是要保证数据可迁移、可导出、可备份。我的方案是用一个 SQLite 文件搞定所有状态,避免启用外部数据库服务。
关键表设计:
| 表名 | 用途 | 关键字段 |
|---|---|---|
sessions | 会话元数据 | session_id, created_at, updated_at |
messages | 最近对话内容 | id, session_id, role, content, token_count |
tool_calls | 工具调用记录 | id, tool_name, params, result, error, duration_ms |
memories | 长期记忆(含向量) | id, content, summary, embedding, score |
settings | 用户配置 | key, value |
SQLite 的并发写是一个坑。虽然 Ktor 可以同时处理很多请求,但如果多个会话同时写同一个 SQLite 文件,很容易遇到SQLITE_BUSY。我在底层用了一个SingleFlight机制,把写操作串行化到同一个线程,配合 WAL 模式,实测并发写入不会阻塞太久。
每次 Agent 执行完任务,我会把一轮完整的轨迹(用户输入、模型输出、工具调用列表、耗时)写入一个 JSON 文件,同时把摘要写入 SQLite。这样既方便排查问题,又能生成一份可读性很强的“审计日志”,这是很多本地优先项目忽略的点。
3.3 REST API 与 WebSocket 流式输出
Agent 的交互不仅局限于终端,我还期望它能作为一个本地 API 服务被其他程序调用。Ktor 在这里派上了大用场,它天然支持 REST 和 WebSocket,两种方式我都实现了:
- REST:
POST /api/agent/run传任务内容,等待完整输出后返回 JSON; - WebSocket:
ws://localhost:8080/ws/agent流式返回模型 token 和工具调用进度。
流式输出非常关键,因为大模型推理通常要几秒到几十秒,如果一直让客户端傻等,体验极差。我通过 WebSocket 把 Agent 的状态机事件推给前端:正在思考、正在调用工具、工具执行完成、最终回答。这样前端可以做一个类似“实时运行过程”的界面,用户能看到 Agent 到底在做什么,而不是干瞪眼。
Ktor 的 WebSocket 代码挺简洁:
webSocket("/ws/agent") { for (frame in incoming) { if (frame is Frame.Text) { val task = frame.readText() launch { agent.run(task) { update -> send(Frame.Text(Json.encodeToString(update))) } } } } }需要注意的是,send()并发调用时可能会报ClosedSendChannelException,所以我对发送操作加了 try-catch,并在 Agent 实例进入终止状态后不再发送。
3.4 开源发布与文档化
开源不能只是把代码往 GitHub 一扔就完事。我这份项目发布了 MIT 许可证,核心原因是希望它能被更多人用在真实场景里,包括商业项目。开源时整理了三个层次的文档:
- README:说明项目能做什么、快速启动命令、演示截图;
docs/architecture.md:画清楚模块关系和 Agent 执行流;docs/tool-development.md:写给准备贡献代码的人,如何开发新工具。
文档写完后,我还做了一个可执行的“玩具任务”作为验收用例,比如让 Agent 读取本地某个 Markdown 文件并生成摘要,确保用户 clone 下来跑./gradlew run就能看到完整流程,而不用先去配大模型 API 密钥。
4. 常见问题与排查技巧实录
4.1 模型调用超时与重试策略的坑
本地优先场景下,用户的机器性能差异很大。我在本地模型上跑了几个模型后,发现推理时间的波动非常恐怖:同样一句话,有的模型 500 毫秒返回,有的模型可能要 15 秒。如果超时时间设得太短,Agent 总是超时重试,白费 token;设得太长,用户又觉得卡死。
我的做法是动态超时:首次调用使用配置默认值 60 秒,如果同一任务内连续多次调用同一模型,会根据前几轮平均耗时动态调整超时时间。重试策略上,只有两类异常值得重试:
- 网络抖动(超时、连接重置);
- 模型服务返回 503 或 429。
业务错误和参数错误不要重试,直接返回给模型纠错。
4.2 上下文膨胀的解决
Agent 循环过程中,每调用一次工具,工具结果都会拼到上下文里。如果工具返回一个超长的 JSON(比如“列出整个目录树”),上下文很快就会爆满。我的策略是:
- 工具结果截断:默认最多保留 4000 字符,超出部分提示“结果过长,已截断”;
- 多轮压缩:每轮对话结束后计算 token 数,超过阈值时用大模型生成一个浓缩摘要,把旧消息替换掉;
- 快捷键配置:用户可以设置“详细模式”或“省流模式”。
有一个细节:压缩摘要时一定要把“用户原始目标”和“已完成的子任务”保留清楚,否则 Agent 下一步不知道自己该干什么。我吃过亏,压缩后上下文丢失了关键约束,导致 Agent 自己跑偏,后来加了一个trunk字段,专门保存用户最初的任务描述,任何压缩都不能覆盖它。
4.3 本地嵌入模型与向量检索的兼容问题
不是所有本地模型都提供嵌入接口。我最初想直接调用本地大模型自带的 embedding,结果发现很多社区模型并没有实现 embedding API。解决方案是利用一个轻量级 embedding 模型,在本地启动一个小服务,Agent 通过 HTTP 调用。这个服务我用 Python 写过一版,后来又换成 Kotlin 封装一个 Java 版 embedding 库,减少技术栈复杂度。
做向量召回时,另一个坑是不同本地模型生成的向量维度不一样。项目里我直接在配置中设定embeddingDim字段,默认 1024,一旦用户切换模型,需要重建向量索引。我在代码里加了版本号,如果 embedding 维度不匹配,会在启动时报错并提示重建。
4.4 打包与分发的工程问题
如果不做额外处理,Ktor 应用直接打个 fat jar,体积大约是 80 MB,其实还能接受。但如果想做成“绿色工具”,推荐用 GraalVM native-image,可以把体积压到 30~40 MB,启动时间也降到了百毫秒级。
native-image 有两个大坑需要注意:
- 反射:SQLite、Jackson、Ktor 路由都可能用到反射,必须加
reflect-config.json和resource-config.json; - HTTPS:如果模型 API 走 HTTPS,需要把证书打包进去,并在 native-image 配置里启用安全服务。
我在 GitHub Actions 里配了一个nativeprofile,每次打 tag 自动构建 windows/linux/macOS 三个平台的可执行文件。这样用户下载下来就是免安装压缩包,比较友好。整个过程卡了几天,主要是 native-image 的配置问题,后来直接用graalvm-reachability-metadata仓库里的配置模板,省了很多事。
5. 本地优先 AI Agent 更适合哪些场景
5.1 个人知识库与私有文档问答
这是我最常用、也最推荐周边朋友使用的场景。把 PDF、Markdown、TXT 文档扔进一个目录,Agent 扫描后生成向量索引,之后你就可以像聊天一样问它“我在绿皮书里看到过一句关于经济周期的描述,帮我找到原文”。因为数据完全在本地,文档内容不会外泄。
实测下来,单纯做 RAG(检索增强生成)其实不需要什么复杂框架,Ktor + 本地 Embedding + SQLite 就够了。你不需要为一个“查文档的机器人”引入完整的云服务基础设施。
5.2 个人自动化助手
本地优先的 Agent 更适合和文件系统、命令行工具打交道。我给它配了三个默认工具:Shell 执行器、文件读写器、定时任务注册器。注意,Shell 执行器非常危险,如果 Agent 被 prompt injection 攻击,用户可能会在不知情下执行恶意命令。所以我默认关闭 Shell 执行器,只有用户在配置里显式开机并限定白名单命令时才可用。
一个安全用法是限定白名单,比如只允许git status、git log、python run_report.py,其余命令一律拒绝执行。Ktor 配置里可以直接用一个正则列表做命令过滤。
5.3 局域网 API 网关
因为 Ktor 服务可以轻松绑定0.0.0.0,所以它非常适合在家庭或办公室局域网里提供一个统一 AI 接口。手机、平板、树莓派上的小应用都可以通过这个服务调用 AI Agent,同时数据不出内网。如果再加上一个前置的简单认证,安全性会更高。
我在项目里内置了一个 Token 鉴权插件,开启后每个 REST 请求都要带Authorization: Bearer <token>。虽然本地环境不能百分百防攻击,但至少能防住一些未授权的访问。
6. 后续展望与扩展思路
这个项目离“生产可用”还有一段距离。我自己在代码注释里列了一个 TODO,优先级最高的是这三项:
- 更多模型适配器:目前兼容 OpenAI API 和 Ollama 原生 API,下一步想加一个通用的 MCP(Model Context Protocol)接入层,让社区工具能被更灵活地复用;
- 插件市场机制:希望用户能从远程仓库拉取一个 agent-plugin 的 zip 包,自动注册到本地工具列表,实现“安装即用”;
- 可视化后台:目前的日志和输出都是终端友好型,之后想加一个简单的 Web UI,查看记忆列表、工具调用记录和 token 消耗统计。
其实这个项目对大多数读者来说不一定需要照搬,我更建议把它当作一个“本地优先架构的样本”。如果你想做自己的 AI Agent,也可以先跑通一个小实验:用 Ktor 写一个只有文件搜索和摘要功能的 Agent,放到本机跑几天,看看哪些设计能扛住真实使用,哪些是过度设计。我的经验是,从最小集开始往往比一开始就堆功能靠谱得多。
最后分享一个小技巧:如果你也打算用 Ktor 搭这类服务,务必从第一版就开始写结构化日志,每次模型调用、工具调用都记录耗时和参数。排查问题时,这些日志的价值比任何注释都大。我在项目早期因为日志不规范,面对一个“为什么 Agent 偶尔会卡住”的问题,排查了三天才意识到是 SQLite 写锁冲突。后来加了结构化日志和指标输出,定位问题从小时级降到了分钟级。